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