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