···11//! Core data structures for the markdown editor.
22//!
33//! Uses Loro CRDT for text storage with built-in undo/redo support.
44+//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration.
4556use loro::{
66- ExportMode, LoroDoc, LoroResult, LoroText, UndoManager,
77+ ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager,
78 cursor::{Cursor, Side},
89};
9101111+use jacquard::IntoStatic;
1212+use jacquard::from_json_value;
1313+use jacquard::types::string::AtUri;
1414+use weaver_api::sh_weaver::embed::images::Image;
1515+1616+/// Helper for working with editor images.
1717+/// Constructed from LoroMap data, NOT serialized directly.
1818+/// The Image lexicon type stores our `publishedBlobUri` in its `extra_data` field.
1919+#[derive(Clone, Debug)]
2020+pub struct EditorImage {
2121+ /// The lexicon Image type (deserialized via from_json_value)
2222+ pub image: Image<'static>,
2323+ /// AT-URI of the PublishedBlob record (for cleanup on publish/delete)
2424+ /// None for existing images that are already in an entry record.
2525+ pub published_blob_uri: Option<AtUri<'static>>,
2626+}
2727+1028/// Single source of truth for editor state.
1129///
1230/// Contains the document text (backed by Loro CRDT), cursor position,
1313-/// selection, and IME composition state.
3131+/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
3232+/// schema with CRDT containers for each field.
1433#[derive(Debug)]
1534pub struct EditorDocument {
1635 /// The Loro document containing all editor state.
1717- /// Using full LoroDoc (not just LoroText) to support future
1818- /// expansion to blobs, metadata, etc.
1936 doc: LoroDoc,
20372121- /// Handle to the text container within the doc.
2222- text: LoroText,
3838+ // --- Entry schema containers ---
3939+ /// Markdown content (maps to entry.content)
4040+ content: LoroText,
4141+4242+ /// Entry title (maps to entry.title)
4343+ title: LoroText,
4444+4545+ /// URL path/slug (maps to entry.path)
4646+ path: LoroText,
4747+4848+ /// ISO datetime string (maps to entry.createdAt)
4949+ created_at: LoroText,
5050+5151+ /// Tags list (maps to entry.tags)
5252+ tags: LoroList,
5353+5454+ /// Embeds container (maps to entry.embeds)
5555+ /// Contains nested containers: images (LoroList), externals (LoroList), etc.
5656+ embeds: LoroMap,
23575858+ // --- Editor state ---
2459 /// Undo manager for the document.
2560 undo_mgr: UndoManager,
2661···111146 return true;
112147 }
113148114114- let content = self.text.to_string();
149149+ let content_str = self.content.to_string();
115150 let mut last_newline_pos: Option<usize> = None;
116151117117- for (i, c) in content.chars().take(pos).enumerate() {
152152+ for (i, c) in content_str.chars().take(pos).enumerate() {
118153 if c == '\n' {
119154 last_newline_pos = Some(i);
120155 }
···129164 }
130165131166 /// Create a new editor document with the given content.
132132- pub fn new(content: String) -> Self {
167167+ /// Sets `created_at` to current time.
168168+ pub fn new(initial_content: String) -> Self {
133169 let doc = LoroDoc::new();
134134- let text = doc.get_text("content");
170170+171171+ // Get all containers
172172+ let content = doc.get_text("content");
173173+ let title = doc.get_text("title");
174174+ let path = doc.get_text("path");
175175+ let created_at = doc.get_text("created_at");
176176+ let tags = doc.get_list("tags");
177177+ let embeds = doc.get_map("embeds");
135178136179 // Insert initial content if any
137137- if !content.is_empty() {
138138- text.insert(0, &content)
180180+ if !initial_content.is_empty() {
181181+ content
182182+ .insert(0, &initial_content)
139183 .expect("failed to insert initial content");
140184 }
141185186186+ // Set created_at to current time (ISO 8601)
187187+ let now = Self::current_datetime_string();
188188+ created_at
189189+ .insert(0, &now)
190190+ .expect("failed to set created_at");
191191+142192 // Set up undo manager with merge interval for batching keystrokes
143193 let mut undo_mgr = UndoManager::new(&doc);
144194 undo_mgr.set_merge_interval(300); // 300ms merge window
145195 undo_mgr.set_max_undo_steps(100);
146196147197 // Create initial Loro cursor at position 0
148148- let loro_cursor = text.get_cursor(0, Side::default());
198198+ let loro_cursor = content.get_cursor(0, Side::default());
149199150200 Self {
151201 doc,
152152- text,
202202+ content,
203203+ title,
204204+ path,
205205+ created_at,
206206+ tags,
207207+ embeds,
153208 undo_mgr,
154209 cursor: CursorState {
155210 offset: 0,
···163218 }
164219 }
165220166166- /// Get the underlying LoroText for read operations.
221221+ /// Generate current datetime as ISO 8601 string.
222222+ #[cfg(target_family = "wasm")]
223223+ fn current_datetime_string() -> String {
224224+ js_sys::Date::new_0()
225225+ .to_iso_string()
226226+ .as_string()
227227+ .unwrap_or_default()
228228+ }
229229+230230+ #[cfg(not(target_family = "wasm"))]
231231+ fn current_datetime_string() -> String {
232232+ // Fallback for non-wasm (tests, etc.)
233233+ chrono::Utc::now().to_rfc3339()
234234+ }
235235+236236+ /// Get the underlying LoroText for read operations on content.
167237 pub fn loro_text(&self) -> &LoroText {
168168- &self.text
238238+ &self.content
239239+ }
240240+241241+ // --- Content accessors ---
242242+243243+ /// Get the markdown content as a string.
244244+ pub fn content(&self) -> String {
245245+ self.content.to_string()
169246 }
170247171171- /// Convert the document to a string.
248248+ /// Convert the document content to a string (alias for content()).
172249 pub fn to_string(&self) -> String {
173173- self.text.to_string()
250250+ self.content.to_string()
174251 }
175252176176- /// Get the length of the document in characters.
253253+ /// Get the length of the content in characters.
177254 pub fn len_chars(&self) -> usize {
178178- self.text.len_unicode()
255255+ self.content.len_unicode()
179256 }
180257181181- /// Get the length of the document in UTF-8 bytes.
258258+ /// Get the length of the content in UTF-8 bytes.
182259 pub fn len_bytes(&self) -> usize {
183183- self.text.len_utf8()
260260+ self.content.len_utf8()
184261 }
185262186186- /// Get the length of the document in UTF-16 code units.
263263+ /// Get the length of the content in UTF-16 code units.
187264 pub fn len_utf16(&self) -> usize {
188188- self.text.len_utf16()
265265+ self.content.len_utf16()
189266 }
190267191191- /// Check if the document is empty.
268268+ /// Check if the content is empty.
192269 pub fn is_empty(&self) -> bool {
193193- self.text.len_unicode() == 0
270270+ self.content.len_unicode() == 0
271271+ }
272272+273273+ // --- Entry metadata accessors ---
274274+275275+ /// Get the entry title.
276276+ pub fn title(&self) -> String {
277277+ self.title.to_string()
278278+ }
279279+280280+ /// Set the entry title (replaces existing).
281281+ pub fn set_title(&mut self, new_title: &str) {
282282+ let current_len = self.title.len_unicode();
283283+ if current_len > 0 {
284284+ self.title.delete(0, current_len).ok();
285285+ }
286286+ self.title.insert(0, new_title).ok();
287287+ }
288288+289289+ /// Get the URL path/slug.
290290+ pub fn path(&self) -> String {
291291+ self.path.to_string()
292292+ }
293293+294294+ /// Set the URL path/slug (replaces existing).
295295+ pub fn set_path(&mut self, new_path: &str) {
296296+ let current_len = self.path.len_unicode();
297297+ if current_len > 0 {
298298+ self.path.delete(0, current_len).ok();
299299+ }
300300+ self.path.insert(0, new_path).ok();
301301+ }
302302+303303+ /// Get the created_at timestamp (ISO 8601 string).
304304+ pub fn created_at(&self) -> String {
305305+ self.created_at.to_string()
306306+ }
307307+308308+ /// Set the created_at timestamp (usually only called once on creation or when loading).
309309+ pub fn set_created_at(&mut self, datetime: &str) {
310310+ let current_len = self.created_at.len_unicode();
311311+ if current_len > 0 {
312312+ self.created_at.delete(0, current_len).ok();
313313+ }
314314+ self.created_at.insert(0, datetime).ok();
315315+ }
316316+317317+ // --- Tags accessors ---
318318+319319+ /// Get all tags as a vector of strings.
320320+ pub fn tags(&self) -> Vec<String> {
321321+ let len = self.tags.len();
322322+ (0..len)
323323+ .filter_map(|i| match self.tags.get(i)? {
324324+ loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()),
325325+ _ => None,
326326+ })
327327+ .collect()
328328+ }
329329+330330+ /// Add a tag (if not already present).
331331+ pub fn add_tag(&mut self, tag: &str) {
332332+ let existing = self.tags();
333333+ if !existing.iter().any(|t| t == tag) {
334334+ self.tags.push(LoroValue::String(tag.into())).ok();
335335+ }
336336+ }
337337+338338+ /// Remove a tag by value.
339339+ pub fn remove_tag(&mut self, tag: &str) {
340340+ let len = self.tags.len();
341341+ for i in (0..len).rev() {
342342+ if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) {
343343+ if s.as_str() == tag {
344344+ self.tags.delete(i, 1).ok();
345345+ break;
346346+ }
347347+ }
348348+ }
349349+ }
350350+351351+ /// Clear all tags.
352352+ pub fn clear_tags(&mut self) {
353353+ let len = self.tags.len();
354354+ if len > 0 {
355355+ self.tags.delete(0, len).ok();
356356+ }
357357+ }
358358+359359+ // --- Images accessors ---
360360+361361+ /// Get the images LoroList from embeds, creating it if needed.
362362+ fn get_images_list(&self) -> LoroList {
363363+ self.embeds
364364+ .get_or_create_container("images", LoroList::new())
365365+ .unwrap()
194366 }
195367196196- /// Insert text and record edit info for incremental rendering.
368368+ /// Get all images as a Vec.
369369+ pub fn images(&self) -> Vec<EditorImage> {
370370+ let images_list = self.get_images_list();
371371+ let mut result = Vec::new();
372372+373373+ for i in 0..images_list.len() {
374374+ if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) {
375375+ result.push(editor_image);
376376+ }
377377+ }
378378+379379+ result
380380+ }
381381+382382+ /// Convert a LoroValue at the given index to an EditorImage.
383383+ fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option<EditorImage> {
384384+ let value = list.get(index)?;
385385+386386+ // Extract LoroValue from ValueOrContainer
387387+ let loro_value = value.as_value()?;
388388+389389+ // Convert LoroValue to serde_json::Value
390390+ let json = loro_value.to_json_value();
391391+392392+ // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data
393393+ let image: Image<'static> = from_json_value::<Image>(json).ok()?;
394394+395395+ // Extract our tracking field from extra_data
396396+ let published_blob_uri = image
397397+ .extra_data
398398+ .as_ref()
399399+ .and_then(|m| m.get("publishedBlobUri"))
400400+ .and_then(|d| d.as_str())
401401+ .and_then(|s| AtUri::new(s).ok())
402402+ .map(|uri| uri.into_static());
403403+404404+ Some(EditorImage {
405405+ image,
406406+ published_blob_uri,
407407+ })
408408+ }
409409+410410+ /// Add an image to the embeds.
411411+ /// The Image is serialized to JSON with our publishedBlobUri added.
412412+ pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) {
413413+ // Serialize the Image to serde_json::Value
414414+ let mut json = serde_json::to_value(image).expect("Image serializes");
415415+416416+ // Add our tracking field (not part of lexicon, stored in extra_data on deserialize)
417417+ if let Some(uri) = published_blob_uri {
418418+ json.as_object_mut()
419419+ .unwrap()
420420+ .insert("publishedBlobUri".into(), uri.as_str().into());
421421+ }
422422+423423+ // Insert into the images list
424424+ let images_list = self.get_images_list();
425425+ images_list.push(json).ok();
426426+ }
427427+428428+ /// Remove an image by index.
429429+ pub fn remove_image(&mut self, index: usize) {
430430+ let images_list = self.get_images_list();
431431+ if index < images_list.len() {
432432+ images_list.delete(index, 1).ok();
433433+ }
434434+ }
435435+436436+ /// Get a single image by index.
437437+ pub fn get_image(&self, index: usize) -> Option<EditorImage> {
438438+ let images_list = self.get_images_list();
439439+ self.loro_value_to_editor_image(&images_list, index)
440440+ }
441441+442442+ /// Get the number of images.
443443+ pub fn images_len(&self) -> usize {
444444+ self.get_images_list().len()
445445+ }
446446+447447+ /// Update the alt text of an image at the given index.
448448+ pub fn update_image_alt(&mut self, index: usize, alt: &str) {
449449+ let images_list = self.get_images_list();
450450+ if let Some(value) = images_list.get(index) {
451451+ if let Some(loro_value) = value.as_value() {
452452+ let mut json = loro_value.to_json_value();
453453+ if let Some(obj) = json.as_object_mut() {
454454+ obj.insert("alt".into(), alt.into());
455455+ // Replace the entire value at this index
456456+ images_list.delete(index, 1).ok();
457457+ images_list.insert(index, json).ok();
458458+ }
459459+ }
460460+ }
461461+ }
462462+463463+ /// Insert text into content and record edit info for incremental rendering.
197464 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
198465 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
199199- let len_before = self.text.len_unicode();
200200- let result = self.text.insert(pos, text);
201201- let len_after = self.text.len_unicode();
466466+ let len_before = self.content.len_unicode();
467467+ let result = self.content.insert(pos, text);
468468+ let len_after = self.content.len_unicode();
202469 self.last_edit = Some(EditInfo {
203470 edit_char_pos: pos,
204471 inserted_len: len_after.saturating_sub(len_before),
···210477 result
211478 }
212479213213- /// Push text to end of document. Faster than insert for appending.
480480+ /// Push text to end of content. Faster than insert for appending.
214481 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
215215- let pos = self.text.len_unicode();
482482+ let pos = self.content.len_unicode();
216483 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
217217- let result = self.text.push_str(text);
218218- let len_after = self.text.len_unicode();
484484+ let result = self.content.push_str(text);
485485+ let len_after = self.content.len_unicode();
219486 self.last_edit = Some(EditInfo {
220487 edit_char_pos: pos,
221488 inserted_len: text.chars().count(),
···227494 result
228495 }
229496230230- /// Remove text range and record edit info for incremental rendering.
497497+ /// Remove text range from content and record edit info for incremental rendering.
231498 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
232232- let content = self.text.to_string();
233233- let contains_newline = content.chars().skip(start).take(len).any(|c| c == '\n');
499499+ let content_str = self.content.to_string();
500500+ let contains_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n');
234501 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
235502236236- let result = self.text.delete(start, len);
503503+ let result = self.content.delete(start, len);
237504 self.last_edit = Some(EditInfo {
238505 edit_char_pos: start,
239506 inserted_len: 0,
240507 deleted_len: len,
241508 contains_newline,
242509 in_block_syntax_zone,
243243- doc_len_after: self.text.len_unicode(),
510510+ doc_len_after: self.content.len_unicode(),
244511 });
245512 result
246513 }
247514248248- /// Replace text (delete then insert) and record combined edit info.
515515+ /// Replace text in content (delete then insert) and record combined edit info.
249516 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
250250- let content = self.text.to_string();
251251- let delete_has_newline = content.chars().skip(start).take(len).any(|c| c == '\n');
517517+ let content_str = self.content.to_string();
518518+ let delete_has_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n');
252519 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
253520254254- let len_before = self.text.len_unicode();
521521+ let len_before = self.content.len_unicode();
255522 // Use splice for atomic replace
256256- self.text.splice(start, len, text)?;
257257- let len_after = self.text.len_unicode();
523523+ self.content.splice(start, len, text)?;
524524+ let len_after = self.content.len_unicode();
258525259526 // inserted_len = (len_after - len_before) + deleted_len
260527 // because: len_after = len_before - deleted + inserted
···312579 self.undo_mgr.can_redo()
313580 }
314581315315- /// Get a slice of the document text.
582582+ /// Get a slice of the content text.
316583 /// Returns None if the range is invalid.
317584 pub fn slice(&self, start: usize, end: usize) -> Option<String> {
318318- self.text.slice(start, end).ok()
585585+ self.content.slice(start, end).ok()
319586 }
320587321588 /// Sync the Loro cursor to the current cursor.offset position.
322589 /// Call this after OUR edits where we know the new cursor position.
323590 pub fn sync_loro_cursor(&mut self) {
324324- self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default());
591591+ self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default());
325592 }
326593327594 /// Update cursor.offset from the Loro cursor's tracked position.
···383650 }
384651 }
385652386386- let text = doc.get_text("content");
653653+ // Get all containers (they will contain data from the snapshot if import succeeded)
654654+ let content = doc.get_text("content");
655655+ let title = doc.get_text("title");
656656+ let path = doc.get_text("path");
657657+ let created_at = doc.get_text("created_at");
658658+ let tags = doc.get_list("tags");
659659+ let embeds = doc.get_map("embeds");
387660388661 // Set up undo manager - tracks operations from this point forward only
389662 let mut undo_mgr = UndoManager::new(&doc);
···391664 undo_mgr.set_max_undo_steps(100);
392665393666 // Try to restore cursor from Loro cursor, fall back to offset
394394- let max_offset = text.len_unicode();
667667+ let max_offset = content.len_unicode();
395668 let cursor_offset = if let Some(ref lc) = loro_cursor {
396669 doc.get_cursor_pos(lc)
397670 .map(|r| r.current.pos)
···406679 };
407680408681 // If no Loro cursor provided, create one at the restored position
409409- let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default()));
682682+ let loro_cursor =
683683+ loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default()));
410684411685 Self {
412686 doc,
413413- text,
687687+ content,
688688+ title,
689689+ path,
690690+ created_at,
691691+ tags,
692692+ embeds,
414693 undo_mgr,
415694 cursor,
416695 loro_cursor,
···427706428707impl Clone for EditorDocument {
429708 fn clone(&self) -> Self {
430430- // Create a new document with the same content
431431- let content = self.to_string();
432432- let mut new_doc = Self::new(content);
709709+ // Use snapshot export/import for a complete clone including all containers
710710+ let snapshot = self.export_snapshot();
711711+ let mut new_doc =
712712+ Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset);
713713+714714+ // Copy non-CRDT state
433715 new_doc.cursor = self.cursor;
434434- // Recreate Loro cursor at the same position in the new doc
435716 new_doc.sync_loro_cursor();
436717 new_doc.selection = self.selection;
437718 new_doc.composition = self.composition.clone();
···11+//! Entry publishing functionality for the markdown editor.
22+//!
33+//! Handles creating/updating AT Protocol notebook entries from editor state.
44+55+use dioxus::prelude::*;
66+use jacquard::types::string::{AtUri, Datetime};
77+use weaver_api::sh_weaver::embed::images::Images;
88+use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds};
99+use weaver_common::{WeaverError, WeaverExt};
1010+1111+use crate::auth::AuthState;
1212+use crate::fetch::Fetcher;
1313+1414+use super::document::EditorDocument;
1515+use super::storage::delete_draft;
1616+1717+/// Result of a publish operation.
1818+#[derive(Clone, Debug)]
1919+pub enum PublishResult {
2020+ /// Entry was created (new)
2121+ Created(AtUri<'static>),
2222+ /// Entry was updated (existing)
2323+ Updated(AtUri<'static>),
2424+}
2525+2626+impl PublishResult {
2727+ pub fn uri(&self) -> &AtUri<'static> {
2828+ match self {
2929+ PublishResult::Created(uri) | PublishResult::Updated(uri) => uri,
3030+ }
3131+ }
3232+}
3333+3434+/// Publish an entry to the AT Protocol.
3535+///
3636+/// # Arguments
3737+/// * `fetcher` - The authenticated fetcher/client
3838+/// * `doc` - The editor document containing entry data
3939+/// * `notebook_title` - Title of the notebook to publish to
4040+/// * `draft_key` - Storage key for the draft (for cleanup)
4141+///
4242+/// # Returns
4343+/// The AT-URI of the created/updated entry, or an error.
4444+pub async fn publish_entry(
4545+ fetcher: &Fetcher,
4646+ doc: &EditorDocument,
4747+ notebook_title: &str,
4848+ draft_key: &str,
4949+) -> Result<PublishResult, WeaverError> {
5050+ // Get images from the document
5151+ let editor_images = doc.images();
5252+5353+ // Build embeds if we have images
5454+ let entry_embeds = if editor_images.is_empty() {
5555+ None
5656+ } else {
5757+ // Extract Image types from EditorImage wrappers
5858+ let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect();
5959+6060+ Some(EntryEmbeds {
6161+ images: Some(Images {
6262+ images,
6363+ extra_data: None,
6464+ }),
6565+ ..Default::default()
6666+ })
6767+ };
6868+6969+ // Build tags (convert Vec<String> to the expected type)
7070+ let tags = {
7171+ let tag_strings = doc.tags();
7272+ if tag_strings.is_empty() {
7373+ None
7474+ } else {
7575+ Some(tag_strings.into_iter().map(Into::into).collect())
7676+ }
7777+ };
7878+7979+ // Determine path - use doc path if set, otherwise slugify title
8080+ let path = {
8181+ let doc_path = doc.path();
8282+ if doc_path.is_empty() {
8383+ slugify(&doc.title())
8484+ } else {
8585+ doc_path
8686+ }
8787+ };
8888+8989+ // Build the entry
9090+ let entry = Entry::new()
9191+ .content(doc.content())
9292+ .title(doc.title())
9393+ .path(path)
9494+ .created_at(Datetime::now())
9595+ .maybe_tags(tags)
9696+ .maybe_embeds(entry_embeds)
9797+ .build();
9898+9999+ // Publish via upsert_entry
100100+ let client = fetcher.get_client();
101101+ let (uri, was_created) = client
102102+ .upsert_entry(notebook_title, &doc.title(), entry)
103103+ .await?;
104104+105105+ // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive)
106106+ // TODO: Implement when image upload is added
107107+ // for img in &editor_images {
108108+ // if let Some(ref published_uri) = img.published_blob_uri {
109109+ // let _ = delete_published_blob(fetcher, published_uri).await;
110110+ // }
111111+ // }
112112+113113+ // Clear local draft
114114+ delete_draft(draft_key);
115115+116116+ if was_created {
117117+ Ok(PublishResult::Created(uri))
118118+ } else {
119119+ Ok(PublishResult::Updated(uri))
120120+ }
121121+}
122122+123123+/// Simple slug generation from title.
124124+fn slugify(title: &str) -> String {
125125+ title
126126+ .to_lowercase()
127127+ .chars()
128128+ .map(|c| {
129129+ if c.is_ascii_alphanumeric() {
130130+ c
131131+ } else if c.is_whitespace() || c == '-' || c == '_' {
132132+ '-'
133133+ } else {
134134+ // Skip other characters
135135+ '\0'
136136+ }
137137+ })
138138+ .filter(|&c| c != '\0')
139139+ .collect::<String>()
140140+ // Collapse multiple dashes
141141+ .split('-')
142142+ .filter(|s| !s.is_empty())
143143+ .collect::<Vec<_>>()
144144+ .join("-")
145145+}
146146+147147+/// Props for the publish button component.
148148+#[derive(Props, Clone, PartialEq)]
149149+pub struct PublishButtonProps {
150150+ /// The editor document signal
151151+ pub document: Signal<EditorDocument>,
152152+ /// Storage key for the draft
153153+ pub draft_key: String,
154154+}
155155+156156+/// Publish button component with notebook selection.
157157+#[component]
158158+pub fn PublishButton(props: PublishButtonProps) -> Element {
159159+ let fetcher = use_context::<Fetcher>();
160160+ let auth_state = use_context::<Signal<AuthState>>();
161161+162162+ let mut show_dialog = use_signal(|| false);
163163+ let mut notebook_title = use_signal(|| String::from("Default"));
164164+ let mut is_publishing = use_signal(|| false);
165165+ let mut error_message: Signal<Option<String>> = use_signal(|| None);
166166+ let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None);
167167+168168+ let is_authenticated = auth_state.read().is_authenticated();
169169+ let doc = props.document;
170170+ let draft_key = props.draft_key.clone();
171171+172172+ // Validate that we have required fields
173173+ let can_publish = {
174174+ let d = doc();
175175+ !d.title().trim().is_empty() && !d.content().trim().is_empty()
176176+ };
177177+178178+ let open_dialog = move |_| {
179179+ error_message.set(None);
180180+ success_uri.set(None);
181181+ show_dialog.set(true);
182182+ };
183183+184184+ let close_dialog = move |_| {
185185+ show_dialog.set(false);
186186+ };
187187+188188+ let draft_key_clone = draft_key.clone();
189189+ let do_publish = move |_| {
190190+ let fetcher = fetcher.clone();
191191+ let draft_key = draft_key_clone.clone();
192192+ let notebook = notebook_title();
193193+194194+ spawn(async move {
195195+ is_publishing.set(true);
196196+ error_message.set(None);
197197+198198+ // Get document snapshot for publishing
199199+ let doc_snapshot = doc();
200200+201201+ match publish_entry(&fetcher, &doc_snapshot, ¬ebook, &draft_key).await {
202202+ Ok(result) => {
203203+ success_uri.set(Some(result.uri().clone()));
204204+ }
205205+ Err(e) => {
206206+ error_message.set(Some(format!("{}", e)));
207207+ }
208208+ }
209209+210210+ is_publishing.set(false);
211211+ });
212212+ };
213213+214214+ rsx! {
215215+ button {
216216+ class: "publish-button",
217217+ disabled: !is_authenticated || !can_publish,
218218+ onclick: open_dialog,
219219+ title: if !is_authenticated {
220220+ "Log in to publish"
221221+ } else if !can_publish {
222222+ "Title and content required"
223223+ } else {
224224+ "Publish entry"
225225+ },
226226+ "Publish"
227227+ }
228228+229229+ if show_dialog() {
230230+ div {
231231+ class: "publish-dialog-overlay",
232232+ onclick: close_dialog,
233233+234234+ div {
235235+ class: "publish-dialog",
236236+ onclick: move |e| e.stop_propagation(),
237237+238238+ h2 { "Publish Entry" }
239239+240240+ if let Some(uri) = success_uri() {
241241+ div { class: "publish-success",
242242+ p { "Entry published successfully!" }
243243+ a {
244244+ href: "{uri}",
245245+ target: "_blank",
246246+ "View entry →"
247247+ }
248248+ button {
249249+ class: "publish-done",
250250+ onclick: close_dialog,
251251+ "Done"
252252+ }
253253+ }
254254+ } else {
255255+ div { class: "publish-form",
256256+ div { class: "publish-field",
257257+ label { "Notebook" }
258258+ input {
259259+ r#type: "text",
260260+ class: "publish-input",
261261+ placeholder: "Notebook title...",
262262+ value: "{notebook_title}",
263263+ oninput: move |e| notebook_title.set(e.value()),
264264+ }
265265+ }
266266+267267+ div { class: "publish-preview",
268268+ p { "Title: {doc().title()}" }
269269+ p { "Path: {doc().path()}" }
270270+ if !doc().tags().is_empty() {
271271+ p { "Tags: {doc().tags().join(\", \")}" }
272272+ }
273273+ }
274274+275275+ if let Some(err) = error_message() {
276276+ div { class: "publish-error",
277277+ "{err}"
278278+ }
279279+ }
280280+281281+ div { class: "publish-actions",
282282+ button {
283283+ class: "publish-cancel",
284284+ onclick: close_dialog,
285285+ disabled: is_publishing(),
286286+ "Cancel"
287287+ }
288288+ button {
289289+ class: "publish-submit",
290290+ onclick: do_publish,
291291+ disabled: is_publishing() || notebook_title().trim().is_empty(),
292292+ if is_publishing() {
293293+ "Publishing..."
294294+ } else {
295295+ "Publish"
296296+ }
297297+ }
298298+ }
299299+ }
300300+ }
301301+ }
302302+ }
303303+ }
304304+ }
305305+}
+5-1
crates/weaver-app/src/components/editor/render.rs
···181181182182 // Compute delta from actual length difference, not edit info
183183 // This handles stale edits gracefully (delta = 0 if lengths match)
184184- let cached_len = cache.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0);
184184+ let cached_len = cache
185185+ .paragraphs
186186+ .last()
187187+ .map(|p| p.char_range.end)
188188+ .unwrap_or(0);
185189 let char_delta = current_len as isize - cached_len as isize;
186190187191 // Adjust each cached paragraph's range
+2-2
crates/weaver-app/src/components/editor/report.rs
···2727 .map(|e| e.outer_html())
2828 .unwrap_or_default();
29293030- let editor_text = load_from_storage()
3131- .map(|snapshot| snapshot.to_string())
3030+ let editor_text = load_from_storage("current")
3131+ .map(|doc| doc.content())
3232 .unwrap_or_default();
33333434 let platform_info = {
···22//!
33//! Stores both human-readable content (for debugging) and the full CRDT
44//! snapshot (for undo history preservation across sessions).
55+//!
66+//! Storage key strategy:
77+//! - New entries: `"draft:new:{uuid}"`
88+//! - Editing existing: `"draft:{at-uri}"`
59610#[cfg(all(target_family = "wasm", target_os = "unknown"))]
711use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
···12161317use super::document::EditorDocument;
14181919+/// Prefix for all draft storage keys.
2020+pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:";
2121+1522/// Editor snapshot for persistence.
1623///
1724/// Stores both human-readable content and CRDT snapshot for best of both worlds:
1825/// - `content`: Human-readable text for debugging
1919-/// - `snapshot`: Base64-encoded CRDT state for document history
2626+/// - `title`: Entry title for debugging/display in drafts list
2727+/// - `snapshot`: Base64-encoded CRDT state for document history (includes all embeds)
2028/// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position
2129/// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored
3030+/// - `editing_uri`: AT-URI if editing an existing entry
2231///
2332/// Note: Undo/redo is session-only (UndoManager state is ephemeral).
2433/// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
···2635pub struct EditorSnapshot {
2736 /// Human-readable document content (for debugging/fallback)
2837 pub content: String,
2929- /// Base64-encoded CRDT snapshot
3838+3939+ /// Entry title (for debugging/display in drafts list)
4040+ #[serde(default)]
4141+ pub title: String,
4242+4343+ /// Base64-encoded CRDT snapshot (contains ALL fields including embeds)
4444+ #[serde(default, skip_serializing_if = "Option::is_none")]
3045 pub snapshot: Option<String>,
4646+3147 /// Loro Cursor for stable cursor position tracking
4848+ #[serde(default, skip_serializing_if = "Option::is_none")]
3249 pub cursor: Option<Cursor>,
5050+3351 /// Fallback cursor offset (used if Loro cursor can't be restored)
5252+ #[serde(default)]
3453 pub cursor_offset: usize,
5454+5555+ /// AT-URI if editing an existing entry (None for new entries)
5656+ #[serde(default, skip_serializing_if = "Option::is_none")]
5757+ pub editing_uri: Option<String>,
3558}
36593737-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
3838-const STORAGE_KEY: &str = "weaver_editor_draft";
6060+/// Build the full storage key from a draft key.
6161+fn storage_key(key: &str) -> String {
6262+ format!("{}{}", DRAFT_KEY_PREFIX, key)
6363+}
39644065/// Save editor state to LocalStorage (WASM only).
6666+///
6767+/// # Arguments
6868+/// * `doc` - The editor document to save
6969+/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
7070+/// * `editing_uri` - AT-URI if editing an existing entry
4171#[cfg(all(target_family = "wasm", target_os = "unknown"))]
4242-pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors::StorageError> {
7272+pub fn save_to_storage(
7373+ doc: &EditorDocument,
7474+ key: &str,
7575+ editing_uri: Option<&str>,
7676+) -> Result<(), gloo_storage::errors::StorageError> {
4377 let snapshot_bytes = doc.export_snapshot();
4478 let snapshot_b64 = if snapshot_bytes.is_empty() {
4579 None
···4882 };
49835084 let snapshot = EditorSnapshot {
5151- content: doc.to_string(),
8585+ content: doc.content(),
8686+ title: doc.title(),
5287 snapshot: snapshot_b64,
5388 cursor: doc.loro_cursor().cloned(),
5489 cursor_offset: doc.cursor.offset,
9090+ editing_uri: editing_uri.map(String::from),
5591 };
5656- LocalStorage::set(STORAGE_KEY, &snapshot)
9292+ LocalStorage::set(storage_key(key), &snapshot)
5793}
58945995/// Load editor state from LocalStorage (WASM only).
9696+///
6097/// Returns an EditorDocument restored from CRDT snapshot if available,
6198/// otherwise falls back to just the text content.
9999+///
100100+/// # Arguments
101101+/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
62102#[cfg(all(target_family = "wasm", target_os = "unknown"))]
6363-pub fn load_from_storage() -> Option<EditorDocument> {
6464- let snapshot: EditorSnapshot = LocalStorage::get(STORAGE_KEY).ok()?;
103103+pub fn load_from_storage(key: &str) -> Option<EditorDocument> {
104104+ let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
6510566106 // Try to restore from CRDT snapshot first
67107 if let Some(ref snapshot_b64) = snapshot.snapshot {
···72112 snapshot.cursor_offset,
73113 );
74114 // Verify the content matches (sanity check)
7575- if doc.to_string() == snapshot.content {
115115+ if doc.content() == snapshot.content {
76116 return Some(doc);
77117 }
78118 tracing::warn!("Snapshot content mismatch, falling back to text content");
···86126 Some(doc)
87127}
881288989-/// Clear editor state from LocalStorage (WASM only).
129129+/// Delete a draft from LocalStorage (WASM only).
130130+///
131131+/// # Arguments
132132+/// * `key` - Storage key to delete
133133+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
134134+pub fn delete_draft(key: &str) {
135135+ LocalStorage::delete(storage_key(key));
136136+}
137137+138138+/// List all draft keys from LocalStorage (WASM only).
139139+///
140140+/// Returns a list of (key, title, editing_uri) tuples for all saved drafts.
141141+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
142142+pub fn list_drafts() -> Vec<(String, String, Option<String>)> {
143143+ let mut drafts = Vec::new();
144144+145145+ // gloo_storage doesn't have a direct way to iterate keys,
146146+ // so we use web_sys directly
147147+ if let Some(storage) = web_sys::window()
148148+ .and_then(|w| w.local_storage().ok())
149149+ .flatten()
150150+ {
151151+ let len = storage.length().unwrap_or(0);
152152+ for i in 0..len {
153153+ if let Ok(Some(key)) = storage.key(i) {
154154+ if key.starts_with(DRAFT_KEY_PREFIX) {
155155+ // Try to load just the metadata
156156+ if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) {
157157+ let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key);
158158+ drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri));
159159+ }
160160+ }
161161+ }
162162+ }
163163+ }
164164+165165+ drafts
166166+}
167167+168168+/// Clear all editor drafts from LocalStorage (WASM only).
90169#[cfg(all(target_family = "wasm", target_os = "unknown"))]
91170#[allow(dead_code)]
9292-pub fn clear_storage() {
9393- LocalStorage::delete(STORAGE_KEY);
171171+pub fn clear_all_drafts() {
172172+ for (key, _, _) in list_drafts() {
173173+ delete_draft(&key);
174174+ }
94175}
9517696177// Stub implementations for non-WASM targets
97178#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
9898-pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> {
179179+pub fn save_to_storage(
180180+ _doc: &EditorDocument,
181181+ _key: &str,
182182+ _editing_uri: Option<&str>,
183183+) -> Result<(), String> {
99184 Ok(())
100185}
101186102187#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
103103-pub fn load_from_storage() -> Option<EditorDocument> {
188188+pub fn load_from_storage(_key: &str) -> Option<EditorDocument> {
104189 None
105190}
106191107192#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
193193+pub fn delete_draft(_key: &str) {}
194194+195195+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
196196+pub fn list_drafts() -> Vec<(String, String, Option<String>)> {
197197+ Vec::new()
198198+}
199199+200200+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
108201#[allow(dead_code)]
109109-pub fn clear_storage() {}
202202+pub fn clear_all_drafts() {}
+48-15
crates/weaver-app/src/components/editor/tests.rs
···418418 // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2)
419419 let result = render_test("# Title\n\n\n\nContent"); // 4 newlines
420420 assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace");
421421- assert!(result[1].html.contains("gap-"), "Middle element should be a gap");
421421+ assert!(
422422+ result[1].html.contains("gap-"),
423423+ "Middle element should be a gap"
424424+ );
422425423426 // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element)
424427 let result2 = render_test("# Title\n\n\nContent"); // 3 newlines
425425- assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent");
428428+ assert_eq!(
429429+ result2.len(),
430430+ 2,
431431+ "Expected 2 elements with standard break equivalent"
432432+ );
426433}
427434428435// =============================================================================
···630637fn test_heading_to_non_heading_transition() {
631638 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
632639 // This tests that the syntax spans are correctly updated on content change.
633633- use loro::LoroDoc;
634640 use super::render::render_paragraphs_incremental;
641641+ use loro::LoroDoc;
635642636643 let doc = LoroDoc::new();
637644 let text = doc.get_text("content");
···680687 let result = render_test(">");
681688 eprintln!("Paragraphs for '>': {:?}", result.len());
682689 for (i, p) in result.iter().enumerate() {
683683- eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range);
690690+ eprintln!(
691691+ " Para {}: html={}, char_range={:?}",
692692+ i, p.html, p.char_range
693693+ );
684694 }
685695686696 // Empty blockquote should still produce at least one paragraph
···759769760770 // With standard \n\n break, we expect 2 paragraphs (no gap element)
761771 // Paragraph ranges include some trailing whitespace from markdown parsing
762762- assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break");
772772+ assert_eq!(
773773+ paragraphs.len(),
774774+ 2,
775775+ "Expected 2 paragraphs for standard break"
776776+ );
763777764778 // First paragraph ends before second starts, with gap for \n\n
765779 let gap_start = paragraphs[0].char_range.end;
766780 let gap_end = paragraphs[1].char_range.start;
767781 let gap_size = gap_end - gap_start;
768768- assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size);
782782+ assert!(
783783+ gap_size <= 2,
784784+ "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}",
785785+ gap_size
786786+ );
769787}
770788771789#[test]
···779797 let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
780798781799 // With extra newlines, we expect 3 elements: para, gap, para
782782- assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace");
800800+ assert_eq!(
801801+ paragraphs.len(),
802802+ 3,
803803+ "Expected 3 elements with extra whitespace"
804804+ );
783805784806 // Gap element should exist and cover whitespace zone
785807 let gap = ¶graphs[1];
786808 assert!(gap.html.contains("gap-"), "Second element should be a gap");
787809788810 // Gap should cover ALL whitespace (not just extra)
789789- assert_eq!(gap.char_range.start, paragraphs[0].char_range.end,
790790- "Gap should start where first paragraph ends");
791791- assert_eq!(gap.char_range.end, paragraphs[2].char_range.start,
792792- "Gap should end where second paragraph starts");
811811+ assert_eq!(
812812+ gap.char_range.start, paragraphs[0].char_range.end,
813813+ "Gap should start where first paragraph ends"
814814+ );
815815+ assert_eq!(
816816+ gap.char_range.end, paragraphs[2].char_range.start,
817817+ "Gap should end where second paragraph starts"
818818+ );
793819}
794820795821#[test]
···9991025 // UTF-16: 0 1 2 3 4 5 6,7 8 9 10
1000102610011027 assert_eq!(char_to_utf16(&text, 0), 0);
10021002- assert_eq!(char_to_utf16(&text, 6), 6); // before emoji
10031003- assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units)
10281028+ assert_eq!(char_to_utf16(&text, 6), 6); // before emoji
10291029+ assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units)
10041030 assert_eq!(char_to_utf16(&text, 10), 11); // end
10051031}
10061032···10261052 if text.len_unicode() == text.len_utf16() {
10271053 return char_pos; // fast path
10281054 }
10291029- text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0)
10551055+ text.slice(0, char_pos)
10561056+ .map(|s| s.encode_utf16().count())
10571057+ .unwrap_or(0)
10301058 }
1031105910321060 // All positions should be identity for ASCII
10331061 for i in 0..=text.len_unicode() {
10341034- assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i);
10621062+ assert_eq!(
10631063+ char_to_utf16(&text, i),
10641064+ i,
10651065+ "ASCII fast path failed at pos {}",
10661066+ i
10671067+ );
10351068 }
10361069}
···188188 pos.saturating_add(amount).min(max_pos)
189189}
190190191191+/// Update syntax span visibility in the DOM based on cursor position.
192192+///
193193+/// Toggles the "hidden" class on syntax spans based on calculated visibility.
194194+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
195195+pub fn update_syntax_visibility(
196196+ cursor_offset: usize,
197197+ selection: Option<&Selection>,
198198+ syntax_spans: &[SyntaxSpanInfo],
199199+ paragraphs: &[ParagraphRender],
200200+) {
201201+ let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
202202+203203+ let Some(window) = web_sys::window() else {
204204+ return;
205205+ };
206206+ let Some(document) = window.document() else {
207207+ return;
208208+ };
209209+210210+ // Update each syntax span's visibility
211211+ for span in syntax_spans {
212212+ let selector = format!("[data-syn-id='{}']", span.syn_id);
213213+ if let Ok(Some(element)) = document.query_selector(&selector) {
214214+ let class_list = element.class_list();
215215+ if visibility.is_visible(&span.syn_id) {
216216+ let _ = class_list.remove_1("hidden");
217217+ } else {
218218+ let _ = class_list.add_1("hidden");
219219+ }
220220+ }
221221+ }
222222+}
223223+224224+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
225225+pub fn update_syntax_visibility(
226226+ _cursor_offset: usize,
227227+ _selection: Option<&Selection>,
228228+ _syntax_spans: &[SyntaxSpanInfo],
229229+ _paragraphs: &[ParagraphRender],
230230+) {
231231+ // No-op on non-wasm
232232+}
233233+191234#[cfg(test)]
192235mod tests {
193236 use super::*;
194237195195- fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo {
238238+ fn make_span(
239239+ syn_id: &str,
240240+ start: usize,
241241+ end: usize,
242242+ syntax_type: SyntaxType,
243243+ ) -> SyntaxSpanInfo {
196244 SyntaxSpanInfo {
197245 syn_id: syn_id.to_string(),
198246 char_range: start..end,
···240288241289 // Cursor at position 4 (middle of "bold", inside formatted region)
242290 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
243243- assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region");
244244- assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region");
291291+ assert!(
292292+ vis.is_visible("s0"),
293293+ "opening ** should be visible when cursor inside formatted region"
294294+ );
295295+ assert!(
296296+ vis.is_visible("s1"),
297297+ "closing ** should be visible when cursor inside formatted region"
298298+ );
245299246300 // Cursor at position 2 (adjacent to opening **, start of "bold")
247301 let vis = VisibilityState::calculate(2, None, &spans, ¶s);
248248- assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold");
302302+ assert!(
303303+ vis.is_visible("s0"),
304304+ "opening ** should be visible when cursor adjacent at start of bold"
305305+ );
249306250307 // Cursor at position 5 (adjacent to closing **, end of "bold")
251308 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
252252- assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold");
309309+ assert!(
310310+ vis.is_visible("s1"),
311311+ "closing ** should be visible when cursor adjacent at end of bold"
312312+ );
253313 }
254314255315 #[test]
···263323264324 // Cursor at position 4 (middle of "bold", not adjacent to either marker)
265325 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
266266- assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent");
267267- assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent");
326326+ assert!(
327327+ !vis.is_visible("s0"),
328328+ "opening ** should be hidden when no formatted_range and cursor not adjacent"
329329+ );
330330+ assert!(
331331+ !vis.is_visible("s1"),
332332+ "closing ** should be hidden when no formatted_range and cursor not adjacent"
333333+ );
268334 }
269335270336 #[test]
···278344279345 // Cursor at position 4 (one before ** which starts at 5)
280346 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
281281- assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent");
347347+ assert!(
348348+ vis.is_visible("s0"),
349349+ "** should be visible when cursor adjacent"
350350+ );
282351283352 // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
284353 let vis = VisibilityState::calculate(7, None, &spans, ¶s);
285285- assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span");
354354+ assert!(
355355+ vis.is_visible("s0"),
356356+ "** should be visible when cursor adjacent after span"
357357+ );
286358 }
287359288360 #[test]
289361 fn test_inline_visibility_cursor_far() {
290290- let spans = vec![
291291- make_span("s0", 10, 12, SyntaxType::Inline),
292292- ];
362362+ let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
293363 let paras = vec![make_para(0, 33, spans.clone())];
294364295365 // Cursor at position 0 (far from **)
296366 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
297297- assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away");
367367+ assert!(
368368+ !vis.is_visible("s0"),
369369+ "** should be hidden when cursor far away"
370370+ );
298371 }
299372300373 #[test]
···310383311384 // Cursor at position 5 (inside heading)
312385 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
313313- assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph");
386386+ assert!(
387387+ vis.is_visible("s0"),
388388+ "# should be visible when cursor in same paragraph"
389389+ );
314390 }
315391316392 #[test]
317393 fn test_block_visibility_different_paragraph() {
318318- let spans = vec![
319319- make_span("s0", 0, 2, SyntaxType::Block),
320320- ];
321321- let paras = vec![
322322- make_para(0, 10, spans.clone()),
323323- make_para(12, 30, vec![]),
324324- ];
394394+ let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
395395+ let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
325396326397 // Cursor at position 20 (in second paragraph)
327398 let vis = VisibilityState::calculate(20, None, &spans, ¶s);
328328- assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph");
399399+ assert!(
400400+ !vis.is_visible("s0"),
401401+ "# should be hidden when cursor in different paragraph"
402402+ );
329403 }
330404331405 #[test]
332406 fn test_selection_reveals_syntax() {
333333- let spans = vec![
334334- make_span("s0", 5, 7, SyntaxType::Inline),
335335- ];
407407+ let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
336408 let paras = vec![make_para(0, 24, spans.clone())];
337409338410 // Selection overlaps the syntax span
339339- let selection = Selection { anchor: 3, head: 10 };
411411+ let selection = Selection {
412412+ anchor: 3,
413413+ head: 10,
414414+ };
340415 let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
341341- assert!(vis.is_visible("s0"), "** should be visible when selection overlaps");
416416+ assert!(
417417+ vis.is_visible("s0"),
418418+ "** should be visible when selection overlaps"
419419+ );
342420 }
343421344422 #[test]
···351429 make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
352430 ];
353431 let paras = vec![
354354- make_para(0, 8, spans.clone()), // "**bold**"
355355- make_para(9, 13, vec![]), // "text" (after newline)
432432+ make_para(0, 8, spans.clone()), // "**bold**"
433433+ make_para(9, 13, vec![]), // "text" (after newline)
356434 ];
357435358436 // Cursor at position 9 (start of second paragraph)
359437 // Should NOT reveal the closing ** because para bounds clamp extension
360438 let vis = VisibilityState::calculate(9, None, &spans, ¶s);
361361- assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph");
439439+ assert!(
440440+ !vis.is_visible("s1"),
441441+ "closing ** should NOT be visible when cursor is in next paragraph"
442442+ );
362443 }
363444364445 #[test]
365446 fn test_extension_clamps_to_paragraph() {
366447 // Syntax at very start of paragraph - extension left should stop at para start
367367- let spans = vec![
368368- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8),
369369- ];
448448+ let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
370449 let paras = vec![make_para(0, 8, spans.clone())];
371450372451 // Cursor at position 0 - should still see the opening **
373452 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
374374- assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0");
453453+ assert!(
454454+ vis.is_visible("s0"),
455455+ "** at start should be visible when cursor at position 0"
456456+ );
375457 }
376458}
+1-1
crates/weaver-app/src/components/input/mod.rs
···11mod component;
22-pub use component::*;22+pub use component::*;
+1-1
crates/weaver-app/src/components/mod.rs
···127127pub mod accordion;
128128pub mod button;
129129pub mod dialog;
130130-pub mod input;
131130pub mod editor;
131131+pub mod input;
+1-1
crates/weaver-app/src/main.rs
···106106 {
107107 use tracing::Level;
108108 use tracing::subscriber::set_global_default;
109109- use tracing_subscriber::layer::SubscriberExt;
110109 use tracing_subscriber::Registry;
110110+ use tracing_subscriber::layer::SubscriberExt;
111111112112 let console_level = if cfg!(debug_assertions) {
113113 Level::DEBUG