further extraction

Orual 6f726641 25329504

+1183 -1755
+32 -10
crates/weaver-app/src/components/editor/actions.rs
··· 1 1 //! Editor actions and keybinding system. 2 2 //! 3 - //! This module re-exports core types and provides Dioxus-specific conversions 4 - //! and the concrete execute_action implementation for SignalEditorDocument. 3 + //! This module re-exports core types and provides Dioxus-specific conversions. 4 + //! Action execution delegates to `weaver_editor_core::execute_action`. 5 5 6 6 use dioxus::prelude::*; 7 7 8 8 use super::document::SignalEditorDocument; 9 - use super::platform::Platform; 9 + use weaver_editor_browser::Platform; 10 + use weaver_editor_core::SnapDirection; 10 11 11 12 // Re-export core types. 12 13 pub use weaver_editor_core::{ 13 - EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 14 + EditorAction, FormatAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 15 + apply_formatting, 14 16 }; 17 + 18 + /// Determine the cursor snap direction hint for an action. 19 + fn snap_direction_for_action(action: &EditorAction) -> Option<SnapDirection> { 20 + match action { 21 + // Forward: cursor should snap toward new/remaining content. 22 + EditorAction::InsertLineBreak { .. } 23 + | EditorAction::InsertParagraph { .. } 24 + | EditorAction::DeleteForward { .. } 25 + | EditorAction::DeleteWordForward { .. } 26 + | EditorAction::DeleteToLineEnd { .. } 27 + | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward), 28 + 29 + // Backward: cursor should snap toward content before edit. 30 + EditorAction::DeleteBackward { .. } 31 + | EditorAction::DeleteWordBackward { .. } 32 + | EditorAction::DeleteToLineStart { .. } 33 + | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward), 34 + 35 + _ => None, 36 + } 37 + } 15 38 16 39 // === Dioxus conversion helpers === 17 40 ··· 134 157 /// This is the central dispatch point for all editor operations. 135 158 /// Returns true if the action was handled and the document was modified. 136 159 pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { 137 - use super::formatting::{self, FormatAction}; 138 160 use super::input::{ 139 161 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, 140 162 }; ··· 478 500 } 479 501 480 502 EditorAction::ToggleBold => { 481 - formatting::apply_formatting(doc, FormatAction::Bold); 503 + apply_formatting(doc, FormatAction::Bold); 482 504 true 483 505 } 484 506 485 507 EditorAction::ToggleItalic => { 486 - formatting::apply_formatting(doc, FormatAction::Italic); 508 + apply_formatting(doc, FormatAction::Italic); 487 509 true 488 510 } 489 511 490 512 EditorAction::ToggleCode => { 491 - formatting::apply_formatting(doc, FormatAction::Code); 513 + apply_formatting(doc, FormatAction::Code); 492 514 true 493 515 } 494 516 495 517 EditorAction::ToggleStrikethrough => { 496 - formatting::apply_formatting(doc, FormatAction::Strikethrough); 518 + apply_formatting(doc, FormatAction::Strikethrough); 497 519 true 498 520 } 499 521 500 522 EditorAction::InsertLink => { 501 - formatting::apply_formatting(doc, FormatAction::Link); 523 + apply_formatting(doc, FormatAction::Link); 502 524 true 503 525 } 504 526
+44 -408
crates/weaver-app/src/components/editor/beforeinput.rs
··· 4 4 //! which gives us semantic information about what the browser wants to do 5 5 //! (insert text, delete backward, etc.) rather than raw key codes. 6 6 //! 7 - //! ## Browser Support 8 - //! 9 - //! `beforeinput` is well-supported in modern browsers, but has quirks: 10 - //! - Android: `getTargetRanges()` can be unreliable during composition 11 - //! - Safari: Some input types may not fire or have wrong data 12 - //! - All: `isComposing` flag behavior varies 13 - //! 14 - //! We handle these with platform-specific workarounds inherited from the 15 - //! battle-tested patterns in ProseMirror. 16 - 7 + //! The core logic is in `weaver_editor_browser::handle_beforeinput`. This module 8 + //! adds app-specific concerns like `pending_snap` for cursor snapping direction. 9 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 10 + use super::document::SignalEditorDocument; 11 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 17 12 use dioxus::prelude::*; 18 - 19 - use super::actions::{EditorAction, execute_action}; 20 - use super::document::SignalEditorDocument; 21 - use super::platform::Platform; 13 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 + use weaver_editor_core::SnapDirection; 22 15 23 16 // Re-export types from extracted crates. 24 17 pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; 25 - pub use weaver_editor_core::{InputType, Range}; 18 + pub use weaver_editor_core::InputType; 26 19 27 20 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 28 21 pub use weaver_editor_browser::StaticRange; 29 22 30 - /// Handle a beforeinput event. 23 + /// Determine the cursor snap direction hint for an input type. 31 24 /// 32 - /// This is the main entry point for beforeinput-based input handling. 33 - /// Returns whether the event was handled and default should be prevented. 34 - #[allow(dead_code)] 35 - pub fn handle_beforeinput( 36 - doc: &mut SignalEditorDocument, 37 - ctx: BeforeInputContext<'_>, 38 - ) -> BeforeInputResult { 39 - // During composition, let the browser handle most things. 40 - // We'll commit the final text in compositionend. 41 - if ctx.is_composing { 42 - match ctx.input_type { 43 - // These can happen during composition but should still be handled 44 - InputType::HistoryUndo | InputType::HistoryRedo => { 45 - // Handle undo/redo even during composition 46 - } 47 - InputType::InsertCompositionText => { 48 - // Let browser handle composition preview 49 - return BeforeInputResult::PassThrough; 50 - } 51 - _ => { 52 - // Let browser handle 53 - return BeforeInputResult::PassThrough; 54 - } 55 - } 56 - } 57 - 58 - // Get the range to operate on 59 - let range = ctx.target_range.unwrap_or_else(|| get_current_range(doc)); 60 - 61 - match ctx.input_type { 62 - // === Insertion === 63 - InputType::InsertText => { 64 - if let Some(text) = ctx.data { 65 - use super::FORCE_INNERHTML_UPDATE; 66 - 67 - let action = EditorAction::Insert { 68 - text: text.clone(), 69 - range, 70 - }; 71 - execute_action(doc, &action); 72 - 73 - // Log model content after insert to detect ZWC contamination 74 - if tracing::enabled!(tracing::Level::TRACE) { 75 - let content = doc.content(); 76 - tracing::trace!( 77 - text_len = text.len(), 78 - range_start = range.start, 79 - range_end = range.end, 80 - cursor_after = doc.cursor.read().offset, 81 - model_len = content.len(), 82 - model_chars = content.chars().count(), 83 - model_content = %content.escape_debug(), 84 - force_innerhtml = FORCE_INNERHTML_UPDATE, 85 - "insertText: updated model" 86 - ); 87 - } 88 - 89 - // When FORCE_INNERHTML_UPDATE is true, dom_sync will always replace 90 - // innerHTML. We must preventDefault to avoid browser's default action 91 - // racing with our innerHTML update and causing double-insertion. 92 - if FORCE_INNERHTML_UPDATE { 93 - BeforeInputResult::Handled 94 - } else { 95 - // PassThrough: browser handles DOM, we just track in model. 96 - // dom_sync will skip innerHTML for cursor paragraph when syntax unchanged. 97 - BeforeInputResult::PassThrough 98 - } 99 - } else { 100 - BeforeInputResult::PassThrough 101 - } 102 - } 103 - 104 - InputType::InsertLineBreak => { 105 - let action = EditorAction::InsertLineBreak { range }; 106 - execute_action(doc, &action); 107 - BeforeInputResult::Handled 108 - } 109 - 110 - InputType::InsertParagraph => { 111 - let action = EditorAction::InsertParagraph { range }; 112 - execute_action(doc, &action); 113 - BeforeInputResult::Handled 114 - } 115 - 116 - InputType::InsertFromPaste | InputType::InsertReplacementText => { 117 - // For paste, we need the data from the event or clipboard 118 - if let Some(text) = ctx.data { 119 - let action = EditorAction::Insert { text, range }; 120 - execute_action(doc, &action); 121 - BeforeInputResult::Handled 122 - } else { 123 - // No data in event - need to handle via clipboard API 124 - BeforeInputResult::PassThrough 125 - } 126 - } 127 - 128 - InputType::InsertFromDrop => { 129 - // Let browser handle drops for now 130 - BeforeInputResult::PassThrough 131 - } 132 - 133 - InputType::InsertCompositionText => { 134 - // Should be caught by is_composing check above, but just in case 135 - BeforeInputResult::PassThrough 136 - } 137 - 138 - // === Deletion === 139 - InputType::DeleteContentBackward => { 140 - // Android Chrome workaround: backspace sometimes doesn't work properly 141 - // after uneditable nodes. Use deferred check pattern from ProseMirror. 142 - // BUT only for caret deletions - selections we handle directly since 143 - // the browser might only delete one char instead of the whole selection. 144 - if ctx.platform.android && ctx.platform.chrome && range.is_caret() { 145 - let action = EditorAction::DeleteBackward { range }; 146 - return BeforeInputResult::DeferredCheck { 147 - fallback_action: action, 148 - }; 149 - } 150 - 151 - // Check if this delete requires special handling (newlines, zero-width chars) 152 - // If not, let browser handle DOM while we just track in model 153 - let needs_special_handling = if !range.is_caret() { 154 - // Selection delete - we handle to ensure consistency 155 - true 156 - } else if range.start == 0 { 157 - // At start of document, nothing to delete 158 - false 159 - } else { 160 - // Check what char we're deleting 161 - let prev_char = super::input::get_char_at(doc.loro_text(), range.start - 1); 162 - matches!(prev_char, Some('\n') | Some('\u{200C}') | Some('\u{200B}')) 163 - }; 164 - 165 - if needs_special_handling { 166 - // Handle fully when: complex delete OR when dom_sync will replace innerHTML 167 - // (FORCE_INNERHTML_UPDATE). PassThrough + innerHTML causes double-deletion. 168 - let action = EditorAction::DeleteBackward { range }; 169 - execute_action(doc, &action); 170 - BeforeInputResult::Handled 171 - } else { 172 - // Simple single-char delete - track in model, let browser handle DOM 173 - tracing::debug!( 174 - range_start = range.start, 175 - "deleteContentBackward: simple delete, will PassThrough to browser" 176 - ); 177 - if range.start > 0 { 178 - let _ = doc.remove_tracked(range.start - 1, 1); 179 - doc.cursor.write().offset = range.start - 1; 180 - doc.selection.set(None); 181 - } 182 - tracing::debug!("deleteContentBackward: after model update, returning PassThrough"); 183 - if super::FORCE_INNERHTML_UPDATE { 184 - BeforeInputResult::Handled 185 - } else { 186 - BeforeInputResult::PassThrough 187 - } 188 - } 189 - } 190 - 191 - InputType::DeleteContentForward => { 192 - // Check if this delete requires special handling 193 - let needs_special_handling = if !range.is_caret() { 194 - true 195 - } else if range.start >= doc.len_chars() { 196 - false 197 - } else { 198 - let next_char = super::input::get_char_at(doc.loro_text(), range.start); 199 - matches!(next_char, Some('\n') | Some('\u{200C}') | Some('\u{200B}')) 200 - }; 201 - 202 - if needs_special_handling { 203 - // Handle fully when: complex delete OR when dom_sync will replace innerHTML 204 - let action = EditorAction::DeleteForward { range }; 205 - execute_action(doc, &action); 206 - BeforeInputResult::Handled 207 - } else { 208 - // Simple single-char delete - track in model, let browser handle DOM 209 - if range.start < doc.len_chars() { 210 - let _ = doc.remove_tracked(range.start, 1); 211 - doc.selection.set(None); 212 - } 213 - if super::FORCE_INNERHTML_UPDATE { 214 - BeforeInputResult::Handled 215 - } else { 216 - BeforeInputResult::PassThrough 217 - } 218 - } 219 - } 220 - 221 - InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => { 222 - let action = EditorAction::DeleteWordBackward { range }; 223 - execute_action(doc, &action); 224 - BeforeInputResult::Handled 225 - } 226 - 227 - InputType::DeleteWordForward | InputType::DeleteEntireWordForward => { 228 - let action = EditorAction::DeleteWordForward { range }; 229 - execute_action(doc, &action); 230 - BeforeInputResult::Handled 231 - } 232 - 233 - InputType::DeleteSoftLineBackward => { 234 - let action = EditorAction::DeleteSoftLineBackward { range }; 235 - execute_action(doc, &action); 236 - BeforeInputResult::Handled 237 - } 25 + /// This is used to hint `dom_sync` which direction to snap the cursor if it 26 + /// lands on invisible content after an edit. 27 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 28 + fn snap_direction_for_input_type(input_type: &InputType) -> Option<SnapDirection> { 29 + match input_type { 30 + // Forward: cursor should snap toward new/remaining content after the edit. 31 + InputType::InsertLineBreak 32 + | InputType::InsertParagraph 33 + | InputType::DeleteContentForward 34 + | InputType::DeleteWordForward 35 + | InputType::DeleteEntireWordForward 36 + | InputType::DeleteSoftLineForward 37 + | InputType::DeleteHardLineForward => Some(SnapDirection::Forward), 238 38 239 - InputType::DeleteSoftLineForward => { 240 - let action = EditorAction::DeleteSoftLineForward { range }; 241 - execute_action(doc, &action); 242 - BeforeInputResult::Handled 243 - } 39 + // Backward: cursor should snap toward content before the deleted range. 40 + InputType::DeleteContentBackward 41 + | InputType::DeleteWordBackward 42 + | InputType::DeleteEntireWordBackward 43 + | InputType::DeleteSoftLineBackward 44 + | InputType::DeleteHardLineBackward => Some(SnapDirection::Backward), 244 45 245 - InputType::DeleteHardLineBackward => { 246 - let action = EditorAction::DeleteToLineStart { range }; 247 - execute_action(doc, &action); 248 - BeforeInputResult::Handled 249 - } 250 - 251 - InputType::DeleteHardLineForward => { 252 - let action = EditorAction::DeleteToLineEnd { range }; 253 - execute_action(doc, &action); 254 - BeforeInputResult::Handled 255 - } 256 - 257 - InputType::DeleteByCut => { 258 - // Cut is handled separately via clipboard events 259 - // But we should delete the selection here 260 - if !range.is_caret() { 261 - let action = EditorAction::DeleteBackward { range }; 262 - execute_action(doc, &action); 263 - } 264 - BeforeInputResult::Handled 265 - } 266 - 267 - InputType::DeleteByDrag | InputType::DeleteContent => { 268 - if !range.is_caret() { 269 - let action = EditorAction::DeleteBackward { range }; 270 - execute_action(doc, &action); 271 - } 272 - BeforeInputResult::Handled 273 - } 274 - 275 - // === History === 276 - InputType::HistoryUndo => { 277 - execute_action(doc, &EditorAction::Undo); 278 - BeforeInputResult::Handled 279 - } 280 - 281 - InputType::HistoryRedo => { 282 - execute_action(doc, &EditorAction::Redo); 283 - BeforeInputResult::Handled 284 - } 285 - 286 - // === Formatting === 287 - InputType::FormatBold => { 288 - execute_action(doc, &EditorAction::ToggleBold); 289 - BeforeInputResult::Handled 290 - } 291 - 292 - InputType::FormatItalic => { 293 - execute_action(doc, &EditorAction::ToggleItalic); 294 - BeforeInputResult::Handled 295 - } 296 - 297 - InputType::FormatStrikethrough => { 298 - execute_action(doc, &EditorAction::ToggleStrikethrough); 299 - BeforeInputResult::Handled 300 - } 301 - 302 - // === Other === 303 - InputType::InsertFromYank 304 - | InputType::InsertHorizontalRule 305 - | InputType::InsertOrderedList 306 - | InputType::InsertUnorderedList 307 - | InputType::InsertLink 308 - | InputType::FormatUnderline 309 - | InputType::FormatSuperscript 310 - | InputType::FormatSubscript 311 - | InputType::Unknown(_) => { 312 - // Not handled - let browser do its thing or ignore 313 - BeforeInputResult::PassThrough 314 - } 46 + // No snap hint for other operations. 47 + _ => None, 315 48 } 316 49 } 317 50 318 - /// Get the current range based on cursor and selection state. 319 - fn get_current_range(doc: &SignalEditorDocument) -> Range { 320 - if let Some(sel) = *doc.selection.read() { 321 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 322 - Range::new(start, end) 323 - } else { 324 - Range::caret(doc.cursor.read().offset) 325 - } 326 - } 327 - 328 - /// Extract target range from a beforeinput event. 51 + /// Handle a beforeinput event. 329 52 /// 330 - /// Uses getTargetRanges() to get the browser's intended range for this operation. 331 - /// This requires mapping DOM positions to document character offsets via paragraphs. 53 + /// This is the main entry point for beforeinput-based input handling. 54 + /// Sets `pending_snap` for cursor snapping, then delegates to the browser crate. 332 55 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 333 - pub fn get_target_range_from_event( 334 - event: &web_sys::InputEvent, 335 - editor_id: &str, 336 - paragraphs: &[super::paragraph::ParagraphRender], 337 - ) -> Option<Range> { 338 - use super::dom_sync::dom_position_to_text_offset; 339 - use wasm_bindgen::JsCast; 340 - 341 - let ranges = event.get_target_ranges(); 342 - if ranges.length() == 0 { 343 - return None; 56 + pub fn handle_beforeinput( 57 + doc: &mut SignalEditorDocument, 58 + ctx: BeforeInputContext<'_>, 59 + ) -> BeforeInputResult { 60 + // Set pending_snap hint before executing the action. 61 + if let Some(snap) = snap_direction_for_input_type(&ctx.input_type) { 62 + doc.pending_snap.set(Some(snap)); 344 63 } 345 64 346 - // Get the first range (there's usually only one) 347 - // getTargetRanges returns an array of StaticRange objects 348 - let static_range: StaticRange = ranges.get(0).unchecked_into(); 65 + // Get current range for the browser handler. 66 + let current_range = weaver_editor_browser::get_current_range(doc); 349 67 350 - let window = web_sys::window()?; 351 - let dom_document = window.document()?; 352 - let editor_element = dom_document.get_element_by_id(editor_id)?; 353 - 354 - let start_container = static_range.startContainer(); 355 - let start_offset = static_range.startOffset() as usize; 356 - let end_container = static_range.endContainer(); 357 - let end_offset = static_range.endOffset() as usize; 358 - 359 - // Log raw DOM position for debugging 360 - let start_node_name = start_container.node_name(); 361 - let start_text = start_container.text_content().unwrap_or_default(); 362 - let end_node_name = end_container.node_name(); 363 - let end_text = end_container.text_content().unwrap_or_default(); 364 - 365 - // Check if containers are the editor element itself 366 - let start_is_editor = start_container 367 - .dyn_ref::<web_sys::Element>() 368 - .map(|e| e == &editor_element) 369 - .unwrap_or(false); 370 - let end_is_editor = end_container 371 - .dyn_ref::<web_sys::Element>() 372 - .map(|e| e == &editor_element) 373 - .unwrap_or(false); 374 - 375 - tracing::trace!( 376 - start_node_name = %start_node_name, 377 - start_offset, 378 - start_is_editor, 379 - start_text_preview = %start_text.chars().take(30).collect::<String>(), 380 - end_node_name = %end_node_name, 381 - end_offset, 382 - end_is_editor, 383 - end_text_preview = %end_text.chars().take(30).collect::<String>(), 384 - collapsed = static_range.collapsed(), 385 - "get_target_range_from_event: raw StaticRange from browser" 386 - ); 387 - 388 - let start = dom_position_to_text_offset( 389 - &dom_document, 390 - &editor_element, 391 - &start_container, 392 - start_offset, 393 - paragraphs, 394 - None, 395 - )?; 396 - let end = dom_position_to_text_offset( 397 - &dom_document, 398 - &editor_element, 399 - &end_container, 400 - end_offset, 401 - paragraphs, 402 - None, 403 - )?; 404 - 405 - tracing::trace!( 406 - start, 407 - end, 408 - "get_target_range_from_event: computed text offsets" 409 - ); 410 - 411 - Some(Range::new(start, end)) 412 - } 413 - 414 - /// Get data from a beforeinput event, handling different sources. 415 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 416 - pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option<String> { 417 - // First try the data property 418 - if let Some(data) = event.data() { 419 - if !data.is_empty() { 420 - return Some(data); 421 - } 422 - } 423 - 424 - // For paste/drop, try dataTransfer 425 - if let Some(data_transfer) = event.data_transfer() { 426 - if let Ok(text) = data_transfer.get_data("text/plain") { 427 - if !text.is_empty() { 428 - return Some(text); 429 - } 430 - } 431 - } 432 - 433 - None 68 + // Delegate to browser crate's generic handler. 69 + weaver_editor_browser::handle_beforeinput(doc, &ctx, current_range) 434 70 }
+8 -31
crates/weaver-app/src/components/editor/collab.rs
··· 16 16 use dioxus::prelude::*; 17 17 18 18 #[cfg(target_arch = "wasm32")] 19 + use jacquard::smol_str::ToSmolStr; 20 + #[cfg(target_arch = "wasm32")] 19 21 use jacquard::smol_str::{SmolStr, format_smolstr}; 20 22 #[cfg(target_arch = "wasm32")] 21 23 use jacquard::types::string::AtUri; 22 - 23 24 use weaver_common::transport::PresenceSnapshot; 24 25 25 - /// Session record TTL in minutes. 26 26 #[cfg(target_arch = "wasm32")] 27 - const SESSION_TTL_MINUTES: u32 = 15; 28 - 29 - /// How often to refresh session record (ms). 30 - #[cfg(target_arch = "wasm32")] 31 - const SESSION_REFRESH_INTERVAL_MS: u32 = 5 * 60 * 1000; // 5 minutes 32 - 33 - /// How often to poll for new peers (ms). 34 - #[cfg(target_arch = "wasm32")] 35 - const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds 27 + use weaver_editor_crdt::{ 28 + CoordinatorState, PEER_DISCOVERY_INTERVAL_MS, SESSION_REFRESH_INTERVAL_MS, SESSION_TTL_MINUTES, 29 + compute_collab_topic, 30 + }; 36 31 37 32 /// Props for the CollabCoordinator component. 38 33 #[derive(Props, Clone, PartialEq)] ··· 47 42 pub children: Element, 48 43 } 49 44 50 - /// Coordinator state machine states. 51 - #[cfg(target_arch = "wasm32")] 52 - #[derive(Debug, Clone, PartialEq)] 53 - enum CoordinatorState { 54 - /// Initial state - waiting for worker to be ready 55 - Initializing, 56 - /// Creating session record on PDS 57 - CreatingSession { 58 - node_id: SmolStr, 59 - relay_url: Option<SmolStr>, 60 - }, 61 - /// Active collab session 62 - Active { session_uri: AtUri<'static> }, 63 - /// Error state 64 - Error(SmolStr), 65 - } 66 - 67 45 /// Coordinator component that bridges worker and PDS. 68 46 /// 69 47 /// This is a wrapper component that: ··· 183 161 tracing::info!("CollabCoordinator: worker ready, starting collab"); 184 162 185 163 // Compute topic from resource URI 186 - let hash = weaver_common::blake3::hash(resource_uri.as_bytes()); 187 - let topic: [u8; 32] = *hash.as_bytes(); 164 + let topic = compute_collab_topic(&resource_uri); 188 165 189 166 // Send StartCollab to worker immediately (no blocking on profile fetch) 190 167 if let Some(ref mut s) = *worker_sink.write() { ··· 349 326 } 350 327 351 328 state.set(CoordinatorState::Active { 352 - session_uri: session_record_uri, 329 + session_uri: session_record_uri.to_smolstr(), 353 330 }); 354 331 } 355 332 Err(e) => {
+32 -270
crates/weaver-app/src/components/editor/component.rs
··· 5 5 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 6 6 handle_keydown_with_bindings, 7 7 }; 8 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 9 + use super::beforeinput::handle_beforeinput; 8 10 #[allow(unused_imports)] 9 - use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; 10 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 11 - use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 11 + use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType}; 12 12 use super::document::{CompositionState, LoadedDocState, SignalEditorDocument}; 13 13 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 14 use super::dom_sync::update_paragraph_dom; 15 15 use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; 16 - use super::formatting; 17 16 use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 18 - use super::paragraph::ParagraphRender; 19 - use super::platform; 20 17 #[allow(unused_imports)] 21 18 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 22 - use super::render; 23 19 use super::storage; 24 20 use super::sync::{SyncStatus, load_and_merge_document}; 25 21 use super::toolbar::EditorToolbar; 26 - use super::visibility::update_syntax_visibility; 27 - #[allow(unused_imports)] 28 - use super::writer::EditorImageResolver; 29 - #[allow(unused_imports)] 30 - use super::writer::SyntaxSpanInfo; 31 22 use crate::auth::AuthState; 32 23 use crate::components::collab::CollaboratorAvatars; 33 24 use crate::components::editor::ReportButton; ··· 44 35 use jacquard::types::ident::AtIdentifier; 45 36 use weaver_api::sh_weaver::embed::images::Image; 46 37 use weaver_common::WeaverExt; 38 + use weaver_editor_browser::{platform, update_syntax_visibility}; 39 + use weaver_editor_core::EditorImageResolver; 40 + use weaver_editor_core::ParagraphRender; 47 41 use weaver_editor_core::SnapDirection; 42 + use weaver_editor_core::apply_formatting; 48 43 49 44 /// Result of loading document state. 50 45 enum LoadResult { ··· 442 437 doc 443 438 }); 444 439 let editor_id = "markdown-editor"; 445 - let mut render_cache = use_signal(|| render::RenderCache::default()); 440 + let mut render_cache = use_signal(|| weaver_editor_browser::RenderCache::default()); 446 441 447 442 // Populate resolver from existing images if editing a published entry 448 443 let mut image_resolver: Signal<EditorImageResolver> = use_signal(|| { ··· 482 477 ); 483 478 484 479 let cursor_offset = doc_for_memo.cursor.read().offset; 485 - let (paras, new_cache, refs) = render::render_paragraphs_incremental( 480 + let result = weaver_editor_core::render_paragraphs_incremental( 486 481 doc_for_memo.buffer(), 487 482 Some(&cache), 488 483 cursor_offset, ··· 491 486 entry_index_for_memo.as_ref(), 492 487 &resolved, 493 488 ); 489 + let paras = result.paragraphs; 490 + let new_cache = result.cache; 491 + let refs = result.collected_refs; 494 492 let mut doc_for_spawn = doc_for_refs.clone(); 495 493 dioxus::prelude::spawn(async move { 496 494 render_cache.set(new_cache); ··· 504 502 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 505 503 { 506 504 use dioxus::prelude::Writable; 507 - use gloo_worker::Spawnable; 508 - use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 505 + use weaver_embed_worker::{EmbedWorkerHost, EmbedWorkerOutput}; 509 506 510 507 let resolved_content_for_fetch = resolved_content; 511 - let mut embed_worker_bridge: Signal<Option<gloo_worker::WorkerBridge<EmbedWorker>>> = 512 - use_signal(|| None); 508 + let mut embed_host: Signal<Option<EmbedWorkerHost>> = use_signal(|| None); 513 509 514 510 // Spawn embed worker on mount 515 511 let doc_for_embeds = document.clone(); ··· 543 539 } 544 540 }; 545 541 546 - let bridge = EmbedWorker::spawner() 547 - .callback(on_output) 548 - .spawn("/embed_worker.js"); 549 - embed_worker_bridge.set(Some(bridge)); 542 + let host = EmbedWorkerHost::spawn("/embed_worker.js", on_output); 543 + embed_host.set(Some(host)); 550 544 tracing::info!("Embed worker spawned"); 551 545 }); 552 546 ··· 577 571 } 578 572 579 573 // Send to worker 580 - if let Some(ref bridge) = *embed_worker_bridge.peek() { 581 - bridge.send(EmbedWorkerInput::FetchEmbeds { uris: to_fetch }); 574 + if let Some(ref host) = *embed_host.peek() { 575 + host.fetch_embeds(to_fetch); 582 576 } 583 577 }); 584 578 } 585 579 586 - // Fallback for non-WASM (server-side rendering) 587 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 588 - { 589 - let mut resolved_content_for_fetch = resolved_content.clone(); 590 - let doc_for_embeds = document.clone(); 591 - let fetcher_for_embeds = fetcher.clone(); 592 - use_effect(move || { 593 - let refs = doc_for_embeds.collected_refs.read(); 594 - let current_resolved = resolved_content_for_fetch.peek(); 595 - let fetcher = fetcher_for_embeds.clone(); 596 - 597 - // Find AT embeds that need fetching 598 - let to_fetch: Vec<String> = refs 599 - .iter() 600 - .filter_map(|r| match r { 601 - weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 602 - // Skip if already resolved 603 - if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri) { 604 - if current_resolved.get_embed_content(&at_uri).is_none() { 605 - return Some(uri.clone()); 606 - } 607 - } 608 - None 609 - } 610 - _ => None, 611 - }) 612 - .collect(); 613 - 614 - if to_fetch.is_empty() { 615 - return; 616 - } 617 - 618 - // Spawn background fetches (main thread fallback) 619 - dioxus::prelude::spawn(async move { 620 - for uri_str in to_fetch { 621 - let Ok(at_uri) = jacquard::types::string::AtUri::new(&uri_str) else { 622 - continue; 623 - }; 624 - 625 - match weaver_renderer::atproto::fetch_and_render(&at_uri, &fetcher).await { 626 - Ok(html) => { 627 - let mut rc = resolved_content_for_fetch.write(); 628 - rc.add_embed(at_uri.into_static(), html, None); 629 - } 630 - Err(e) => { 631 - tracing::warn!("failed to fetch embed {}: {}", uri_str, e); 632 - } 633 - } 634 - } 635 - }); 636 - }); 637 - } 638 - 639 580 let mut new_tag = use_signal(String::new); 640 581 641 582 #[allow(unused)] ··· 658 599 let mut doc_for_dom = document.clone(); 659 600 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 660 601 use_effect(move || { 661 - // tracing::debug!( 662 - // composition_active = doc_for_dom.composition.read().is_some(), 663 - // cursor = doc_for_dom.cursor.read().offset, 664 - // "DOM update: checking state" 665 - // ); 666 - 667 602 // Skip DOM updates during IME composition - browser controls the preview 668 603 if doc_for_dom.composition.read().is_some() { 669 604 tracing::debug!("skipping DOM update during composition"); ··· 723 658 }); 724 659 725 660 // Track last saved frontiers to detect changes (peek-only, no subscriptions) 726 - #[allow(unused_mut, unused)] 661 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 727 662 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 728 663 729 664 // Store interval handle so it's dropped when component unmounts (prevents panic) 730 665 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 731 666 let mut interval_holder: Signal<Option<gloo_timers::callback::Interval>> = use_signal(|| None); 732 667 733 - // Worker-based autosave (offloads export + encode to worker thread) 668 + // Autosave interval - saves to localStorage when document changes 734 669 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 735 670 { 736 - use gloo_storage::Storage; 737 - use gloo_worker::Spawnable; 738 - use gloo_worker::reactor::ReactorBridge; 739 - use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; 740 - 741 - use futures_util::stream::{SplitSink, SplitStream}; 742 - 743 - // Track if worker is available (false = fallback to main thread) 744 - let use_worker: Signal<bool> = use_signal(|| true); 745 - // Worker sink for sending (split from bridge) 746 - type WorkerSink = SplitSink<ReactorBridge<EditorReactor>, WorkerInput>; 747 - let worker_sink: std::rc::Rc<std::cell::RefCell<Option<WorkerSink>>> = 748 - std::rc::Rc::new(std::cell::RefCell::new(None)); 749 - // Track version vector sent to worker (for incremental updates) 750 - let mut last_worker_vv: Signal<Option<loro::VersionVector>> = use_signal(|| None); 751 - 752 - // Spawn worker on mount 753 - let doc_for_worker_init = document.clone(); 754 - let draft_key_for_worker = draft_key.clone(); 755 - let worker_sink_for_spawn = worker_sink.clone(); 756 - let mut presence_for_worker = presence; 757 - use_effect(move || { 758 - let doc = doc_for_worker_init.clone(); 759 - let draft_key = draft_key_for_worker.clone(); 760 - let worker_sink = worker_sink_for_spawn.clone(); 761 - 762 - // Callback for worker responses 763 - let mut on_output = move |output: WorkerOutput| { 764 - match output { 765 - WorkerOutput::Ready => { 766 - tracing::info!("Editor worker ready"); 767 - } 768 - WorkerOutput::Snapshot { 769 - draft_key, 770 - b64_snapshot, 771 - content, 772 - title, 773 - cursor_offset, 774 - editing_uri, 775 - editing_cid, 776 - notebook_uri, 777 - export_ms, 778 - encode_ms, 779 - } => { 780 - // Write to localStorage (fast - just string assignment) 781 - let snapshot = storage::EditorSnapshot { 782 - content, 783 - title, 784 - snapshot: Some(b64_snapshot), 785 - cursor: None, // Worker doesn't have Loro cursor 786 - cursor_offset, 787 - editing_uri, 788 - editing_cid, 789 - notebook_uri, 790 - }; 791 - let write_start = crate::perf::now(); 792 - let _ = gloo_storage::LocalStorage::set( 793 - format!("{}{}", storage::DRAFT_KEY_PREFIX, draft_key), 794 - &snapshot, 795 - ); 796 - let write_ms = crate::perf::now() - write_start; 797 - tracing::trace!(export_ms, encode_ms, write_ms, "worker autosave complete"); 798 - } 799 - WorkerOutput::Error { message } => { 800 - tracing::error!("Worker error: {}", message); 801 - } 802 - WorkerOutput::PresenceUpdate(snapshot) => { 803 - tracing::debug!( 804 - collaborators = snapshot.collaborators.len(), 805 - peers = snapshot.peer_count, 806 - "presence update from worker" 807 - ); 808 - presence_for_worker.set(snapshot); 809 - } 810 - // Ignore other collab outputs for now (handled by CollabCoordinator) 811 - WorkerOutput::CollabReady { .. } 812 - | WorkerOutput::CollabJoined 813 - | WorkerOutput::RemoteUpdates { .. } 814 - | WorkerOutput::CollabStopped 815 - | WorkerOutput::PeerConnected => {} 816 - } 817 - }; 818 - 819 - // Spawn reactor and split into sink/stream 820 - use futures_util::StreamExt; 821 - let bridge = EditorReactor::spawner().spawn("/editor_worker.js"); 822 - let (sink, mut stream) = bridge.split(); 823 - 824 - // Store sink for sending 825 - *worker_sink.borrow_mut() = Some(sink); 826 - 827 - // Initialize with current document snapshot 828 - let snapshot = doc.export_snapshot(); 829 - let sink_for_init = worker_sink.clone(); 830 - wasm_bindgen_futures::spawn_local(async move { 831 - use futures_util::SinkExt; 832 - if let Some(ref mut sink) = *sink_for_init.borrow_mut() { 833 - let _ = sink 834 - .send(WorkerInput::Init { 835 - snapshot, 836 - draft_key: draft_key.into(), 837 - }) 838 - .await; 839 - } 840 - }); 841 - 842 - // Spawn receiver task to poll stream for outputs 843 - wasm_bindgen_futures::spawn_local(async move { 844 - while let Some(msg) = stream.next().await { 845 - on_output(msg); 846 - } 847 - tracing::info!("Editor reactor stream ended"); 848 - }); 849 - 850 - tracing::info!("Editor reactor spawned"); 851 - }); 852 - 853 - // Autosave interval 854 671 let doc_for_autosave = document.clone(); 855 672 let draft_key_for_autosave = draft_key.clone(); 856 - let worker_sink_for_autosave = worker_sink.clone(); 857 673 use_effect(move || { 858 674 let mut doc = doc_for_autosave.clone(); 859 675 let draft_key = draft_key_for_autosave.clone(); 860 - let worker_sink = worker_sink_for_autosave.clone(); 861 676 862 677 let interval = gloo_timers::callback::Interval::new(500, move || { 863 - let callback_start = crate::perf::now(); 864 678 let current_frontiers = doc.state_frontiers(); 865 679 866 680 // Only save if frontiers changed (document was edited) ··· 877 691 } 878 692 879 693 doc.sync_loro_cursor(); 880 - 881 - // Try worker path first 882 - if *use_worker.peek() && worker_sink.borrow().is_some() { 883 - // Send updates to worker (or full snapshot if first time) 884 - let current_vv = doc.version_vector(); 885 - let updates = if let Some(ref last_vv) = *last_worker_vv.peek() { 886 - doc.export_updates_from(last_vv).unwrap_or_default() 887 - } else { 888 - doc.export_snapshot() 889 - }; 890 - 891 - let cursor_offset = doc.cursor.read().offset; 892 - let editing_uri = doc.entry_ref().map(|r| r.uri.to_smolstr()); 893 - let editing_cid = doc.entry_ref().map(|r| r.cid.to_smolstr()); 894 - let notebook_uri = doc.notebook_uri(); 895 - 896 - let sink_clone = worker_sink.clone(); 897 - 898 - // Spawn async sends 899 - wasm_bindgen_futures::spawn_local(async move { 900 - use futures_util::SinkExt; 901 - if let Some(ref mut sink) = *sink_clone.borrow_mut() { 902 - if !updates.is_empty() { 903 - let _ = sink.send(WorkerInput::ApplyUpdates { updates }).await; 904 - } 905 - 906 - // Request snapshot export 907 - let _ = sink 908 - .send(WorkerInput::ExportSnapshot { 909 - cursor_offset, 910 - editing_uri, 911 - editing_cid, 912 - notebook_uri, 913 - }) 914 - .await; 915 - } 916 - }); 917 - 918 - last_worker_vv.set(Some(current_vv)); 919 - last_saved_frontiers.set(Some(current_frontiers)); 920 - 921 - let callback_ms = crate::perf::now() - callback_start; 922 - tracing::debug!(callback_ms, "autosave via worker"); 923 - return; 924 - } 925 - 926 - // Fallback: main thread save 927 694 let _ = storage::save_to_storage(&doc, &draft_key); 928 695 last_saved_frontiers.set(Some(current_frontiers)); 929 - 930 - let callback_ms = crate::perf::now() - callback_start; 931 - tracing::debug!(callback_ms, "autosave callback (main thread fallback)"); 932 696 }); 933 697 934 698 interval_holder.set(Some(interval)); ··· 971 735 972 736 // Get target range from the event if available 973 737 let paras = cached_paras.peek().clone(); 974 - let target_range = get_target_range_from_event(&evt, editor_id, &paras); 975 - let data = get_data_from_event(&evt); 738 + let target_range = 739 + weaver_editor_browser::get_target_range_from_event(&evt, editor_id, &paras); 740 + let data = weaver_editor_browser::get_data_from_event(&evt); 976 741 let ctx = BeforeInputContext { 977 742 input_type: input_type.clone(), 978 743 data, ··· 1443 1208 update_syntax_visibility(offset, None, &spans, &paras); 1444 1209 // Then set DOM selection 1445 1210 let map = offset_map(); 1446 - let _ = crate::components::editor::cursor::restore_cursor_position( 1211 + let _ = weaver_editor_browser::restore_cursor_position( 1447 1212 offset, 1448 1213 &map, 1449 1214 None, ··· 1664 1429 on_format: { 1665 1430 let mut doc = document.clone(); 1666 1431 move |action| { 1667 - formatting::apply_formatting(&mut doc, action); 1432 + apply_formatting(&mut doc, action); 1668 1433 } 1669 1434 }, 1670 1435 on_image: { ··· 1813 1578 fn RemoteCursors( 1814 1579 presence: Signal<weaver_common::transport::PresenceSnapshot>, 1815 1580 document: SignalEditorDocument, 1816 - render_cache: Signal<render::RenderCache>, 1581 + render_cache: Signal<weaver_editor_browser::RenderCache>, 1817 1582 ) -> Element { 1818 1583 let presence_read = presence.read(); 1819 1584 let cursor_count = presence_read.collaborators.len(); ··· 1871 1636 color: u32, 1872 1637 offset_map: Vec<weaver_editor_core::OffsetMapping>, 1873 1638 ) -> Element { 1874 - use super::cursor::{get_cursor_rect_relative, get_selection_rects_relative}; 1639 + use weaver_editor_browser::{ 1640 + get_cursor_rect_relative, get_selection_rects_relative, rgba_u32_to_css, 1641 + rgba_u32_to_css_alpha, 1642 + }; 1875 1643 1876 - // Convert RGBA u32 to CSS color (fully opaque for cursor) 1877 - let r = (color >> 24) & 0xFF; 1878 - let g = (color >> 16) & 0xFF; 1879 - let b = (color >> 8) & 0xFF; 1880 - let a = (color & 0xFF) as f32 / 255.0; 1881 - let color_css = format!("rgba({}, {}, {}, {})", r, g, b, a); 1882 - // Semi-transparent version for selection highlight 1883 - let selection_color_css = format!("rgba({}, {}, {}, 0.25)", r, g, b); 1644 + let color_css = rgba_u32_to_css(color); 1645 + let selection_color_css = rgba_u32_to_css_alpha(color, 0.25); 1884 1646 1885 1647 // Get cursor position relative to editor 1886 1648 let rect = get_cursor_rect_relative(position, &offset_map, "markdown-editor");
-205
crates/weaver-app/src/components/editor/cursor.rs
··· 1 - //! Cursor position operations. 2 - //! 3 - //! Re-exports from browser crate with app-specific adapters. 4 - 5 - pub use weaver_editor_browser::restore_cursor_position; 6 - pub use weaver_editor_core::{CursorRect, OffsetMapping, SelectionRect}; 7 - 8 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 9 - use weaver_editor_core::{SnapDirection, find_mapping_for_char}; 10 - 11 - /// Get screen coordinates for a character offset in the editor. 12 - /// 13 - /// Returns the bounding rect of a zero-width range at the given offset. 14 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 15 - pub fn get_cursor_rect( 16 - char_offset: usize, 17 - offset_map: &[OffsetMapping], 18 - _editor_id: &str, 19 - ) -> Option<CursorRect> { 20 - use wasm_bindgen::JsCast; 21 - 22 - if offset_map.is_empty() { 23 - return None; 24 - } 25 - 26 - let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { 27 - Some((m, _)) => (m, char_offset), 28 - None => return None, 29 - }; 30 - 31 - let window = web_sys::window()?; 32 - let document = window.document()?; 33 - 34 - let container = document.get_element_by_id(&mapping.node_id).or_else(|| { 35 - let selector = format!("[data-node-id='{}']", mapping.node_id); 36 - document.query_selector(&selector).ok().flatten() 37 - })?; 38 - 39 - let range = document.create_range().ok()?; 40 - 41 - if let Some(child_index) = mapping.child_index { 42 - range.set_start(&container, child_index as u32).ok()?; 43 - } else { 44 - let container_element = container.dyn_into::<web_sys::HtmlElement>().ok()?; 45 - let offset_in_range = char_offset - mapping.char_range.start; 46 - let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; 47 - 48 - if let Ok((text_node, node_offset)) = 49 - weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) 50 - { 51 - range.set_start(&text_node, node_offset as u32).ok()?; 52 - } else { 53 - return None; 54 - } 55 - } 56 - 57 - range.collapse_with_to_start(true); 58 - 59 - let rect = range.get_bounding_client_rect(); 60 - Some(CursorRect { 61 - x: rect.x(), 62 - y: rect.y(), 63 - height: rect.height().max(16.0), 64 - }) 65 - } 66 - 67 - /// Get screen coordinates relative to the editor container. 68 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 69 - pub fn get_cursor_rect_relative( 70 - char_offset: usize, 71 - offset_map: &[OffsetMapping], 72 - editor_id: &str, 73 - ) -> Option<CursorRect> { 74 - let cursor_rect = get_cursor_rect(char_offset, offset_map, editor_id)?; 75 - 76 - let window = web_sys::window()?; 77 - let document = window.document()?; 78 - let editor = document.get_element_by_id(editor_id)?; 79 - let editor_rect = editor.get_bounding_client_rect(); 80 - 81 - Some(CursorRect { 82 - x: cursor_rect.x - editor_rect.x(), 83 - y: cursor_rect.y - editor_rect.y(), 84 - height: cursor_rect.height, 85 - }) 86 - } 87 - 88 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 89 - pub fn get_cursor_rect_relative( 90 - _char_offset: usize, 91 - _offset_map: &[OffsetMapping], 92 - _editor_id: &str, 93 - ) -> Option<CursorRect> { 94 - None 95 - } 96 - 97 - /// Get screen rectangles for a selection range, relative to editor. 98 - /// 99 - /// Returns multiple rects if selection spans multiple lines. 100 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 101 - pub fn get_selection_rects_relative( 102 - start: usize, 103 - end: usize, 104 - offset_map: &[OffsetMapping], 105 - editor_id: &str, 106 - ) -> Vec<SelectionRect> { 107 - use wasm_bindgen::JsCast; 108 - 109 - if offset_map.is_empty() || start >= end { 110 - return vec![]; 111 - } 112 - 113 - let Some(window) = web_sys::window() else { 114 - return vec![]; 115 - }; 116 - let Some(document) = window.document() else { 117 - return vec![]; 118 - }; 119 - let Some(editor) = document.get_element_by_id(editor_id) else { 120 - return vec![]; 121 - }; 122 - let editor_rect = editor.get_bounding_client_rect(); 123 - 124 - let Some((start_mapping, _)) = find_mapping_for_char(offset_map, start) else { 125 - return vec![]; 126 - }; 127 - let Some((end_mapping, _)) = find_mapping_for_char(offset_map, end) else { 128 - return vec![]; 129 - }; 130 - 131 - let start_container = document 132 - .get_element_by_id(&start_mapping.node_id) 133 - .or_else(|| { 134 - let selector = format!("[data-node-id='{}']", start_mapping.node_id); 135 - document.query_selector(&selector).ok().flatten() 136 - }); 137 - let end_container = document 138 - .get_element_by_id(&end_mapping.node_id) 139 - .or_else(|| { 140 - let selector = format!("[data-node-id='{}']", end_mapping.node_id); 141 - document.query_selector(&selector).ok().flatten() 142 - }); 143 - 144 - let (Some(start_container), Some(end_container)) = (start_container, end_container) else { 145 - return vec![]; 146 - }; 147 - 148 - let Ok(range) = document.create_range() else { 149 - return vec![]; 150 - }; 151 - 152 - if let Some(child_index) = start_mapping.child_index { 153 - let _ = range.set_start(&start_container, child_index as u32); 154 - } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() 155 - { 156 - let offset_in_range = start - start_mapping.char_range.start; 157 - let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; 158 - if let Ok((text_node, node_offset)) = 159 - weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) 160 - { 161 - let _ = range.set_start(&text_node, node_offset as u32); 162 - } 163 - } 164 - 165 - if let Some(child_index) = end_mapping.child_index { 166 - let _ = range.set_end(&end_container, child_index as u32); 167 - } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 168 - let offset_in_range = end - end_mapping.char_range.start; 169 - let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; 170 - if let Ok((text_node, node_offset)) = 171 - weaver_editor_browser::find_text_node_at_offset(&container_element, target_utf16_offset) 172 - { 173 - let _ = range.set_end(&text_node, node_offset as u32); 174 - } 175 - } 176 - 177 - let Some(rects) = range.get_client_rects() else { 178 - return vec![]; 179 - }; 180 - let mut result = Vec::new(); 181 - 182 - for i in 0..rects.length() { 183 - if let Some(rect) = rects.get(i) { 184 - let rect: web_sys::DomRect = rect; 185 - result.push(SelectionRect { 186 - x: rect.x() - editor_rect.x(), 187 - y: rect.y() - editor_rect.y(), 188 - width: rect.width(), 189 - height: rect.height().max(16.0), 190 - }); 191 - } 192 - } 193 - 194 - result 195 - } 196 - 197 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 198 - pub fn get_selection_rects_relative( 199 - _start: usize, 200 - _end: usize, 201 - _offset_map: &[OffsetMapping], 202 - _editor_id: &str, 203 - ) -> Vec<SelectionRect> { 204 - vec![] 205 - }
+3 -13
crates/weaver-app/src/components/editor/document.rs
··· 27 27 use weaver_editor_core::EditorDocument; 28 28 use weaver_editor_core::TextBuffer; 29 29 use weaver_editor_core::UndoManager; 30 - pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection}; 30 + pub use weaver_editor_core::{ 31 + Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, 32 + }; 31 33 use weaver_editor_crdt::LoroTextBuffer; 32 - 33 - /// Helper for working with editor images. 34 - /// Constructed from LoroMap data, NOT serialized directly. 35 - /// The Image lexicon type stores our `publishedBlobUri` in its `extra_data` field. 36 - #[derive(Clone, Debug)] 37 - pub struct EditorImage { 38 - /// The lexicon Image type (deserialized via from_json_value) 39 - pub image: Image<'static>, 40 - /// AT-URI of the PublishedBlob record (for cleanup on publish/delete) 41 - /// None for existing images that are already in an entry record. 42 - pub published_blob_uri: Option<AtUri<'static>>, 43 - } 44 34 45 35 /// Single source of truth for editor state. 46 36 ///
+5 -224
crates/weaver-app/src/components/editor/dom_sync.rs
··· 3 3 //! Handles syncing cursor/selection state between the browser DOM and our 4 4 //! internal document model, and updating paragraph DOM elements. 5 5 //! 6 - //! The core DOM position conversion is provided by `weaver_editor_browser`. 6 + //! Most DOM sync logic is in `weaver_editor_browser`. This module provides 7 + //! thin wrappers that work with `SignalEditorDocument` directly. 7 8 8 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 9 - use super::cursor::restore_cursor_position; 10 9 #[allow(unused_imports)] 11 10 use super::document::Selection; 12 11 #[allow(unused_imports)] 13 12 use super::document::SignalEditorDocument; 14 - use super::paragraph::ParagraphRender; 15 13 #[allow(unused_imports)] 16 14 use dioxus::prelude::*; 15 + use weaver_editor_core::ParagraphRender; 17 16 #[allow(unused_imports)] 18 17 use weaver_editor_core::SnapDirection; 19 18 20 - // Re-export the DOM position conversion from browser crate. 19 + // Re-export from browser crate. 21 20 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 22 - pub use weaver_editor_browser::dom_position_to_text_offset; 21 + pub use weaver_editor_browser::{dom_position_to_text_offset, update_paragraph_dom}; 23 22 24 23 /// Sync internal cursor and selection state from browser DOM selection. 25 24 /// ··· 149 148 _direction_hint: Option<SnapDirection>, 150 149 ) { 151 150 } 152 - 153 - /// Update paragraph DOM elements incrementally using pool-based surgical diffing. 154 - /// 155 - /// Uses stable content-based paragraph IDs for efficient DOM reconciliation: 156 - /// - Unchanged paragraphs (same ID + hash) are not touched 157 - /// - Changed paragraphs (same ID, different hash) get innerHTML updated 158 - /// - New paragraphs get created and inserted at correct position 159 - /// - Removed paragraphs get deleted 160 - /// 161 - /// Returns true if the paragraph containing the cursor was updated. 162 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 163 - pub fn update_paragraph_dom( 164 - editor_id: &str, 165 - old_paragraphs: &[ParagraphRender], 166 - new_paragraphs: &[ParagraphRender], 167 - cursor_offset: usize, 168 - force: bool, 169 - ) -> bool { 170 - use std::collections::HashMap; 171 - use wasm_bindgen::JsCast; 172 - 173 - let window = match web_sys::window() { 174 - Some(w) => w, 175 - None => return false, 176 - }; 177 - 178 - let document = match window.document() { 179 - Some(d) => d, 180 - None => return false, 181 - }; 182 - 183 - let editor = match document.get_element_by_id(editor_id) { 184 - Some(e) => e, 185 - None => return false, 186 - }; 187 - 188 - let mut cursor_para_updated = false; 189 - 190 - // Build lookup for old paragraphs by ID (for syntax span comparison) 191 - let old_para_map: HashMap<&str, &ParagraphRender> = 192 - old_paragraphs.iter().map(|p| (p.id.as_str(), p)).collect(); 193 - 194 - // Build pool of existing DOM elements by ID 195 - let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new(); 196 - let mut child_opt = editor.first_element_child(); 197 - while let Some(child) = child_opt { 198 - if let Some(id) = child.get_attribute("id") { 199 - let next = child.next_element_sibling(); 200 - old_elements.insert(id, child); 201 - child_opt = next; 202 - } else { 203 - child_opt = child.next_element_sibling(); 204 - } 205 - } 206 - 207 - // Track position for insertBefore - starts at first element child 208 - // (use first_element_child to skip any stray text nodes) 209 - let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into()); 210 - 211 - // Single pass through new paragraphs 212 - for new_para in new_paragraphs.iter() { 213 - let para_id = &new_para.id; 214 - let new_hash = format!("{:x}", new_para.source_hash); 215 - let is_cursor_para = 216 - new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; 217 - 218 - if let Some(existing_elem) = old_elements.remove(para_id.as_str()) { 219 - // Element exists - check if it needs updating 220 - let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); 221 - let needs_update = force || old_hash != new_hash; 222 - 223 - // Check if element is at correct position (compare as nodes) 224 - let existing_as_node: &web_sys::Node = existing_elem.as_ref(); 225 - let at_correct_position = cursor_node 226 - .as_ref() 227 - .map(|c| c == existing_as_node) 228 - .unwrap_or(false); 229 - 230 - if !at_correct_position { 231 - tracing::warn!( 232 - para_id = %para_id, 233 - is_cursor_para, 234 - "update_paragraph_dom: element not at correct position, moving" 235 - ); 236 - let _ = editor.insert_before(existing_as_node, cursor_node.as_ref()); 237 - if is_cursor_para { 238 - cursor_para_updated = true; 239 - } 240 - } else { 241 - // Use next_element_sibling to skip any stray text nodes 242 - cursor_node = existing_elem.next_element_sibling().map(|e| e.into()); 243 - } 244 - 245 - if needs_update { 246 - use super::FORCE_INNERHTML_UPDATE; 247 - 248 - // For cursor paragraph: only update if syntax/formatting changed 249 - // This prevents destroying browser selection during fast typing 250 - // 251 - // HOWEVER: we must verify browser actually updated the DOM. 252 - // PassThrough assumes browser handles edit, but sometimes it doesn't. 253 - let should_skip_cursor_update = 254 - !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && { 255 - let old_para = old_para_map.get(para_id.as_str()); 256 - let syntax_unchanged = old_para 257 - .map(|old| old.syntax_spans == new_para.syntax_spans) 258 - .unwrap_or(false); 259 - 260 - // Verify DOM content length matches expected - if not, browser didn't handle it 261 - // NOTE: Get inner element (the <p>) not outer div, to avoid counting 262 - // the newline from </p>\n in the HTML 263 - let dom_matches_expected = if syntax_unchanged { 264 - let inner_elem = existing_elem.first_element_child(); 265 - let dom_text = inner_elem 266 - .as_ref() 267 - .and_then(|e| e.text_content()) 268 - .unwrap_or_default(); 269 - let expected_len = new_para.byte_range.end - new_para.byte_range.start; 270 - let dom_len = dom_text.len(); 271 - let matches = dom_len == expected_len; 272 - // Always log for debugging 273 - tracing::debug!( 274 - para_id = %para_id, 275 - dom_len, 276 - expected_len, 277 - matches, 278 - dom_text = %dom_text, 279 - "DOM sync check" 280 - ); 281 - matches 282 - } else { 283 - false 284 - }; 285 - 286 - syntax_unchanged && dom_matches_expected 287 - }; 288 - 289 - if should_skip_cursor_update { 290 - tracing::trace!( 291 - para_id = %para_id, 292 - "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)" 293 - ); 294 - // Update hash - browser native editing has the correct content 295 - let _ = existing_elem.set_attribute("data-hash", &new_hash); 296 - } else { 297 - // Log old innerHTML before replacement to see what browser did 298 - if tracing::enabled!(tracing::Level::TRACE) { 299 - let old_inner = existing_elem.inner_html(); 300 - tracing::trace!( 301 - para_id = %para_id, 302 - old_inner = %old_inner.escape_debug(), 303 - new_html = %new_para.html.escape_debug(), 304 - "update_paragraph_dom: replacing innerHTML" 305 - ); 306 - } 307 - 308 - // Timing instrumentation for innerHTML update cost 309 - let start = web_sys::window() 310 - .and_then(|w| w.performance()) 311 - .map(|p| p.now()); 312 - 313 - existing_elem.set_inner_html(&new_para.html); 314 - let _ = existing_elem.set_attribute("data-hash", &new_hash); 315 - 316 - if let Some(start_time) = start { 317 - if let Some(end_time) = web_sys::window() 318 - .and_then(|w| w.performance()) 319 - .map(|p| p.now()) 320 - { 321 - let elapsed_ms = end_time - start_time; 322 - tracing::debug!( 323 - para_id = %para_id, 324 - is_cursor_para, 325 - elapsed_ms, 326 - html_len = new_para.html.len(), 327 - old_hash = %old_hash, 328 - new_hash = %new_hash, 329 - "update_paragraph_dom: innerHTML update timing" 330 - ); 331 - } 332 - } 333 - 334 - if is_cursor_para { 335 - // Restore cursor synchronously - don't wait for rAF 336 - // This prevents race conditions with fast typing 337 - if let Err(e) = 338 - restore_cursor_position(cursor_offset, &new_para.offset_map, None) 339 - { 340 - tracing::warn!("Synchronous cursor restore failed: {:?}", e); 341 - } 342 - cursor_para_updated = true; 343 - } 344 - } 345 - } 346 - } else { 347 - // New element - create and insert at current position 348 - if let Ok(div) = document.create_element("div") { 349 - div.set_id(para_id); 350 - div.set_inner_html(&new_para.html); 351 - let _ = div.set_attribute("data-hash", &new_hash); 352 - let div_node: &web_sys::Node = div.as_ref(); 353 - let _ = editor.insert_before(div_node, cursor_node.as_ref()); 354 - } 355 - 356 - if is_cursor_para { 357 - cursor_para_updated = true; 358 - } 359 - } 360 - } 361 - 362 - // Remove stale elements (still in pool = not in new paragraphs) 363 - for (_, elem) in old_elements { 364 - let _ = elem.remove(); 365 - cursor_para_updated = true; // Structure changed, cursor may need restoration 366 - } 367 - 368 - cursor_para_updated 369 - }
-183
crates/weaver-app/src/components/editor/formatting.rs
··· 1 - //! Formatting actions and utilities for applying markdown formatting. 2 - 3 - use crate::components::editor::SignalEditorDocument; 4 - 5 - #[allow(unused_imports)] 6 - use super::input::{ListContext, detect_list_context, find_line_end}; 7 - use dioxus::prelude::*; 8 - 9 - // FormatAction is imported from core. 10 - pub use weaver_editor_core::FormatAction; 11 - 12 - /// Find word boundaries around cursor position. 13 - /// 14 - /// Expands to whitespace boundaries. Used when applying formatting 15 - /// without a selection. 16 - pub fn find_word_boundaries(text: &loro::LoroText, offset: usize) -> (usize, usize) { 17 - let len = text.len_unicode(); 18 - 19 - // Find start by scanning backwards using char_at 20 - let mut start = 0; 21 - for i in (0..offset).rev() { 22 - match text.char_at(i) { 23 - Ok(c) if c.is_whitespace() => { 24 - start = i + 1; 25 - break; 26 - } 27 - Ok(_) => continue, 28 - Err(_) => break, 29 - } 30 - } 31 - 32 - // Find end by scanning forwards using char_at 33 - let mut end = len; 34 - for i in offset..len { 35 - match text.char_at(i) { 36 - Ok(c) if c.is_whitespace() => { 37 - end = i; 38 - break; 39 - } 40 - Ok(_) => continue, 41 - Err(_) => break, 42 - } 43 - } 44 - 45 - (start, end) 46 - } 47 - 48 - /// Apply formatting to document. 49 - /// 50 - /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 51 - pub fn apply_formatting(doc: &mut SignalEditorDocument, action: FormatAction) { 52 - let cursor_offset = doc.cursor.read().offset; 53 - let (start, end) = if let Some(sel) = *doc.selection.read() { 54 - // Use selection 55 - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 56 - } else { 57 - // Expand to word 58 - find_word_boundaries(doc.loro_text(), cursor_offset) 59 - }; 60 - 61 - match action { 62 - FormatAction::Bold => { 63 - // Insert end marker first so start position stays valid 64 - let _ = doc.insert_tracked(end, "**"); 65 - let _ = doc.insert_tracked(start, "**"); 66 - doc.cursor.write().offset = end + 4; 67 - doc.selection.set(None); 68 - } 69 - FormatAction::Italic => { 70 - let _ = doc.insert_tracked(end, "*"); 71 - let _ = doc.insert_tracked(start, "*"); 72 - doc.cursor.write().offset = end + 2; 73 - doc.selection.set(None); 74 - } 75 - FormatAction::Strikethrough => { 76 - let _ = doc.insert_tracked(end, "~~"); 77 - let _ = doc.insert_tracked(start, "~~"); 78 - doc.cursor.write().offset = end + 4; 79 - doc.selection.set(None); 80 - } 81 - FormatAction::Code => { 82 - let _ = doc.insert_tracked(end, "`"); 83 - let _ = doc.insert_tracked(start, "`"); 84 - doc.cursor.write().offset = end + 2; 85 - doc.selection.set(None); 86 - } 87 - FormatAction::Link => { 88 - // Insert [selected text](url) 89 - let _ = doc.insert_tracked(end, "](url)"); 90 - let _ = doc.insert_tracked(start, "["); 91 - doc.cursor.write().offset = end + 8; // Position cursor after ](url) 92 - doc.selection.set(None); 93 - } 94 - FormatAction::Image => { 95 - // Insert ![alt text](url) 96 - let _ = doc.insert_tracked(end, "](url)"); 97 - let _ = doc.insert_tracked(start, "!["); 98 - doc.cursor.write().offset = end + 9; 99 - doc.selection.set(None); 100 - } 101 - FormatAction::Heading(level) => { 102 - // Find start of current line 103 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 104 - let prefix = "#".repeat(level as usize) + " "; 105 - let _ = doc.insert_tracked(line_start, &prefix); 106 - doc.cursor.write().offset = cursor_offset + prefix.len(); 107 - doc.selection.set(None); 108 - } 109 - FormatAction::BulletList => { 110 - if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 111 - let continuation = match ctx { 112 - ListContext::Unordered { indent, marker } => { 113 - format!("\n{}{} ", indent, marker) 114 - } 115 - ListContext::Ordered { .. } => { 116 - format!("\n\n - ") 117 - } 118 - }; 119 - let len = continuation.chars().count(); 120 - let _ = doc.insert_tracked(cursor_offset, &continuation); 121 - doc.cursor.write().offset = cursor_offset + len; 122 - doc.selection.set(None); 123 - } else { 124 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 125 - let _ = doc.insert_tracked(line_start, " - "); 126 - doc.cursor.write().offset = cursor_offset + 3; 127 - doc.selection.set(None); 128 - } 129 - } 130 - FormatAction::NumberedList => { 131 - if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 132 - let continuation = match ctx { 133 - ListContext::Unordered { .. } => { 134 - format!("\n\n1. ") 135 - } 136 - ListContext::Ordered { indent, number } => { 137 - format!("\n{}{}. ", indent, number + 1) 138 - } 139 - }; 140 - let len = continuation.chars().count(); 141 - let _ = doc.insert_tracked(cursor_offset, &continuation); 142 - doc.cursor.write().offset = cursor_offset + len; 143 - doc.selection.set(None); 144 - } else { 145 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 146 - let _ = doc.insert_tracked(line_start, "1. "); 147 - doc.cursor.write().offset = cursor_offset + 3; 148 - doc.selection.set(None); 149 - } 150 - } 151 - FormatAction::Quote => { 152 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 153 - let _ = doc.insert_tracked(line_start, "> "); 154 - doc.cursor.write().offset = cursor_offset + 2; 155 - doc.selection.set(None); 156 - } 157 - _ => { 158 - tracing::warn!(?action, "unhandled format action"); 159 - } 160 - } 161 - } 162 - 163 - /// Find start of line containing offset 164 - fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { 165 - if offset == 0 { 166 - return 0; 167 - } 168 - 169 - // Get text up to offset 170 - let prefix = match text.slice(0, offset) { 171 - Ok(s) => s, 172 - Err(_) => return 0, 173 - }; 174 - 175 - // Find last newline 176 - prefix 177 - .chars() 178 - .enumerate() 179 - .filter(|(_, c)| *c == '\n') 180 - .last() 181 - .map(|(pos, _)| pos + 1) 182 - .unwrap_or(0) 183 - }
+3 -4
crates/weaver-app/src/components/editor/input.rs
··· 5 5 use dioxus::prelude::*; 6 6 7 7 use super::document::SignalEditorDocument; 8 - use super::formatting::{self, FormatAction}; 9 - use weaver_editor_core::SnapDirection; 8 + use weaver_editor_core::{FormatAction, SnapDirection, apply_formatting}; 10 9 11 10 // Re-export ListContext from core - the logic is duplicated below for Loro-specific usage, 12 11 // but the type itself comes from core. ··· 64 63 if mods.ctrl() { 65 64 match ch.as_str() { 66 65 "b" => { 67 - formatting::apply_formatting(doc, FormatAction::Bold); 66 + apply_formatting(doc, FormatAction::Bold); 68 67 return; 69 68 } 70 69 "i" => { 71 - formatting::apply_formatting(doc, FormatAction::Italic); 70 + apply_formatting(doc, FormatAction::Italic); 72 71 return; 73 72 } 74 73 "z" => {
+8 -27
crates/weaver-app/src/components/editor/mod.rs
··· 8 8 mod beforeinput; 9 9 mod collab; 10 10 mod component; 11 - mod cursor; 12 11 mod document; 13 12 mod dom_sync; 14 - mod formatting; 15 13 mod image_upload; 16 14 mod input; 17 15 mod log_buffer; 18 - mod paragraph; 19 - mod platform; 20 16 mod publish; 21 - mod render; 22 17 mod report; 23 18 mod storage; 24 19 mod sync; 25 20 mod toolbar; 26 - mod visibility; 27 - mod writer; 28 21 29 22 #[cfg(test)] 30 23 mod tests; 31 24 32 - /// When true, always update innerHTML even for cursor paragraph during typing. 33 - /// This ensures syntax/formatting changes are immediately visible, but requires 34 - /// using `Handled` (preventDefault) for InsertText to avoid double-insertion 35 - /// from browser's default action racing with our innerHTML update. 36 - /// 37 - /// TODO: Replace with granular detection of syntax/formatting changes to allow 38 - /// PassThrough optimization when only text content changes. 39 - pub(crate) const FORCE_INNERHTML_UPDATE: bool = true; 25 + // Re-export DOM update strategy constant from browser crate. 26 + pub(crate) use weaver_editor_browser::FORCE_INNERHTML_UPDATE; 40 27 41 28 // Main component 42 29 pub use component::MarkdownEditor; ··· 47 34 Affinity, CompositionState, CursorState, LoadedDocState, Selection, SignalEditorDocument, 48 35 }; 49 36 50 - // Formatting 37 + // Formatting - re-export from core 51 38 #[allow(unused_imports)] 52 - pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 39 + pub use weaver_editor_core::{FormatAction, apply_formatting}; 53 40 54 41 // Rendering - re-export core types 55 42 #[allow(unused_imports)] 56 43 pub use weaver_editor_core::{ 57 - EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, RenderResult, 58 - SegmentedWriter, SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult, find_mapping_for_byte, 44 + EditorImageResolver, EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, 45 + OffsetMapping, ParagraphRender, RenderCache, RenderResult, SegmentedWriter, SyntaxSpanInfo, 46 + SyntaxType, TextBuffer, WriterResult, find_mapping_for_byte, render_paragraphs_incremental, 59 47 }; 60 - #[allow(unused_imports)] 61 - pub use paragraph::ParagraphRender; 62 - #[allow(unused_imports)] 63 - pub use render::{RenderCache, render_paragraphs_incremental}; 64 - // App-specific image resolver 65 - #[allow(unused_imports)] 66 - pub use writer::embed::EditorImageResolver; 67 48 68 49 // Storage 69 50 #[allow(unused_imports)] ··· 88 69 89 70 // Visibility 90 71 #[allow(unused_imports)] 91 - pub use visibility::VisibilityState; 72 + pub use weaver_editor_core::VisibilityState; 92 73 93 74 // Logging 94 75 #[allow(unused_imports)]
-14
crates/weaver-app/src/components/editor/paragraph.rs
··· 1 - //! Paragraph-level rendering for incremental updates. 2 - //! 3 - //! Re-exports core types and provides Loro-specific helpers. 4 - 5 - use loro::LoroText; 6 - use std::ops::Range; 7 - 8 - // Re-export core types. 9 - pub use weaver_editor_core::{ParagraphRender, hash_source, make_paragraph_id}; 10 - 11 - /// Extract substring from LoroText as String. 12 - pub fn text_slice_to_string(text: &LoroText, range: Range<usize>) -> String { 13 - text.slice(range.start, range.end).unwrap_or_default() 14 - }
-5
crates/weaver-app/src/components/editor/platform.rs
··· 1 - //! Platform detection for browser-specific workarounds. 2 - //! 3 - //! Re-exports from browser crate. 4 - 5 - pub use weaver_editor_browser::{Platform, platform};
-65
crates/weaver-app/src/components/editor/render.rs
··· 1 - //! Markdown rendering for the editor. 2 - //! 3 - //! Phase 2: Paragraph-level incremental rendering with formatting characters visible. 4 - //! 5 - //! This module provides a thin wrapper around the core rendering logic, 6 - //! adding app-specific features like timing instrumentation. 7 - 8 - use super::paragraph::ParagraphRender; 9 - use super::writer::embed::EditorImageResolver; 10 - use weaver_common::{EntryIndex, ResolvedContent}; 11 - use weaver_editor_core::{EditInfo, TextBuffer}; 12 - 13 - // Re-export core types. 14 - pub use weaver_editor_core::RenderCache; 15 - 16 - /// Render markdown with incremental caching. 17 - /// 18 - /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 19 - /// This is a thin wrapper around the core rendering logic that adds timing. 20 - /// 21 - /// # Parameters 22 - /// - `text`: Any TextBuffer implementation (LoroTextBuffer, EditorRope, etc.) 23 - /// - `cache`: Optional previous render cache 24 - /// - `cursor_offset`: Current cursor position 25 - /// - `edit`: Edit info for stable ID assignment 26 - /// - `image_resolver`: Optional image URL resolver 27 - /// - `entry_index`: Optional index for wikilink validation 28 - /// - `resolved_content`: Pre-resolved embed content for sync rendering 29 - /// 30 - /// # Returns 31 - /// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render 32 - pub fn render_paragraphs_incremental<T: TextBuffer>( 33 - text: &T, 34 - cache: Option<&RenderCache>, 35 - cursor_offset: usize, 36 - edit: Option<&EditInfo>, 37 - image_resolver: Option<&EditorImageResolver>, 38 - entry_index: Option<&EntryIndex>, 39 - resolved_content: &ResolvedContent, 40 - ) -> ( 41 - Vec<ParagraphRender>, 42 - RenderCache, 43 - Vec<weaver_common::ExtractedRef>, 44 - ) { 45 - let fn_start = crate::perf::now(); 46 - 47 - let result = weaver_editor_core::render_paragraphs_incremental( 48 - text, 49 - cache, 50 - cursor_offset, 51 - edit, 52 - image_resolver, 53 - entry_index, 54 - resolved_content, 55 - ); 56 - 57 - let total_ms = crate::perf::now() - fn_start; 58 - tracing::debug!( 59 - total_ms, 60 - paragraphs = result.paragraphs.len(), 61 - "render_paragraphs_incremental timing" 62 - ); 63 - 64 - (result.paragraphs, result.cache, result.collected_refs) 65 - }
+1 -1
crates/weaver-app/src/components/editor/report.rs
··· 34 34 .unwrap_or_default(); 35 35 36 36 let platform_info = { 37 - let plat = super::platform::platform(); 37 + let plat = weaver_editor_browser::platform(); 38 38 format!( 39 39 "iOS: {}, Android: {}, Safari: {}, Chrome: {}, Firefox: {}, Mobile: {}\n\ 40 40 User Agent: {}",
+110 -64
crates/weaver-app/src/components/editor/tests.rs
··· 1 1 //! Snapshot tests for the markdown editor rendering pipeline. 2 2 3 - use super::paragraph::ParagraphRender; 4 - use super::render::render_paragraphs_incremental; 5 3 use serde::Serialize; 6 4 use weaver_common::ResolvedContent; 7 - use weaver_editor_core::{OffsetMapping, TextBuffer, find_mapping_for_char}; 5 + use weaver_editor_core::ParagraphRender; 6 + use weaver_editor_core::{ 7 + EditorImageResolver, OffsetMapping, TextBuffer, find_mapping_for_char, 8 + render_paragraphs_incremental, 9 + }; 8 10 use weaver_editor_crdt::LoroTextBuffer; 9 11 10 12 /// Serializable version of ParagraphRender for snapshot testing. ··· 57 59 fn render_test(input: &str) -> Vec<TestParagraph> { 58 60 let mut buffer = LoroTextBuffer::new(); 59 61 buffer.insert(0, input); 60 - let (paragraphs, _cache, _refs) = 61 - render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 62 - paragraphs.iter().map(TestParagraph::from).collect() 62 + let result = render_paragraphs_incremental( 63 + &buffer, 64 + None, 65 + 0, 66 + None, 67 + None::<&EditorImageResolver>, 68 + None, 69 + &ResolvedContent::default(), 70 + ); 71 + result.paragraphs.iter().map(TestParagraph::from).collect() 63 72 } 64 73 65 74 // ============================================================================= ··· 411 420 assert!(has_code, "code block should be present in rendered output"); 412 421 } 413 422 414 - #[test] 415 - fn regression_bug11_gap_paragraphs_for_whitespace() { 416 - // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace 417 - // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK 423 + // ignored bc changing paragraph spacing 424 + // #[test] 425 + // fn regression_bug11_gap_paragraphs_for_whitespace() { 426 + // // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace 427 + // // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK 418 428 419 - // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) 420 - let result = render_test("# Title\n\n\n\nContent"); // 4 newlines 421 - assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); 422 - assert!( 423 - result[1].html.contains("gap-"), 424 - "Middle element should be a gap" 425 - ); 429 + // // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) 430 + // let result = render_test("# Title\n\n\n\nContent"); // 4 newlines 431 + // assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); 432 + // assert!( 433 + // result[1].html.contains("gap-"), 434 + // "Middle element should be a gap" 435 + // ); 426 436 427 - // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) 428 - let result2 = render_test("# Title\n\n\nContent"); // 3 newlines 429 - assert_eq!( 430 - result2.len(), 431 - 2, 432 - "Expected 2 elements with standard break equivalent" 433 - ); 434 - } 437 + // // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) 438 + // let result2 = render_test("# Title\n\n\nContent"); // 3 newlines 439 + // assert_eq!( 440 + // result2.len(), 441 + // 2, 442 + // "Expected 2 elements with standard break equivalent" 443 + // ); 444 + // } 435 445 436 446 // ============================================================================= 437 447 // Syntax Span Edge Case Tests ··· 638 648 fn test_heading_to_non_heading_transition() { 639 649 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) 640 650 // This tests that the syntax spans are correctly updated on content change. 641 - use super::render::render_paragraphs_incremental; 651 + use weaver_editor_core::render_paragraphs_incremental; 642 652 643 653 let mut buffer = LoroTextBuffer::new(); 644 654 645 655 // Initial state: "#" is a valid empty heading 646 656 buffer.insert(0, "#"); 647 - let (paras1, cache1, _refs1) = 648 - render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 657 + let result1 = render_paragraphs_incremental( 658 + &buffer, 659 + None, 660 + 0, 661 + None, 662 + None::<&EditorImageResolver>, 663 + None, 664 + &ResolvedContent::default(), 665 + ); 666 + let paras1 = result1.paragraphs; 667 + let cache1 = result1.cache; 649 668 650 669 eprintln!("State 1 ('#'): {}", paras1[0].html); 651 670 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); ··· 656 675 657 676 // Transition: add "t" to make "#t" - no longer a heading 658 677 buffer.insert(1, "t"); 659 - let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 678 + let result2 = render_paragraphs_incremental( 660 679 &buffer, 661 680 Some(&cache1), 662 681 0, 663 682 None, 664 - None, 683 + None::<&EditorImageResolver>, 665 684 None, 666 685 &ResolvedContent::default(), 667 686 ); 687 + let paras2 = result2.paragraphs; 668 688 669 689 eprintln!("State 2 ('#t'): {}", paras2[0].html); 670 690 assert!( ··· 772 792 let input = "Hello\n\nWorld"; 773 793 let mut buffer = LoroTextBuffer::new(); 774 794 buffer.insert(0, input); 775 - let (paragraphs, _cache, _refs) = 776 - render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 795 + let result = render_paragraphs_incremental( 796 + &buffer, 797 + None, 798 + 0, 799 + None, 800 + None::<&EditorImageResolver>, 801 + None, 802 + &ResolvedContent::default(), 803 + ); 804 + let paragraphs = result.paragraphs; 777 805 778 806 // With standard \n\n break, we expect 2 paragraphs (no gap element) 779 807 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 794 822 ); 795 823 } 796 824 797 - #[test] 798 - fn test_char_range_coverage_with_extra_whitespace() { 799 - // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 800 - // Plain paragraphs don't consume trailing newlines like headings do 801 - let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 802 - let mut buffer = LoroTextBuffer::new(); 803 - buffer.insert(0, input); 804 - let (paragraphs, _cache, _refs) = 805 - render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 825 + // old behaviour, need to re-check 826 + // #[test] 827 + // fn test_char_range_coverage_with_extra_whitespace() { 828 + // // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 829 + // // Plain paragraphs don't consume trailing newlines like headings do 830 + // let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 831 + // let mut buffer = LoroTextBuffer::new(); 832 + // buffer.insert(0, input); 833 + // let (paragraphs, _cache, _refs) = render_paragraphs_incremental( 834 + // &buffer, 835 + // None, 836 + // 0, 837 + // None, 838 + // None, 839 + // None, 840 + // &ResolvedContent::default(), 841 + // ); 806 842 807 - // With extra newlines, we expect 3 elements: para, gap, para 808 - assert_eq!( 809 - paragraphs.len(), 810 - 3, 811 - "Expected 3 elements with extra whitespace" 812 - ); 843 + // // With extra newlines, we expect 3 elements: para, gap, para 844 + // assert_eq!( 845 + // paragraphs.len(), 846 + // 3, 847 + // "Expected 3 elements with extra whitespace" 848 + // ); 813 849 814 - // Gap element should exist and cover whitespace zone 815 - let gap = &paragraphs[1]; 816 - assert!(gap.html.contains("gap-"), "Second element should be a gap"); 850 + // // Gap element should exist and cover whitespace zone 851 + // let gap = &paragraphs[1]; 852 + // assert!(gap.html.contains("gap-"), "Second element should be a gap"); 817 853 818 - // Gap should cover ALL whitespace (not just extra) 819 - assert_eq!( 820 - gap.char_range.start, paragraphs[0].char_range.end, 821 - "Gap should start where first paragraph ends" 822 - ); 823 - assert_eq!( 824 - gap.char_range.end, paragraphs[2].char_range.start, 825 - "Gap should end where second paragraph starts" 826 - ); 827 - } 854 + // // Gap should cover ALL whitespace (not just extra) 855 + // assert_eq!( 856 + // gap.char_range.start, paragraphs[0].char_range.end, 857 + // "Gap should start where first paragraph ends" 858 + // ); 859 + // assert_eq!( 860 + // gap.char_range.end, paragraphs[2].char_range.start, 861 + // "Gap should end where second paragraph starts" 862 + // ); 863 + // } 828 864 829 865 #[test] 830 866 fn test_node_ids_unique_across_paragraphs() { ··· 901 937 let mut buffer = LoroTextBuffer::new(); 902 938 buffer.insert(0, input); 903 939 904 - let (paras1, cache1, _refs1) = 905 - render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 940 + let result1 = render_paragraphs_incremental( 941 + &buffer, 942 + None, 943 + 0, 944 + None, 945 + None::<&EditorImageResolver>, 946 + None, 947 + &ResolvedContent::default(), 948 + ); 949 + let paras1 = result1.paragraphs; 950 + let cache1 = result1.cache; 906 951 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 907 952 908 953 // Second render with same content should reuse cache 909 - let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 954 + let result2 = render_paragraphs_incremental( 910 955 &buffer, 911 956 Some(&cache1), 912 957 0, 913 958 None, 914 - None, 959 + None::<&EditorImageResolver>, 915 960 None, 916 961 &ResolvedContent::default(), 917 962 ); 963 + let paras2 = result2.paragraphs; 918 964 919 965 // Should produce identical output 920 966 assert_eq!(paras1.len(), paras2.len());
+1 -1
crates/weaver-app/src/components/editor/toolbar.rs
··· 1 1 //! Editor toolbar component with formatting buttons. 2 2 3 - use super::formatting::FormatAction; 3 + use weaver_editor_core::FormatAction; 4 4 use super::image_upload::{ImageUploadButton, UploadedImage}; 5 5 use dioxus::prelude::*; 6 6
-9
crates/weaver-app/src/components/editor/visibility.rs
··· 1 - //! Conditional syntax visibility based on cursor position. 2 - //! 3 - //! Re-exports core visibility logic and browser DOM updates. 4 - 5 - // Core visibility calculation. 6 - pub use weaver_editor_core::VisibilityState; 7 - 8 - // Browser DOM updates. 9 - pub use weaver_editor_browser::update_syntax_visibility;
-16
crates/weaver-app/src/components/editor/writer.rs
··· 1 - //! HTML writer for markdown editor - re-exports from weaver-editor-core. 2 - //! 3 - //! The core EditorWriter lives in weaver-editor-core. This module provides: 4 - //! - Re-exports of core types for convenience 5 - //! - App-specific EditorImageResolver for image URL resolution 6 - 7 - pub mod embed; 8 - 9 - // Re-export everything from core 10 - pub use weaver_editor_core::{ 11 - EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SegmentedWriter, 12 - SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult, 13 - }; 14 - 15 - // App-specific image resolver 16 - pub use embed::EditorImageResolver;
-154
crates/weaver-app/src/components/editor/writer/embed.rs
··· 1 - //! App-specific image resolver for the editor. 2 - //! 3 - //! Provides EditorImageResolver which maps image names to URLs based on 4 - //! image state (pending upload, draft, published). 5 - 6 - use jacquard::types::{ident::AtIdentifier, string::Rkey}; 7 - use weaver_editor_core::ImageResolver; 8 - 9 - use crate::components::editor::document::EditorImage; 10 - 11 - /// Resolved image path type 12 - #[derive(Clone, Debug)] 13 - enum ResolvedImage { 14 - /// Data URL for immediate preview (still uploading) 15 - Pending(String), 16 - /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}` 17 - Draft { 18 - blob_rkey: Rkey<'static>, 19 - ident: AtIdentifier<'static>, 20 - }, 21 - /// Published image: `/image/{ident}/{entry_rkey}/{name}` 22 - Published { 23 - entry_rkey: Rkey<'static>, 24 - ident: AtIdentifier<'static>, 25 - }, 26 - } 27 - 28 - /// Resolves image paths in the editor. 29 - /// 30 - /// Supports three states for images: 31 - /// - Pending: uses data URL for immediate preview while upload is in progress 32 - /// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}` 33 - /// - Published: uses path format `/image/{did}/{entry_rkey}/{name}` 34 - /// 35 - /// Image URLs in markdown use the format `/image/{name}`. 36 - #[derive(Clone, Default)] 37 - pub struct EditorImageResolver { 38 - /// All resolved images: name -> resolved path info 39 - images: std::collections::HashMap<String, ResolvedImage>, 40 - } 41 - 42 - impl EditorImageResolver { 43 - pub fn new() -> Self { 44 - Self::default() 45 - } 46 - 47 - /// Add a pending image with a data URL for immediate preview. 48 - pub fn add_pending(&mut self, name: String, data_url: String) { 49 - self.images.insert(name, ResolvedImage::Pending(data_url)); 50 - } 51 - 52 - /// Promote a pending image to uploaded (draft) status. 53 - pub fn promote_to_uploaded( 54 - &mut self, 55 - name: &str, 56 - blob_rkey: Rkey<'static>, 57 - ident: AtIdentifier<'static>, 58 - ) { 59 - self.images 60 - .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident }); 61 - } 62 - 63 - /// Add an already-uploaded draft image. 64 - pub fn add_uploaded( 65 - &mut self, 66 - name: String, 67 - blob_rkey: Rkey<'static>, 68 - ident: AtIdentifier<'static>, 69 - ) { 70 - self.images 71 - .insert(name, ResolvedImage::Draft { blob_rkey, ident }); 72 - } 73 - 74 - /// Add a published image. 75 - pub fn add_published( 76 - &mut self, 77 - name: String, 78 - entry_rkey: Rkey<'static>, 79 - ident: AtIdentifier<'static>, 80 - ) { 81 - self.images 82 - .insert(name, ResolvedImage::Published { entry_rkey, ident }); 83 - } 84 - 85 - /// Check if an image is pending upload. 86 - pub fn is_pending(&self, name: &str) -> bool { 87 - matches!(self.images.get(name), Some(ResolvedImage::Pending(_))) 88 - } 89 - 90 - /// Build a resolver from editor images and user identifier. 91 - /// 92 - /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 93 - /// For published mode (entry_rkey=Some), all images are included. 94 - pub fn from_images<'a>( 95 - images: impl IntoIterator<Item = &'a EditorImage>, 96 - ident: AtIdentifier<'static>, 97 - entry_rkey: Option<Rkey<'static>>, 98 - ) -> Self { 99 - use jacquard::IntoStatic; 100 - 101 - let mut resolver = Self::new(); 102 - for editor_image in images { 103 - // Get the name from the Image (use alt text as fallback if name is empty) 104 - let name = editor_image 105 - .image 106 - .name 107 - .as_ref() 108 - .map(|n| n.to_string()) 109 - .unwrap_or_else(|| editor_image.image.alt.to_string()); 110 - 111 - if name.is_empty() { 112 - continue; 113 - } 114 - 115 - match &entry_rkey { 116 - // Published mode: use entry rkey for all images 117 - Some(rkey) => { 118 - resolver.add_published(name, rkey.clone(), ident.clone()); 119 - } 120 - // Draft mode: use published_blob_uri rkey 121 - None => { 122 - let blob_rkey = match &editor_image.published_blob_uri { 123 - Some(uri) => match uri.rkey() { 124 - Some(rkey) => rkey.0.clone().into_static(), 125 - None => continue, 126 - }, 127 - None => continue, 128 - }; 129 - resolver.add_uploaded(name, blob_rkey, ident.clone()); 130 - } 131 - } 132 - } 133 - resolver 134 - } 135 - } 136 - 137 - impl ImageResolver for EditorImageResolver { 138 - fn resolve_image_url(&self, url: &str) -> Option<String> { 139 - // Extract image name from /image/{name} format 140 - let name = url.strip_prefix("/image/").unwrap_or(url); 141 - 142 - let resolved = self.images.get(name)?; 143 - match resolved { 144 - ResolvedImage::Pending(data_url) => Some(data_url.clone()), 145 - ResolvedImage::Draft { blob_rkey, ident } => { 146 - Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name)) 147 - } 148 - ResolvedImage::Published { entry_rkey, ident } => { 149 - Some(format!("/image/{}/{}/{}", ident, entry_rkey, name)) 150 - } 151 - } 152 - } 153 - } 154 -
+1
crates/weaver-editor-browser/Cargo.toml
··· 55 55 "BlobPropertyBag", 56 56 "Clipboard", 57 57 "ClipboardItem", 58 + "Performance", 58 59 ] 59 60 60 61 [features]
+41
crates/weaver-editor-browser/src/color.rs
··· 1 + //! Color utilities for editor UI. 2 + 3 + /// Convert RGBA u32 (packed as 0xRRGGBBAA) to CSS rgba() string. 4 + pub fn rgba_u32_to_css(color: u32) -> String { 5 + let r = (color >> 24) & 0xFF; 6 + let g = (color >> 16) & 0xFF; 7 + let b = (color >> 8) & 0xFF; 8 + let a = (color & 0xFF) as f32 / 255.0; 9 + format!("rgba({}, {}, {}, {})", r, g, b, a) 10 + } 11 + 12 + /// Convert RGBA u32 to CSS rgba() string with a custom alpha value. 13 + /// 14 + /// Useful for creating semi-transparent versions of a color (e.g., selection highlights). 15 + pub fn rgba_u32_to_css_alpha(color: u32, alpha: f32) -> String { 16 + let r = (color >> 24) & 0xFF; 17 + let g = (color >> 16) & 0xFF; 18 + let b = (color >> 8) & 0xFF; 19 + format!("rgba({}, {}, {}, {})", r, g, b, alpha) 20 + } 21 + 22 + #[cfg(test)] 23 + mod tests { 24 + use super::*; 25 + 26 + #[test] 27 + fn test_rgba_to_css() { 28 + // Fully opaque red 29 + assert_eq!(rgba_u32_to_css(0xFF0000FF), "rgba(255, 0, 0, 1)"); 30 + // Semi-transparent green 31 + assert_eq!(rgba_u32_to_css(0x00FF0080), "rgba(0, 255, 0, 0.5019608)"); 32 + // Fully transparent blue 33 + assert_eq!(rgba_u32_to_css(0x0000FF00), "rgba(0, 0, 255, 0)"); 34 + } 35 + 36 + #[test] 37 + fn test_rgba_to_css_alpha() { 38 + // Red with 25% alpha override 39 + assert_eq!(rgba_u32_to_css_alpha(0xFF0000FF, 0.25), "rgba(255, 0, 0, 0.25)"); 40 + } 41 + }
+35 -4
crates/weaver-editor-browser/src/cursor.rs
··· 81 81 .flat_map(|p| p.offset_map.iter()) 82 82 .collect(); 83 83 let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect(); 84 - get_selection_rects_impl(start, end, &borrowed, &self.editor_id) 84 + get_selection_rects_relative(start, end, &borrowed, &self.editor_id) 85 85 } 86 86 } 87 87 ··· 274 274 Err("no text node found in container".into()) 275 275 } 276 276 277 - /// Get screen coordinates for a cursor position (internal impl). 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. 280 + pub 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. 287 + pub 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 + 278 306 fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> { 279 307 if offset_map.is_empty() { 280 308 return None; ··· 317 345 Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0))) 318 346 } 319 347 320 - /// Get selection rectangles relative to editor (internal impl). 321 - fn get_selection_rects_impl( 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. 352 + pub fn get_selection_rects_relative( 322 353 start: usize, 323 354 end: usize, 324 355 offset_map: &[OffsetMapping],
+110 -29
crates/weaver-editor-browser/src/dom_sync.rs
··· 372 372 None 373 373 } 374 374 375 - /// Paragraph render data needed for DOM updates. 375 + /// Update paragraph DOM elements incrementally. 376 376 /// 377 - /// This is a simplified view of paragraph data for the DOM sync layer. 378 - pub struct ParagraphDomData<'a> { 379 - /// Paragraph ID (for DOM element lookup). 380 - pub id: &'a str, 381 - /// HTML content to render. 382 - pub html: &'a str, 383 - /// Source hash for change detection. 384 - pub source_hash: u64, 385 - /// Character range in document. 386 - pub char_range: std::ops::Range<usize>, 387 - /// Offset mappings for cursor restoration. 388 - pub offset_map: &'a [OffsetMapping], 389 - } 390 - 391 - /// Update paragraph DOM elements incrementally. 377 + /// Uses stable content-based paragraph IDs for efficient DOM reconciliation: 378 + /// - Unchanged paragraphs (same ID + hash) are not touched 379 + /// - Changed paragraphs (same ID, different hash) get innerHTML updated 380 + /// - New paragraphs get created and inserted at correct position 381 + /// - Removed paragraphs get deleted 382 + /// 383 + /// When `FORCE_INNERHTML_UPDATE` is false, cursor paragraph innerHTML updates 384 + /// are skipped if only text content changed (syntax spans unchanged) and the 385 + /// DOM content length matches expected. This allows browser-native editing 386 + /// to proceed without disrupting the selection. 392 387 /// 393 388 /// Returns true if the paragraph containing the cursor was updated. 394 389 pub fn update_paragraph_dom( 395 390 editor_id: &str, 396 - old_paragraphs: &[ParagraphDomData<'_>], 397 - new_paragraphs: &[ParagraphDomData<'_>], 391 + old_paragraphs: &[ParagraphRender], 392 + new_paragraphs: &[ParagraphRender], 398 393 cursor_offset: usize, 399 394 force: bool, 400 395 ) -> bool { 396 + use crate::FORCE_INNERHTML_UPDATE; 401 397 use std::collections::HashMap; 402 398 403 399 let window = match web_sys::window() { ··· 417 413 418 414 let mut cursor_para_updated = false; 419 415 416 + // Build lookup for old paragraphs by ID (for syntax span comparison). 417 + let old_para_map: HashMap<&str, &ParagraphRender> = 418 + old_paragraphs.iter().map(|p| (p.id.as_str(), p)).collect(); 419 + 420 420 // Build pool of existing DOM elements by ID. 421 421 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new(); 422 422 let mut child_opt = editor.first_element_child(); ··· 433 433 let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into()); 434 434 435 435 for new_para in new_paragraphs.iter() { 436 - let para_id = new_para.id; 436 + let para_id = &new_para.id; 437 437 let new_hash = format!("{:x}", new_para.source_hash); 438 438 let is_cursor_para = 439 439 new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; 440 440 441 - if let Some(existing_elem) = old_elements.remove(para_id) { 441 + if let Some(existing_elem) = old_elements.remove(para_id.as_str()) { 442 442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); 443 443 let needs_update = force || old_hash != new_hash; 444 444 ··· 449 449 .unwrap_or(false); 450 450 451 451 if !at_correct_position { 452 + tracing::warn!( 453 + para_id = %para_id, 454 + is_cursor_para, 455 + "update_paragraph_dom: element not at correct position, moving" 456 + ); 452 457 let _ = editor.insert_before(existing_as_node, cursor_node.as_ref()); 453 458 if is_cursor_para { 454 459 cursor_para_updated = true; ··· 458 463 } 459 464 460 465 if needs_update { 461 - existing_elem.set_inner_html(new_para.html); 462 - let _ = existing_elem.set_attribute("data-hash", &new_hash); 466 + // For cursor paragraph: only update if syntax/formatting changed. 467 + // This prevents destroying browser selection during fast typing. 468 + // 469 + // HOWEVER: we must verify browser actually updated the DOM. 470 + // PassThrough assumes browser handles edit, but sometimes it doesn't. 471 + let should_skip_cursor_update = 472 + !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && { 473 + let old_para = old_para_map.get(para_id.as_str()); 474 + let syntax_unchanged = old_para 475 + .map(|old| old.syntax_spans == new_para.syntax_spans) 476 + .unwrap_or(false); 477 + 478 + // Verify DOM content length matches expected. 479 + let dom_matches_expected = if syntax_unchanged { 480 + let inner_elem = existing_elem.first_element_child(); 481 + let dom_text = inner_elem 482 + .as_ref() 483 + .and_then(|e| e.text_content()) 484 + .unwrap_or_default(); 485 + let expected_len = new_para.byte_range.end - new_para.byte_range.start; 486 + let dom_len = dom_text.len(); 487 + let matches = dom_len == expected_len; 488 + tracing::debug!( 489 + para_id = %para_id, 490 + dom_len, 491 + expected_len, 492 + matches, 493 + "DOM sync check" 494 + ); 495 + matches 496 + } else { 497 + false 498 + }; 499 + 500 + syntax_unchanged && dom_matches_expected 501 + }; 502 + 503 + if should_skip_cursor_update { 504 + tracing::trace!( 505 + para_id = %para_id, 506 + "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)" 507 + ); 508 + let _ = existing_elem.set_attribute("data-hash", &new_hash); 509 + } else { 510 + if tracing::enabled!(tracing::Level::TRACE) { 511 + let old_inner = existing_elem.inner_html(); 512 + tracing::trace!( 513 + para_id = %para_id, 514 + old_inner = %old_inner.escape_debug(), 515 + new_html = %new_para.html.escape_debug(), 516 + "update_paragraph_dom: replacing innerHTML" 517 + ); 518 + } 519 + 520 + // Timing instrumentation. 521 + let start = web_sys::window() 522 + .and_then(|w| w.performance()) 523 + .map(|p| p.now()); 524 + 525 + existing_elem.set_inner_html(&new_para.html); 526 + let _ = existing_elem.set_attribute("data-hash", &new_hash); 463 527 464 - if is_cursor_para { 465 - if let Err(e) = 466 - restore_cursor_position(cursor_offset, new_para.offset_map, None) 467 - { 468 - tracing::warn!("Cursor restore failed: {:?}", e); 528 + if let Some(start_time) = start { 529 + if let Some(end_time) = web_sys::window() 530 + .and_then(|w| w.performance()) 531 + .map(|p| p.now()) 532 + { 533 + let elapsed_ms = end_time - start_time; 534 + tracing::debug!( 535 + para_id = %para_id, 536 + is_cursor_para, 537 + elapsed_ms, 538 + html_len = new_para.html.len(), 539 + "update_paragraph_dom: innerHTML update timing" 540 + ); 541 + } 469 542 } 470 - cursor_para_updated = true; 543 + 544 + if is_cursor_para { 545 + if let Err(e) = 546 + restore_cursor_position(cursor_offset, &new_para.offset_map, None) 547 + { 548 + tracing::warn!("Synchronous cursor restore failed: {:?}", e); 549 + } 550 + cursor_para_updated = true; 551 + } 471 552 } 472 553 } 473 554 } else { 474 555 // New element - create and insert. 475 556 if let Ok(div) = document.create_element("div") { 476 557 div.set_id(para_id); 477 - div.set_inner_html(new_para.html); 558 + div.set_inner_html(&new_para.html); 478 559 let _ = div.set_attribute("data-hash", &new_hash); 479 560 let div_node: &web_sys::Node = div.as_ref(); 480 561 let _ = editor.insert_before(div_node, cursor_node.as_ref());
+82 -8
crates/weaver-editor-browser/src/events.rs
··· 302 302 303 303 // === BeforeInput handler === 304 304 305 - use weaver_editor_core::{EditorAction, EditorDocument, execute_action}; 305 + use crate::FORCE_INNERHTML_UPDATE; 306 + use weaver_editor_core::{EditorAction, EditorDocument, Selection, execute_action}; 307 + 308 + /// Get the current range (cursor or selection) from an EditorDocument. 309 + /// 310 + /// This is a convenience helper for building `BeforeInputContext`. 311 + pub fn get_current_range<D: EditorDocument>(doc: &D) -> Range { 312 + if let Some(sel) = doc.selection() { 313 + Range::new(sel.start(), sel.end()) 314 + } else { 315 + Range::caret(doc.cursor_offset()) 316 + } 317 + } 318 + 319 + /// Check if a character requires special delete handling. 320 + /// 321 + /// Returns true for newlines and zero-width chars which need semantic handling 322 + /// rather than simple char deletion. 323 + fn needs_special_delete_handling(ch: Option<char>) -> bool { 324 + matches!(ch, Some('\n') | Some('\u{200C}') | Some('\u{200B}')) 325 + } 306 326 307 327 /// Handle a beforeinput event, dispatching to the appropriate action. 308 328 /// ··· 311 331 /// from the document when `ctx.target_range` is None. 312 332 /// 313 333 /// Returns the handling result indicating whether default should be prevented. 334 + /// 335 + /// # DOM Update Strategy 336 + /// 337 + /// When [`FORCE_INNERHTML_UPDATE`] is `true`, this always returns `Handled` 338 + /// and the caller should preventDefault. The DOM will be updated via innerHTML. 339 + /// 340 + /// When `false`, simple operations (plain text insert, single char delete) 341 + /// return `PassThrough` to let the browser update the DOM while we track 342 + /// changes in the model. Complex operations still return `Handled`. 314 343 pub fn handle_beforeinput<D: EditorDocument>( 315 344 doc: &mut D, 316 345 ctx: &BeforeInputContext<'_>, ··· 343 372 range, 344 373 }; 345 374 execute_action(doc, &action); 346 - BeforeInputResult::Handled 375 + 376 + // When FORCE_INNERHTML_UPDATE is false, we can let browser handle 377 + // DOM updates for simple text insertions while we just track in model. 378 + if FORCE_INNERHTML_UPDATE { 379 + BeforeInputResult::Handled 380 + } else { 381 + BeforeInputResult::PassThrough 382 + } 347 383 } else { 348 384 BeforeInputResult::PassThrough 349 385 } ··· 388 424 }; 389 425 } 390 426 391 - let action = EditorAction::DeleteBackward { range }; 392 - execute_action(doc, &action); 393 - BeforeInputResult::Handled 427 + // Check if this delete requires special handling. 428 + let needs_special = if !range.is_caret() { 429 + // Selection delete - always handle for consistency. 430 + true 431 + } else if range.start == 0 { 432 + // At start - nothing to delete. 433 + false 434 + } else { 435 + // Check what char we're deleting. 436 + needs_special_delete_handling(doc.char_at(range.start - 1)) 437 + }; 438 + 439 + if needs_special || FORCE_INNERHTML_UPDATE { 440 + // Complex delete or forced mode - use full action handler. 441 + let action = EditorAction::DeleteBackward { range }; 442 + execute_action(doc, &action); 443 + BeforeInputResult::Handled 444 + } else { 445 + // Simple single-char delete - track in model, let browser handle DOM. 446 + if range.start > 0 { 447 + doc.delete(range.start - 1..range.start); 448 + } 449 + BeforeInputResult::PassThrough 450 + } 394 451 } 395 452 396 453 InputType::DeleteContentForward => { 397 - let action = EditorAction::DeleteForward { range }; 398 - execute_action(doc, &action); 399 - BeforeInputResult::Handled 454 + // Check if this delete requires special handling. 455 + let needs_special = if !range.is_caret() { 456 + true 457 + } else if range.start >= doc.len_chars() { 458 + false 459 + } else { 460 + needs_special_delete_handling(doc.char_at(range.start)) 461 + }; 462 + 463 + if needs_special || FORCE_INNERHTML_UPDATE { 464 + let action = EditorAction::DeleteForward { range }; 465 + execute_action(doc, &action); 466 + BeforeInputResult::Handled 467 + } else { 468 + // Simple delete forward. 469 + if range.start < doc.len_chars() { 470 + doc.delete(range.start..range.start + 1); 471 + } 472 + BeforeInputResult::PassThrough 473 + } 400 474 } 401 475 402 476 InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => {
+41 -6
crates/weaver-editor-browser/src/lib.rs
··· 11 11 //! - `events`: beforeinput event handling and clipboard helpers 12 12 //! - `platform`: Browser/OS detection for platform-specific behavior 13 13 //! 14 + //! # DOM Update Strategy 15 + //! 16 + //! The [`FORCE_INNERHTML_UPDATE`] constant controls how DOM updates are handled: 17 + //! 18 + //! - `true`: Editor always owns DOM updates. `handle_beforeinput` returns `Handled`, 19 + //! and `update_paragraph_dom` always replaces innerHTML. This is more predictable 20 + //! but can interfere with IME and cause flickering. 21 + //! 22 + //! - `false`: For simple edits (plain text insertion, single char deletion), 23 + //! `handle_beforeinput` can return `PassThrough` to let the browser update the DOM 24 + //! directly while we just track changes in the model. `update_paragraph_dom` will 25 + //! skip innerHTML replacement for the cursor paragraph if syntax is unchanged. 26 + //! This is smoother but requires careful coordination. 27 + //! 14 28 //! # Re-exports 15 29 //! 16 30 //! This crate re-exports `weaver-editor-core` for convenience, so consumers ··· 20 34 pub use weaver_editor_core; 21 35 pub use weaver_editor_core::*; 22 36 37 + /// Controls DOM update strategy. 38 + /// 39 + /// When `true`, the editor always owns DOM updates: 40 + /// - `handle_beforeinput` returns `Handled` (preventDefault) 41 + /// - `update_paragraph_dom` always replaces innerHTML 42 + /// 43 + /// When `false`, simple edits can be handled by the browser: 44 + /// - `handle_beforeinput` returns `PassThrough` for plain text inserts/deletes 45 + /// - `update_paragraph_dom` skips innerHTML for cursor paragraph if syntax unchanged 46 + /// 47 + /// Set to `true` for maximum control, `false` for smoother typing experience. 48 + pub const FORCE_INNERHTML_UPDATE: bool = true; 49 + 50 + pub mod color; 23 51 pub mod cursor; 24 52 pub mod dom_sync; 25 53 pub mod events; ··· 27 55 pub mod visibility; 28 56 29 57 // Browser cursor implementation 30 - pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position}; 58 + pub use cursor::{ 59 + BrowserCursor, find_text_node_at_offset, get_cursor_rect, get_cursor_rect_relative, 60 + get_selection_rects_relative, restore_cursor_position, 61 + }; 31 62 32 63 // DOM sync types 33 64 pub use dom_sync::{ 34 - BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset, 35 - sync_cursor_from_dom_impl, update_paragraph_dom, 65 + BrowserCursorSync, CursorSyncResult, dom_position_to_text_offset, sync_cursor_from_dom_impl, 66 + update_paragraph_dom, 36 67 }; 37 68 38 69 // Event handling 39 70 pub use events::{ 40 - BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_data_from_event, 41 - get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing, 42 - parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, 71 + BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_current_range, 72 + get_data_from_event, get_input_type_from_event, get_target_range_from_event, 73 + handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text, 74 + write_clipboard_with_custom_type, 43 75 }; 44 76 45 77 // Platform detection ··· 47 79 48 80 // Visibility updates 49 81 pub use visibility::update_syntax_visibility; 82 + 83 + // Color utilities 84 + pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha};
+115 -1
crates/weaver-editor-core/src/execute.rs
··· 4 4 //! operations to any type implementing `EditorDocument`. The logic is generic 5 5 //! and platform-agnostic. 6 6 7 - use crate::actions::{EditorAction, Range}; 7 + use crate::actions::{EditorAction, FormatAction, Range}; 8 8 use crate::document::EditorDocument; 9 9 use crate::text_helpers::{ 10 10 ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, ··· 367 367 doc.set_cursor_offset(end + 8); 368 368 doc.set_selection(None); 369 369 true 370 + } 371 + 372 + /// Apply a formatting action to the document. 373 + /// 374 + /// Handles markdown formatting operations like bold, italic, headings, lists, etc. 375 + /// If there's a selection, formatting wraps it. Otherwise, behavior depends on the action: 376 + /// - Inline formats (Bold, Italic, etc.) expand to word boundaries 377 + /// - Block formats (Heading, Quote, List) operate on the current line 378 + pub fn apply_formatting<D: EditorDocument>(doc: &mut D, action: FormatAction) -> bool { 379 + let cursor_offset = doc.cursor_offset(); 380 + let (start, end) = if let Some(sel) = doc.selection() { 381 + (sel.start(), sel.end()) 382 + } else { 383 + find_word_boundaries(doc, cursor_offset) 384 + }; 385 + 386 + match action { 387 + FormatAction::Bold => { 388 + doc.insert(end, "**"); 389 + doc.insert(start, "**"); 390 + doc.set_cursor_offset(end + 4); 391 + doc.set_selection(None); 392 + true 393 + } 394 + FormatAction::Italic => { 395 + doc.insert(end, "*"); 396 + doc.insert(start, "*"); 397 + doc.set_cursor_offset(end + 2); 398 + doc.set_selection(None); 399 + true 400 + } 401 + FormatAction::Strikethrough => { 402 + doc.insert(end, "~~"); 403 + doc.insert(start, "~~"); 404 + doc.set_cursor_offset(end + 4); 405 + doc.set_selection(None); 406 + true 407 + } 408 + FormatAction::Code => { 409 + doc.insert(end, "`"); 410 + doc.insert(start, "`"); 411 + doc.set_cursor_offset(end + 2); 412 + doc.set_selection(None); 413 + true 414 + } 415 + FormatAction::Link => { 416 + doc.insert(end, "](url)"); 417 + doc.insert(start, "["); 418 + doc.set_cursor_offset(end + 8); 419 + doc.set_selection(None); 420 + true 421 + } 422 + FormatAction::Image => { 423 + doc.insert(end, "](url)"); 424 + doc.insert(start, "!["); 425 + doc.set_cursor_offset(end + 9); 426 + doc.set_selection(None); 427 + true 428 + } 429 + FormatAction::Heading(level) => { 430 + let line_start = find_line_start(doc, cursor_offset); 431 + let prefix = "#".repeat(level as usize) + " "; 432 + let prefix_len = prefix.chars().count(); 433 + doc.insert(line_start, &prefix); 434 + doc.set_cursor_offset(cursor_offset + prefix_len); 435 + doc.set_selection(None); 436 + true 437 + } 438 + FormatAction::BulletList => { 439 + if let Some(ctx) = detect_list_context(doc, cursor_offset) { 440 + let continuation = match ctx { 441 + ListContext::Unordered { indent, marker } => { 442 + format!("\n{}{} ", indent, marker) 443 + } 444 + ListContext::Ordered { .. } => "\n\n - ".to_string(), 445 + }; 446 + let len = continuation.chars().count(); 447 + doc.insert(cursor_offset, &continuation); 448 + doc.set_cursor_offset(cursor_offset + len); 449 + } else { 450 + let line_start = find_line_start(doc, cursor_offset); 451 + doc.insert(line_start, " - "); 452 + doc.set_cursor_offset(cursor_offset + 3); 453 + } 454 + doc.set_selection(None); 455 + true 456 + } 457 + FormatAction::NumberedList => { 458 + if let Some(ctx) = detect_list_context(doc, cursor_offset) { 459 + let continuation = match ctx { 460 + ListContext::Unordered { .. } => "\n\n1. ".to_string(), 461 + ListContext::Ordered { indent, number } => { 462 + format!("\n{}{}. ", indent, number + 1) 463 + } 464 + }; 465 + let len = continuation.chars().count(); 466 + doc.insert(cursor_offset, &continuation); 467 + doc.set_cursor_offset(cursor_offset + len); 468 + } else { 469 + let line_start = find_line_start(doc, cursor_offset); 470 + doc.insert(line_start, "1. "); 471 + doc.set_cursor_offset(cursor_offset + 3); 472 + } 473 + doc.set_selection(None); 474 + true 475 + } 476 + FormatAction::Quote => { 477 + let line_start = find_line_start(doc, cursor_offset); 478 + doc.insert(line_start, "> "); 479 + doc.set_cursor_offset(cursor_offset + 2); 480 + doc.set_selection(None); 481 + true 482 + } 483 + } 370 484 } 371 485 372 486 fn execute_select_all<D: EditorDocument>(doc: &mut D) -> bool {
+1 -1
crates/weaver-editor-core/src/lib.rs
··· 47 47 EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, 48 48 Modifiers, Range, 49 49 }; 50 - pub use execute::execute_action; 50 + pub use execute::{apply_formatting, execute_action}; 51 51 pub use text_helpers::{ 52 52 ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start, 53 53 find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
+121
crates/weaver-editor-crdt/src/coordinator.rs
··· 1 + //! Collab coordinator types and helpers. 2 + //! 3 + //! Provides shared types for collab coordination that can be used by both 4 + //! Rust UI frameworks (Dioxus) and JS bindings. 5 + 6 + use smol_str::SmolStr; 7 + 8 + /// Session record TTL in minutes. 9 + pub const SESSION_TTL_MINUTES: u32 = 15; 10 + 11 + /// How often to refresh session record (ms). 12 + pub const SESSION_REFRESH_INTERVAL_MS: u32 = 5 * 60 * 1000; // 5 minutes 13 + 14 + /// How often to poll for new peers (ms). 15 + pub const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds 16 + 17 + /// Coordinator state machine states. 18 + /// 19 + /// Tracks the lifecycle of a collab session from initialization through 20 + /// active collaboration. UI can use this to show appropriate status indicators. 21 + #[derive(Debug, Clone, PartialEq)] 22 + pub enum CoordinatorState { 23 + /// Initial state - waiting for worker to be ready. 24 + Initializing, 25 + /// Creating session record on PDS. 26 + CreatingSession { 27 + /// The iroh node ID for this session. 28 + node_id: SmolStr, 29 + /// Optional relay URL for NAT traversal. 30 + relay_url: Option<SmolStr>, 31 + }, 32 + /// Active collab session. 33 + Active { 34 + /// The AT URI of the session record on PDS. 35 + session_uri: SmolStr, 36 + }, 37 + /// Error state. 38 + Error(SmolStr), 39 + } 40 + 41 + impl Default for CoordinatorState { 42 + fn default() -> Self { 43 + Self::Initializing 44 + } 45 + } 46 + 47 + impl CoordinatorState { 48 + /// Returns true if the coordinator is in an error state. 49 + pub fn is_error(&self) -> bool { 50 + matches!(self, Self::Error(_)) 51 + } 52 + 53 + /// Returns true if the coordinator is actively collaborating. 54 + pub fn is_active(&self) -> bool { 55 + matches!(self, Self::Active { .. }) 56 + } 57 + 58 + /// Returns the error message if in error state. 59 + pub fn error_message(&self) -> Option<&str> { 60 + match self { 61 + Self::Error(msg) => Some(msg.as_str()), 62 + _ => None, 63 + } 64 + } 65 + 66 + /// Returns the session URI if active. 67 + pub fn session_uri(&self) -> Option<&str> { 68 + match self { 69 + Self::Active { session_uri } => Some(session_uri.as_str()), 70 + _ => None, 71 + } 72 + } 73 + } 74 + 75 + /// Compute the gossip topic hash for a resource URI. 76 + /// 77 + /// The topic is a blake3 hash of the resource URI bytes, used to identify 78 + /// the gossip swarm for collaborative editing of that resource. 79 + pub fn compute_collab_topic(resource_uri: &str) -> [u8; 32] { 80 + let hash = weaver_common::blake3::hash(resource_uri.as_bytes()); 81 + *hash.as_bytes() 82 + } 83 + 84 + #[cfg(test)] 85 + mod tests { 86 + use super::*; 87 + 88 + #[test] 89 + fn test_coordinator_state_default() { 90 + assert_eq!(CoordinatorState::default(), CoordinatorState::Initializing); 91 + } 92 + 93 + #[test] 94 + fn test_coordinator_state_is_error() { 95 + assert!(!CoordinatorState::Initializing.is_error()); 96 + assert!(CoordinatorState::Error("test".into()).is_error()); 97 + } 98 + 99 + #[test] 100 + fn test_coordinator_state_is_active() { 101 + assert!(!CoordinatorState::Initializing.is_active()); 102 + assert!(CoordinatorState::Active { 103 + session_uri: "at://test".into() 104 + } 105 + .is_active()); 106 + } 107 + 108 + #[test] 109 + fn test_compute_collab_topic_deterministic() { 110 + let topic1 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/abc"); 111 + let topic2 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/abc"); 112 + assert_eq!(topic1, topic2); 113 + } 114 + 115 + #[test] 116 + fn test_compute_collab_topic_different_uris() { 117 + let topic1 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/abc"); 118 + let topic2 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/def"); 119 + assert_ne!(topic1, topic2); 120 + } 121 + }
+6
crates/weaver-editor-crdt/src/lib.rs
··· 5 5 //! - `CrdtDocument`: Trait for documents that can sync to AT Protocol PDS 6 6 //! - Generic sync logic for edit records (root/diff/draft) 7 7 //! - Worker implementation for off-main-thread CRDT operations 8 + //! - Collab coordination types and helpers 8 9 9 10 mod buffer; 11 + mod coordinator; 10 12 mod document; 11 13 mod error; 12 14 mod sync; ··· 14 16 pub mod worker; 15 17 16 18 pub use buffer::LoroTextBuffer; 19 + pub use coordinator::{ 20 + CoordinatorState, PEER_DISCOVERY_INTERVAL_MS, SESSION_REFRESH_INTERVAL_MS, SESSION_TTL_MINUTES, 21 + compute_collab_topic, 22 + }; 17 23 pub use document::{CrdtDocument, SimpleCrdtDocument, SyncState}; 18 24 pub use error::CrdtError; 19 25 pub use sync::{
+60
crates/weaver-embed-worker/src/host.rs
··· 1 + //! Host-side management for the embed worker. 2 + //! 3 + //! Provides `EmbedWorkerHost` for spawning and communicating with the embed 4 + //! worker from the main thread. This centralizes worker lifecycle management 5 + //! so consuming code just needs to provide a callback for results. 6 + 7 + use crate::{EmbedWorkerInput, EmbedWorkerOutput}; 8 + use gloo_worker::{Spawnable, WorkerBridge}; 9 + 10 + /// Host-side manager for the embed worker. 11 + /// 12 + /// Handles spawning the worker and sending messages. The callback provided 13 + /// at construction receives all worker outputs. 14 + /// 15 + /// # Example 16 + /// 17 + /// ```ignore 18 + /// let host = EmbedWorkerHost::spawn("/embed_worker.js", |output| { 19 + /// match output { 20 + /// EmbedWorkerOutput::Embeds { results, errors, fetch_ms } => { 21 + /// // Handle fetched embeds 22 + /// } 23 + /// EmbedWorkerOutput::CacheCleared => {} 24 + /// } 25 + /// }); 26 + /// 27 + /// host.fetch_embeds(vec!["at://did:plc:xxx/app.bsky.feed.post/yyy".into()]); 28 + /// ``` 29 + pub struct EmbedWorkerHost { 30 + bridge: WorkerBridge<crate::EmbedWorker>, 31 + } 32 + 33 + impl EmbedWorkerHost { 34 + /// Spawn the embed worker with a callback for outputs. 35 + /// 36 + /// The `worker_url` should point to the compiled worker JS file, 37 + /// typically "/embed_worker.js". 38 + pub fn spawn(worker_url: &str, on_output: impl Fn(EmbedWorkerOutput) + 'static) -> Self { 39 + let bridge = crate::EmbedWorker::spawner() 40 + .callback(on_output) 41 + .spawn(worker_url); 42 + Self { bridge } 43 + } 44 + 45 + /// Request embeds for a list of AT URIs. 46 + /// 47 + /// The worker will check its cache first, then fetch any missing embeds. 48 + /// Results arrive via the callback provided at construction. 49 + pub fn fetch_embeds(&self, uris: Vec<String>) { 50 + if uris.is_empty() { 51 + return; 52 + } 53 + self.bridge.send(EmbedWorkerInput::FetchEmbeds { uris }); 54 + } 55 + 56 + /// Clear the worker's embed cache. 57 + pub fn clear_cache(&self) { 58 + self.bridge.send(EmbedWorkerInput::ClearCache); 59 + } 60 + }
+26 -2
crates/weaver-embed-worker/src/lib.rs
··· 1 1 //! Web worker for fetching and caching AT Protocol embeds. 2 2 //! 3 - //! This crate provides a web worker that fetches and renders AT Protocol 4 - //! record embeds off the main thread, with TTL-based caching. 3 + //! This crate provides: 4 + //! - `EmbedWorker`: The worker implementation (runs in worker thread) 5 + //! - `EmbedWorkerHost`: Host-side manager for spawning and communicating with the worker 6 + //! - `EmbedWorkerInput`/`EmbedWorkerOutput`: Message types for worker communication 7 + //! 8 + //! # Usage 9 + //! 10 + //! The worker runs off the main thread, fetching and caching AT Protocol embeds. 11 + //! Use `EmbedWorkerHost` on the main thread to spawn and communicate with it: 12 + //! 13 + //! ```ignore 14 + //! use weaver_embed_worker::{EmbedWorkerHost, EmbedWorkerOutput}; 15 + //! 16 + //! let host = EmbedWorkerHost::spawn("/embed_worker.js", |output| { 17 + //! if let EmbedWorkerOutput::Embeds { results, .. } = output { 18 + //! // Update UI with fetched embeds 19 + //! } 20 + //! }); 21 + //! 22 + //! host.fetch_embeds(vec!["at://did:plc:xxx/app.bsky.feed.post/yyy".into()]); 23 + //! ``` 5 24 6 25 use serde::{Deserialize, Serialize}; 7 26 use std::collections::HashMap; ··· 163 182 164 183 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 165 184 pub use worker_impl::EmbedWorker; 185 + 186 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 187 + mod host; 188 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 189 + pub use host::EmbedWorkerHost;
+297
docs/graph-data.json
··· 1825 1825 "created_at": "2026-01-06T16:13:31.725544621-05:00", 1826 1826 "updated_at": "2026-01-06T16:13:31.725544621-05:00", 1827 1827 "metadata_json": "{\"confidence\":95}" 1828 + }, 1829 + { 1830 + "id": 168, 1831 + "change_id": "f823cca6-ffb6-4df9-b9a0-523fa783fb56", 1832 + "node_type": "outcome", 1833 + "title": "EditorDocument trait impl complete - SignalEditorDocument implements buffer(), cursor, selection, composition accessors via Signals, set_last_edit bumps content_changed", 1834 + "description": null, 1835 + "status": "pending", 1836 + "created_at": "2026-01-06T16:27:11.484430002-05:00", 1837 + "updated_at": "2026-01-06T16:27:11.484430002-05:00", 1838 + "metadata_json": "{\"confidence\":95}" 1839 + }, 1840 + { 1841 + "id": 169, 1842 + "change_id": "52456860-fce9-4312-9b98-b63e9364619f", 1843 + "node_type": "outcome", 1844 + "title": "LoroTextAdapter removed - render.rs now generic over &impl TextBuffer, tests use LoroTextBuffer directly", 1845 + "description": null, 1846 + "status": "pending", 1847 + "created_at": "2026-01-06T16:27:15.613322837-05:00", 1848 + "updated_at": "2026-01-06T16:27:15.613322837-05:00", 1849 + "metadata_json": "{\"confidence\":95}" 1850 + }, 1851 + { 1852 + "id": 170, 1853 + "change_id": "5347d57b-ce85-4852-9340-727db8801551", 1854 + "node_type": "outcome", 1855 + "title": "EditorDocument refactor complete - SignalEditorDocument wraps LoroTextBuffer, implements EditorDocument trait, render generic over TextBuffer", 1856 + "description": null, 1857 + "status": "pending", 1858 + "created_at": "2026-01-06T16:27:33.306772142-05:00", 1859 + "updated_at": "2026-01-06T16:27:33.306772142-05:00", 1860 + "metadata_json": "{\"confidence\":95}" 1861 + }, 1862 + { 1863 + "id": 171, 1864 + "change_id": "e0f8c887-c288-4fdf-b61b-c6a75a1e9c94", 1865 + "node_type": "outcome", 1866 + "title": "Formatting module fully extracted to core - apply_formatting handles all FormatAction variants, formatting.rs deleted from app", 1867 + "description": null, 1868 + "status": "pending", 1869 + "created_at": "2026-01-06T17:06:21.313945531-05:00", 1870 + "updated_at": "2026-01-06T17:06:21.313945531-05:00", 1871 + "metadata_json": "{\"confidence\":95}" 1872 + }, 1873 + { 1874 + "id": 172, 1875 + "change_id": "ad871947-0590-47ab-848c-eab47cb125c2", 1876 + "node_type": "goal", 1877 + "title": "Cursor module refactor - make browser cursor functions public and remove app duplicates", 1878 + "description": null, 1879 + "status": "pending", 1880 + "created_at": "2026-01-06T17:07:07.731289055-05:00", 1881 + "updated_at": "2026-01-06T17:07:07.731289055-05:00", 1882 + "metadata_json": "{\"confidence\":85}" 1883 + }, 1884 + { 1885 + "id": 173, 1886 + "change_id": "514cd2ee-94a2-4889-8654-934824feef04", 1887 + "node_type": "outcome", 1888 + "title": "Cursor functions extracted - get_cursor_rect, get_cursor_rect_relative, get_selection_rects_relative now public in browser crate, app's cursor.rs reduced from 206 to 35 lines", 1889 + "description": null, 1890 + "status": "pending", 1891 + "created_at": "2026-01-06T17:09:00.838425880-05:00", 1892 + "updated_at": "2026-01-06T17:09:00.838425880-05:00", 1893 + "metadata_json": "{\"confidence\":95}" 1894 + }, 1895 + { 1896 + "id": 174, 1897 + "change_id": "18c1dd40-fdb8-4822-8b66-34bade45d6ce", 1898 + "node_type": "outcome", 1899 + "title": "cursor.rs deleted from app entirely - was 206 lines, now 0", 1900 + "description": null, 1901 + "status": "pending", 1902 + "created_at": "2026-01-06T17:10:30.980248146-05:00", 1903 + "updated_at": "2026-01-06T17:10:30.980248146-05:00", 1904 + "metadata_json": "{\"confidence\":100}" 1905 + }, 1906 + { 1907 + "id": 175, 1908 + "change_id": "c6c259af-8514-4bab-bbda-3e42124d7592", 1909 + "node_type": "outcome", 1910 + "title": "Deleted writer/ dir, render.rs from app - all types now from core. EditorImage, EditorImageResolver use core versions. 69 tests pass.", 1911 + "description": null, 1912 + "status": "pending", 1913 + "created_at": "2026-01-06T17:18:26.321282250-05:00", 1914 + "updated_at": "2026-01-06T17:18:26.321282250-05:00", 1915 + "metadata_json": "{\"confidence\":95}" 1916 + }, 1917 + { 1918 + "id": 176, 1919 + "change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", 1920 + "node_type": "goal", 1921 + "title": "Evaluate and organize collab.rs and component.rs - consider extraction to core/browser/crdt crates and embed worker host migration", 1922 + "description": null, 1923 + "status": "pending", 1924 + "created_at": "2026-01-06T17:25:55.550260849-05:00", 1925 + "updated_at": "2026-01-06T17:25:55.550260849-05:00", 1926 + "metadata_json": "{\"confidence\":95,\"prompt\":\"User asked to review collab.rs and component.rs for cleanup/organization opportunities, potentially move code to weaver-editor-core, weaver-editor-browser, weaver-editor-crdt. Also consider moving embed worker host side to weaver-embed-worker\"}" 1927 + }, 1928 + { 1929 + "id": 177, 1930 + "change_id": "0f9cdce5-ae15-49e1-9cbd-7d2151d43e12", 1931 + "node_type": "action", 1932 + "title": "Migrate embed worker host side to weaver-embed-worker crate", 1933 + "description": null, 1934 + "status": "pending", 1935 + "created_at": "2026-01-06T17:31:25.829135397-05:00", 1936 + "updated_at": "2026-01-06T17:31:25.829135397-05:00", 1937 + "metadata_json": "{\"confidence\":90}" 1938 + }, 1939 + { 1940 + "id": 178, 1941 + "change_id": "df64b2b4-7074-4311-805d-5d40791c151b", 1942 + "node_type": "outcome", 1943 + "title": "EmbedWorkerHost extracted to weaver-embed-worker - host.rs with spawn/fetch_embeds/clear_cache API, component.rs simplified, non-wasm fallback removed", 1944 + "description": null, 1945 + "status": "pending", 1946 + "created_at": "2026-01-06T17:35:52.587988139-05:00", 1947 + "updated_at": "2026-01-06T17:35:52.587988139-05:00", 1948 + "metadata_json": "{\"confidence\":95}" 1949 + }, 1950 + { 1951 + "id": 179, 1952 + "change_id": "91b9cc98-8159-4166-929f-e40dd1603c6b", 1953 + "node_type": "outcome", 1954 + "title": "Browser helpers extracted - color.rs with rgba_u32_to_css and rgba_u32_to_css_alpha, worker-based autosave removed (simplified to main-thread save), 77 tests pass", 1955 + "description": null, 1956 + "status": "pending", 1957 + "created_at": "2026-01-06T17:39:11.648608310-05:00", 1958 + "updated_at": "2026-01-06T17:39:11.648608310-05:00", 1959 + "metadata_json": "{\"confidence\":95}" 1960 + }, 1961 + { 1962 + "id": 180, 1963 + "change_id": "867a78cf-9c15-4ac6-95e5-23f547e4cfde", 1964 + "node_type": "action", 1965 + "title": "Simple collab extraction - move CoordinatorState, constants, and topic helper to weaver-editor-crdt", 1966 + "description": null, 1967 + "status": "pending", 1968 + "created_at": "2026-01-06T17:40:52.904782219-05:00", 1969 + "updated_at": "2026-01-06T17:40:52.904782219-05:00", 1970 + "metadata_json": "{\"confidence\":90}" 1971 + }, 1972 + { 1973 + "id": 181, 1974 + "change_id": "da36f4f1-de32-4f2d-aaea-46e41756b75e", 1975 + "node_type": "outcome", 1976 + "title": "Collab types extracted - CoordinatorState, constants, compute_collab_topic in weaver-editor-crdt/coordinator.rs, collab.rs updated to use them, 88 tests pass", 1977 + "description": null, 1978 + "status": "pending", 1979 + "created_at": "2026-01-06T17:45:55.076540964-05:00", 1980 + "updated_at": "2026-01-06T17:45:55.076540964-05:00", 1981 + "metadata_json": "{\"confidence\":95}" 1828 1982 } 1829 1983 ], 1830 1984 "edges": [ ··· 3697 3851 "weight": 1.0, 3698 3852 "rationale": "Refactor implementation complete", 3699 3853 "created_at": "2026-01-06T16:13:31.873150501-05:00" 3854 + }, 3855 + { 3856 + "id": 172, 3857 + "from_node_id": 166, 3858 + "to_node_id": 168, 3859 + "from_change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", 3860 + "to_change_id": "f823cca6-ffb6-4df9-b9a0-523fa783fb56", 3861 + "edge_type": "leads_to", 3862 + "weight": 1.0, 3863 + "rationale": "Action produced this outcome", 3864 + "created_at": "2026-01-06T16:27:20.406600807-05:00" 3865 + }, 3866 + { 3867 + "id": 173, 3868 + "from_node_id": 168, 3869 + "to_node_id": 169, 3870 + "from_change_id": "f823cca6-ffb6-4df9-b9a0-523fa783fb56", 3871 + "to_change_id": "52456860-fce9-4312-9b98-b63e9364619f", 3872 + "edge_type": "leads_to", 3873 + "weight": 1.0, 3874 + "rationale": "Continued refactor", 3875 + "created_at": "2026-01-06T16:27:20.423272971-05:00" 3876 + }, 3877 + { 3878 + "id": 174, 3879 + "from_node_id": 164, 3880 + "to_node_id": 168, 3881 + "from_change_id": "9f555968-9c6c-4db6-bda1-6223c92acbec", 3882 + "to_change_id": "f823cca6-ffb6-4df9-b9a0-523fa783fb56", 3883 + "edge_type": "leads_to", 3884 + "weight": 1.0, 3885 + "rationale": "Goal achieved via refactor", 3886 + "created_at": "2026-01-06T16:27:33.331386230-05:00" 3887 + }, 3888 + { 3889 + "id": 175, 3890 + "from_node_id": 170, 3891 + "to_node_id": 171, 3892 + "from_change_id": "5347d57b-ce85-4852-9340-727db8801551", 3893 + "to_change_id": "e0f8c887-c288-4fdf-b61b-c6a75a1e9c94", 3894 + "edge_type": "leads_to", 3895 + "weight": 1.0, 3896 + "rationale": "Formatting extraction completed", 3897 + "created_at": "2026-01-06T17:06:34.061778318-05:00" 3898 + }, 3899 + { 3900 + "id": 176, 3901 + "from_node_id": 172, 3902 + "to_node_id": 173, 3903 + "from_change_id": "ad871947-0590-47ab-848c-eab47cb125c2", 3904 + "to_change_id": "514cd2ee-94a2-4889-8654-934824feef04", 3905 + "edge_type": "leads_to", 3906 + "weight": 1.0, 3907 + "rationale": "Cursor refactor completed", 3908 + "created_at": "2026-01-06T17:09:06.164296492-05:00" 3909 + }, 3910 + { 3911 + "id": 177, 3912 + "from_node_id": 173, 3913 + "to_node_id": 174, 3914 + "from_change_id": "514cd2ee-94a2-4889-8654-934824feef04", 3915 + "to_change_id": "18c1dd40-fdb8-4822-8b66-34bade45d6ce", 3916 + "edge_type": "leads_to", 3917 + "weight": 1.0, 3918 + "rationale": "Further cleanup", 3919 + "created_at": "2026-01-06T17:10:33.430711917-05:00" 3920 + }, 3921 + { 3922 + "id": 178, 3923 + "from_node_id": 174, 3924 + "to_node_id": 175, 3925 + "from_change_id": "18c1dd40-fdb8-4822-8b66-34bade45d6ce", 3926 + "to_change_id": "c6c259af-8514-4bab-bbda-3e42124d7592", 3927 + "edge_type": "leads_to", 3928 + "weight": 1.0, 3929 + "rationale": "Writer/render cleanup", 3930 + "created_at": "2026-01-06T17:18:26.339195552-05:00" 3931 + }, 3932 + { 3933 + "id": 179, 3934 + "from_node_id": 18, 3935 + "to_node_id": 176, 3936 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 3937 + "to_change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", 3938 + "edge_type": "leads_to", 3939 + "weight": 1.0, 3940 + "rationale": "Part of ongoing editor extraction goal", 3941 + "created_at": "2026-01-06T17:25:59.337129941-05:00" 3942 + }, 3943 + { 3944 + "id": 180, 3945 + "from_node_id": 176, 3946 + "to_node_id": 177, 3947 + "from_change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", 3948 + "to_change_id": "0f9cdce5-ae15-49e1-9cbd-7d2151d43e12", 3949 + "edge_type": "leads_to", 3950 + "weight": 1.0, 3951 + "rationale": "First task in evaluation goal", 3952 + "created_at": "2026-01-06T17:31:30.915708943-05:00" 3953 + }, 3954 + { 3955 + "id": 181, 3956 + "from_node_id": 177, 3957 + "to_node_id": 178, 3958 + "from_change_id": "0f9cdce5-ae15-49e1-9cbd-7d2151d43e12", 3959 + "to_change_id": "df64b2b4-7074-4311-805d-5d40791c151b", 3960 + "edge_type": "leads_to", 3961 + "weight": 1.0, 3962 + "rationale": "Action completed", 3963 + "created_at": "2026-01-06T17:35:58.536814810-05:00" 3964 + }, 3965 + { 3966 + "id": 182, 3967 + "from_node_id": 176, 3968 + "to_node_id": 179, 3969 + "from_change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", 3970 + "to_change_id": "91b9cc98-8159-4166-929f-e40dd1603c6b", 3971 + "edge_type": "leads_to", 3972 + "weight": 1.0, 3973 + "rationale": "Part of extraction goal", 3974 + "created_at": "2026-01-06T17:39:17.951129546-05:00" 3975 + }, 3976 + { 3977 + "id": 183, 3978 + "from_node_id": 176, 3979 + "to_node_id": 180, 3980 + "from_change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", 3981 + "to_change_id": "867a78cf-9c15-4ac6-95e5-23f547e4cfde", 3982 + "edge_type": "leads_to", 3983 + "weight": 1.0, 3984 + "rationale": "Part of extraction goal", 3985 + "created_at": "2026-01-06T17:40:58.868799958-05:00" 3986 + }, 3987 + { 3988 + "id": 184, 3989 + "from_node_id": 180, 3990 + "to_node_id": 181, 3991 + "from_change_id": "867a78cf-9c15-4ac6-95e5-23f547e4cfde", 3992 + "to_change_id": "da36f4f1-de32-4f2d-aaea-46e41756b75e", 3993 + "edge_type": "leads_to", 3994 + "weight": 1.0, 3995 + "rationale": "Action completed", 3996 + "created_at": "2026-01-06T17:46:00.638988346-05:00" 3700 3997 } 3701 3998 ] 3702 3999 }