atproto blogging
1//! DOM synchronization for the markdown editor.
2//!
3//! Handles syncing cursor/selection state between the browser DOM and the
4//! editor document model, and updating paragraph DOM elements.
5
6use wasm_bindgen::JsCast;
7use weaver_editor_core::{
8 CursorSync, OffsetMapping, ParagraphRender, SnapDirection, find_nearest_valid_position,
9 is_valid_cursor_position,
10};
11
12use weaver_editor_core::{EditorDocument, Selection, SyntaxSpanInfo};
13
14use crate::cursor::restore_cursor_position;
15use crate::update_syntax_visibility;
16
17/// Result of syncing cursor from DOM.
18#[derive(Debug, Clone)]
19pub enum CursorSyncResult {
20 /// Cursor is collapsed at this offset.
21 Cursor(usize),
22 /// Selection from anchor to head.
23 Selection { anchor: usize, head: usize },
24 /// Could not determine cursor position.
25 None,
26}
27
28/// Browser-based cursor sync implementation.
29///
30/// Holds reference to editor element ID and provides methods to sync
31/// cursor state from DOM back to the editor model.
32pub struct BrowserCursorSync {
33 editor_id: String,
34}
35
36impl BrowserCursorSync {
37 /// Create a new browser cursor sync for the given editor element.
38 pub fn new(editor_id: impl Into<String>) -> Self {
39 Self {
40 editor_id: editor_id.into(),
41 }
42 }
43
44 /// Get the editor element ID.
45 pub fn editor_id(&self) -> &str {
46 &self.editor_id
47 }
48}
49
50impl CursorSync for BrowserCursorSync {
51 fn sync_cursor_from_platform<F, G>(
52 &self,
53 paragraphs: &[ParagraphRender],
54 direction_hint: Option<SnapDirection>,
55 on_cursor: F,
56 on_selection: G,
57 ) where
58 F: FnOnce(usize),
59 G: FnOnce(usize, usize),
60 {
61 if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, paragraphs, direction_hint)
62 {
63 match result {
64 CursorSyncResult::Cursor(offset) => on_cursor(offset),
65 CursorSyncResult::Selection { anchor, head } => {
66 if anchor == head {
67 on_cursor(anchor);
68 } else {
69 on_selection(anchor, head);
70 }
71 }
72 CursorSyncResult::None => {}
73 }
74 }
75 }
76}
77
78/// Sync cursor state from DOM selection, returning the result.
79///
80/// This is the core implementation that reads the browser's selection state
81/// and converts it to character offsets using paragraph offset maps.
82pub fn sync_cursor_from_dom_impl(
83 editor_id: &str,
84 paragraphs: &[ParagraphRender],
85 direction_hint: Option<SnapDirection>,
86) -> Option<CursorSyncResult> {
87 if paragraphs.is_empty() {
88 return Some(CursorSyncResult::None);
89 }
90
91 let window = web_sys::window()?;
92 let dom_document = window.document()?;
93 let editor_element = dom_document.get_element_by_id(editor_id)?;
94
95 let selection = window.get_selection().ok()??;
96
97 let anchor_node = selection.anchor_node()?;
98 let focus_node = selection.focus_node()?;
99 let anchor_offset = selection.anchor_offset() as usize;
100 let focus_offset = selection.focus_offset() as usize;
101
102 tracing::trace!(
103 anchor_node_name = %anchor_node.node_name(),
104 anchor_offset,
105 focus_node_name = %focus_node.node_name(),
106 focus_offset,
107 "sync_cursor_from_dom_impl: browser selection state"
108 );
109
110 let anchor_char = dom_position_to_text_offset(
111 &dom_document,
112 &editor_element,
113 &anchor_node,
114 anchor_offset,
115 paragraphs,
116 direction_hint,
117 );
118 let focus_char = dom_position_to_text_offset(
119 &dom_document,
120 &editor_element,
121 &focus_node,
122 focus_offset,
123 paragraphs,
124 direction_hint,
125 );
126
127 match (anchor_char, focus_char) {
128 (Some(anchor), Some(head)) => {
129 if anchor == head {
130 Some(CursorSyncResult::Cursor(head))
131 } else {
132 Some(CursorSyncResult::Selection { anchor, head })
133 }
134 }
135 _ => {
136 tracing::warn!("Could not map DOM selection to text offsets");
137 Some(CursorSyncResult::None)
138 }
139 }
140}
141
142/// Convert a DOM position (node + offset) to a text char offset.
143///
144/// Walks up from the node to find a container with a node ID, then uses
145/// the paragraph offset maps to convert the UTF-16 offset to a character offset.
146/// The `direction_hint` is used when snapping from invisible content to determine
147/// which direction to prefer.
148pub fn dom_position_to_text_offset(
149 dom_document: &web_sys::Document,
150 editor_element: &web_sys::Element,
151 node: &web_sys::Node,
152 offset_in_text_node: usize,
153 paragraphs: &[ParagraphRender],
154 direction_hint: Option<SnapDirection>,
155) -> Option<usize> {
156 // Find the containing element with a node ID (walk up from text node).
157 let mut current_node = node.clone();
158 let mut walked_from: Option<web_sys::Node> = None;
159
160 let node_id = loop {
161 let node_name = current_node.node_name();
162 let node_id_attr = current_node
163 .dyn_ref::<web_sys::Element>()
164 .and_then(|e| e.get_attribute("id"));
165 let text_content_preview = current_node
166 .text_content()
167 .map(|s| s.chars().take(20).collect::<String>())
168 .unwrap_or_default();
169 tracing::trace!(
170 node_name = %node_name,
171 node_id_attr = ?node_id_attr,
172 text_preview = %text_content_preview.escape_debug(),
173 "dom_position_to_text_offset: walk-up iteration"
174 );
175
176 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
177 if element == editor_element {
178 // Selection is on the editor container itself.
179 // IMPORTANT: If we WALKED UP to the editor from a descendant,
180 // offset_in_text_node is the offset within that descendant, NOT the
181 // child index in the editor.
182 if let Some(ref walked_node) = walked_from {
183 tracing::trace!(
184 walked_from_node_name = %walked_node.node_name(),
185 "dom_position_to_text_offset: walked up to editor from descendant"
186 );
187
188 // Find paragraph containing this node by checking paragraph wrapper divs.
189 for (idx, para) in paragraphs.iter().enumerate() {
190 if let Some(para_elem) = dom_document.get_element_by_id(¶.id) {
191 let para_node: &web_sys::Node = para_elem.as_ref();
192 if para_node.contains(Some(walked_node)) {
193 tracing::trace!(
194 para_id = %para.id,
195 para_idx = idx,
196 char_start = para.char_range.start,
197 "dom_position_to_text_offset: found containing paragraph"
198 );
199 return Some(para.char_range.start);
200 }
201 }
202 }
203 tracing::warn!(
204 "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"
205 );
206 break None;
207 }
208
209 // Selection is directly on the editor container (e.g., Cmd+A).
210 let child_count = editor_element.child_element_count() as usize;
211 tracing::trace!(
212 offset_in_text_node,
213 child_count,
214 "dom_position_to_text_offset: selection directly on editor container"
215 );
216 if offset_in_text_node == 0 {
217 tracing::trace!(
218 "dom_position_to_text_offset: returning 0 (editor container offset 0)"
219 );
220 return Some(0);
221 } else if offset_in_text_node >= child_count {
222 let end = paragraphs.last().map(|p| p.char_range.end);
223 tracing::trace!(end = ?end, "dom_position_to_text_offset: returning end of last paragraph");
224 return end;
225 }
226 break None;
227 }
228
229 let id = element
230 .get_attribute("id")
231 .or_else(|| element.get_attribute("data-node-id"));
232
233 if let Some(id) = id {
234 // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs.
235 let is_node_id = id.starts_with('n') || id.contains("-n");
236 tracing::trace!(
237 id = %id,
238 is_node_id,
239 "dom_position_to_text_offset: checking ID pattern"
240 );
241 if is_node_id {
242 break Some(id);
243 }
244 }
245 }
246
247 walked_from = Some(current_node.clone());
248 current_node = current_node.parent_node()?;
249 };
250
251 let node_id = match node_id {
252 Some(id) => id,
253 None => {
254 tracing::trace!("dom_position_to_text_offset: no node_id found in walk-up");
255 return None;
256 }
257 };
258
259 tracing::trace!(node_id = %node_id, "dom_position_to_text_offset: found node_id");
260
261 let container = dom_document.get_element_by_id(&node_id).or_else(|| {
262 let selector = format!("[data-node-id='{}']", node_id);
263 dom_document.query_selector(&selector).ok().flatten()
264 })?;
265
266 // Calculate UTF-16 offset from start of container to the position.
267 let mut utf16_offset_in_container = 0;
268
269 let node_is_container = node
270 .dyn_ref::<web_sys::Element>()
271 .map(|e| e == &container)
272 .unwrap_or(false);
273
274 if node_is_container {
275 // offset_in_text_node is a child index - count text content up to that child.
276 let child_index = offset_in_text_node;
277 let children = container.child_nodes();
278 let mut text_counted = 0usize;
279
280 for i in 0..child_index.min(children.length() as usize) {
281 if let Some(child) = children.get(i as u32) {
282 if let Some(text) = child.text_content() {
283 text_counted += text.encode_utf16().count();
284 }
285 }
286 }
287 utf16_offset_in_container = text_counted;
288
289 tracing::trace!(
290 child_index,
291 utf16_offset = utf16_offset_in_container,
292 "dom_position_to_text_offset: node is container, using child index"
293 );
294 } else {
295 // Normal case: node is a text node, walk to find it.
296 if let Ok(walker) =
297 dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF)
298 {
299 let mut skip_until_exit: Option<web_sys::Element> = None;
300
301 while let Ok(Some(dom_node)) = walker.next_node() {
302 if let Some(ref skip_elem) = skip_until_exit {
303 if !skip_elem.contains(Some(&dom_node)) {
304 skip_until_exit = None;
305 }
306 }
307
308 if skip_until_exit.is_none() {
309 if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
310 if element.get_attribute("contenteditable").as_deref() == Some("false") {
311 skip_until_exit = Some(element.clone());
312 continue;
313 }
314 }
315 }
316
317 if skip_until_exit.is_some() {
318 continue;
319 }
320
321 if dom_node.node_type() == web_sys::Node::TEXT_NODE {
322 if &dom_node == node {
323 utf16_offset_in_container += offset_in_text_node;
324 break;
325 }
326
327 if let Some(text) = dom_node.text_content() {
328 utf16_offset_in_container += text.encode_utf16().count();
329 }
330 }
331 }
332 }
333 }
334
335 // Log what we're looking for.
336 tracing::trace!(
337 node_id = %node_id,
338 utf16_offset = utf16_offset_in_container,
339 num_paragraphs = paragraphs.len(),
340 "dom_position_to_text_offset: looking up mapping"
341 );
342
343 // Look up the offset in paragraph offset maps.
344 // Track the best match for the node_id in case offset is past the end.
345 let mut best_match_for_node: Option<(usize, &OffsetMapping)> = None;
346
347 for para in paragraphs {
348 for mapping in ¶.offset_map {
349 if mapping.node_id == node_id {
350 let mapping_start = mapping.char_offset_in_node;
351 let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
352
353 tracing::trace!(
354 mapping_node_id = %mapping.node_id,
355 mapping_start,
356 mapping_end,
357 utf16_offset = utf16_offset_in_container,
358 char_range_start = mapping.char_range.start,
359 char_range_end = mapping.char_range.end,
360 "dom_position_to_text_offset: found matching node_id"
361 );
362
363 // Track the mapping with the highest end position for this node.
364 if best_match_for_node.is_none() || mapping_end > best_match_for_node.unwrap().0 {
365 best_match_for_node = Some((mapping_end, mapping));
366 }
367
368 let in_range = utf16_offset_in_container >= mapping_start
369 && utf16_offset_in_container <= mapping_end;
370
371 if in_range {
372 let offset_in_mapping = utf16_offset_in_container - mapping_start;
373 let char_offset = mapping.char_range.start + offset_in_mapping;
374
375 tracing::trace!(
376 node_id = %node_id,
377 utf16_offset = utf16_offset_in_container,
378 mapping_start,
379 mapping_end,
380 offset_in_mapping,
381 char_range_start = mapping.char_range.start,
382 char_offset,
383 "dom_position_to_text_offset: MATCHED mapping"
384 );
385
386 // Check if position is valid (not on invisible content).
387 if is_valid_cursor_position(¶.offset_map, char_offset) {
388 tracing::trace!(
389 char_offset,
390 "dom_position_to_text_offset: returning valid position from mapping"
391 );
392 return Some(char_offset);
393 }
394
395 // Position is on invisible content, snap to nearest valid.
396 if let Some(snapped) =
397 find_nearest_valid_position(¶.offset_map, char_offset, direction_hint)
398 {
399 tracing::trace!(
400 original = char_offset,
401 snapped = snapped.char_offset(),
402 "dom_position_to_text_offset: snapped from invisible to valid"
403 );
404 return Some(snapped.char_offset());
405 }
406
407 // Fallback to original if no snap target.
408 tracing::trace!(
409 char_offset,
410 "dom_position_to_text_offset: returning original (no snap target)"
411 );
412 return Some(char_offset);
413 }
414 }
415 }
416 }
417
418 // If we found the node_id but offset was past the end, snap to the last tracked position.
419 if let Some((max_end, mapping)) = best_match_for_node {
420 if utf16_offset_in_container > max_end {
421 // Cursor is past the end of tracked content - snap to end of last mapping.
422 let char_offset = mapping.char_range.end;
423 tracing::trace!(
424 node_id = %node_id,
425 utf16_offset = utf16_offset_in_container,
426 max_tracked_end = max_end,
427 snapped_to = char_offset,
428 "dom_position_to_text_offset: offset past tracked content, snapping to end"
429 );
430 return Some(char_offset);
431 }
432 }
433
434 // No mapping found - try to find a valid position in the paragraph matching the node_id.
435 // Extract paragraph index from node_id format "p-{idx}-n{node}" to avoid jumping to wrong paragraph.
436 let para_idx_from_node = node_id
437 .strip_prefix("p-")
438 .and_then(|rest| rest.split('-').next())
439 .and_then(|idx_str| idx_str.parse::<usize>().ok());
440
441 tracing::trace!(
442 node_id = %node_id,
443 utf16_offset = utf16_offset_in_container,
444 para_idx_from_node = ?para_idx_from_node,
445 num_paragraphs = paragraphs.len(),
446 "dom_position_to_text_offset: NO MAPPING FOUND - falling back"
447 );
448
449 // First try the paragraph that matches the node_id prefix.
450 if let Some(idx) = para_idx_from_node {
451 if let Some(para) = paragraphs.get(idx) {
452 if let Some(snapped) =
453 find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
454 {
455 tracing::trace!(
456 para_id = %para.id,
457 snapped_offset = snapped.char_offset(),
458 "dom_position_to_text_offset: fallback to matching paragraph"
459 );
460 return Some(snapped.char_offset());
461 }
462 }
463 }
464
465 // Last resort: try any paragraph (starting from first).
466 for para in paragraphs {
467 if let Some(snapped) =
468 find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
469 {
470 tracing::trace!(
471 para_id = %para.id,
472 snapped_offset = snapped.char_offset(),
473 "dom_position_to_text_offset: fallback to first available paragraph"
474 );
475 return Some(snapped.char_offset());
476 }
477 }
478
479 None
480}
481
482/// Sync cursor state from DOM to an EditorDocument.
483///
484/// This is a generic version that works with any `EditorDocument` implementation.
485/// It reads the browser's selection state and updates the document's cursor and selection.
486pub fn sync_cursor_from_dom<D: EditorDocument>(
487 doc: &mut D,
488 editor_id: &str,
489 paragraphs: &[ParagraphRender],
490 direction_hint: Option<SnapDirection>,
491) {
492 if let Some(result) = sync_cursor_from_dom_impl(editor_id, paragraphs, direction_hint) {
493 match result {
494 CursorSyncResult::Cursor(offset) => {
495 doc.set_cursor_offset(offset);
496 doc.set_selection(None);
497 }
498 CursorSyncResult::Selection { anchor, head } => {
499 doc.set_cursor_offset(head);
500 if anchor != head {
501 doc.set_selection(Some(Selection { anchor, head }));
502 } else {
503 doc.set_selection(None);
504 }
505 }
506 CursorSyncResult::None => {}
507 }
508 }
509}
510
511/// Sync cursor from DOM and update syntax visibility in one call.
512///
513/// This is the common pattern used by most event handlers: sync the cursor
514/// position from the browser's selection, then update which syntax elements
515/// are visible based on the new cursor position.
516///
517/// Use this for: onclick, onselect, onselectstart, onselectionchange, onkeyup.
518pub fn sync_cursor_and_visibility<D: EditorDocument>(
519 doc: &mut D,
520 editor_id: &str,
521 paragraphs: &[ParagraphRender],
522 syntax_spans: &[SyntaxSpanInfo],
523 direction_hint: Option<SnapDirection>,
524) {
525 sync_cursor_from_dom(doc, editor_id, paragraphs, direction_hint);
526 let cursor_offset = doc.cursor_offset();
527 let selection = doc.selection();
528 update_syntax_visibility(cursor_offset, selection.as_ref(), syntax_spans, paragraphs);
529}
530
531/// Update paragraph DOM elements incrementally.
532///
533/// Uses stable content-based paragraph IDs for efficient DOM reconciliation:
534/// - Unchanged paragraphs (same ID + hash) are not touched
535/// - Changed paragraphs (same ID, different hash) get innerHTML updated
536/// - New paragraphs get created and inserted at correct position
537/// - Removed paragraphs get deleted
538///
539/// When `FORCE_INNERHTML_UPDATE` is false, cursor paragraph innerHTML updates
540/// are skipped if only text content changed (syntax spans unchanged) and the
541/// DOM content length matches expected. This allows browser-native editing
542/// to proceed without disrupting the selection.
543///
544/// Returns true if the paragraph containing the cursor was updated.
545pub fn update_paragraph_dom(
546 editor_id: &str,
547 old_paragraphs: &[ParagraphRender],
548 new_paragraphs: &[ParagraphRender],
549 cursor_offset: usize,
550 force: bool,
551) -> bool {
552 use crate::FORCE_INNERHTML_UPDATE;
553 use std::collections::HashMap;
554
555 let window = match web_sys::window() {
556 Some(w) => w,
557 None => return false,
558 };
559
560 let document = match window.document() {
561 Some(d) => d,
562 None => return false,
563 };
564
565 let editor = match document.get_element_by_id(editor_id) {
566 Some(e) => e,
567 None => return false,
568 };
569
570 let mut cursor_para_updated = false;
571
572 // Build lookup for old paragraphs by ID (for syntax span comparison).
573 let old_para_map: HashMap<&str, &ParagraphRender> =
574 old_paragraphs.iter().map(|p| (p.id.as_str(), p)).collect();
575
576 // Build pool of existing DOM elements by ID.
577 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new();
578 let mut child_opt = editor.first_element_child();
579 while let Some(child) = child_opt {
580 if let Some(id) = child.get_attribute("id") {
581 let next = child.next_element_sibling();
582 old_elements.insert(id, child);
583 child_opt = next;
584 } else {
585 child_opt = child.next_element_sibling();
586 }
587 }
588
589 let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into());
590
591 for new_para in new_paragraphs.iter() {
592 let para_id = &new_para.id;
593 let new_hash = format!("{:x}", new_para.source_hash);
594 let is_cursor_para =
595 new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end;
596
597 if let Some(existing_elem) = old_elements.remove(para_id.as_str()) {
598 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
599 let needs_update = force || old_hash != new_hash;
600
601 let existing_as_node: &web_sys::Node = existing_elem.as_ref();
602 let at_correct_position = cursor_node
603 .as_ref()
604 .map(|c| c == existing_as_node)
605 .unwrap_or(false);
606
607 if !at_correct_position {
608 tracing::warn!(
609 para_id = %para_id,
610 is_cursor_para,
611 "update_paragraph_dom: element not at correct position, moving"
612 );
613 let _ = editor.insert_before(existing_as_node, cursor_node.as_ref());
614 if is_cursor_para {
615 cursor_para_updated = true;
616 }
617 } else {
618 cursor_node = existing_elem.next_element_sibling().map(|e| e.into());
619 }
620
621 if needs_update {
622 // For cursor paragraph: only update if syntax/formatting changed.
623 // This prevents destroying browser selection during fast typing.
624 //
625 // HOWEVER: we must verify browser actually updated the DOM.
626 // PassThrough assumes browser handles edit, but sometimes it doesn't.
627 let should_skip_cursor_update =
628 !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && {
629 let old_para = old_para_map.get(para_id.as_str());
630 let syntax_unchanged = old_para
631 .map(|old| old.syntax_spans == new_para.syntax_spans)
632 .unwrap_or(false);
633
634 // Verify DOM content length matches expected.
635 let dom_matches_expected = if syntax_unchanged {
636 let inner_elem = existing_elem.first_element_child();
637 let dom_text = inner_elem
638 .as_ref()
639 .and_then(|e| e.text_content())
640 .unwrap_or_default();
641 let expected_len = new_para.byte_range.end - new_para.byte_range.start;
642 let dom_len = dom_text.len();
643 let matches = dom_len == expected_len;
644 tracing::trace!(
645 para_id = %para_id,
646 dom_len,
647 expected_len,
648 matches,
649 "DOM sync check"
650 );
651 matches
652 } else {
653 false
654 };
655
656 syntax_unchanged && dom_matches_expected
657 };
658
659 if should_skip_cursor_update {
660 tracing::trace!(
661 para_id = %para_id,
662 "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)"
663 );
664 let _ = existing_elem.set_attribute("data-hash", &new_hash);
665 } else {
666 if tracing::enabled!(tracing::Level::TRACE) {
667 let old_inner = existing_elem.inner_html();
668 tracing::trace!(
669 para_id = %para_id,
670 old_inner = %old_inner.escape_debug(),
671 new_html = %new_para.html.escape_debug(),
672 "update_paragraph_dom: replacing innerHTML"
673 );
674 }
675
676 // Timing instrumentation.
677 let start = web_sys::window()
678 .and_then(|w| w.performance())
679 .map(|p| p.now());
680
681 existing_elem.set_inner_html(&new_para.html);
682 let _ = existing_elem.set_attribute("data-hash", &new_hash);
683
684 if let Some(start_time) = start {
685 if let Some(end_time) = web_sys::window()
686 .and_then(|w| w.performance())
687 .map(|p| p.now())
688 {
689 let elapsed_ms = end_time - start_time;
690 tracing::trace!(
691 para_id = %para_id,
692 is_cursor_para,
693 elapsed_ms,
694 html_len = new_para.html.len(),
695 "update_paragraph_dom: innerHTML update timing"
696 );
697 }
698 }
699
700 if is_cursor_para {
701 if let Err(e) =
702 restore_cursor_position(cursor_offset, &new_para.offset_map, None)
703 {
704 tracing::warn!("Synchronous cursor restore failed: {:?}", e);
705 }
706 cursor_para_updated = true;
707 }
708 }
709 }
710 } else {
711 // New element - create and insert.
712 if let Ok(div) = document.create_element("div") {
713 div.set_id(para_id);
714 div.set_inner_html(&new_para.html);
715 let _ = div.set_attribute("data-hash", &new_hash);
716 let div_node: &web_sys::Node = div.as_ref();
717 let _ = editor.insert_before(div_node, cursor_node.as_ref());
718
719 if is_cursor_para {
720 if let Err(e) =
721 restore_cursor_position(cursor_offset, &new_para.offset_map, None)
722 {
723 tracing::warn!("Cursor restore for new paragraph failed: {:?}", e);
724 }
725 cursor_para_updated = true;
726 }
727 }
728 }
729 }
730
731 // Remove stale elements.
732 for (_, elem) in old_elements {
733 let _ = elem.remove();
734 cursor_para_updated = true;
735 }
736
737 cursor_para_updated
738}