at main 446 lines 15 kB view raw
1//! Browser implementation of cursor platform operations. 2//! 3//! Uses the DOM Selection API to position cursors and retrieve screen coordinates. 4 5use wasm_bindgen::JsCast; 6use weaver_editor_core::{ 7 CursorPlatform, CursorRect, OffsetMapping, ParagraphRender, PlatformError, SelectionRect, 8 SnapDirection, find_mapping_for_char, find_nearest_valid_position, 9}; 10 11/// Browser-based cursor platform implementation. 12/// 13/// Holds a reference to the editor element ID for DOM lookups. 14pub struct BrowserCursor { 15 editor_id: String, 16} 17 18impl BrowserCursor { 19 /// Create a new browser cursor handler for the given editor element. 20 pub fn new(editor_id: impl Into<String>) -> Self { 21 Self { 22 editor_id: editor_id.into(), 23 } 24 } 25 26 /// Get the editor element ID. 27 pub fn editor_id(&self) -> &str { 28 &self.editor_id 29 } 30} 31 32impl CursorPlatform for BrowserCursor { 33 fn restore_cursor( 34 &self, 35 char_offset: usize, 36 paragraphs: &[ParagraphRender], 37 snap_direction: Option<SnapDirection>, 38 ) -> Result<(), PlatformError> { 39 // Find the paragraph containing this offset and use its offset map. 40 let offset_map = find_offset_map_for_char(paragraphs, char_offset); 41 restore_cursor_position(char_offset, offset_map, snap_direction) 42 } 43 44 fn get_cursor_rect( 45 &self, 46 char_offset: usize, 47 paragraphs: &[ParagraphRender], 48 ) -> Option<CursorRect> { 49 let offset_map = find_offset_map_for_char(paragraphs, char_offset); 50 get_cursor_rect_impl(char_offset, offset_map) 51 } 52 53 fn get_cursor_rect_relative( 54 &self, 55 char_offset: usize, 56 paragraphs: &[ParagraphRender], 57 ) -> Option<CursorRect> { 58 let cursor_rect = self.get_cursor_rect(char_offset, paragraphs)?; 59 60 let window = web_sys::window()?; 61 let document = window.document()?; 62 let editor = document.get_element_by_id(&self.editor_id)?; 63 let editor_rect = editor.get_bounding_client_rect(); 64 65 Some(CursorRect::new( 66 cursor_rect.x - editor_rect.x(), 67 cursor_rect.y - editor_rect.y(), 68 cursor_rect.height, 69 )) 70 } 71 72 fn get_selection_rects_relative( 73 &self, 74 start: usize, 75 end: usize, 76 paragraphs: &[ParagraphRender], 77 ) -> Vec<SelectionRect> { 78 // For selection, we need all offset maps since selection can span paragraphs. 79 let all_maps: Vec<_> = paragraphs 80 .iter() 81 .flat_map(|p| p.offset_map.iter()) 82 .collect(); 83 let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect(); 84 get_selection_rects_relative(start, end, &borrowed, &self.editor_id) 85 } 86} 87 88/// Find the offset map for a character offset from paragraphs. 89/// 90/// Returns the offset map of the paragraph containing the given offset, 91/// or an empty slice if no paragraph contains it. 92fn 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(&[]) 106} 107 108/// Restore cursor position in the DOM after re-render. 109pub fn restore_cursor_position( 110 char_offset: usize, 111 offset_map: &[OffsetMapping], 112 snap_direction: Option<SnapDirection>, 113) -> Result<(), PlatformError> { 114 if offset_map.is_empty() { 115 return Ok(()); 116 } 117 118 let max_offset = offset_map 119 .iter() 120 .map(|m| m.char_range.end) 121 .max() 122 .unwrap_or(0); 123 124 if char_offset > max_offset { 125 tracing::warn!( 126 "cursor offset {} > max mapping offset {}", 127 char_offset, 128 max_offset 129 ); 130 return Ok(()); 131 } 132 133 let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { 134 Some((m, false)) => (m, char_offset), 135 Some((m, true)) => { 136 if let Some(snapped) = 137 find_nearest_valid_position(offset_map, char_offset, snap_direction) 138 { 139 tracing::trace!( 140 target: "weaver::cursor", 141 original_offset = char_offset, 142 snapped_offset = snapped.char_offset(), 143 direction = ?snapped.snapped, 144 "snapping cursor from invisible content" 145 ); 146 (snapped.mapping, snapped.char_offset()) 147 } else { 148 (m, char_offset) 149 } 150 } 151 None => return Err("no mapping found for cursor offset".into()), 152 }; 153 154 tracing::trace!( 155 target: "weaver::cursor", 156 char_offset, 157 node_id = %mapping.node_id, 158 mapping_range = ?mapping.char_range, 159 child_index = ?mapping.child_index, 160 "restoring cursor position" 161 ); 162 163 let window = web_sys::window().ok_or("no window")?; 164 let document = window.document().ok_or("no document")?; 165 166 let container = document 167 .get_element_by_id(&mapping.node_id) 168 .or_else(|| { 169 let selector = format!("[data-node-id='{}']", mapping.node_id); 170 document.query_selector(&selector).ok().flatten() 171 }) 172 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; 173 174 let selection = window 175 .get_selection() 176 .map_err(|e| format!("get_selection failed: {:?}", e))? 177 .ok_or("no selection object")?; 178 let range = document 179 .create_range() 180 .map_err(|e| format!("create_range failed: {:?}", e))?; 181 182 if let Some(child_index) = mapping.child_index { 183 range 184 .set_start(&container, child_index as u32) 185 .map_err(|e| format!("set_start failed: {:?}", e))?; 186 } else { 187 let container_element = container 188 .dyn_into::<web_sys::HtmlElement>() 189 .map_err(|_| "container is not HtmlElement")?; 190 let offset_in_range = char_offset - mapping.char_range.start; 191 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; 192 let (text_node, node_offset) = 193 find_text_node_at_offset(&container_element, target_utf16_offset)?; 194 range 195 .set_start(&text_node, node_offset as u32) 196 .map_err(|e| format!("set_start failed: {:?}", e))?; 197 } 198 199 range.collapse_with_to_start(true); 200 201 selection 202 .remove_all_ranges() 203 .map_err(|e| format!("remove_all_ranges failed: {:?}", e))?; 204 selection 205 .add_range(&range) 206 .map_err(|e| format!("add_range failed: {:?}", e))?; 207 208 Ok(()) 209} 210 211/// Find text node at given UTF-16 offset within element. 212pub fn find_text_node_at_offset( 213 container: &web_sys::HtmlElement, 214 target_utf16_offset: usize, 215) -> Result<(web_sys::Node, usize), PlatformError> { 216 let document = web_sys::window() 217 .ok_or("no window")? 218 .document() 219 .ok_or("no document")?; 220 221 let walker = document 222 .create_tree_walker_with_what_to_show(container, 0xFFFFFFFF) 223 .map_err(|e| format!("create_tree_walker failed: {:?}", e))?; 224 225 let mut accumulated_utf16 = 0; 226 let mut last_node: Option<web_sys::Node> = None; 227 let mut skip_until_exit: Option<web_sys::Element> = None; 228 229 while let Ok(Some(node)) = walker.next_node() { 230 if let Some(ref skip_elem) = skip_until_exit { 231 if !skip_elem.contains(Some(&node)) { 232 skip_until_exit = None; 233 } 234 } 235 236 if skip_until_exit.is_none() { 237 if let Some(element) = node.dyn_ref::<web_sys::Element>() { 238 if element.get_attribute("contenteditable").as_deref() == Some("false") { 239 skip_until_exit = Some(element.clone()); 240 continue; 241 } 242 } 243 } 244 245 if skip_until_exit.is_some() { 246 continue; 247 } 248 249 if node.node_type() != web_sys::Node::TEXT_NODE { 250 continue; 251 } 252 253 last_node = Some(node.clone()); 254 255 if let Some(text) = node.text_content() { 256 let text_len = text.encode_utf16().count(); 257 258 if accumulated_utf16 + text_len >= target_utf16_offset { 259 let offset_in_node = target_utf16_offset - accumulated_utf16; 260 return Ok((node, offset_in_node)); 261 } 262 263 accumulated_utf16 += text_len; 264 } 265 } 266 267 if let Some(node) = last_node { 268 if let Some(text) = node.text_content() { 269 let text_len = text.encode_utf16().count(); 270 return Ok((node, text_len)); 271 } 272 } 273 274 Err("no text node found in container".into()) 275} 276 277/// Get screen coordinates for a cursor position. 278/// 279/// Takes an offset map directly for cases where you don't have full paragraph data. 280pub fn get_cursor_rect(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> { 281 get_cursor_rect_impl(char_offset, offset_map) 282} 283 284/// Get screen coordinates relative to the editor container. 285/// 286/// Takes an offset map directly for cases where you don't have full paragraph data. 287pub fn get_cursor_rect_relative( 288 char_offset: usize, 289 offset_map: &[OffsetMapping], 290 editor_id: &str, 291) -> Option<CursorRect> { 292 let cursor_rect = get_cursor_rect(char_offset, offset_map)?; 293 294 let window = web_sys::window()?; 295 let document = window.document()?; 296 let editor = document.get_element_by_id(editor_id)?; 297 let editor_rect = editor.get_bounding_client_rect(); 298 299 Some(CursorRect::new( 300 cursor_rect.x - editor_rect.x(), 301 cursor_rect.y - editor_rect.y(), 302 cursor_rect.height, 303 )) 304} 305 306fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> { 307 if offset_map.is_empty() { 308 return None; 309 } 310 311 let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { 312 Some((m, _)) => (m, char_offset), 313 None => return None, 314 }; 315 316 let window = web_sys::window()?; 317 let document = window.document()?; 318 319 let container = document.get_element_by_id(&mapping.node_id).or_else(|| { 320 let selector = format!("[data-node-id='{}']", mapping.node_id); 321 document.query_selector(&selector).ok().flatten() 322 })?; 323 324 let range = document.create_range().ok()?; 325 326 if let Some(child_index) = mapping.child_index { 327 range.set_start(&container, child_index as u32).ok()?; 328 } else { 329 let container_element = container.dyn_into::<web_sys::HtmlElement>().ok()?; 330 let offset_in_range = char_offset - mapping.char_range.start; 331 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; 332 333 if let Ok((text_node, node_offset)) = 334 find_text_node_at_offset(&container_element, target_utf16_offset) 335 { 336 range.set_start(&text_node, node_offset as u32).ok()?; 337 } else { 338 return None; 339 } 340 } 341 342 range.collapse_with_to_start(true); 343 344 let rect = range.get_bounding_client_rect(); 345 Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0))) 346} 347 348/// Get selection rectangles relative to editor. 349/// 350/// Takes an offset map directly for cases where you don't have full paragraph data. 351/// Returns multiple rects if selection spans multiple lines. 352pub fn get_selection_rects_relative( 353 start: usize, 354 end: usize, 355 offset_map: &[OffsetMapping], 356 editor_id: &str, 357) -> Vec<SelectionRect> { 358 if offset_map.is_empty() || start >= end { 359 return vec![]; 360 } 361 362 let Some(window) = web_sys::window() else { 363 return vec![]; 364 }; 365 let Some(document) = window.document() else { 366 return vec![]; 367 }; 368 let Some(editor) = document.get_element_by_id(editor_id) else { 369 return vec![]; 370 }; 371 let editor_rect = editor.get_bounding_client_rect(); 372 373 let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else { 374 return vec![]; 375 }; 376 let Some((end_mapping, _)) = find_mapping_for_char(offset_map, end) else { 377 return vec![]; 378 }; 379 380 let start_container = document 381 .get_element_by_id(&start_mapping.node_id) 382 .or_else(|| { 383 let selector = format!("[data-node-id='{}']", start_mapping.node_id); 384 document.query_selector(&selector).ok().flatten() 385 }); 386 let end_container = document 387 .get_element_by_id(&end_mapping.node_id) 388 .or_else(|| { 389 let selector = format!("[data-node-id='{}']", end_mapping.node_id); 390 document.query_selector(&selector).ok().flatten() 391 }); 392 393 let (Some(start_container), Some(end_container)) = (start_container, end_container) else { 394 return vec![]; 395 }; 396 397 let Ok(range) = document.create_range() else { 398 return vec![]; 399 }; 400 401 // Set start 402 if let Some(child_index) = start_mapping.child_index { 403 let _ = range.set_start(&start_container, child_index as u32); 404 } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() 405 { 406 let offset_in_range = start - start_mapping.char_range.start; 407 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; 408 if let Ok((text_node, node_offset)) = 409 find_text_node_at_offset(&container_element, target_utf16_offset) 410 { 411 let _ = range.set_start(&text_node, node_offset as u32); 412 } 413 } 414 415 // Set end 416 if let Some(child_index) = end_mapping.child_index { 417 let _ = range.set_end(&end_container, child_index as u32); 418 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 419 let offset_in_range = end - end_mapping.char_range.start; 420 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; 421 if let Ok((text_node, node_offset)) = 422 find_text_node_at_offset(&container_element, target_utf16_offset) 423 { 424 let _ = range.set_end(&text_node, node_offset as u32); 425 } 426 } 427 428 let Some(rects) = range.get_client_rects() else { 429 return vec![]; 430 }; 431 let mut result = Vec::new(); 432 433 for i in 0..rects.length() { 434 if let Some(rect) = rects.get(i) { 435 let rect: web_sys::DomRect = rect; 436 result.push(SelectionRect::new( 437 rect.x() - editor_rect.x(), 438 rect.y() - editor_rect.y(), 439 rect.width(), 440 rect.height().max(16.0), 441 )); 442 } 443 } 444 445 result 446}