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::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 #[prop_or_default]
49 pub theme: String,
50}
51
52#[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 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 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 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 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 use_effect_with(filter_dropdown.clone(), |filter_dropdown| {
137 clone!(filter_dropdown);
138 move || filter_dropdown.hide().unwrap()
139 });
140
141 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 " "
198 } }
199 </pre>
200 </div>
201 </div>
202 <FunctionDropDownPortal
203 element={(*portal_dropdown).clone()}
204 theme={props.theme.clone()}
205 />
206 </>
207 }
208}
209
210fn on_input_callback(
213 event: InputEvent,
214 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 }
225
226fn 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 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 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
262fn 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
268fn 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
280fn 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}