1use leptos::html;
4use leptos::prelude::*;
5use wasm_bindgen::JsCast;
6use wasm_bindgen::prelude::Closure;
7
8use crate::web::code_editor::Highlighter;
9use crate::web::{use_retained_closures, visible_range};
10
11fn selection_text(chars: &[char], carets: &[Caret]) -> String {
12 let mut sorted = carets.to_vec();
13 sorted.sort_by_key(Caret::low);
14 sorted
15 .iter()
16 .map(|caret| chars[caret.low()..caret.high()].iter().collect::<String>())
17 .collect::<Vec<_>>()
18 .join("\n")
19}
20
21fn move_line_edge(chars: &[char], carets: &mut [Caret], to_end: bool, extend: bool) {
22 for caret in carets.iter_mut() {
23 let (line, _) = line_col(chars, caret.head);
24 let target = if to_end {
25 line_start(chars, line) + line_len(chars, line)
26 } else {
27 line_start(chars, line)
28 };
29 caret.head = target;
30 if !extend {
31 caret.anchor = target;
32 }
33 }
34}
35
36fn write_clipboard(text: &str) {
37 if let Some(window) = web_sys::window() {
38 let _ = window.navigator().clipboard().write_text(text);
39 }
40}
41
42#[derive(Clone, Copy)]
43struct Caret {
44 anchor: usize,
45 head: usize,
46}
47
48impl Caret {
49 fn collapsed(position: usize) -> Self {
50 Self {
51 anchor: position,
52 head: position,
53 }
54 }
55
56 fn low(&self) -> usize {
57 self.anchor.min(self.head)
58 }
59
60 fn high(&self) -> usize {
61 self.anchor.max(self.head)
62 }
63}
64
65fn line_col(chars: &[char], index: usize) -> (usize, usize) {
66 let mut line = 0;
67 let mut col = 0;
68 for &character in &chars[..index.min(chars.len())] {
69 if character == '\n' {
70 line += 1;
71 col = 0;
72 } else {
73 col += 1;
74 }
75 }
76 (line, col)
77}
78
79fn line_start(chars: &[char], line: usize) -> usize {
80 let mut current = 0;
81 let mut index = 0;
82 while index < chars.len() && current < line {
83 if chars[index] == '\n' {
84 current += 1;
85 }
86 index += 1;
87 }
88 index
89}
90
91fn line_len(chars: &[char], line: usize) -> usize {
92 let start = line_start(chars, line);
93 let mut end = start;
94 while end < chars.len() && chars[end] != '\n' {
95 end += 1;
96 }
97 end - start
98}
99
100fn total_lines(chars: &[char]) -> usize {
101 chars.iter().filter(|&&character| character == '\n').count() + 1
102}
103
104fn offset_of(chars: &[char], line: usize, col: usize) -> usize {
105 line_start(chars, line) + col.min(line_len(chars, line))
106}
107
108fn normalize(carets: &mut Vec<Caret>) {
109 carets.sort_by_key(Caret::low);
110 carets.dedup_by_key(|caret| (caret.anchor, caret.head));
111}
112
113fn insert_text(chars: &mut Vec<char>, carets: &mut [Caret], insert: &[char]) {
114 carets.sort_by_key(Caret::low);
115 let mut delta: isize = 0;
116 for caret in carets.iter_mut() {
117 let low = (caret.low() as isize + delta) as usize;
118 let high = (caret.high() as isize + delta) as usize;
119 chars.splice(low..high, insert.iter().copied());
120 *caret = Caret::collapsed(low + insert.len());
121 delta += insert.len() as isize - (high as isize - low as isize);
122 }
123}
124
125fn delete_backward(chars: &mut Vec<char>, carets: &mut [Caret]) {
126 carets.sort_by_key(Caret::low);
127 let mut delta: isize = 0;
128 for caret in carets.iter_mut() {
129 let low = (caret.low() as isize + delta) as usize;
130 let high = (caret.high() as isize + delta) as usize;
131 let (from, to) = if high > low {
132 (low, high)
133 } else if low > 0 {
134 (low - 1, low)
135 } else {
136 (low, low)
137 };
138 chars.drain(from..to);
139 *caret = Caret::collapsed(from);
140 delta -= (to - from) as isize;
141 }
142}
143
144fn move_horizontal(chars: &[char], carets: &mut [Caret], direction: isize, extend: bool) {
145 let len = chars.len() as isize;
146 for caret in carets.iter_mut() {
147 let head = (caret.head as isize + direction).clamp(0, len) as usize;
148 caret.head = head;
149 if !extend {
150 caret.anchor = head;
151 }
152 }
153}
154
155fn move_vertical(chars: &[char], carets: &mut [Caret], direction: isize, extend: bool) {
156 for caret in carets.iter_mut() {
157 let (line, col) = line_col(chars, caret.head);
158 let target = (line as isize + direction).max(0) as usize;
159 if target < total_lines(chars) {
160 caret.head = offset_of(chars, target, col);
161 if !extend {
162 caret.anchor = caret.head;
163 }
164 }
165 }
166}
167
168fn word_bounds(chars: &[char], index: usize) -> (usize, usize) {
169 let is_word = |character: char| character.is_alphanumeric() || character == '_';
170 let mut start = index;
171 while start > 0 && chars.get(start - 1).is_some_and(|&c| is_word(c)) {
172 start -= 1;
173 }
174 let mut end = index;
175 while end < chars.len() && chars.get(end).is_some_and(|&c| is_word(c)) {
176 end += 1;
177 }
178 (start, end)
179}
180
181fn add_next_occurrence(chars: &[char], carets: &mut Vec<Caret>) {
182 let Some(last) = carets.iter().max_by_key(|caret| caret.high()).copied() else {
183 return;
184 };
185 let (low, high) = if last.high() > last.low() {
186 (last.low(), last.high())
187 } else {
188 word_bounds(chars, last.head)
189 };
190 if high <= low {
191 return;
192 }
193 let needle = &chars[low..high];
194 let mut cursor = high;
195 while cursor + needle.len() <= chars.len() {
196 if &chars[cursor..cursor + needle.len()] == needle {
197 carets.push(Caret {
198 anchor: cursor,
199 head: cursor + needle.len(),
200 });
201 return;
202 }
203 cursor += 1;
204 }
205}
206
207#[component]
214pub fn MultiEditor(
215 value: RwSignal<String>,
216 #[prop(optional)] highlighter: Option<Highlighter>,
217 #[prop(default = 20.0)] line_height: f64,
218 #[prop(default = 360.0)] height: f64,
219 #[prop(default = 12)] overscan: usize,
220) -> impl IntoView {
221 let carets = RwSignal::new(vec![Caret::collapsed(0)]);
222 let scroll_top = RwSignal::new(0.0);
223 let viewport_height = RwSignal::new(height);
224 let wrap_ref = NodeRef::<html::Div>::new();
225 let ruler_ref = NodeRef::<html::Span>::new();
226 let pressed = StoredValue::new(false);
227 let retained = use_retained_closures();
228 let textarea_ref = NodeRef::<html::Textarea>::new();
229 let composing = StoredValue::new(false);
230
231 let model = Memo::new(move |_| {
232 let source = value.get();
233 (
234 source.chars().collect::<Vec<char>>(),
235 source
236 .split('\n')
237 .map(str::to_string)
238 .collect::<Vec<String>>(),
239 )
240 });
241
242 let offset_at = move |event: &web_sys::PointerEvent| -> Option<usize> {
243 let wrap = wrap_ref.get()?;
244 let rect = wrap.get_bounding_client_rect();
245 let char_width = ruler_ref
246 .get()
247 .map(|ruler| ruler.get_bounding_client_rect().width() / 10.0)
248 .filter(|width| *width > 0.0)
249 .unwrap_or(8.0);
250 let x = event.client_x() as f64 - rect.left() + wrap.scroll_left() as f64 - 10.0;
251 let y = event.client_y() as f64 - rect.top() + wrap.scroll_top() as f64;
252 let target_line = (y / line_height).floor().max(0.0) as usize;
253 let target_col = (x / char_width).round().max(0.0) as usize;
254 model.with(|(chars, _)| {
255 let line = target_line.min(total_lines(chars).saturating_sub(1));
256 Some(offset_of(chars, line, target_col))
257 })
258 };
259
260 let on_pointer_down = move |event: web_sys::PointerEvent| {
261 if let Some(offset) = offset_at(&event) {
262 carets.set(vec![Caret::collapsed(offset)]);
263 pressed.set_value(true);
264 if let Some(area) = textarea_ref.get() {
265 let _ = area.focus();
266 }
267 }
268 };
269 let on_pointer_move = move |event: web_sys::PointerEvent| {
270 if pressed.get_value()
271 && let Some(offset) = offset_at(&event)
272 {
273 carets.update(|list| {
274 if let Some(first) = list.first_mut() {
275 first.head = offset;
276 }
277 });
278 }
279 };
280 let release_handle =
281 window_event_listener(leptos::ev::pointerup, move |_| pressed.set_value(false));
282 on_cleanup(move || release_handle.remove());
283
284 let edit = move |mutate: &dyn Fn(&mut Vec<char>, &mut Vec<Caret>)| {
285 let mut chars: Vec<char> = value.get_untracked().chars().collect();
286 let mut current = carets.get_untracked();
287 mutate(&mut chars, &mut current);
288 normalize(&mut current);
289 value.set(chars.into_iter().collect());
290 carets.set(current);
291 };
292
293 let copy_selection = move || {
294 let chars: Vec<char> = value.get_untracked().chars().collect();
295 let text = selection_text(&chars, &carets.get_untracked());
296 if !text.is_empty() {
297 write_clipboard(&text);
298 }
299 };
300 let paste = move || {
301 let Some(window) = web_sys::window() else {
302 return;
303 };
304 let promise = window.navigator().clipboard().read_text();
305 let closure = Closure::<dyn FnMut(wasm_bindgen::JsValue)>::new(
306 move |result: wasm_bindgen::JsValue| {
307 if let Some(text) = result.as_string() {
308 let insert: Vec<char> = text.chars().collect();
309 let mut chars: Vec<char> = value.get_untracked().chars().collect();
310 let mut current = carets.get_untracked();
311 insert_text(&mut chars, &mut current, &insert);
312 normalize(&mut current);
313 value.set(chars.into_iter().collect());
314 carets.set(current);
315 }
316 },
317 );
318 let _ = promise.then(&closure);
319 retained.retain(closure);
320 };
321
322 let commit_input = move || {
323 if let Some(area) = textarea_ref.get() {
324 let text = area.value();
325 if !text.is_empty() {
326 let inserted: Vec<char> = text.chars().collect();
327 edit(&move |chars, carets| insert_text(chars, carets, &inserted));
328 area.set_value("");
329 }
330 }
331 };
332 let on_input = move |_: web_sys::Event| {
333 if !composing.get_value() {
334 commit_input();
335 }
336 };
337 let on_compose_start = move |_: web_sys::CompositionEvent| composing.set_value(true);
338 let on_compose_end = move |_: web_sys::CompositionEvent| {
339 composing.set_value(false);
340 commit_input();
341 };
342
343 let on_key = move |event: web_sys::KeyboardEvent| {
344 let key = event.key();
345 let ctrl = event.ctrl_key() || event.meta_key();
346 let alt = event.alt_key();
347 let shift = event.shift_key();
348
349 if ctrl && alt && key == "ArrowDown" {
350 event.prevent_default();
351 edit(&|chars, carets| {
352 if let Some(last) = carets.iter().max_by_key(|caret| caret.high()).copied() {
353 let (line, col) = line_col(chars, last.head);
354 if line + 1 < total_lines(chars) {
355 carets.push(Caret::collapsed(offset_of(chars, line + 1, col)));
356 }
357 }
358 });
359 return;
360 }
361 if ctrl && alt && key == "ArrowUp" {
362 event.prevent_default();
363 edit(&|chars, carets| {
364 if let Some(first) = carets.iter().min_by_key(|caret| caret.low()).copied() {
365 let (line, col) = line_col(chars, first.head);
366 if line > 0 {
367 carets.push(Caret::collapsed(offset_of(chars, line - 1, col)));
368 }
369 }
370 });
371 return;
372 }
373 if ctrl && key == "d" {
374 event.prevent_default();
375 edit(&|chars, carets| add_next_occurrence(chars, carets));
376 return;
377 }
378 if ctrl && key == "a" {
379 event.prevent_default();
380 let len = value.get_untracked().chars().count();
381 carets.set(vec![Caret {
382 anchor: 0,
383 head: len,
384 }]);
385 return;
386 }
387 if ctrl && key == "c" {
388 event.prevent_default();
389 copy_selection();
390 return;
391 }
392 if ctrl && key == "x" {
393 event.prevent_default();
394 copy_selection();
395 edit(&|chars, carets| insert_text(chars, carets, &[]));
396 return;
397 }
398 if ctrl && key == "v" {
399 event.prevent_default();
400 paste();
401 return;
402 }
403 match key.as_str() {
404 "Home" => {
405 event.prevent_default();
406 edit(&move |chars, carets| move_line_edge(chars, carets, false, shift));
407 }
408 "End" => {
409 event.prevent_default();
410 edit(&move |chars, carets| move_line_edge(chars, carets, true, shift));
411 }
412 "ArrowLeft" => {
413 event.prevent_default();
414 edit(&move |chars, carets| move_horizontal(chars, carets, -1, shift));
415 }
416 "ArrowRight" => {
417 event.prevent_default();
418 edit(&move |chars, carets| move_horizontal(chars, carets, 1, shift));
419 }
420 "ArrowUp" => {
421 event.prevent_default();
422 edit(&move |chars, carets| move_vertical(chars, carets, -1, shift));
423 }
424 "ArrowDown" => {
425 event.prevent_default();
426 edit(&move |chars, carets| move_vertical(chars, carets, 1, shift));
427 }
428 "Backspace" => {
429 event.prevent_default();
430 edit(&|chars, carets| delete_backward(chars, carets));
431 }
432 "Enter" => {
433 event.prevent_default();
434 edit(&|chars, carets| insert_text(chars, carets, &['\n']));
435 }
436 "Tab" => {
437 event.prevent_default();
438 edit(&|chars, carets| insert_text(chars, carets, &[' ', ' ', ' ', ' ']));
439 }
440 "Escape" => {
441 event.prevent_default();
442 carets.update(|list| {
443 if let Some(first) = list.first().copied() {
444 *list = vec![Caret::collapsed(first.head)];
445 }
446 });
447 }
448 _ => {}
449 }
450 };
451
452 let on_scroll = move |event: web_sys::Event| {
453 if let Some(element) = event
454 .target()
455 .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
456 {
457 scroll_top.set(element.scroll_top() as f64);
458 viewport_height.set(element.client_height() as f64);
459 }
460 };
461
462 Effect::new(move |_| {
463 if let Some(element) = wrap_ref.get() {
464 viewport_height.set(element.client_height() as f64);
465 }
466 });
467
468 let content = move || {
469 let view_height = viewport_height.get().max(line_height);
470 let scroll = scroll_top.get();
471 let cursors = carets.get();
472 model.with(|(chars, lines)| {
473 let total = lines.len();
474 let (start, end) = visible_range(scroll, view_height, line_height, overscan, total);
475
476 let rendered_lines = (start..end)
477 .map(|index| {
478 let text = lines[index].clone();
479 let spans = match highlighter {
480 Some(highlight) => highlight(&text),
481 None => vec![("tok-plain", text)],
482 };
483 view! {
484 <div
485 class="nightshade-ml-line"
486 style=format!("top:{}px;height:{}px", index as f64 * line_height, line_height)
487 >
488 {spans
489 .into_iter()
490 .map(|(class, text)| view! { <span class=class>{text}</span> })
491 .collect_view()}
492 </div>
493 }
494 })
495 .collect_view();
496
497 let overlays = cursors
498 .into_iter()
499 .flat_map(|caret| {
500 let mut nodes = Vec::new();
501 if caret.high() > caret.low() {
502 let (start_line, start_col) = line_col(chars, caret.low());
503 let (end_line, end_col) = line_col(chars, caret.high());
504 for line in start_line..=end_line {
505 let from = if line == start_line { start_col } else { 0 };
506 let to = if line == end_line {
507 end_col
508 } else {
509 line_len(chars, line) + 1
510 };
511 nodes.push(
512 view! {
513 <div
514 class="nightshade-ml-selection"
515 style=format!(
516 "top:{}px;height:{}px;left:calc(10px + {}ch);width:calc({}ch)",
517 line as f64 * line_height,
518 line_height,
519 from,
520 to.saturating_sub(from),
521 )
522 ></div>
523 }
524 .into_any(),
525 );
526 }
527 }
528 let (line, col) = line_col(chars, caret.head);
529 nodes.push(
530 view! {
531 <div
532 class="nightshade-ml-caret"
533 style=format!(
534 "top:{}px;height:{}px;left:calc(10px + {}ch)",
535 line as f64 * line_height,
536 line_height,
537 col,
538 )
539 ></div>
540 }
541 .into_any(),
542 );
543 nodes
544 })
545 .collect_view();
546
547 view! {
548 <div
549 class="nightshade-ml-inner"
550 style=format!("height:{}px", total as f64 * line_height)
551 >
552 {overlays}
553 {rendered_lines}
554 </div>
555 }
556 })
557 };
558
559 view! {
560 <div
561 class="nightshade-ml"
562 tabindex="0"
563 node_ref=wrap_ref
564 style=format!("height:{height}px")
565 on:keydown=on_key
566 on:scroll=on_scroll
567 on:pointerdown=on_pointer_down
568 on:pointermove=on_pointer_move
569 >
570 <span node_ref=ruler_ref class="nightshade-ml-ruler">"0000000000"</span>
571 <textarea
572 class="nightshade-ml-sink"
573 node_ref=textarea_ref
574 on:input=on_input
575 on:compositionstart=on_compose_start
576 on:compositionend=on_compose_end
577 style=move || {
578 let head = carets.get().last().map(|caret| caret.head).unwrap_or(0);
579 model.with(|(chars, _)| {
580 let (line, col) = line_col(chars, head);
581 format!(
582 "top:{}px; left:calc(10px + {}ch); height:{}px",
583 line as f64 * line_height,
584 col,
585 line_height,
586 )
587 })
588 }
589 ></textarea>
590 {content}
591 </div>
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::{Caret, add_next_occurrence, delete_backward, insert_text};
598
599 fn chars(text: &str) -> Vec<char> {
600 text.chars().collect()
601 }
602
603 #[test]
604 fn insert_at_multiple_carets_shifts_correctly() {
605 let mut buffer = chars("ab\ncd");
606 let mut carets = vec![Caret::collapsed(0), Caret::collapsed(3)];
607 insert_text(&mut buffer, &mut carets, &['X']);
608 assert_eq!(buffer.iter().collect::<String>(), "Xab\nXcd");
609 assert_eq!(carets[0].head, 1);
610 assert_eq!(carets[1].head, 5);
611 }
612
613 #[test]
614 fn backspace_deletes_before_each_caret() {
615 let mut buffer = chars("ab\ncd");
616 let mut carets = vec![Caret::collapsed(2), Caret::collapsed(5)];
617 delete_backward(&mut buffer, &mut carets);
618 assert_eq!(buffer.iter().collect::<String>(), "a\nc");
619 }
620
621 #[test]
622 fn selection_text_joins_multiple_carets() {
623 let buffer = super::selection_text(
624 &chars("abcdef"),
625 &[Caret { anchor: 0, head: 2 }, Caret { anchor: 4, head: 6 }],
626 );
627 assert_eq!(buffer, "ab\nef");
628 }
629
630 #[test]
631 fn add_next_occurrence_selects_following_match() {
632 let buffer = chars("foo bar foo");
633 let mut carets = vec![Caret { anchor: 0, head: 3 }];
634 add_next_occurrence(&buffer, &mut carets);
635 assert_eq!(carets.len(), 2);
636 assert_eq!(carets[1].anchor, 8);
637 assert_eq!(carets[1].head, 11);
638 }
639}