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