perspective_viewer/components/form/
code_editor.rs1use 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#[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 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 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 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 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 use_effect_with(filter_dropdown.clone(), |filter_dropdown| {
133 clone!(filter_dropdown);
134 move || filter_dropdown.hide().unwrap()
135 });
136
137 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 " "
190 } }
191 </pre>
192 </div>
193 </div>
194 </>
195 }
196}
197
198fn on_input_callback(
201 event: InputEvent,
202 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 }
213
214fn 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 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 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
250fn 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
256fn 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
268fn 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}