perspective_viewer/components/form/
code_editor.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
13use std::rc::Rc;
14
15use perspective_client::ExprValidationError;
16use wasm_bindgen::JsCast;
17use web_sys::*;
18use yew::prelude::*;
19
20use crate::components::form::highlight::highlight;
21use crate::components::style::LocalStyle;
22use crate::custom_elements::FunctionDropDownElement;
23use crate::exprtk::{Cursor, tokenize};
24use crate::utils::*;
25use crate::*;
26
27#[derive(Properties, PartialEq)]
28pub struct CodeEditorProps {
29    pub expr: Rc<String>,
30    pub oninput: Callback<Rc<String>>,
31    pub onsave: Callback<()>,
32    pub disabled: bool,
33
34    #[prop_or_default]
35    pub autofocus: bool,
36
37    #[prop_or_default]
38    pub wordwrap: bool,
39
40    #[prop_or_default]
41    pub autosuggest: bool,
42
43    #[prop_or_default]
44    pub select_all: Subscriber<()>,
45
46    #[prop_or_default]
47    pub error: Option<ExprValidationError>,
48}
49
50/// Capture the input (for re-parse) and caret position whenever the input
51/// text changes.
52fn on_input_callback(
53    event: InputEvent,
54    // position: &UseStateSetter<u32>,
55    oninput: &Callback<Rc<String>>,
56) {
57    let elem = event
58        .target()
59        .unwrap()
60        .unchecked_into::<web_sys::HtmlTextAreaElement>();
61
62    oninput.emit(elem.value().into());
63    // position.set(elem.get_caret_position().unwrap_or_default());
64}
65
66/// Overide for special non-character commands e.g. shift+enter
67fn on_keydown(event: KeyboardEvent, deps: &(UseStateSetter<u32>, Callback<()>)) {
68    let elem = event
69        .target()
70        .unwrap()
71        .unchecked_into::<web_sys::HtmlTextAreaElement>();
72
73    deps.0.set(elem.get_caret_position().unwrap_or_default());
74    if event.shift_key() && event.key_code() == 13 {
75        event.prevent_default();
76        deps.1.emit(())
77    }
78
79    // handle the tab key press
80    if event.key() == "Tab" {
81        event.prevent_default();
82
83        let caret_pos = elem.selection_start().unwrap().unwrap_or_default() as usize;
84
85        let mut initial_text = elem.value();
86
87        initial_text.insert(caret_pos, '\t');
88
89        elem.set_value(&initial_text);
90
91        let input_event = web_sys::InputEvent::new("input").unwrap();
92        let _ = elem.dispatch_event(&input_event).unwrap();
93
94        // place caret after inserted tab
95        let new_caret_pos = (caret_pos + 1) as u32;
96        let _ = elem.set_selection_range(new_caret_pos, new_caret_pos);
97
98        elem.focus().unwrap();
99    }
100}
101
102/// Scrolling callback
103fn on_scroll(scroll: &UseStateSetter<(i32, i32)>, editable: &NodeRef) {
104    let div = editable.cast::<HtmlElement>().unwrap();
105    scroll.set((div.scroll_top(), div.scroll_left()));
106}
107
108/// Scrolling sync
109fn scroll_sync(scroll: &UseStateHandle<(i32, i32)>, div: &NodeRef, lineno: &NodeRef) {
110    if let Some(div) = div.cast::<HtmlElement>() {
111        div.set_scroll_top(scroll.0);
112        div.set_scroll_left(scroll.1);
113    }
114
115    if let Some(div) = lineno.cast::<HtmlElement>() {
116        div.set_scroll_top(scroll.0);
117    }
118}
119
120/// Autocomplete
121/// TODO this should use a portal
122fn autocomplete(
123    filter_dropdown: &Rc<FunctionDropDownElement>,
124    token: &Option<String>,
125    target: &NodeRef,
126) {
127    if let Some(x) = token {
128        let elem = target.cast::<HtmlElement>().unwrap();
129        if elem.is_connected() {
130            filter_dropdown
131                .autocomplete(x.clone(), elem, Callback::from(|_| ()))
132                .unwrap();
133        } else {
134            filter_dropdown.hide().unwrap();
135        }
136    } else {
137        filter_dropdown.hide().unwrap();
138    }
139}
140
141/// A syntax-highlighted text editor component.
142#[function_component(CodeEditor)]
143pub fn code_editor(props: &CodeEditorProps) -> Html {
144    let select_all = use_state_eq(|| false);
145    let caret_position = use_state_eq(|| 0_u32);
146    let scroll_offset = use_state_eq(|| (0, 0));
147    let textarea_ref = use_node_ref().tee::<5>();
148    let content_ref = use_node_ref().tee::<3>();
149    let lineno_ref = use_node_ref().tee::<2>();
150    let filter_dropdown = use_memo((), |_| FunctionDropDownElement::default());
151    let mut cursor = Cursor::new(&props.error);
152    let terms = tokenize(&props.expr)
153        .into_iter()
154        .map(|token| highlight(&mut cursor, token, *caret_position))
155        .collect::<Html>();
156
157    let onkeydown = use_callback((caret_position.setter(), props.onsave.clone()), on_keydown);
158    let oninput = use_callback(props.oninput.clone(), |event, deps| {
159        on_input_callback(event, deps)
160    });
161
162    let onscroll = use_callback((scroll_offset.setter(), textarea_ref.0), |_, deps| {
163        on_scroll(&deps.0, &deps.1)
164    });
165
166    let autofocus = props.autofocus;
167    use_effect_with((props.expr.clone(), textarea_ref.1), {
168        move |(expr, textarea_ref)| {
169            let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
170            if autofocus {
171                elem.focus().unwrap();
172            }
173
174            if **expr != elem.value() {
175                elem.set_value(&format!("{expr}"));
176                elem.set_scroll_top(0);
177                elem.set_scroll_left(0);
178                elem.set_caret_position(0).unwrap();
179            }
180        }
181    });
182
183    // select_all.set(props.select_all);
184    use_effect_with((select_all.setter(), props.select_all.clone()), {
185        move |(select_all, props_select_all)| {
186            clone!(select_all);
187            let sub = props_select_all.add_listener(move |()| select_all.set(true));
188            move || drop(sub)
189        }
190    });
191
192    // select_all.set(props.select_all);
193    use_effect_with((select_all, textarea_ref.2), {
194        move |(select_all, textarea_ref)| {
195            let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
196            if **select_all {
197                elem.focus().unwrap();
198                elem.select_all().unwrap();
199            }
200
201            select_all.set(false);
202        }
203    });
204
205    // ????
206    let autofocus = props.autofocus;
207    use_effect_with((props.error.clone(), textarea_ref.3), {
208        move |(_, textarea_ref)| {
209            if autofocus {
210                let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
211                elem.focus().unwrap();
212            }
213        }
214    });
215
216    // Sync scrolling between textarea and formatted HTML
217    use_effect_with((scroll_offset, content_ref.0, lineno_ref.0), |deps| {
218        scroll_sync(&deps.0, &deps.1, &deps.2)
219    });
220
221    // Blur if this element is not in the tree
222    use_effect_with(filter_dropdown.clone(), |filter_dropdown| {
223        clone!(filter_dropdown);
224        move || filter_dropdown.hide().unwrap()
225    });
226
227    // Show autocomplete
228    use_effect_with(
229        (
230            props.autosuggest,
231            filter_dropdown,
232            cursor.auto.clone(),
233            cursor.noderef.clone(),
234        ),
235        |deps| {
236            if deps.0 {
237                autocomplete(&deps.1, &deps.2, &deps.3)
238            }
239        },
240    );
241
242    let error_line = props.error.as_ref().map(|x| x.line);
243    let line_numbers = cursor
244        .map_rows(|x| html!(
245            <span
246                class={if Some(x) == error_line {"line_number error_highlight"} else {"line_number"}}
247            >
248                { x + 1 }
249            </span>
250        ))
251        .collect::<Html>();
252
253    let class = if props.wordwrap { "wordwrap" } else { "" };
254    clone!(props.disabled);
255    html! {
256        <>
257            <LocalStyle href={css!("form/code-editor")} />
258            <div id="editor" {class}>
259                <div id="line_numbers" ref={lineno_ref.1}>{ line_numbers }</div>
260                <div id="editor-inner" {class}>
261                    <textarea
262                        {disabled}
263                        id="textarea_editable"
264                        ref={textarea_ref.4}
265                        spellcheck="false"
266                        {oninput}
267                        {onscroll}
268                        {onkeydown}
269                    />
270                    <div id="editor-height-sizer" />
271                    <pre id="content" ref={content_ref.1}>
272                        { terms }
273                        { {
274                        // A linebreak which pushs a textarea into scroll overflow
275                        // may not necessarily do so in the `<pre>`, because there is
276                        // no cursor when the last line has no content, so add
277                        // some space here to make sure overlfow is in sync
278                        // with the text area.
279                        " "
280                    } }
281                    </pre>
282                </div>
283            </div>
284        </>
285    }
286}