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