From aba348b665e7dd2964abcb564e9eeff9c095800a Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 6 Jan 2026 13:14:14 -0500 Subject: [PATCH] further extraction --- .../src/components/editor/beforeinput.rs | 34 +-- .../src/components/editor/cursor.rs | 223 +------------- .../src/components/editor/dom_sync.rs | 271 +----------------- .../src/components/editor/platform.rs | 117 +------- crates/weaver-editor-browser/src/cursor.rs | 47 ++- crates/weaver-editor-browser/src/dom_sync.rs | 167 ++++++++--- crates/weaver-editor-browser/src/events.rs | 20 +- crates/weaver-editor-browser/src/lib.rs | 7 +- crates/weaver-editor-browser/tests/web.rs | 54 ++-- crates/weaver-editor-core/src/platform.rs | 14 +- docs/graph-data.json | 44 +++ 11 files changed, 278 insertions(+), 720 deletions(-) diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs index 8a59e07..9967753 100644 --- a/crates/weaver-app/src/components/editor/beforeinput.rs +++ b/crates/weaver-app/src/components/editor/beforeinput.rs @@ -21,44 +21,12 @@ use super::document::EditorDocument; use super::platform::Platform; // Re-export types from extracted crates. +pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; pub use weaver_editor_core::{InputType, Range}; #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] pub use weaver_editor_browser::StaticRange; -/// Result of handling a beforeinput event. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum BeforeInputResult { - /// Event was handled, prevent default browser behavior. - Handled, - /// Event should be handled by browser (e.g., during composition). - PassThrough, - /// Event was handled but requires async follow-up (e.g., paste). - HandledAsync, - /// Android backspace workaround: defer and check if browser handled it. - DeferredCheck { - /// The action to execute if browser didn't handle it. - fallback_action: EditorAction, - }, -} - -/// Context for beforeinput handling. -#[allow(dead_code)] -pub struct BeforeInputContext<'a> { - /// The input type. - pub input_type: InputType, - /// The data (text to insert, if any). - pub data: Option, - /// Target range from getTargetRanges(), if available. - /// This is the range the browser wants to modify. - pub target_range: Option, - /// Whether the event is part of an IME composition. - pub is_composing: bool, - /// Platform info for quirks handling. - pub platform: &'a Platform, -} - /// Handle a beforeinput event. /// /// This is the main entry point for beforeinput-based input handling. diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index 5d5cf72..987f042 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -1,201 +1,12 @@ -//! Cursor position restoration in the DOM. +//! Cursor position operations. //! -//! After re-rendering HTML, we need to restore the cursor to its original -//! position in the source text. This involves: -//! 1. Finding the offset mapping for the cursor's char position -//! 2. Getting the DOM element by node ID -//! 3. Walking text nodes to find the UTF-16 offset within the element -//! 4. Setting cursor with web_sys Selection API +//! Re-exports from browser crate with app-specific adapters. -use weaver_editor_core::OffsetMapping; -pub use weaver_editor_core::{CursorRect, SelectionRect}; -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; - -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -use wasm_bindgen::JsCast; - -/// Restore cursor position in the DOM after re-render. -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn restore_cursor_position( - char_offset: usize, - offset_map: &[OffsetMapping], - editor_id: &str, - snap_direction: Option, -) -> Result<(), wasm_bindgen::JsValue> { - // Empty document - no cursor to restore - if offset_map.is_empty() { - return Ok(()); - } - - // Bounds check using offset map - let max_offset = offset_map - .iter() - .map(|m| m.char_range.end) - .max() - .unwrap_or(0); - if char_offset > max_offset { - tracing::warn!( - "cursor offset {} > max mapping offset {}", - char_offset, - max_offset - ); - // Don't error, just skip restoration - this can happen during edits - return Ok(()); - } - - // Find mapping for this cursor position, snapping if needed - let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { - Some((m, false)) => (m, char_offset), // Valid position, use as-is - Some((m, true)) => { - // Position is on invisible content, snap to nearest valid - if let Some(snapped) = - find_nearest_valid_position(offset_map, char_offset, snap_direction) - { - tracing::trace!( - target: "weaver::cursor", - original_offset = char_offset, - snapped_offset = snapped.char_offset(), - direction = ?snapped.snapped, - "snapping cursor from invisible content" - ); - (snapped.mapping, snapped.char_offset()) - } else { - // Fallback to original mapping if no valid snap target - (m, char_offset) - } - } - None => return Err("no mapping found for cursor offset".into()), - }; - - tracing::trace!( - target: "weaver::cursor", - char_offset, - node_id = %mapping.node_id, - mapping_range = ?mapping.char_range, - child_index = ?mapping.child_index, - "restoring cursor position" - ); - - // Get window and document - let window = web_sys::window().ok_or("no window")?; - let document = window.document().ok_or("no document")?; - - // Get the container element by node ID (try id attribute first, then data-node-id) - let container = document - .get_element_by_id(&mapping.node_id) - .or_else(|| { - let selector = format!("[data-node-id='{}']", mapping.node_id); - document.query_selector(&selector).ok().flatten() - }) - .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; - - // Set selection using Range API - let selection = window.get_selection()?.ok_or("no selection object")?; - let range = document.create_range()?; - - // Check if this is an element-based position (e.g., after
) - if let Some(child_index) = mapping.child_index { - // Position cursor at child index in the element - range.set_start(&container, child_index as u32)?; - } else { - // Position cursor in text content - let container_element = container.dyn_into::()?; - let offset_in_range = char_offset - mapping.char_range.start; - let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; - let (text_node, node_offset) = - find_text_node_at_offset(&container_element, target_utf16_offset)?; - range.set_start(&text_node, node_offset as u32)?; - } - - range.collapse_with_to_start(true); - - selection.remove_all_ranges()?; - selection.add_range(&range)?; +pub use weaver_editor_browser::restore_cursor_position; +pub use weaver_editor_core::{CursorRect, OffsetMapping, SelectionRect}; - Ok(()) -} - -/// Find text node at given UTF-16 offset within element. -/// -/// Walks all text nodes in the container, accumulating their UTF-16 lengths -/// until we find the node containing the target offset. -/// Skips text nodes inside contenteditable="false" elements (like embeds). -/// -/// Returns (text_node, offset_within_node). #[cfg(all(target_family = "wasm", target_os = "unknown"))] -fn find_text_node_at_offset( - container: &web_sys::HtmlElement, - target_utf16_offset: usize, -) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> { - let document = web_sys::window() - .ok_or("no window")? - .document() - .ok_or("no document")?; - - // Use SHOW_ALL to see element boundaries for tracking non-editable regions - let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?; - - let mut accumulated_utf16 = 0; - let mut last_node: Option = None; - let mut skip_until_exit: Option = None; - - while let Some(node) = walker.next_node()? { - // Check if we've exited the non-editable subtree - if let Some(ref skip_elem) = skip_until_exit { - if !skip_elem.contains(Some(&node)) { - skip_until_exit = None; - } - } - - // Check if entering a non-editable element - if skip_until_exit.is_none() { - if let Some(element) = node.dyn_ref::() { - if element.get_attribute("contenteditable").as_deref() == Some("false") { - skip_until_exit = Some(element.clone()); - continue; - } - } - } - - // Skip everything inside non-editable regions - if skip_until_exit.is_some() { - continue; - } - - // Only process text nodes - if node.node_type() != web_sys::Node::TEXT_NODE { - continue; - } - - last_node = Some(node.clone()); - - if let Some(text) = node.text_content() { - let text_len = text.encode_utf16().count(); - - // Found the node containing target offset - if accumulated_utf16 + text_len >= target_utf16_offset { - let offset_in_node = target_utf16_offset - accumulated_utf16; - return Ok((node, offset_in_node)); - } - - accumulated_utf16 += text_len; - } - } - - // Fallback: return last node at its end - // This handles cursor at end of document - if let Some(node) = last_node { - if let Some(text) = node.text_content() { - let text_len = text.encode_utf16().count(); - return Ok((node, text_len)); - } - } - - Err("no text node found in container".into()) -} - -// CursorRect is imported from weaver_editor_core. +use weaver_editor_core::{SnapDirection, find_mapping_for_char}; /// Get screen coordinates for a character offset in the editor. /// @@ -204,13 +15,14 @@ fn find_text_node_at_offset( pub fn get_cursor_rect( char_offset: usize, offset_map: &[OffsetMapping], - editor_id: &str, + _editor_id: &str, ) -> Option { + use wasm_bindgen::JsCast; + if offset_map.is_empty() { return None; } - // Find mapping for this position let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { Some((m, _)) => (m, char_offset), None => return None, @@ -219,7 +31,6 @@ pub fn get_cursor_rect( let window = web_sys::window()?; let document = window.document()?; - // Get container element let container = document.get_element_by_id(&mapping.node_id).or_else(|| { let selector = format!("[data-node-id='{}']", mapping.node_id); document.query_selector(&selector).ok().flatten() @@ -227,7 +38,6 @@ pub fn get_cursor_rect( let range = document.create_range().ok()?; - // Position the range at the character offset if let Some(child_index) = mapping.child_index { range.set_start(&container, child_index as u32).ok()?; } else { @@ -236,7 +46,7 @@ pub fn get_cursor_rect( let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; if let Ok((text_node, node_offset)) = - find_text_node_at_offset(&container_element, target_utf16_offset) + weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) { range.set_start(&text_node, node_offset as u32).ok()?; } else { @@ -246,12 +56,11 @@ pub fn get_cursor_rect( range.collapse_with_to_start(true); - // Get the bounding rect let rect = range.get_bounding_client_rect(); Some(CursorRect { x: rect.x(), y: rect.y(), - height: rect.height().max(16.0), // Minimum height for empty lines + height: rect.height().max(16.0), }) } @@ -285,8 +94,6 @@ pub fn get_cursor_rect_relative( None } -// SelectionRect is imported from weaver_editor_core. - /// Get screen rectangles for a selection range, relative to editor. /// /// Returns multiple rects if selection spans multiple lines. @@ -314,7 +121,6 @@ pub fn get_selection_rects_relative( }; let editor_rect = editor.get_bounding_client_rect(); - // Find mappings for start and end let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else { return vec![]; }; @@ -322,7 +128,6 @@ pub fn get_selection_rects_relative( return vec![]; }; - // Get containers let start_container = document .get_element_by_id(&start_mapping.node_id) .or_else(|| { @@ -340,12 +145,10 @@ pub fn get_selection_rects_relative( return vec![]; }; - // Create range let Ok(range) = document.create_range() else { return vec![]; }; - // Set start if let Some(child_index) = start_mapping.child_index { let _ = range.set_start(&start_container, child_index as u32); } else if let Ok(container_element) = start_container.clone().dyn_into::() @@ -353,26 +156,24 @@ pub fn get_selection_rects_relative( let offset_in_range = start - start_mapping.char_range.start; let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; if let Ok((text_node, node_offset)) = - find_text_node_at_offset(&container_element, target_utf16_offset) + weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) { let _ = range.set_start(&text_node, node_offset as u32); } } - // Set end if let Some(child_index) = end_mapping.child_index { let _ = range.set_end(&end_container, child_index as u32); } else if let Ok(container_element) = end_container.dyn_into::() { let offset_in_range = end - end_mapping.char_range.start; let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; if let Ok((text_node, node_offset)) = - find_text_node_at_offset(&container_element, target_utf16_offset) + weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) { let _ = range.set_end(&text_node, node_offset as u32); } } - // Get all rects (one per line) let Some(rects) = range.get_client_rects() else { return vec![]; }; diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index 2213d1e..778df02 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -2,16 +2,22 @@ //! //! Handles syncing cursor/selection state between the browser DOM and our //! internal document model, and updating paragraph DOM elements. +//! +//! The core DOM position conversion is provided by `weaver_editor_browser`. #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use super::cursor::restore_cursor_position; #[allow(unused_imports)] use super::document::{EditorDocument, Selection}; -#[allow(unused_imports)] -use weaver_editor_core::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; use super::paragraph::ParagraphRender; #[allow(unused_imports)] use dioxus::prelude::*; +#[allow(unused_imports)] +use weaver_editor_core::SnapDirection; + +// Re-export the DOM position conversion from browser crate. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use weaver_editor_browser::dom_position_to_text_offset; /// Sync internal cursor and selection state from browser DOM selection. /// @@ -125,267 +131,6 @@ pub fn sync_cursor_from_dom_with_direction( } } -/// Convert a DOM position (node + offset) to a rope char offset using offset maps. -/// -/// The `direction_hint` is used when snapping from invisible content to determine -/// which direction to prefer. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub fn dom_position_to_text_offset( - dom_document: &web_sys::Document, - editor_element: &web_sys::Element, - node: &web_sys::Node, - offset_in_text_node: usize, - paragraphs: &[ParagraphRender], - direction_hint: Option, -) -> Option { - use wasm_bindgen::JsCast; - - // Find the containing element with a node ID (walk up from text node) - let mut current_node = node.clone(); - let mut walked_from: Option = None; // Track the child we walked up from - let node_id = loop { - let node_name = current_node.node_name(); - let node_id_attr = current_node - .dyn_ref::() - .and_then(|e| e.get_attribute("id")); - tracing::trace!( - node_name = %node_name, - node_id_attr = ?node_id_attr, - "dom_position_to_text_offset: walk-up iteration" - ); - - if let Some(element) = current_node.dyn_ref::() { - if element == editor_element { - // Selection is on the editor container itself - // - // IMPORTANT: If we WALKED UP to the editor from a descendant, - // offset_in_text_node is the offset within that descendant, NOT the - // child index in the editor. We need to find which paragraph contains - // the node we walked from. - if let Some(ref walked_node) = walked_from { - // We walked up from a descendant - find which paragraph it belongs to - tracing::debug!( - walked_from_node_name = %walked_node.node_name(), - "dom_position_to_text_offset: walked up to editor from descendant" - ); - - // Find paragraph containing this node by checking paragraph wrapper divs - for (idx, para) in paragraphs.iter().enumerate() { - if let Some(para_elem) = dom_document.get_element_by_id(¶.id) { - let para_node: &web_sys::Node = para_elem.as_ref(); - if para_node.contains(Some(walked_node)) { - // Found the paragraph - return its start - tracing::trace!( - para_id = %para.id, - para_idx = idx, - char_start = para.char_range.start, - "dom_position_to_text_offset: found containing paragraph" - ); - return Some(para.char_range.start); - } - } - } - // Couldn't find containing paragraph, fall through - tracing::warn!( - "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph" - ); - break None; - } - - // Selection is directly on the editor container (e.g., Cmd+A select all) - // Return boundary position based on offset: - // offset 0 = start of editor, offset == child count = end of editor - let child_count = editor_element.child_element_count() as usize; - if offset_in_text_node == 0 { - return Some(0); // Start of document - } else if offset_in_text_node >= child_count { - // End of document - find last paragraph's end - return paragraphs.last().map(|p| p.char_range.end); - } - break None; - } - - let id = element - .get_attribute("id") - .or_else(|| element.get_attribute("data-node-id")); - - if let Some(id) = id { - // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs - let is_node_id = id.starts_with('n') || id.contains("-n"); - tracing::trace!( - id = %id, - is_node_id, - starts_with_n = id.starts_with('n'), - contains_dash_n = id.contains("-n"), - "dom_position_to_text_offset: checking ID pattern" - ); - if is_node_id { - break Some(id); - } - } - } - - walked_from = Some(current_node.clone()); - current_node = current_node.parent_node()?; - }; - - let node_id = node_id?; - - let container = dom_document.get_element_by_id(&node_id).or_else(|| { - let selector = format!("[data-node-id='{}']", node_id); - dom_document.query_selector(&selector).ok().flatten() - })?; - - // Calculate UTF-16 offset from start of container to the position - // Skip text nodes inside contenteditable="false" elements (like embeds) - let mut utf16_offset_in_container = 0; - - // Check if the node IS the container element itself (not a text node descendant) - // In this case, offset_in_text_node is actually a child index, not a character offset - let node_is_container = node - .dyn_ref::() - .map(|e| e == &container) - .unwrap_or(false); - - if node_is_container { - // offset_in_text_node is a child index - count text content up to that child - let child_index = offset_in_text_node; - let children = container.child_nodes(); - let mut text_counted = 0usize; - - for i in 0..child_index.min(children.length() as usize) { - if let Some(child) = children.get(i as u32) { - if let Some(text) = child.text_content() { - text_counted += text.encode_utf16().count(); - } - } - } - utf16_offset_in_container = text_counted; - - tracing::debug!( - child_index, - utf16_offset = utf16_offset_in_container, - "dom_position_to_text_offset: node is container, using child index" - ); - } else { - // Normal case: node is a text node, walk to find it - // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions - if let Ok(walker) = - dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) - { - // Track the non-editable element we're inside (if any) - let mut skip_until_exit: Option = None; - - while let Ok(Some(dom_node)) = walker.next_node() { - // Check if we've exited the non-editable subtree - if let Some(ref skip_elem) = skip_until_exit { - if !skip_elem.contains(Some(&dom_node)) { - skip_until_exit = None; - } - } - - // Check if entering a non-editable element - if skip_until_exit.is_none() { - if let Some(element) = dom_node.dyn_ref::() { - if element.get_attribute("contenteditable").as_deref() == Some("false") { - skip_until_exit = Some(element.clone()); - continue; - } - } - } - - // Skip everything inside non-editable regions - if skip_until_exit.is_some() { - continue; - } - - // Only process text nodes - if dom_node.node_type() == web_sys::Node::TEXT_NODE { - if &dom_node == node { - utf16_offset_in_container += offset_in_text_node; - break; - } - - if let Some(text) = dom_node.text_content() { - utf16_offset_in_container += text.encode_utf16().count(); - } - } - } - } - } - - // Log what we're looking for - tracing::trace!( - node_id = %node_id, - utf16_offset = utf16_offset_in_container, - num_paragraphs = paragraphs.len(), - "dom_position_to_text_offset: looking up mapping" - ); - - for para in paragraphs { - for mapping in ¶.offset_map { - if mapping.node_id == node_id { - let mapping_start = mapping.char_offset_in_node; - let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; - - tracing::trace!( - mapping_node_id = %mapping.node_id, - mapping_start, - mapping_end, - char_range_start = mapping.char_range.start, - char_range_end = mapping.char_range.end, - "dom_position_to_text_offset: found matching node_id" - ); - - if utf16_offset_in_container >= mapping_start - && utf16_offset_in_container <= mapping_end - { - let offset_in_mapping = utf16_offset_in_container - mapping_start; - let char_offset = mapping.char_range.start + offset_in_mapping; - - tracing::trace!( - node_id = %node_id, - utf16_offset = utf16_offset_in_container, - mapping_start, - mapping_end, - offset_in_mapping, - char_range_start = mapping.char_range.start, - char_offset, - "dom_position_to_text_offset: MATCHED mapping" - ); - - // Check if this position is valid (not on invisible content) - if is_valid_cursor_position(¶.offset_map, char_offset) { - return Some(char_offset); - } - - // Position is on invisible content, snap to nearest valid - if let Some(snapped) = - find_nearest_valid_position(¶.offset_map, char_offset, direction_hint) - { - return Some(snapped.char_offset()); - } - - // Fallback to original if no snap target - return Some(char_offset); - } - } - } - } - - // No mapping found - try to find any valid position in paragraphs - // This handles clicks on non-text elements like images - for para in paragraphs { - if let Some(snapped) = - find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint) - { - return Some(snapped.char_offset()); - } - } - - None -} - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub fn sync_cursor_from_dom( _document: &mut EditorDocument, diff --git a/crates/weaver-app/src/components/editor/platform.rs b/crates/weaver-app/src/components/editor/platform.rs index 03d4094..56833d5 100644 --- a/crates/weaver-app/src/components/editor/platform.rs +++ b/crates/weaver-app/src/components/editor/platform.rs @@ -1,118 +1,5 @@ //! Platform detection for browser-specific workarounds. //! -//! Based on patterns from ProseMirror's input handling, adapted for Rust/wasm. +//! Re-exports from browser crate. -use std::sync::OnceLock; - -/// Cached platform detection results. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct Platform { - pub ios: bool, - pub mac: bool, - pub android: bool, - pub chrome: bool, - pub safari: bool, - pub gecko: bool, - pub webkit_version: Option, - pub chrome_version: Option, - pub mobile: bool, -} - -impl Default for Platform { - fn default() -> Self { - Self { - ios: false, - mac: false, - android: false, - chrome: false, - safari: false, - gecko: false, - webkit_version: None, - chrome_version: None, - mobile: false, - } - } -} - -static PLATFORM: OnceLock = OnceLock::new(); - -/// Get cached platform info. Detection runs once on first call. -pub fn platform() -> &'static Platform { - PLATFORM.get_or_init(detect_platform) -} - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn detect_platform() -> Platform { - let window = match web_sys::window() { - Some(w) => w, - None => return Platform::default(), - }; - - let navigator = window.navigator(); - let user_agent = navigator.user_agent().unwrap_or_default().to_lowercase(); - let platform_str = navigator.platform().unwrap_or_default().to_lowercase(); - - // iOS detection: iPhone/iPad/iPod in UA, or Mac platform with touch - let ios = user_agent.contains("iphone") - || user_agent.contains("ipad") - || user_agent.contains("ipod") - || (platform_str.contains("mac") && has_touch_support(&navigator)); - - // macOS (but not iOS) - let mac = platform_str.contains("mac") && !ios; - - // Android - let android = user_agent.contains("android"); - - // Chrome (but not Edge, which also contains Chrome) - let chrome = user_agent.contains("chrome") && !user_agent.contains("edg"); - - // Safari (WebKit but not Chrome) - let safari = user_agent.contains("safari") && !user_agent.contains("chrome"); - - // Firefox/Gecko - let gecko = user_agent.contains("gecko/") && !user_agent.contains("like gecko"); - - // WebKit version extraction - let webkit_version = extract_version(&user_agent, "applewebkit/"); - - // Chrome version extraction - let chrome_version = extract_version(&user_agent, "chrome/"); - - // Mobile detection - let mobile = ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile"); - - Platform { - ios, - mac, - android, - chrome, - safari, - gecko, - webkit_version, - chrome_version, - mobile, - } -} - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn has_touch_support(navigator: &web_sys::Navigator) -> bool { - // Check maxTouchPoints > 0 (indicates touch capability) - navigator.max_touch_points() > 0 -} - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn extract_version(ua: &str, prefix: &str) -> Option { - ua.find(prefix).and_then(|idx| { - let after = &ua[idx + prefix.len()..]; - // Take digits until non-digit - let version_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); - version_str.parse().ok() - }) -} - -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -fn detect_platform() -> Platform { - Platform::default() -} +pub use weaver_editor_browser::{Platform, platform}; diff --git a/crates/weaver-editor-browser/src/cursor.rs b/crates/weaver-editor-browser/src/cursor.rs index 9d7e13e..0bb99fa 100644 --- a/crates/weaver-editor-browser/src/cursor.rs +++ b/crates/weaver-editor-browser/src/cursor.rs @@ -4,8 +4,8 @@ use wasm_bindgen::JsCast; use weaver_editor_core::{ - CursorPlatform, CursorRect, OffsetMapping, PlatformError, SelectionRect, SnapDirection, - find_mapping_for_char, find_nearest_valid_position, + CursorPlatform, CursorRect, OffsetMapping, ParagraphRender, PlatformError, SelectionRect, + SnapDirection, find_mapping_for_char, find_nearest_valid_position, }; /// Browser-based cursor platform implementation. @@ -33,26 +33,29 @@ impl CursorPlatform for BrowserCursor { fn restore_cursor( &self, char_offset: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], snap_direction: Option, ) -> Result<(), PlatformError> { + // Find the paragraph containing this offset and use its offset map. + let offset_map = find_offset_map_for_char(paragraphs, char_offset); restore_cursor_position(char_offset, offset_map, snap_direction) } fn get_cursor_rect( &self, char_offset: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Option { + let offset_map = find_offset_map_for_char(paragraphs, char_offset); get_cursor_rect_impl(char_offset, offset_map) } fn get_cursor_rect_relative( &self, char_offset: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Option { - let cursor_rect = self.get_cursor_rect(char_offset, offset_map)?; + let cursor_rect = self.get_cursor_rect(char_offset, paragraphs)?; let window = web_sys::window()?; let document = window.document()?; @@ -70,12 +73,38 @@ impl CursorPlatform for BrowserCursor { &self, start: usize, end: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Vec { - get_selection_rects_impl(start, end, offset_map, &self.editor_id) + // For selection, we need all offset maps since selection can span paragraphs. + let all_maps: Vec<_> = paragraphs + .iter() + .flat_map(|p| p.offset_map.iter()) + .collect(); + let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect(); + get_selection_rects_impl(start, end, &borrowed, &self.editor_id) } } +/// Find the offset map for a character offset from paragraphs. +/// +/// Returns the offset map of the paragraph containing the given offset, +/// or an empty slice if no paragraph contains it. +fn find_offset_map_for_char( + paragraphs: &[ParagraphRender], + char_offset: usize, +) -> &[OffsetMapping] { + for para in paragraphs { + if para.char_range.start <= char_offset && char_offset <= para.char_range.end { + return ¶.offset_map; + } + } + // Fallback: if offset is past the end, use the last paragraph. + paragraphs + .last() + .map(|p| p.offset_map.as_slice()) + .unwrap_or(&[]) +} + /// Restore cursor position in the DOM after re-render. pub fn restore_cursor_position( char_offset: usize, @@ -180,7 +209,7 @@ pub fn restore_cursor_position( } /// Find text node at given UTF-16 offset within element. -fn find_text_node_at_offset( +pub fn find_text_node_at_offset( container: &web_sys::HtmlElement, target_utf16_offset: usize, ) -> Result<(web_sys::Node, usize), PlatformError> { diff --git a/crates/weaver-editor-browser/src/dom_sync.rs b/crates/weaver-editor-browser/src/dom_sync.rs index 22a5ea3..fe5f1b4 100644 --- a/crates/weaver-editor-browser/src/dom_sync.rs +++ b/crates/weaver-editor-browser/src/dom_sync.rs @@ -5,7 +5,8 @@ use wasm_bindgen::JsCast; use weaver_editor_core::{ - CursorSync, OffsetMapping, SnapDirection, find_nearest_valid_position, is_valid_cursor_position, + CursorSync, OffsetMapping, ParagraphRender, SnapDirection, find_nearest_valid_position, + is_valid_cursor_position, }; use crate::cursor::restore_cursor_position; @@ -46,7 +47,7 @@ impl BrowserCursorSync { impl CursorSync for BrowserCursorSync { fn sync_cursor_from_platform( &self, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], direction_hint: Option, on_cursor: F, on_selection: G, @@ -54,7 +55,7 @@ impl CursorSync for BrowserCursorSync { F: FnOnce(usize), G: FnOnce(usize, usize), { - if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, offset_map, direction_hint) + if let Some(result) = sync_cursor_from_dom_impl(&self.editor_id, paragraphs, direction_hint) { match result { CursorSyncResult::Cursor(offset) => on_cursor(offset), @@ -74,13 +75,13 @@ impl CursorSync for BrowserCursorSync { /// Sync cursor state from DOM selection, returning the result. /// /// This is the core implementation that reads the browser's selection state -/// and converts it to character offsets using the offset map. +/// and converts it to character offsets using paragraph offset maps. pub fn sync_cursor_from_dom_impl( editor_id: &str, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], direction_hint: Option, ) -> Option { - if offset_map.is_empty() { + if paragraphs.is_empty() { return Some(CursorSyncResult::None); } @@ -100,7 +101,7 @@ pub fn sync_cursor_from_dom_impl( &editor_element, &anchor_node, anchor_offset, - offset_map, + paragraphs, direction_hint, ); let focus_char = dom_position_to_text_offset( @@ -108,7 +109,7 @@ pub fn sync_cursor_from_dom_impl( &editor_element, &focus_node, focus_offset, - offset_map, + paragraphs, direction_hint, ); @@ -130,13 +131,15 @@ pub fn sync_cursor_from_dom_impl( /// Convert a DOM position (node + offset) to a text char offset. /// /// Walks up from the node to find a container with a node ID, then uses -/// the offset map to convert the UTF-16 offset to a character offset. +/// the paragraph offset maps to convert the UTF-16 offset to a character offset. +/// The `direction_hint` is used when snapping from invisible content to determine +/// which direction to prefer. pub fn dom_position_to_text_offset( dom_document: &web_sys::Document, editor_element: &web_sys::Element, node: &web_sys::Node, offset_in_text_node: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], direction_hint: Option, ) -> Option { // Find the containing element with a node ID (walk up from text node). @@ -144,19 +147,46 @@ pub fn dom_position_to_text_offset( let mut walked_from: Option = None; let node_id = loop { + let node_name = current_node.node_name(); + let node_id_attr = current_node + .dyn_ref::() + .and_then(|e| e.get_attribute("id")); + tracing::trace!( + node_name = %node_name, + node_id_attr = ?node_id_attr, + "dom_position_to_text_offset: walk-up iteration" + ); + if let Some(element) = current_node.dyn_ref::() { if element == editor_element { // Selection is on the editor container itself. + // IMPORTANT: If we WALKED UP to the editor from a descendant, + // offset_in_text_node is the offset within that descendant, NOT the + // child index in the editor. if let Some(ref walked_node) = walked_from { - // We walked up from a descendant - find which mapping it belongs to. - for mapping in offset_map { - if let Some(elem) = dom_document.get_element_by_id(&mapping.node_id) { - let elem_node: &web_sys::Node = elem.as_ref(); - if elem_node.contains(Some(walked_node)) { - return Some(mapping.char_range.start); + tracing::debug!( + walked_from_node_name = %walked_node.node_name(), + "dom_position_to_text_offset: walked up to editor from descendant" + ); + + // Find paragraph containing this node by checking paragraph wrapper divs. + for (idx, para) in paragraphs.iter().enumerate() { + if let Some(para_elem) = dom_document.get_element_by_id(¶.id) { + let para_node: &web_sys::Node = para_elem.as_ref(); + if para_node.contains(Some(walked_node)) { + tracing::trace!( + para_id = %para.id, + para_idx = idx, + char_start = para.char_range.start, + "dom_position_to_text_offset: found containing paragraph" + ); + return Some(para.char_range.start); } } } + tracing::warn!( + "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph" + ); break None; } @@ -165,7 +195,7 @@ pub fn dom_position_to_text_offset( if offset_in_text_node == 0 { return Some(0); } else if offset_in_text_node >= child_count { - return offset_map.last().map(|m| m.char_range.end); + return paragraphs.last().map(|p| p.char_range.end); } break None; } @@ -175,7 +205,15 @@ pub fn dom_position_to_text_offset( .or_else(|| element.get_attribute("data-node-id")); if let Some(id) = id { + // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs. let is_node_id = id.starts_with('n') || id.contains("-n"); + tracing::trace!( + id = %id, + is_node_id, + starts_with_n = id.starts_with('n'), + contains_dash_n = id.contains("-n"), + "dom_position_to_text_offset: checking ID pattern" + ); if is_node_id { break Some(id); } @@ -202,7 +240,7 @@ pub fn dom_position_to_text_offset( .unwrap_or(false); if node_is_container { - // offset_in_text_node is a child index. + // offset_in_text_node is a child index - count text content up to that child. let child_index = offset_in_text_node; let children = container.child_nodes(); let mut text_counted = 0usize; @@ -215,6 +253,12 @@ pub fn dom_position_to_text_offset( } } utf16_offset_in_container = text_counted; + + tracing::debug!( + child_index, + utf16_offset = utf16_offset_in_container, + "dom_position_to_text_offset: node is container, using child index" + ); } else { // Normal case: node is a text node, walk to find it. if let Ok(walker) = @@ -256,38 +300,73 @@ pub fn dom_position_to_text_offset( } } - // Look up the offset in the offset map. - for mapping in offset_map { - if mapping.node_id == node_id { - let mapping_start = mapping.char_offset_in_node; - let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; + // Log what we're looking for. + tracing::trace!( + node_id = %node_id, + utf16_offset = utf16_offset_in_container, + num_paragraphs = paragraphs.len(), + "dom_position_to_text_offset: looking up mapping" + ); - if utf16_offset_in_container >= mapping_start - && utf16_offset_in_container <= mapping_end - { - let offset_in_mapping = utf16_offset_in_container - mapping_start; - let char_offset = mapping.char_range.start + offset_in_mapping; + // Look up the offset in paragraph offset maps. + for para in paragraphs { + for mapping in ¶.offset_map { + if mapping.node_id == node_id { + let mapping_start = mapping.char_offset_in_node; + let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; + + tracing::trace!( + mapping_node_id = %mapping.node_id, + mapping_start, + mapping_end, + char_range_start = mapping.char_range.start, + char_range_end = mapping.char_range.end, + "dom_position_to_text_offset: found matching node_id" + ); + + if utf16_offset_in_container >= mapping_start + && utf16_offset_in_container <= mapping_end + { + let offset_in_mapping = utf16_offset_in_container - mapping_start; + let char_offset = mapping.char_range.start + offset_in_mapping; + + tracing::trace!( + node_id = %node_id, + utf16_offset = utf16_offset_in_container, + mapping_start, + mapping_end, + offset_in_mapping, + char_range_start = mapping.char_range.start, + char_offset, + "dom_position_to_text_offset: MATCHED mapping" + ); + + // Check if position is valid (not on invisible content). + if is_valid_cursor_position(¶.offset_map, char_offset) { + return Some(char_offset); + } - // Check if position is valid (not on invisible content). - if is_valid_cursor_position(offset_map, char_offset) { - return Some(char_offset); - } + // Position is on invisible content, snap to nearest valid. + if let Some(snapped) = + find_nearest_valid_position(¶.offset_map, char_offset, direction_hint) + { + return Some(snapped.char_offset()); + } - // Position is on invisible content, snap to nearest valid. - if let Some(snapped) = - find_nearest_valid_position(offset_map, char_offset, direction_hint) - { - return Some(snapped.char_offset()); + // Fallback to original if no snap target. + return Some(char_offset); } - - return Some(char_offset); } } } - // No mapping found - try to find any valid position. - if let Some(snapped) = find_nearest_valid_position(offset_map, 0, direction_hint) { - return Some(snapped.char_offset()); + // No mapping found - try to find any valid position in paragraphs. + for para in paragraphs { + if let Some(snapped) = + find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint) + { + return Some(snapped.char_offset()); + } } None @@ -356,8 +435,8 @@ pub fn update_paragraph_dom( for new_para in new_paragraphs.iter() { let para_id = new_para.id; let new_hash = format!("{:x}", new_para.source_hash); - let is_cursor_para = new_para.char_range.start <= cursor_offset - && cursor_offset <= new_para.char_range.end; + let is_cursor_para = + new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; if let Some(existing_elem) = old_elements.remove(para_id) { let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); diff --git a/crates/weaver-editor-browser/src/events.rs b/crates/weaver-editor-browser/src/events.rs index 25c60ff..2c99a5a 100644 --- a/crates/weaver-editor-browser/src/events.rs +++ b/crates/weaver-editor-browser/src/events.rs @@ -4,9 +4,10 @@ //! the `beforeinput` event and other DOM events. use wasm_bindgen::prelude::*; -use weaver_editor_core::{InputType, OffsetMapping, Range}; +use weaver_editor_core::{InputType, ParagraphRender, Range}; use crate::dom_sync::dom_position_to_text_offset; +use crate::platform::Platform; // === StaticRange binding === // @@ -115,15 +116,12 @@ pub struct BeforeInputContext<'a> { /// The data (text to insert, if any). pub data: Option, /// Target range from getTargetRanges(), if available. + /// This is the range the browser wants to modify. pub target_range: Option, /// Whether the event is part of an IME composition. pub is_composing: bool, - /// Whether we're on Android. - pub is_android: bool, - /// Whether we're on Chrome. - pub is_chrome: bool, - /// Offset mappings for the document. - pub offset_map: &'a [OffsetMapping], + /// Platform info for quirks handling. + pub platform: &'a Platform, } /// Extract target range from a beforeinput event. @@ -132,7 +130,7 @@ pub struct BeforeInputContext<'a> { pub fn get_target_range_from_event( event: &web_sys::InputEvent, editor_id: &str, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Option { use wasm_bindgen::JsCast; @@ -157,7 +155,7 @@ pub fn get_target_range_from_event( &editor_element, &start_container, start_offset, - offset_map, + paragraphs, None, )?; @@ -166,7 +164,7 @@ pub fn get_target_range_from_event( &editor_element, &end_container, end_offset, - offset_map, + paragraphs, None, )?; @@ -337,7 +335,7 @@ pub fn handle_beforeinput( // === Deletion === InputType::DeleteContentBackward => { // Android Chrome workaround: backspace sometimes doesn't work properly. - if ctx.is_android && ctx.is_chrome && range.is_caret() { + if ctx.platform.android && ctx.platform.chrome && range.is_caret() { let action = EditorAction::DeleteBackward { range }; return BeforeInputResult::DeferredCheck { fallback_action: action, diff --git a/crates/weaver-editor-browser/src/lib.rs b/crates/weaver-editor-browser/src/lib.rs index 98b46d6..f8b50b2 100644 --- a/crates/weaver-editor-browser/src/lib.rs +++ b/crates/weaver-editor-browser/src/lib.rs @@ -27,10 +27,13 @@ pub mod platform; pub mod visibility; // Browser cursor implementation -pub use cursor::BrowserCursor; +pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position}; // DOM sync types -pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData}; +pub use dom_sync::{ + BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset, + sync_cursor_from_dom_impl, update_paragraph_dom, +}; // Event handling pub use events::{ diff --git a/crates/weaver-editor-browser/tests/web.rs b/crates/weaver-editor-browser/tests/web.rs index 1142964..1672d42 100644 --- a/crates/weaver-editor-browser/tests/web.rs +++ b/crates/weaver-editor-browser/tests/web.rs @@ -7,7 +7,7 @@ use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); use weaver_editor_browser::{ - BeforeInputContext, BeforeInputResult, InputType, Range, handle_beforeinput, + BeforeInputContext, BeforeInputResult, InputType, Platform, Range, handle_beforeinput, parse_browser_input_type, platform, }; use weaver_editor_core::{EditorDocument, EditorRope, PlainEditor, UndoableBuffer}; @@ -20,6 +20,20 @@ fn make_editor(content: &str) -> TestEditor { PlainEditor::new(buf) } +fn test_platform() -> Platform { + Platform { + ios: false, + mac: false, + android: false, + chrome: false, + safari: false, + gecko: false, + webkit_version: None, + chrome_version: None, + mobile: false, + } +} + // === InputType parsing tests === #[wasm_bindgen_test] @@ -68,15 +82,14 @@ fn test_platform_detection() { fn test_handle_insert_text() { let mut editor = make_editor("hello"); editor.set_cursor_offset(5); + let plat = test_platform(); let ctx = BeforeInputContext { input_type: InputType::InsertText, data: Some(" world".to_string()), target_range: None, is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); @@ -88,15 +101,14 @@ fn test_handle_insert_text() { fn test_handle_delete_backward() { let mut editor = make_editor("hello"); editor.set_cursor_offset(5); + let plat = test_platform(); let ctx = BeforeInputContext { input_type: InputType::DeleteContentBackward, data: None, target_range: None, is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); @@ -107,15 +119,14 @@ fn test_handle_delete_backward() { #[wasm_bindgen_test] fn test_handle_composition_passthrough() { let mut editor = make_editor("hello"); + let plat = test_platform(); let ctx = BeforeInputContext { input_type: InputType::InsertText, data: Some("x".to_string()), target_range: None, is_composing: true, // During composition - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); @@ -128,6 +139,7 @@ fn test_handle_composition_passthrough() { fn test_handle_undo_redo() { let mut editor = make_editor("hello"); editor.set_cursor_offset(5); + let plat = test_platform(); // Insert text first. let insert_ctx = BeforeInputContext { @@ -135,9 +147,7 @@ fn test_handle_undo_redo() { data: Some(" world".to_string()), target_range: None, is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; handle_beforeinput(&mut editor, &insert_ctx, Range::caret(5)); assert_eq!(editor.content_string(), "hello world"); @@ -148,9 +158,7 @@ fn test_handle_undo_redo() { data: None, target_range: None, is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &undo_ctx, Range::caret(11)); assert!(matches!(result, BeforeInputResult::Handled)); @@ -162,9 +170,7 @@ fn test_handle_undo_redo() { data: None, target_range: None, is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &redo_ctx, Range::caret(5)); assert!(matches!(result, BeforeInputResult::Handled)); @@ -175,15 +181,14 @@ fn test_handle_undo_redo() { fn test_handle_insert_paragraph() { let mut editor = make_editor("hello"); editor.set_cursor_offset(5); + let plat = test_platform(); let ctx = BeforeInputContext { input_type: InputType::InsertParagraph, data: None, target_range: None, is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); @@ -195,15 +200,14 @@ fn test_handle_insert_paragraph() { #[wasm_bindgen_test] fn test_handle_selection_delete() { let mut editor = make_editor("hello world"); + let plat = test_platform(); let ctx = BeforeInputContext { input_type: InputType::DeleteContentBackward, data: None, target_range: Some(Range::new(5, 11)), // Select " world" is_composing: false, - is_android: false, - is_chrome: false, - offset_map: &[], + platform: &plat, }; let result = handle_beforeinput(&mut editor, &ctx, Range::new(5, 11)); diff --git a/crates/weaver-editor-core/src/platform.rs b/crates/weaver-editor-core/src/platform.rs index d336d5a..a01ffe2 100644 --- a/crates/weaver-editor-core/src/platform.rs +++ b/crates/weaver-editor-core/src/platform.rs @@ -5,8 +5,8 @@ //! logic to work across different platforms. use crate::offset_map::SnapDirection; +use crate::paragraph::ParagraphRender; use crate::types::{CursorRect, SelectionRect}; -use crate::OffsetMapping; /// Error type for platform operations. #[derive(Debug, Clone)] @@ -40,13 +40,13 @@ impl From for PlatformError { pub trait CursorPlatform { /// Restore cursor position in the UI after content changes. /// - /// Given a character offset and the current offset map, positions the cursor + /// Given a character offset and rendered paragraphs, positions the cursor /// in the rendered content. The snap direction is used when the offset falls /// on invisible content (formatting syntax). fn restore_cursor( &self, char_offset: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], snap_direction: Option, ) -> Result<(), PlatformError>; @@ -56,7 +56,7 @@ pub trait CursorPlatform { fn get_cursor_rect( &self, char_offset: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Option; /// Get screen coordinates relative to the editor container. @@ -66,7 +66,7 @@ pub trait CursorPlatform { fn get_cursor_rect_relative( &self, char_offset: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Option; /// Get screen rectangles for a selection range. @@ -77,7 +77,7 @@ pub trait CursorPlatform { &self, start: usize, end: usize, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], ) -> Vec; } @@ -95,7 +95,7 @@ pub trait CursorSync { /// - For a selection: calls `on_selection(anchor, head)` fn sync_cursor_from_platform( &self, - offset_map: &[OffsetMapping], + paragraphs: &[ParagraphRender], direction_hint: Option, on_cursor: F, on_selection: G, diff --git a/docs/graph-data.json b/docs/graph-data.json index 74d1ea3..0e03ea7 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1506,6 +1506,28 @@ "created_at": "2026-01-06T12:49:01.362223737-05:00", "updated_at": "2026-01-06T12:49:01.362223737-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 139, + "change_id": "3d5b52b1-30d1-4aa4-9294-c25f47650e4a", + "node_type": "action", + "title": "Moved update_syntax_visibility to browser crate, visibility.rs now thin re-export", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:54:14.442798720-05:00", + "updated_at": "2026-01-06T12:54:14.442798720-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 140, + "change_id": "d30cb8c3-82ac-413a-9e7f-d9a7bd49ab79", + "node_type": "action", + "title": "Added FormatAction to core with #[non_exhaustive]", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:54:14.487538586-05:00", + "updated_at": "2026-01-06T12:54:14.487538586-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -3136,6 +3158,28 @@ "weight": 1.0, "rationale": "dedup effort", "created_at": "2026-01-06T12:49:06.980327331-05:00" + }, + { + "id": 150, + "from_node_id": 132, + "to_node_id": 139, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "3d5b52b1-30d1-4aa4-9294-c25f47650e4a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "dedup effort", + "created_at": "2026-01-06T12:54:14.515301027-05:00" + }, + { + "id": 151, + "from_node_id": 132, + "to_node_id": 140, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "d30cb8c3-82ac-413a-9e7f-d9a7bd49ab79", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "dedup effort", + "created_at": "2026-01-06T12:54:14.531458670-05:00" } ] } \ No newline at end of file -- 2.51.2