···1+//! The main MarkdownEditor component.
2+3+use dioxus::prelude::*;
4+5+use crate::components::editor::ReportButton;
6+7+use super::document::{CompositionState, EditorDocument};
8+use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom};
9+use super::formatting;
10+use super::input::{
11+ get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key,
12+};
13+use super::paragraph::ParagraphRender;
14+use super::platform;
15+use super::publish::PublishButton;
16+use super::render;
17+use super::storage;
18+use super::toolbar::EditorToolbar;
19+use super::visibility::update_syntax_visibility;
20+use super::writer::SyntaxSpanInfo;
21+22+/// Main markdown editor component.
23+///
24+/// # Props
25+/// - `initial_content`: Optional initial markdown content
26+///
27+/// # Features
28+/// - Loro CRDT-based text storage with undo/redo support
29+/// - Event interception for full control over editing operations
30+/// - Toolbar formatting buttons
31+/// - LocalStorage auto-save with debouncing
32+/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
33+#[component]
34+pub fn MarkdownEditor(initial_content: Option<String>) -> Element {
35+ // Try to restore from localStorage (includes CRDT state for undo history)
36+ // Use "current" as the default draft key for now
37+ let draft_key = "current";
38+ let mut document = use_signal(move || {
39+ storage::load_from_storage(draft_key)
40+ .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default()))
41+ });
42+ let editor_id = "markdown-editor";
43+44+ // Cache for incremental paragraph rendering
45+ let mut render_cache = use_signal(|| render::RenderCache::default());
46+47+ // Render paragraphs with incremental caching
48+ let paragraphs = use_memo(move || {
49+ let doc = document();
50+ let cache = render_cache.peek();
51+ let edit = doc.last_edit.as_ref();
52+53+ let (paras, new_cache) =
54+ render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit);
55+56+ // Update cache for next render (write-only via spawn to avoid reactive loop)
57+ dioxus::prelude::spawn(async move {
58+ render_cache.set(new_cache);
59+ });
60+61+ paras
62+ });
63+64+ // Flatten offset maps from all paragraphs
65+ let offset_map = use_memo(move || {
66+ paragraphs()
67+ .iter()
68+ .flat_map(|p| p.offset_map.iter().cloned())
69+ .collect::<Vec<_>>()
70+ });
71+72+ // Flatten syntax spans from all paragraphs
73+ let syntax_spans = use_memo(move || {
74+ paragraphs()
75+ .iter()
76+ .flat_map(|p| p.syntax_spans.iter().cloned())
77+ .collect::<Vec<_>>()
78+ });
79+80+ // Cache paragraphs for change detection AND for event handlers to access
81+ let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
82+83+ // Update DOM when paragraphs change (incremental rendering)
84+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
85+ use_effect(move || {
86+ tracing::debug!("DOM update effect triggered");
87+88+ // Read document once to avoid multiple borrows
89+ let doc = document();
90+91+ tracing::debug!(
92+ composition_active = doc.composition.is_some(),
93+ cursor = doc.cursor.offset,
94+ "DOM update: checking state"
95+ );
96+97+ // Skip DOM updates during IME composition - browser controls the preview
98+ if doc.composition.is_some() {
99+ tracing::debug!("skipping DOM update during composition");
100+ return;
101+ }
102+103+ tracing::debug!(
104+ cursor = doc.cursor.offset,
105+ len = doc.len_chars(),
106+ "DOM update proceeding (not in composition)"
107+ );
108+109+ let cursor_offset = doc.cursor.offset;
110+ let selection = doc.selection;
111+ drop(doc); // Release borrow before other operations
112+113+ let new_paras = paragraphs();
114+ let map = offset_map();
115+ let spans = syntax_spans();
116+117+ // Use peek() to avoid creating reactive dependency on cached_paragraphs
118+ let prev = cached_paragraphs.peek().clone();
119+120+ let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset);
121+122+ // Only restore cursor if we actually re-rendered the paragraph it's in
123+ if cursor_para_updated {
124+ use wasm_bindgen::JsCast;
125+ use wasm_bindgen::prelude::*;
126+127+ // Use requestAnimationFrame to wait for browser paint
128+ if let Some(window) = web_sys::window() {
129+ let closure = Closure::once(move || {
130+ if let Err(e) =
131+ super::cursor::restore_cursor_position(cursor_offset, &map, editor_id)
132+ {
133+ tracing::warn!("Cursor restoration failed: {:?}", e);
134+ }
135+ });
136+137+ let _ = window.request_animation_frame(closure.as_ref().unchecked_ref());
138+ closure.forget();
139+ }
140+ }
141+142+ // Store for next comparison AND for event handlers (write-only, no reactive read)
143+ cached_paragraphs.set(new_paras.clone());
144+145+ // Update syntax visibility after DOM changes
146+ update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras);
147+ });
148+149+ // Track last saved frontiers to detect changes (peek-only, no subscriptions)
150+ let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None);
151+152+ // Auto-save with periodic check (no reactive dependency to avoid loops)
153+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
154+ use_effect(move || {
155+ // Check every 500ms if there are unsaved changes
156+ let interval = gloo_timers::callback::Interval::new(500, move || {
157+ // Peek both signals without creating reactive dependencies
158+ let current_frontiers = document.peek().state_frontiers();
159+160+ // Only save if frontiers changed (document was edited)
161+ let needs_save = {
162+ let last_frontiers = last_saved_frontiers.peek();
163+ match &*last_frontiers {
164+ None => true, // First save
165+ Some(last) => ¤t_frontiers != last,
166+ }
167+ }; // drop last_frontiers borrow here
168+169+ if needs_save {
170+ document.with_mut(|doc| {
171+ doc.sync_loro_cursor();
172+ let _ = storage::save_to_storage(doc, draft_key, None);
173+ });
174+175+ // Update last saved frontiers
176+ last_saved_frontiers.set(Some(current_frontiers));
177+ }
178+ });
179+ interval.forget();
180+ });
181+182+ // Set up beforeinput listener for iOS/Android virtual keyboard quirks
183+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
184+ use_effect(move || {
185+ use wasm_bindgen::JsCast;
186+ use wasm_bindgen::prelude::*;
187+188+ let plat = platform::platform();
189+190+ // Only needed on mobile
191+ if !plat.mobile {
192+ return;
193+ }
194+195+ let window = match web_sys::window() {
196+ Some(w) => w,
197+ None => return,
198+ };
199+ let dom_document = match window.document() {
200+ Some(d) => d,
201+ None => return,
202+ };
203+ let editor = match dom_document.get_element_by_id(editor_id) {
204+ Some(e) => e,
205+ None => return,
206+ };
207+208+ let mut document_signal = document;
209+ let cached_paras = cached_paragraphs;
210+211+ let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| {
212+ let input_type = evt.input_type();
213+ tracing::debug!(input_type = %input_type, "beforeinput");
214+215+ let plat = platform::platform();
216+217+ // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak
218+ // without proper keydown events. Handle them here.
219+ if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") {
220+ tracing::debug!("iOS: intercepting {} via beforeinput", input_type);
221+ evt.prevent_default();
222+223+ // Handle as Enter key
224+ document_signal.with_mut(|doc| {
225+ if let Some(sel) = doc.selection.take() {
226+ let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
227+ let _ = doc.remove_tracked(start, end.saturating_sub(start));
228+ doc.cursor.offset = start;
229+ }
230+231+ if input_type == "insertLineBreak" {
232+ // Soft break (like Shift+Enter)
233+ let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}");
234+ doc.cursor.offset += 3;
235+ } else {
236+ // Paragraph break
237+ let _ = doc.insert_tracked(doc.cursor.offset, "\n\n");
238+ doc.cursor.offset += 2;
239+ }
240+ });
241+ }
242+243+ // Android workaround: When swipe keyboard picks a suggestion,
244+ // DOM mutations fire before selection moves. We detect this pattern
245+ // and defer cursor sync.
246+ if plat.android && input_type == "insertText" {
247+ // Check if this might be a suggestion pick (has data that looks like a word)
248+ if let Some(data) = evt.data() {
249+ if data.contains(' ') || data.len() > 3 {
250+ tracing::debug!("Android: possible suggestion pick, deferring cursor sync");
251+ // Defer cursor sync by 20ms to let selection settle
252+ let paras = cached_paras;
253+ let doc_sig = document_signal;
254+ let window = web_sys::window();
255+ if let Some(window) = window {
256+ let closure = Closure::once(move || {
257+ let paras = paras();
258+ sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, ¶s);
259+ });
260+ let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
261+ closure.as_ref().unchecked_ref(),
262+ 20,
263+ );
264+ closure.forget();
265+ }
266+ }
267+ }
268+ }
269+ }) as Box<dyn FnMut(web_sys::InputEvent)>);
270+271+ let _ = editor
272+ .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref());
273+ closure.forget();
274+ });
275+276+ // Local state for adding new tags
277+ let mut new_tag = use_signal(String::new);
278+279+ rsx! {
280+ Stylesheet { href: asset!("/assets/styling/editor.css") }
281+ div { class: "markdown-editor-container",
282+ // Title bar
283+ div { class: "editor-title-bar",
284+ input {
285+ r#type: "text",
286+ class: "title-input",
287+ placeholder: "Entry title...",
288+ value: "{document().title()}",
289+ oninput: move |e| {
290+ document.with_mut(|doc| doc.set_title(&e.value()));
291+ },
292+ }
293+ }
294+295+ // Meta row - path, tags, publish
296+ div { class: "editor-meta-row",
297+ div { class: "meta-path",
298+ label { "Path" }
299+ input {
300+ r#type: "text",
301+ class: "path-input",
302+ placeholder: "url-slug",
303+ value: "{document().path()}",
304+ oninput: move |e| {
305+ document.with_mut(|doc| doc.set_path(&e.value()));
306+ },
307+ }
308+ }
309+310+ div { class: "meta-tags",
311+ label { "Tags" }
312+ div { class: "tags-container",
313+ for tag in document().tags() {
314+ span {
315+ class: "tag-chip",
316+ "{tag}"
317+ button {
318+ class: "tag-remove",
319+ onclick: {
320+ let tag_to_remove = tag.clone();
321+ move |_| {
322+ document.with_mut(|doc| doc.remove_tag(&tag_to_remove));
323+ }
324+ },
325+ "×"
326+ }
327+ }
328+ }
329+ input {
330+ r#type: "text",
331+ class: "tag-input",
332+ placeholder: "Add tag...",
333+ value: "{new_tag}",
334+ oninput: move |e| new_tag.set(e.value()),
335+ onkeydown: move |e| {
336+ use dioxus::prelude::keyboard_types::Key;
337+ if e.key() == Key::Enter && !new_tag().trim().is_empty() {
338+ e.prevent_default();
339+ let tag = new_tag().trim().to_string();
340+ document.with_mut(|doc| doc.add_tag(&tag));
341+ new_tag.set(String::new());
342+ }
343+ },
344+ }
345+ }
346+ }
347+348+ PublishButton {
349+ document: document,
350+ draft_key: draft_key.to_string(),
351+ }
352+ }
353+354+ // Editor content
355+ div { class: "editor-content-wrapper",
356+ div {
357+ id: "{editor_id}",
358+ class: "editor-content",
359+ contenteditable: "true",
360+361+ onkeydown: move |evt| {
362+ use dioxus::prelude::keyboard_types::Key;
363+ use std::time::Duration;
364+365+ let plat = platform::platform();
366+ let mods = evt.modifiers();
367+ let has_modifier = mods.ctrl() || mods.meta() || mods.alt();
368+369+ // During IME composition:
370+ // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.)
371+ // - Allow Escape to cancel composition
372+ // - Block text input (let browser handle composition preview)
373+ if document.peek().composition.is_some() {
374+ if evt.key() == Key::Escape {
375+ tracing::debug!("Escape pressed - cancelling composition");
376+ document.with_mut(|doc| {
377+ doc.composition = None;
378+ });
379+ return;
380+ }
381+382+ // Allow modifier shortcuts through during composition
383+ if !has_modifier {
384+ tracing::debug!(
385+ key = ?evt.key(),
386+ "keydown during composition - delegating to browser"
387+ );
388+ return;
389+ }
390+ // Fall through to handle the shortcut
391+ }
392+393+ // Safari workaround: After Japanese IME composition ends, both
394+ // compositionend and keydown fire for Enter. Ignore keydown
395+ // within 500ms of composition end to prevent double-newline.
396+ if plat.safari && evt.key() == Key::Enter {
397+ if let Some(ended_at) = document.peek().composition_ended_at {
398+ if ended_at.elapsed() < Duration::from_millis(500) {
399+ tracing::debug!(
400+ "Safari: ignoring Enter within 500ms of compositionend"
401+ );
402+ return;
403+ }
404+ }
405+ }
406+407+ // Android workaround: Chrome Android gets confused by Enter during/after
408+ // composition. Defer Enter handling to onkeypress instead.
409+ if plat.android && evt.key() == Key::Enter {
410+ tracing::debug!("Android: deferring Enter to keypress");
411+ return;
412+ }
413+414+ // Only prevent default for operations that modify content
415+ // Let browser handle arrow keys, Home/End naturally
416+ if should_intercept_key(&evt) {
417+ evt.prevent_default();
418+ handle_keydown(evt, &mut document);
419+ }
420+ },
421+422+ onkeyup: move |evt| {
423+ use dioxus::prelude::keyboard_types::Key;
424+425+ // Navigation keys (with or without Shift for selection)
426+ let navigation = matches!(
427+ evt.key(),
428+ Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown |
429+ Key::Home | Key::End | Key::PageUp | Key::PageDown
430+ );
431+432+ // Cmd/Ctrl+A for select all
433+ let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl())
434+ && matches!(evt.key(), Key::Character(ref c) if c == "a");
435+436+ if navigation || select_all {
437+ let paras = cached_paragraphs();
438+ sync_cursor_from_dom(&mut document, editor_id, ¶s);
439+ let doc = document();
440+ let spans = syntax_spans();
441+ update_syntax_visibility(
442+ doc.cursor.offset,
443+ doc.selection.as_ref(),
444+ &spans,
445+ ¶s,
446+ );
447+ }
448+ },
449+450+ onselect: move |_evt| {
451+ tracing::debug!("onselect fired");
452+ let paras = cached_paragraphs();
453+ sync_cursor_from_dom(&mut document, editor_id, ¶s);
454+ let doc = document();
455+ let spans = syntax_spans();
456+ update_syntax_visibility(
457+ doc.cursor.offset,
458+ doc.selection.as_ref(),
459+ &spans,
460+ ¶s,
461+ );
462+ },
463+464+ onselectstart: move |_evt| {
465+ tracing::debug!("onselectstart fired");
466+ let paras = cached_paragraphs();
467+ sync_cursor_from_dom(&mut document, editor_id, ¶s);
468+ let doc = document();
469+ let spans = syntax_spans();
470+ update_syntax_visibility(
471+ doc.cursor.offset,
472+ doc.selection.as_ref(),
473+ &spans,
474+ ¶s,
475+ );
476+ },
477+478+ onselectionchange: move |_evt| {
479+ tracing::debug!("onselectionchange fired");
480+ let paras = cached_paragraphs();
481+ sync_cursor_from_dom(&mut document, editor_id, ¶s);
482+ let doc = document();
483+ let spans = syntax_spans();
484+ update_syntax_visibility(
485+ doc.cursor.offset,
486+ doc.selection.as_ref(),
487+ &spans,
488+ ¶s,
489+ );
490+ },
491+492+ onclick: move |_evt| {
493+ tracing::debug!("onclick fired");
494+ let paras = cached_paragraphs();
495+ sync_cursor_from_dom(&mut document, editor_id, ¶s);
496+ let doc = document();
497+ let spans = syntax_spans();
498+ update_syntax_visibility(
499+ doc.cursor.offset,
500+ doc.selection.as_ref(),
501+ &spans,
502+ ¶s,
503+ );
504+ },
505+506+ // Android workaround: Handle Enter in keypress instead of keydown.
507+ // Chrome Android fires confused composition events on Enter in keydown,
508+ // but keypress fires after composition state settles.
509+ onkeypress: move |evt| {
510+ use dioxus::prelude::keyboard_types::Key;
511+512+ let plat = platform::platform();
513+ if plat.android && evt.key() == Key::Enter {
514+ tracing::debug!("Android: handling Enter in keypress");
515+ evt.prevent_default();
516+ handle_keydown(evt, &mut document);
517+ }
518+ },
519+520+ onpaste: move |evt| {
521+ handle_paste(evt, &mut document);
522+ },
523+524+ oncut: move |evt| {
525+ handle_cut(evt, &mut document);
526+ },
527+528+ oncopy: move |evt| {
529+ handle_copy(evt, &document);
530+ },
531+532+ onblur: move |_| {
533+ // Cancel any in-progress IME composition on focus loss
534+ let had_composition = document.peek().composition.is_some();
535+ if had_composition {
536+ tracing::debug!("onblur: clearing active composition");
537+ }
538+ document.with_mut(|doc| {
539+ doc.composition = None;
540+ });
541+ },
542+543+ oncompositionstart: move |evt: CompositionEvent| {
544+ let data = evt.data().data();
545+ tracing::debug!(
546+ data = %data,
547+ "compositionstart"
548+ );
549+ document.with_mut(|doc| {
550+ // Delete selection if present (composition replaces it)
551+ if let Some(sel) = doc.selection.take() {
552+ let (start, end) =
553+ (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
554+ tracing::debug!(
555+ start,
556+ end,
557+ "compositionstart: deleting selection"
558+ );
559+ let _ = doc.remove_tracked(start, end.saturating_sub(start));
560+ doc.cursor.offset = start;
561+ }
562+563+ tracing::debug!(
564+ cursor = doc.cursor.offset,
565+ "compositionstart: setting composition state"
566+ );
567+ doc.composition = Some(CompositionState {
568+ start_offset: doc.cursor.offset,
569+ text: data,
570+ });
571+ });
572+ },
573+574+ oncompositionupdate: move |evt: CompositionEvent| {
575+ let data = evt.data().data();
576+ tracing::debug!(
577+ data = %data,
578+ "compositionupdate"
579+ );
580+ document.with_mut(|doc| {
581+ if let Some(ref mut comp) = doc.composition {
582+ comp.text = data;
583+ } else {
584+ tracing::debug!("compositionupdate without active composition state");
585+ }
586+ });
587+ },
588+589+ oncompositionend: move |evt: CompositionEvent| {
590+ let final_text = evt.data().data();
591+ tracing::debug!(
592+ data = %final_text,
593+ "compositionend"
594+ );
595+ document.with_mut(|doc| {
596+ // Record when composition ended for Safari timing workaround
597+ doc.composition_ended_at = Some(web_time::Instant::now());
598+599+ if let Some(comp) = doc.composition.take() {
600+ tracing::debug!(
601+ start_offset = comp.start_offset,
602+ final_text = %final_text,
603+ chars = final_text.chars().count(),
604+ "compositionend: inserting text"
605+ );
606+607+ if !final_text.is_empty() {
608+ let mut delete_start = comp.start_offset;
609+ while delete_start > 0 {
610+ match get_char_at(doc.loro_text(), delete_start - 1) {
611+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
612+ _ => break,
613+ }
614+ }
615+616+ let zw_count = doc.cursor.offset - delete_start;
617+ if zw_count > 0 {
618+ // Splice: delete zero-width chars and insert new char in one op
619+ let _ = doc.replace_tracked(delete_start, zw_count, &final_text);
620+ doc.cursor.offset = delete_start + final_text.chars().count();
621+ } else if doc.cursor.offset == doc.len_chars() {
622+ // Fast path: append at end
623+ let _ = doc.push_tracked(&final_text);
624+ doc.cursor.offset = comp.start_offset + final_text.chars().count();
625+ } else {
626+ let _ = doc.insert_tracked(doc.cursor.offset, &final_text);
627+ doc.cursor.offset = comp.start_offset + final_text.chars().count();
628+ }
629+ }
630+ } else {
631+ tracing::debug!("compositionend without active composition state");
632+ }
633+ });
634+ },
635+ }
636+637+ // Debug panel snug below editor
638+ div { class: "editor-debug",
639+ div { "Cursor: {document().cursor.offset}, Chars: {document().len_chars()}" },
640+ ReportButton {
641+ email: "editor-bugs@weaver.sh".to_string(),
642+ editor_id: "markdown-editor".to_string(),
643+ }
644+ }
645+ }
646+647+ // Toolbar in grid column 2, row 3
648+ EditorToolbar {
649+ on_format: move |action| {
650+ document.with_mut(|doc| {
651+ formatting::apply_formatting(doc, action);
652+ });
653+ }
654+ }
655+656+ }
657+ }
658+}
+15-11
crates/weaver-app/src/components/editor/cursor.rs
···7//! 3. Walking text nodes to find the UTF-16 offset within the element
8//! 4. Setting cursor with web_sys Selection API
910-use super::offset_map::{find_mapping_for_char, OffsetMapping};
1112#[cfg(all(target_family = "wasm", target_os = "unknown"))]
13use wasm_bindgen::JsCast;
···36 }
3738 // Bounds check using offset map
39- let max_offset = offset_map.iter().map(|m| m.char_range.end).max().unwrap_or(0);
000040 if char_offset > max_offset {
41- tracing::warn!("cursor offset {} > max mapping offset {}", char_offset, max_offset);
000042 // Don't error, just skip restoration - this can happen during edits
43 return Ok(());
44 }
···70 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
7172 // Set selection using Range API
73- let selection = window
74- .get_selection()?
75- .ok_or("no selection object")?;
76 let range = document.create_range()?;
7778 // Check if this is an element-based position (e.g., after <br />)
···84 let container_element = container.dyn_into::<web_sys::HtmlElement>()?;
85 let offset_in_range = char_offset - mapping.char_range.start;
86 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
87- let (text_node, node_offset) = find_text_node_at_offset(&container_element, target_utf16_offset)?;
088 range.set_start(&text_node, node_offset as u32)?;
89 }
90···114115 // Create tree walker to find text nodes
116 // SHOW_TEXT = 4 (from DOM spec)
117- let walker = document.create_tree_walker_with_what_to_show(
118- container,
119- 4,
120- )?;
121122 let mut accumulated_utf16 = 0;
123 let mut last_node: Option<web_sys::Node> = None;
···7//! 3. Walking text nodes to find the UTF-16 offset within the element
8//! 4. Setting cursor with web_sys Selection API
910+use super::offset_map::{OffsetMapping, find_mapping_for_char};
1112#[cfg(all(target_family = "wasm", target_os = "unknown"))]
13use wasm_bindgen::JsCast;
···36 }
3738 // Bounds check using offset map
39+ let max_offset = offset_map
40+ .iter()
41+ .map(|m| m.char_range.end)
42+ .max()
43+ .unwrap_or(0);
44 if char_offset > max_offset {
45+ tracing::warn!(
46+ "cursor offset {} > max mapping offset {}",
47+ char_offset,
48+ max_offset
49+ );
50 // Don't error, just skip restoration - this can happen during edits
51 return Ok(());
52 }
···78 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
7980 // Set selection using Range API
81+ let selection = window.get_selection()?.ok_or("no selection object")?;
0082 let range = document.create_range()?;
8384 // Check if this is an element-based position (e.g., after <br />)
···90 let container_element = container.dyn_into::<web_sys::HtmlElement>()?;
91 let offset_in_range = char_offset - mapping.char_range.start;
92 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range;
93+ let (text_node, node_offset) =
94+ find_text_node_at_offset(&container_element, target_utf16_offset)?;
95 range.set_start(&text_node, node_offset as u32)?;
96 }
97···121122 // Create tree walker to find text nodes
123 // SHOW_TEXT = 4 (from DOM spec)
124+ let walker = document.create_tree_walker_with_what_to_show(container, 4)?;
000125126 let mut accumulated_utf16 = 0;
127 let mut last_node: Option<web_sys::Node> = None;
···1//! Core data structures for the markdown editor.
2//!
3//! Uses Loro CRDT for text storage with built-in undo/redo support.
045use loro::{
6- ExportMode, LoroDoc, LoroResult, LoroText, UndoManager,
7 cursor::{Cursor, Side},
8};
90000000000000000010/// Single source of truth for editor state.
11///
12/// Contains the document text (backed by Loro CRDT), cursor position,
13-/// selection, and IME composition state.
014#[derive(Debug)]
15pub struct EditorDocument {
16 /// The Loro document containing all editor state.
17- /// Using full LoroDoc (not just LoroText) to support future
18- /// expansion to blobs, metadata, etc.
19 doc: LoroDoc,
2021- /// Handle to the text container within the doc.
22- text: LoroText,
0000000000000000023024 /// Undo manager for the document.
25 undo_mgr: UndoManager,
26···111 return true;
112 }
113114- let content = self.text.to_string();
115 let mut last_newline_pos: Option<usize> = None;
116117- for (i, c) in content.chars().take(pos).enumerate() {
118 if c == '\n' {
119 last_newline_pos = Some(i);
120 }
···129 }
130131 /// Create a new editor document with the given content.
132- pub fn new(content: String) -> Self {
0133 let doc = LoroDoc::new();
134- let text = doc.get_text("content");
0000000135136 // Insert initial content if any
137- if !content.is_empty() {
138- text.insert(0, &content)
0139 .expect("failed to insert initial content");
140 }
141000000142 // Set up undo manager with merge interval for batching keystrokes
143 let mut undo_mgr = UndoManager::new(&doc);
144 undo_mgr.set_merge_interval(300); // 300ms merge window
145 undo_mgr.set_max_undo_steps(100);
146147 // Create initial Loro cursor at position 0
148- let loro_cursor = text.get_cursor(0, Side::default());
149150 Self {
151 doc,
152- text,
00000153 undo_mgr,
154 cursor: CursorState {
155 offset: 0,
···163 }
164 }
165166- /// Get the underlying LoroText for read operations.
000000000000000167 pub fn loro_text(&self) -> &LoroText {
168- &self.text
0000000169 }
170171- /// Convert the document to a string.
172 pub fn to_string(&self) -> String {
173- self.text.to_string()
174 }
175176- /// Get the length of the document in characters.
177 pub fn len_chars(&self) -> usize {
178- self.text.len_unicode()
179 }
180181- /// Get the length of the document in UTF-8 bytes.
182 pub fn len_bytes(&self) -> usize {
183- self.text.len_utf8()
184 }
185186- /// Get the length of the document in UTF-16 code units.
187 pub fn len_utf16(&self) -> usize {
188- self.text.len_utf16()
189 }
190191- /// Check if the document is empty.
192 pub fn is_empty(&self) -> bool {
193- self.text.len_unicode() == 0
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000194 }
195196- /// Insert text and record edit info for incremental rendering.
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000197 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
198 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
199- let len_before = self.text.len_unicode();
200- let result = self.text.insert(pos, text);
201- let len_after = self.text.len_unicode();
202 self.last_edit = Some(EditInfo {
203 edit_char_pos: pos,
204 inserted_len: len_after.saturating_sub(len_before),
···210 result
211 }
212213- /// Push text to end of document. Faster than insert for appending.
214 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
215- let pos = self.text.len_unicode();
216 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
217- let result = self.text.push_str(text);
218- let len_after = self.text.len_unicode();
219 self.last_edit = Some(EditInfo {
220 edit_char_pos: pos,
221 inserted_len: text.chars().count(),
···227 result
228 }
229230- /// Remove text range and record edit info for incremental rendering.
231 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
232- let content = self.text.to_string();
233- let contains_newline = content.chars().skip(start).take(len).any(|c| c == '\n');
234 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
235236- let result = self.text.delete(start, len);
237 self.last_edit = Some(EditInfo {
238 edit_char_pos: start,
239 inserted_len: 0,
240 deleted_len: len,
241 contains_newline,
242 in_block_syntax_zone,
243- doc_len_after: self.text.len_unicode(),
244 });
245 result
246 }
247248- /// Replace text (delete then insert) and record combined edit info.
249 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
250- let content = self.text.to_string();
251- let delete_has_newline = content.chars().skip(start).take(len).any(|c| c == '\n');
252 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
253254- let len_before = self.text.len_unicode();
255 // Use splice for atomic replace
256- self.text.splice(start, len, text)?;
257- let len_after = self.text.len_unicode();
258259 // inserted_len = (len_after - len_before) + deleted_len
260 // because: len_after = len_before - deleted + inserted
···312 self.undo_mgr.can_redo()
313 }
314315- /// Get a slice of the document text.
316 /// Returns None if the range is invalid.
317 pub fn slice(&self, start: usize, end: usize) -> Option<String> {
318- self.text.slice(start, end).ok()
319 }
320321 /// Sync the Loro cursor to the current cursor.offset position.
322 /// Call this after OUR edits where we know the new cursor position.
323 pub fn sync_loro_cursor(&mut self) {
324- self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default());
325 }
326327 /// Update cursor.offset from the Loro cursor's tracked position.
···383 }
384 }
385386- let text = doc.get_text("content");
000000387388 // Set up undo manager - tracks operations from this point forward only
389 let mut undo_mgr = UndoManager::new(&doc);
···391 undo_mgr.set_max_undo_steps(100);
392393 // Try to restore cursor from Loro cursor, fall back to offset
394- let max_offset = text.len_unicode();
395 let cursor_offset = if let Some(ref lc) = loro_cursor {
396 doc.get_cursor_pos(lc)
397 .map(|r| r.current.pos)
···406 };
407408 // If no Loro cursor provided, create one at the restored position
409- let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default()));
0410411 Self {
412 doc,
413- text,
00000414 undo_mgr,
415 cursor,
416 loro_cursor,
···427428impl Clone for EditorDocument {
429 fn clone(&self) -> Self {
430- // Create a new document with the same content
431- let content = self.to_string();
432- let mut new_doc = Self::new(content);
000433 new_doc.cursor = self.cursor;
434- // Recreate Loro cursor at the same position in the new doc
435 new_doc.sync_loro_cursor();
436 new_doc.selection = self.selection;
437 new_doc.composition = self.composition.clone();
···1//! Core data structures for the markdown editor.
2//!
3//! Uses Loro CRDT for text storage with built-in undo/redo support.
4+//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration.
56use loro::{
7+ ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager,
8 cursor::{Cursor, Side},
9};
1011+use jacquard::IntoStatic;
12+use jacquard::from_json_value;
13+use jacquard::types::string::AtUri;
14+use weaver_api::sh_weaver::embed::images::Image;
15+16+/// Helper for working with editor images.
17+/// Constructed from LoroMap data, NOT serialized directly.
18+/// The Image lexicon type stores our `publishedBlobUri` in its `extra_data` field.
19+#[derive(Clone, Debug)]
20+pub struct EditorImage {
21+ /// The lexicon Image type (deserialized via from_json_value)
22+ pub image: Image<'static>,
23+ /// AT-URI of the PublishedBlob record (for cleanup on publish/delete)
24+ /// None for existing images that are already in an entry record.
25+ pub published_blob_uri: Option<AtUri<'static>>,
26+}
27+28/// Single source of truth for editor state.
29///
30/// Contains the document text (backed by Loro CRDT), cursor position,
31+/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
32+/// schema with CRDT containers for each field.
33#[derive(Debug)]
34pub struct EditorDocument {
35 /// The Loro document containing all editor state.
0036 doc: LoroDoc,
3738+ // --- Entry schema containers ---
39+ /// Markdown content (maps to entry.content)
40+ content: LoroText,
41+42+ /// Entry title (maps to entry.title)
43+ title: LoroText,
44+45+ /// URL path/slug (maps to entry.path)
46+ path: LoroText,
47+48+ /// ISO datetime string (maps to entry.createdAt)
49+ created_at: LoroText,
50+51+ /// Tags list (maps to entry.tags)
52+ tags: LoroList,
53+54+ /// Embeds container (maps to entry.embeds)
55+ /// Contains nested containers: images (LoroList), externals (LoroList), etc.
56+ embeds: LoroMap,
5758+ // --- Editor state ---
59 /// Undo manager for the document.
60 undo_mgr: UndoManager,
61···146 return true;
147 }
148149+ let content_str = self.content.to_string();
150 let mut last_newline_pos: Option<usize> = None;
151152+ for (i, c) in content_str.chars().take(pos).enumerate() {
153 if c == '\n' {
154 last_newline_pos = Some(i);
155 }
···164 }
165166 /// Create a new editor document with the given content.
167+ /// Sets `created_at` to current time.
168+ pub fn new(initial_content: String) -> Self {
169 let doc = LoroDoc::new();
170+171+ // Get all containers
172+ let content = doc.get_text("content");
173+ let title = doc.get_text("title");
174+ let path = doc.get_text("path");
175+ let created_at = doc.get_text("created_at");
176+ let tags = doc.get_list("tags");
177+ let embeds = doc.get_map("embeds");
178179 // Insert initial content if any
180+ if !initial_content.is_empty() {
181+ content
182+ .insert(0, &initial_content)
183 .expect("failed to insert initial content");
184 }
185186+ // Set created_at to current time (ISO 8601)
187+ let now = Self::current_datetime_string();
188+ created_at
189+ .insert(0, &now)
190+ .expect("failed to set created_at");
191+192 // Set up undo manager with merge interval for batching keystrokes
193 let mut undo_mgr = UndoManager::new(&doc);
194 undo_mgr.set_merge_interval(300); // 300ms merge window
195 undo_mgr.set_max_undo_steps(100);
196197 // Create initial Loro cursor at position 0
198+ let loro_cursor = content.get_cursor(0, Side::default());
199200 Self {
201 doc,
202+ content,
203+ title,
204+ path,
205+ created_at,
206+ tags,
207+ embeds,
208 undo_mgr,
209 cursor: CursorState {
210 offset: 0,
···218 }
219 }
220221+ /// Generate current datetime as ISO 8601 string.
222+ #[cfg(target_family = "wasm")]
223+ fn current_datetime_string() -> String {
224+ js_sys::Date::new_0()
225+ .to_iso_string()
226+ .as_string()
227+ .unwrap_or_default()
228+ }
229+230+ #[cfg(not(target_family = "wasm"))]
231+ fn current_datetime_string() -> String {
232+ // Fallback for non-wasm (tests, etc.)
233+ chrono::Utc::now().to_rfc3339()
234+ }
235+236+ /// Get the underlying LoroText for read operations on content.
237 pub fn loro_text(&self) -> &LoroText {
238+ &self.content
239+ }
240+241+ // --- Content accessors ---
242+243+ /// Get the markdown content as a string.
244+ pub fn content(&self) -> String {
245+ self.content.to_string()
246 }
247248+ /// Convert the document content to a string (alias for content()).
249 pub fn to_string(&self) -> String {
250+ self.content.to_string()
251 }
252253+ /// Get the length of the content in characters.
254 pub fn len_chars(&self) -> usize {
255+ self.content.len_unicode()
256 }
257258+ /// Get the length of the content in UTF-8 bytes.
259 pub fn len_bytes(&self) -> usize {
260+ self.content.len_utf8()
261 }
262263+ /// Get the length of the content in UTF-16 code units.
264 pub fn len_utf16(&self) -> usize {
265+ self.content.len_utf16()
266 }
267268+ /// Check if the content is empty.
269 pub fn is_empty(&self) -> bool {
270+ self.content.len_unicode() == 0
271+ }
272+273+ // --- Entry metadata accessors ---
274+275+ /// Get the entry title.
276+ pub fn title(&self) -> String {
277+ self.title.to_string()
278+ }
279+280+ /// Set the entry title (replaces existing).
281+ pub fn set_title(&mut self, new_title: &str) {
282+ let current_len = self.title.len_unicode();
283+ if current_len > 0 {
284+ self.title.delete(0, current_len).ok();
285+ }
286+ self.title.insert(0, new_title).ok();
287+ }
288+289+ /// Get the URL path/slug.
290+ pub fn path(&self) -> String {
291+ self.path.to_string()
292+ }
293+294+ /// Set the URL path/slug (replaces existing).
295+ pub fn set_path(&mut self, new_path: &str) {
296+ let current_len = self.path.len_unicode();
297+ if current_len > 0 {
298+ self.path.delete(0, current_len).ok();
299+ }
300+ self.path.insert(0, new_path).ok();
301+ }
302+303+ /// Get the created_at timestamp (ISO 8601 string).
304+ pub fn created_at(&self) -> String {
305+ self.created_at.to_string()
306+ }
307+308+ /// Set the created_at timestamp (usually only called once on creation or when loading).
309+ pub fn set_created_at(&mut self, datetime: &str) {
310+ let current_len = self.created_at.len_unicode();
311+ if current_len > 0 {
312+ self.created_at.delete(0, current_len).ok();
313+ }
314+ self.created_at.insert(0, datetime).ok();
315+ }
316+317+ // --- Tags accessors ---
318+319+ /// Get all tags as a vector of strings.
320+ pub fn tags(&self) -> Vec<String> {
321+ let len = self.tags.len();
322+ (0..len)
323+ .filter_map(|i| match self.tags.get(i)? {
324+ loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()),
325+ _ => None,
326+ })
327+ .collect()
328+ }
329+330+ /// Add a tag (if not already present).
331+ pub fn add_tag(&mut self, tag: &str) {
332+ let existing = self.tags();
333+ if !existing.iter().any(|t| t == tag) {
334+ self.tags.push(LoroValue::String(tag.into())).ok();
335+ }
336+ }
337+338+ /// Remove a tag by value.
339+ pub fn remove_tag(&mut self, tag: &str) {
340+ let len = self.tags.len();
341+ for i in (0..len).rev() {
342+ if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) {
343+ if s.as_str() == tag {
344+ self.tags.delete(i, 1).ok();
345+ break;
346+ }
347+ }
348+ }
349+ }
350+351+ /// Clear all tags.
352+ pub fn clear_tags(&mut self) {
353+ let len = self.tags.len();
354+ if len > 0 {
355+ self.tags.delete(0, len).ok();
356+ }
357+ }
358+359+ // --- Images accessors ---
360+361+ /// Get the images LoroList from embeds, creating it if needed.
362+ fn get_images_list(&self) -> LoroList {
363+ self.embeds
364+ .get_or_create_container("images", LoroList::new())
365+ .unwrap()
366 }
367368+ /// Get all images as a Vec.
369+ pub fn images(&self) -> Vec<EditorImage> {
370+ let images_list = self.get_images_list();
371+ let mut result = Vec::new();
372+373+ for i in 0..images_list.len() {
374+ if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) {
375+ result.push(editor_image);
376+ }
377+ }
378+379+ result
380+ }
381+382+ /// Convert a LoroValue at the given index to an EditorImage.
383+ fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option<EditorImage> {
384+ let value = list.get(index)?;
385+386+ // Extract LoroValue from ValueOrContainer
387+ let loro_value = value.as_value()?;
388+389+ // Convert LoroValue to serde_json::Value
390+ let json = loro_value.to_json_value();
391+392+ // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data
393+ let image: Image<'static> = from_json_value::<Image>(json).ok()?;
394+395+ // Extract our tracking field from extra_data
396+ let published_blob_uri = image
397+ .extra_data
398+ .as_ref()
399+ .and_then(|m| m.get("publishedBlobUri"))
400+ .and_then(|d| d.as_str())
401+ .and_then(|s| AtUri::new(s).ok())
402+ .map(|uri| uri.into_static());
403+404+ Some(EditorImage {
405+ image,
406+ published_blob_uri,
407+ })
408+ }
409+410+ /// Add an image to the embeds.
411+ /// The Image is serialized to JSON with our publishedBlobUri added.
412+ pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) {
413+ // Serialize the Image to serde_json::Value
414+ let mut json = serde_json::to_value(image).expect("Image serializes");
415+416+ // Add our tracking field (not part of lexicon, stored in extra_data on deserialize)
417+ if let Some(uri) = published_blob_uri {
418+ json.as_object_mut()
419+ .unwrap()
420+ .insert("publishedBlobUri".into(), uri.as_str().into());
421+ }
422+423+ // Insert into the images list
424+ let images_list = self.get_images_list();
425+ images_list.push(json).ok();
426+ }
427+428+ /// Remove an image by index.
429+ pub fn remove_image(&mut self, index: usize) {
430+ let images_list = self.get_images_list();
431+ if index < images_list.len() {
432+ images_list.delete(index, 1).ok();
433+ }
434+ }
435+436+ /// Get a single image by index.
437+ pub fn get_image(&self, index: usize) -> Option<EditorImage> {
438+ let images_list = self.get_images_list();
439+ self.loro_value_to_editor_image(&images_list, index)
440+ }
441+442+ /// Get the number of images.
443+ pub fn images_len(&self) -> usize {
444+ self.get_images_list().len()
445+ }
446+447+ /// Update the alt text of an image at the given index.
448+ pub fn update_image_alt(&mut self, index: usize, alt: &str) {
449+ let images_list = self.get_images_list();
450+ if let Some(value) = images_list.get(index) {
451+ if let Some(loro_value) = value.as_value() {
452+ let mut json = loro_value.to_json_value();
453+ if let Some(obj) = json.as_object_mut() {
454+ obj.insert("alt".into(), alt.into());
455+ // Replace the entire value at this index
456+ images_list.delete(index, 1).ok();
457+ images_list.insert(index, json).ok();
458+ }
459+ }
460+ }
461+ }
462+463+ /// Insert text into content and record edit info for incremental rendering.
464 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
465 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
466+ let len_before = self.content.len_unicode();
467+ let result = self.content.insert(pos, text);
468+ let len_after = self.content.len_unicode();
469 self.last_edit = Some(EditInfo {
470 edit_char_pos: pos,
471 inserted_len: len_after.saturating_sub(len_before),
···477 result
478 }
479480+ /// Push text to end of content. Faster than insert for appending.
481 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
482+ let pos = self.content.len_unicode();
483 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
484+ let result = self.content.push_str(text);
485+ let len_after = self.content.len_unicode();
486 self.last_edit = Some(EditInfo {
487 edit_char_pos: pos,
488 inserted_len: text.chars().count(),
···494 result
495 }
496497+ /// Remove text range from content and record edit info for incremental rendering.
498 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
499+ let content_str = self.content.to_string();
500+ let contains_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n');
501 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
502503+ let result = self.content.delete(start, len);
504 self.last_edit = Some(EditInfo {
505 edit_char_pos: start,
506 inserted_len: 0,
507 deleted_len: len,
508 contains_newline,
509 in_block_syntax_zone,
510+ doc_len_after: self.content.len_unicode(),
511 });
512 result
513 }
514515+ /// Replace text in content (delete then insert) and record combined edit info.
516 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
517+ let content_str = self.content.to_string();
518+ let delete_has_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n');
519 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
520521+ let len_before = self.content.len_unicode();
522 // Use splice for atomic replace
523+ self.content.splice(start, len, text)?;
524+ let len_after = self.content.len_unicode();
525526 // inserted_len = (len_after - len_before) + deleted_len
527 // because: len_after = len_before - deleted + inserted
···579 self.undo_mgr.can_redo()
580 }
581582+ /// Get a slice of the content text.
583 /// Returns None if the range is invalid.
584 pub fn slice(&self, start: usize, end: usize) -> Option<String> {
585+ self.content.slice(start, end).ok()
586 }
587588 /// Sync the Loro cursor to the current cursor.offset position.
589 /// Call this after OUR edits where we know the new cursor position.
590 pub fn sync_loro_cursor(&mut self) {
591+ self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default());
592 }
593594 /// Update cursor.offset from the Loro cursor's tracked position.
···650 }
651 }
652653+ // Get all containers (they will contain data from the snapshot if import succeeded)
654+ let content = doc.get_text("content");
655+ let title = doc.get_text("title");
656+ let path = doc.get_text("path");
657+ let created_at = doc.get_text("created_at");
658+ let tags = doc.get_list("tags");
659+ let embeds = doc.get_map("embeds");
660661 // Set up undo manager - tracks operations from this point forward only
662 let mut undo_mgr = UndoManager::new(&doc);
···664 undo_mgr.set_max_undo_steps(100);
665666 // Try to restore cursor from Loro cursor, fall back to offset
667+ let max_offset = content.len_unicode();
668 let cursor_offset = if let Some(ref lc) = loro_cursor {
669 doc.get_cursor_pos(lc)
670 .map(|r| r.current.pos)
···679 };
680681 // If no Loro cursor provided, create one at the restored position
682+ let loro_cursor =
683+ loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default()));
684685 Self {
686 doc,
687+ content,
688+ title,
689+ path,
690+ created_at,
691+ tags,
692+ embeds,
693 undo_mgr,
694 cursor,
695 loro_cursor,
···706707impl Clone for EditorDocument {
708 fn clone(&self) -> Self {
709+ // Use snapshot export/import for a complete clone including all containers
710+ let snapshot = self.export_snapshot();
711+ let mut new_doc =
712+ Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset);
713+714+ // Copy non-CRDT state
715 new_doc.cursor = self.cursor;
0716 new_doc.sync_loro_cursor();
717 new_doc.selection = self.selection;
718 new_doc.composition = self.composition.clone();
···1+//! Entry publishing functionality for the markdown editor.
2+//!
3+//! Handles creating/updating AT Protocol notebook entries from editor state.
4+5+use dioxus::prelude::*;
6+use jacquard::types::string::{AtUri, Datetime};
7+use weaver_api::sh_weaver::embed::images::Images;
8+use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds};
9+use weaver_common::{WeaverError, WeaverExt};
10+11+use crate::auth::AuthState;
12+use crate::fetch::Fetcher;
13+14+use super::document::EditorDocument;
15+use super::storage::delete_draft;
16+17+/// Result of a publish operation.
18+#[derive(Clone, Debug)]
19+pub enum PublishResult {
20+ /// Entry was created (new)
21+ Created(AtUri<'static>),
22+ /// Entry was updated (existing)
23+ Updated(AtUri<'static>),
24+}
25+26+impl PublishResult {
27+ pub fn uri(&self) -> &AtUri<'static> {
28+ match self {
29+ PublishResult::Created(uri) | PublishResult::Updated(uri) => uri,
30+ }
31+ }
32+}
33+34+/// Publish an entry to the AT Protocol.
35+///
36+/// # Arguments
37+/// * `fetcher` - The authenticated fetcher/client
38+/// * `doc` - The editor document containing entry data
39+/// * `notebook_title` - Title of the notebook to publish to
40+/// * `draft_key` - Storage key for the draft (for cleanup)
41+///
42+/// # Returns
43+/// The AT-URI of the created/updated entry, or an error.
44+pub async fn publish_entry(
45+ fetcher: &Fetcher,
46+ doc: &EditorDocument,
47+ notebook_title: &str,
48+ draft_key: &str,
49+) -> Result<PublishResult, WeaverError> {
50+ // Get images from the document
51+ let editor_images = doc.images();
52+53+ // Build embeds if we have images
54+ let entry_embeds = if editor_images.is_empty() {
55+ None
56+ } else {
57+ // Extract Image types from EditorImage wrappers
58+ let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect();
59+60+ Some(EntryEmbeds {
61+ images: Some(Images {
62+ images,
63+ extra_data: None,
64+ }),
65+ ..Default::default()
66+ })
67+ };
68+69+ // Build tags (convert Vec<String> to the expected type)
70+ let tags = {
71+ let tag_strings = doc.tags();
72+ if tag_strings.is_empty() {
73+ None
74+ } else {
75+ Some(tag_strings.into_iter().map(Into::into).collect())
76+ }
77+ };
78+79+ // Determine path - use doc path if set, otherwise slugify title
80+ let path = {
81+ let doc_path = doc.path();
82+ if doc_path.is_empty() {
83+ slugify(&doc.title())
84+ } else {
85+ doc_path
86+ }
87+ };
88+89+ // Build the entry
90+ let entry = Entry::new()
91+ .content(doc.content())
92+ .title(doc.title())
93+ .path(path)
94+ .created_at(Datetime::now())
95+ .maybe_tags(tags)
96+ .maybe_embeds(entry_embeds)
97+ .build();
98+99+ // Publish via upsert_entry
100+ let client = fetcher.get_client();
101+ let (uri, was_created) = client
102+ .upsert_entry(notebook_title, &doc.title(), entry)
103+ .await?;
104+105+ // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive)
106+ // TODO: Implement when image upload is added
107+ // for img in &editor_images {
108+ // if let Some(ref published_uri) = img.published_blob_uri {
109+ // let _ = delete_published_blob(fetcher, published_uri).await;
110+ // }
111+ // }
112+113+ // Clear local draft
114+ delete_draft(draft_key);
115+116+ if was_created {
117+ Ok(PublishResult::Created(uri))
118+ } else {
119+ Ok(PublishResult::Updated(uri))
120+ }
121+}
122+123+/// Simple slug generation from title.
124+fn slugify(title: &str) -> String {
125+ title
126+ .to_lowercase()
127+ .chars()
128+ .map(|c| {
129+ if c.is_ascii_alphanumeric() {
130+ c
131+ } else if c.is_whitespace() || c == '-' || c == '_' {
132+ '-'
133+ } else {
134+ // Skip other characters
135+ '\0'
136+ }
137+ })
138+ .filter(|&c| c != '\0')
139+ .collect::<String>()
140+ // Collapse multiple dashes
141+ .split('-')
142+ .filter(|s| !s.is_empty())
143+ .collect::<Vec<_>>()
144+ .join("-")
145+}
146+147+/// Props for the publish button component.
148+#[derive(Props, Clone, PartialEq)]
149+pub struct PublishButtonProps {
150+ /// The editor document signal
151+ pub document: Signal<EditorDocument>,
152+ /// Storage key for the draft
153+ pub draft_key: String,
154+}
155+156+/// Publish button component with notebook selection.
157+#[component]
158+pub fn PublishButton(props: PublishButtonProps) -> Element {
159+ let fetcher = use_context::<Fetcher>();
160+ let auth_state = use_context::<Signal<AuthState>>();
161+162+ let mut show_dialog = use_signal(|| false);
163+ let mut notebook_title = use_signal(|| String::from("Default"));
164+ let mut is_publishing = use_signal(|| false);
165+ let mut error_message: Signal<Option<String>> = use_signal(|| None);
166+ let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None);
167+168+ let is_authenticated = auth_state.read().is_authenticated();
169+ let doc = props.document;
170+ let draft_key = props.draft_key.clone();
171+172+ // Validate that we have required fields
173+ let can_publish = {
174+ let d = doc();
175+ !d.title().trim().is_empty() && !d.content().trim().is_empty()
176+ };
177+178+ let open_dialog = move |_| {
179+ error_message.set(None);
180+ success_uri.set(None);
181+ show_dialog.set(true);
182+ };
183+184+ let close_dialog = move |_| {
185+ show_dialog.set(false);
186+ };
187+188+ let draft_key_clone = draft_key.clone();
189+ let do_publish = move |_| {
190+ let fetcher = fetcher.clone();
191+ let draft_key = draft_key_clone.clone();
192+ let notebook = notebook_title();
193+194+ spawn(async move {
195+ is_publishing.set(true);
196+ error_message.set(None);
197+198+ // Get document snapshot for publishing
199+ let doc_snapshot = doc();
200+201+ match publish_entry(&fetcher, &doc_snapshot, ¬ebook, &draft_key).await {
202+ Ok(result) => {
203+ success_uri.set(Some(result.uri().clone()));
204+ }
205+ Err(e) => {
206+ error_message.set(Some(format!("{}", e)));
207+ }
208+ }
209+210+ is_publishing.set(false);
211+ });
212+ };
213+214+ rsx! {
215+ button {
216+ class: "publish-button",
217+ disabled: !is_authenticated || !can_publish,
218+ onclick: open_dialog,
219+ title: if !is_authenticated {
220+ "Log in to publish"
221+ } else if !can_publish {
222+ "Title and content required"
223+ } else {
224+ "Publish entry"
225+ },
226+ "Publish"
227+ }
228+229+ if show_dialog() {
230+ div {
231+ class: "publish-dialog-overlay",
232+ onclick: close_dialog,
233+234+ div {
235+ class: "publish-dialog",
236+ onclick: move |e| e.stop_propagation(),
237+238+ h2 { "Publish Entry" }
239+240+ if let Some(uri) = success_uri() {
241+ div { class: "publish-success",
242+ p { "Entry published successfully!" }
243+ a {
244+ href: "{uri}",
245+ target: "_blank",
246+ "View entry →"
247+ }
248+ button {
249+ class: "publish-done",
250+ onclick: close_dialog,
251+ "Done"
252+ }
253+ }
254+ } else {
255+ div { class: "publish-form",
256+ div { class: "publish-field",
257+ label { "Notebook" }
258+ input {
259+ r#type: "text",
260+ class: "publish-input",
261+ placeholder: "Notebook title...",
262+ value: "{notebook_title}",
263+ oninput: move |e| notebook_title.set(e.value()),
264+ }
265+ }
266+267+ div { class: "publish-preview",
268+ p { "Title: {doc().title()}" }
269+ p { "Path: {doc().path()}" }
270+ if !doc().tags().is_empty() {
271+ p { "Tags: {doc().tags().join(\", \")}" }
272+ }
273+ }
274+275+ if let Some(err) = error_message() {
276+ div { class: "publish-error",
277+ "{err}"
278+ }
279+ }
280+281+ div { class: "publish-actions",
282+ button {
283+ class: "publish-cancel",
284+ onclick: close_dialog,
285+ disabled: is_publishing(),
286+ "Cancel"
287+ }
288+ button {
289+ class: "publish-submit",
290+ onclick: do_publish,
291+ disabled: is_publishing() || notebook_title().trim().is_empty(),
292+ if is_publishing() {
293+ "Publishing..."
294+ } else {
295+ "Publish"
296+ }
297+ }
298+ }
299+ }
300+ }
301+ }
302+ }
303+ }
304+ }
305+}
+5-1
crates/weaver-app/src/components/editor/render.rs
···181182 // Compute delta from actual length difference, not edit info
183 // This handles stale edits gracefully (delta = 0 if lengths match)
184- let cached_len = cache.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0);
0000185 let char_delta = current_len as isize - cached_len as isize;
186187 // Adjust each cached paragraph's range
···181182 // Compute delta from actual length difference, not edit info
183 // This handles stale edits gracefully (delta = 0 if lengths match)
184+ let cached_len = cache
185+ .paragraphs
186+ .last()
187+ .map(|p| p.char_range.end)
188+ .unwrap_or(0);
189 let char_delta = current_len as isize - cached_len as isize;
190191 // Adjust each cached paragraph's range
+2-2
crates/weaver-app/src/components/editor/report.rs
···27 .map(|e| e.outer_html())
28 .unwrap_or_default();
2930- let editor_text = load_from_storage()
31- .map(|snapshot| snapshot.to_string())
32 .unwrap_or_default();
3334 let platform_info = {
···27 .map(|e| e.outer_html())
28 .unwrap_or_default();
2930+ let editor_text = load_from_storage("current")
31+ .map(|doc| doc.content())
32 .unwrap_or_default();
3334 let platform_info = {
···2//!
3//! Stores both human-readable content (for debugging) and the full CRDT
4//! snapshot (for undo history preservation across sessions).
000056#[cfg(all(target_family = "wasm", target_os = "unknown"))]
7use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
···1213use super::document::EditorDocument;
1400015/// Editor snapshot for persistence.
16///
17/// Stores both human-readable content and CRDT snapshot for best of both worlds:
18/// - `content`: Human-readable text for debugging
19-/// - `snapshot`: Base64-encoded CRDT state for document history
020/// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position
21/// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored
022///
23/// Note: Undo/redo is session-only (UndoManager state is ephemeral).
24/// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
···26pub struct EditorSnapshot {
27 /// Human-readable document content (for debugging/fallback)
28 pub content: String,
29- /// Base64-encoded CRDT snapshot
00000030 pub snapshot: Option<String>,
031 /// Loro Cursor for stable cursor position tracking
032 pub cursor: Option<Cursor>,
033 /// Fallback cursor offset (used if Loro cursor can't be restored)
034 pub cursor_offset: usize,
000035}
3637-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
38-const STORAGE_KEY: &str = "weaver_editor_draft";
003940/// Save editor state to LocalStorage (WASM only).
0000041#[cfg(all(target_family = "wasm", target_os = "unknown"))]
42-pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors::StorageError> {
000043 let snapshot_bytes = doc.export_snapshot();
44 let snapshot_b64 = if snapshot_bytes.is_empty() {
45 None
···48 };
4950 let snapshot = EditorSnapshot {
51- content: doc.to_string(),
052 snapshot: snapshot_b64,
53 cursor: doc.loro_cursor().cloned(),
54 cursor_offset: doc.cursor.offset,
055 };
56- LocalStorage::set(STORAGE_KEY, &snapshot)
57}
5859/// Load editor state from LocalStorage (WASM only).
060/// Returns an EditorDocument restored from CRDT snapshot if available,
61/// otherwise falls back to just the text content.
00062#[cfg(all(target_family = "wasm", target_os = "unknown"))]
63-pub fn load_from_storage() -> Option<EditorDocument> {
64- let snapshot: EditorSnapshot = LocalStorage::get(STORAGE_KEY).ok()?;
6566 // Try to restore from CRDT snapshot first
67 if let Some(ref snapshot_b64) = snapshot.snapshot {
···72 snapshot.cursor_offset,
73 );
74 // Verify the content matches (sanity check)
75- if doc.to_string() == snapshot.content {
76 return Some(doc);
77 }
78 tracing::warn!("Snapshot content mismatch, falling back to text content");
···86 Some(doc)
87}
8889-/// Clear editor state from LocalStorage (WASM only).
00000000000000000000000000000000000000090#[cfg(all(target_family = "wasm", target_os = "unknown"))]
91#[allow(dead_code)]
92-pub fn clear_storage() {
93- LocalStorage::delete(STORAGE_KEY);
0094}
9596// Stub implementations for non-WASM targets
97#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
98-pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> {
000099 Ok(())
100}
101102#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
103-pub fn load_from_storage() -> Option<EditorDocument> {
104 None
105}
106107#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
00000000108#[allow(dead_code)]
109-pub fn clear_storage() {}
···2//!
3//! Stores both human-readable content (for debugging) and the full CRDT
4//! snapshot (for undo history preservation across sessions).
5+//!
6+//! Storage key strategy:
7+//! - New entries: `"draft:new:{uuid}"`
8+//! - Editing existing: `"draft:{at-uri}"`
910#[cfg(all(target_family = "wasm", target_os = "unknown"))]
11use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
···1617use super::document::EditorDocument;
1819+/// Prefix for all draft storage keys.
20+pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:";
21+22/// Editor snapshot for persistence.
23///
24/// Stores both human-readable content and CRDT snapshot for best of both worlds:
25/// - `content`: Human-readable text for debugging
26+/// - `title`: Entry title for debugging/display in drafts list
27+/// - `snapshot`: Base64-encoded CRDT state for document history (includes all embeds)
28/// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position
29/// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored
30+/// - `editing_uri`: AT-URI if editing an existing entry
31///
32/// Note: Undo/redo is session-only (UndoManager state is ephemeral).
33/// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
···35pub struct EditorSnapshot {
36 /// Human-readable document content (for debugging/fallback)
37 pub content: String,
38+39+ /// Entry title (for debugging/display in drafts list)
40+ #[serde(default)]
41+ pub title: String,
42+43+ /// Base64-encoded CRDT snapshot (contains ALL fields including embeds)
44+ #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub snapshot: Option<String>,
46+47 /// Loro Cursor for stable cursor position tracking
48+ #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub cursor: Option<Cursor>,
50+51 /// Fallback cursor offset (used if Loro cursor can't be restored)
52+ #[serde(default)]
53 pub cursor_offset: usize,
54+55+ /// AT-URI if editing an existing entry (None for new entries)
56+ #[serde(default, skip_serializing_if = "Option::is_none")]
57+ pub editing_uri: Option<String>,
58}
5960+/// Build the full storage key from a draft key.
61+fn storage_key(key: &str) -> String {
62+ format!("{}{}", DRAFT_KEY_PREFIX, key)
63+}
6465/// Save editor state to LocalStorage (WASM only).
66+///
67+/// # Arguments
68+/// * `doc` - The editor document to save
69+/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
70+/// * `editing_uri` - AT-URI if editing an existing entry
71#[cfg(all(target_family = "wasm", target_os = "unknown"))]
72+pub fn save_to_storage(
73+ doc: &EditorDocument,
74+ key: &str,
75+ editing_uri: Option<&str>,
76+) -> Result<(), gloo_storage::errors::StorageError> {
77 let snapshot_bytes = doc.export_snapshot();
78 let snapshot_b64 = if snapshot_bytes.is_empty() {
79 None
···82 };
8384 let snapshot = EditorSnapshot {
85+ content: doc.content(),
86+ title: doc.title(),
87 snapshot: snapshot_b64,
88 cursor: doc.loro_cursor().cloned(),
89 cursor_offset: doc.cursor.offset,
90+ editing_uri: editing_uri.map(String::from),
91 };
92+ LocalStorage::set(storage_key(key), &snapshot)
93}
9495/// Load editor state from LocalStorage (WASM only).
96+///
97/// Returns an EditorDocument restored from CRDT snapshot if available,
98/// otherwise falls back to just the text content.
99+///
100+/// # Arguments
101+/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
102#[cfg(all(target_family = "wasm", target_os = "unknown"))]
103+pub fn load_from_storage(key: &str) -> Option<EditorDocument> {
104+ let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
105106 // Try to restore from CRDT snapshot first
107 if let Some(ref snapshot_b64) = snapshot.snapshot {
···112 snapshot.cursor_offset,
113 );
114 // Verify the content matches (sanity check)
115+ if doc.content() == snapshot.content {
116 return Some(doc);
117 }
118 tracing::warn!("Snapshot content mismatch, falling back to text content");
···126 Some(doc)
127}
128129+/// Delete a draft from LocalStorage (WASM only).
130+///
131+/// # Arguments
132+/// * `key` - Storage key to delete
133+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
134+pub fn delete_draft(key: &str) {
135+ LocalStorage::delete(storage_key(key));
136+}
137+138+/// List all draft keys from LocalStorage (WASM only).
139+///
140+/// Returns a list of (key, title, editing_uri) tuples for all saved drafts.
141+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
142+pub fn list_drafts() -> Vec<(String, String, Option<String>)> {
143+ let mut drafts = Vec::new();
144+145+ // gloo_storage doesn't have a direct way to iterate keys,
146+ // so we use web_sys directly
147+ if let Some(storage) = web_sys::window()
148+ .and_then(|w| w.local_storage().ok())
149+ .flatten()
150+ {
151+ let len = storage.length().unwrap_or(0);
152+ for i in 0..len {
153+ if let Ok(Some(key)) = storage.key(i) {
154+ if key.starts_with(DRAFT_KEY_PREFIX) {
155+ // Try to load just the metadata
156+ if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) {
157+ let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key);
158+ drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri));
159+ }
160+ }
161+ }
162+ }
163+ }
164+165+ drafts
166+}
167+168+/// Clear all editor drafts from LocalStorage (WASM only).
169#[cfg(all(target_family = "wasm", target_os = "unknown"))]
170#[allow(dead_code)]
171+pub fn clear_all_drafts() {
172+ for (key, _, _) in list_drafts() {
173+ delete_draft(&key);
174+ }
175}
176177// Stub implementations for non-WASM targets
178#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
179+pub fn save_to_storage(
180+ _doc: &EditorDocument,
181+ _key: &str,
182+ _editing_uri: Option<&str>,
183+) -> Result<(), String> {
184 Ok(())
185}
186187#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
188+pub fn load_from_storage(_key: &str) -> Option<EditorDocument> {
189 None
190}
191192#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
193+pub fn delete_draft(_key: &str) {}
194+195+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
196+pub fn list_drafts() -> Vec<(String, String, Option<String>)> {
197+ Vec::new()
198+}
199+200+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
201#[allow(dead_code)]
202+pub fn clear_all_drafts() {}
+48-15
crates/weaver-app/src/components/editor/tests.rs
···418 // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2)
419 let result = render_test("# Title\n\n\n\nContent"); // 4 newlines
420 assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace");
421- assert!(result[1].html.contains("gap-"), "Middle element should be a gap");
000422423 // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element)
424 let result2 = render_test("# Title\n\n\nContent"); // 3 newlines
425- assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent");
0000426}
427428// =============================================================================
···630fn test_heading_to_non_heading_transition() {
631 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
632 // This tests that the syntax spans are correctly updated on content change.
633- use loro::LoroDoc;
634 use super::render::render_paragraphs_incremental;
0635636 let doc = LoroDoc::new();
637 let text = doc.get_text("content");
···680 let result = render_test(">");
681 eprintln!("Paragraphs for '>': {:?}", result.len());
682 for (i, p) in result.iter().enumerate() {
683- eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range);
000684 }
685686 // Empty blockquote should still produce at least one paragraph
···759760 // With standard \n\n break, we expect 2 paragraphs (no gap element)
761 // Paragraph ranges include some trailing whitespace from markdown parsing
762- assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break");
0000763764 // First paragraph ends before second starts, with gap for \n\n
765 let gap_start = paragraphs[0].char_range.end;
766 let gap_end = paragraphs[1].char_range.start;
767 let gap_size = gap_end - gap_start;
768- assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size);
0000769}
770771#[test]
···779 let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
780781 // With extra newlines, we expect 3 elements: para, gap, para
782- assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace");
0000783784 // Gap element should exist and cover whitespace zone
785 let gap = ¶graphs[1];
786 assert!(gap.html.contains("gap-"), "Second element should be a gap");
787788 // Gap should cover ALL whitespace (not just extra)
789- assert_eq!(gap.char_range.start, paragraphs[0].char_range.end,
790- "Gap should start where first paragraph ends");
791- assert_eq!(gap.char_range.end, paragraphs[2].char_range.start,
792- "Gap should end where second paragraph starts");
0000793}
794795#[test]
···999 // UTF-16: 0 1 2 3 4 5 6,7 8 9 10
10001001 assert_eq!(char_to_utf16(&text, 0), 0);
1002- assert_eq!(char_to_utf16(&text, 6), 6); // before emoji
1003- assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units)
1004 assert_eq!(char_to_utf16(&text, 10), 11); // end
1005}
1006···1026 if text.len_unicode() == text.len_utf16() {
1027 return char_pos; // fast path
1028 }
1029- text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0)
001030 }
10311032 // All positions should be identity for ASCII
1033 for i in 0..=text.len_unicode() {
1034- assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i);
000001035 }
1036}
···418 // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2)
419 let result = render_test("# Title\n\n\n\nContent"); // 4 newlines
420 assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace");
421+ assert!(
422+ result[1].html.contains("gap-"),
423+ "Middle element should be a gap"
424+ );
425426 // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element)
427 let result2 = render_test("# Title\n\n\nContent"); // 3 newlines
428+ assert_eq!(
429+ result2.len(),
430+ 2,
431+ "Expected 2 elements with standard break equivalent"
432+ );
433}
434435// =============================================================================
···637fn test_heading_to_non_heading_transition() {
638 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
639 // This tests that the syntax spans are correctly updated on content change.
0640 use super::render::render_paragraphs_incremental;
641+ use loro::LoroDoc;
642643 let doc = LoroDoc::new();
644 let text = doc.get_text("content");
···687 let result = render_test(">");
688 eprintln!("Paragraphs for '>': {:?}", result.len());
689 for (i, p) in result.iter().enumerate() {
690+ eprintln!(
691+ " Para {}: html={}, char_range={:?}",
692+ i, p.html, p.char_range
693+ );
694 }
695696 // Empty blockquote should still produce at least one paragraph
···769770 // With standard \n\n break, we expect 2 paragraphs (no gap element)
771 // Paragraph ranges include some trailing whitespace from markdown parsing
772+ assert_eq!(
773+ paragraphs.len(),
774+ 2,
775+ "Expected 2 paragraphs for standard break"
776+ );
777778 // First paragraph ends before second starts, with gap for \n\n
779 let gap_start = paragraphs[0].char_range.end;
780 let gap_end = paragraphs[1].char_range.start;
781 let gap_size = gap_end - gap_start;
782+ assert!(
783+ gap_size <= 2,
784+ "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}",
785+ gap_size
786+ );
787}
788789#[test]
···797 let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
798799 // With extra newlines, we expect 3 elements: para, gap, para
800+ assert_eq!(
801+ paragraphs.len(),
802+ 3,
803+ "Expected 3 elements with extra whitespace"
804+ );
805806 // Gap element should exist and cover whitespace zone
807 let gap = ¶graphs[1];
808 assert!(gap.html.contains("gap-"), "Second element should be a gap");
809810 // Gap should cover ALL whitespace (not just extra)
811+ assert_eq!(
812+ gap.char_range.start, paragraphs[0].char_range.end,
813+ "Gap should start where first paragraph ends"
814+ );
815+ assert_eq!(
816+ gap.char_range.end, paragraphs[2].char_range.start,
817+ "Gap should end where second paragraph starts"
818+ );
819}
820821#[test]
···1025 // UTF-16: 0 1 2 3 4 5 6,7 8 9 10
10261027 assert_eq!(char_to_utf16(&text, 0), 0);
1028+ assert_eq!(char_to_utf16(&text, 6), 6); // before emoji
1029+ assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units)
1030 assert_eq!(char_to_utf16(&text, 10), 11); // end
1031}
1032···1052 if text.len_unicode() == text.len_utf16() {
1053 return char_pos; // fast path
1054 }
1055+ text.slice(0, char_pos)
1056+ .map(|s| s.encode_utf16().count())
1057+ .unwrap_or(0)
1058 }
10591060 // All positions should be identity for ASCII
1061 for i in 0..=text.len_unicode() {
1062+ assert_eq!(
1063+ char_to_utf16(&text, i),
1064+ i,
1065+ "ASCII fast path failed at pos {}",
1066+ i
1067+ );
1068 }
1069}
···188 pos.saturating_add(amount).min(max_pos)
189}
1900000000000000000000000000000000000000000000191#[cfg(test)]
192mod tests {
193 use super::*;
194195- fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo {
00000196 SyntaxSpanInfo {
197 syn_id: syn_id.to_string(),
198 char_range: start..end,
···240241 // Cursor at position 4 (middle of "bold", inside formatted region)
242 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
243- assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region");
244- assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region");
000000245246 // Cursor at position 2 (adjacent to opening **, start of "bold")
247 let vis = VisibilityState::calculate(2, None, &spans, ¶s);
248- assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold");
000249250 // Cursor at position 5 (adjacent to closing **, end of "bold")
251 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
252- assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold");
000253 }
254255 #[test]
···263264 // Cursor at position 4 (middle of "bold", not adjacent to either marker)
265 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
266- assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent");
267- assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent");
000000268 }
269270 #[test]
···278279 // Cursor at position 4 (one before ** which starts at 5)
280 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
281- assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent");
000282283 // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
284 let vis = VisibilityState::calculate(7, None, &spans, ¶s);
285- assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span");
000286 }
287288 #[test]
289 fn test_inline_visibility_cursor_far() {
290- let spans = vec![
291- make_span("s0", 10, 12, SyntaxType::Inline),
292- ];
293 let paras = vec![make_para(0, 33, spans.clone())];
294295 // Cursor at position 0 (far from **)
296 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
297- assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away");
000298 }
299300 #[test]
···310311 // Cursor at position 5 (inside heading)
312 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
313- assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph");
000314 }
315316 #[test]
317 fn test_block_visibility_different_paragraph() {
318- let spans = vec![
319- make_span("s0", 0, 2, SyntaxType::Block),
320- ];
321- let paras = vec![
322- make_para(0, 10, spans.clone()),
323- make_para(12, 30, vec![]),
324- ];
325326 // Cursor at position 20 (in second paragraph)
327 let vis = VisibilityState::calculate(20, None, &spans, ¶s);
328- assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph");
000329 }
330331 #[test]
332 fn test_selection_reveals_syntax() {
333- let spans = vec![
334- make_span("s0", 5, 7, SyntaxType::Inline),
335- ];
336 let paras = vec![make_para(0, 24, spans.clone())];
337338 // Selection overlaps the syntax span
339- let selection = Selection { anchor: 3, head: 10 };
000340 let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
341- assert!(vis.is_visible("s0"), "** should be visible when selection overlaps");
000342 }
343344 #[test]
···351 make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
352 ];
353 let paras = vec![
354- make_para(0, 8, spans.clone()), // "**bold**"
355- make_para(9, 13, vec![]), // "text" (after newline)
356 ];
357358 // Cursor at position 9 (start of second paragraph)
359 // Should NOT reveal the closing ** because para bounds clamp extension
360 let vis = VisibilityState::calculate(9, None, &spans, ¶s);
361- assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph");
000362 }
363364 #[test]
365 fn test_extension_clamps_to_paragraph() {
366 // Syntax at very start of paragraph - extension left should stop at para start
367- let spans = vec![
368- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8),
369- ];
370 let paras = vec![make_para(0, 8, spans.clone())];
371372 // Cursor at position 0 - should still see the opening **
373 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
374- assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0");
000375 }
376}
···188 pos.saturating_add(amount).min(max_pos)
189}
190191+/// Update syntax span visibility in the DOM based on cursor position.
192+///
193+/// Toggles the "hidden" class on syntax spans based on calculated visibility.
194+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
195+pub fn update_syntax_visibility(
196+ cursor_offset: usize,
197+ selection: Option<&Selection>,
198+ syntax_spans: &[SyntaxSpanInfo],
199+ paragraphs: &[ParagraphRender],
200+) {
201+ let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
202+203+ let Some(window) = web_sys::window() else {
204+ return;
205+ };
206+ let Some(document) = window.document() else {
207+ return;
208+ };
209+210+ // Update each syntax span's visibility
211+ for span in syntax_spans {
212+ let selector = format!("[data-syn-id='{}']", span.syn_id);
213+ if let Ok(Some(element)) = document.query_selector(&selector) {
214+ let class_list = element.class_list();
215+ if visibility.is_visible(&span.syn_id) {
216+ let _ = class_list.remove_1("hidden");
217+ } else {
218+ let _ = class_list.add_1("hidden");
219+ }
220+ }
221+ }
222+}
223+224+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
225+pub fn update_syntax_visibility(
226+ _cursor_offset: usize,
227+ _selection: Option<&Selection>,
228+ _syntax_spans: &[SyntaxSpanInfo],
229+ _paragraphs: &[ParagraphRender],
230+) {
231+ // No-op on non-wasm
232+}
233+234#[cfg(test)]
235mod tests {
236 use super::*;
237238+ fn make_span(
239+ syn_id: &str,
240+ start: usize,
241+ end: usize,
242+ syntax_type: SyntaxType,
243+ ) -> SyntaxSpanInfo {
244 SyntaxSpanInfo {
245 syn_id: syn_id.to_string(),
246 char_range: start..end,
···288289 // Cursor at position 4 (middle of "bold", inside formatted region)
290 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
291+ assert!(
292+ vis.is_visible("s0"),
293+ "opening ** should be visible when cursor inside formatted region"
294+ );
295+ assert!(
296+ vis.is_visible("s1"),
297+ "closing ** should be visible when cursor inside formatted region"
298+ );
299300 // Cursor at position 2 (adjacent to opening **, start of "bold")
301 let vis = VisibilityState::calculate(2, None, &spans, ¶s);
302+ assert!(
303+ vis.is_visible("s0"),
304+ "opening ** should be visible when cursor adjacent at start of bold"
305+ );
306307 // Cursor at position 5 (adjacent to closing **, end of "bold")
308 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
309+ assert!(
310+ vis.is_visible("s1"),
311+ "closing ** should be visible when cursor adjacent at end of bold"
312+ );
313 }
314315 #[test]
···323324 // Cursor at position 4 (middle of "bold", not adjacent to either marker)
325 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
326+ assert!(
327+ !vis.is_visible("s0"),
328+ "opening ** should be hidden when no formatted_range and cursor not adjacent"
329+ );
330+ assert!(
331+ !vis.is_visible("s1"),
332+ "closing ** should be hidden when no formatted_range and cursor not adjacent"
333+ );
334 }
335336 #[test]
···344345 // Cursor at position 4 (one before ** which starts at 5)
346 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
347+ assert!(
348+ vis.is_visible("s0"),
349+ "** should be visible when cursor adjacent"
350+ );
351352 // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
353 let vis = VisibilityState::calculate(7, None, &spans, ¶s);
354+ assert!(
355+ vis.is_visible("s0"),
356+ "** should be visible when cursor adjacent after span"
357+ );
358 }
359360 #[test]
361 fn test_inline_visibility_cursor_far() {
362+ let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
00363 let paras = vec![make_para(0, 33, spans.clone())];
364365 // Cursor at position 0 (far from **)
366 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
367+ assert!(
368+ !vis.is_visible("s0"),
369+ "** should be hidden when cursor far away"
370+ );
371 }
372373 #[test]
···383384 // Cursor at position 5 (inside heading)
385 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
386+ assert!(
387+ vis.is_visible("s0"),
388+ "# should be visible when cursor in same paragraph"
389+ );
390 }
391392 #[test]
393 fn test_block_visibility_different_paragraph() {
394+ let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
395+ let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
00000396397 // Cursor at position 20 (in second paragraph)
398 let vis = VisibilityState::calculate(20, None, &spans, ¶s);
399+ assert!(
400+ !vis.is_visible("s0"),
401+ "# should be hidden when cursor in different paragraph"
402+ );
403 }
404405 #[test]
406 fn test_selection_reveals_syntax() {
407+ let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
00408 let paras = vec![make_para(0, 24, spans.clone())];
409410 // Selection overlaps the syntax span
411+ let selection = Selection {
412+ anchor: 3,
413+ head: 10,
414+ };
415 let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
416+ assert!(
417+ vis.is_visible("s0"),
418+ "** should be visible when selection overlaps"
419+ );
420 }
421422 #[test]
···429 make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
430 ];
431 let paras = vec![
432+ make_para(0, 8, spans.clone()), // "**bold**"
433+ make_para(9, 13, vec![]), // "text" (after newline)
434 ];
435436 // Cursor at position 9 (start of second paragraph)
437 // Should NOT reveal the closing ** because para bounds clamp extension
438 let vis = VisibilityState::calculate(9, None, &spans, ¶s);
439+ assert!(
440+ !vis.is_visible("s1"),
441+ "closing ** should NOT be visible when cursor is in next paragraph"
442+ );
443 }
444445 #[test]
446 fn test_extension_clamps_to_paragraph() {
447 // Syntax at very start of paragraph - extension left should stop at para start
448+ let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
00449 let paras = vec![make_para(0, 8, spans.clone())];
450451 // Cursor at position 0 - should still see the opening **
452 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
453+ assert!(
454+ vis.is_visible("s0"),
455+ "** at start should be visible when cursor at position 0"
456+ );
457 }
458}
···127pub mod accordion;
128pub mod button;
129pub mod dialog;
130-pub mod input;
131pub mod editor;
0
···127pub mod accordion;
128pub mod button;
129pub mod dialog;
0130pub mod editor;
131+pub mod input;
+1-1
crates/weaver-app/src/main.rs
···106 {
107 use tracing::Level;
108 use tracing::subscriber::set_global_default;
109- use tracing_subscriber::layer::SubscriberExt;
110 use tracing_subscriber::Registry;
0111112 let console_level = if cfg!(debug_assertions) {
113 Level::DEBUG
···106 {
107 use tracing::Level;
108 use tracing::subscriber::set_global_default;
0109 use tracing_subscriber::Registry;
110+ use tracing_subscriber::layer::SubscriberExt;
111112 let console_level = if cfg!(debug_assertions) {
113 Level::DEBUG