···2121use super::platform::Platform;
22222323// Re-export types from extracted crates.
2424+pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult};
2425pub use weaver_editor_core::{InputType, Range};
25262627#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
2728pub use weaver_editor_browser::StaticRange;
2828-2929-/// Result of handling a beforeinput event.
3030-#[derive(Debug, Clone)]
3131-#[allow(dead_code)]
3232-pub enum BeforeInputResult {
3333- /// Event was handled, prevent default browser behavior.
3434- Handled,
3535- /// Event should be handled by browser (e.g., during composition).
3636- PassThrough,
3737- /// Event was handled but requires async follow-up (e.g., paste).
3838- HandledAsync,
3939- /// Android backspace workaround: defer and check if browser handled it.
4040- DeferredCheck {
4141- /// The action to execute if browser didn't handle it.
4242- fallback_action: EditorAction,
4343- },
4444-}
4545-4646-/// Context for beforeinput handling.
4747-#[allow(dead_code)]
4848-pub struct BeforeInputContext<'a> {
4949- /// The input type.
5050- pub input_type: InputType,
5151- /// The data (text to insert, if any).
5252- pub data: Option<String>,
5353- /// Target range from getTargetRanges(), if available.
5454- /// This is the range the browser wants to modify.
5555- pub target_range: Option<Range>,
5656- /// Whether the event is part of an IME composition.
5757- pub is_composing: bool,
5858- /// Platform info for quirks handling.
5959- pub platform: &'a Platform,
6060-}
61296230/// Handle a beforeinput event.
6331///
+12-211
crates/weaver-app/src/components/editor/cursor.rs
···11-//! Cursor position restoration in the DOM.
11+//! Cursor position operations.
22//!
33-//! After re-rendering HTML, we need to restore the cursor to its original
44-//! position in the source text. This involves:
55-//! 1. Finding the offset mapping for the cursor's char position
66-//! 2. Getting the DOM element by node ID
77-//! 3. Walking text nodes to find the UTF-16 offset within the element
88-//! 4. Setting cursor with web_sys Selection API
99-1010-use weaver_editor_core::OffsetMapping;
1111-pub use weaver_editor_core::{CursorRect, SelectionRect};
1212-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1313-use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position};
33+//! Re-exports from browser crate with app-specific adapters.
1441515-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1616-use wasm_bindgen::JsCast;
55+pub use weaver_editor_browser::restore_cursor_position;
66+pub use weaver_editor_core::{CursorRect, OffsetMapping, SelectionRect};
1771818-/// Restore cursor position in the DOM after re-render.
198#[cfg(all(target_family = "wasm", target_os = "unknown"))]
2020-pub fn restore_cursor_position(
2121- char_offset: usize,
2222- offset_map: &[OffsetMapping],
2323- editor_id: &str,
2424- snap_direction: Option<SnapDirection>,
2525-) -> Result<(), wasm_bindgen::JsValue> {
2626- // Empty document - no cursor to restore
2727- if offset_map.is_empty() {
2828- return Ok(());
2929- }
3030-3131- // Bounds check using offset map
3232- let max_offset = offset_map
3333- .iter()
3434- .map(|m| m.char_range.end)
3535- .max()
3636- .unwrap_or(0);
3737- if char_offset > max_offset {
3838- tracing::warn!(
3939- "cursor offset {} > max mapping offset {}",
4040- char_offset,
4141- max_offset
4242- );
4343- // Don't error, just skip restoration - this can happen during edits
4444- return Ok(());
4545- }
4646-4747- // Find mapping for this cursor position, snapping if needed
4848- let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
4949- Some((m, false)) => (m, char_offset), // Valid position, use as-is
5050- Some((m, true)) => {
5151- // Position is on invisible content, snap to nearest valid
5252- if let Some(snapped) =
5353- find_nearest_valid_position(offset_map, char_offset, snap_direction)
5454- {
5555- tracing::trace!(
5656- target: "weaver::cursor",
5757- original_offset = char_offset,
5858- snapped_offset = snapped.char_offset(),
5959- direction = ?snapped.snapped,
6060- "snapping cursor from invisible content"
6161- );
6262- (snapped.mapping, snapped.char_offset())
6363- } else {
6464- // Fallback to original mapping if no valid snap target
6565- (m, char_offset)
6666- }
6767- }
6868- None => return Err("no mapping found for cursor offset".into()),
6969- };
7070-7171- tracing::trace!(
7272- target: "weaver::cursor",
7373- char_offset,
7474- node_id = %mapping.node_id,
7575- mapping_range = ?mapping.char_range,
7676- child_index = ?mapping.child_index,
7777- "restoring cursor position"
7878- );
7979-8080- // Get window and document
8181- let window = web_sys::window().ok_or("no window")?;
8282- let document = window.document().ok_or("no document")?;
8383-8484- // Get the container element by node ID (try id attribute first, then data-node-id)
8585- let container = document
8686- .get_element_by_id(&mapping.node_id)
8787- .or_else(|| {
8888- let selector = format!("[data-node-id='{}']", mapping.node_id);
8989- document.query_selector(&selector).ok().flatten()
9090- })
9191- .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
9292-9393- // Set selection using Range API
9494- let selection = window.get_selection()?.ok_or("no selection object")?;
9595- let range = document.create_range()?;
9696-9797- // Check if this is an element-based position (e.g., after <br />)
9898- if let Some(child_index) = mapping.child_index {
9999- // Position cursor at child index in the element
100100- range.set_start(&container, child_index as u32)?;
101101- } else {
102102- // Position cursor in text content
103103- let container_element = container.dyn_into::<web_sys::HtmlElement>()?;
104104- let offset_in_range = char_offset - mapping.char_range.start;
105105- let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
106106- let (text_node, node_offset) =
107107- find_text_node_at_offset(&container_element, target_utf16_offset)?;
108108- range.set_start(&text_node, node_offset as u32)?;
109109- }
110110-111111- range.collapse_with_to_start(true);
112112-113113- selection.remove_all_ranges()?;
114114- selection.add_range(&range)?;
115115-116116- Ok(())
117117-}
118118-119119-/// Find text node at given UTF-16 offset within element.
120120-///
121121-/// Walks all text nodes in the container, accumulating their UTF-16 lengths
122122-/// until we find the node containing the target offset.
123123-/// Skips text nodes inside contenteditable="false" elements (like embeds).
124124-///
125125-/// Returns (text_node, offset_within_node).
126126-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
127127-fn find_text_node_at_offset(
128128- container: &web_sys::HtmlElement,
129129- target_utf16_offset: usize,
130130-) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> {
131131- let document = web_sys::window()
132132- .ok_or("no window")?
133133- .document()
134134- .ok_or("no document")?;
135135-136136- // Use SHOW_ALL to see element boundaries for tracking non-editable regions
137137- let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?;
138138-139139- let mut accumulated_utf16 = 0;
140140- let mut last_node: Option<web_sys::Node> = None;
141141- let mut skip_until_exit: Option<web_sys::Element> = None;
142142-143143- while let Some(node) = walker.next_node()? {
144144- // Check if we've exited the non-editable subtree
145145- if let Some(ref skip_elem) = skip_until_exit {
146146- if !skip_elem.contains(Some(&node)) {
147147- skip_until_exit = None;
148148- }
149149- }
150150-151151- // Check if entering a non-editable element
152152- if skip_until_exit.is_none() {
153153- if let Some(element) = node.dyn_ref::<web_sys::Element>() {
154154- if element.get_attribute("contenteditable").as_deref() == Some("false") {
155155- skip_until_exit = Some(element.clone());
156156- continue;
157157- }
158158- }
159159- }
160160-161161- // Skip everything inside non-editable regions
162162- if skip_until_exit.is_some() {
163163- continue;
164164- }
165165-166166- // Only process text nodes
167167- if node.node_type() != web_sys::Node::TEXT_NODE {
168168- continue;
169169- }
170170-171171- last_node = Some(node.clone());
172172-173173- if let Some(text) = node.text_content() {
174174- let text_len = text.encode_utf16().count();
175175-176176- // Found the node containing target offset
177177- if accumulated_utf16 + text_len >= target_utf16_offset {
178178- let offset_in_node = target_utf16_offset - accumulated_utf16;
179179- return Ok((node, offset_in_node));
180180- }
181181-182182- accumulated_utf16 += text_len;
183183- }
184184- }
185185-186186- // Fallback: return last node at its end
187187- // This handles cursor at end of document
188188- if let Some(node) = last_node {
189189- if let Some(text) = node.text_content() {
190190- let text_len = text.encode_utf16().count();
191191- return Ok((node, text_len));
192192- }
193193- }
194194-195195- Err("no text node found in container".into())
196196-}
197197-198198-// CursorRect is imported from weaver_editor_core.
99+use weaver_editor_core::{SnapDirection, find_mapping_for_char};
1991020011/// Get screen coordinates for a character offset in the editor.
20112///
···20415pub fn get_cursor_rect(
20516 char_offset: usize,
20617 offset_map: &[OffsetMapping],
207207- editor_id: &str,
1818+ _editor_id: &str,
20819) -> Option<CursorRect> {
2020+ use wasm_bindgen::JsCast;
2121+20922 if offset_map.is_empty() {
21023 return None;
21124 }
21225213213- // Find mapping for this position
21426 let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) {
21527 Some((m, _)) => (m, char_offset),
21628 None => return None,
···21931 let window = web_sys::window()?;
22032 let document = window.document()?;
22133222222- // Get container element
22334 let container = document.get_element_by_id(&mapping.node_id).or_else(|| {
22435 let selector = format!("[data-node-id='{}']", mapping.node_id);
22536 document.query_selector(&selector).ok().flatten()
···2273822839 let range = document.create_range().ok()?;
22940230230- // Position the range at the character offset
23141 if let Some(child_index) = mapping.child_index {
23242 range.set_start(&container, child_index as u32).ok()?;
23343 } else {
···23646 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
2374723848 if let Ok((text_node, node_offset)) =
239239- find_text_node_at_offset(&container_element, target_utf16_offset)
4949+ weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset)
24050 {
24151 range.set_start(&text_node, node_offset as u32).ok()?;
24252 } else {
···2465624757 range.collapse_with_to_start(true);
24858249249- // Get the bounding rect
25059 let rect = range.get_bounding_client_rect();
25160 Some(CursorRect {
25261 x: rect.x(),
25362 y: rect.y(),
254254- height: rect.height().max(16.0), // Minimum height for empty lines
6363+ height: rect.height().max(16.0),
25564 })
25665}
25766···28594 None
28695}
28796288288-// SelectionRect is imported from weaver_editor_core.
289289-29097/// Get screen rectangles for a selection range, relative to editor.
29198///
29299/// Returns multiple rects if selection spans multiple lines.
···314121 };
315122 let editor_rect = editor.get_bounding_client_rect();
316123317317- // Find mappings for start and end
318124 let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else {
319125 return vec![];
320126 };
···322128 return vec![];
323129 };
324130325325- // Get containers
326131 let start_container = document
327132 .get_element_by_id(&start_mapping.node_id)
328133 .or_else(|| {
···340145 return vec![];
341146 };
342147343343- // Create range
344148 let Ok(range) = document.create_range() else {
345149 return vec![];
346150 };
347151348348- // Set start
349152 if let Some(child_index) = start_mapping.child_index {
350153 let _ = range.set_start(&start_container, child_index as u32);
351154 } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>()
···353156 let offset_in_range = start - start_mapping.char_range.start;
354157 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range;
355158 if let Ok((text_node, node_offset)) =
356356- find_text_node_at_offset(&container_element, target_utf16_offset)
159159+ weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset)
357160 {
358161 let _ = range.set_start(&text_node, node_offset as u32);
359162 }
360163 }
361164362362- // Set end
363165 if let Some(child_index) = end_mapping.child_index {
364166 let _ = range.set_end(&end_container, child_index as u32);
365167 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() {
366168 let offset_in_range = end - end_mapping.char_range.start;
367169 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range;
368170 if let Ok((text_node, node_offset)) =
369369- find_text_node_at_offset(&container_element, target_utf16_offset)
171171+ weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset)
370172 {
371173 let _ = range.set_end(&text_node, node_offset as u32);
372174 }
373175 }
374176375375- // Get all rects (one per line)
376177 let Some(rects) = range.get_client_rects() else {
377178 return vec![];
378179 };
···22//!
33//! Handles syncing cursor/selection state between the browser DOM and our
44//! internal document model, and updating paragraph DOM elements.
55+//!
66+//! The core DOM position conversion is provided by `weaver_editor_browser`.
5768#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
79use super::cursor::restore_cursor_position;
810#[allow(unused_imports)]
911use super::document::{EditorDocument, Selection};
1010-#[allow(unused_imports)]
1111-use weaver_editor_core::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position};
1212use super::paragraph::ParagraphRender;
1313#[allow(unused_imports)]
1414use dioxus::prelude::*;
1515+#[allow(unused_imports)]
1616+use weaver_editor_core::SnapDirection;
1717+1818+// Re-export the DOM position conversion from browser crate.
1919+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
2020+pub use weaver_editor_browser::dom_position_to_text_offset;
15211622/// Sync internal cursor and selection state from browser DOM selection.
1723///
···123129 tracing::warn!("Could not map DOM selection to rope offsets");
124130 }
125131 }
126126-}
127127-128128-/// Convert a DOM position (node + offset) to a rope char offset using offset maps.
129129-///
130130-/// The `direction_hint` is used when snapping from invisible content to determine
131131-/// which direction to prefer.
132132-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
133133-pub fn dom_position_to_text_offset(
134134- dom_document: &web_sys::Document,
135135- editor_element: &web_sys::Element,
136136- node: &web_sys::Node,
137137- offset_in_text_node: usize,
138138- paragraphs: &[ParagraphRender],
139139- direction_hint: Option<SnapDirection>,
140140-) -> Option<usize> {
141141- use wasm_bindgen::JsCast;
142142-143143- // Find the containing element with a node ID (walk up from text node)
144144- let mut current_node = node.clone();
145145- let mut walked_from: Option<web_sys::Node> = None; // Track the child we walked up from
146146- let node_id = loop {
147147- let node_name = current_node.node_name();
148148- let node_id_attr = current_node
149149- .dyn_ref::<web_sys::Element>()
150150- .and_then(|e| e.get_attribute("id"));
151151- tracing::trace!(
152152- node_name = %node_name,
153153- node_id_attr = ?node_id_attr,
154154- "dom_position_to_text_offset: walk-up iteration"
155155- );
156156-157157- if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
158158- if element == editor_element {
159159- // Selection is on the editor container itself
160160- //
161161- // IMPORTANT: If we WALKED UP to the editor from a descendant,
162162- // offset_in_text_node is the offset within that descendant, NOT the
163163- // child index in the editor. We need to find which paragraph contains
164164- // the node we walked from.
165165- if let Some(ref walked_node) = walked_from {
166166- // We walked up from a descendant - find which paragraph it belongs to
167167- tracing::debug!(
168168- walked_from_node_name = %walked_node.node_name(),
169169- "dom_position_to_text_offset: walked up to editor from descendant"
170170- );
171171-172172- // Find paragraph containing this node by checking paragraph wrapper divs
173173- for (idx, para) in paragraphs.iter().enumerate() {
174174- if let Some(para_elem) = dom_document.get_element_by_id(¶.id) {
175175- let para_node: &web_sys::Node = para_elem.as_ref();
176176- if para_node.contains(Some(walked_node)) {
177177- // Found the paragraph - return its start
178178- tracing::trace!(
179179- para_id = %para.id,
180180- para_idx = idx,
181181- char_start = para.char_range.start,
182182- "dom_position_to_text_offset: found containing paragraph"
183183- );
184184- return Some(para.char_range.start);
185185- }
186186- }
187187- }
188188- // Couldn't find containing paragraph, fall through
189189- tracing::warn!(
190190- "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"
191191- );
192192- break None;
193193- }
194194-195195- // Selection is directly on the editor container (e.g., Cmd+A select all)
196196- // Return boundary position based on offset:
197197- // offset 0 = start of editor, offset == child count = end of editor
198198- let child_count = editor_element.child_element_count() as usize;
199199- if offset_in_text_node == 0 {
200200- return Some(0); // Start of document
201201- } else if offset_in_text_node >= child_count {
202202- // End of document - find last paragraph's end
203203- return paragraphs.last().map(|p| p.char_range.end);
204204- }
205205- break None;
206206- }
207207-208208- let id = element
209209- .get_attribute("id")
210210- .or_else(|| element.get_attribute("data-node-id"));
211211-212212- if let Some(id) = id {
213213- // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs
214214- let is_node_id = id.starts_with('n') || id.contains("-n");
215215- tracing::trace!(
216216- id = %id,
217217- is_node_id,
218218- starts_with_n = id.starts_with('n'),
219219- contains_dash_n = id.contains("-n"),
220220- "dom_position_to_text_offset: checking ID pattern"
221221- );
222222- if is_node_id {
223223- break Some(id);
224224- }
225225- }
226226- }
227227-228228- walked_from = Some(current_node.clone());
229229- current_node = current_node.parent_node()?;
230230- };
231231-232232- let node_id = node_id?;
233233-234234- let container = dom_document.get_element_by_id(&node_id).or_else(|| {
235235- let selector = format!("[data-node-id='{}']", node_id);
236236- dom_document.query_selector(&selector).ok().flatten()
237237- })?;
238238-239239- // Calculate UTF-16 offset from start of container to the position
240240- // Skip text nodes inside contenteditable="false" elements (like embeds)
241241- let mut utf16_offset_in_container = 0;
242242-243243- // Check if the node IS the container element itself (not a text node descendant)
244244- // In this case, offset_in_text_node is actually a child index, not a character offset
245245- let node_is_container = node
246246- .dyn_ref::<web_sys::Element>()
247247- .map(|e| e == &container)
248248- .unwrap_or(false);
249249-250250- if node_is_container {
251251- // offset_in_text_node is a child index - count text content up to that child
252252- let child_index = offset_in_text_node;
253253- let children = container.child_nodes();
254254- let mut text_counted = 0usize;
255255-256256- for i in 0..child_index.min(children.length() as usize) {
257257- if let Some(child) = children.get(i as u32) {
258258- if let Some(text) = child.text_content() {
259259- text_counted += text.encode_utf16().count();
260260- }
261261- }
262262- }
263263- utf16_offset_in_container = text_counted;
264264-265265- tracing::debug!(
266266- child_index,
267267- utf16_offset = utf16_offset_in_container,
268268- "dom_position_to_text_offset: node is container, using child index"
269269- );
270270- } else {
271271- // Normal case: node is a text node, walk to find it
272272- // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions
273273- if let Ok(walker) =
274274- dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF)
275275- {
276276- // Track the non-editable element we're inside (if any)
277277- let mut skip_until_exit: Option<web_sys::Element> = None;
278278-279279- while let Ok(Some(dom_node)) = walker.next_node() {
280280- // Check if we've exited the non-editable subtree
281281- if let Some(ref skip_elem) = skip_until_exit {
282282- if !skip_elem.contains(Some(&dom_node)) {
283283- skip_until_exit = None;
284284- }
285285- }
286286-287287- // Check if entering a non-editable element
288288- if skip_until_exit.is_none() {
289289- if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
290290- if element.get_attribute("contenteditable").as_deref() == Some("false") {
291291- skip_until_exit = Some(element.clone());
292292- continue;
293293- }
294294- }
295295- }
296296-297297- // Skip everything inside non-editable regions
298298- if skip_until_exit.is_some() {
299299- continue;
300300- }
301301-302302- // Only process text nodes
303303- if dom_node.node_type() == web_sys::Node::TEXT_NODE {
304304- if &dom_node == node {
305305- utf16_offset_in_container += offset_in_text_node;
306306- break;
307307- }
308308-309309- if let Some(text) = dom_node.text_content() {
310310- utf16_offset_in_container += text.encode_utf16().count();
311311- }
312312- }
313313- }
314314- }
315315- }
316316-317317- // Log what we're looking for
318318- tracing::trace!(
319319- node_id = %node_id,
320320- utf16_offset = utf16_offset_in_container,
321321- num_paragraphs = paragraphs.len(),
322322- "dom_position_to_text_offset: looking up mapping"
323323- );
324324-325325- for para in paragraphs {
326326- for mapping in ¶.offset_map {
327327- if mapping.node_id == node_id {
328328- let mapping_start = mapping.char_offset_in_node;
329329- let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
330330-331331- tracing::trace!(
332332- mapping_node_id = %mapping.node_id,
333333- mapping_start,
334334- mapping_end,
335335- char_range_start = mapping.char_range.start,
336336- char_range_end = mapping.char_range.end,
337337- "dom_position_to_text_offset: found matching node_id"
338338- );
339339-340340- if utf16_offset_in_container >= mapping_start
341341- && utf16_offset_in_container <= mapping_end
342342- {
343343- let offset_in_mapping = utf16_offset_in_container - mapping_start;
344344- let char_offset = mapping.char_range.start + offset_in_mapping;
345345-346346- tracing::trace!(
347347- node_id = %node_id,
348348- utf16_offset = utf16_offset_in_container,
349349- mapping_start,
350350- mapping_end,
351351- offset_in_mapping,
352352- char_range_start = mapping.char_range.start,
353353- char_offset,
354354- "dom_position_to_text_offset: MATCHED mapping"
355355- );
356356-357357- // Check if this position is valid (not on invisible content)
358358- if is_valid_cursor_position(¶.offset_map, char_offset) {
359359- return Some(char_offset);
360360- }
361361-362362- // Position is on invisible content, snap to nearest valid
363363- if let Some(snapped) =
364364- find_nearest_valid_position(¶.offset_map, char_offset, direction_hint)
365365- {
366366- return Some(snapped.char_offset());
367367- }
368368-369369- // Fallback to original if no snap target
370370- return Some(char_offset);
371371- }
372372- }
373373- }
374374- }
375375-376376- // No mapping found - try to find any valid position in paragraphs
377377- // This handles clicks on non-text elements like images
378378- for para in paragraphs {
379379- if let Some(snapped) =
380380- find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
381381- {
382382- return Some(snapped.char_offset());
383383- }
384384- }
385385-386386- None
387132}
388133389134#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
···11//! Platform detection for browser-specific workarounds.
22//!
33-//! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm.
44-55-use std::sync::OnceLock;
66-77-/// Cached platform detection results.
88-#[derive(Debug, Clone)]
99-#[allow(dead_code)]
1010-pub struct Platform {
1111- pub ios: bool,
1212- pub mac: bool,
1313- pub android: bool,
1414- pub chrome: bool,
1515- pub safari: bool,
1616- pub gecko: bool,
1717- pub webkit_version: Option<u32>,
1818- pub chrome_version: Option<u32>,
1919- pub mobile: bool,
2020-}
33+//! Re-exports from browser crate.
2142222-impl Default for Platform {
2323- fn default() -> Self {
2424- Self {
2525- ios: false,
2626- mac: false,
2727- android: false,
2828- chrome: false,
2929- safari: false,
3030- gecko: false,
3131- webkit_version: None,
3232- chrome_version: None,
3333- mobile: false,
3434- }
3535- }
3636-}
3737-3838-static PLATFORM: OnceLock<Platform> = OnceLock::new();
3939-4040-/// Get cached platform info. Detection runs once on first call.
4141-pub fn platform() -> &'static Platform {
4242- PLATFORM.get_or_init(detect_platform)
4343-}
4444-4545-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
4646-fn detect_platform() -> Platform {
4747- let window = match web_sys::window() {
4848- Some(w) => w,
4949- None => return Platform::default(),
5050- };
5151-5252- let navigator = window.navigator();
5353- let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase();
5454- let platform_str = navigator.platform().unwrap_or_default().to_lowercase();
5555-5656- // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch
5757- let ios = user_agent.contains("iphone")
5858- || user_agent.contains("ipad")
5959- || user_agent.contains("ipod")
6060- || (platform_str.contains("mac") && has_touch_support(&navigator));
6161-6262- // macOS (but not iOS)
6363- let mac = platform_str.contains("mac") && !ios;
6464-6565- // Android
6666- let android = user_agent.contains("android");
6767-6868- // Chrome (but not Edge, which also contains Chrome)
6969- let chrome = user_agent.contains("chrome") && !user_agent.contains("edg");
7070-7171- // Safari (WebKit but not Chrome)
7272- let safari = user_agent.contains("safari") && !user_agent.contains("chrome");
7373-7474- // Firefox/Gecko
7575- let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko");
7676-7777- // WebKit version extraction
7878- let webkit_version = extract_version(&user_agent, "applewebkit/");
7979-8080- // Chrome version extraction
8181- let chrome_version = extract_version(&user_agent, "chrome/");
8282-8383- // Mobile detection
8484- let mobile = ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile");
8585-8686- Platform {
8787- ios,
8888- mac,
8989- android,
9090- chrome,
9191- safari,
9292- gecko,
9393- webkit_version,
9494- chrome_version,
9595- mobile,
9696- }
9797-}
9898-9999-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
100100-fn has_touch_support(navigator: &web_sys::Navigator) -> bool {
101101- // Check maxTouchPoints > 0 (indicates touch capability)
102102- navigator.max_touch_points() > 0
103103-}
104104-105105-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
106106-fn extract_version(ua: &str, prefix: &str) -> Option<u32> {
107107- ua.find(prefix).and_then(|idx| {
108108- let after = &ua[idx + prefix.len()..];
109109- // Take digits until non-digit
110110- let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
111111- version_str.parse().ok()
112112- })
113113-}
114114-115115-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
116116-fn detect_platform() -> Platform {
117117- Platform::default()
118118-}
55+pub use weaver_editor_browser::{Platform, platform};
+38-9
crates/weaver-editor-browser/src/cursor.rs
···4455use wasm_bindgen::JsCast;
66use weaver_editor_core::{
77- CursorPlatform, CursorRect, OffsetMapping, PlatformError, SelectionRect, SnapDirection,
88- find_mapping_for_char, find_nearest_valid_position,
77+ CursorPlatform, CursorRect, OffsetMapping, ParagraphRender, PlatformError, SelectionRect,
88+ SnapDirection, find_mapping_for_char, find_nearest_valid_position,
99};
10101111/// Browser-based cursor platform implementation.
···3333 fn restore_cursor(
3434 &self,
3535 char_offset: usize,
3636- offset_map: &[OffsetMapping],
3636+ paragraphs: &[ParagraphRender],
3737 snap_direction: Option<SnapDirection>,
3838 ) -> Result<(), PlatformError> {
3939+ // Find the paragraph containing this offset and use its offset map.
4040+ let offset_map = find_offset_map_for_char(paragraphs, char_offset);
3941 restore_cursor_position(char_offset, offset_map, snap_direction)
4042 }
41434244 fn get_cursor_rect(
4345 &self,
4446 char_offset: usize,
4545- offset_map: &[OffsetMapping],
4747+ paragraphs: &[ParagraphRender],
4648 ) -> Option<CursorRect> {
4949+ let offset_map = find_offset_map_for_char(paragraphs, char_offset);
4750 get_cursor_rect_impl(char_offset, offset_map)
4851 }
49525053 fn get_cursor_rect_relative(
5154 &self,
5255 char_offset: usize,
5353- offset_map: &[OffsetMapping],
5656+ paragraphs: &[ParagraphRender],
5457 ) -> Option<CursorRect> {
5555- let cursor_rect = self.get_cursor_rect(char_offset, offset_map)?;
5858+ let cursor_rect = self.get_cursor_rect(char_offset, paragraphs)?;
56595760 let window = web_sys::window()?;
5861 let document = window.document()?;
···7073 &self,
7174 start: usize,
7275 end: usize,
7373- offset_map: &[OffsetMapping],
7676+ paragraphs: &[ParagraphRender],
7477 ) -> Vec<SelectionRect> {
7575- get_selection_rects_impl(start, end, offset_map, &self.editor_id)
7878+ // For selection, we need all offset maps since selection can span paragraphs.
7979+ let all_maps: Vec<_> = paragraphs
8080+ .iter()
8181+ .flat_map(|p| p.offset_map.iter())
8282+ .collect();
8383+ let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect();
8484+ get_selection_rects_impl(start, end, &borrowed, &self.editor_id)
7685 }
8686+}
8787+8888+/// Find the offset map for a character offset from paragraphs.
8989+///
9090+/// Returns the offset map of the paragraph containing the given offset,
9191+/// or an empty slice if no paragraph contains it.
9292+fn find_offset_map_for_char(
9393+ paragraphs: &[ParagraphRender],
9494+ char_offset: usize,
9595+) -> &[OffsetMapping] {
9696+ for para in paragraphs {
9797+ if para.char_range.start <= char_offset && char_offset <= para.char_range.end {
9898+ return ¶.offset_map;
9999+ }
100100+ }
101101+ // Fallback: if offset is past the end, use the last paragraph.
102102+ paragraphs
103103+ .last()
104104+ .map(|p| p.offset_map.as_slice())
105105+ .unwrap_or(&[])
77106}
7810779108/// Restore cursor position in the DOM after re-render.
···180209}
181210182211/// Find text node at given UTF-16 offset within element.
183183-fn find_text_node_at_offset(
212212+pub fn find_text_node_at_offset(
184213 container: &web_sys::HtmlElement,
185214 target_utf16_offset: usize,
186215) -> Result<(web_sys::Node, usize), PlatformError> {
+122-43
crates/weaver-editor-browser/src/dom_sync.rs
···5566use wasm_bindgen::JsCast;
77use weaver_editor_core::{
88- CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position,
88+ CursorSync, OffsetMapping, ParagraphRender, SnapDirection, find_nearest_valid_position,
99+ is_valid_cursor_position,
910};
10111112use crate::cursor::restore_cursor_position;
···4647impl CursorSync for BrowserCursorSync {
4748 fn sync_cursor_from_platform<F, G>(
4849 &self,
4949- offset_map: &[OffsetMapping],
5050+ paragraphs: &[ParagraphRender],
5051 direction_hint: Option<SnapDirection>,
5152 on_cursor: F,
5253 on_selection: G,
···5455 F: FnOnce(usize),
5556 G: FnOnce(usize, usize),
5657 {
5757- if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint)
5858+ if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, paragraphs, direction_hint)
5859 {
5960 match result {
6061 CursorSyncResult::Cursor(offset) => on_cursor(offset),
···7475/// Sync cursor state from DOM selection, returning the result.
7576///
7677/// This is the core implementation that reads the browser's selection state
7777-/// and converts it to character offsets using the offset map.
7878+/// and converts it to character offsets using paragraph offset maps.
7879pub fn sync_cursor_from_dom_impl(
7980 editor_id: &str,
8080- offset_map: &[OffsetMapping],
8181+ paragraphs: &[ParagraphRender],
8182 direction_hint: Option<SnapDirection>,
8283) -> Option<CursorSyncResult> {
8383- if offset_map.is_empty() {
8484+ if paragraphs.is_empty() {
8485 return Some(CursorSyncResult::None);
8586 }
8687···100101 &editor_element,
101102 &anchor_node,
102103 anchor_offset,
103103- offset_map,
104104+ paragraphs,
104105 direction_hint,
105106 );
106107 let focus_char = dom_position_to_text_offset(
···108109 &editor_element,
109110 &focus_node,
110111 focus_offset,
111111- offset_map,
112112+ paragraphs,
112113 direction_hint,
113114 );
114115···130131/// Convert a DOM position (node + offset) to a text char offset.
131132///
132133/// Walks up from the node to find a container with a node ID, then uses
133133-/// the offset map to convert the UTF-16 offset to a character offset.
134134+/// the paragraph offset maps to convert the UTF-16 offset to a character offset.
135135+/// The `direction_hint` is used when snapping from invisible content to determine
136136+/// which direction to prefer.
134137pub fn dom_position_to_text_offset(
135138 dom_document: &web_sys::Document,
136139 editor_element: &web_sys::Element,
137140 node: &web_sys::Node,
138141 offset_in_text_node: usize,
139139- offset_map: &[OffsetMapping],
142142+ paragraphs: &[ParagraphRender],
140143 direction_hint: Option<SnapDirection>,
141144) -> Option<usize> {
142145 // Find the containing element with a node ID (walk up from text node).
···144147 let mut walked_from: Option<web_sys::Node> = None;
145148146149 let node_id = loop {
150150+ let node_name = current_node.node_name();
151151+ let node_id_attr = current_node
152152+ .dyn_ref::<web_sys::Element>()
153153+ .and_then(|e| e.get_attribute("id"));
154154+ tracing::trace!(
155155+ node_name = %node_name,
156156+ node_id_attr = ?node_id_attr,
157157+ "dom_position_to_text_offset: walk-up iteration"
158158+ );
159159+147160 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
148161 if element == editor_element {
149162 // Selection is on the editor container itself.
163163+ // IMPORTANT: If we WALKED UP to the editor from a descendant,
164164+ // offset_in_text_node is the offset within that descendant, NOT the
165165+ // child index in the editor.
150166 if let Some(ref walked_node) = walked_from {
151151- // We walked up from a descendant - find which mapping it belongs to.
152152- for mapping in offset_map {
153153- if let Some(elem) = dom_document.get_element_by_id(&mapping.node_id) {
154154- let elem_node: &web_sys::Node = elem.as_ref();
155155- if elem_node.contains(Some(walked_node)) {
156156- return Some(mapping.char_range.start);
167167+ tracing::debug!(
168168+ walked_from_node_name = %walked_node.node_name(),
169169+ "dom_position_to_text_offset: walked up to editor from descendant"
170170+ );
171171+172172+ // Find paragraph containing this node by checking paragraph wrapper divs.
173173+ for (idx, para) in paragraphs.iter().enumerate() {
174174+ if let Some(para_elem) = dom_document.get_element_by_id(¶.id) {
175175+ let para_node: &web_sys::Node = para_elem.as_ref();
176176+ if para_node.contains(Some(walked_node)) {
177177+ tracing::trace!(
178178+ para_id = %para.id,
179179+ para_idx = idx,
180180+ char_start = para.char_range.start,
181181+ "dom_position_to_text_offset: found containing paragraph"
182182+ );
183183+ return Some(para.char_range.start);
157184 }
158185 }
159186 }
187187+ tracing::warn!(
188188+ "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"
189189+ );
160190 break None;
161191 }
162192···165195 if offset_in_text_node == 0 {
166196 return Some(0);
167197 } else if offset_in_text_node >= child_count {
168168- return offset_map.last().map(|m| m.char_range.end);
198198+ return paragraphs.last().map(|p| p.char_range.end);
169199 }
170200 break None;
171201 }
···175205 .or_else(|| element.get_attribute("data-node-id"));
176206177207 if let Some(id) = id {
208208+ // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs.
178209 let is_node_id = id.starts_with('n') || id.contains("-n");
210210+ tracing::trace!(
211211+ id = %id,
212212+ is_node_id,
213213+ starts_with_n = id.starts_with('n'),
214214+ contains_dash_n = id.contains("-n"),
215215+ "dom_position_to_text_offset: checking ID pattern"
216216+ );
179217 if is_node_id {
180218 break Some(id);
181219 }
···202240 .unwrap_or(false);
203241204242 if node_is_container {
205205- // offset_in_text_node is a child index.
243243+ // offset_in_text_node is a child index - count text content up to that child.
206244 let child_index = offset_in_text_node;
207245 let children = container.child_nodes();
208246 let mut text_counted = 0usize;
···215253 }
216254 }
217255 utf16_offset_in_container = text_counted;
256256+257257+ tracing::debug!(
258258+ child_index,
259259+ utf16_offset = utf16_offset_in_container,
260260+ "dom_position_to_text_offset: node is container, using child index"
261261+ );
218262 } else {
219263 // Normal case: node is a text node, walk to find it.
220264 if let Ok(walker) =
···256300 }
257301 }
258302259259- // Look up the offset in the offset map.
260260- for mapping in offset_map {
261261- if mapping.node_id == node_id {
262262- let mapping_start = mapping.char_offset_in_node;
263263- let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
303303+ // Log what we're looking for.
304304+ tracing::trace!(
305305+ node_id = %node_id,
306306+ utf16_offset = utf16_offset_in_container,
307307+ num_paragraphs = paragraphs.len(),
308308+ "dom_position_to_text_offset: looking up mapping"
309309+ );
264310265265- if utf16_offset_in_container >= mapping_start
266266- && utf16_offset_in_container <= mapping_end
267267- {
268268- let offset_in_mapping = utf16_offset_in_container - mapping_start;
269269- let char_offset = mapping.char_range.start + offset_in_mapping;
311311+ // Look up the offset in paragraph offset maps.
312312+ for para in paragraphs {
313313+ for mapping in ¶.offset_map {
314314+ if mapping.node_id == node_id {
315315+ let mapping_start = mapping.char_offset_in_node;
316316+ let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
270317271271- // Check if position is valid (not on invisible content).
272272- if is_valid_cursor_position(offset_map, char_offset) {
273273- return Some(char_offset);
274274- }
318318+ tracing::trace!(
319319+ mapping_node_id = %mapping.node_id,
320320+ mapping_start,
321321+ mapping_end,
322322+ char_range_start = mapping.char_range.start,
323323+ char_range_end = mapping.char_range.end,
324324+ "dom_position_to_text_offset: found matching node_id"
325325+ );
275326276276- // Position is on invisible content, snap to nearest valid.
277277- if let Some(snapped) =
278278- find_nearest_valid_position(offset_map, char_offset, direction_hint)
327327+ if utf16_offset_in_container >= mapping_start
328328+ && utf16_offset_in_container <= mapping_end
279329 {
280280- return Some(snapped.char_offset());
281281- }
330330+ let offset_in_mapping = utf16_offset_in_container - mapping_start;
331331+ let char_offset = mapping.char_range.start + offset_in_mapping;
282332283283- return Some(char_offset);
333333+ tracing::trace!(
334334+ node_id = %node_id,
335335+ utf16_offset = utf16_offset_in_container,
336336+ mapping_start,
337337+ mapping_end,
338338+ offset_in_mapping,
339339+ char_range_start = mapping.char_range.start,
340340+ char_offset,
341341+ "dom_position_to_text_offset: MATCHED mapping"
342342+ );
343343+344344+ // Check if position is valid (not on invisible content).
345345+ if is_valid_cursor_position(¶.offset_map, char_offset) {
346346+ return Some(char_offset);
347347+ }
348348+349349+ // Position is on invisible content, snap to nearest valid.
350350+ if let Some(snapped) =
351351+ find_nearest_valid_position(¶.offset_map, char_offset, direction_hint)
352352+ {
353353+ return Some(snapped.char_offset());
354354+ }
355355+356356+ // Fallback to original if no snap target.
357357+ return Some(char_offset);
358358+ }
284359 }
285360 }
286361 }
287362288288- // No mapping found - try to find any valid position.
289289- if let Some(snapped) = find_nearest_valid_position(offset_map, 0, direction_hint) {
290290- return Some(snapped.char_offset());
363363+ // No mapping found - try to find any valid position in paragraphs.
364364+ for para in paragraphs {
365365+ if let Some(snapped) =
366366+ find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
367367+ {
368368+ return Some(snapped.char_offset());
369369+ }
291370 }
292371293372 None
···356435 for new_para in new_paragraphs.iter() {
357436 let para_id = new_para.id;
358437 let new_hash = format!("{:x}", new_para.source_hash);
359359- let is_cursor_para = new_para.char_range.start <= cursor_offset
360360- && cursor_offset <= new_para.char_range.end;
438438+ let is_cursor_para =
439439+ new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end;
361440362441 if let Some(existing_elem) = old_elements.remove(para_id) {
363442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
+9-11
crates/weaver-editor-browser/src/events.rs
···44//! the `beforeinput` event and other DOM events.
5566use wasm_bindgen::prelude::*;
77-use weaver_editor_core::{InputType, OffsetMapping, Range};
77+use weaver_editor_core::{InputType, ParagraphRender, Range};
8899use crate::dom_sync::dom_position_to_text_offset;
1010+use crate::platform::Platform;
10111112// === StaticRange binding ===
1213//
···115116 /// The data (text to insert, if any).
116117 pub data: Option<String>,
117118 /// Target range from getTargetRanges(), if available.
119119+ /// This is the range the browser wants to modify.
118120 pub target_range: Option<Range>,
119121 /// Whether the event is part of an IME composition.
120122 pub is_composing: bool,
121121- /// Whether we're on Android.
122122- pub is_android: bool,
123123- /// Whether we're on Chrome.
124124- pub is_chrome: bool,
125125- /// Offset mappings for the document.
126126- pub offset_map: &'a [OffsetMapping],
123123+ /// Platform info for quirks handling.
124124+ pub platform: &'a Platform,
127125}
128126129127/// Extract target range from a beforeinput event.
···132130pub fn get_target_range_from_event(
133131 event: &web_sys::InputEvent,
134132 editor_id: &str,
135135- offset_map: &[OffsetMapping],
133133+ paragraphs: &[ParagraphRender],
136134) -> Option<Range> {
137135 use wasm_bindgen::JsCast;
138136···157155 &editor_element,
158156 &start_container,
159157 start_offset,
160160- offset_map,
158158+ paragraphs,
161159 None,
162160 )?;
163161···166164 &editor_element,
167165 &end_container,
168166 end_offset,
169169- offset_map,
167167+ paragraphs,
170168 None,
171169 )?;
172170···337335 // === Deletion ===
338336 InputType::DeleteContentBackward => {
339337 // Android Chrome workaround: backspace sometimes doesn't work properly.
340340- if ctx.is_android && ctx.is_chrome && range.is_caret() {
338338+ if ctx.platform.android && ctx.platform.chrome && range.is_caret() {
341339 let action = EditorAction::DeleteBackward { range };
342340 return BeforeInputResult::DeferredCheck {
343341 fallback_action: action,
+5-2
crates/weaver-editor-browser/src/lib.rs
···2727pub mod visibility;
28282929// Browser cursor implementation
3030-pub use cursor::BrowserCursor;
3030+pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position};
31313232// DOM sync types
3333-pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData};
3333+pub use dom_sync::{
3434+ BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset,
3535+ sync_cursor_from_dom_impl, update_paragraph_dom,
3636+};
34373538// Event handling
3639pub use events::{