Skip to main content

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