Skip to main content

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