perspective_viewer/components/form/mirrored_textarea.rs
1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! A `<textarea>` whose box is sized by its own text.
14//!
15//! A textarea cannot grow with its content — it scrolls at whatever height
16//! it was given. The technique here, shared by [`CodeEditor`] and the chat
17//! prompt, is to make the textarea a purely functional layer: it takes the
18//! keystrokes, the selection and the caret, but paints no glyphs
19//! (`color: transparent`). Underneath sits a `<pre>` mirror of the same
20//! characters which IS painted and IS in flow, so the ordinary layout
21//! engine sizes the box.
22//!
23//! This is deliberately not a JS measure-and-resize loop
24//! (`scrollHeight` → inline `height`): that re-measures on a reflow it
25//! just forced, drifts whenever font-size or the loaded font changes
26//! without an `input` event to re-trigger it, and jitters at fractional
27//! line heights. Here the two layers share their text metrics, so growth
28//! is exact by construction and free at runtime.
29//!
30//! Consumers decide the AXES: the mirror's `white-space` governs growth,
31//! so `pre-wrap` grows only downward (the chat prompt) while `pre` also
32//! grows sideways (the expression editor). They also own the box —
33//! padding, borders, min/max size — with `mirrored-textarea.css` fixing
34//! only what MUST agree between the two layers.
35//!
36//! [`CodeEditor`]: super::code_editor::CodeEditor
37
38use yew::html::IntoPropValue;
39use yew::prelude::*;
40
41#[derive(Clone)]
42pub struct Mirror(pub Html);
43
44impl PartialEq for Mirror {
45 fn eq(&self, _other: &Self) -> bool {
46 false
47 }
48}
49
50impl IntoPropValue<Mirror> for Html {
51 fn into_prop_value(self) -> Mirror {
52 Mirror(self)
53 }
54}
55
56#[derive(Properties, PartialEq)]
57pub struct MirroredTextareaProps {
58 /// `id` of the `<textarea>` itself — consumer CSS and tests key on it.
59 pub id: AttrValue,
60
61 /// The mirror's content: the same characters the textarea holds,
62 /// as plain text, or marked up (`CodeEditor` passes highlighted
63 /// spans). A stale mirror means a caret that drifts from the glyphs,
64 /// so this is re-rendered unconditionally — see [`Mirror`].
65 pub mirror: Mirror,
66
67 /// `id` of the mirror `<pre>`, for consumers that style it directly.
68 #[prop_or_default]
69 pub mirror_id: Option<AttrValue>,
70
71 /// Extra classes for the positioned container.
72 #[prop_or_default]
73 pub class: Classes,
74
75 #[prop_or_default]
76 pub textarea_ref: NodeRef,
77
78 #[prop_or_default]
79 pub mirror_ref: NodeRef,
80
81 /// The `placeholder` ATTRIBUTE, which exists for assistive tech: it
82 /// is painted transparent, because a CSS-`content` label cannot be
83 /// written into an attribute and so cannot be localized. The text a
84 /// sighted user reads comes from the mirror instead — see
85 /// [`Self::is_empty`].
86 #[prop_or_default]
87 pub placeholder: AttrValue,
88
89 /// Whether [`Self::mirror`] is empty, which the component cannot see
90 /// for itself (the mirror is opaque `Html`). Tags the mirror with
91 /// `is-empty` so a consumer's stylesheet can supply placeholder text
92 /// through `content`, and therefore through the intl label
93 /// mechanism, rather than through the untranslatable attribute.
94 #[prop_or_default]
95 pub is_empty: bool,
96
97 #[prop_or_default]
98 pub disabled: bool,
99
100 #[prop_or_default]
101 pub oninput: Callback<InputEvent>,
102
103 #[prop_or_default]
104 pub onkeydown: Callback<KeyboardEvent>,
105
106 #[prop_or_default]
107 pub onscroll: Callback<Event>,
108
109 /// Extra nodes inside the positioned container, e.g. `CodeEditor`'s
110 /// minimum-height sizer.
111 #[prop_or_default]
112 pub children: Children,
113}
114
115#[function_component]
116pub fn MirroredTextarea(props: &MirroredTextareaProps) -> Html {
117 html! {
118 <div class={classes!("mirrored-textarea", props.class.clone())}>
119 // `scrollable` is the viewer's webkit scrollbar styling: the
120 // input layer scrolls wherever a consumer lets it (the
121 // expression editor syncs its `scrollTop` to the mirror and
122 // line numbers), and is inert where it does not (the chat
123 // prompt, whose wrapper scrolls instead).
124 <textarea
125 id={props.id.clone()}
126 ref={props.textarea_ref.clone()}
127 class="mirrored-textarea-input scrollable"
128 spellcheck="false"
129 placeholder={props.placeholder.clone()}
130 disabled={props.disabled}
131 oninput={props.oninput.clone()}
132 onkeydown={props.onkeydown.clone()}
133 onscroll={props.onscroll.clone()}
134 />
135 { props.children.iter().collect::<Html>() }
136 <pre
137 id={props.mirror_id.clone()}
138 ref={props.mirror_ref.clone()}
139 class={classes!(
140 "mirrored-textarea-mirror",
141 props.is_empty.then_some("is-empty"),
142 )}
143 >
144 { props.mirror.0.clone() }
145 // A trailing newline opens a line box in the textarea but
146 // not in the `<pre>` (which has no caret to place there),
147 // so the mirror would come up one line short exactly when
148 // the text is about to overflow. The space forces it.
149 { " " }
150 </pre>
151 </div>
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn mirror_never_compares_equal() {
161 let mirror = Mirror(Html::default());
162 assert!(mirror != mirror.clone());
163 assert!(Mirror(Html::default()) != Mirror(Html::default()));
164 }
165}