···2727use weaver_editor_core::EditorDocument;
2828use weaver_editor_core::TextBuffer;
2929use weaver_editor_core::UndoManager;
3030-pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection};
3030+pub use weaver_editor_core::{
3131+ Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection,
3232+};
3133use weaver_editor_crdt::LoroTextBuffer;
3232-3333-/// Helper for working with editor images.
3434-/// Constructed from LoroMap data, NOT serialized directly.
3535-/// The Image lexicon type stores our `publishedBlobUri` in its `extra_data` field.
3636-#[derive(Clone, Debug)]
3737-pub struct EditorImage {
3838- /// The lexicon Image type (deserialized via from_json_value)
3939- pub image: Image<'static>,
4040- /// AT-URI of the PublishedBlob record (for cleanup on publish/delete)
4141- /// None for existing images that are already in an entry record.
4242- pub published_blob_uri: Option<AtUri<'static>>,
4343-}
44344535/// Single source of truth for editor state.
4636///
···11-//! Platform detection for browser-specific workarounds.
22-//!
33-//! Re-exports from browser crate.
44-55-pub use weaver_editor_browser::{Platform, platform};
-65
crates/weaver-app/src/components/editor/render.rs
···11-//! Markdown rendering for the editor.
22-//!
33-//! Phase 2: Paragraph-level incremental rendering with formatting characters visible.
44-//!
55-//! This module provides a thin wrapper around the core rendering logic,
66-//! adding app-specific features like timing instrumentation.
77-88-use super::paragraph::ParagraphRender;
99-use super::writer::embed::EditorImageResolver;
1010-use weaver_common::{EntryIndex, ResolvedContent};
1111-use weaver_editor_core::{EditInfo, TextBuffer};
1212-1313-// Re-export core types.
1414-pub use weaver_editor_core::RenderCache;
1515-1616-/// Render markdown with incremental caching.
1717-///
1818-/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
1919-/// This is a thin wrapper around the core rendering logic that adds timing.
2020-///
2121-/// # Parameters
2222-/// - `text`: Any TextBuffer implementation (LoroTextBuffer, EditorRope, etc.)
2323-/// - `cache`: Optional previous render cache
2424-/// - `cursor_offset`: Current cursor position
2525-/// - `edit`: Edit info for stable ID assignment
2626-/// - `image_resolver`: Optional image URL resolver
2727-/// - `entry_index`: Optional index for wikilink validation
2828-/// - `resolved_content`: Pre-resolved embed content for sync rendering
2929-///
3030-/// # Returns
3131-/// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render
3232-pub fn render_paragraphs_incremental<T: TextBuffer>(
3333- text: &T,
3434- cache: Option<&RenderCache>,
3535- cursor_offset: usize,
3636- edit: Option<&EditInfo>,
3737- image_resolver: Option<&EditorImageResolver>,
3838- entry_index: Option<&EntryIndex>,
3939- resolved_content: &ResolvedContent,
4040-) -> (
4141- Vec<ParagraphRender>,
4242- RenderCache,
4343- Vec<weaver_common::ExtractedRef>,
4444-) {
4545- let fn_start = crate::perf::now();
4646-4747- let result = weaver_editor_core::render_paragraphs_incremental(
4848- text,
4949- cache,
5050- cursor_offset,
5151- edit,
5252- image_resolver,
5353- entry_index,
5454- resolved_content,
5555- );
5656-5757- let total_ms = crate::perf::now() - fn_start;
5858- tracing::debug!(
5959- total_ms,
6060- paragraphs = result.paragraphs.len(),
6161- "render_paragraphs_incremental timing"
6262- );
6363-6464- (result.paragraphs, result.cache, result.collected_refs)
6565-}
+1-1
crates/weaver-app/src/components/editor/report.rs
···3434 .unwrap_or_default();
35353636 let platform_info = {
3737- let plat = super::platform::platform();
3737+ let plat = weaver_editor_browser::platform();
3838 format!(
3939 "iOS: {}, Android: {}, Safari: {}, Chrome: {}, Firefox: {}, Mobile: {}\n\
4040 User Agent: {}",
+110-64
crates/weaver-app/src/components/editor/tests.rs
···11//! Snapshot tests for the markdown editor rendering pipeline.
2233-use super::paragraph::ParagraphRender;
44-use super::render::render_paragraphs_incremental;
53use serde::Serialize;
64use weaver_common::ResolvedContent;
77-use weaver_editor_core::{OffsetMapping, TextBuffer, find_mapping_for_char};
55+use weaver_editor_core::ParagraphRender;
66+use weaver_editor_core::{
77+ EditorImageResolver, OffsetMapping, TextBuffer, find_mapping_for_char,
88+ render_paragraphs_incremental,
99+};
810use weaver_editor_crdt::LoroTextBuffer;
9111012/// Serializable version of ParagraphRender for snapshot testing.
···5759fn render_test(input: &str) -> Vec<TestParagraph> {
5860 let mut buffer = LoroTextBuffer::new();
5961 buffer.insert(0, input);
6060- let (paragraphs, _cache, _refs) =
6161- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
6262- paragraphs.iter().map(TestParagraph::from).collect()
6262+ let result = render_paragraphs_incremental(
6363+ &buffer,
6464+ None,
6565+ 0,
6666+ None,
6767+ None::<&EditorImageResolver>,
6868+ None,
6969+ &ResolvedContent::default(),
7070+ );
7171+ result.paragraphs.iter().map(TestParagraph::from).collect()
6372}
64736574// =============================================================================
···411420 assert!(has_code, "code block should be present in rendered output");
412421}
413422414414-#[test]
415415-fn regression_bug11_gap_paragraphs_for_whitespace() {
416416- // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace
417417- // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK
423423+// ignored bc changing paragraph spacing
424424+// #[test]
425425+// fn regression_bug11_gap_paragraphs_for_whitespace() {
426426+// // Bug #11: Gap paragraphs should be created for EXTRA inter-block whitespace
427427+// // Note: Headings consume trailing newline, so need 4 newlines total for gap > MIN_PARAGRAPH_BREAK
418428419419- // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2)
420420- let result = render_test("# Title\n\n\n\nContent"); // 4 newlines
421421- assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace");
422422- assert!(
423423- result[1].html.contains("gap-"),
424424- "Middle element should be a gap"
425425- );
429429+// // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2)
430430+// let result = render_test("# Title\n\n\n\nContent"); // 4 newlines
431431+// assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace");
432432+// assert!(
433433+// result[1].html.contains("gap-"),
434434+// "Middle element should be a gap"
435435+// );
426436427427- // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element)
428428- let result2 = render_test("# Title\n\n\nContent"); // 3 newlines
429429- assert_eq!(
430430- result2.len(),
431431- 2,
432432- "Expected 2 elements with standard break equivalent"
433433- );
434434-}
437437+// // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element)
438438+// let result2 = render_test("# Title\n\n\nContent"); // 3 newlines
439439+// assert_eq!(
440440+// result2.len(),
441441+// 2,
442442+// "Expected 2 elements with standard break equivalent"
443443+// );
444444+// }
435445436446// =============================================================================
437447// Syntax Span Edge Case Tests
···638648fn test_heading_to_non_heading_transition() {
639649 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
640650 // This tests that the syntax spans are correctly updated on content change.
641641- use super::render::render_paragraphs_incremental;
651651+ use weaver_editor_core::render_paragraphs_incremental;
642652643653 let mut buffer = LoroTextBuffer::new();
644654645655 // Initial state: "#" is a valid empty heading
646656 buffer.insert(0, "#");
647647- let (paras1, cache1, _refs1) =
648648- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
657657+ let result1 = render_paragraphs_incremental(
658658+ &buffer,
659659+ None,
660660+ 0,
661661+ None,
662662+ None::<&EditorImageResolver>,
663663+ None,
664664+ &ResolvedContent::default(),
665665+ );
666666+ let paras1 = result1.paragraphs;
667667+ let cache1 = result1.cache;
649668650669 eprintln!("State 1 ('#'): {}", paras1[0].html);
651670 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···656675657676 // Transition: add "t" to make "#t" - no longer a heading
658677 buffer.insert(1, "t");
659659- let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
678678+ let result2 = render_paragraphs_incremental(
660679 &buffer,
661680 Some(&cache1),
662681 0,
663682 None,
664664- None,
683683+ None::<&EditorImageResolver>,
665684 None,
666685 &ResolvedContent::default(),
667686 );
687687+ let paras2 = result2.paragraphs;
668688669689 eprintln!("State 2 ('#t'): {}", paras2[0].html);
670690 assert!(
···772792 let input = "Hello\n\nWorld";
773793 let mut buffer = LoroTextBuffer::new();
774794 buffer.insert(0, input);
775775- let (paragraphs, _cache, _refs) =
776776- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
795795+ let result = render_paragraphs_incremental(
796796+ &buffer,
797797+ None,
798798+ 0,
799799+ None,
800800+ None::<&EditorImageResolver>,
801801+ None,
802802+ &ResolvedContent::default(),
803803+ );
804804+ let paragraphs = result.paragraphs;
777805778806 // With standard \n\n break, we expect 2 paragraphs (no gap element)
779807 // Paragraph ranges include some trailing whitespace from markdown parsing
···794822 );
795823}
796824797797-#[test]
798798-fn test_char_range_coverage_with_extra_whitespace() {
799799- // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
800800- // Plain paragraphs don't consume trailing newlines like headings do
801801- let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
802802- let mut buffer = LoroTextBuffer::new();
803803- buffer.insert(0, input);
804804- let (paragraphs, _cache, _refs) =
805805- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
825825+// old behaviour, need to re-check
826826+// #[test]
827827+// fn test_char_range_coverage_with_extra_whitespace() {
828828+// // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
829829+// // Plain paragraphs don't consume trailing newlines like headings do
830830+// let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
831831+// let mut buffer = LoroTextBuffer::new();
832832+// buffer.insert(0, input);
833833+// let (paragraphs, _cache, _refs) = render_paragraphs_incremental(
834834+// &buffer,
835835+// None,
836836+// 0,
837837+// None,
838838+// None,
839839+// None,
840840+// &ResolvedContent::default(),
841841+// );
806842807807- // With extra newlines, we expect 3 elements: para, gap, para
808808- assert_eq!(
809809- paragraphs.len(),
810810- 3,
811811- "Expected 3 elements with extra whitespace"
812812- );
843843+// // With extra newlines, we expect 3 elements: para, gap, para
844844+// assert_eq!(
845845+// paragraphs.len(),
846846+// 3,
847847+// "Expected 3 elements with extra whitespace"
848848+// );
813849814814- // Gap element should exist and cover whitespace zone
815815- let gap = ¶graphs[1];
816816- assert!(gap.html.contains("gap-"), "Second element should be a gap");
850850+// // Gap element should exist and cover whitespace zone
851851+// let gap = ¶graphs[1];
852852+// assert!(gap.html.contains("gap-"), "Second element should be a gap");
817853818818- // Gap should cover ALL whitespace (not just extra)
819819- assert_eq!(
820820- gap.char_range.start, paragraphs[0].char_range.end,
821821- "Gap should start where first paragraph ends"
822822- );
823823- assert_eq!(
824824- gap.char_range.end, paragraphs[2].char_range.start,
825825- "Gap should end where second paragraph starts"
826826- );
827827-}
854854+// // Gap should cover ALL whitespace (not just extra)
855855+// assert_eq!(
856856+// gap.char_range.start, paragraphs[0].char_range.end,
857857+// "Gap should start where first paragraph ends"
858858+// );
859859+// assert_eq!(
860860+// gap.char_range.end, paragraphs[2].char_range.start,
861861+// "Gap should end where second paragraph starts"
862862+// );
863863+// }
828864829865#[test]
830866fn test_node_ids_unique_across_paragraphs() {
···901937 let mut buffer = LoroTextBuffer::new();
902938 buffer.insert(0, input);
903939904904- let (paras1, cache1, _refs1) =
905905- render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
940940+ let result1 = render_paragraphs_incremental(
941941+ &buffer,
942942+ None,
943943+ 0,
944944+ None,
945945+ None::<&EditorImageResolver>,
946946+ None,
947947+ &ResolvedContent::default(),
948948+ );
949949+ let paras1 = result1.paragraphs;
950950+ let cache1 = result1.cache;
906951 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
907952908953 // Second render with same content should reuse cache
909909- let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
954954+ let result2 = render_paragraphs_incremental(
910955 &buffer,
911956 Some(&cache1),
912957 0,
913958 None,
914914- None,
959959+ None::<&EditorImageResolver>,
915960 None,
916961 &ResolvedContent::default(),
917962 );
963963+ let paras2 = result2.paragraphs;
918964919965 // Should produce identical output
920966 assert_eq!(paras1.len(), paras2.len());
···11-//! Conditional syntax visibility based on cursor position.
22-//!
33-//! Re-exports core visibility logic and browser DOM updates.
44-55-// Core visibility calculation.
66-pub use weaver_editor_core::VisibilityState;
77-88-// Browser DOM updates.
99-pub use weaver_editor_browser::update_syntax_visibility;
-16
crates/weaver-app/src/components/editor/writer.rs
···11-//! HTML writer for markdown editor - re-exports from weaver-editor-core.
22-//!
33-//! The core EditorWriter lives in weaver-editor-core. This module provides:
44-//! - Re-exports of core types for convenience
55-//! - App-specific EditorImageResolver for image URL resolution
66-77-pub mod embed;
88-99-// Re-export everything from core
1010-pub use weaver_editor_core::{
1111- EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SegmentedWriter,
1212- SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult,
1313-};
1414-1515-// App-specific image resolver
1616-pub use embed::EditorImageResolver;
···11+//! Color utilities for editor UI.
22+33+/// Convert RGBA u32 (packed as 0xRRGGBBAA) to CSS rgba() string.
44+pub fn rgba_u32_to_css(color: u32) -> String {
55+ let r = (color >> 24) & 0xFF;
66+ let g = (color >> 16) & 0xFF;
77+ let b = (color >> 8) & 0xFF;
88+ let a = (color & 0xFF) as f32 / 255.0;
99+ format!("rgba({}, {}, {}, {})", r, g, b, a)
1010+}
1111+1212+/// Convert RGBA u32 to CSS rgba() string with a custom alpha value.
1313+///
1414+/// Useful for creating semi-transparent versions of a color (e.g., selection highlights).
1515+pub fn rgba_u32_to_css_alpha(color: u32, alpha: f32) -> String {
1616+ let r = (color >> 24) & 0xFF;
1717+ let g = (color >> 16) & 0xFF;
1818+ let b = (color >> 8) & 0xFF;
1919+ format!("rgba({}, {}, {}, {})", r, g, b, alpha)
2020+}
2121+2222+#[cfg(test)]
2323+mod tests {
2424+ use super::*;
2525+2626+ #[test]
2727+ fn test_rgba_to_css() {
2828+ // Fully opaque red
2929+ assert_eq!(rgba_u32_to_css(0xFF0000FF), "rgba(255, 0, 0, 1)");
3030+ // Semi-transparent green
3131+ assert_eq!(rgba_u32_to_css(0x00FF0080), "rgba(0, 255, 0, 0.5019608)");
3232+ // Fully transparent blue
3333+ assert_eq!(rgba_u32_to_css(0x0000FF00), "rgba(0, 0, 255, 0)");
3434+ }
3535+3636+ #[test]
3737+ fn test_rgba_to_css_alpha() {
3838+ // Red with 25% alpha override
3939+ assert_eq!(rgba_u32_to_css_alpha(0xFF0000FF, 0.25), "rgba(255, 0, 0, 0.25)");
4040+ }
4141+}
+35-4
crates/weaver-editor-browser/src/cursor.rs
···8181 .flat_map(|p| p.offset_map.iter())
8282 .collect();
8383 let borrowed: Vec<_> = all_maps.iter().map(|m| (*m).clone()).collect();
8484- get_selection_rects_impl(start, end, &borrowed, &self.editor_id)
8484+ get_selection_rects_relative(start, end, &borrowed, &self.editor_id)
8585 }
8686}
8787···274274 Err("no text node found in container".into())
275275}
276276277277-/// Get screen coordinates for a cursor position (internal impl).
277277+/// Get screen coordinates for a cursor position.
278278+///
279279+/// Takes an offset map directly for cases where you don't have full paragraph data.
280280+pub fn get_cursor_rect(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> {
281281+ get_cursor_rect_impl(char_offset, offset_map)
282282+}
283283+284284+/// Get screen coordinates relative to the editor container.
285285+///
286286+/// Takes an offset map directly for cases where you don't have full paragraph data.
287287+pub fn get_cursor_rect_relative(
288288+ char_offset: usize,
289289+ offset_map: &[OffsetMapping],
290290+ editor_id: &str,
291291+) -> Option<CursorRect> {
292292+ let cursor_rect = get_cursor_rect(char_offset, offset_map)?;
293293+294294+ let window = web_sys::window()?;
295295+ let document = window.document()?;
296296+ let editor = document.get_element_by_id(editor_id)?;
297297+ let editor_rect = editor.get_bounding_client_rect();
298298+299299+ Some(CursorRect::new(
300300+ cursor_rect.x - editor_rect.x(),
301301+ cursor_rect.y - editor_rect.y(),
302302+ cursor_rect.height,
303303+ ))
304304+}
305305+278306fn get_cursor_rect_impl(char_offset: usize, offset_map: &[OffsetMapping]) -> Option<CursorRect> {
279307 if offset_map.is_empty() {
280308 return None;
···317345 Some(CursorRect::new(rect.x(), rect.y(), rect.height().max(16.0)))
318346}
319347320320-/// Get selection rectangles relative to editor (internal impl).
321321-fn get_selection_rects_impl(
348348+/// Get selection rectangles relative to editor.
349349+///
350350+/// Takes an offset map directly for cases where you don't have full paragraph data.
351351+/// Returns multiple rects if selection spans multiple lines.
352352+pub fn get_selection_rects_relative(
322353 start: usize,
323354 end: usize,
324355 offset_map: &[OffsetMapping],
+110-29
crates/weaver-editor-browser/src/dom_sync.rs
···372372 None
373373}
374374375375-/// Paragraph render data needed for DOM updates.
375375+/// Update paragraph DOM elements incrementally.
376376///
377377-/// This is a simplified view of paragraph data for the DOM sync layer.
378378-pub struct ParagraphDomData<'a> {
379379- /// Paragraph ID (for DOM element lookup).
380380- pub id: &'a str,
381381- /// HTML content to render.
382382- pub html: &'a str,
383383- /// Source hash for change detection.
384384- pub source_hash: u64,
385385- /// Character range in document.
386386- pub char_range: std::ops::Range<usize>,
387387- /// Offset mappings for cursor restoration.
388388- pub offset_map: &'a [OffsetMapping],
389389-}
390390-391391-/// Update paragraph DOM elements incrementally.
377377+/// Uses stable content-based paragraph IDs for efficient DOM reconciliation:
378378+/// - Unchanged paragraphs (same ID + hash) are not touched
379379+/// - Changed paragraphs (same ID, different hash) get innerHTML updated
380380+/// - New paragraphs get created and inserted at correct position
381381+/// - Removed paragraphs get deleted
382382+///
383383+/// When `FORCE_INNERHTML_UPDATE` is false, cursor paragraph innerHTML updates
384384+/// are skipped if only text content changed (syntax spans unchanged) and the
385385+/// DOM content length matches expected. This allows browser-native editing
386386+/// to proceed without disrupting the selection.
392387///
393388/// Returns true if the paragraph containing the cursor was updated.
394389pub fn update_paragraph_dom(
395390 editor_id: &str,
396396- old_paragraphs: &[ParagraphDomData<'_>],
397397- new_paragraphs: &[ParagraphDomData<'_>],
391391+ old_paragraphs: &[ParagraphRender],
392392+ new_paragraphs: &[ParagraphRender],
398393 cursor_offset: usize,
399394 force: bool,
400395) -> bool {
396396+ use crate::FORCE_INNERHTML_UPDATE;
401397 use std::collections::HashMap;
402398403399 let window = match web_sys::window() {
···417413418414 let mut cursor_para_updated = false;
419415416416+ // Build lookup for old paragraphs by ID (for syntax span comparison).
417417+ let old_para_map: HashMap<&str, &ParagraphRender> =
418418+ old_paragraphs.iter().map(|p| (p.id.as_str(), p)).collect();
419419+420420 // Build pool of existing DOM elements by ID.
421421 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new();
422422 let mut child_opt = editor.first_element_child();
···433433 let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into());
434434435435 for new_para in new_paragraphs.iter() {
436436- let para_id = new_para.id;
436436+ let para_id = &new_para.id;
437437 let new_hash = format!("{:x}", new_para.source_hash);
438438 let is_cursor_para =
439439 new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end;
440440441441- if let Some(existing_elem) = old_elements.remove(para_id) {
441441+ if let Some(existing_elem) = old_elements.remove(para_id.as_str()) {
442442 let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default();
443443 let needs_update = force || old_hash != new_hash;
444444···449449 .unwrap_or(false);
450450451451 if !at_correct_position {
452452+ tracing::warn!(
453453+ para_id = %para_id,
454454+ is_cursor_para,
455455+ "update_paragraph_dom: element not at correct position, moving"
456456+ );
452457 let _ = editor.insert_before(existing_as_node, cursor_node.as_ref());
453458 if is_cursor_para {
454459 cursor_para_updated = true;
···458463 }
459464460465 if needs_update {
461461- existing_elem.set_inner_html(new_para.html);
462462- let _ = existing_elem.set_attribute("data-hash", &new_hash);
466466+ // For cursor paragraph: only update if syntax/formatting changed.
467467+ // This prevents destroying browser selection during fast typing.
468468+ //
469469+ // HOWEVER: we must verify browser actually updated the DOM.
470470+ // PassThrough assumes browser handles edit, but sometimes it doesn't.
471471+ let should_skip_cursor_update =
472472+ !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && {
473473+ let old_para = old_para_map.get(para_id.as_str());
474474+ let syntax_unchanged = old_para
475475+ .map(|old| old.syntax_spans == new_para.syntax_spans)
476476+ .unwrap_or(false);
477477+478478+ // Verify DOM content length matches expected.
479479+ let dom_matches_expected = if syntax_unchanged {
480480+ let inner_elem = existing_elem.first_element_child();
481481+ let dom_text = inner_elem
482482+ .as_ref()
483483+ .and_then(|e| e.text_content())
484484+ .unwrap_or_default();
485485+ let expected_len = new_para.byte_range.end - new_para.byte_range.start;
486486+ let dom_len = dom_text.len();
487487+ let matches = dom_len == expected_len;
488488+ tracing::debug!(
489489+ para_id = %para_id,
490490+ dom_len,
491491+ expected_len,
492492+ matches,
493493+ "DOM sync check"
494494+ );
495495+ matches
496496+ } else {
497497+ false
498498+ };
499499+500500+ syntax_unchanged && dom_matches_expected
501501+ };
502502+503503+ if should_skip_cursor_update {
504504+ tracing::trace!(
505505+ para_id = %para_id,
506506+ "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)"
507507+ );
508508+ let _ = existing_elem.set_attribute("data-hash", &new_hash);
509509+ } else {
510510+ if tracing::enabled!(tracing::Level::TRACE) {
511511+ let old_inner = existing_elem.inner_html();
512512+ tracing::trace!(
513513+ para_id = %para_id,
514514+ old_inner = %old_inner.escape_debug(),
515515+ new_html = %new_para.html.escape_debug(),
516516+ "update_paragraph_dom: replacing innerHTML"
517517+ );
518518+ }
519519+520520+ // Timing instrumentation.
521521+ let start = web_sys::window()
522522+ .and_then(|w| w.performance())
523523+ .map(|p| p.now());
524524+525525+ existing_elem.set_inner_html(&new_para.html);
526526+ let _ = existing_elem.set_attribute("data-hash", &new_hash);
463527464464- if is_cursor_para {
465465- if let Err(e) =
466466- restore_cursor_position(cursor_offset, new_para.offset_map, None)
467467- {
468468- tracing::warn!("Cursor restore failed: {:?}", e);
528528+ if let Some(start_time) = start {
529529+ if let Some(end_time) = web_sys::window()
530530+ .and_then(|w| w.performance())
531531+ .map(|p| p.now())
532532+ {
533533+ let elapsed_ms = end_time - start_time;
534534+ tracing::debug!(
535535+ para_id = %para_id,
536536+ is_cursor_para,
537537+ elapsed_ms,
538538+ html_len = new_para.html.len(),
539539+ "update_paragraph_dom: innerHTML update timing"
540540+ );
541541+ }
469542 }
470470- cursor_para_updated = true;
543543+544544+ if is_cursor_para {
545545+ if let Err(e) =
546546+ restore_cursor_position(cursor_offset, &new_para.offset_map, None)
547547+ {
548548+ tracing::warn!("Synchronous cursor restore failed: {:?}", e);
549549+ }
550550+ cursor_para_updated = true;
551551+ }
471552 }
472553 }
473554 } else {
474555 // New element - create and insert.
475556 if let Ok(div) = document.create_element("div") {
476557 div.set_id(para_id);
477477- div.set_inner_html(new_para.html);
558558+ div.set_inner_html(&new_para.html);
478559 let _ = div.set_attribute("data-hash", &new_hash);
479560 let div_node: &web_sys::Node = div.as_ref();
480561 let _ = editor.insert_before(div_node, cursor_node.as_ref());
+82-8
crates/weaver-editor-browser/src/events.rs
···302302303303// === BeforeInput handler ===
304304305305-use weaver_editor_core::{EditorAction, EditorDocument, execute_action};
305305+use crate::FORCE_INNERHTML_UPDATE;
306306+use weaver_editor_core::{EditorAction, EditorDocument, Selection, execute_action};
307307+308308+/// Get the current range (cursor or selection) from an EditorDocument.
309309+///
310310+/// This is a convenience helper for building `BeforeInputContext`.
311311+pub fn get_current_range<D: EditorDocument>(doc: &D) -> Range {
312312+ if let Some(sel) = doc.selection() {
313313+ Range::new(sel.start(), sel.end())
314314+ } else {
315315+ Range::caret(doc.cursor_offset())
316316+ }
317317+}
318318+319319+/// Check if a character requires special delete handling.
320320+///
321321+/// Returns true for newlines and zero-width chars which need semantic handling
322322+/// rather than simple char deletion.
323323+fn needs_special_delete_handling(ch: Option<char>) -> bool {
324324+ matches!(ch, Some('\n') | Some('\u{200C}') | Some('\u{200B}'))
325325+}
306326307327/// Handle a beforeinput event, dispatching to the appropriate action.
308328///
···311331/// from the document when `ctx.target_range` is None.
312332///
313333/// Returns the handling result indicating whether default should be prevented.
334334+///
335335+/// # DOM Update Strategy
336336+///
337337+/// When [`FORCE_INNERHTML_UPDATE`] is `true`, this always returns `Handled`
338338+/// and the caller should preventDefault. The DOM will be updated via innerHTML.
339339+///
340340+/// When `false`, simple operations (plain text insert, single char delete)
341341+/// return `PassThrough` to let the browser update the DOM while we track
342342+/// changes in the model. Complex operations still return `Handled`.
314343pub fn handle_beforeinput<D: EditorDocument>(
315344 doc: &mut D,
316345 ctx: &BeforeInputContext<'_>,
···343372 range,
344373 };
345374 execute_action(doc, &action);
346346- BeforeInputResult::Handled
375375+376376+ // When FORCE_INNERHTML_UPDATE is false, we can let browser handle
377377+ // DOM updates for simple text insertions while we just track in model.
378378+ if FORCE_INNERHTML_UPDATE {
379379+ BeforeInputResult::Handled
380380+ } else {
381381+ BeforeInputResult::PassThrough
382382+ }
347383 } else {
348384 BeforeInputResult::PassThrough
349385 }
···388424 };
389425 }
390426391391- let action = EditorAction::DeleteBackward { range };
392392- execute_action(doc, &action);
393393- BeforeInputResult::Handled
427427+ // Check if this delete requires special handling.
428428+ let needs_special = if !range.is_caret() {
429429+ // Selection delete - always handle for consistency.
430430+ true
431431+ } else if range.start == 0 {
432432+ // At start - nothing to delete.
433433+ false
434434+ } else {
435435+ // Check what char we're deleting.
436436+ needs_special_delete_handling(doc.char_at(range.start - 1))
437437+ };
438438+439439+ if needs_special || FORCE_INNERHTML_UPDATE {
440440+ // Complex delete or forced mode - use full action handler.
441441+ let action = EditorAction::DeleteBackward { range };
442442+ execute_action(doc, &action);
443443+ BeforeInputResult::Handled
444444+ } else {
445445+ // Simple single-char delete - track in model, let browser handle DOM.
446446+ if range.start > 0 {
447447+ doc.delete(range.start - 1..range.start);
448448+ }
449449+ BeforeInputResult::PassThrough
450450+ }
394451 }
395452396453 InputType::DeleteContentForward => {
397397- let action = EditorAction::DeleteForward { range };
398398- execute_action(doc, &action);
399399- BeforeInputResult::Handled
454454+ // Check if this delete requires special handling.
455455+ let needs_special = if !range.is_caret() {
456456+ true
457457+ } else if range.start >= doc.len_chars() {
458458+ false
459459+ } else {
460460+ needs_special_delete_handling(doc.char_at(range.start))
461461+ };
462462+463463+ if needs_special || FORCE_INNERHTML_UPDATE {
464464+ let action = EditorAction::DeleteForward { range };
465465+ execute_action(doc, &action);
466466+ BeforeInputResult::Handled
467467+ } else {
468468+ // Simple delete forward.
469469+ if range.start < doc.len_chars() {
470470+ doc.delete(range.start..range.start + 1);
471471+ }
472472+ BeforeInputResult::PassThrough
473473+ }
400474 }
401475402476 InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => {
+41-6
crates/weaver-editor-browser/src/lib.rs
···1111//! - `events`: beforeinput event handling and clipboard helpers
1212//! - `platform`: Browser/OS detection for platform-specific behavior
1313//!
1414+//! # DOM Update Strategy
1515+//!
1616+//! The [`FORCE_INNERHTML_UPDATE`] constant controls how DOM updates are handled:
1717+//!
1818+//! - `true`: Editor always owns DOM updates. `handle_beforeinput` returns `Handled`,
1919+//! and `update_paragraph_dom` always replaces innerHTML. This is more predictable
2020+//! but can interfere with IME and cause flickering.
2121+//!
2222+//! - `false`: For simple edits (plain text insertion, single char deletion),
2323+//! `handle_beforeinput` can return `PassThrough` to let the browser update the DOM
2424+//! directly while we just track changes in the model. `update_paragraph_dom` will
2525+//! skip innerHTML replacement for the cursor paragraph if syntax is unchanged.
2626+//! This is smoother but requires careful coordination.
2727+//!
1428//! # Re-exports
1529//!
1630//! This crate re-exports `weaver-editor-core` for convenience, so consumers
···2034pub use weaver_editor_core;
2135pub use weaver_editor_core::*;
22363737+/// Controls DOM update strategy.
3838+///
3939+/// When `true`, the editor always owns DOM updates:
4040+/// - `handle_beforeinput` returns `Handled` (preventDefault)
4141+/// - `update_paragraph_dom` always replaces innerHTML
4242+///
4343+/// When `false`, simple edits can be handled by the browser:
4444+/// - `handle_beforeinput` returns `PassThrough` for plain text inserts/deletes
4545+/// - `update_paragraph_dom` skips innerHTML for cursor paragraph if syntax unchanged
4646+///
4747+/// Set to `true` for maximum control, `false` for smoother typing experience.
4848+pub const FORCE_INNERHTML_UPDATE: bool = true;
4949+5050+pub mod color;
2351pub mod cursor;
2452pub mod dom_sync;
2553pub mod events;
···2755pub mod visibility;
28562957// Browser cursor implementation
3030-pub use cursor::{BrowserCursor, find_text_node_at_offset, restore_cursor_position};
5858+pub use cursor::{
5959+ BrowserCursor, find_text_node_at_offset, get_cursor_rect, get_cursor_rect_relative,
6060+ get_selection_rects_relative, restore_cursor_position,
6161+};
31623263// DOM sync types
3364pub use dom_sync::{
3434- BrowserCursorSync, CursorSyncResult, ParagraphDomData, dom_position_to_text_offset,
3535- sync_cursor_from_dom_impl, update_paragraph_dom,
6565+ BrowserCursorSync, CursorSyncResult, dom_position_to_text_offset, sync_cursor_from_dom_impl,
6666+ update_paragraph_dom,
3667};
37683869// Event handling
3970pub use events::{
4040- BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_data_from_event,
4141- get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing,
4242- parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type,
7171+ BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_current_range,
7272+ get_data_from_event, get_input_type_from_event, get_target_range_from_event,
7373+ handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text,
7474+ write_clipboard_with_custom_type,
4375};
44764577// Platform detection
···47794880// Visibility updates
4981pub use visibility::update_syntax_visibility;
8282+8383+// Color utilities
8484+pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha};
···11+//! Collab coordinator types and helpers.
22+//!
33+//! Provides shared types for collab coordination that can be used by both
44+//! Rust UI frameworks (Dioxus) and JS bindings.
55+66+use smol_str::SmolStr;
77+88+/// Session record TTL in minutes.
99+pub const SESSION_TTL_MINUTES: u32 = 15;
1010+1111+/// How often to refresh session record (ms).
1212+pub const SESSION_REFRESH_INTERVAL_MS: u32 = 5 * 60 * 1000; // 5 minutes
1313+1414+/// How often to poll for new peers (ms).
1515+pub const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds
1616+1717+/// Coordinator state machine states.
1818+///
1919+/// Tracks the lifecycle of a collab session from initialization through
2020+/// active collaboration. UI can use this to show appropriate status indicators.
2121+#[derive(Debug, Clone, PartialEq)]
2222+pub enum CoordinatorState {
2323+ /// Initial state - waiting for worker to be ready.
2424+ Initializing,
2525+ /// Creating session record on PDS.
2626+ CreatingSession {
2727+ /// The iroh node ID for this session.
2828+ node_id: SmolStr,
2929+ /// Optional relay URL for NAT traversal.
3030+ relay_url: Option<SmolStr>,
3131+ },
3232+ /// Active collab session.
3333+ Active {
3434+ /// The AT URI of the session record on PDS.
3535+ session_uri: SmolStr,
3636+ },
3737+ /// Error state.
3838+ Error(SmolStr),
3939+}
4040+4141+impl Default for CoordinatorState {
4242+ fn default() -> Self {
4343+ Self::Initializing
4444+ }
4545+}
4646+4747+impl CoordinatorState {
4848+ /// Returns true if the coordinator is in an error state.
4949+ pub fn is_error(&self) -> bool {
5050+ matches!(self, Self::Error(_))
5151+ }
5252+5353+ /// Returns true if the coordinator is actively collaborating.
5454+ pub fn is_active(&self) -> bool {
5555+ matches!(self, Self::Active { .. })
5656+ }
5757+5858+ /// Returns the error message if in error state.
5959+ pub fn error_message(&self) -> Option<&str> {
6060+ match self {
6161+ Self::Error(msg) => Some(msg.as_str()),
6262+ _ => None,
6363+ }
6464+ }
6565+6666+ /// Returns the session URI if active.
6767+ pub fn session_uri(&self) -> Option<&str> {
6868+ match self {
6969+ Self::Active { session_uri } => Some(session_uri.as_str()),
7070+ _ => None,
7171+ }
7272+ }
7373+}
7474+7575+/// Compute the gossip topic hash for a resource URI.
7676+///
7777+/// The topic is a blake3 hash of the resource URI bytes, used to identify
7878+/// the gossip swarm for collaborative editing of that resource.
7979+pub fn compute_collab_topic(resource_uri: &str) -> [u8; 32] {
8080+ let hash = weaver_common::blake3::hash(resource_uri.as_bytes());
8181+ *hash.as_bytes()
8282+}
8383+8484+#[cfg(test)]
8585+mod tests {
8686+ use super::*;
8787+8888+ #[test]
8989+ fn test_coordinator_state_default() {
9090+ assert_eq!(CoordinatorState::default(), CoordinatorState::Initializing);
9191+ }
9292+9393+ #[test]
9494+ fn test_coordinator_state_is_error() {
9595+ assert!(!CoordinatorState::Initializing.is_error());
9696+ assert!(CoordinatorState::Error("test".into()).is_error());
9797+ }
9898+9999+ #[test]
100100+ fn test_coordinator_state_is_active() {
101101+ assert!(!CoordinatorState::Initializing.is_active());
102102+ assert!(CoordinatorState::Active {
103103+ session_uri: "at://test".into()
104104+ }
105105+ .is_active());
106106+ }
107107+108108+ #[test]
109109+ fn test_compute_collab_topic_deterministic() {
110110+ let topic1 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/abc");
111111+ let topic2 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/abc");
112112+ assert_eq!(topic1, topic2);
113113+ }
114114+115115+ #[test]
116116+ fn test_compute_collab_topic_different_uris() {
117117+ let topic1 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/abc");
118118+ let topic2 = compute_collab_topic("at://did:plc:test/app.weaver.notebook.entry/def");
119119+ assert_ne!(topic1, topic2);
120120+ }
121121+}
+6
crates/weaver-editor-crdt/src/lib.rs
···55//! - `CrdtDocument`: Trait for documents that can sync to AT Protocol PDS
66//! - Generic sync logic for edit records (root/diff/draft)
77//! - Worker implementation for off-main-thread CRDT operations
88+//! - Collab coordination types and helpers
89910mod buffer;
1111+mod coordinator;
1012mod document;
1113mod error;
1214mod sync;
···1416pub mod worker;
15171618pub use buffer::LoroTextBuffer;
1919+pub use coordinator::{
2020+ CoordinatorState, PEER_DISCOVERY_INTERVAL_MS, SESSION_REFRESH_INTERVAL_MS, SESSION_TTL_MINUTES,
2121+ compute_collab_topic,
2222+};
1723pub use document::{CrdtDocument, SimpleCrdtDocument, SyncState};
1824pub use error::CrdtError;
1925pub use sync::{
+60
crates/weaver-embed-worker/src/host.rs
···11+//! Host-side management for the embed worker.
22+//!
33+//! Provides `EmbedWorkerHost` for spawning and communicating with the embed
44+//! worker from the main thread. This centralizes worker lifecycle management
55+//! so consuming code just needs to provide a callback for results.
66+77+use crate::{EmbedWorkerInput, EmbedWorkerOutput};
88+use gloo_worker::{Spawnable, WorkerBridge};
99+1010+/// Host-side manager for the embed worker.
1111+///
1212+/// Handles spawning the worker and sending messages. The callback provided
1313+/// at construction receives all worker outputs.
1414+///
1515+/// # Example
1616+///
1717+/// ```ignore
1818+/// let host = EmbedWorkerHost::spawn("/embed_worker.js", |output| {
1919+/// match output {
2020+/// EmbedWorkerOutput::Embeds { results, errors, fetch_ms } => {
2121+/// // Handle fetched embeds
2222+/// }
2323+/// EmbedWorkerOutput::CacheCleared => {}
2424+/// }
2525+/// });
2626+///
2727+/// host.fetch_embeds(vec!["at://did:plc:xxx/app.bsky.feed.post/yyy".into()]);
2828+/// ```
2929+pub struct EmbedWorkerHost {
3030+ bridge: WorkerBridge<crate::EmbedWorker>,
3131+}
3232+3333+impl EmbedWorkerHost {
3434+ /// Spawn the embed worker with a callback for outputs.
3535+ ///
3636+ /// The `worker_url` should point to the compiled worker JS file,
3737+ /// typically "/embed_worker.js".
3838+ pub fn spawn(worker_url: &str, on_output: impl Fn(EmbedWorkerOutput) + 'static) -> Self {
3939+ let bridge = crate::EmbedWorker::spawner()
4040+ .callback(on_output)
4141+ .spawn(worker_url);
4242+ Self { bridge }
4343+ }
4444+4545+ /// Request embeds for a list of AT URIs.
4646+ ///
4747+ /// The worker will check its cache first, then fetch any missing embeds.
4848+ /// Results arrive via the callback provided at construction.
4949+ pub fn fetch_embeds(&self, uris: Vec<String>) {
5050+ if uris.is_empty() {
5151+ return;
5252+ }
5353+ self.bridge.send(EmbedWorkerInput::FetchEmbeds { uris });
5454+ }
5555+5656+ /// Clear the worker's embed cache.
5757+ pub fn clear_cache(&self) {
5858+ self.bridge.send(EmbedWorkerInput::ClearCache);
5959+ }
6060+}
+26-2
crates/weaver-embed-worker/src/lib.rs
···11//! Web worker for fetching and caching AT Protocol embeds.
22//!
33-//! This crate provides a web worker that fetches and renders AT Protocol
44-//! record embeds off the main thread, with TTL-based caching.
33+//! This crate provides:
44+//! - `EmbedWorker`: The worker implementation (runs in worker thread)
55+//! - `EmbedWorkerHost`: Host-side manager for spawning and communicating with the worker
66+//! - `EmbedWorkerInput`/`EmbedWorkerOutput`: Message types for worker communication
77+//!
88+//! # Usage
99+//!
1010+//! The worker runs off the main thread, fetching and caching AT Protocol embeds.
1111+//! Use `EmbedWorkerHost` on the main thread to spawn and communicate with it:
1212+//!
1313+//! ```ignore
1414+//! use weaver_embed_worker::{EmbedWorkerHost, EmbedWorkerOutput};
1515+//!
1616+//! let host = EmbedWorkerHost::spawn("/embed_worker.js", |output| {
1717+//! if let EmbedWorkerOutput::Embeds { results, .. } = output {
1818+//! // Update UI with fetched embeds
1919+//! }
2020+//! });
2121+//!
2222+//! host.fetch_embeds(vec!["at://did:plc:xxx/app.bsky.feed.post/yyy".into()]);
2323+//! ```
524625use serde::{Deserialize, Serialize};
726use std::collections::HashMap;
···163182164183#[cfg(all(target_family = "wasm", target_os = "unknown"))]
165184pub use worker_impl::EmbedWorker;
185185+186186+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
187187+mod host;
188188+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
189189+pub use host::EmbedWorkerHost;