perspective_viewer/components/
code_editor.rs1mod highlight;
14
15use std::rc::Rc;
16
17use perspective_client::ExprValidationError;
18use wasm_bindgen::JsCast;
19use web_sys::*;
20use yew::prelude::*;
21
22use self::highlight::highlight;
23use crate::components::function_dropdown::{FunctionDropDownElement, FunctionDropDownPortal};
24use crate::exprtk::{Cursor, tokenize};
25use crate::ui::MirroredTextarea;
26use crate::utils::*;
27
28#[derive(Properties, PartialEq)]
29pub struct CodeEditorProps {
30 pub expr: Rc<String>,
31 pub oninput: Callback<Rc<String>>,
32 pub onsave: Callback<()>,
33 pub disabled: bool,
34
35 #[prop_or_default]
36 pub autofocus: bool,
37
38 #[prop_or_default]
39 pub wordwrap: bool,
40
41 #[prop_or_default]
42 pub autosuggest: bool,
43
44 #[prop_or_default]
45 pub select_all: Subscriber<()>,
46
47 #[prop_or_default]
48 pub error: Option<ExprValidationError>,
49
50 #[prop_or_default]
51 pub contained: bool,
52
53 #[prop_or_default]
55 pub theme: String,
56}
57
58#[function_component(CodeEditor)]
60pub fn code_editor(props: &CodeEditorProps) -> Html {
61 let select_all = use_state_eq(|| false);
62 let caret_position = use_state_eq(|| 0_u32);
63 let scroll_offset = use_state_eq(|| (0, 0));
64 let textarea_ref = use_node_ref();
65 let content_ref = use_node_ref();
66 let lineno_ref = use_node_ref();
67 let filter_dropdown = use_memo((), |_| FunctionDropDownElement::default());
68 let mut cursor = Cursor::new(&props.error);
69 let terms = tokenize(&props.expr)
70 .into_iter()
71 .map(|token| highlight(&mut cursor, token, *caret_position))
72 .collect::<Html>();
73
74 let onkeydown = use_callback((caret_position.setter(), props.onsave.clone()), on_keydown);
75 let oninput = use_callback(props.oninput.clone(), |event, deps| {
76 on_input_callback(event, deps)
77 });
78
79 let onscroll = use_callback((scroll_offset.setter(), textarea_ref.clone()), |_, deps| {
80 on_scroll(&deps.0, &deps.1)
81 });
82
83 let autofocus = props.autofocus;
84 use_effect_with((props.expr.clone(), textarea_ref.clone()), {
85 move |(expr, textarea_ref)| {
86 let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
87 if autofocus {
88 elem.focus().unwrap();
89 }
90
91 if **expr != elem.value() {
92 elem.set_value(&format!("{expr}"));
93 elem.set_scroll_top(0);
94 elem.set_scroll_left(0);
95 elem.set_caret_position(0).unwrap();
96 }
97 }
98 });
99
100 use_effect_with((select_all.setter(), props.select_all.clone()), {
102 move |(select_all, props_select_all)| {
103 clone!(select_all);
104 let sub = props_select_all.add_listener(move |()| select_all.set(true));
105 move || drop(sub)
106 }
107 });
108
109 use_effect_with((select_all, textarea_ref.clone()), {
111 move |(select_all, textarea_ref)| {
112 let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
113 if **select_all {
114 elem.focus().unwrap();
115 elem.select_all().unwrap();
116 }
117
118 select_all.set(false);
119 }
120 });
121
122 let autofocus = props.autofocus;
124 use_effect_with((props.error.clone(), textarea_ref.clone()), {
125 move |(_, textarea_ref)| {
126 if autofocus {
127 let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
128 elem.focus().unwrap();
129 }
130 }
131 });
132
133 use_effect_with(
135 (scroll_offset, content_ref.clone(), lineno_ref.clone()),
136 |deps| scroll_sync(&deps.0, &deps.1, &deps.2),
137 );
138
139 let portal_dropdown = filter_dropdown.clone();
140
141 use_effect_with(filter_dropdown.clone(), |filter_dropdown| {
143 clone!(filter_dropdown);
144 move || filter_dropdown.hide().unwrap()
145 });
146
147 use_effect_with(
149 (
150 props.autosuggest,
151 filter_dropdown,
152 cursor.auto.clone(),
153 cursor.noderef.clone(),
154 ),
155 |deps| {
156 if deps.0 {
157 autocomplete(&deps.1, &deps.2, &deps.3)
158 }
159 },
160 );
161
162 let error_line = props.error.as_ref().map(|x| x.line);
163 let line_numbers = cursor
164 .map_rows(|x| html!(
165 <span
166 class={if Some(x) == error_line {"line_number error_highlight"} else {"line_number"}}
167 >
168 { x + 1 }
169 </span>
170 ))
171 .collect::<Html>();
172
173 let mut outer_class = classes!("scrollable");
174 let mut inner_class = classes!("editor-inner", "scrollable");
175
176 if props.wordwrap {
177 outer_class.push("wordwrap");
178 inner_class.push("wordwrap");
179 };
180
181 if props.contained {
182 outer_class.push("contained")
183 }
184
185 clone!(props.disabled);
186 html! {
187 <>
188 <div id="editor" class={outer_class}>
189 <div id="line_numbers" ref={lineno_ref}>{ line_numbers }</div>
190 <MirroredTextarea
191 id="textarea_editable"
192 mirror_id="content"
193 class={inner_class}
194 mirror={terms}
195 {textarea_ref}
196 mirror_ref={content_ref}
197 {disabled}
198 {oninput}
199 {onscroll}
200 {onkeydown}
201 >
202 <div id="editor-height-sizer" />
203 </MirroredTextarea>
204 </div>
205 <FunctionDropDownPortal
206 element={(*portal_dropdown).clone()}
207 theme={props.theme.clone()}
208 />
209 </>
210 }
211}
212
213fn on_input_callback(
216 event: InputEvent,
217 oninput: &Callback<Rc<String>>,
219) {
220 let elem = event
221 .target()
222 .unwrap()
223 .unchecked_into::<web_sys::HtmlTextAreaElement>();
224
225 oninput.emit(elem.value().into());
226 }
228
229fn on_keydown(event: KeyboardEvent, deps: &(UseStateSetter<u32>, Callback<()>)) {
231 let elem = event
232 .target()
233 .unwrap()
234 .unchecked_into::<web_sys::HtmlTextAreaElement>();
235
236 deps.0.set(elem.get_caret_position().unwrap_or_default());
237 if event.shift_key() && event.key_code() == 13 {
238 event.prevent_default();
239 deps.1.emit(())
240 }
241
242 if event.key() == "Tab" {
244 event.prevent_default();
245
246 let caret_pos = elem.selection_start().unwrap().unwrap_or_default() as usize;
247
248 let mut initial_text = elem.value();
249
250 initial_text.insert(caret_pos, '\t');
251
252 elem.set_value(&initial_text);
253
254 let input_event = web_sys::InputEvent::new("input").unwrap();
255 let _ = elem.dispatch_event(&input_event).unwrap();
256
257 let new_caret_pos = (caret_pos + 1) as u32;
259 let _ = elem.set_selection_range(new_caret_pos, new_caret_pos);
260
261 elem.focus().unwrap();
262 }
263}
264
265fn on_scroll(scroll: &UseStateSetter<(i32, i32)>, editable: &NodeRef) {
267 let div = editable.cast::<HtmlElement>().unwrap();
268 scroll.set((div.scroll_top(), div.scroll_left()));
269}
270
271fn scroll_sync(scroll: &UseStateHandle<(i32, i32)>, div: &NodeRef, lineno: &NodeRef) {
273 if let Some(div) = div.cast::<HtmlElement>() {
274 div.set_scroll_top(scroll.0);
275 div.set_scroll_left(scroll.1);
276 }
277
278 if let Some(div) = lineno.cast::<HtmlElement>() {
279 div.set_scroll_top(scroll.0);
280 }
281}
282
283fn autocomplete(
286 filter_dropdown: &Rc<FunctionDropDownElement>,
287 token: &Option<String>,
288 target: &NodeRef,
289) {
290 if let Some(x) = token {
291 let elem = target.cast::<HtmlElement>().unwrap();
292 if elem.is_connected() {
293 filter_dropdown
294 .autocomplete(x.clone(), elem, Callback::from(|_| ()))
295 .unwrap();
296 } else {
297 filter_dropdown.hide().unwrap();
298 }
299 } else {
300 filter_dropdown.hide().unwrap();
301 }
302}