basic thing works, really bad perf

Orual 468ca19d 25836bdb

+740 -73
+1
.gitignore
··· 18 **/.obsidian 19 **/.trash 20 **/bug_notes.md
··· 18 **/.obsidian 19 **/.trash 20 **/bug_notes.md 21 + /opencodetmp
-10
crates/weaver-app/.env-example
··· 1 - WEAVER_APP_ENV="dev" 2 - WEAVER_APP_HOST="http://localhost" 3 - WEAVER_APP_DOMAIN="" 4 - WEAVER_PORT=8080 5 - WEAVER_APP_SCOPES="atproto transition:generic" 6 - WEAVER_CLIENT_NAME="Weaver" 7 - 8 - WEAVER_LOGO_URI="" 9 - WEAVER_TOS_URI="" 10 - WEAVER_PRIVACY_POLICY_URI=""
···
+10
crates/weaver-app/.env-prod
···
··· 1 + WEAVER_APP_ENV="prod" 2 + WEAVER_APP_HOST="https://alpha.weaver.sh" 3 + WEAVER_APP_DOMAIN="https://alpha.weaver.sh" 4 + WEAVER_PORT=8080 5 + WEAVER_APP_SCOPES="atproto transition:generic" 6 + WEAVER_CLIENT_NAME="Weaver" 7 + 8 + WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico" 9 + WEAVER_TOS_URI="" 10 + WEAVER_PRIVACY_POLICY_URI=""
+47
crates/weaver-app/assets/styling/editor.css
··· 565 opacity: 0.5; 566 cursor: not-allowed; 567 }
··· 565 opacity: 0.5; 566 cursor: not-allowed; 567 } 568 + 569 + /* Image upload dialog */ 570 + .image-preview-container { 571 + display: flex; 572 + justify-content: center; 573 + margin-bottom: 1rem; 574 + } 575 + 576 + .image-preview { 577 + max-width: 100%; 578 + max-height: 300px; 579 + border-radius: 4px; 580 + object-fit: contain; 581 + } 582 + 583 + .image-alt-input-container { 584 + display: flex; 585 + flex-direction: column; 586 + gap: 0.5rem; 587 + } 588 + 589 + .image-alt-input-container label { 590 + font-weight: 500; 591 + color: var(--color-text); 592 + } 593 + 594 + .image-alt-input { 595 + width: 100%; 596 + padding: 0.75rem; 597 + border: 1px solid var(--color-border); 598 + border-radius: 4px; 599 + background: var(--color-base); 600 + color: var(--color-text); 601 + font-family: var(--font-body); 602 + resize: vertical; 603 + } 604 + 605 + .image-alt-input::placeholder { 606 + color: var(--color-muted); 607 + } 608 + 609 + .dialog-actions { 610 + display: flex; 611 + gap: 1rem; 612 + justify-content: flex-end; 613 + margin-top: 1rem; 614 + }
+124 -4
crates/weaver-app/src/components/editor/component.rs
··· 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}; ··· 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 /// ··· 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"; ··· 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 { ··· 161 let needs_save = { 162 let last_frontiers = last_saved_frontiers.peek(); 163 match &*last_frontiers { 164 - None => true, // First save 165 Some(last) => &current_frontiers != last, 166 } 167 }; // drop last_frontiers borrow here ··· 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 ··· 650 document.with_mut(|doc| { 651 formatting::apply_formatting(doc, action); 652 }); 653 } 654 } 655
··· 1 //! The main MarkdownEditor component. 2 3 use dioxus::prelude::*; 4 + use jacquard::cowstr::ToCowStr; 5 + use jacquard::types::blob::BlobRef; 6 + use weaver_api::sh_weaver::embed::images::Image; 7 + use weaver_common::WeaverExt; 8 9 + use crate::auth::AuthState; 10 use crate::components::editor::ReportButton; 11 + use crate::fetch::Fetcher; 12 13 use super::document::{CompositionState, EditorDocument}; 14 use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; ··· 23 use super::storage; 24 use super::toolbar::EditorToolbar; 25 use super::visibility::update_syntax_visibility; 26 + use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 27 28 /// Main markdown editor component. 29 /// ··· 38 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 39 #[component] 40 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 41 + // Context for authenticated API calls 42 + let fetcher = use_context::<Fetcher>(); 43 + let auth_state = use_context::<Signal<AuthState>>(); 44 + 45 // Try to restore from localStorage (includes CRDT state for undo history) 46 // Use "current" as the default draft key for now 47 let draft_key = "current"; ··· 54 // Cache for incremental paragraph rendering 55 let mut render_cache = use_signal(|| render::RenderCache::default()); 56 57 + // Image resolver for mapping /image/{name} to data URLs or CDN URLs 58 + let mut image_resolver = use_signal(EditorImageResolver::default); 59 + 60 // Render paragraphs with incremental caching 61 let paragraphs = use_memo(move || { 62 let doc = document(); 63 let cache = render_cache.peek(); 64 let edit = doc.last_edit.as_ref(); 65 + let resolver = image_resolver(); 66 67 let (paras, new_cache) = 68 + render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit, Some(&resolver)); 69 70 // Update cache for next render (write-only via spawn to avoid reactive loop) 71 dioxus::prelude::spawn(async move { ··· 175 let needs_save = { 176 let last_frontiers = last_saved_frontiers.peek(); 177 match &*last_frontiers { 178 + None => true, 179 Some(last) => &current_frontiers != last, 180 } 181 }; // drop last_frontiers borrow here ··· 183 if needs_save { 184 document.with_mut(|doc| { 185 doc.sync_loro_cursor(); 186 + let _ = storage::save_to_storage(doc, draft_key); 187 }); 188 189 // Update last saved frontiers ··· 664 document.with_mut(|doc| { 665 formatting::apply_formatting(doc, action); 666 }); 667 + }, 668 + on_image: move |uploaded: super::image_upload::UploadedImage| { 669 + // Build data URL for immediate preview 670 + use base64::{Engine, engine::general_purpose::STANDARD}; 671 + let data_url = format!( 672 + "data:{};base64,{}", 673 + uploaded.mime_type, 674 + STANDARD.encode(&uploaded.data) 675 + ); 676 + 677 + // Add to resolver for immediate display 678 + let name = uploaded.name.clone(); 679 + image_resolver.with_mut(|resolver| { 680 + resolver.add_pending(name.clone(), data_url); 681 + }); 682 + 683 + // Insert markdown image syntax at cursor 684 + let alt_text = if uploaded.alt.is_empty() { 685 + name.clone() 686 + } else { 687 + uploaded.alt.clone() 688 + }; 689 + let markdown = format!("![{}](/image/{})", alt_text, name); 690 + 691 + document.with_mut(|doc| { 692 + let pos = doc.cursor.offset; 693 + let _ = doc.insert_tracked(pos, &markdown); 694 + doc.cursor.offset = pos + markdown.chars().count(); 695 + }); 696 + 697 + // Upload to PDS in background if authenticated 698 + let is_authenticated = auth_state.read().is_authenticated(); 699 + if is_authenticated { 700 + let fetcher = fetcher.clone(); 701 + let name_for_upload = name.clone(); 702 + let alt_for_upload = alt_text.clone(); 703 + let data = uploaded.data.clone(); 704 + 705 + spawn(async move { 706 + let client = fetcher.get_client(); 707 + 708 + // Upload blob and create temporary PublishedBlob record 709 + match client.publish_blob(data, &name_for_upload, None).await { 710 + Ok((strong_ref, published_blob)) => { 711 + // Extract the blob from PublishedBlob 712 + let blob = match published_blob.upload { 713 + BlobRef::Blob(b) => b, 714 + _ => { 715 + tracing::warn!("Unexpected BlobRef variant"); 716 + return; 717 + } 718 + }; 719 + 720 + // Get format from mime type 721 + let format = blob 722 + .mime_type 723 + .0 724 + .strip_prefix("image/") 725 + .unwrap_or("jpeg") 726 + .to_string(); 727 + 728 + // Get DID from fetcher 729 + let did = match fetcher.current_did().await { 730 + Some(d) => d.to_string(), 731 + None => { 732 + tracing::warn!("No DID available"); 733 + return; 734 + } 735 + }; 736 + 737 + let cid = blob.cid().to_string(); 738 + 739 + // Build Image using the builder API 740 + let name_for_resolver = name_for_upload.clone(); 741 + let image = Image::new() 742 + .alt(alt_for_upload.to_cowstr()) 743 + .image(BlobRef::Blob(blob)) 744 + .name(name_for_upload.to_cowstr()) 745 + .build(); 746 + 747 + // Add to document 748 + document.with_mut(|doc| { 749 + doc.add_image(&image, Some(&strong_ref.uri)); 750 + }); 751 + 752 + // Promote from pending to uploaded in resolver 753 + image_resolver.with_mut(|resolver| { 754 + resolver.promote_to_uploaded( 755 + &name_for_resolver, 756 + cid, 757 + did, 758 + format, 759 + ); 760 + }); 761 + 762 + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 763 + } 764 + Err(e) => { 765 + tracing::error!(error = %e, "Failed to upload image"); 766 + // Image stays as data URL - will work for preview but not publish 767 + } 768 + } 769 + }); 770 + } else { 771 + tracing::info!(name = %name, "Image added with data URL (not authenticated)"); 772 + } 773 } 774 } 775
+20
crates/weaver-app/src/components/editor/document.rs
··· 55 /// Contains nested containers: images (LoroList), externals (LoroList), etc. 56 embeds: LoroMap, 57 58 // --- Editor state --- 59 /// Undo manager for the document. 60 undo_mgr: UndoManager, ··· 205 created_at, 206 tags, 207 embeds, 208 undo_mgr, 209 cursor: CursorState { 210 offset: 0, ··· 312 self.created_at.delete(0, current_len).ok(); 313 } 314 self.created_at.insert(0, datetime).ok(); 315 } 316 317 // --- Tags accessors --- ··· 690 created_at, 691 tags, 692 embeds, 693 undo_mgr, 694 cursor, 695 loro_cursor, ··· 718 new_doc.composition = self.composition.clone(); 719 new_doc.composition_ended_at = self.composition_ended_at; 720 new_doc.last_edit = self.last_edit.clone(); 721 new_doc 722 } 723 }
··· 55 /// Contains nested containers: images (LoroList), externals (LoroList), etc. 56 embeds: LoroMap, 57 58 + // --- Entry tracking --- 59 + /// AT-URI of the entry if editing an existing record. 60 + /// None for new entries that haven't been published yet. 61 + entry_uri: Option<AtUri<'static>>, 62 + 63 // --- Editor state --- 64 /// Undo manager for the document. 65 undo_mgr: UndoManager, ··· 210 created_at, 211 tags, 212 embeds, 213 + entry_uri: None, 214 undo_mgr, 215 cursor: CursorState { 216 offset: 0, ··· 318 self.created_at.delete(0, current_len).ok(); 319 } 320 self.created_at.insert(0, datetime).ok(); 321 + } 322 + 323 + // --- Entry URI accessors --- 324 + 325 + /// Get the AT-URI of the entry if editing an existing record. 326 + pub fn entry_uri(&self) -> Option<&AtUri<'static>> { 327 + self.entry_uri.as_ref() 328 + } 329 + 330 + /// Set the AT-URI when editing an existing entry. 331 + pub fn set_entry_uri(&mut self, uri: Option<AtUri<'static>>) { 332 + self.entry_uri = uri; 333 } 334 335 // --- Tags accessors --- ··· 708 created_at, 709 tags, 710 embeds, 711 + entry_uri: None, 712 undo_mgr, 713 cursor, 714 loro_cursor, ··· 737 new_doc.composition = self.composition.clone(); 738 new_doc.composition_ended_at = self.composition_ended_at; 739 new_doc.last_edit = self.last_edit.clone(); 740 + new_doc.entry_uri = self.entry_uri.clone(); 741 new_doc 742 } 743 }
+176
crates/weaver-app/src/components/editor/image_upload.rs
···
··· 1 + //! Image upload component for the markdown editor. 2 + //! 3 + //! Provides file picker and upload functionality for adding images to entries. 4 + //! Shows a preview dialog with alt text input before confirming the upload. 5 + 6 + use base64::{Engine, engine::general_purpose::STANDARD}; 7 + use dioxus::prelude::*; 8 + use jacquard::bytes::Bytes; 9 + use mime_sniffer::MimeTypeSniffer; 10 + 11 + use crate::components::{ 12 + button::{Button, ButtonVariant}, 13 + dialog::{DialogContent, DialogRoot, DialogTitle}, 14 + }; 15 + 16 + /// Result of an image upload operation. 17 + #[derive(Clone, Debug)] 18 + pub struct UploadedImage { 19 + /// The filename (used as the markdown reference name) 20 + pub name: String, 21 + /// Alt text for accessibility 22 + pub alt: String, 23 + /// MIME type of the image (sniffed from bytes) 24 + pub mime_type: String, 25 + /// Raw image bytes 26 + pub data: Bytes, 27 + } 28 + 29 + /// Pending image data before user confirms with alt text. 30 + #[derive(Clone, Default)] 31 + struct PendingImage { 32 + name: String, 33 + mime_type: String, 34 + data: Bytes, 35 + data_url: String, 36 + } 37 + 38 + /// Props for the ImageUploadButton component. 39 + #[derive(Props, Clone, PartialEq)] 40 + pub struct ImageUploadButtonProps { 41 + /// Callback when an image is selected and confirmed with alt text 42 + pub on_image_selected: EventHandler<UploadedImage>, 43 + /// Whether the button is disabled 44 + #[props(default = false)] 45 + pub disabled: bool, 46 + } 47 + 48 + /// A button that opens a file picker for image selection. 49 + /// 50 + /// When a file is selected, shows a preview dialog with alt text input. 51 + /// Only triggers the callback after user confirms. 52 + #[component] 53 + pub fn ImageUploadButton(props: ImageUploadButtonProps) -> Element { 54 + let mut show_dialog = use_signal(|| false); 55 + let mut pending_image = use_signal(PendingImage::default); 56 + let mut alt_text = use_signal(String::new); 57 + 58 + let on_file_change = move |evt: Event<FormData>| { 59 + spawn(async move { 60 + let files = evt.files(); 61 + if let Some(file) = files.first() { 62 + let name = file.name(); 63 + 64 + if let Ok(data) = file.read_bytes().await { 65 + let bytes = Bytes::from(data); 66 + let mime_type = bytes 67 + .sniff_mime_type() 68 + .unwrap_or("application/octet-stream") 69 + .to_string(); 70 + 71 + let data_url = format!("data:{};base64,{}", mime_type, STANDARD.encode(&bytes)); 72 + 73 + pending_image.set(PendingImage { 74 + name: name.clone(), 75 + mime_type, 76 + data: bytes, 77 + data_url, 78 + }); 79 + alt_text.set(String::new()); 80 + show_dialog.set(true); 81 + } 82 + } 83 + }); 84 + }; 85 + 86 + let on_image_selected = props.on_image_selected.clone(); 87 + let confirm_upload = move |_| { 88 + let pending = pending_image(); 89 + let uploaded = UploadedImage { 90 + name: pending.name, 91 + alt: alt_text(), 92 + mime_type: pending.mime_type, 93 + data: pending.data, 94 + }; 95 + on_image_selected.call(uploaded); 96 + show_dialog.set(false); 97 + pending_image.set(PendingImage::default()); 98 + alt_text.set(String::new()); 99 + }; 100 + 101 + let cancel_upload = move |_| { 102 + show_dialog.set(false); 103 + pending_image.set(PendingImage::default()); 104 + alt_text.set(String::new()); 105 + }; 106 + 107 + rsx! { 108 + label { 109 + class: "toolbar-button", 110 + title: "Image", 111 + input { 112 + r#type: "file", 113 + accept: "image/*", 114 + style: "display: none;", 115 + disabled: props.disabled, 116 + onchange: on_file_change, 117 + } 118 + "🖼" 119 + } 120 + 121 + DialogRoot { 122 + open: show_dialog(), 123 + on_open_change: move |v| show_dialog.set(v), 124 + 125 + DialogContent { 126 + button { 127 + class: "dialog-close", 128 + r#type: "button", 129 + aria_label: "Close", 130 + tabindex: if show_dialog() { "0" } else { "-1" }, 131 + onclick: cancel_upload, 132 + "×" 133 + } 134 + 135 + DialogTitle { "Add Image" } 136 + 137 + div { class: "image-preview-container", 138 + img { 139 + class: "image-preview", 140 + src: "{pending_image().data_url}", 141 + alt: "Preview", 142 + } 143 + } 144 + 145 + div { class: "image-alt-input-container", 146 + label { 147 + r#for: "image-alt-text", 148 + "Alt text" 149 + } 150 + textarea { 151 + id: "image-alt-text", 152 + class: "image-alt-input", 153 + placeholder: "Describe this image for people who can't see it", 154 + value: "{alt_text}", 155 + oninput: move |e| alt_text.set(e.value()), 156 + rows: "3", 157 + } 158 + } 159 + 160 + div { class: "dialog-actions", 161 + Button { 162 + r#type: "button", 163 + onclick: cancel_upload, 164 + variant: ButtonVariant::Secondary, 165 + "Cancel" 166 + } 167 + Button { 168 + r#type: "button", 169 + onclick: confirm_upload, 170 + "Add Image" 171 + } 172 + } 173 + } 174 + } 175 + } 176 + }
+3 -1
crates/weaver-app/src/components/editor/mod.rs
··· 9 mod document; 10 mod dom_sync; 11 mod formatting; 12 mod input; 13 mod log_buffer; 14 mod offset_map; ··· 44 #[allow(unused_imports)] 45 pub use render::{RenderCache, render_paragraphs_incremental}; 46 #[allow(unused_imports)] 47 - pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; 48 49 // Storage 50 #[allow(unused_imports)] ··· 54 }; 55 56 // UI components 57 pub use publish::PublishButton; 58 pub use report::ReportButton; 59 #[allow(unused_imports)]
··· 9 mod document; 10 mod dom_sync; 11 mod formatting; 12 + mod image_upload; 13 mod input; 14 mod log_buffer; 15 mod offset_map; ··· 45 #[allow(unused_imports)] 46 pub use render::{RenderCache, render_paragraphs_incremental}; 47 #[allow(unused_imports)] 48 + pub use writer::{EditorImageResolver, ImageResolver, SyntaxSpanInfo, SyntaxType, WriterResult}; 49 50 // Storage 51 #[allow(unused_imports)] ··· 55 }; 56 57 // UI components 58 + pub use image_upload::{ImageUploadButton, UploadedImage}; 59 pub use publish::PublishButton; 60 pub use report::ReportButton; 61 #[allow(unused_imports)]
+116 -23
crates/weaver-app/src/components/editor/publish.rs
··· 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; ··· 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 ··· 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 ··· 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 ··· 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. ··· 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); ··· 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 = { ··· 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); ··· 198 // Get document snapshot for publishing 199 let doc_snapshot = doc(); 200 201 - match publish_entry(&fetcher, &doc_snapshot, &notebook, &draft_key).await { 202 Ok(result) => { 203 success_uri.set(Some(result.uri().clone())); 204 } ··· 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 ··· 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 {
··· 3 //! Handles creating/updating AT Protocol notebook entries from editor state. 4 5 use dioxus::prelude::*; 6 + use jacquard::types::ident::AtIdentifier; 7 + use jacquard::types::string::{AtUri, Datetime, Nsid}; 8 + use jacquard::{IntoStatic, prelude::*, to_data}; 9 + use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord}; 10 use weaver_api::sh_weaver::embed::images::Images; 11 use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; 12 use weaver_common::{WeaverError, WeaverExt}; 13 + 14 + const ENTRY_NSID: &str = "sh.weaver.notebook.entry"; 15 16 use crate::auth::AuthState; 17 use crate::fetch::Fetcher; ··· 38 39 /// Publish an entry to the AT Protocol. 40 /// 41 + /// Supports three modes: 42 + /// - With notebook_title: uses `upsert_entry` to publish to a notebook 43 + /// - Without notebook but with entry_uri in doc: uses `put_record` to update existing 44 + /// - Without notebook and no entry_uri: uses `create_record` for free-floating entry 45 + /// 46 /// # Arguments 47 /// * `fetcher` - The authenticated fetcher/client 48 /// * `doc` - The editor document containing entry data 49 + /// * `notebook_title` - Optional title of the notebook to publish to 50 /// * `draft_key` - Storage key for the draft (for cleanup) 51 /// 52 /// # Returns ··· 54 pub async fn publish_entry( 55 fetcher: &Fetcher, 56 doc: &EditorDocument, 57 + notebook_title: Option<&str>, 58 draft_key: &str, 59 ) -> Result<PublishResult, WeaverError> { 60 // Get images from the document ··· 105 .maybe_tags(tags) 106 .maybe_embeds(entry_embeds) 107 .build(); 108 + let entry_data = to_data(&entry).unwrap(); 109 110 let client = fetcher.get_client(); 111 + let result = if let Some(notebook) = notebook_title { 112 + // Publish to a notebook via upsert_entry 113 + let (uri, was_created) = client.upsert_entry(notebook, &doc.title(), entry).await?; 114 + 115 + if was_created { 116 + PublishResult::Created(uri) 117 + } else { 118 + PublishResult::Updated(uri) 119 + } 120 + } else if let Some(existing_uri) = doc.entry_uri() { 121 + // Update existing free-floating entry 122 + let did = fetcher 123 + .current_did() 124 + .await 125 + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 126 + 127 + let rkey = existing_uri 128 + .rkey() 129 + .ok_or_else(|| WeaverError::InvalidNotebook("Entry URI missing rkey".into()))?; 130 + 131 + let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?; 132 + 133 + let request = PutRecord::new() 134 + .repo(AtIdentifier::Did(did)) 135 + .collection(collection) 136 + .rkey(rkey.clone()) 137 + .record(entry_data) 138 + .build(); 139 + 140 + let response = fetcher 141 + .send(request) 142 + .await 143 + .map_err(jacquard::client::AgentError::from)?; 144 + let output = response 145 + .into_output() 146 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 147 + 148 + PublishResult::Updated(output.uri.into_static()) 149 + } else { 150 + // Create new free-floating entry 151 + let did = fetcher 152 + .current_did() 153 + .await 154 + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 155 + 156 + let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?; 157 + 158 + let request = CreateRecord::new() 159 + .repo(AtIdentifier::Did(did)) 160 + .collection(collection) 161 + .record(entry_data) 162 + .build(); 163 + 164 + let response = fetcher 165 + .send(request) 166 + .await 167 + .map_err(jacquard::client::AgentError::from)?; 168 + let output = response 169 + .into_output() 170 + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; 171 + 172 + PublishResult::Created(output.uri.into_static()) 173 + }; 174 175 // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive) 176 // TODO: Implement when image upload is added ··· 183 // Clear local draft 184 delete_draft(draft_key); 185 186 + Ok(result) 187 } 188 189 /// Simple slug generation from title. ··· 227 228 let mut show_dialog = use_signal(|| false); 229 let mut notebook_title = use_signal(|| String::from("Default")); 230 + let mut use_notebook = use_signal(|| true); 231 let mut is_publishing = use_signal(|| false); 232 let mut error_message: Signal<Option<String>> = use_signal(|| None); 233 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); ··· 235 let is_authenticated = auth_state.read().is_authenticated(); 236 let doc = props.document; 237 let draft_key = props.draft_key.clone(); 238 + 239 + // Check if we're editing an existing entry 240 + let is_editing_existing = doc().entry_uri().is_some(); 241 242 // Validate that we have required fields 243 let can_publish = { ··· 259 let do_publish = move |_| { 260 let fetcher = fetcher.clone(); 261 let draft_key = draft_key_clone.clone(); 262 + let notebook = if use_notebook() { 263 + Some(notebook_title()) 264 + } else { 265 + None 266 + }; 267 268 spawn(async move { 269 is_publishing.set(true); ··· 272 // Get document snapshot for publishing 273 let doc_snapshot = doc(); 274 275 + match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await { 276 Ok(result) => { 277 success_uri.set(Some(result.uri().clone())); 278 } ··· 327 } 328 } else { 329 div { class: "publish-form", 330 + if is_editing_existing { 331 + div { class: "publish-info", 332 + p { "Updating existing entry" } 333 + } 334 + } 335 + 336 + div { class: "publish-field publish-checkbox", 337 + label { 338 + input { 339 + r#type: "checkbox", 340 + checked: use_notebook(), 341 + onchange: move |e| use_notebook.set(e.checked()), 342 + } 343 + " Publish to notebook" 344 + } 345 + } 346 + 347 + if use_notebook() { 348 + div { class: "publish-field", 349 + label { "Notebook" } 350 + input { 351 + r#type: "text", 352 + class: "publish-input", 353 + placeholder: "Notebook title...", 354 + value: "{notebook_title}", 355 + oninput: move |e| notebook_title.set(e.value()), 356 + } 357 } 358 } 359 ··· 381 button { 382 class: "publish-submit", 383 onclick: do_publish, 384 + disabled: is_publishing() || (use_notebook() && notebook_title().trim().is_empty()), 385 if is_publishing() { 386 "Publishing..." 387 } else {
+8 -2
crates/weaver-app/src/components/editor/render.rs
··· 7 use super::document::EditInfo; 8 use super::offset_map::{OffsetMapping, RenderResult}; 9 use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string}; 10 - use super::writer::{EditorWriter, SyntaxSpanInfo}; 11 use loro::LoroText; 12 use markdown_weaver::Parser; 13 use std::ops::Range; ··· 112 /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. 113 /// 114 /// # Arguments 115 - /// - `rope`: The document rope to render 116 /// - `cache`: Previous render cache (if any) 117 /// - `edit`: Information about the most recent edit (if any) 118 /// 119 /// # Returns 120 /// Tuple of (rendered paragraphs, updated cache) ··· 122 text: &LoroText, 123 cache: Option<&RenderCache>, 124 edit: Option<&EditInfo>, 125 ) -> (Vec<ParagraphRender>, RenderCache) { 126 let source = text.to_string(); 127 ··· 297 .into_offset_iter(); 298 let mut output = String::new(); 299 300 let (mut offset_map, mut syntax_spans) = 301 match EditorWriter::<_, _, ()>::new_with_offsets( 302 &para_source, ··· 306 node_id_offset, 307 syn_id_offset, 308 ) 309 .run() 310 { 311 Ok(result) => {
··· 7 use super::document::EditInfo; 8 use super::offset_map::{OffsetMapping, RenderResult}; 9 use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string}; 10 + use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; 11 use loro::LoroText; 12 use markdown_weaver::Parser; 13 use std::ops::Range; ··· 112 /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. 113 /// 114 /// # Arguments 115 + /// - `text`: The document text to render 116 /// - `cache`: Previous render cache (if any) 117 /// - `edit`: Information about the most recent edit (if any) 118 + /// - `image_resolver`: Optional resolver for mapping image URLs to data/CDN URLs 119 /// 120 /// # Returns 121 /// Tuple of (rendered paragraphs, updated cache) ··· 123 text: &LoroText, 124 cache: Option<&RenderCache>, 125 edit: Option<&EditInfo>, 126 + image_resolver: Option<&EditorImageResolver>, 127 ) -> (Vec<ParagraphRender>, RenderCache) { 128 let source = text.to_string(); 129 ··· 299 .into_offset_iter(); 300 let mut output = String::new(); 301 302 + // Use provided resolver or empty default 303 + let resolver = image_resolver.cloned().unwrap_or_default(); 304 + 305 let (mut offset_map, mut syntax_spans) = 306 match EditorWriter::<_, _, ()>::new_with_offsets( 307 &para_source, ··· 311 node_id_offset, 312 syn_id_offset, 313 ) 314 + .with_image_resolver(&resolver) 315 .run() 316 { 317 Ok(result) => {
+14 -9
crates/weaver-app/src/components/editor/storage.rs
··· 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 use gloo_storage::{LocalStorage, Storage}; 14 use loro::cursor::Cursor; 15 use serde::{Deserialize, Serialize}; 16 ··· 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() { ··· 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 } ··· 103 pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 104 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 105 106 // Try to restore from CRDT snapshot first 107 if let Some(ref snapshot_b64) = snapshot.snapshot { 108 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { 109 - let doc = EditorDocument::from_snapshot( 110 &snapshot_bytes, 111 snapshot.cursor.clone(), 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"); ··· 123 let mut doc = EditorDocument::new(snapshot.content); 124 doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); 125 doc.sync_loro_cursor(); 126 Some(doc) 127 } 128 ··· 176 177 // 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 } 186
··· 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 use gloo_storage::{LocalStorage, Storage}; 14 + use jacquard::IntoStatic; 15 + use jacquard::types::string::AtUri; 16 use loro::cursor::Cursor; 17 use serde::{Deserialize, Serialize}; 18 ··· 69 /// # Arguments 70 /// * `doc` - The editor document to save 71 /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 72 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 73 pub fn save_to_storage( 74 doc: &EditorDocument, 75 key: &str, 76 ) -> Result<(), gloo_storage::errors::StorageError> { 77 let snapshot_bytes = doc.export_snapshot(); 78 let snapshot_b64 = if snapshot_bytes.is_empty() { ··· 87 snapshot: snapshot_b64, 88 cursor: doc.loro_cursor().cloned(), 89 cursor_offset: doc.cursor.offset, 90 + editing_uri: doc.entry_uri().map(|u| u.to_string()), 91 }; 92 LocalStorage::set(storage_key(key), &snapshot) 93 } ··· 103 pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 104 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 105 106 + // Parse entry_uri from the snapshot 107 + let entry_uri = snapshot 108 + .editing_uri 109 + .as_ref() 110 + .and_then(|s| AtUri::new(s).ok()) 111 + .map(|u| u.into_static()); 112 + 113 // Try to restore from CRDT snapshot first 114 if let Some(ref snapshot_b64) = snapshot.snapshot { 115 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { 116 + let mut doc = EditorDocument::from_snapshot( 117 &snapshot_bytes, 118 snapshot.cursor.clone(), 119 snapshot.cursor_offset, 120 ); 121 // Verify the content matches (sanity check) 122 if doc.content() == snapshot.content { 123 + doc.set_entry_uri(entry_uri); 124 return Some(doc); 125 } 126 tracing::warn!("Snapshot content mismatch, falling back to text content"); ··· 131 let mut doc = EditorDocument::new(snapshot.content); 132 doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); 133 doc.sync_loro_cursor(); 134 + doc.set_entry_uri(entry_uri); 135 Some(doc) 136 } 137 ··· 185 186 // Stub implementations for non-WASM targets 187 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 188 + pub fn save_to_storage(_doc: &EditorDocument, _key: &str) -> Result<(), String> { 189 Ok(()) 190 } 191
+7 -7
crates/weaver-app/src/components/editor/tests.rs
··· 57 let doc = LoroDoc::new(); 58 let text = doc.get_text("content"); 59 text.insert(0, input).unwrap(); 60 - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 61 paragraphs.iter().map(TestParagraph::from).collect() 62 } 63 ··· 645 646 // Initial state: "#" is a valid empty heading 647 text.insert(0, "#").unwrap(); 648 - let (paras1, cache1) = render_paragraphs_incremental(&text, None, None); 649 650 eprintln!("State 1 ('#'): {}", paras1[0].html); 651 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); ··· 656 657 // Transition: add "t" to make "#t" - no longer a heading 658 text.insert(1, "t").unwrap(); 659 - let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None); 660 661 eprintln!("State 2 ('#t'): {}", paras2[0].html); 662 assert!( ··· 765 let doc = LoroDoc::new(); 766 let text = doc.get_text("content"); 767 text.insert(0, input).unwrap(); 768 - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 769 770 // With standard \n\n break, we expect 2 paragraphs (no gap element) 771 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 794 let doc = LoroDoc::new(); 795 let text = doc.get_text("content"); 796 text.insert(0, input).unwrap(); 797 - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 798 799 // With extra newlines, we expect 3 elements: para, gap, para 800 assert_eq!( ··· 894 let text = doc.get_text("content"); 895 text.insert(0, input).unwrap(); 896 897 - let (paras1, cache1) = render_paragraphs_incremental(&text, None, None); 898 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 899 900 // Second render with same content should reuse cache 901 - let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None); 902 903 // Should produce identical output 904 assert_eq!(paras1.len(), paras2.len());
··· 57 let doc = LoroDoc::new(); 58 let text = doc.get_text("content"); 59 text.insert(0, input).unwrap(); 60 + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); 61 paragraphs.iter().map(TestParagraph::from).collect() 62 } 63 ··· 645 646 // Initial state: "#" is a valid empty heading 647 text.insert(0, "#").unwrap(); 648 + let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None); 649 650 eprintln!("State 1 ('#'): {}", paras1[0].html); 651 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); ··· 656 657 // Transition: add "t" to make "#t" - no longer a heading 658 text.insert(1, "t").unwrap(); 659 + let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None); 660 661 eprintln!("State 2 ('#t'): {}", paras2[0].html); 662 assert!( ··· 765 let doc = LoroDoc::new(); 766 let text = doc.get_text("content"); 767 text.insert(0, input).unwrap(); 768 + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); 769 770 // With standard \n\n break, we expect 2 paragraphs (no gap element) 771 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 794 let doc = LoroDoc::new(); 795 let text = doc.get_text("content"); 796 text.insert(0, input).unwrap(); 797 + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); 798 799 // With extra newlines, we expect 3 elements: para, gap, para 800 assert_eq!( ··· 894 let text = doc.get_text("content"); 895 text.insert(0, input).unwrap(); 896 897 + let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None); 898 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 899 900 // Second render with same content should reuse cache 901 + let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None); 902 903 // Should produce identical output 904 assert_eq!(paras1.len(), paras2.len());
+7 -6
crates/weaver-app/src/components/editor/toolbar.rs
··· 1 //! Editor toolbar component with formatting buttons. 2 3 use super::formatting::FormatAction; 4 use dioxus::prelude::*; 5 6 /// Editor toolbar with formatting buttons. 7 /// 8 /// Provides buttons for common markdown formatting operations. 9 #[component] 10 - pub fn EditorToolbar(on_format: EventHandler<FormatAction>) -> Element { 11 rsx! { 12 div { class: "editor-toolbar", 13 button { ··· 85 onclick: move |_| on_format.call(FormatAction::Link), 86 "🔗" 87 } 88 - button { 89 - class: "toolbar-button", 90 - title: "Image", 91 - onclick: move |_| on_format.call(FormatAction::Image), 92 - "🖼" 93 } 94 } 95 }
··· 1 //! Editor toolbar component with formatting buttons. 2 3 use super::formatting::FormatAction; 4 + use super::image_upload::{ImageUploadButton, UploadedImage}; 5 use dioxus::prelude::*; 6 7 /// Editor toolbar with formatting buttons. 8 /// 9 /// Provides buttons for common markdown formatting operations. 10 #[component] 11 + pub fn EditorToolbar( 12 + on_format: EventHandler<FormatAction>, 13 + on_image: EventHandler<UploadedImage>, 14 + ) -> Element { 15 rsx! { 16 div { class: "editor-toolbar", 17 button { ··· 89 onclick: move |_| on_format.call(FormatAction::Link), 90 "🔗" 91 } 92 + ImageUploadButton { 93 + on_image_selected: move |img| on_image.call(img), 94 } 95 } 96 }
+203 -7
crates/weaver-app/src/components/editor/writer.rs
··· 103 } 104 } 105 106 /// HTML writer that preserves markdown formatting characters. 107 /// 108 /// This writer processes offset-iter events to detect gaps (consumed formatting) 109 /// and emits them as styled spans for visibility in the editor. 110 - pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = ()> { 111 source: &'a str, 112 source_text: &'a LoroText, 113 events: I, ··· 125 numbers: HashMap<String, usize>, 126 127 embed_provider: Option<E>, 128 129 code_buffer: Option<(Option<String>, String)>, // (lang, content) 130 code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content ··· 172 Body, 173 } 174 175 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider> 176 - EditorWriter<'a, I, W, E> 177 { 178 pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { 179 Self::new_with_node_offset(source, source_text, events, writer, 0) ··· 211 table_cell_index: 0, 212 numbers: HashMap::new(), 213 embed_provider: None, 214 code_buffer: None, 215 code_buffer_byte_range: None, 216 code_buffer_char_range: None, ··· 256 table_cell_index: 0, 257 numbers: HashMap::new(), 258 embed_provider: None, 259 code_buffer: None, 260 code_buffer_byte_range: None, 261 code_buffer_char_range: None, ··· 280 } 281 282 /// Add an embed content provider 283 - pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> { 284 EditorWriter { 285 source: self.source, 286 source_text: self.source_text, ··· 295 table_cell_index: self.table_cell_index, 296 numbers: self.numbers, 297 embed_provider: Some(provider), 298 code_buffer: self.code_buffer, 299 code_buffer_byte_range: self.code_buffer_byte_range, 300 code_buffer_char_range: self.code_buffer_char_range, ··· 1753 } 1754 1755 self.write("<img src=\"")?; 1756 - escape_href(&mut self.writer, &dest_url)?; 1757 self.write("\" alt=\"")?; 1758 // Consume text events for alt attribute 1759 self.raw_text()?; ··· 2139 } 2140 } 2141 2142 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider> 2143 - EditorWriter<'a, I, W, E> 2144 { 2145 fn write_embed( 2146 &mut self,
··· 103 } 104 } 105 106 + /// Resolves image URLs to CDN URLs based on stored images. 107 + /// 108 + /// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). 109 + /// This trait maps those names to the actual CDN URL using the blob CID and owner DID. 110 + pub trait ImageResolver { 111 + /// Resolve an image URL from markdown to a CDN URL. 112 + /// 113 + /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. 114 + fn resolve_image_url(&self, url: &str) -> Option<String>; 115 + } 116 + 117 + impl ImageResolver for () { 118 + fn resolve_image_url(&self, _url: &str) -> Option<String> { 119 + None 120 + } 121 + } 122 + 123 + /// Concrete image resolver that maps image names to URLs. 124 + /// 125 + /// Supports two states for images: 126 + /// - Pending: uses data URL for immediate preview while upload is in progress 127 + /// - Uploaded: uses CDN URL format `https://cdn.bsky.app/img/feed_fullsize/plain/{did}/{cid}@{format}` 128 + /// 129 + /// Image URLs in markdown use the format `/image/{name}`. 130 + #[derive(Clone, Default)] 131 + pub struct EditorImageResolver { 132 + /// Pending images: name -> data URL (still uploading) 133 + pending: std::collections::HashMap<String, String>, 134 + /// Uploaded images: name -> (CID string, DID string, format) 135 + uploaded: std::collections::HashMap<String, (String, String, String)>, 136 + } 137 + 138 + impl EditorImageResolver { 139 + pub fn new() -> Self { 140 + Self::default() 141 + } 142 + 143 + /// Add a pending image with a data URL for immediate preview. 144 + /// 145 + /// # Arguments 146 + /// * `name` - The image name used in markdown (e.g., "photo.jpg") 147 + /// * `data_url` - The base64 data URL for preview 148 + pub fn add_pending(&mut self, name: String, data_url: String) { 149 + self.pending.insert(name, data_url); 150 + } 151 + 152 + /// Promote a pending image to uploaded status. 153 + /// 154 + /// Removes from pending and adds to uploaded with CDN info. 155 + pub fn promote_to_uploaded(&mut self, name: &str, cid: String, did: String, format: String) { 156 + self.pending.remove(name); 157 + self.uploaded.insert(name.to_string(), (cid, did, format)); 158 + } 159 + 160 + /// Add an already-uploaded image. 161 + /// 162 + /// # Arguments 163 + /// * `name` - The name/URL used in markdown (e.g., "photo.jpg") 164 + /// * `cid` - The blob CID 165 + /// * `did` - The DID of the blob owner 166 + /// * `format` - The image format (e.g., "jpeg", "png") 167 + pub fn add_uploaded(&mut self, name: String, cid: String, did: String, format: String) { 168 + self.uploaded.insert(name, (cid, did, format)); 169 + } 170 + 171 + /// Check if an image is pending upload. 172 + pub fn is_pending(&self, name: &str) -> bool { 173 + self.pending.contains_key(name) 174 + } 175 + 176 + /// Build a resolver from editor images and user DID. 177 + pub fn from_images<'a>( 178 + images: impl IntoIterator<Item = &'a super::document::EditorImage>, 179 + user_did: &str, 180 + ) -> Self { 181 + let mut resolver = Self::new(); 182 + for editor_image in images { 183 + // Get the name from the Image (use alt text as fallback if name is empty) 184 + let name = editor_image 185 + .image 186 + .name 187 + .as_ref() 188 + .map(|n| n.to_string()) 189 + .unwrap_or_else(|| editor_image.image.alt.to_string()); 190 + 191 + if name.is_empty() { 192 + continue; 193 + } 194 + 195 + // Get CID and format from the blob ref 196 + let blob = editor_image.image.image.blob(); 197 + let cid = blob.cid().to_string(); 198 + let format = blob 199 + .mime_type 200 + .0 201 + .strip_prefix("image/") 202 + .unwrap_or("jpeg") 203 + .to_string(); 204 + 205 + resolver.add_uploaded(name, cid, user_did.to_string(), format); 206 + } 207 + resolver 208 + } 209 + } 210 + 211 + impl ImageResolver for EditorImageResolver { 212 + fn resolve_image_url(&self, url: &str) -> Option<String> { 213 + // Extract image name from /image/{name} format 214 + let name = url.strip_prefix("/image/").unwrap_or(url); 215 + 216 + // Check pending first (data URL for immediate preview) 217 + if let Some(data_url) = self.pending.get(name) { 218 + return Some(data_url.clone()); 219 + } 220 + 221 + // Then check uploaded (CDN URL) 222 + let (cid, did, format) = self.uploaded.get(name)?; 223 + Some(format!( 224 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", 225 + did, cid, format 226 + )) 227 + } 228 + } 229 + 230 + impl ImageResolver for &EditorImageResolver { 231 + fn resolve_image_url(&self, url: &str) -> Option<String> { 232 + (*self).resolve_image_url(url) 233 + } 234 + } 235 + 236 /// HTML writer that preserves markdown formatting characters. 237 /// 238 /// This writer processes offset-iter events to detect gaps (consumed formatting) 239 /// and emits them as styled spans for visibility in the editor. 240 + pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = (), R = ()> { 241 source: &'a str, 242 source_text: &'a LoroText, 243 events: I, ··· 255 numbers: HashMap<String, usize>, 256 257 embed_provider: Option<E>, 258 + image_resolver: Option<R>, 259 260 code_buffer: Option<(Option<String>, String)>, // (lang, content) 261 code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content ··· 303 Body, 304 } 305 306 + impl< 307 + 'a, 308 + I: Iterator<Item = (Event<'a>, Range<usize>)>, 309 + W: StrWrite, 310 + E: EmbedContentProvider, 311 + R: ImageResolver, 312 + > EditorWriter<'a, I, W, E, R> 313 { 314 pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { 315 Self::new_with_node_offset(source, source_text, events, writer, 0) ··· 347 table_cell_index: 0, 348 numbers: HashMap::new(), 349 embed_provider: None, 350 + image_resolver: None, 351 code_buffer: None, 352 code_buffer_byte_range: None, 353 code_buffer_char_range: None, ··· 393 table_cell_index: 0, 394 numbers: HashMap::new(), 395 embed_provider: None, 396 + image_resolver: None, 397 code_buffer: None, 398 code_buffer_byte_range: None, 399 code_buffer_char_range: None, ··· 418 } 419 420 /// Add an embed content provider 421 + pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E, R> { 422 EditorWriter { 423 source: self.source, 424 source_text: self.source_text, ··· 433 table_cell_index: self.table_cell_index, 434 numbers: self.numbers, 435 embed_provider: Some(provider), 436 + image_resolver: self.image_resolver, 437 + code_buffer: self.code_buffer, 438 + code_buffer_byte_range: self.code_buffer_byte_range, 439 + code_buffer_char_range: self.code_buffer_char_range, 440 + pending_blockquote_range: self.pending_blockquote_range, 441 + render_tables_as_markdown: self.render_tables_as_markdown, 442 + table_start_offset: self.table_start_offset, 443 + offset_maps: self.offset_maps, 444 + next_node_id: self.next_node_id, 445 + current_node_id: self.current_node_id, 446 + current_node_char_offset: self.current_node_char_offset, 447 + current_node_child_count: self.current_node_child_count, 448 + utf16_checkpoints: self.utf16_checkpoints, 449 + paragraph_ranges: self.paragraph_ranges, 450 + current_paragraph_start: self.current_paragraph_start, 451 + list_depth: self.list_depth, 452 + boundary_only: self.boundary_only, 453 + syntax_spans: self.syntax_spans, 454 + next_syn_id: self.next_syn_id, 455 + pending_inline_formats: self.pending_inline_formats, 456 + _phantom: std::marker::PhantomData, 457 + } 458 + } 459 + 460 + /// Add an image resolver for mapping markdown image URLs to CDN URLs 461 + pub fn with_image_resolver<R2: ImageResolver>( 462 + self, 463 + resolver: R2, 464 + ) -> EditorWriter<'a, I, W, E, R2> { 465 + EditorWriter { 466 + source: self.source, 467 + source_text: self.source_text, 468 + events: self.events, 469 + writer: self.writer, 470 + last_byte_offset: self.last_byte_offset, 471 + last_char_offset: self.last_char_offset, 472 + end_newline: self.end_newline, 473 + in_non_writing_block: self.in_non_writing_block, 474 + table_state: self.table_state, 475 + table_alignments: self.table_alignments, 476 + table_cell_index: self.table_cell_index, 477 + numbers: self.numbers, 478 + embed_provider: self.embed_provider, 479 + image_resolver: Some(resolver), 480 code_buffer: self.code_buffer, 481 code_buffer_byte_range: self.code_buffer_byte_range, 482 code_buffer_char_range: self.code_buffer_char_range, ··· 1935 } 1936 1937 self.write("<img src=\"")?; 1938 + // Try to resolve image URL via resolver, fall back to original 1939 + let resolved_url = self 1940 + .image_resolver 1941 + .as_ref() 1942 + .and_then(|r| r.resolve_image_url(&dest_url)); 1943 + if let Some(ref cdn_url) = resolved_url { 1944 + escape_href(&mut self.writer, cdn_url)?; 1945 + } else { 1946 + escape_href(&mut self.writer, &dest_url)?; 1947 + } 1948 self.write("\" alt=\"")?; 1949 // Consume text events for alt attribute 1950 self.raw_text()?; ··· 2330 } 2331 } 2332 2333 + impl< 2334 + 'a, 2335 + I: Iterator<Item = (Event<'a>, Range<usize>)>, 2336 + W: StrWrite, 2337 + E: EmbedContentProvider, 2338 + R: ImageResolver, 2339 + > EditorWriter<'a, I, W, E, R> 2340 { 2341 fn write_embed( 2342 &mut self,
+4 -4
crates/weaver-app/src/env.rs
··· 1 // This file is automatically generated by build.rs 2 3 #[allow(unused)] 4 - pub const WEAVER_APP_ENV: &'static str = "prod"; 5 #[allow(unused)] 6 - pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh"; 7 #[allow(unused)] 8 - pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh"; 9 #[allow(unused)] 10 pub const WEAVER_PORT: &'static str = "8080"; 11 #[allow(unused)] ··· 13 #[allow(unused)] 14 pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; 15 #[allow(unused)] 16 - pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; 17 #[allow(unused)] 18 pub const WEAVER_TOS_URI: &'static str = ""; 19 #[allow(unused)]
··· 1 // This file is automatically generated by build.rs 2 3 #[allow(unused)] 4 + pub const WEAVER_APP_ENV: &'static str = "dev"; 5 #[allow(unused)] 6 + pub const WEAVER_APP_HOST: &'static str = "http://localhost"; 7 #[allow(unused)] 8 + pub const WEAVER_APP_DOMAIN: &'static str = ""; 9 #[allow(unused)] 10 pub const WEAVER_PORT: &'static str = "8080"; 11 #[allow(unused)] ··· 13 #[allow(unused)] 14 pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; 15 #[allow(unused)] 16 + pub const WEAVER_LOGO_URI: &'static str = ""; 17 #[allow(unused)] 18 pub const WEAVER_TOS_URI: &'static str = ""; 19 #[allow(unused)]