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