···21use super::platform::Platform;
2223// Re-export types from extracted crates.
024pub use weaver_editor_core::{InputType, Range};
2526#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
27pub use weaver_editor_browser::StaticRange;
28-29-/// Result of handling a beforeinput event.
30-#[derive(Debug, Clone)]
31-#[allow(dead_code)]
32-pub enum BeforeInputResult {
33- /// Event was handled, prevent default browser behavior.
34- Handled,
35- /// Event should be handled by browser (e.g., during composition).
36- PassThrough,
37- /// Event was handled but requires async follow-up (e.g., paste).
38- HandledAsync,
39- /// Android backspace workaround: defer and check if browser handled it.
40- DeferredCheck {
41- /// The action to execute if browser didn't handle it.
42- fallback_action: EditorAction,
43- },
44-}
45-46-/// Context for beforeinput handling.
47-#[allow(dead_code)]
48-pub struct BeforeInputContext<'a> {
49- /// The input type.
50- pub input_type: InputType,
51- /// The data (text to insert, if any).
52- pub data: Option<String>,
53- /// Target range from getTargetRanges(), if available.
54- /// This is the range the browser wants to modify.
55- pub target_range: Option<Range>,
56- /// Whether the event is part of an IME composition.
57- pub is_composing: bool,
58- /// Platform info for quirks handling.
59- pub platform: &'a Platform,
60-}
6162/// Handle a beforeinput event.
63///
···21use super::platform::Platform;
2223// Re-export types from extracted crates.
24+pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult};
25pub use weaver_editor_core::{InputType, Range};
2627#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28pub use weaver_editor_browser::StaticRange;
0000000000000000000000000000000002930/// Handle a beforeinput event.
31///
+12-211
crates/weaver-app/src/components/editor/cursor.rs
···1-//! Cursor position restoration in the DOM.
2//!
3-//! After re-rendering HTML, we need to restore the cursor to its original
4-//! position in the source text. This involves:
5-//! 1. Finding the offset mapping for the cursor's char position
6-//! 2. Getting the DOM element by node ID
7-//! 3. Walking text nodes to find the UTF-16 offset within the element
8-//! 4. Setting cursor with web_sys Selection API
9-10-use weaver_editor_core::OffsetMapping;
11-pub use weaver_editor_core::{CursorRect, SelectionRect};
12-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
13-use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position};
1415-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
16-use wasm_bindgen::JsCast;
1718-/// Restore cursor position in the DOM after re-render.
19#[cfg(all(target_family = "wasm", target_os = "unknown"))]
20-pub fn restore_cursor_position(
21- char_offset: usize,
22- offset_map: &[OffsetMapping],
23- editor_id: &str,
24- snap_direction: Option<SnapDirection>,
25-) -> Result<(), wasm_bindgen::JsValue> {
26- // Empty document - no cursor to restore
27- if offset_map.is_empty() {
28- return Ok(());
29- }
30-31- // Bounds check using offset map
32- let max_offset = offset_map
33- .iter()
34- .map(|m| m.char_range.end)
35- .max()
36- .unwrap_or(0);
37- if char_offset > max_offset {
38- tracing::warn!(
39- "cursor offset {} > max mapping offset {}",
40- char_offset,
41- max_offset
42- );
43- // Don't error, just skip restoration - this can happen during edits
44- return Ok(());
45- }
46-47- // Find mapping for this cursor position, snapping if needed
48- let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
49- Some((m, false)) => (m, char_offset), // Valid position, use as-is
50- Some((m, true)) => {
51- // Position is on invisible content, snap to nearest valid
52- if let Some(snapped) =
53- find_nearest_valid_position(offset_map, char_offset, snap_direction)
54- {
55- tracing::trace!(
56- target: "weaver::cursor",
57- original_offset = char_offset,
58- snapped_offset = snapped.char_offset(),
59- direction = ?snapped.snapped,
60- "snapping cursor from invisible content"
61- );
62- (snapped.mapping, snapped.char_offset())
63- } else {
64- // Fallback to original mapping if no valid snap target
65- (m, char_offset)
66- }
67- }
68- None => return Err("no mapping found for cursor offset".into()),
69- };
70-71- tracing::trace!(
72- target: "weaver::cursor",
73- char_offset,
74- node_id = %mapping.node_id,
75- mapping_range = ?mapping.char_range,
76- child_index = ?mapping.child_index,
77- "restoring cursor position"
78- );
79-80- // Get window and document
81- let window = web_sys::window().ok_or("no window")?;
82- let document = window.document().ok_or("no document")?;
83-84- // Get the container element by node ID (try id attribute first, then data-node-id)
85- let container = document
86- .get_element_by_id(&mapping.node_id)
87- .or_else(|| {
88- let selector = format!("[data-node-id='{}']", mapping.node_id);
89- document.query_selector(&selector).ok().flatten()
90- })
91- .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
92-93- // Set selection using Range API
94- let selection = window.get_selection()?.ok_or("no selection object")?;
95- let range = document.create_range()?;
96-97- // Check if this is an element-based position (e.g., after <br />)
98- if let Some(child_index) = mapping.child_index {
99- // Position cursor at child index in the element
100- range.set_start(&container, child_index as u32)?;
101- } else {
102- // Position cursor in text content
103- let container_element = container.dyn_into::<web_sys::HtmlElement>()?;
104- let offset_in_range = char_offset - mapping.char_range.start;
105- let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
106- let (text_node, node_offset) =
107- find_text_node_at_offset(&container_element, target_utf16_offset)?;
108- range.set_start(&text_node, node_offset as u32)?;
109- }
110-111- range.collapse_with_to_start(true);
112-113- selection.remove_all_ranges()?;
114- selection.add_range(&range)?;
115-116- Ok(())
117-}
118-119-/// Find text node at given UTF-16 offset within element.
120-///
121-/// Walks all text nodes in the container, accumulating their UTF-16 lengths
122-/// until we find the node containing the target offset.
123-/// Skips text nodes inside contenteditable="false" elements (like embeds).
124-///
125-/// Returns (text_node, offset_within_node).
126-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
127-fn find_text_node_at_offset(
128- container: &web_sys::HtmlElement,
129- target_utf16_offset: usize,
130-) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> {
131- let document = web_sys::window()
132- .ok_or("no window")?
133- .document()
134- .ok_or("no document")?;
135-136- // Use SHOW_ALL to see element boundaries for tracking non-editable regions
137- let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?;
138-139- let mut accumulated_utf16 = 0;
140- let mut last_node: Option<web_sys::Node> = None;
141- let mut skip_until_exit: Option<web_sys::Element> = None;
142-143- while let Some(node) = walker.next_node()? {
144- // Check if we've exited the non-editable subtree
145- if let Some(ref skip_elem) = skip_until_exit {
146- if !skip_elem.contains(Some(&node)) {
147- skip_until_exit = None;
148- }
149- }
150-151- // Check if entering a non-editable element
152- if skip_until_exit.is_none() {
153- if let Some(element) = node.dyn_ref::<web_sys::Element>() {
154- if element.get_attribute("contenteditable").as_deref() == Some("false") {
155- skip_until_exit = Some(element.clone());
156- continue;
157- }
158- }
159- }
160-161- // Skip everything inside non-editable regions
162- if skip_until_exit.is_some() {
163- continue;
164- }
165-166- // Only process text nodes
167- if node.node_type() != web_sys::Node::TEXT_NODE {
168- continue;
169- }
170-171- last_node = Some(node.clone());
172-173- if let Some(text) = node.text_content() {
174- let text_len = text.encode_utf16().count();
175-176- // Found the node containing target offset
177- if accumulated_utf16 + text_len >= target_utf16_offset {
178- let offset_in_node = target_utf16_offset - accumulated_utf16;
179- return Ok((node, offset_in_node));
180- }
181-182- accumulated_utf16 += text_len;
183- }
184- }
185-186- // Fallback: return last node at its end
187- // This handles cursor at end of document
188- if let Some(node) = last_node {
189- if let Some(text) = node.text_content() {
190- let text_len = text.encode_utf16().count();
191- return Ok((node, text_len));
192- }
193- }
194-195- Err("no text node found in container".into())
196-}
197-198-// CursorRect is imported from weaver_editor_core.
199200/// Get screen coordinates for a character offset in the editor.
201///
···204pub fn get_cursor_rect(
205 char_offset: usize,
206 offset_map: &[OffsetMapping],
207- editor_id: &str,
208) -> Option<CursorRect> {
00209 if offset_map.is_empty() {
210 return None;
211 }
212213- // Find mapping for this position
214 let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
215 Some((m, _)) => (m, char_offset),
216 None => return None,
···219 let window = web_sys::window()?;
220 let document = window.document()?;
221222- // Get container element
223 let container = document.get_element_by_id(&mapping.node_id).or_else(|| {
224 let selector = format!("[data-node-id='{}']", mapping.node_id);
225 document.query_selector(&selector).ok().flatten()
···227228 let range = document.create_range().ok()?;
229230- // Position the range at the character offset
231 if let Some(child_index) = mapping.child_index {
232 range.set_start(&container, child_index as u32).ok()?;
233 } else {
···236 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
237238 if let Ok((text_node, node_offset)) =
239- find_text_node_at_offset(&container_element, target_utf16_offset)
240 {
241 range.set_start(&text_node, node_offset as u32).ok()?;
242 } else {
···246247 range.collapse_with_to_start(true);
248249- // Get the bounding rect
250 let rect = range.get_bounding_client_rect();
251 Some(CursorRect {
252 x: rect.x(),
253 y: rect.y(),
254- height: rect.height().max(16.0), // Minimum height for empty lines
255 })
256}
257···285 None
286}
287288-// SelectionRect is imported from weaver_editor_core.
289-290/// Get screen rectangles for a selection range, relative to editor.
291///
292/// Returns multiple rects if selection spans multiple lines.
···314 };
315 let editor_rect = editor.get_bounding_client_rect();
316317- // Find mappings for start and end
318 let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else {
319 return vec![];
320 };
···322 return vec![];
323 };
324325- // Get containers
326 let start_container = document
327 .get_element_by_id(&start_mapping.node_id)
328 .or_else(|| {
···340 return vec![];
341 };
342343- // Create range
344 let Ok(range) = document.create_range() else {
345 return vec![];
346 };
347348- // Set start
349 if let Some(child_index) = start_mapping.child_index {
350 let _ = range.set_start(&start_container, child_index as u32);
351 } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>()
···353 let offset_in_range = start - start_mapping.char_range.start;
354 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range;
355 if let Ok((text_node, node_offset)) =
356- find_text_node_at_offset(&container_element, target_utf16_offset)
357 {
358 let _ = range.set_start(&text_node, node_offset as u32);
359 }
360 }
361362- // Set end
363 if let Some(child_index) = end_mapping.child_index {
364 let _ = range.set_end(&end_container, child_index as u32);
365 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() {
366 let offset_in_range = end - end_mapping.char_range.start;
367 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range;
368 if let Ok((text_node, node_offset)) =
369- find_text_node_at_offset(&container_element, target_utf16_offset)
370 {
371 let _ = range.set_end(&text_node, node_offset as u32);
372 }
373 }
374375- // Get all rects (one per line)
376 let Some(rects) = range.get_client_rects() else {
377 return vec![];
378 };
···1+//! Cursor position operations.
2//!
3+//! Re-exports from browser crate with app-specific adapters.
000000000045+pub use weaver_editor_browser::restore_cursor_position;
6+pub use weaver_editor_core::{CursorRect, OffsetMapping, SelectionRect};
708#[cfg(all(target_family = "wasm", target_os = "unknown"))]
9+use weaver_editor_core::{SnapDirection, find_mapping_for_char};
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001011/// Get screen coordinates for a character offset in the editor.
12///
···15pub fn get_cursor_rect(
16 char_offset: usize,
17 offset_map: &[OffsetMapping],
18+ _editor_id: &str,
19) -> Option<CursorRect> {
20+ use wasm_bindgen::JsCast;
21+22 if offset_map.is_empty() {
23 return None;
24 }
25026 let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
27 Some((m, _)) => (m, char_offset),
28 None => return None,
···31 let window = web_sys::window()?;
32 let document = window.document()?;
33034 let container = document.get_element_by_id(&mapping.node_id).or_else(|| {
35 let selector = format!("[data-node-id='{}']", mapping.node_id);
36 document.query_selector(&selector).ok().flatten()
···3839 let range = document.create_range().ok()?;
40041 if let Some(child_index) = mapping.child_index {
42 range.set_start(&container, child_index as u32).ok()?;
43 } else {
···46 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
4748 if let Ok((text_node, node_offset)) =
49+ weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset)
50 {
51 range.set_start(&text_node, node_offset as u32).ok()?;
52 } else {
···5657 range.collapse_with_to_start(true);
58059 let rect = range.get_bounding_client_rect();
60 Some(CursorRect {
61 x: rect.x(),
62 y: rect.y(),
63+ height: rect.height().max(16.0),
64 })
65}
66···94 None
95}
960097/// Get screen rectangles for a selection range, relative to editor.
98///
99/// Returns multiple rects if selection spans multiple lines.
···121 };
122 let editor_rect = editor.get_bounding_client_rect();
1230124 let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else {
125 return vec![];
126 };
···128 return vec![];
129 };
1300131 let start_container = document
132 .get_element_by_id(&start_mapping.node_id)
133 .or_else(|| {
···145 return vec![];
146 };
1470148 let Ok(range) = document.create_range() else {
149 return vec![];
150 };
1510152 if let Some(child_index) = start_mapping.child_index {
153 let _ = range.set_start(&start_container, child_index as u32);
154 } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>()
···156 let offset_in_range = start - start_mapping.char_range.start;
157 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range;
158 if let Ok((text_node, node_offset)) =
159+ weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset)
160 {
161 let _ = range.set_start(&text_node, node_offset as u32);
162 }
163 }
1640165 if let Some(child_index) = end_mapping.child_index {
166 let _ = range.set_end(&end_container, child_index as u32);
167 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() {
168 let offset_in_range = end - end_mapping.char_range.start;
169 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range;
170 if let Ok((text_node, node_offset)) =
171+ weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset)
172 {
173 let _ = range.set_end(&text_node, node_offset as u32);
174 }
175 }
1760177 let Some(rects) = range.get_client_rects() else {
178 return vec![];
179 };
···2//!
3//! Handles syncing cursor/selection state between the browser DOM and our
4//! internal document model, and updating paragraph DOM elements.
0056#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
7use super::cursor::restore_cursor_position;
8#[allow(unused_imports)]
9use super::document::{EditorDocument, Selection};
10-#[allow(unused_imports)]
11-use weaver_editor_core::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position};
12use super::paragraph::ParagraphRender;
13#[allow(unused_imports)]
14use dioxus::prelude::*;
0000001516/// Sync internal cursor and selection state from browser DOM selection.
17///
···123 tracing::warn!("Could not map DOM selection to rope offsets");
124 }
125 }
126-}
127-128-/// Convert a DOM position (node + offset) to a rope char offset using offset maps.
129-///
130-/// The `direction_hint` is used when snapping from invisible content to determine
131-/// which direction to prefer.
132-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
133-pub fn dom_position_to_text_offset(
134- dom_document: &web_sys::Document,
135- editor_element: &web_sys::Element,
136- node: &web_sys::Node,
137- offset_in_text_node: usize,
138- paragraphs: &[ParagraphRender],
139- direction_hint: Option<SnapDirection>,
140-) -> Option<usize> {
141- use wasm_bindgen::JsCast;
142-143- // Find the containing element with a node ID (walk up from text node)
144- let mut current_node = node.clone();
145- let mut walked_from: Option<web_sys::Node> = None; // Track the child we walked up from
146- let node_id = loop {
147- let node_name = current_node.node_name();
148- let node_id_attr = current_node
149- .dyn_ref::<web_sys::Element>()
150- .and_then(|e| e.get_attribute("id"));
151- tracing::trace!(
152- node_name = %node_name,
153- node_id_attr = ?node_id_attr,
154- "dom_position_to_text_offset: walk-up iteration"
155- );
156-157- if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
158- if element == editor_element {
159- // Selection is on the editor container itself
160- //
161- // IMPORTANT: If we WALKED UP to the editor from a descendant,
162- // offset_in_text_node is the offset within that descendant, NOT the
163- // child index in the editor. We need to find which paragraph contains
164- // the node we walked from.
165- if let Some(ref walked_node) = walked_from {
166- // We walked up from a descendant - find which paragraph it belongs to
167- tracing::debug!(
168- walked_from_node_name = %walked_node.node_name(),
169- "dom_position_to_text_offset: walked up to editor from descendant"
170- );
171-172- // Find paragraph containing this node by checking paragraph wrapper divs
173- for (idx, para) in paragraphs.iter().enumerate() {
174- if let Some(para_elem) = dom_document.get_element_by_id(¶.id) {
175- let para_node: &web_sys::Node = para_elem.as_ref();
176- if para_node.contains(Some(walked_node)) {
177- // Found the paragraph - return its start
178- tracing::trace!(
179- para_id = %para.id,
180- para_idx = idx,
181- char_start = para.char_range.start,
182- "dom_position_to_text_offset: found containing paragraph"
183- );
184- return Some(para.char_range.start);
185- }
186- }
187- }
188- // Couldn't find containing paragraph, fall through
189- tracing::warn!(
190- "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"
191- );
192- break None;
193- }
194-195- // Selection is directly on the editor container (e.g., Cmd+A select all)
196- // Return boundary position based on offset:
197- // offset 0 = start of editor, offset == child count = end of editor
198- let child_count = editor_element.child_element_count() as usize;
199- if offset_in_text_node == 0 {
200- return Some(0); // Start of document
201- } else if offset_in_text_node >= child_count {
202- // End of document - find last paragraph's end
203- return paragraphs.last().map(|p| p.char_range.end);
204- }
205- break None;
206- }
207-208- let id = element
209- .get_attribute("id")
210- .or_else(|| element.get_attribute("data-node-id"));
211-212- if let Some(id) = id {
213- // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs
214- let is_node_id = id.starts_with('n') || id.contains("-n");
215- tracing::trace!(
216- id = %id,
217- is_node_id,
218- starts_with_n = id.starts_with('n'),
219- contains_dash_n = id.contains("-n"),
220- "dom_position_to_text_offset: checking ID pattern"
221- );
222- if is_node_id {
223- break Some(id);
224- }
225- }
226- }
227-228- walked_from = Some(current_node.clone());
229- current_node = current_node.parent_node()?;
230- };
231-232- let node_id = node_id?;
233-234- let container = dom_document.get_element_by_id(&node_id).or_else(|| {
235- let selector = format!("[data-node-id='{}']", node_id);
236- dom_document.query_selector(&selector).ok().flatten()
237- })?;
238-239- // Calculate UTF-16 offset from start of container to the position
240- // Skip text nodes inside contenteditable="false" elements (like embeds)
241- let mut utf16_offset_in_container = 0;
242-243- // Check if the node IS the container element itself (not a text node descendant)
244- // In this case, offset_in_text_node is actually a child index, not a character offset
245- let node_is_container = node
246- .dyn_ref::<web_sys::Element>()
247- .map(|e| e == &container)
248- .unwrap_or(false);
249-250- if node_is_container {
251- // offset_in_text_node is a child index - count text content up to that child
252- let child_index = offset_in_text_node;
253- let children = container.child_nodes();
254- let mut text_counted = 0usize;
255-256- for i in 0..child_index.min(children.length() as usize) {
257- if let Some(child) = children.get(i as u32) {
258- if let Some(text) = child.text_content() {
259- text_counted += text.encode_utf16().count();
260- }
261- }
262- }
263- utf16_offset_in_container = text_counted;
264-265- tracing::debug!(
266- child_index,
267- utf16_offset = utf16_offset_in_container,
268- "dom_position_to_text_offset: node is container, using child index"
269- );
270- } else {
271- // Normal case: node is a text node, walk to find it
272- // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions
273- if let Ok(walker) =
274- dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF)
275- {
276- // Track the non-editable element we're inside (if any)
277- let mut skip_until_exit: Option<web_sys::Element> = None;
278-279- while let Ok(Some(dom_node)) = walker.next_node() {
280- // Check if we've exited the non-editable subtree
281- if let Some(ref skip_elem) = skip_until_exit {
282- if !skip_elem.contains(Some(&dom_node)) {
283- skip_until_exit = None;
284- }
285- }
286-287- // Check if entering a non-editable element
288- if skip_until_exit.is_none() {
289- if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
290- if element.get_attribute("contenteditable").as_deref() == Some("false") {
291- skip_until_exit = Some(element.clone());
292- continue;
293- }
294- }
295- }
296-297- // Skip everything inside non-editable regions
298- if skip_until_exit.is_some() {
299- continue;
300- }
301-302- // Only process text nodes
303- if dom_node.node_type() == web_sys::Node::TEXT_NODE {
304- if &dom_node == node {
305- utf16_offset_in_container += offset_in_text_node;
306- break;
307- }
308-309- if let Some(text) = dom_node.text_content() {
310- utf16_offset_in_container += text.encode_utf16().count();
311- }
312- }
313- }
314- }
315- }
316-317- // Log what we're looking for
318- tracing::trace!(
319- node_id = %node_id,
320- utf16_offset = utf16_offset_in_container,
321- num_paragraphs = paragraphs.len(),
322- "dom_position_to_text_offset: looking up mapping"
323- );
324-325- for para in paragraphs {
326- for mapping in ¶.offset_map {
327- if mapping.node_id == node_id {
328- let mapping_start = mapping.char_offset_in_node;
329- let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
330-331- tracing::trace!(
332- mapping_node_id = %mapping.node_id,
333- mapping_start,
334- mapping_end,
335- char_range_start = mapping.char_range.start,
336- char_range_end = mapping.char_range.end,
337- "dom_position_to_text_offset: found matching node_id"
338- );
339-340- if utf16_offset_in_container >= mapping_start
341- && utf16_offset_in_container <= mapping_end
342- {
343- let offset_in_mapping = utf16_offset_in_container - mapping_start;
344- let char_offset = mapping.char_range.start + offset_in_mapping;
345-346- tracing::trace!(
347- node_id = %node_id,
348- utf16_offset = utf16_offset_in_container,
349- mapping_start,
350- mapping_end,
351- offset_in_mapping,
352- char_range_start = mapping.char_range.start,
353- char_offset,
354- "dom_position_to_text_offset: MATCHED mapping"
355- );
356-357- // Check if this position is valid (not on invisible content)
358- if is_valid_cursor_position(¶.offset_map, char_offset) {
359- return Some(char_offset);
360- }
361-362- // Position is on invisible content, snap to nearest valid
363- if let Some(snapped) =
364- find_nearest_valid_position(¶.offset_map, char_offset, direction_hint)
365- {
366- return Some(snapped.char_offset());
367- }
368-369- // Fallback to original if no snap target
370- return Some(char_offset);
371- }
372- }
373- }
374- }
375-376- // No mapping found - try to find any valid position in paragraphs
377- // This handles clicks on non-text elements like images
378- for para in paragraphs {
379- if let Some(snapped) =
380- find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
381- {
382- return Some(snapped.char_offset());
383- }
384- }
385-386- None
387}
388389#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
···2//!
3//! Handles syncing cursor/selection state between the browser DOM and our
4//! internal document model, and updating paragraph DOM elements.
5+//!
6+//! The core DOM position conversion is provided by `weaver_editor_browser`.
78#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
9use super::cursor::restore_cursor_position;
10#[allow(unused_imports)]
11use super::document::{EditorDocument, Selection};
0012use super::paragraph::ParagraphRender;
13#[allow(unused_imports)]
14use dioxus::prelude::*;
15+#[allow(unused_imports)]
16+use weaver_editor_core::SnapDirection;
17+18+// Re-export the DOM position conversion from browser crate.
19+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
20+pub use weaver_editor_browser::dom_position_to_text_offset;
2122/// Sync internal cursor and selection state from browser DOM selection.
23///
···129 tracing::warn!("Could not map DOM selection to rope offsets");
130 }
131 }
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000132}
133134#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
···45use wasm_bindgen::JsCast;
6use weaver_editor_core::{
7+ CursorPlatform, CursorRect, OffsetMapping, ParagraphRender, PlatformError, SelectionRect,
8+ SnapDirection, find_mapping_for_char, find_nearest_valid_position,
9};
1011/// Browser-based cursor platform implementation.
···33 fn restore_cursor(
34 &self,
35 char_offset: usize,
36+ paragraphs: &[ParagraphRender],
37 snap_direction: Option<SnapDirection>,
38 ) -> Result<(), PlatformError> {
39+ // Find the paragraph containing this offset and use its offset map.
40+ let offset_map = find_offset_map_for_char(paragraphs, char_offset);
41 restore_cursor_position(char_offset, offset_map, snap_direction)
42 }
4344 fn get_cursor_rect(
45 &self,
46 char_offset: usize,
47+ paragraphs: &[ParagraphRender],
48 ) -> Option<CursorRect> {
49+ let offset_map = find_offset_map_for_char(paragraphs, char_offset);
50 get_cursor_rect_impl(char_offset, offset_map)
51 }
5253 fn get_cursor_rect_relative(
54 &self,
55 char_offset: usize,
56+ paragraphs: &[ParagraphRender],
57 ) -> Option<CursorRect> {
58+ let cursor_rect = self.get_cursor_rect(char_offset, paragraphs)?;
5960 let window = web_sys::window()?;
61 let document = window.document()?;
···73 &self,
74 start: usize,
75 end: usize,
76+ paragraphs: &[ParagraphRender],
77 ) -> Vec<SelectionRect> {
78+ // For selection, we need all offset maps since selection can span paragraphs.
79+ let all_maps: Vec<_> = paragraphs
80+ .iter()
81+ .flat_map(|p| p.offset_map.iter())
82+ .collect();
83+ let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect();
84+ get_selection_rects_impl(start, end, &borrowed, &self.editor_id)
85 }
86+}
87+88+/// Find the offset map for a character offset from paragraphs.
89+///
90+/// Returns the offset map of the paragraph containing the given offset,
91+/// or an empty slice if no paragraph contains it.
92+fn find_offset_map_for_char(
93+ paragraphs: &[ParagraphRender],
94+ char_offset: usize,
95+) -> &[OffsetMapping] {
96+ for para in paragraphs {
97+ if para.char_range.start <= char_offset && char_offset <= para.char_range.end {
98+ return ¶.offset_map;
99+ }
100+ }
101+ // Fallback: if offset is past the end, use the last paragraph.
102+ paragraphs
103+ .last()
104+ .map(|p| p.offset_map.as_slice())
105+ .unwrap_or(&[])
106}
107108/// Restore cursor position in the DOM after re-render.
···209}
210211/// Find text node at given UTF-16 offset within element.
212+pub fn find_text_node_at_offset(
213 container: &web_sys::HtmlElement,
214 target_utf16_offset: usize,
215) -> Result<(web_sys::Node, usize), PlatformError> {
+122-43
crates/weaver-editor-browser/src/dom_sync.rs
···56use wasm_bindgen::JsCast;
7use weaver_editor_core::{
8- CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position,
09};
1011use crate::cursor::restore_cursor_position;
···46impl CursorSync for BrowserCursorSync {
47 fn sync_cursor_from_platform<F, G>(
48 &self,
49- offset_map: &[OffsetMapping],
50 direction_hint: Option<SnapDirection>,
51 on_cursor: F,
52 on_selection: G,
···54 F: FnOnce(usize),
55 G: FnOnce(usize, usize),
56 {
57- if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint)
58 {
59 match result {
60 CursorSyncResult::Cursor(offset) => on_cursor(offset),
···74/// Sync cursor state from DOM selection, returning the result.
75///
76/// This is the core implementation that reads the browser's selection state
77-/// and converts it to character offsets using the offset map.
78pub fn sync_cursor_from_dom_impl(
79 editor_id: &str,
80- offset_map: &[OffsetMapping],
81 direction_hint: Option<SnapDirection>,
82) -> Option<CursorSyncResult> {
83- if offset_map.is_empty() {
84 return Some(CursorSyncResult::None);
85 }
86···100 &editor_element,
101 &anchor_node,
102 anchor_offset,
103- offset_map,
104 direction_hint,
105 );
106 let focus_char = dom_position_to_text_offset(
···108 &editor_element,
109 &focus_node,
110 focus_offset,
111- offset_map,
112 direction_hint,
113 );
114···130/// Convert a DOM position (node + offset) to a text char offset.
131///
132/// Walks up from the node to find a container with a node ID, then uses
133-/// the offset map to convert the UTF-16 offset to a character offset.
00134pub fn dom_position_to_text_offset(
135 dom_document: &web_sys::Document,
136 editor_element: &web_sys::Element,
137 node: &web_sys::Node,
138 offset_in_text_node: usize,
139- offset_map: &[OffsetMapping],
140 direction_hint: Option<SnapDirection>,
141) -> Option<usize> {
142 // Find the containing element with a node ID (walk up from text node).
···144 let mut walked_from: Option<web_sys::Node> = None;
145146 let node_id = loop {
0000000000147 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
148 if element == editor_element {
149 // Selection is on the editor container itself.
000150 if let Some(ref walked_node) = walked_from {
151- // We walked up from a descendant - find which mapping it belongs to.
152- for mapping in offset_map {
153- if let Some(elem) = dom_document.get_element_by_id(&mapping.node_id) {
154- let elem_node: &web_sys::Node = elem.as_ref();
155- if elem_node.contains(Some(walked_node)) {
156- return Some(mapping.char_range.start);
00000000000157 }
158 }
159 }
000160 break None;
161 }
162···165 if offset_in_text_node == 0 {
166 return Some(0);
167 } else if offset_in_text_node >= child_count {
168- return offset_map.last().map(|m| m.char_range.end);
169 }
170 break None;
171 }
···175 .or_else(|| element.get_attribute("data-node-id"));
176177 if let Some(id) = id {
0178 let is_node_id = id.starts_with('n') || id.contains("-n");
0000000179 if is_node_id {
180 break Some(id);
181 }
···202 .unwrap_or(false);
203204 if node_is_container {
205- // offset_in_text_node is a child index.
206 let child_index = offset_in_text_node;
207 let children = container.child_nodes();
208 let mut text_counted = 0usize;
···215 }
216 }
217 utf16_offset_in_container = text_counted;
000000218 } else {
219 // Normal case: node is a text node, walk to find it.
220 if let Ok(walker) =
···256 }
257 }
258259- // Look up the offset in the offset map.
260- for mapping in offset_map {
261- if mapping.node_id == node_id {
262- let mapping_start = mapping.char_offset_in_node;
263- let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
00264265- if utf16_offset_in_container >= mapping_start
266- && utf16_offset_in_container <= mapping_end
267- {
268- let offset_in_mapping = utf16_offset_in_container - mapping_start;
269- let char_offset = mapping.char_range.start + offset_in_mapping;
0270271- // Check if position is valid (not on invisible content).
272- if is_valid_cursor_position(offset_map, char_offset) {
273- return Some(char_offset);
274- }
0000275276- // Position is on invisible content, snap to nearest valid.
277- if let Some(snapped) =
278- find_nearest_valid_position(offset_map, char_offset, direction_hint)
279 {
280- return Some(snapped.char_offset());
281- }
282283- return Some(char_offset);
0000000000000000000000000284 }
285 }
286 }
287288- // No mapping found - try to find any valid position.
289- if let Some(snapped) = find_nearest_valid_position(offset_map, 0, direction_hint) {
290- return Some(snapped.char_offset());
0000291 }
292293 None
···356 for new_para in new_paragraphs.iter() {
357 let para_id = new_para.id;
358 let new_hash = format!("{:x}", new_para.source_hash);
359- let is_cursor_para = new_para.char_range.start <= cursor_offset
360- && cursor_offset <= new_para.char_range.end;
361362 if let Some(existing_elem) = old_elements.remove(para_id) {
363 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
···56use wasm_bindgen::JsCast;
7use weaver_editor_core::{
8+ CursorSync, OffsetMapping, ParagraphRender, SnapDirection, find_nearest_valid_position,
9+ is_valid_cursor_position,
10};
1112use crate::cursor::restore_cursor_position;
···47impl CursorSync for BrowserCursorSync {
48 fn sync_cursor_from_platform<F, G>(
49 &self,
50+ paragraphs: &[ParagraphRender],
51 direction_hint: Option<SnapDirection>,
52 on_cursor: F,
53 on_selection: G,
···55 F: FnOnce(usize),
56 G: FnOnce(usize, usize),
57 {
58+ if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, paragraphs, direction_hint)
59 {
60 match result {
61 CursorSyncResult::Cursor(offset) => on_cursor(offset),
···75/// Sync cursor state from DOM selection, returning the result.
76///
77/// This is the core implementation that reads the browser's selection state
78+/// and converts it to character offsets using paragraph offset maps.
79pub fn sync_cursor_from_dom_impl(
80 editor_id: &str,
81+ paragraphs: &[ParagraphRender],
82 direction_hint: Option<SnapDirection>,
83) -> Option<CursorSyncResult> {
84+ if paragraphs.is_empty() {
85 return Some(CursorSyncResult::None);
86 }
87···101 &editor_element,
102 &anchor_node,
103 anchor_offset,
104+ paragraphs,
105 direction_hint,
106 );
107 let focus_char = dom_position_to_text_offset(
···109 &editor_element,
110 &focus_node,
111 focus_offset,
112+ paragraphs,
113 direction_hint,
114 );
115···131/// Convert a DOM position (node + offset) to a text char offset.
132///
133/// Walks up from the node to find a container with a node ID, then uses
134+/// the paragraph offset maps to convert the UTF-16 offset to a character offset.
135+/// The `direction_hint` is used when snapping from invisible content to determine
136+/// which direction to prefer.
137pub fn dom_position_to_text_offset(
138 dom_document: &web_sys::Document,
139 editor_element: &web_sys::Element,
140 node: &web_sys::Node,
141 offset_in_text_node: usize,
142+ paragraphs: &[ParagraphRender],
143 direction_hint: Option<SnapDirection>,
144) -> Option<usize> {
145 // Find the containing element with a node ID (walk up from text node).
···147 let mut walked_from: Option<web_sys::Node> = None;
148149 let node_id = loop {
150+ let node_name = current_node.node_name();
151+ let node_id_attr = current_node
152+ .dyn_ref::<web_sys::Element>()
153+ .and_then(|e| e.get_attribute("id"));
154+ tracing::trace!(
155+ node_name = %node_name,
156+ node_id_attr = ?node_id_attr,
157+ "dom_position_to_text_offset: walk-up iteration"
158+ );
159+160 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
161 if element == editor_element {
162 // Selection is on the editor container itself.
163+ // IMPORTANT: If we WALKED UP to the editor from a descendant,
164+ // offset_in_text_node is the offset within that descendant, NOT the
165+ // child index in the editor.
166 if let Some(ref walked_node) = walked_from {
167+ tracing::debug!(
168+ walked_from_node_name = %walked_node.node_name(),
169+ "dom_position_to_text_offset: walked up to editor from descendant"
170+ );
171+172+ // Find paragraph containing this node by checking paragraph wrapper divs.
173+ for (idx, para) in paragraphs.iter().enumerate() {
174+ if let Some(para_elem) = dom_document.get_element_by_id(¶.id) {
175+ let para_node: &web_sys::Node = para_elem.as_ref();
176+ if para_node.contains(Some(walked_node)) {
177+ tracing::trace!(
178+ para_id = %para.id,
179+ para_idx = idx,
180+ char_start = para.char_range.start,
181+ "dom_position_to_text_offset: found containing paragraph"
182+ );
183+ return Some(para.char_range.start);
184 }
185 }
186 }
187+ tracing::warn!(
188+ "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"
189+ );
190 break None;
191 }
192···195 if offset_in_text_node == 0 {
196 return Some(0);
197 } else if offset_in_text_node >= child_count {
198+ return paragraphs.last().map(|p| p.char_range.end);
199 }
200 break None;
201 }
···205 .or_else(|| element.get_attribute("data-node-id"));
206207 if let Some(id) = id {
208+ // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs.
209 let is_node_id = id.starts_with('n') || id.contains("-n");
210+ tracing::trace!(
211+ id = %id,
212+ is_node_id,
213+ starts_with_n = id.starts_with('n'),
214+ contains_dash_n = id.contains("-n"),
215+ "dom_position_to_text_offset: checking ID pattern"
216+ );
217 if is_node_id {
218 break Some(id);
219 }
···240 .unwrap_or(false);
241242 if node_is_container {
243+ // offset_in_text_node is a child index - count text content up to that child.
244 let child_index = offset_in_text_node;
245 let children = container.child_nodes();
246 let mut text_counted = 0usize;
···253 }
254 }
255 utf16_offset_in_container = text_counted;
256+257+ tracing::debug!(
258+ child_index,
259+ utf16_offset = utf16_offset_in_container,
260+ "dom_position_to_text_offset: node is container, using child index"
261+ );
262 } else {
263 // Normal case: node is a text node, walk to find it.
264 if let Ok(walker) =
···300 }
301 }
302303+ // Log what we're looking for.
304+ tracing::trace!(
305+ node_id = %node_id,
306+ utf16_offset = utf16_offset_in_container,
307+ num_paragraphs = paragraphs.len(),
308+ "dom_position_to_text_offset: looking up mapping"
309+ );
310311+ // Look up the offset in paragraph offset maps.
312+ for para in paragraphs {
313+ for mapping in ¶.offset_map {
314+ if mapping.node_id == node_id {
315+ let mapping_start = mapping.char_offset_in_node;
316+ let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
317318+ tracing::trace!(
319+ mapping_node_id = %mapping.node_id,
320+ mapping_start,
321+ mapping_end,
322+ char_range_start = mapping.char_range.start,
323+ char_range_end = mapping.char_range.end,
324+ "dom_position_to_text_offset: found matching node_id"
325+ );
326327+ if utf16_offset_in_container >= mapping_start
328+ && utf16_offset_in_container <= mapping_end
0329 {
330+ let offset_in_mapping = utf16_offset_in_container - mapping_start;
331+ let char_offset = mapping.char_range.start + offset_in_mapping;
332333+ tracing::trace!(
334+ node_id = %node_id,
335+ utf16_offset = utf16_offset_in_container,
336+ mapping_start,
337+ mapping_end,
338+ offset_in_mapping,
339+ char_range_start = mapping.char_range.start,
340+ char_offset,
341+ "dom_position_to_text_offset: MATCHED mapping"
342+ );
343+344+ // Check if position is valid (not on invisible content).
345+ if is_valid_cursor_position(¶.offset_map, char_offset) {
346+ return Some(char_offset);
347+ }
348+349+ // Position is on invisible content, snap to nearest valid.
350+ if let Some(snapped) =
351+ find_nearest_valid_position(¶.offset_map, char_offset, direction_hint)
352+ {
353+ return Some(snapped.char_offset());
354+ }
355+356+ // Fallback to original if no snap target.
357+ return Some(char_offset);
358+ }
359 }
360 }
361 }
362363+ // No mapping found - try to find any valid position in paragraphs.
364+ for para in paragraphs {
365+ if let Some(snapped) =
366+ find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
367+ {
368+ return Some(snapped.char_offset());
369+ }
370 }
371372 None
···435 for new_para in new_paragraphs.iter() {
436 let para_id = new_para.id;
437 let new_hash = format!("{:x}", new_para.source_hash);
438+ let is_cursor_para =
439+ new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end;
440441 if let Some(existing_elem) = old_elements.remove(para_id) {
442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
+9-11
crates/weaver-editor-browser/src/events.rs
···4//! the `beforeinput` event and other DOM events.
56use wasm_bindgen::prelude::*;
7-use weaver_editor_core::{InputType, OffsetMapping, Range};
89use crate::dom_sync::dom_position_to_text_offset;
01011// === StaticRange binding ===
12//
···115 /// The data (text to insert, if any).
116 pub data: Option<String>,
117 /// Target range from getTargetRanges(), if available.
0118 pub target_range: Option<Range>,
119 /// Whether the event is part of an IME composition.
120 pub is_composing: bool,
121- /// Whether we're on Android.
122- pub is_android: bool,
123- /// Whether we're on Chrome.
124- pub is_chrome: bool,
125- /// Offset mappings for the document.
126- pub offset_map: &'a [OffsetMapping],
127}
128129/// Extract target range from a beforeinput event.
···132pub fn get_target_range_from_event(
133 event: &web_sys::InputEvent,
134 editor_id: &str,
135- offset_map: &[OffsetMapping],
136) -> Option<Range> {
137 use wasm_bindgen::JsCast;
138···157 &editor_element,
158 &start_container,
159 start_offset,
160- offset_map,
161 None,
162 )?;
163···166 &editor_element,
167 &end_container,
168 end_offset,
169- offset_map,
170 None,
171 )?;
172···337 // === Deletion ===
338 InputType::DeleteContentBackward => {
339 // Android Chrome workaround: backspace sometimes doesn't work properly.
340- if ctx.is_android && ctx.is_chrome && range.is_caret() {
341 let action = EditorAction::DeleteBackward { range };
342 return BeforeInputResult::DeferredCheck {
343 fallback_action: action,
···4//! the `beforeinput` event and other DOM events.
56use wasm_bindgen::prelude::*;
7+use weaver_editor_core::{InputType, ParagraphRender, Range};
89use crate::dom_sync::dom_position_to_text_offset;
10+use crate::platform::Platform;
1112// === StaticRange binding ===
13//
···116 /// The data (text to insert, if any).
117 pub data: Option<String>,
118 /// Target range from getTargetRanges(), if available.
119+ /// This is the range the browser wants to modify.
120 pub target_range: Option<Range>,
121 /// Whether the event is part of an IME composition.
122 pub is_composing: bool,
123+ /// Platform info for quirks handling.
124+ pub platform: &'a Platform,
0000125}
126127/// Extract target range from a beforeinput event.
···130pub fn get_target_range_from_event(
131 event: &web_sys::InputEvent,
132 editor_id: &str,
133+ paragraphs: &[ParagraphRender],
134) -> Option<Range> {
135 use wasm_bindgen::JsCast;
136···155 &editor_element,
156 &start_container,
157 start_offset,
158+ paragraphs,
159 None,
160 )?;
161···164 &editor_element,
165 &end_container,
166 end_offset,
167+ paragraphs,
168 None,
169 )?;
170···335 // === Deletion ===
336 InputType::DeleteContentBackward => {
337 // Android Chrome workaround: backspace sometimes doesn't work properly.
338+ if ctx.platform.android && ctx.platform.chrome && range.is_caret() {
339 let action = EditorAction::DeleteBackward { range };
340 return BeforeInputResult::DeferredCheck {
341 fallback_action: action,
+5-2
crates/weaver-editor-browser/src/lib.rs
···27pub mod visibility;
2829// Browser cursor implementation
30-pub use cursor::BrowserCursor;
3132// DOM sync types
33-pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData};
0003435// Event handling
36pub use events::{
···27pub mod visibility;
2829// Browser cursor implementation
30+pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position};
3132// DOM sync types
33+pub use dom_sync::{
34+ BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset,
35+ sync_cursor_from_dom_impl, update_paragraph_dom,
36+};
3738// Event handling
39pub use events::{