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