From 468ca19df31819de98e03354107645890d3d74d6 Mon Sep 17 00:00:00 2001 From: Orual Date: Thu, 27 Nov 2025 12:36:38 -0500 Subject: [PATCH] basic thing works, really bad perf --- .gitignore | 1 + crates/weaver-app/.env-example | 10 - crates/weaver-app/.env-prod | 10 + crates/weaver-app/assets/styling/editor.css | 47 ++++ .../src/components/editor/component.rs | 128 ++++++++++- .../src/components/editor/document.rs | 20 ++ .../src/components/editor/image_upload.rs | 176 +++++++++++++++ .../weaver-app/src/components/editor/mod.rs | 4 +- .../src/components/editor/publish.rs | 139 ++++++++++-- .../src/components/editor/render.rs | 10 +- .../src/components/editor/storage.rs | 23 +- .../weaver-app/src/components/editor/tests.rs | 14 +- .../src/components/editor/toolbar.rs | 13 +- .../src/components/editor/writer.rs | 210 +++++++++++++++++- crates/weaver-app/src/env.rs | 8 +- 15 files changed, 740 insertions(+), 73 deletions(-) delete mode 100644 crates/weaver-app/.env-example create mode 100644 crates/weaver-app/.env-prod create mode 100644 crates/weaver-app/src/components/editor/image_upload.rs diff --git a/.gitignore b/.gitignore index 69b779c..c51b84d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ grant_proposal.md **/.obsidian **/.trash **/bug_notes.md +/opencodetmp diff --git a/crates/weaver-app/.env-example b/crates/weaver-app/.env-example deleted file mode 100644 index 5d82bc4..0000000 --- a/crates/weaver-app/.env-example +++ /dev/null @@ -1,10 +0,0 @@ -WEAVER_APP_ENV="dev" -WEAVER_APP_HOST="http://localhost" -WEAVER_APP_DOMAIN="" -WEAVER_PORT=8080 -WEAVER_APP_SCOPES="atproto transition:generic" -WEAVER_CLIENT_NAME="Weaver" - -WEAVER_LOGO_URI="" -WEAVER_TOS_URI="" -WEAVER_PRIVACY_POLICY_URI="" diff --git a/crates/weaver-app/.env-prod b/crates/weaver-app/.env-prod new file mode 100644 index 0000000..76ce3db --- /dev/null +++ b/crates/weaver-app/.env-prod @@ -0,0 +1,10 @@ +WEAVER_APP_ENV="prod" +WEAVER_APP_HOST="https://alpha.weaver.sh" +WEAVER_APP_DOMAIN="https://alpha.weaver.sh" +WEAVER_PORT=8080 +WEAVER_APP_SCOPES="atproto transition:generic" +WEAVER_CLIENT_NAME="Weaver" + +WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico" +WEAVER_TOS_URI="" +WEAVER_PRIVACY_POLICY_URI="" diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index f37c14c..2c79053 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -565,3 +565,50 @@ li:has(.md-syntax-block:not(.hidden)) { opacity: 0.5; cursor: not-allowed; } + +/* Image upload dialog */ +.image-preview-container { + display: flex; + justify-content: center; + margin-bottom: 1rem; +} + +.image-preview { + max-width: 100%; + max-height: 300px; + border-radius: 4px; + object-fit: contain; +} + +.image-alt-input-container { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.image-alt-input-container label { + font-weight: 500; + color: var(--color-text); +} + +.image-alt-input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-body); + resize: vertical; +} + +.image-alt-input::placeholder { + color: var(--color-muted); +} + +.dialog-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 402fb84..2fb25ae 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -1,8 +1,14 @@ //! The main MarkdownEditor component. use dioxus::prelude::*; +use jacquard::cowstr::ToCowStr; +use jacquard::types::blob::BlobRef; +use weaver_api::sh_weaver::embed::images::Image; +use weaver_common::WeaverExt; +use crate::auth::AuthState; use crate::components::editor::ReportButton; +use crate::fetch::Fetcher; use super::document::{CompositionState, EditorDocument}; use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; @@ -17,7 +23,7 @@ use super::render; use super::storage; use super::toolbar::EditorToolbar; use super::visibility::update_syntax_visibility; -use super::writer::SyntaxSpanInfo; +use super::writer::{EditorImageResolver, SyntaxSpanInfo}; /// Main markdown editor component. /// @@ -32,6 +38,10 @@ use super::writer::SyntaxSpanInfo; /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) #[component] pub fn MarkdownEditor(initial_content: Option) -> Element { + // Context for authenticated API calls + let fetcher = use_context::(); + let auth_state = use_context::>(); + // Try to restore from localStorage (includes CRDT state for undo history) // Use "current" as the default draft key for now let draft_key = "current"; @@ -44,14 +54,18 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Cache for incremental paragraph rendering let mut render_cache = use_signal(|| render::RenderCache::default()); + // Image resolver for mapping /image/{name} to data URLs or CDN URLs + let mut image_resolver = use_signal(EditorImageResolver::default); + // Render paragraphs with incremental caching let paragraphs = use_memo(move || { let doc = document(); let cache = render_cache.peek(); let edit = doc.last_edit.as_ref(); + let resolver = image_resolver(); let (paras, new_cache) = - render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); + render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit, Some(&resolver)); // Update cache for next render (write-only via spawn to avoid reactive loop) dioxus::prelude::spawn(async move { @@ -161,7 +175,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { let needs_save = { let last_frontiers = last_saved_frontiers.peek(); match &*last_frontiers { - None => true, // First save + None => true, Some(last) => ¤t_frontiers != last, } }; // drop last_frontiers borrow here @@ -169,7 +183,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { if needs_save { document.with_mut(|doc| { doc.sync_loro_cursor(); - let _ = storage::save_to_storage(doc, draft_key, None); + let _ = storage::save_to_storage(doc, draft_key); }); // Update last saved frontiers @@ -650,6 +664,112 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { document.with_mut(|doc| { formatting::apply_formatting(doc, action); }); + }, + on_image: move |uploaded: super::image_upload::UploadedImage| { + // Build data URL for immediate preview + use base64::{Engine, engine::general_purpose::STANDARD}; + let data_url = format!( + "data:{};base64,{}", + uploaded.mime_type, + STANDARD.encode(&uploaded.data) + ); + + // Add to resolver for immediate display + let name = uploaded.name.clone(); + image_resolver.with_mut(|resolver| { + resolver.add_pending(name.clone(), data_url); + }); + + // Insert markdown image syntax at cursor + let alt_text = if uploaded.alt.is_empty() { + name.clone() + } else { + uploaded.alt.clone() + }; + let markdown = format!("![{}](/image/{})", alt_text, name); + + document.with_mut(|doc| { + let pos = doc.cursor.offset; + let _ = doc.insert_tracked(pos, &markdown); + doc.cursor.offset = pos + markdown.chars().count(); + }); + + // Upload to PDS in background if authenticated + let is_authenticated = auth_state.read().is_authenticated(); + if is_authenticated { + let fetcher = fetcher.clone(); + let name_for_upload = name.clone(); + let alt_for_upload = alt_text.clone(); + let data = uploaded.data.clone(); + + spawn(async move { + let client = fetcher.get_client(); + + // Upload blob and create temporary PublishedBlob record + match client.publish_blob(data, &name_for_upload, None).await { + Ok((strong_ref, published_blob)) => { + // Extract the blob from PublishedBlob + let blob = match published_blob.upload { + BlobRef::Blob(b) => b, + _ => { + tracing::warn!("Unexpected BlobRef variant"); + return; + } + }; + + // Get format from mime type + let format = blob + .mime_type + .0 + .strip_prefix("image/") + .unwrap_or("jpeg") + .to_string(); + + // Get DID from fetcher + let did = match fetcher.current_did().await { + Some(d) => d.to_string(), + None => { + tracing::warn!("No DID available"); + return; + } + }; + + let cid = blob.cid().to_string(); + + // Build Image using the builder API + let name_for_resolver = name_for_upload.clone(); + let image = Image::new() + .alt(alt_for_upload.to_cowstr()) + .image(BlobRef::Blob(blob)) + .name(name_for_upload.to_cowstr()) + .build(); + + // Add to document + document.with_mut(|doc| { + doc.add_image(&image, Some(&strong_ref.uri)); + }); + + // Promote from pending to uploaded in resolver + image_resolver.with_mut(|resolver| { + resolver.promote_to_uploaded( + &name_for_resolver, + cid, + did, + format, + ); + }); + + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); + } + Err(e) => { + tracing::error!(error = %e, "Failed to upload image"); + // Image stays as data URL - will work for preview but not publish + } + } + }); + } else { + tracing::info!(name = %name, "Image added with data URL (not authenticated)"); + } } } diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 7797330..3d6aad2 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -55,6 +55,11 @@ pub struct EditorDocument { /// Contains nested containers: images (LoroList), externals (LoroList), etc. embeds: LoroMap, + // --- Entry tracking --- + /// AT-URI of the entry if editing an existing record. + /// None for new entries that haven't been published yet. + entry_uri: Option>, + // --- Editor state --- /// Undo manager for the document. undo_mgr: UndoManager, @@ -205,6 +210,7 @@ impl EditorDocument { created_at, tags, embeds, + entry_uri: None, undo_mgr, cursor: CursorState { offset: 0, @@ -314,6 +320,18 @@ impl EditorDocument { self.created_at.insert(0, datetime).ok(); } + // --- Entry URI accessors --- + + /// Get the AT-URI of the entry if editing an existing record. + pub fn entry_uri(&self) -> Option<&AtUri<'static>> { + self.entry_uri.as_ref() + } + + /// Set the AT-URI when editing an existing entry. + pub fn set_entry_uri(&mut self, uri: Option>) { + self.entry_uri = uri; + } + // --- Tags accessors --- /// Get all tags as a vector of strings. @@ -690,6 +708,7 @@ impl EditorDocument { created_at, tags, embeds, + entry_uri: None, undo_mgr, cursor, loro_cursor, @@ -718,6 +737,7 @@ impl Clone for EditorDocument { new_doc.composition = self.composition.clone(); new_doc.composition_ended_at = self.composition_ended_at; new_doc.last_edit = self.last_edit.clone(); + new_doc.entry_uri = self.entry_uri.clone(); new_doc } } diff --git a/crates/weaver-app/src/components/editor/image_upload.rs b/crates/weaver-app/src/components/editor/image_upload.rs new file mode 100644 index 0000000..1cc13b6 --- /dev/null +++ b/crates/weaver-app/src/components/editor/image_upload.rs @@ -0,0 +1,176 @@ +//! Image upload component for the markdown editor. +//! +//! Provides file picker and upload functionality for adding images to entries. +//! Shows a preview dialog with alt text input before confirming the upload. + +use base64::{Engine, engine::general_purpose::STANDARD}; +use dioxus::prelude::*; +use jacquard::bytes::Bytes; +use mime_sniffer::MimeTypeSniffer; + +use crate::components::{ + button::{Button, ButtonVariant}, + dialog::{DialogContent, DialogRoot, DialogTitle}, +}; + +/// Result of an image upload operation. +#[derive(Clone, Debug)] +pub struct UploadedImage { + /// The filename (used as the markdown reference name) + pub name: String, + /// Alt text for accessibility + pub alt: String, + /// MIME type of the image (sniffed from bytes) + pub mime_type: String, + /// Raw image bytes + pub data: Bytes, +} + +/// Pending image data before user confirms with alt text. +#[derive(Clone, Default)] +struct PendingImage { + name: String, + mime_type: String, + data: Bytes, + data_url: String, +} + +/// Props for the ImageUploadButton component. +#[derive(Props, Clone, PartialEq)] +pub struct ImageUploadButtonProps { + /// Callback when an image is selected and confirmed with alt text + pub on_image_selected: EventHandler, + /// Whether the button is disabled + #[props(default = false)] + pub disabled: bool, +} + +/// A button that opens a file picker for image selection. +/// +/// When a file is selected, shows a preview dialog with alt text input. +/// Only triggers the callback after user confirms. +#[component] +pub fn ImageUploadButton(props: ImageUploadButtonProps) -> Element { + let mut show_dialog = use_signal(|| false); + let mut pending_image = use_signal(PendingImage::default); + let mut alt_text = use_signal(String::new); + + let on_file_change = move |evt: Event| { + spawn(async move { + let files = evt.files(); + if let Some(file) = files.first() { + let name = file.name(); + + if let Ok(data) = file.read_bytes().await { + let bytes = Bytes::from(data); + let mime_type = bytes + .sniff_mime_type() + .unwrap_or("application/octet-stream") + .to_string(); + + let data_url = format!("data:{};base64,{}", mime_type, STANDARD.encode(&bytes)); + + pending_image.set(PendingImage { + name: name.clone(), + mime_type, + data: bytes, + data_url, + }); + alt_text.set(String::new()); + show_dialog.set(true); + } + } + }); + }; + + let on_image_selected = props.on_image_selected.clone(); + let confirm_upload = move |_| { + let pending = pending_image(); + let uploaded = UploadedImage { + name: pending.name, + alt: alt_text(), + mime_type: pending.mime_type, + data: pending.data, + }; + on_image_selected.call(uploaded); + show_dialog.set(false); + pending_image.set(PendingImage::default()); + alt_text.set(String::new()); + }; + + let cancel_upload = move |_| { + show_dialog.set(false); + pending_image.set(PendingImage::default()); + alt_text.set(String::new()); + }; + + rsx! { + label { + class: "toolbar-button", + title: "Image", + input { + r#type: "file", + accept: "image/*", + style: "display: none;", + disabled: props.disabled, + onchange: on_file_change, + } + "🖼" + } + + DialogRoot { + open: show_dialog(), + on_open_change: move |v| show_dialog.set(v), + + DialogContent { + button { + class: "dialog-close", + r#type: "button", + aria_label: "Close", + tabindex: if show_dialog() { "0" } else { "-1" }, + onclick: cancel_upload, + "×" + } + + DialogTitle { "Add Image" } + + div { class: "image-preview-container", + img { + class: "image-preview", + src: "{pending_image().data_url}", + alt: "Preview", + } + } + + div { class: "image-alt-input-container", + label { + r#for: "image-alt-text", + "Alt text" + } + textarea { + id: "image-alt-text", + class: "image-alt-input", + placeholder: "Describe this image for people who can't see it", + value: "{alt_text}", + oninput: move |e| alt_text.set(e.value()), + rows: "3", + } + } + + div { class: "dialog-actions", + Button { + r#type: "button", + onclick: cancel_upload, + variant: ButtonVariant::Secondary, + "Cancel" + } + Button { + r#type: "button", + onclick: confirm_upload, + "Add Image" + } + } + } + } + } +} diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 87b737a..9fd2649 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -9,6 +9,7 @@ mod cursor; mod document; mod dom_sync; mod formatting; +mod image_upload; mod input; mod log_buffer; mod offset_map; @@ -44,7 +45,7 @@ pub use paragraph::ParagraphRender; #[allow(unused_imports)] pub use render::{RenderCache, render_paragraphs_incremental}; #[allow(unused_imports)] -pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; +pub use writer::{EditorImageResolver, ImageResolver, SyntaxSpanInfo, SyntaxType, WriterResult}; // Storage #[allow(unused_imports)] @@ -54,6 +55,7 @@ pub use storage::{ }; // UI components +pub use image_upload::{ImageUploadButton, UploadedImage}; pub use publish::PublishButton; pub use report::ReportButton; #[allow(unused_imports)] diff --git a/crates/weaver-app/src/components/editor/publish.rs b/crates/weaver-app/src/components/editor/publish.rs index be3a1fc..5ce85f4 100644 --- a/crates/weaver-app/src/components/editor/publish.rs +++ b/crates/weaver-app/src/components/editor/publish.rs @@ -3,11 +3,16 @@ //! Handles creating/updating AT Protocol notebook entries from editor state. use dioxus::prelude::*; -use jacquard::types::string::{AtUri, Datetime}; +use jacquard::types::ident::AtIdentifier; +use jacquard::types::string::{AtUri, Datetime, Nsid}; +use jacquard::{IntoStatic, prelude::*, to_data}; +use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord}; use weaver_api::sh_weaver::embed::images::Images; use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; use weaver_common::{WeaverError, WeaverExt}; +const ENTRY_NSID: &str = "sh.weaver.notebook.entry"; + use crate::auth::AuthState; use crate::fetch::Fetcher; @@ -33,10 +38,15 @@ impl PublishResult { /// Publish an entry to the AT Protocol. /// +/// Supports three modes: +/// - With notebook_title: uses `upsert_entry` to publish to a notebook +/// - Without notebook but with entry_uri in doc: uses `put_record` to update existing +/// - Without notebook and no entry_uri: uses `create_record` for free-floating entry +/// /// # Arguments /// * `fetcher` - The authenticated fetcher/client /// * `doc` - The editor document containing entry data -/// * `notebook_title` - Title of the notebook to publish to +/// * `notebook_title` - Optional title of the notebook to publish to /// * `draft_key` - Storage key for the draft (for cleanup) /// /// # Returns @@ -44,7 +54,7 @@ impl PublishResult { pub async fn publish_entry( fetcher: &Fetcher, doc: &EditorDocument, - notebook_title: &str, + notebook_title: Option<&str>, draft_key: &str, ) -> Result { // Get images from the document @@ -95,12 +105,72 @@ pub async fn publish_entry( .maybe_tags(tags) .maybe_embeds(entry_embeds) .build(); + let entry_data = to_data(&entry).unwrap(); - // Publish via upsert_entry let client = fetcher.get_client(); - let (uri, was_created) = client - .upsert_entry(notebook_title, &doc.title(), entry) - .await?; + let result = if let Some(notebook) = notebook_title { + // Publish to a notebook via upsert_entry + let (uri, was_created) = client.upsert_entry(notebook, &doc.title(), entry).await?; + + if was_created { + PublishResult::Created(uri) + } else { + PublishResult::Updated(uri) + } + } else if let Some(existing_uri) = doc.entry_uri() { + // Update existing free-floating entry + let did = fetcher + .current_did() + .await + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; + + let rkey = existing_uri + .rkey() + .ok_or_else(|| WeaverError::InvalidNotebook("Entry URI missing rkey".into()))?; + + let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?; + + let request = PutRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .rkey(rkey.clone()) + .record(entry_data) + .build(); + + let response = fetcher + .send(request) + .await + .map_err(jacquard::client::AgentError::from)?; + let output = response + .into_output() + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; + + PublishResult::Updated(output.uri.into_static()) + } else { + // Create new free-floating entry + let did = fetcher + .current_did() + .await + .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; + + let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?; + + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .record(entry_data) + .build(); + + let response = fetcher + .send(request) + .await + .map_err(jacquard::client::AgentError::from)?; + let output = response + .into_output() + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; + + PublishResult::Created(output.uri.into_static()) + }; // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive) // TODO: Implement when image upload is added @@ -113,11 +183,7 @@ pub async fn publish_entry( // Clear local draft delete_draft(draft_key); - if was_created { - Ok(PublishResult::Created(uri)) - } else { - Ok(PublishResult::Updated(uri)) - } + Ok(result) } /// Simple slug generation from title. @@ -161,6 +227,7 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { let mut show_dialog = use_signal(|| false); let mut notebook_title = use_signal(|| String::from("Default")); + let mut use_notebook = use_signal(|| true); let mut is_publishing = use_signal(|| false); let mut error_message: Signal> = use_signal(|| None); let mut success_uri: Signal>> = use_signal(|| None); @@ -169,6 +236,9 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { let doc = props.document; let draft_key = props.draft_key.clone(); + // Check if we're editing an existing entry + let is_editing_existing = doc().entry_uri().is_some(); + // Validate that we have required fields let can_publish = { let d = doc(); @@ -189,7 +259,11 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { let do_publish = move |_| { let fetcher = fetcher.clone(); let draft_key = draft_key_clone.clone(); - let notebook = notebook_title(); + let notebook = if use_notebook() { + Some(notebook_title()) + } else { + None + }; spawn(async move { is_publishing.set(true); @@ -198,7 +272,7 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { // Get document snapshot for publishing let doc_snapshot = doc(); - match publish_entry(&fetcher, &doc_snapshot, ¬ebook, &draft_key).await { + match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await { Ok(result) => { success_uri.set(Some(result.uri().clone())); } @@ -253,14 +327,33 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { } } else { div { class: "publish-form", - div { class: "publish-field", - label { "Notebook" } - input { - r#type: "text", - class: "publish-input", - placeholder: "Notebook title...", - value: "{notebook_title}", - oninput: move |e| notebook_title.set(e.value()), + if is_editing_existing { + div { class: "publish-info", + p { "Updating existing entry" } + } + } + + div { class: "publish-field publish-checkbox", + label { + input { + r#type: "checkbox", + checked: use_notebook(), + onchange: move |e| use_notebook.set(e.checked()), + } + " Publish to notebook" + } + } + + if use_notebook() { + div { class: "publish-field", + label { "Notebook" } + input { + r#type: "text", + class: "publish-input", + placeholder: "Notebook title...", + value: "{notebook_title}", + oninput: move |e| notebook_title.set(e.value()), + } } } @@ -288,7 +381,7 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { button { class: "publish-submit", onclick: do_publish, - disabled: is_publishing() || notebook_title().trim().is_empty(), + disabled: is_publishing() || (use_notebook() && notebook_title().trim().is_empty()), if is_publishing() { "Publishing..." } else { diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 3b4c71a..a2b70d6 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -7,7 +7,7 @@ use super::document::EditInfo; use super::offset_map::{OffsetMapping, RenderResult}; use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string}; -use super::writer::{EditorWriter, SyntaxSpanInfo}; +use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; use loro::LoroText; use markdown_weaver::Parser; use std::ops::Range; @@ -112,9 +112,10 @@ fn adjust_paragraph_positions( /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. /// /// # Arguments -/// - `rope`: The document rope to render +/// - `text`: The document text to render /// - `cache`: Previous render cache (if any) /// - `edit`: Information about the most recent edit (if any) +/// - `image_resolver`: Optional resolver for mapping image URLs to data/CDN URLs /// /// # Returns /// Tuple of (rendered paragraphs, updated cache) @@ -122,6 +123,7 @@ pub fn render_paragraphs_incremental( text: &LoroText, cache: Option<&RenderCache>, edit: Option<&EditInfo>, + image_resolver: Option<&EditorImageResolver>, ) -> (Vec, RenderCache) { let source = text.to_string(); @@ -297,6 +299,9 @@ pub fn render_paragraphs_incremental( .into_offset_iter(); let mut output = String::new(); + // Use provided resolver or empty default + let resolver = image_resolver.cloned().unwrap_or_default(); + let (mut offset_map, mut syntax_spans) = match EditorWriter::<_, _, ()>::new_with_offsets( ¶_source, @@ -306,6 +311,7 @@ pub fn render_paragraphs_incremental( node_id_offset, syn_id_offset, ) + .with_image_resolver(&resolver) .run() { Ok(result) => { diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index f939c9f..4ad34f9 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -11,6 +11,8 @@ use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use gloo_storage::{LocalStorage, Storage}; +use jacquard::IntoStatic; +use jacquard::types::string::AtUri; use loro::cursor::Cursor; use serde::{Deserialize, Serialize}; @@ -67,12 +69,10 @@ fn storage_key(key: &str) -> String { /// # Arguments /// * `doc` - The editor document to save /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) -/// * `editing_uri` - AT-URI if editing an existing entry #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub fn save_to_storage( doc: &EditorDocument, key: &str, - editing_uri: Option<&str>, ) -> Result<(), gloo_storage::errors::StorageError> { let snapshot_bytes = doc.export_snapshot(); let snapshot_b64 = if snapshot_bytes.is_empty() { @@ -87,7 +87,7 @@ pub fn save_to_storage( snapshot: snapshot_b64, cursor: doc.loro_cursor().cloned(), cursor_offset: doc.cursor.offset, - editing_uri: editing_uri.map(String::from), + editing_uri: doc.entry_uri().map(|u| u.to_string()), }; LocalStorage::set(storage_key(key), &snapshot) } @@ -103,16 +103,24 @@ pub fn save_to_storage( pub fn load_from_storage(key: &str) -> Option { let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; + // Parse entry_uri from the snapshot + let entry_uri = snapshot + .editing_uri + .as_ref() + .and_then(|s| AtUri::new(s).ok()) + .map(|u| u.into_static()); + // Try to restore from CRDT snapshot first if let Some(ref snapshot_b64) = snapshot.snapshot { if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { - let doc = EditorDocument::from_snapshot( + let mut doc = EditorDocument::from_snapshot( &snapshot_bytes, snapshot.cursor.clone(), snapshot.cursor_offset, ); // Verify the content matches (sanity check) if doc.content() == snapshot.content { + doc.set_entry_uri(entry_uri); return Some(doc); } tracing::warn!("Snapshot content mismatch, falling back to text content"); @@ -123,6 +131,7 @@ pub fn load_from_storage(key: &str) -> Option { let mut doc = EditorDocument::new(snapshot.content); doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); doc.sync_loro_cursor(); + doc.set_entry_uri(entry_uri); Some(doc) } @@ -176,11 +185,7 @@ pub fn clear_all_drafts() { // Stub implementations for non-WASM targets #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn save_to_storage( - _doc: &EditorDocument, - _key: &str, - _editing_uri: Option<&str>, -) -> Result<(), String> { +pub fn save_to_storage(_doc: &EditorDocument, _key: &str) -> Result<(), String> { Ok(()) } diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index e6fc2bc..b6c75a2 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -57,7 +57,7 @@ fn render_test(input: &str) -> Vec { let doc = LoroDoc::new(); let text = doc.get_text("content"); text.insert(0, input).unwrap(); - let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None); paragraphs.iter().map(TestParagraph::from).collect() } @@ -645,7 +645,7 @@ fn test_heading_to_non_heading_transition() { // Initial state: "#" is a valid empty heading text.insert(0, "#").unwrap(); - let (paras1, cache1) = render_paragraphs_incremental(&text, None, None); + let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None); eprintln!("State 1 ('#'): {}", paras1[0].html); assert!(paras1[0].html.contains(") -> Element { +pub fn EditorToolbar( + on_format: EventHandler, + on_image: EventHandler, +) -> Element { rsx! { div { class: "editor-toolbar", button { @@ -85,11 +89,8 @@ pub fn EditorToolbar(on_format: EventHandler) -> Element { onclick: move |_| on_format.call(FormatAction::Link), "🔗" } - button { - class: "toolbar-button", - title: "Image", - onclick: move |_| on_format.call(FormatAction::Image), - "🖼" + ImageUploadButton { + on_image_selected: move |img| on_image.call(img), } } } diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 3d8de67..3787620 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -103,11 +103,141 @@ impl EmbedContentProvider for () { } } +/// Resolves image URLs to CDN URLs based on stored images. +/// +/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). +/// This trait maps those names to the actual CDN URL using the blob CID and owner DID. +pub trait ImageResolver { + /// Resolve an image URL from markdown to a CDN URL. + /// + /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. + fn resolve_image_url(&self, url: &str) -> Option; +} + +impl ImageResolver for () { + fn resolve_image_url(&self, _url: &str) -> Option { + None + } +} + +/// Concrete image resolver that maps image names to URLs. +/// +/// Supports two states for images: +/// - Pending: uses data URL for immediate preview while upload is in progress +/// - Uploaded: uses CDN URL format `https://cdn.bsky.app/img/feed_fullsize/plain/{did}/{cid}@{format}` +/// +/// Image URLs in markdown use the format `/image/{name}`. +#[derive(Clone, Default)] +pub struct EditorImageResolver { + /// Pending images: name -> data URL (still uploading) + pending: std::collections::HashMap, + /// Uploaded images: name -> (CID string, DID string, format) + uploaded: std::collections::HashMap, +} + +impl EditorImageResolver { + pub fn new() -> Self { + Self::default() + } + + /// Add a pending image with a data URL for immediate preview. + /// + /// # Arguments + /// * `name` - The image name used in markdown (e.g., "photo.jpg") + /// * `data_url` - The base64 data URL for preview + pub fn add_pending(&mut self, name: String, data_url: String) { + self.pending.insert(name, data_url); + } + + /// Promote a pending image to uploaded status. + /// + /// Removes from pending and adds to uploaded with CDN info. + pub fn promote_to_uploaded(&mut self, name: &str, cid: String, did: String, format: String) { + self.pending.remove(name); + self.uploaded.insert(name.to_string(), (cid, did, format)); + } + + /// Add an already-uploaded image. + /// + /// # Arguments + /// * `name` - The name/URL used in markdown (e.g., "photo.jpg") + /// * `cid` - The blob CID + /// * `did` - The DID of the blob owner + /// * `format` - The image format (e.g., "jpeg", "png") + pub fn add_uploaded(&mut self, name: String, cid: String, did: String, format: String) { + self.uploaded.insert(name, (cid, did, format)); + } + + /// Check if an image is pending upload. + pub fn is_pending(&self, name: &str) -> bool { + self.pending.contains_key(name) + } + + /// Build a resolver from editor images and user DID. + pub fn from_images<'a>( + images: impl IntoIterator, + user_did: &str, + ) -> Self { + let mut resolver = Self::new(); + for editor_image in images { + // Get the name from the Image (use alt text as fallback if name is empty) + let name = editor_image + .image + .name + .as_ref() + .map(|n| n.to_string()) + .unwrap_or_else(|| editor_image.image.alt.to_string()); + + if name.is_empty() { + continue; + } + + // Get CID and format from the blob ref + let blob = editor_image.image.image.blob(); + let cid = blob.cid().to_string(); + let format = blob + .mime_type + .0 + .strip_prefix("image/") + .unwrap_or("jpeg") + .to_string(); + + resolver.add_uploaded(name, cid, user_did.to_string(), format); + } + resolver + } +} + +impl ImageResolver for EditorImageResolver { + fn resolve_image_url(&self, url: &str) -> Option { + // Extract image name from /image/{name} format + let name = url.strip_prefix("/image/").unwrap_or(url); + + // Check pending first (data URL for immediate preview) + if let Some(data_url) = self.pending.get(name) { + return Some(data_url.clone()); + } + + // Then check uploaded (CDN URL) + let (cid, did, format) = self.uploaded.get(name)?; + Some(format!( + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}", + did, cid, format + )) + } +} + +impl ImageResolver for &EditorImageResolver { + fn resolve_image_url(&self, url: &str) -> Option { + (*self).resolve_image_url(url) + } +} + /// HTML writer that preserves markdown formatting characters. /// /// This writer processes offset-iter events to detect gaps (consumed formatting) /// and emits them as styled spans for visibility in the editor. -pub struct EditorWriter<'a, I: Iterator, Range)>, W: StrWrite, E = ()> { +pub struct EditorWriter<'a, I: Iterator, Range)>, W: StrWrite, E = (), R = ()> { source: &'a str, source_text: &'a LoroText, events: I, @@ -125,6 +255,7 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, W: St numbers: HashMap, embed_provider: Option, + image_resolver: Option, code_buffer: Option<(Option, String)>, // (lang, content) code_buffer_byte_range: Option>, // byte range of buffered code content @@ -172,8 +303,13 @@ enum TableState { Body, } -impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedContentProvider> - EditorWriter<'a, I, W, E> +impl< + 'a, + I: Iterator, Range)>, + W: StrWrite, + E: EmbedContentProvider, + R: ImageResolver, + > EditorWriter<'a, I, W, E, R> { pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { Self::new_with_node_offset(source, source_text, events, writer, 0) @@ -211,6 +347,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon table_cell_index: 0, numbers: HashMap::new(), embed_provider: None, + image_resolver: None, code_buffer: None, code_buffer_byte_range: None, code_buffer_char_range: None, @@ -256,6 +393,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon table_cell_index: 0, numbers: HashMap::new(), embed_provider: None, + image_resolver: None, code_buffer: None, code_buffer_byte_range: None, code_buffer_char_range: None, @@ -280,7 +418,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } /// Add an embed content provider - pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> { + pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E, R> { EditorWriter { source: self.source, source_text: self.source_text, @@ -295,6 +433,50 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon table_cell_index: self.table_cell_index, numbers: self.numbers, embed_provider: Some(provider), + image_resolver: self.image_resolver, + code_buffer: self.code_buffer, + code_buffer_byte_range: self.code_buffer_byte_range, + code_buffer_char_range: self.code_buffer_char_range, + pending_blockquote_range: self.pending_blockquote_range, + render_tables_as_markdown: self.render_tables_as_markdown, + table_start_offset: self.table_start_offset, + offset_maps: self.offset_maps, + next_node_id: self.next_node_id, + current_node_id: self.current_node_id, + current_node_char_offset: self.current_node_char_offset, + current_node_child_count: self.current_node_child_count, + utf16_checkpoints: self.utf16_checkpoints, + paragraph_ranges: self.paragraph_ranges, + current_paragraph_start: self.current_paragraph_start, + list_depth: self.list_depth, + boundary_only: self.boundary_only, + syntax_spans: self.syntax_spans, + next_syn_id: self.next_syn_id, + pending_inline_formats: self.pending_inline_formats, + _phantom: std::marker::PhantomData, + } + } + + /// Add an image resolver for mapping markdown image URLs to CDN URLs + pub fn with_image_resolver( + self, + resolver: R2, + ) -> EditorWriter<'a, I, W, E, R2> { + EditorWriter { + source: self.source, + source_text: self.source_text, + events: self.events, + writer: self.writer, + last_byte_offset: self.last_byte_offset, + last_char_offset: self.last_char_offset, + end_newline: self.end_newline, + in_non_writing_block: self.in_non_writing_block, + table_state: self.table_state, + table_alignments: self.table_alignments, + table_cell_index: self.table_cell_index, + numbers: self.numbers, + embed_provider: self.embed_provider, + image_resolver: Some(resolver), code_buffer: self.code_buffer, code_buffer_byte_range: self.code_buffer_byte_range, code_buffer_char_range: self.code_buffer_char_range, @@ -1753,7 +1935,16 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } self.write("\"")?;, Range)>, W: StrWrite, E: EmbedCon } } -impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedContentProvider> - EditorWriter<'a, I, W, E> +impl< + 'a, + I: Iterator, Range)>, + W: StrWrite, + E: EmbedContentProvider, + R: ImageResolver, + > EditorWriter<'a, I, W, E, R> { fn write_embed( &mut self, diff --git a/crates/weaver-app/src/env.rs b/crates/weaver-app/src/env.rs index f8a1ad7..b8c902b 100644 --- a/crates/weaver-app/src/env.rs +++ b/crates/weaver-app/src/env.rs @@ -1,11 +1,11 @@ // This file is automatically generated by build.rs #[allow(unused)] -pub const WEAVER_APP_ENV: &'static str = "prod"; +pub const WEAVER_APP_ENV: &'static str = "dev"; #[allow(unused)] -pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh"; +pub const WEAVER_APP_HOST: &'static str = "http://localhost"; #[allow(unused)] -pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh"; +pub const WEAVER_APP_DOMAIN: &'static str = ""; #[allow(unused)] pub const WEAVER_PORT: &'static str = "8080"; #[allow(unused)] @@ -13,7 +13,7 @@ pub const WEAVER_APP_SCOPES: &'static str = "atproto transition:generic"; #[allow(unused)] pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; #[allow(unused)] -pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; +pub const WEAVER_LOGO_URI: &'static str = ""; #[allow(unused)] pub const WEAVER_TOS_URI: &'static str = ""; #[allow(unused)] -- 2.51.2