···4//! which gives us semantic information about what the browser wants to do
5//! (insert text, delete backward, etc.) rather than raw key codes.
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-17use dioxus::prelude::*;
18-19-use super::actions::{EditorAction, execute_action};
20-use super::document::SignalEditorDocument;
21-use super::platform::Platform;
2223// Re-export types from extracted crates.
24pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult};
25-pub use weaver_editor_core::{InputType, Range};
2627#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28pub use weaver_editor_browser::StaticRange;
2930-/// Handle a beforeinput event.
31///
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- }
238239- InputType::DeleteSoftLineForward => {
240- let action = EditorAction::DeleteSoftLineForward { range };
241- execute_action(doc, &action);
242- BeforeInputResult::Handled
243- }
0244245- 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- }
315 }
316}
317318-/// 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.
329///
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.
332#[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;
344 }
345346- // 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();
349350- 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
434}
···4//! which gives us semantic information about what the browser wants to do
5//! (insert text, delete backward, etc.) rather than raw key codes.
6//!
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"))]
0000012use dioxus::prelude::*;
13+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
14+use weaver_editor_core::SnapDirection;
001516// Re-export types from extracted crates.
17pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult};
18+pub use weaver_editor_core::InputType;
1920#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
21pub use weaver_editor_browser::StaticRange;
2223+/// Determine the cursor snap direction hint for an input type.
24///
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),
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003839+ // 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),
4546+ // No snap hint for other operations.
47+ _ => None,
0000000000000000000000000000000000000000000000000000000000000000000048 }
49}
5051+/// Handle a beforeinput event.
000000000052///
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.
55#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
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));
000063 }
6465+ // Get current range for the browser handler.
66+ let current_range = weaver_editor_browser::get_current_range(doc);
06768+ // Delegate to browser crate's generic handler.
69+ weaver_editor_browser::handle_beforeinput(doc, &ctx, current_range)
000000000000000000000000000000000000000000000000000000000000000000000000000000000070}
+8-31
crates/weaver-app/src/components/editor/collab.rs
···16use dioxus::prelude::*;
1718#[cfg(target_arch = "wasm32")]
0019use jacquard::smol_str::{SmolStr, format_smolstr};
20#[cfg(target_arch = "wasm32")]
21use jacquard::types::string::AtUri;
22-23use weaver_common::transport::PresenceSnapshot;
2425-/// Session record TTL in minutes.
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
3637/// Props for the CollabCoordinator component.
38#[derive(Props, Clone, PartialEq)]
···47 pub children: Element,
48}
4950-/// 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/// Coordinator component that bridges worker and PDS.
68///
69/// This is a wrapper component that:
···183 tracing::info!("CollabCoordinator: worker ready, starting collab");
184185 // Compute topic from resource URI
186- let hash = weaver_common::blake3::hash(resource_uri.as_bytes());
187- let topic: [u8; 32] = *hash.as_bytes();
188189 // Send StartCollab to worker immediately (no blocking on profile fetch)
190 if let Some(ref mut s) = *worker_sink.write() {
···349 }
350351 state.set(CoordinatorState::Active {
352- session_uri: session_record_uri,
353 });
354 }
355 Err(e) => {
···16use dioxus::prelude::*;
1718#[cfg(target_arch = "wasm32")]
19+use jacquard::smol_str::ToSmolStr;
20+#[cfg(target_arch = "wasm32")]
21use jacquard::smol_str::{SmolStr, format_smolstr};
22#[cfg(target_arch = "wasm32")]
23use jacquard::types::string::AtUri;
024use weaver_common::transport::PresenceSnapshot;
25026#[cfg(target_arch = "wasm32")]
27+use weaver_editor_crdt::{
28+ CoordinatorState, PEER_DISCOVERY_INTERVAL_MS, SESSION_REFRESH_INTERVAL_MS, SESSION_TTL_MINUTES,
29+ compute_collab_topic,
30+};
000003132/// Props for the CollabCoordinator component.
33#[derive(Props, Clone, PartialEq)]
···42 pub children: Element,
43}
440000000000000000045/// Coordinator component that bridges worker and PDS.
46///
47/// This is a wrapper component that:
···161 tracing::info!("CollabCoordinator: worker ready, starting collab");
162163 // Compute topic from resource URI
164+ let topic = compute_collab_topic(&resource_uri);
0165166 // Send StartCollab to worker immediately (no blocking on profile fetch)
167 if let Some(ref mut s) = *worker_sink.write() {
···326 }
327328 state.set(CoordinatorState::Active {
329+ session_uri: session_record_uri.to_smolstr(),
330 });
331 }
332 Err(e) => {
···27use weaver_editor_core::EditorDocument;
28use weaver_editor_core::TextBuffer;
29use weaver_editor_core::UndoManager;
30-pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection};
0031use 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-}
4445/// Single source of truth for editor state.
46///
···27use weaver_editor_core::EditorDocument;
28use weaver_editor_core::TextBuffer;
29use weaver_editor_core::UndoManager;
30+pub use weaver_editor_core::{
31+ Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection,
32+};
33use weaver_editor_crdt::LoroTextBuffer;
0000000000003435/// Single source of truth for editor state.
36///
···3//! Handles syncing cursor/selection state between the browser DOM and our
4//! internal document model, and updating paragraph DOM elements.
5//!
6-//! The core DOM position conversion is provided by `weaver_editor_browser`.
078-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
9-use super::cursor::restore_cursor_position;
10#[allow(unused_imports)]
11use super::document::Selection;
12#[allow(unused_imports)]
13use super::document::SignalEditorDocument;
14-use super::paragraph::ParagraphRender;
15#[allow(unused_imports)]
16use dioxus::prelude::*;
017#[allow(unused_imports)]
18use weaver_editor_core::SnapDirection;
1920-// Re-export the DOM position conversion from browser crate.
21#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
22-pub use weaver_editor_browser::dom_position_to_text_offset;
2324/// Sync internal cursor and selection state from browser DOM selection.
25///
···149 _direction_hint: Option<SnapDirection>,
150) {
151}
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-}
···3//! Handles syncing cursor/selection state between the browser DOM and our
4//! internal document model, and updating paragraph DOM elements.
5//!
6+//! Most DOM sync logic is in `weaver_editor_browser`. This module provides
7+//! thin wrappers that work with `SignalEditorDocument` directly.
8009#[allow(unused_imports)]
10use super::document::Selection;
11#[allow(unused_imports)]
12use super::document::SignalEditorDocument;
013#[allow(unused_imports)]
14use dioxus::prelude::*;
15+use weaver_editor_core::ParagraphRender;
16#[allow(unused_imports)]
17use weaver_editor_core::SnapDirection;
1819+// Re-export from browser crate.
20#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
21+pub use weaver_editor_browser::{dom_position_to_text_offset, update_paragraph_dom};
2223/// Sync internal cursor and selection state from browser DOM selection.
24///
···148 _direction_hint: Option<SnapDirection>,
149) {
150}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1//! Snapshot tests for the markdown editor rendering pipeline.
23-use super::paragraph::ParagraphRender;
4-use super::render::render_paragraphs_incremental;
5use serde::Serialize;
6use weaver_common::ResolvedContent;
7-use weaver_editor_core::{OffsetMapping, TextBuffer, find_mapping_for_char};
00008use weaver_editor_crdt::LoroTextBuffer;
910/// Serializable version of ParagraphRender for snapshot testing.
···57fn render_test(input: &str) -> Vec<TestParagraph> {
58 let mut buffer = LoroTextBuffer::new();
59 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()
000000063}
6465// =============================================================================
···411 assert!(has_code, "code block should be present in rendered output");
412}
413414-#[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
0418419- // 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- );
426427- // 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-}
435436// =============================================================================
437// Syntax Span Edge Case Tests
···638fn test_heading_to_non_heading_transition() {
639 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
640 // This tests that the syntax spans are correctly updated on content change.
641- use super::render::render_paragraphs_incremental;
642643 let mut buffer = LoroTextBuffer::new();
644645 // Initial state: "#" is a valid empty heading
646 buffer.insert(0, "#");
647- let (paras1, cache1, _refs1) =
648- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
000000000649650 eprintln!("State 1 ('#'): {}", paras1[0].html);
651 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···656657 // Transition: add "t" to make "#t" - no longer a heading
658 buffer.insert(1, "t");
659- let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
660 &buffer,
661 Some(&cache1),
662 0,
663 None,
664- None,
665 None,
666 &ResolvedContent::default(),
667 );
0668669 eprintln!("State 2 ('#t'): {}", paras2[0].html);
670 assert!(
···772 let input = "Hello\n\nWorld";
773 let mut buffer = LoroTextBuffer::new();
774 buffer.insert(0, input);
775- let (paragraphs, _cache, _refs) =
776- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
00000000777778 // With standard \n\n break, we expect 2 paragraphs (no gap element)
779 // Paragraph ranges include some trailing whitespace from markdown parsing
···794 );
795}
796797-#[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());
00000000806807- // 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- );
813814- // Gap element should exist and cover whitespace zone
815- let gap = ¶graphs[1];
816- assert!(gap.html.contains("gap-"), "Second element should be a gap");
817818- // 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-}
828829#[test]
830fn test_node_ids_unique_across_paragraphs() {
···901 let mut buffer = LoroTextBuffer::new();
902 buffer.insert(0, input);
903904- let (paras1, cache1, _refs1) =
905- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
000000000906 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
907908 // Second render with same content should reuse cache
909- let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
910 &buffer,
911 Some(&cache1),
912 0,
913 None,
914- None,
915 None,
916 &ResolvedContent::default(),
917 );
0918919 // Should produce identical output
920 assert_eq!(paras1.len(), paras2.len());
···1//! Snapshot tests for the markdown editor rendering pipeline.
2003use serde::Serialize;
4use weaver_common::ResolvedContent;
5+use weaver_editor_core::ParagraphRender;
6+use weaver_editor_core::{
7+ EditorImageResolver, OffsetMapping, TextBuffer, find_mapping_for_char,
8+ render_paragraphs_incremental,
9+};
10use weaver_editor_crdt::LoroTextBuffer;
1112/// Serializable version of ParagraphRender for snapshot testing.
···59fn render_test(input: &str) -> Vec<TestParagraph> {
60 let mut buffer = LoroTextBuffer::new();
61 buffer.insert(0, input);
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()
72}
7374// =============================================================================
···420 assert!(has_code, "code block should be present in rendered output");
421}
422423+// 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
428429+// // 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+// );
436437+// // 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+// }
445446// =============================================================================
447// Syntax Span Edge Case Tests
···648fn test_heading_to_non_heading_transition() {
649 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
650 // This tests that the syntax spans are correctly updated on content change.
651+ use weaver_editor_core::render_paragraphs_incremental;
652653 let mut buffer = LoroTextBuffer::new();
654655 // Initial state: "#" is a valid empty heading
656 buffer.insert(0, "#");
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;
668669 eprintln!("State 1 ('#'): {}", paras1[0].html);
670 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···675676 // Transition: add "t" to make "#t" - no longer a heading
677 buffer.insert(1, "t");
678+ let result2 = render_paragraphs_incremental(
679 &buffer,
680 Some(&cache1),
681 0,
682 None,
683+ None::<&EditorImageResolver>,
684 None,
685 &ResolvedContent::default(),
686 );
687+ let paras2 = result2.paragraphs;
688689 eprintln!("State 2 ('#t'): {}", paras2[0].html);
690 assert!(
···792 let input = "Hello\n\nWorld";
793 let mut buffer = LoroTextBuffer::new();
794 buffer.insert(0, input);
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;
805806 // With standard \n\n break, we expect 2 paragraphs (no gap element)
807 // Paragraph ranges include some trailing whitespace from markdown parsing
···822 );
823}
824825+// 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+// );
842843+// // 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+// );
849850+// // Gap element should exist and cover whitespace zone
851+// let gap = ¶graphs[1];
852+// assert!(gap.html.contains("gap-"), "Second element should be a gap");
853854+// // 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+// }
864865#[test]
866fn test_node_ids_unique_across_paragraphs() {
···937 let mut buffer = LoroTextBuffer::new();
938 buffer.insert(0, input);
939940+ 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;
951 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
952953 // Second render with same content should reuse cache
954+ let result2 = render_paragraphs_incremental(
955 &buffer,
956 Some(&cache1),
957 0,
958 None,
959+ None::<&EditorImageResolver>,
960 None,
961 &ResolvedContent::default(),
962 );
963+ let paras2 = result2.paragraphs;
964965 // Should produce identical output
966 assert_eq!(paras1.len(), paras2.len());
···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;
···000000000
-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;
···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 .flat_map(|p| p.offset_map.iter())
82 .collect();
83 let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect();
84- get_selection_rects_impl(start, end, &borrowed, &self.editor_id)
85 }
86}
87···274 Err("no text node found in container".into())
275}
276277-/// Get screen coordinates for a cursor position (internal impl).
0000000000000000000000000000278fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> {
279 if offset_map.is_empty() {
280 return None;
···317 Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0)))
318}
319320-/// Get selection rectangles relative to editor (internal impl).
321-fn get_selection_rects_impl(
000322 start: usize,
323 end: usize,
324 offset_map: &[OffsetMapping],
···81 .flat_map(|p| p.offset_map.iter())
82 .collect();
83 let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect();
84+ get_selection_rects_relative(start, end, &borrowed, &self.editor_id)
85 }
86}
87···274 Err("no text node found in container".into())
275}
276277+/// 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+306fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> {
307 if offset_map.is_empty() {
308 return None;
···345 Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0)))
346}
347348+/// 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(
353 start: usize,
354 end: usize,
355 offset_map: &[OffsetMapping],
+110-29
crates/weaver-editor-browser/src/dom_sync.rs
···372 None
373}
374375-/// Paragraph render data needed for DOM updates.
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.
392///
393/// Returns true if the paragraph containing the cursor was updated.
394pub fn update_paragraph_dom(
395 editor_id: &str,
396- old_paragraphs: &[ParagraphDomData<'_>],
397- new_paragraphs: &[ParagraphDomData<'_>],
398 cursor_offset: usize,
399 force: bool,
400) -> bool {
0401 use std::collections::HashMap;
402403 let window = match web_sys::window() {
···417418 let mut cursor_para_updated = false;
4190000420 // Build pool of existing DOM elements by ID.
421 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new();
422 let mut child_opt = editor.first_element_child();
···433 let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into());
434435 for new_para in new_paragraphs.iter() {
436- let para_id = new_para.id;
437 let new_hash = format!("{:x}", new_para.source_hash);
438 let is_cursor_para =
439 new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end;
440441- if let Some(existing_elem) = old_elements.remove(para_id) {
442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
443 let needs_update = force || old_hash != new_hash;
444···449 .unwrap_or(false);
450451 if !at_correct_position {
00000452 let _ = editor.insert_before(existing_as_node, cursor_node.as_ref());
453 if is_cursor_para {
454 cursor_para_updated = true;
···458 }
459460 if needs_update {
461- existing_elem.set_inner_html(new_para.html);
462- let _ = existing_elem.set_attribute("data-hash", &new_hash);
00000000000000000000000000000000000000000000000000000000000463464- 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);
000000000469 }
470- cursor_para_updated = true;
00000000471 }
472 }
473 } else {
474 // New element - create and insert.
475 if let Ok(div) = document.create_element("div") {
476 div.set_id(para_id);
477- div.set_inner_html(new_para.html);
478 let _ = div.set_attribute("data-hash", &new_hash);
479 let div_node: &web_sys::Node = div.as_ref();
480 let _ = editor.insert_before(div_node, cursor_node.as_ref());
···372 None
373}
374375+/// Update paragraph DOM elements incrementally.
376///
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.
00000387///
388/// Returns true if the paragraph containing the cursor was updated.
389pub fn update_paragraph_dom(
390 editor_id: &str,
391+ old_paragraphs: &[ParagraphRender],
392+ new_paragraphs: &[ParagraphRender],
393 cursor_offset: usize,
394 force: bool,
395) -> bool {
396+ use crate::FORCE_INNERHTML_UPDATE;
397 use std::collections::HashMap;
398399 let window = match web_sys::window() {
···413414 let mut cursor_para_updated = false;
415416+ // 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 // Build pool of existing DOM elements by ID.
421 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new();
422 let mut child_opt = editor.first_element_child();
···433 let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into());
434435 for new_para in new_paragraphs.iter() {
436+ let para_id = &new_para.id;
437 let new_hash = format!("{:x}", new_para.source_hash);
438 let is_cursor_para =
439 new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end;
440441+ if let Some(existing_elem) = old_elements.remove(para_id.as_str()) {
442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
443 let needs_update = force || old_hash != new_hash;
444···449 .unwrap_or(false);
450451 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+ );
457 let _ = editor.insert_before(existing_as_node, cursor_node.as_ref());
458 if is_cursor_para {
459 cursor_para_updated = true;
···463 }
464465 if needs_update {
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);
527528+ 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+ }
542 }
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+ }
552 }
553 }
554 } else {
555 // New element - create and insert.
556 if let Ok(div) = document.create_element("div") {
557 div.set_id(para_id);
558+ div.set_inner_html(&new_para.html);
559 let _ = div.set_attribute("data-hash", &new_hash);
560 let div_node: &web_sys::Node = div.as_ref();
561 let _ = editor.insert_before(div_node, cursor_node.as_ref());
+82-8
crates/weaver-editor-browser/src/events.rs
···302303// === BeforeInput handler ===
304305-use weaver_editor_core::{EditorAction, EditorDocument, execute_action};
00000000000000000000306307/// Handle a beforeinput event, dispatching to the appropriate action.
308///
···311/// from the document when `ctx.target_range` is None.
312///
313/// Returns the handling result indicating whether default should be prevented.
000000000314pub fn handle_beforeinput<D: EditorDocument>(
315 doc: &mut D,
316 ctx: &BeforeInputContext<'_>,
···343 range,
344 };
345 execute_action(doc, &action);
346- BeforeInputResult::Handled
0000000347 } else {
348 BeforeInputResult::PassThrough
349 }
···388 };
389 }
390391- let action = EditorAction::DeleteBackward { range };
392- execute_action(doc, &action);
393- BeforeInputResult::Handled
000000000000000000000394 }
395396 InputType::DeleteContentForward => {
397- let action = EditorAction::DeleteForward { range };
398- execute_action(doc, &action);
399- BeforeInputResult::Handled
00000000000000000400 }
401402 InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => {
···302303// === BeforeInput handler ===
304305+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+}
326327/// Handle a beforeinput event, dispatching to the appropriate action.
328///
···331/// from the document when `ctx.target_range` is None.
332///
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`.
343pub fn handle_beforeinput<D: EditorDocument>(
344 doc: &mut D,
345 ctx: &BeforeInputContext<'_>,
···372 range,
373 };
374 execute_action(doc, &action);
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+ }
383 } else {
384 BeforeInputResult::PassThrough
385 }
···424 };
425 }
426427+ // 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+ }
451 }
452453 InputType::DeleteContentForward => {
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+ }
474 }
475476 InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => {
+41-6
crates/weaver-editor-browser/src/lib.rs
···11//! - `events`: beforeinput event handling and clipboard helpers
12//! - `platform`: Browser/OS detection for platform-specific behavior
13//!
0000000000000014//! # Re-exports
15//!
16//! This crate re-exports `weaver-editor-core` for convenience, so consumers
···20pub use weaver_editor_core;
21pub use weaver_editor_core::*;
220000000000000023pub mod cursor;
24pub mod dom_sync;
25pub mod events;
···27pub mod visibility;
2829// Browser cursor implementation
30-pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position};
0003132// DOM sync types
33pub use dom_sync::{
34- BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset,
35- sync_cursor_from_dom_impl, update_paragraph_dom,
36};
3738// Event handling
39pub 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,
043};
4445// Platform detection
···4748// Visibility updates
49pub use visibility::update_syntax_visibility;
000
···11//! - `events`: beforeinput event handling and clipboard helpers
12//! - `platform`: Browser/OS detection for platform-specific behavior
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+//!
28//! # Re-exports
29//!
30//! This crate re-exports `weaver-editor-core` for convenience, so consumers
···34pub use weaver_editor_core;
35pub use weaver_editor_core::*;
3637+/// 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;
51pub mod cursor;
52pub mod dom_sync;
53pub mod events;
···55pub mod visibility;
5657// Browser cursor implementation
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+};
6263// DOM sync types
64pub use dom_sync::{
65+ BrowserCursorSync, CursorSyncResult, dom_position_to_text_offset, sync_cursor_from_dom_impl,
66+ update_paragraph_dom,
67};
6869// Event handling
70pub use events::{
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,
75};
7677// Platform detection
···7980// Visibility updates
81pub 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//! operations to any type implementing `EditorDocument`. The logic is generic
5//! and platform-agnostic.
67-use crate::actions::{EditorAction, Range};
8use crate::document::EditorDocument;
9use crate::text_helpers::{
10 ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward,
···367 doc.set_cursor_offset(end + 8);
368 doc.set_selection(None);
369 true
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000370}
371372fn execute_select_all<D: EditorDocument>(doc: &mut D) -> bool {
···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//! - `CrdtDocument`: Trait for documents that can sync to AT Protocol PDS
6//! - Generic sync logic for edit records (root/diff/draft)
7//! - Worker implementation for off-main-thread CRDT operations
089mod buffer;
010mod document;
11mod error;
12mod sync;
···14pub mod worker;
1516pub use buffer::LoroTextBuffer;
000017pub use document::{CrdtDocument, SimpleCrdtDocument, SyncState};
18pub use error::CrdtError;
19pub use sync::{
···5//! - `CrdtDocument`: Trait for documents that can sync to AT Protocol PDS
6//! - Generic sync logic for edit records (root/diff/draft)
7//! - Worker implementation for off-main-thread CRDT operations
8+//! - Collab coordination types and helpers
910mod buffer;
11+mod coordinator;
12mod document;
13mod error;
14mod sync;
···16pub mod worker;
1718pub 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+};
23pub use document::{CrdtDocument, SimpleCrdtDocument, SyncState};
24pub use error::CrdtError;
25pub use sync::{
···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//! Web worker for fetching and caching AT Protocol embeds.
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.
000000000000000000056use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
···163164#[cfg(all(target_family = "wasm", target_os = "unknown"))]
165pub use worker_impl::EmbedWorker;
00000
···1//! Web worker for fetching and caching AT Protocol embeds.
2//!
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+//! ```
2425use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
···182183#[cfg(all(target_family = "wasm", target_os = "unknown"))]
184pub 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;