further extraction

Orual aba348b6 834bf8b1

+277 -719
+1 -33
crates/weaver-app/src/components/editor/beforeinput.rs
··· 21 21 use super::platform::Platform; 22 22 23 23 // Re-export types from extracted crates. 24 + pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; 24 25 pub use weaver_editor_core::{InputType, Range}; 25 26 26 27 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 27 28 pub 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 - } 61 29 62 30 /// Handle a beforeinput event. 63 31 ///
+12 -211
crates/weaver-app/src/components/editor/cursor.rs
··· 1 - //! Cursor position restoration in the DOM. 1 + //! Cursor position operations. 2 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}; 3 + //! Re-exports from browser crate with app-specific adapters. 14 4 15 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 16 - use wasm_bindgen::JsCast; 5 + pub use weaver_editor_browser::restore_cursor_position; 6 + pub use weaver_editor_core::{CursorRect, OffsetMapping, SelectionRect}; 17 7 18 - /// Restore cursor position in the DOM after re-render. 19 8 #[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. 9 + use weaver_editor_core::{SnapDirection, find_mapping_for_char}; 199 10 200 11 /// Get screen coordinates for a character offset in the editor. 201 12 /// ··· 204 15 pub fn get_cursor_rect( 205 16 char_offset: usize, 206 17 offset_map: &[OffsetMapping], 207 - editor_id: &str, 18 + _editor_id: &str, 208 19 ) -> Option<CursorRect> { 20 + use wasm_bindgen::JsCast; 21 + 209 22 if offset_map.is_empty() { 210 23 return None; 211 24 } 212 25 213 - // Find mapping for this position 214 26 let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { 215 27 Some((m, _)) => (m, char_offset), 216 28 None => return None, ··· 219 31 let window = web_sys::window()?; 220 32 let document = window.document()?; 221 33 222 - // Get container element 223 34 let container = document.get_element_by_id(&mapping.node_id).or_else(|| { 224 35 let selector = format!("[data-node-id='{}']", mapping.node_id); 225 36 document.query_selector(&selector).ok().flatten() ··· 227 38 228 39 let range = document.create_range().ok()?; 229 40 230 - // Position the range at the character offset 231 41 if let Some(child_index) = mapping.child_index { 232 42 range.set_start(&container, child_index as u32).ok()?; 233 43 } else { ··· 236 46 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; 237 47 238 48 if let Ok((text_node, node_offset)) = 239 - find_text_node_at_offset(&container_element, target_utf16_offset) 49 + weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) 240 50 { 241 51 range.set_start(&text_node, node_offset as u32).ok()?; 242 52 } else { ··· 246 56 247 57 range.collapse_with_to_start(true); 248 58 249 - // Get the bounding rect 250 59 let rect = range.get_bounding_client_rect(); 251 60 Some(CursorRect { 252 61 x: rect.x(), 253 62 y: rect.y(), 254 - height: rect.height().max(16.0), // Minimum height for empty lines 63 + height: rect.height().max(16.0), 255 64 }) 256 65 } 257 66 ··· 285 94 None 286 95 } 287 96 288 - // SelectionRect is imported from weaver_editor_core. 289 - 290 97 /// Get screen rectangles for a selection range, relative to editor. 291 98 /// 292 99 /// Returns multiple rects if selection spans multiple lines. ··· 314 121 }; 315 122 let editor_rect = editor.get_bounding_client_rect(); 316 123 317 - // Find mappings for start and end 318 124 let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else { 319 125 return vec![]; 320 126 }; ··· 322 128 return vec![]; 323 129 }; 324 130 325 - // Get containers 326 131 let start_container = document 327 132 .get_element_by_id(&start_mapping.node_id) 328 133 .or_else(|| { ··· 340 145 return vec![]; 341 146 }; 342 147 343 - // Create range 344 148 let Ok(range) = document.create_range() else { 345 149 return vec![]; 346 150 }; 347 151 348 - // Set start 349 152 if let Some(child_index) = start_mapping.child_index { 350 153 let _ = range.set_start(&start_container, child_index as u32); 351 154 } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() ··· 353 156 let offset_in_range = start - start_mapping.char_range.start; 354 157 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; 355 158 if let Ok((text_node, node_offset)) = 356 - find_text_node_at_offset(&container_element, target_utf16_offset) 159 + weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) 357 160 { 358 161 let _ = range.set_start(&text_node, node_offset as u32); 359 162 } 360 163 } 361 164 362 - // Set end 363 165 if let Some(child_index) = end_mapping.child_index { 364 166 let _ = range.set_end(&end_container, child_index as u32); 365 167 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 366 168 let offset_in_range = end - end_mapping.char_range.start; 367 169 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; 368 170 if let Ok((text_node, node_offset)) = 369 - find_text_node_at_offset(&container_element, target_utf16_offset) 171 + weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) 370 172 { 371 173 let _ = range.set_end(&text_node, node_offset as u32); 372 174 } 373 175 } 374 176 375 - // Get all rects (one per line) 376 177 let Some(rects) = range.get_client_rects() else { 377 178 return vec![]; 378 179 };
+8 -263
crates/weaver-app/src/components/editor/dom_sync.rs
··· 2 2 //! 3 3 //! Handles syncing cursor/selection state between the browser DOM and our 4 4 //! internal document model, and updating paragraph DOM elements. 5 + //! 6 + //! The core DOM position conversion is provided by `weaver_editor_browser`. 5 7 6 8 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 7 9 use super::cursor::restore_cursor_position; 8 10 #[allow(unused_imports)] 9 11 use super::document::{EditorDocument, Selection}; 10 - #[allow(unused_imports)] 11 - use weaver_editor_core::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 12 12 use super::paragraph::ParagraphRender; 13 13 #[allow(unused_imports)] 14 14 use 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; 15 21 16 22 /// Sync internal cursor and selection state from browser DOM selection. 17 23 /// ··· 123 129 tracing::warn!("Could not map DOM selection to rope offsets"); 124 130 } 125 131 } 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(&para.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 &para.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(&para.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(&para.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(&para.offset_map, para.char_range.start, direction_hint) 381 - { 382 - return Some(snapped.char_offset()); 383 - } 384 - } 385 - 386 - None 387 132 } 388 133 389 134 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
+2 -115
crates/weaver-app/src/components/editor/platform.rs
··· 1 1 //! Platform detection for browser-specific workarounds. 2 2 //! 3 - //! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm. 4 - 5 - use std::sync::OnceLock; 6 - 7 - /// Cached platform detection results. 8 - #[derive(Debug, Clone)] 9 - #[allow(dead_code)] 10 - pub struct Platform { 11 - pub ios: bool, 12 - pub mac: bool, 13 - pub android: bool, 14 - pub chrome: bool, 15 - pub safari: bool, 16 - pub gecko: bool, 17 - pub webkit_version: Option<u32>, 18 - pub chrome_version: Option<u32>, 19 - pub mobile: bool, 20 - } 3 + //! Re-exports from browser crate. 21 4 22 - impl Default for Platform { 23 - fn default() -> Self { 24 - Self { 25 - ios: false, 26 - mac: false, 27 - android: false, 28 - chrome: false, 29 - safari: false, 30 - gecko: false, 31 - webkit_version: None, 32 - chrome_version: None, 33 - mobile: false, 34 - } 35 - } 36 - } 37 - 38 - static PLATFORM: OnceLock<Platform> = OnceLock::new(); 39 - 40 - /// Get cached platform info. Detection runs once on first call. 41 - pub fn platform() -> &'static Platform { 42 - PLATFORM.get_or_init(detect_platform) 43 - } 44 - 45 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 46 - fn detect_platform() -> Platform { 47 - let window = match web_sys::window() { 48 - Some(w) => w, 49 - None => return Platform::default(), 50 - }; 51 - 52 - let navigator = window.navigator(); 53 - let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase(); 54 - let platform_str = navigator.platform().unwrap_or_default().to_lowercase(); 55 - 56 - // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch 57 - let ios = user_agent.contains("iphone") 58 - || user_agent.contains("ipad") 59 - || user_agent.contains("ipod") 60 - || (platform_str.contains("mac") && has_touch_support(&navigator)); 61 - 62 - // macOS (but not iOS) 63 - let mac = platform_str.contains("mac") && !ios; 64 - 65 - // Android 66 - let android = user_agent.contains("android"); 67 - 68 - // Chrome (but not Edge, which also contains Chrome) 69 - let chrome = user_agent.contains("chrome") && !user_agent.contains("edg"); 70 - 71 - // Safari (WebKit but not Chrome) 72 - let safari = user_agent.contains("safari") && !user_agent.contains("chrome"); 73 - 74 - // Firefox/Gecko 75 - let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko"); 76 - 77 - // WebKit version extraction 78 - let webkit_version = extract_version(&user_agent, "applewebkit/"); 79 - 80 - // Chrome version extraction 81 - let chrome_version = extract_version(&user_agent, "chrome/"); 82 - 83 - // Mobile detection 84 - let mobile = ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile"); 85 - 86 - Platform { 87 - ios, 88 - mac, 89 - android, 90 - chrome, 91 - safari, 92 - gecko, 93 - webkit_version, 94 - chrome_version, 95 - mobile, 96 - } 97 - } 98 - 99 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 100 - fn has_touch_support(navigator: &web_sys::Navigator) -> bool { 101 - // Check maxTouchPoints > 0 (indicates touch capability) 102 - navigator.max_touch_points() > 0 103 - } 104 - 105 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 106 - fn extract_version(ua: &str, prefix: &str) -> Option<u32> { 107 - ua.find(prefix).and_then(|idx| { 108 - let after = &ua[idx + prefix.len()..]; 109 - // Take digits until non-digit 110 - let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); 111 - version_str.parse().ok() 112 - }) 113 - } 114 - 115 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 116 - fn detect_platform() -> Platform { 117 - Platform::default() 118 - } 5 + pub use weaver_editor_browser::{Platform, platform};
+38 -9
crates/weaver-editor-browser/src/cursor.rs
··· 4 4 5 5 use wasm_bindgen::JsCast; 6 6 use weaver_editor_core::{ 7 - CursorPlatform, CursorRect, OffsetMapping, PlatformError, SelectionRect, SnapDirection, 8 - find_mapping_for_char, find_nearest_valid_position, 7 + CursorPlatform, CursorRect, OffsetMapping, ParagraphRender, PlatformError, SelectionRect, 8 + SnapDirection, find_mapping_for_char, find_nearest_valid_position, 9 9 }; 10 10 11 11 /// Browser-based cursor platform implementation. ··· 33 33 fn restore_cursor( 34 34 &self, 35 35 char_offset: usize, 36 - offset_map: &[OffsetMapping], 36 + paragraphs: &[ParagraphRender], 37 37 snap_direction: Option<SnapDirection>, 38 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); 39 41 restore_cursor_position(char_offset, offset_map, snap_direction) 40 42 } 41 43 42 44 fn get_cursor_rect( 43 45 &self, 44 46 char_offset: usize, 45 - offset_map: &[OffsetMapping], 47 + paragraphs: &[ParagraphRender], 46 48 ) -> Option<CursorRect> { 49 + let offset_map = find_offset_map_for_char(paragraphs, char_offset); 47 50 get_cursor_rect_impl(char_offset, offset_map) 48 51 } 49 52 50 53 fn get_cursor_rect_relative( 51 54 &self, 52 55 char_offset: usize, 53 - offset_map: &[OffsetMapping], 56 + paragraphs: &[ParagraphRender], 54 57 ) -> Option<CursorRect> { 55 - let cursor_rect = self.get_cursor_rect(char_offset, offset_map)?; 58 + let cursor_rect = self.get_cursor_rect(char_offset, paragraphs)?; 56 59 57 60 let window = web_sys::window()?; 58 61 let document = window.document()?; ··· 70 73 &self, 71 74 start: usize, 72 75 end: usize, 73 - offset_map: &[OffsetMapping], 76 + paragraphs: &[ParagraphRender], 74 77 ) -> Vec<SelectionRect> { 75 - get_selection_rects_impl(start, end, offset_map, &self.editor_id) 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) 76 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 &para.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(&[]) 77 106 } 78 107 79 108 /// Restore cursor position in the DOM after re-render. ··· 180 209 } 181 210 182 211 /// Find text node at given UTF-16 offset within element. 183 - fn find_text_node_at_offset( 212 + pub fn find_text_node_at_offset( 184 213 container: &web_sys::HtmlElement, 185 214 target_utf16_offset: usize, 186 215 ) -> Result<(web_sys::Node, usize), PlatformError> {
+122 -43
crates/weaver-editor-browser/src/dom_sync.rs
··· 5 5 6 6 use wasm_bindgen::JsCast; 7 7 use weaver_editor_core::{ 8 - CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position, 8 + CursorSync, OffsetMapping, ParagraphRender, SnapDirection, find_nearest_valid_position, 9 + is_valid_cursor_position, 9 10 }; 10 11 11 12 use crate::cursor::restore_cursor_position; ··· 46 47 impl CursorSync for BrowserCursorSync { 47 48 fn sync_cursor_from_platform<F, G>( 48 49 &self, 49 - offset_map: &[OffsetMapping], 50 + paragraphs: &[ParagraphRender], 50 51 direction_hint: Option<SnapDirection>, 51 52 on_cursor: F, 52 53 on_selection: G, ··· 54 55 F: FnOnce(usize), 55 56 G: FnOnce(usize, usize), 56 57 { 57 - if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint) 58 + if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, paragraphs, direction_hint) 58 59 { 59 60 match result { 60 61 CursorSyncResult::Cursor(offset) => on_cursor(offset), ··· 74 75 /// Sync cursor state from DOM selection, returning the result. 75 76 /// 76 77 /// This is the core implementation that reads the browser's selection state 77 - /// and converts it to character offsets using the offset map. 78 + /// and converts it to character offsets using paragraph offset maps. 78 79 pub fn sync_cursor_from_dom_impl( 79 80 editor_id: &str, 80 - offset_map: &[OffsetMapping], 81 + paragraphs: &[ParagraphRender], 81 82 direction_hint: Option<SnapDirection>, 82 83 ) -> Option<CursorSyncResult> { 83 - if offset_map.is_empty() { 84 + if paragraphs.is_empty() { 84 85 return Some(CursorSyncResult::None); 85 86 } 86 87 ··· 100 101 &editor_element, 101 102 &anchor_node, 102 103 anchor_offset, 103 - offset_map, 104 + paragraphs, 104 105 direction_hint, 105 106 ); 106 107 let focus_char = dom_position_to_text_offset( ··· 108 109 &editor_element, 109 110 &focus_node, 110 111 focus_offset, 111 - offset_map, 112 + paragraphs, 112 113 direction_hint, 113 114 ); 114 115 ··· 130 131 /// Convert a DOM position (node + offset) to a text char offset. 131 132 /// 132 133 /// 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. 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. 134 137 pub fn dom_position_to_text_offset( 135 138 dom_document: &web_sys::Document, 136 139 editor_element: &web_sys::Element, 137 140 node: &web_sys::Node, 138 141 offset_in_text_node: usize, 139 - offset_map: &[OffsetMapping], 142 + paragraphs: &[ParagraphRender], 140 143 direction_hint: Option<SnapDirection>, 141 144 ) -> Option<usize> { 142 145 // Find the containing element with a node ID (walk up from text node). ··· 144 147 let mut walked_from: Option<web_sys::Node> = None; 145 148 146 149 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 + 147 160 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 148 161 if element == editor_element { 149 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. 150 166 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); 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(&para.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); 157 184 } 158 185 } 159 186 } 187 + tracing::warn!( 188 + "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph" 189 + ); 160 190 break None; 161 191 } 162 192 ··· 165 195 if offset_in_text_node == 0 { 166 196 return Some(0); 167 197 } else if offset_in_text_node >= child_count { 168 - return offset_map.last().map(|m| m.char_range.end); 198 + return paragraphs.last().map(|p| p.char_range.end); 169 199 } 170 200 break None; 171 201 } ··· 175 205 .or_else(|| element.get_attribute("data-node-id")); 176 206 177 207 if let Some(id) = id { 208 + // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs. 178 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 + ); 179 217 if is_node_id { 180 218 break Some(id); 181 219 } ··· 202 240 .unwrap_or(false); 203 241 204 242 if node_is_container { 205 - // offset_in_text_node is a child index. 243 + // offset_in_text_node is a child index - count text content up to that child. 206 244 let child_index = offset_in_text_node; 207 245 let children = container.child_nodes(); 208 246 let mut text_counted = 0usize; ··· 215 253 } 216 254 } 217 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 + ); 218 262 } else { 219 263 // Normal case: node is a text node, walk to find it. 220 264 if let Ok(walker) = ··· 256 300 } 257 301 } 258 302 259 - // 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; 303 + // 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 + ); 264 310 265 - 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; 311 + // Look up the offset in paragraph offset maps. 312 + for para in paragraphs { 313 + for mapping in &para.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; 270 317 271 - // 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 - } 318 + 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 + ); 275 326 276 - // 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) 327 + if utf16_offset_in_container >= mapping_start 328 + && utf16_offset_in_container <= mapping_end 279 329 { 280 - return Some(snapped.char_offset()); 281 - } 330 + let offset_in_mapping = utf16_offset_in_container - mapping_start; 331 + let char_offset = mapping.char_range.start + offset_in_mapping; 282 332 283 - return Some(char_offset); 333 + 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(&para.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(&para.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 + } 284 359 } 285 360 } 286 361 } 287 362 288 - // 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()); 363 + // 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(&para.offset_map, para.char_range.start, direction_hint) 367 + { 368 + return Some(snapped.char_offset()); 369 + } 291 370 } 292 371 293 372 None ··· 356 435 for new_para in new_paragraphs.iter() { 357 436 let para_id = new_para.id; 358 437 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; 438 + let is_cursor_para = 439 + new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; 361 440 362 441 if let Some(existing_elem) = old_elements.remove(para_id) { 363 442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
+9 -11
crates/weaver-editor-browser/src/events.rs
··· 4 4 //! the `beforeinput` event and other DOM events. 5 5 6 6 use wasm_bindgen::prelude::*; 7 - use weaver_editor_core::{InputType, OffsetMapping, Range}; 7 + use weaver_editor_core::{InputType, ParagraphRender, Range}; 8 8 9 9 use crate::dom_sync::dom_position_to_text_offset; 10 + use crate::platform::Platform; 10 11 11 12 // === StaticRange binding === 12 13 // ··· 115 116 /// The data (text to insert, if any). 116 117 pub data: Option<String>, 117 118 /// Target range from getTargetRanges(), if available. 119 + /// This is the range the browser wants to modify. 118 120 pub target_range: Option<Range>, 119 121 /// Whether the event is part of an IME composition. 120 122 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], 123 + /// Platform info for quirks handling. 124 + pub platform: &'a Platform, 127 125 } 128 126 129 127 /// Extract target range from a beforeinput event. ··· 132 130 pub fn get_target_range_from_event( 133 131 event: &web_sys::InputEvent, 134 132 editor_id: &str, 135 - offset_map: &[OffsetMapping], 133 + paragraphs: &[ParagraphRender], 136 134 ) -> Option<Range> { 137 135 use wasm_bindgen::JsCast; 138 136 ··· 157 155 &editor_element, 158 156 &start_container, 159 157 start_offset, 160 - offset_map, 158 + paragraphs, 161 159 None, 162 160 )?; 163 161 ··· 166 164 &editor_element, 167 165 &end_container, 168 166 end_offset, 169 - offset_map, 167 + paragraphs, 170 168 None, 171 169 )?; 172 170 ··· 337 335 // === Deletion === 338 336 InputType::DeleteContentBackward => { 339 337 // Android Chrome workaround: backspace sometimes doesn't work properly. 340 - if ctx.is_android && ctx.is_chrome && range.is_caret() { 338 + if ctx.platform.android && ctx.platform.chrome && range.is_caret() { 341 339 let action = EditorAction::DeleteBackward { range }; 342 340 return BeforeInputResult::DeferredCheck { 343 341 fallback_action: action,
+5 -2
crates/weaver-editor-browser/src/lib.rs
··· 27 27 pub mod visibility; 28 28 29 29 // Browser cursor implementation 30 - pub use cursor::BrowserCursor; 30 + pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position}; 31 31 32 32 // DOM sync types 33 - pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData}; 33 + pub use dom_sync::{ 34 + BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset, 35 + sync_cursor_from_dom_impl, update_paragraph_dom, 36 + }; 34 37 35 38 // Event handling 36 39 pub use events::{
+29 -25
crates/weaver-editor-browser/tests/web.rs
··· 7 7 wasm_bindgen_test_configure!(run_in_browser); 8 8 9 9 use weaver_editor_browser::{ 10 - BeforeInputContext, BeforeInputResult, InputType, Range, handle_beforeinput, 10 + BeforeInputContext, BeforeInputResult, InputType, Platform, Range, handle_beforeinput, 11 11 parse_browser_input_type, platform, 12 12 }; 13 13 use weaver_editor_core::{EditorDocument, EditorRope, PlainEditor, UndoableBuffer}; ··· 20 20 PlainEditor::new(buf) 21 21 } 22 22 23 + fn test_platform() -> Platform { 24 + Platform { 25 + ios: false, 26 + mac: false, 27 + android: false, 28 + chrome: false, 29 + safari: false, 30 + gecko: false, 31 + webkit_version: None, 32 + chrome_version: None, 33 + mobile: false, 34 + } 35 + } 36 + 23 37 // === InputType parsing tests === 24 38 25 39 #[wasm_bindgen_test] ··· 68 82 fn test_handle_insert_text() { 69 83 let mut editor = make_editor("hello"); 70 84 editor.set_cursor_offset(5); 85 + let plat = test_platform(); 71 86 72 87 let ctx = BeforeInputContext { 73 88 input_type: InputType::InsertText, 74 89 data: Some(" world".to_string()), 75 90 target_range: None, 76 91 is_composing: false, 77 - is_android: false, 78 - is_chrome: false, 79 - offset_map: &[], 92 + platform: &plat, 80 93 }; 81 94 82 95 let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); ··· 88 101 fn test_handle_delete_backward() { 89 102 let mut editor = make_editor("hello"); 90 103 editor.set_cursor_offset(5); 104 + let plat = test_platform(); 91 105 92 106 let ctx = BeforeInputContext { 93 107 input_type: InputType::DeleteContentBackward, 94 108 data: None, 95 109 target_range: None, 96 110 is_composing: false, 97 - is_android: false, 98 - is_chrome: false, 99 - offset_map: &[], 111 + platform: &plat, 100 112 }; 101 113 102 114 let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); ··· 107 119 #[wasm_bindgen_test] 108 120 fn test_handle_composition_passthrough() { 109 121 let mut editor = make_editor("hello"); 122 + let plat = test_platform(); 110 123 111 124 let ctx = BeforeInputContext { 112 125 input_type: InputType::InsertText, 113 126 data: Some("x".to_string()), 114 127 target_range: None, 115 128 is_composing: true, // During composition 116 - is_android: false, 117 - is_chrome: false, 118 - offset_map: &[], 129 + platform: &plat, 119 130 }; 120 131 121 132 let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); ··· 128 139 fn test_handle_undo_redo() { 129 140 let mut editor = make_editor("hello"); 130 141 editor.set_cursor_offset(5); 142 + let plat = test_platform(); 131 143 132 144 // Insert text first. 133 145 let insert_ctx = BeforeInputContext { ··· 135 147 data: Some(" world".to_string()), 136 148 target_range: None, 137 149 is_composing: false, 138 - is_android: false, 139 - is_chrome: false, 140 - offset_map: &[], 150 + platform: &plat, 141 151 }; 142 152 handle_beforeinput(&mut editor, &insert_ctx, Range::caret(5)); 143 153 assert_eq!(editor.content_string(), "hello world"); ··· 148 158 data: None, 149 159 target_range: None, 150 160 is_composing: false, 151 - is_android: false, 152 - is_chrome: false, 153 - offset_map: &[], 161 + platform: &plat, 154 162 }; 155 163 let result = handle_beforeinput(&mut editor, &undo_ctx, Range::caret(11)); 156 164 assert!(matches!(result, BeforeInputResult::Handled)); ··· 162 170 data: None, 163 171 target_range: None, 164 172 is_composing: false, 165 - is_android: false, 166 - is_chrome: false, 167 - offset_map: &[], 173 + platform: &plat, 168 174 }; 169 175 let result = handle_beforeinput(&mut editor, &redo_ctx, Range::caret(5)); 170 176 assert!(matches!(result, BeforeInputResult::Handled)); ··· 175 181 fn test_handle_insert_paragraph() { 176 182 let mut editor = make_editor("hello"); 177 183 editor.set_cursor_offset(5); 184 + let plat = test_platform(); 178 185 179 186 let ctx = BeforeInputContext { 180 187 input_type: InputType::InsertParagraph, 181 188 data: None, 182 189 target_range: None, 183 190 is_composing: false, 184 - is_android: false, 185 - is_chrome: false, 186 - offset_map: &[], 191 + platform: &plat, 187 192 }; 188 193 189 194 let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); ··· 195 200 #[wasm_bindgen_test] 196 201 fn test_handle_selection_delete() { 197 202 let mut editor = make_editor("hello world"); 203 + let plat = test_platform(); 198 204 199 205 let ctx = BeforeInputContext { 200 206 input_type: InputType::DeleteContentBackward, 201 207 data: None, 202 208 target_range: Some(Range::new(5, 11)), // Select " world" 203 209 is_composing: false, 204 - is_android: false, 205 - is_chrome: false, 206 - offset_map: &[], 210 + platform: &plat, 207 211 }; 208 212 209 213 let result = handle_beforeinput(&mut editor, &ctx, Range::new(5, 11));
+7 -7
crates/weaver-editor-core/src/platform.rs
··· 5 5 //! logic to work across different platforms. 6 6 7 7 use crate::offset_map::SnapDirection; 8 + use crate::paragraph::ParagraphRender; 8 9 use crate::types::{CursorRect, SelectionRect}; 9 - use crate::OffsetMapping; 10 10 11 11 /// Error type for platform operations. 12 12 #[derive(Debug, Clone)] ··· 40 40 pub trait CursorPlatform { 41 41 /// Restore cursor position in the UI after content changes. 42 42 /// 43 - /// Given a character offset and the current offset map, positions the cursor 43 + /// Given a character offset and rendered paragraphs, positions the cursor 44 44 /// in the rendered content. The snap direction is used when the offset falls 45 45 /// on invisible content (formatting syntax). 46 46 fn restore_cursor( 47 47 &self, 48 48 char_offset: usize, 49 - offset_map: &[OffsetMapping], 49 + paragraphs: &[ParagraphRender], 50 50 snap_direction: Option<SnapDirection>, 51 51 ) -> Result<(), PlatformError>; 52 52 ··· 56 56 fn get_cursor_rect( 57 57 &self, 58 58 char_offset: usize, 59 - offset_map: &[OffsetMapping], 59 + paragraphs: &[ParagraphRender], 60 60 ) -> Option<CursorRect>; 61 61 62 62 /// Get screen coordinates relative to the editor container. ··· 66 66 fn get_cursor_rect_relative( 67 67 &self, 68 68 char_offset: usize, 69 - offset_map: &[OffsetMapping], 69 + paragraphs: &[ParagraphRender], 70 70 ) -> Option<CursorRect>; 71 71 72 72 /// Get screen rectangles for a selection range. ··· 77 77 &self, 78 78 start: usize, 79 79 end: usize, 80 - offset_map: &[OffsetMapping], 80 + paragraphs: &[ParagraphRender], 81 81 ) -> Vec<SelectionRect>; 82 82 } 83 83 ··· 95 95 /// - For a selection: calls `on_selection(anchor, head)` 96 96 fn sync_cursor_from_platform<F, G>( 97 97 &self, 98 - offset_map: &[OffsetMapping], 98 + paragraphs: &[ParagraphRender], 99 99 direction_hint: Option<SnapDirection>, 100 100 on_cursor: F, 101 101 on_selection: G,
+44
docs/graph-data.json
··· 1506 1506 "created_at": "2026-01-06T12:49:01.362223737-05:00", 1507 1507 "updated_at": "2026-01-06T12:49:01.362223737-05:00", 1508 1508 "metadata_json": "{\"confidence\":95}" 1509 + }, 1510 + { 1511 + "id": 139, 1512 + "change_id": "3d5b52b1-30d1-4aa4-9294-c25f47650e4a", 1513 + "node_type": "action", 1514 + "title": "Moved update_syntax_visibility to browser crate, visibility.rs now thin re-export", 1515 + "description": null, 1516 + "status": "pending", 1517 + "created_at": "2026-01-06T12:54:14.442798720-05:00", 1518 + "updated_at": "2026-01-06T12:54:14.442798720-05:00", 1519 + "metadata_json": "{\"confidence\":95}" 1520 + }, 1521 + { 1522 + "id": 140, 1523 + "change_id": "d30cb8c3-82ac-413a-9e7f-d9a7bd49ab79", 1524 + "node_type": "action", 1525 + "title": "Added FormatAction to core with #[non_exhaustive]", 1526 + "description": null, 1527 + "status": "pending", 1528 + "created_at": "2026-01-06T12:54:14.487538586-05:00", 1529 + "updated_at": "2026-01-06T12:54:14.487538586-05:00", 1530 + "metadata_json": "{\"confidence\":95}" 1509 1531 } 1510 1532 ], 1511 1533 "edges": [ ··· 3136 3158 "weight": 1.0, 3137 3159 "rationale": "dedup effort", 3138 3160 "created_at": "2026-01-06T12:49:06.980327331-05:00" 3161 + }, 3162 + { 3163 + "id": 150, 3164 + "from_node_id": 132, 3165 + "to_node_id": 139, 3166 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3167 + "to_change_id": "3d5b52b1-30d1-4aa4-9294-c25f47650e4a", 3168 + "edge_type": "leads_to", 3169 + "weight": 1.0, 3170 + "rationale": "dedup effort", 3171 + "created_at": "2026-01-06T12:54:14.515301027-05:00" 3172 + }, 3173 + { 3174 + "id": 151, 3175 + "from_node_id": 132, 3176 + "to_node_id": 140, 3177 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3178 + "to_change_id": "d30cb8c3-82ac-413a-9e7f-d9a7bd49ab79", 3179 + "edge_type": "leads_to", 3180 + "weight": 1.0, 3181 + "rationale": "dedup effort", 3182 + "created_at": "2026-01-06T12:54:14.531458670-05:00" 3139 3183 } 3140 3184 ] 3141 3185 }