atproto blogging
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 ¶.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}