diff --git a/.gitignore b/.gitignore index a882cf7..c4e6e44 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ grant_proposal.md **/.trash **/bug_notes.md /opencodetmp +deploy.sh diff --git a/Cargo.lock b/Cargo.lock index 06d7766..3bc8ee3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7135,6 +7135,15 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pulldown-latex" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abae0e37a730c3e0c6805cfc30d80754fa03091bf5bad54ae47d033aae6ba8b" +dependencies = [ + "bumpalo", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -10455,6 +10464,7 @@ dependencies = [ "n0-future", "pin-project", "pin-utils", + "pulldown-latex", "regex", "regex-lite", "reqwest", diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index f0884d1..7997056 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -156,13 +156,13 @@ min-height: 700px; line-height: var(--spacing-line-height); font-size: 16px; - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--color-base); + border: 1px solid var(--color-overlay); color: var(--color-text); } .editor-content:focus { - background: var(--color-surface); + border: 1px solid var(--color-border); } .editor-toolbar { @@ -228,8 +228,54 @@ /* Hidden syntax spans - collapsed when cursor is not near */ .md-syntax-inline.hidden, .md-syntax-block.hidden, -.image-alt.hidden { +.image-alt.hidden, +.math-source.hidden { + display: none; +} + +/* Math source is hidden by default, shown when syntax is visible */ +.math-source { display: none; + color: var(--color-text); + font-family: var(--font-mono); + white-space: pre-wrap; + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + padding: 0 2px; + border-radius: 2px; +} + +/* When syntax is visible (cursor nearby), show source too */ +.math-source:not(.hidden) { + display: inline; +} + +/* Rendered math is always visible */ +.math-rendered { + display: inline; +} + +.math-display.math-rendered { + display: block; + text-align: center; + margin: 0.5rem 0; +} + +/* Clickable math - click to edit */ +.math-clickable { + cursor: pointer; +} + +/* Math error styling */ +.math-error { + border: 1px dashed var(--color-error, #eb6f92); + border-radius: 4px; + padding: 2px 6px; + background: color-mix(in srgb, var(--color-error, #eb6f92) 10%, transparent); +} + +.math-error code { + font-family: var(--font-mono); + font-size: 0.9em; } /* Hide HTML list markers when markdown syntax is visible (not hidden) */ @@ -630,7 +676,9 @@ li:has(.md-syntax-block:not(.hidden)) { border-radius: 12px; font-size: 12px; cursor: pointer; - transition: background 0.2s ease, opacity 0.2s ease; + transition: + background 0.2s ease, + opacity 0.2s ease; user-select: none; } @@ -665,8 +713,12 @@ li:has(.md-syntax-block:not(.hidden)) { } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } /* Unsynced state - has pending changes */ diff --git a/crates/weaver-app/src/blobcache.rs b/crates/weaver-app/src/blobcache.rs index 66f1404..79778d1 100644 --- a/crates/weaver-app/src/blobcache.rs +++ b/crates/weaver-app/src/blobcache.rs @@ -1,37 +1,38 @@ use crate::cache_impl; +use crate::fetch::Fetcher; use dioxus::{CapturedError, Result}; use jacquard::{ + IntoStatic, bytes::Bytes, - client::UnauthenticatedSession, - identity::JacquardResolver, prelude::*, smol_str::SmolStr, types::{cid::Cid, collection::Collection, ident::AtIdentifier, nsid::Nsid, string::Rkey}, xrpc::XrpcExt, - IntoStatic, -}; -use std::{ - sync::Arc, - time::Duration, }; +use std::{sync::Arc, time::Duration}; use weaver_api::com_atproto::repo::get_record::GetRecord; use weaver_api::com_atproto::sync::get_blob::GetBlob; use weaver_api::sh_weaver::notebook::entry::Entry; use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; +use weaver_common::WeaverExt; #[derive(Clone)] pub struct BlobCache { - client: Arc>, + fetcher: Arc, cache: cache_impl::Cache, Bytes>, map: cache_impl::Cache>, } impl BlobCache { - pub fn new(client: Arc>) -> Self { - let cache = cache_impl::new_cache(100, Duration::from_secs(1200)); - let map = cache_impl::new_cache(500, Duration::from_secs(1200)); + pub fn new(fetcher: Arc) -> Self { + let cache = cache_impl::new_cache(100, Duration::from_secs(12000)); + let map = cache_impl::new_cache(500, Duration::from_secs(12000)); - Self { client, cache, map } + Self { + fetcher, + cache, + map, + } } /// Resolve DID and PDS URL from an identifier @@ -41,11 +42,11 @@ impl BlobCache { ) -> Result<(jacquard::types::string::Did<'static>, jacquard::url::Url)> { match ident { AtIdentifier::Did(did) => { - let pds = self.client.pds_for_did(did).await?; + let pds = self.fetcher.pds_for_did(did).await?; Ok((did.clone().into_static(), pds)) } AtIdentifier::Handle(handle) => { - let (did, pds) = self.client.pds_for_handle(handle).await?; + let (did, pds) = self.fetcher.pds_for_handle(handle).await?; Ok((did, pds)) } } @@ -59,14 +60,9 @@ impl BlobCache { cid: &Cid<'_>, ) -> Result { match self - .client + .fetcher .xrpc(pds_url.clone()) - .send( - &GetBlob::new() - .cid(cid.clone()) - .did(did.clone()) - .build(), - ) + .send(&GetBlob::new().cid(cid.clone()).did(did.clone()).build()) .await { Ok(blob_stream) => Ok(blob_stream.buffer().clone()), @@ -98,11 +94,11 @@ impl BlobCache { name: Option, ) -> Result<()> { let (repo_did, pds_url) = self.resolve_ident(&ident).await?; - + if self.get_cid(&cid).is_some() { return Ok(()); } - + let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; self.cache.insert(cid.clone(), blob); @@ -114,7 +110,7 @@ impl BlobCache { } /// Resolve an image from a published entry by name. - /// + /// /// Looks up the entry record at `{ident}/sh.weaver.notebook.entry/{rkey}`, /// finds the image by name in the embeds, and returns the blob bytes. pub async fn resolve_from_entry( @@ -124,10 +120,10 @@ impl BlobCache { name: &str, ) -> Result { let (repo_did, pds_url) = self.resolve_ident(ident).await?; - + // Fetch the entry record let resp = self - .client + .fetcher .xrpc(pds_url.clone()) .send( &GetRecord::new() @@ -144,8 +140,9 @@ impl BlobCache { .map_err(|e| CapturedError::from_display(format!("Failed to parse entry: {}", e)))?; // Parse the entry - let entry: Entry = jacquard::from_data(&record.value) - .map_err(|e| CapturedError::from_display(format!("Failed to deserialize entry: {}", e)))?; + let entry: Entry = jacquard::from_data(&record.value).map_err(|e| { + CapturedError::from_display(format!("Failed to deserialize entry: {}", e)) + })?; // Find the image by name let cid = entry @@ -153,12 +150,14 @@ impl BlobCache { .as_ref() .and_then(|e| e.images.as_ref()) .and_then(|imgs| { - imgs.images.iter().find(|img| { - img.name.as_ref().map(|n| n.as_ref()) == Some(name) - }) + imgs.images + .iter() + .find(|img| img.name.as_ref().map(|n| n.as_ref()) == Some(name)) }) .map(|img| img.image.blob().cid().clone().into_static()) - .ok_or_else(|| CapturedError::from_display(format!("Image '{}' not found in entry", name)))?; + .ok_or_else(|| { + CapturedError::from_display(format!("Image '{}' not found in entry", name)) + })?; // Check cache first if let Some(bytes) = self.get_cid(&cid) { @@ -169,12 +168,12 @@ impl BlobCache { let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; self.cache.insert(cid.clone(), blob.clone()); self.map.insert(name.into(), cid); - + Ok(blob) } /// Resolve an image from a draft (unpublished) entry via PublishedBlob record. - /// + /// /// Looks up the PublishedBlob record at `{ident}/sh.weaver.publish.blob/{blob_rkey}`, /// gets the CID from it, and returns the blob bytes. pub async fn resolve_from_draft( @@ -183,10 +182,10 @@ impl BlobCache { blob_rkey: &str, ) -> Result { let (repo_did, pds_url) = self.resolve_ident(ident).await?; - + // Fetch the PublishedBlob record let resp = self - .client + .fetcher .xrpc(pds_url.clone()) .send( &GetRecord::new() @@ -196,15 +195,18 @@ impl BlobCache { .build(), ) .await - .map_err(|e| CapturedError::from_display(format!("Failed to fetch PublishedBlob: {}", e)))?; + .map_err(|e| { + CapturedError::from_display(format!("Failed to fetch PublishedBlob: {}", e)) + })?; - let record = resp - .into_output() - .map_err(|e| CapturedError::from_display(format!("Failed to parse PublishedBlob: {}", e)))?; + let record = resp.into_output().map_err(|e| { + CapturedError::from_display(format!("Failed to parse PublishedBlob: {}", e)) + })?; // Parse the PublishedBlob - let published: PublishedBlob = jacquard::from_data(&record.value) - .map_err(|e| CapturedError::from_display(format!("Failed to deserialize PublishedBlob: {}", e)))?; + let published: PublishedBlob = jacquard::from_data(&record.value).map_err(|e| { + CapturedError::from_display(format!("Failed to deserialize PublishedBlob: {}", e)) + })?; // Get CID from the upload blob ref let cid = published.upload.blob().cid().clone().into_static(); @@ -217,27 +219,115 @@ impl BlobCache { // Fetch and cache the blob let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; self.cache.insert(cid, blob.clone()); - + Ok(blob) } /// Resolve an image from a notebook entry by name. - /// - /// This is a convenience method that looks up the notebook, finds the entry, - /// and resolves the image. Used for `/image/{notebook}/{name}` paths. + /// + /// Looks up the notebook by title or path, iterates through entries to find + /// the image by name, and returns the blob bytes. Used for `/image/{notebook}/{name}` paths. + /// Cache key uses `{notebook_key}_{image_name}` to avoid collisions across notebooks. pub async fn resolve_from_notebook( &self, - notebook_title: &str, + notebook_key: &str, image_name: &str, ) -> Result { - // For now, just try to get from the name cache - // Full notebook resolution would require fetching the notebook record - // and iterating through entries to find the image - self.get_named(&image_name.into()) - .ok_or_else(|| CapturedError::from_display(format!( - "Image '{}' not found in notebook '{}'", - image_name, notebook_title - ))) + // Try scoped cache key first: {notebook_key}_{image_name} + let cache_key: SmolStr = format!("{}_{}", notebook_key, image_name).into(); + if let Some(bytes) = self.get_named(&cache_key) { + return Ok(bytes); + } + + // Use Fetcher's notebook lookup (works with title or path) + let notebook = self + .fetcher + .get_notebook_by_key(notebook_key) + .await? + .ok_or_else(|| { + CapturedError::from_display(format!("Notebook '{}' not found", notebook_key)) + })?; + + let (view, entry_refs) = notebook.as_ref(); + + // Get the DID from the notebook URI for blob fetching + let notebook_did = jacquard::types::aturi::AtUri::new(view.uri.as_ref()) + .map_err(|e| CapturedError::from_display(format!("Invalid notebook URI: {}", e)))? + .authority() + .clone() + .into_static(); + let repo_did = match ¬ebook_did { + AtIdentifier::Did(d) => d.clone(), + AtIdentifier::Handle(h) => self + .fetcher + .resolve_handle(h) + .await + .map_err(|e| CapturedError::from_display(e))?, + }; + let pds_url = self + .fetcher + .pds_for_did(&repo_did) + .await + .map_err(|e| CapturedError::from_display(e))?; + + // Iterate through entries to find the image + let client = self.fetcher.get_client(); + for entry_ref in entry_refs { + // Parse the entry URI to get rkey + let entry_uri = jacquard::types::aturi::AtUri::new(entry_ref.uri.as_ref()) + .map_err(|e| CapturedError::from_display(format!("Invalid entry URI: {}", e)))?; + let rkey = entry_uri + .rkey() + .ok_or_else(|| CapturedError::from_display("Entry URI missing rkey"))?; + + // Fetch entry using client's cached method + let (_entry_view, entry) = match client + .fetch_entry_by_rkey(¬ebook_did, rkey.0.as_str()) + .await + { + Ok(result) => result, + Err(_) => continue, + }; + + // Check if this entry has the image we're looking for + if let Some(embeds) = &entry.embeds { + if let Some(images) = &embeds.images { + if let Some(img) = images + .images + .iter() + .find(|i| i.name.as_deref() == Some(image_name)) + { + let cid = img.image.blob().cid().clone().into_static(); + + // Check blob cache + if let Some(bytes) = self.get_cid(&cid) { + // Also cache with scoped key for next time + self.map.insert(cache_key, cid); + return Ok(bytes); + } + + // Fetch and cache the blob + let blob = self.fetch_blob(&repo_did, pds_url, &cid).await?; + self.cache.insert(cid.clone(), blob.clone()); + self.map.insert(cache_key, cid); + return Ok(blob); + } + } + } + } + + Err(CapturedError::from_display(format!( + "Image '{}' not found in notebook '{}'", + image_name, notebook_key + ))) + } + + /// Insert bytes directly into cache (for pre-warming after upload) + pub fn insert_bytes(&self, cid: Cid<'static>, bytes: Bytes, name: Option) { + self.cache.insert(cid.clone(), bytes); + if let Some(name) = name { + self.map.insert(name, cid); + } } pub fn get_cid(&self, cid: &Cid<'static>) -> Option { diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 642ab5d..4311d45 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -59,15 +59,16 @@ enum LoadResult { /// - `initial_content`: Optional initial markdown content (for new entries) /// - `entry_uri`: Optional AT-URI of an existing entry to edit /// - `target_notebook`: Optional notebook title to add the entry to when publishing +/// - `entry_index`: Optional index of entries for wikilink validation #[component] pub fn MarkdownEditor( initial_content: Option, entry_uri: Option, target_notebook: Option, + entry_index: Option, ) -> Element { let fetcher = use_context::(); - // Determine draft key - use entry URI if editing existing, otherwise generate TID let draft_key = use_hook(|| { entry_uri.clone().unwrap_or_else(|| { format!( @@ -77,17 +78,14 @@ pub fn MarkdownEditor( }) }); - // Parse entry URI once let parsed_uri = entry_uri.as_ref().and_then(|s| { jacquard::types::string::AtUri::new(s) .ok() .map(|u| u.into_static()) }); - - // Clone draft_key for render (resource closure moves it) let draft_key_for_render = draft_key.clone(); + let target_notebook_for_render = target_notebook.clone(); - // Resource loads and merges document state let load_resource = use_resource(move || { let fetcher = fetcher.clone(); let draft_key = draft_key.clone(); @@ -95,7 +93,6 @@ pub fn MarkdownEditor( let initial_content = initial_content.clone(); async move { - // Try to load merged state from PDS + localStorage match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await { Ok(Some(state)) => { tracing::debug!("Loaded merged document state"); @@ -110,7 +107,6 @@ pub fn MarkdownEditor( let is_own_entry = match entry_authority { AtIdentifier::Did(did) => did == ¤t_did, AtIdentifier::Handle(handle) => { - // Resolve handle to DID and compare match fetcher.client.resolve_handle(handle).await { Ok(resolved_did) => resolved_did == current_did, Err(_) => false, @@ -127,8 +123,6 @@ pub fn MarkdownEditor( ); } } - - // Try to load the entry content from PDS match load_entry_for_editing(&fetcher, uri).await { Ok(loaded) => { // Create LoadedDocState from entry @@ -188,7 +182,6 @@ pub fn MarkdownEditor( } }); - // Render based on load state match &*load_resource.read() { Some(LoadResult::Loaded(state)) => { rsx! { @@ -196,6 +189,8 @@ pub fn MarkdownEditor( key: "{draft_key_for_render}", draft_key: draft_key_for_render.clone(), loaded_state: state.clone(), + target_notebook: target_notebook_for_render.clone(), + entry_index: entry_index.clone(), } } } @@ -226,69 +221,121 @@ pub fn MarkdownEditor( /// - PDS sync with auto-save /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) #[component] -fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Element { +fn MarkdownEditorInner( + draft_key: String, + loaded_state: LoadedDocState, + target_notebook: Option, + /// Optional entry index for wikilink validation in the editor + entry_index: Option, +) -> Element { // Context for authenticated API calls let fetcher = use_context::(); let auth_state = use_context::>(); - // Create EditorDocument from loaded state (must be in use_hook for Signals) let mut document = use_hook(|| { let doc = EditorDocument::from_loaded_state(loaded_state.clone()); - // Save to localStorage so we have a local backup storage::save_to_storage(&doc, &draft_key).ok(); doc }); let editor_id = "markdown-editor"; - - // 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); + let resolved_content = use_signal(weaver_common::ResolvedContent::default); - // Render paragraphs with incremental caching - // Reads document.last_edit signal - creates dependency on content changes only let doc_for_memo = document.clone(); + let doc_for_refs = document.clone(); let paragraphs = use_memo(move || { - let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency + let edit = doc_for_memo.last_edit(); let cache = render_cache.peek(); let resolver = image_resolver(); + let resolved = resolved_content(); - let (paras, new_cache) = render::render_paragraphs_incremental( + let (paras, new_cache, refs) = render::render_paragraphs_incremental( doc_for_memo.loro_text(), Some(&cache), edit.as_ref(), Some(&resolver), + entry_index.as_ref(), + &resolved, ); - - // Update cache for next render (write-only via spawn to avoid reactive loop) + let mut doc_for_spawn = doc_for_refs.clone(); dioxus::prelude::spawn(async move { render_cache.set(new_cache); + doc_for_spawn.set_collected_refs(refs); }); paras }); - // Flatten offset maps from all paragraphs + // Background fetch for AT embeds + let mut resolved_content_for_fetch = resolved_content.clone(); + let doc_for_embeds = document.clone(); + let fetcher_for_embeds = fetcher.clone(); + use_effect(move || { + let refs = doc_for_embeds.collected_refs.read(); + let current_resolved = resolved_content_for_fetch.peek(); + let fetcher = fetcher_for_embeds.clone(); + + // Find AT embeds that need fetching + let to_fetch: Vec = refs + .iter() + .filter_map(|r| match r { + weaver_common::ExtractedRef::AtEmbed { uri, .. } => { + // Skip if already resolved + if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) { + if current_resolved.get_embed_content(&at_uri).is_none() { + return Some(uri.clone()); + } + } + None + } + _ => None, + }) + .collect(); + + if to_fetch.is_empty() { + return; + } + + // Spawn background fetches + dioxus::prelude::spawn(async move { + for uri_str in to_fetch { + let Ok(at_uri) = jacquard::types::string::AtUri::new(&uri_str) else { + continue; + }; + + match weaver_renderer::atproto::fetch_and_render(&at_uri, &fetcher) + .await + { + Ok(html) => { + resolved_content_for_fetch.with_mut(|rc| { + rc.add_embed(at_uri.into_static(), html, None); + }); + } + Err(e) => { + tracing::warn!("failed to fetch embed {}: {}", uri_str, e); + } + } + } + }); + }); + + let mut new_tag = use_signal(String::new); + let offset_map = use_memo(move || { paragraphs() .iter() .flat_map(|p| p.offset_map.iter().cloned()) .collect::>() }); - - // Flatten syntax spans from all paragraphs let syntax_spans = use_memo(move || { paragraphs() .iter() .flat_map(|p| p.syntax_spans.iter().cloned()) .collect::>() }); - - // Cache paragraphs for change detection AND for event handlers to access let mut cached_paragraphs = use_signal(|| Vec::::new()); - // Update DOM when paragraphs change (incremental rendering) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] let mut doc_for_dom = document.clone(); #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -365,7 +412,6 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] let mut interval_holder: Signal> = use_signal(|| None); - // Auto-save with periodic check (no reactive dependency to avoid loops) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] let doc_for_autosave = document.clone(); #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -385,7 +431,7 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme None => true, Some(last) => ¤t_frontiers != last, } - }; // drop last_frontiers borrow here + }; if needs_save { doc.sync_loro_cursor(); @@ -436,11 +482,7 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme // Get target range from the event if available let paras = cached_paras.peek().clone(); let target_range = get_target_range_from_event(&evt, editor_id, ¶s); - - // Get data from the event let data = get_data_from_event(&evt); - - // Build context and handle let ctx = BeforeInputContext { input_type: input_type.clone(), data, @@ -530,9 +572,6 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme closure.forget(); }); - // Local state for adding new tags - let mut new_tag = use_signal(String::new); - rsx! { Stylesheet { href: asset!("/assets/styling/editor.css") } div { class: "markdown-editor-container", @@ -621,6 +660,7 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme PublishButton { document: document.clone(), draft_key: draft_key.to_string(), + target_notebook: target_notebook.as_ref().map(|s| s.to_string()), } } } @@ -804,9 +844,45 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme onclick: { let mut doc = document.clone(); - move |_evt| { + move |evt| { tracing::debug!("onclick fired"); let paras = cached_paragraphs(); + + // Check if click target is a math-clickable element + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let web_evt = evt.as_web_event(); + if let Some(target) = web_evt.target() { + if let Some(element) = target.dyn_ref::() { + // Check element or ancestors for math-clickable + if let Ok(Some(math_el)) = element.closest(".math-clickable") { + if let Some(char_target) = math_el.get_attribute("data-char-target") { + if let Ok(offset) = char_target.parse::() { + tracing::debug!("math-clickable clicked, moving cursor to {}", offset); + doc.cursor.write().offset = offset; + *doc.selection.write() = None; + // Update visibility FIRST so math-source is visible + let spans = syntax_spans(); + update_syntax_visibility(offset, None, &spans, ¶s); + // Then set DOM selection + let map = offset_map(); + let _ = crate::components::editor::cursor::restore_cursor_position( + offset, + &map, + editor_id, + None, + ); + return; + } + } + } + } + } + } + sync_cursor_from_dom(&mut doc, editor_id, ¶s); let spans = syntax_spans(); let cursor_offset = doc.cursor.read().offset; @@ -980,8 +1056,6 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme } }, } - - // Debug panel snug below editor div { class: "editor-debug", div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, ReportButton { @@ -991,7 +1065,6 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme } } - // Toolbar in grid column 2, row 3 EditorToolbar { on_format: { let mut doc = document.clone(); @@ -1040,6 +1113,9 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme spawn(async move { let client = fetcher.get_client(); + // Clone data for cache pre-warming + let data_for_cache = data.clone(); + // Upload blob and create temporary PublishedBlob record match client.publish_blob(data, &name_for_upload, None).await { Ok((strong_ref, published_blob)) => { @@ -1061,15 +1137,14 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme } }; - // Build Image using the builder API + let cid = published_blob.upload.blob().cid().clone().into_static(); + let name_for_resolver = name_for_upload.clone(); let image = Image::new() .alt(alt_for_upload.to_cowstr()) .image(published_blob.upload) .name(name_for_upload.to_cowstr()) .build(); - - // Add to document doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); // Promote from pending to uploaded in resolver @@ -1083,6 +1158,20 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme }); tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); + + // Pre-warm server cache with blob bytes + #[cfg(feature = "fullstack-server")] + { + use jacquard::smol_str::ToSmolStr; + if let Err(e) = crate::data::cache_blob_bytes( + cid.to_smolstr(), + Some(name_for_resolver.into()), + None, + data_for_cache.into(), + ).await { + tracing::warn!(error = %e, "Failed to pre-warm blob cache"); + } + } } Err(e) => { tracing::error!(error = %e, "Failed to upload image"); @@ -1091,7 +1180,7 @@ fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Eleme } }); } else { - tracing::info!(name = %name, "Image added with data URL (not authenticated)"); + tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); } } }, diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index 03749ef..f4e9551 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -115,6 +115,7 @@ pub fn restore_cursor_position( /// /// Walks all text nodes in the container, accumulating their UTF-16 lengths /// until we find the node containing the target offset. +/// Skips text nodes inside contenteditable="false" elements (like embeds). /// /// Returns (text_node, offset_within_node). #[cfg(all(target_family = "wasm", target_os = "unknown"))] @@ -127,14 +128,41 @@ fn find_text_node_at_offset( .document() .ok_or("no document")?; - // Create tree walker to find text nodes - // SHOW_TEXT = 4 (from DOM spec) - let walker = document.create_tree_walker_with_what_to_show(container, 4)?; + // Use SHOW_ALL to see element boundaries for tracking non-editable regions + let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?; let mut accumulated_utf16 = 0; let mut last_node: Option = None; + let mut skip_until_exit: Option = None; while let Some(node) = walker.next_node()? { + // Check if we've exited the non-editable subtree + if let Some(ref skip_elem) = skip_until_exit { + if !skip_elem.contains(Some(&node)) { + skip_until_exit = None; + } + } + + // Check if entering a non-editable element + if skip_until_exit.is_none() { + if let Some(element) = node.dyn_ref::() { + if element.get_attribute("contenteditable").as_deref() == Some("false") { + skip_until_exit = Some(element.clone()); + continue; + } + } + } + + // Skip everything inside non-editable regions + if skip_until_exit.is_some() { + continue; + } + + // Only process text nodes + if node.node_type() != web_sys::Node::TEXT_NODE { + continue; + } + last_node = Some(node.clone()); if let Some(text) = node.text_content() { diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 16f5baa..60f76d4 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -134,6 +134,10 @@ pub struct EditorDocument { /// Pending snap direction for cursor restoration after edits. /// Set by input handlers, consumed by cursor restoration. pub pending_snap: Signal>, + + /// Collected refs (wikilinks, AT embeds) from the most recent render. + /// Updated by the render pipeline, read by publish for populating records. + pub collected_refs: Signal>, } /// Cursor state including position and affinity. @@ -312,6 +316,7 @@ impl EditorDocument { composition_ended_at: Signal::new(None), last_edit: Signal::new(None), pending_snap: Signal::new(None), + collected_refs: Signal::new(Vec::new()), } } @@ -789,6 +794,29 @@ impl EditorDocument { self.last_edit.read().clone() } + // --- Collected refs accessors --- + + /// Update collected refs from the render pipeline. + pub fn set_collected_refs(&mut self, refs: Vec) { + self.collected_refs.set(refs); + } + + /// Get AT URIs from collected embeds for populating entry.embeds.records. + /// + /// Filters for AtEmbed refs and parses to AtUri. Invalid URIs are skipped. + pub fn at_embed_uris(&self) -> Vec> { + self.collected_refs + .read() + .iter() + .filter_map(|r| match r { + weaver_common::ExtractedRef::AtEmbed { uri, .. } => { + AtUri::new(uri).ok().map(|u| u.into_static()) + } + _ => None, + }) + .collect() + } + // --- Edit sync methods --- /// Get the edit root StrongRef if set. @@ -951,6 +979,7 @@ impl EditorDocument { composition_ended_at: Signal::new(None), last_edit: Signal::new(None), pending_snap: Signal::new(None), + collected_refs: Signal::new(Vec::new()), } } @@ -1009,6 +1038,7 @@ impl EditorDocument { composition_ended_at: Signal::new(None), last_edit: Signal::new(None), pending_snap: Signal::new(None), + collected_refs: Signal::new(Vec::new()), } } } diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index 7c66d47..4de29fe 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -161,17 +161,47 @@ pub fn dom_position_to_text_offset( })?; // Calculate UTF-16 offset from start of container to the position + // Skip text nodes inside contenteditable="false" elements (like embeds) let mut utf16_offset_in_container = 0; - if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { - while let Ok(Some(text_node)) = walker.next_node() { - if &text_node == node { - utf16_offset_in_container += offset_in_text_node; - break; + // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions + if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) { + // Track the non-editable element we're inside (if any) + let mut skip_until_exit: Option = None; + + while let Ok(Some(dom_node)) = walker.next_node() { + // Check if we've exited the non-editable subtree + if let Some(ref skip_elem) = skip_until_exit { + if !skip_elem.contains(Some(&dom_node)) { + skip_until_exit = None; + } + } + + // Check if entering a non-editable element + if skip_until_exit.is_none() { + if let Some(element) = dom_node.dyn_ref::() { + if element.get_attribute("contenteditable").as_deref() == Some("false") { + skip_until_exit = Some(element.clone()); + continue; + } + } } - if let Some(text) = text_node.text_content() { - utf16_offset_in_container += text.encode_utf16().count(); + // Skip everything inside non-editable regions + if skip_until_exit.is_some() { + continue; + } + + // Only process text nodes + if dom_node.node_type() == web_sys::Node::TEXT_NODE { + if &dom_node == node { + utf16_offset_in_container += offset_in_text_node; + break; + } + + if let Some(text) = dom_node.text_content() { + utf16_offset_in_container += text.encode_utf16().count(); + } } } } @@ -331,3 +361,4 @@ pub fn update_paragraph_dom( ) -> bool { false } + diff --git a/crates/weaver-app/src/components/editor/publish.rs b/crates/weaver-app/src/components/editor/publish.rs index 4a63ac0..71209e4 100644 --- a/crates/weaver-app/src/components/editor/publish.rs +++ b/crates/weaver-app/src/components/editor/publish.rs @@ -15,6 +15,7 @@ use weaver_api::com_atproto::repo::get_record::GetRecord; use weaver_api::com_atproto::repo::strong_ref::StrongRef; 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::embed::records::{RecordEmbed, Records}; use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; use weaver_common::{WeaverError, WeaverExt}; @@ -38,6 +39,18 @@ fn rewrite_draft_paths(content: &str, entry_rkey: &str) -> String { .into_owned() } +/// Rewrite draft paths for notebook entries. +/// +/// Converts `/image/{did}/draft/{blob_rkey}/{name}` to `/image/{notebook}/{name}` +fn rewrite_draft_paths_for_notebook(content: &str, notebook_key: &str) -> String { + DRAFT_IMAGE_PATH_REGEX + .replace_all(content, |caps: ®ex_lite::Captures| { + let name = &caps[3]; + format!("/image/{}/{}", notebook_key, name) + }) + .into_owned() +} + use crate::auth::AuthState; use crate::fetch::Fetcher; @@ -82,19 +95,15 @@ pub async fn load_entry_for_editing( // Resolve DID and PDS let (did, pds_url) = match ident { AtIdentifier::Did(d) => { - let pds = fetcher - .client - .pds_for_did(d) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)))?; + let pds = fetcher.client.pds_for_did(d).await.map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)) + })?; (d.clone(), pds) } AtIdentifier::Handle(h) => { - let (did, pds) = fetcher - .client - .pds_for_handle(h) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)))?; + let (did, pds) = fetcher.client.pds_for_handle(h).await.map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)) + })?; (did, pds) } }; @@ -124,9 +133,12 @@ pub async fn load_entry_for_editing( // Build StrongRef from URI and CID let entry_ref = StrongRef::new() .uri(uri.clone().into_static()) - .cid(record.cid.ok_or_else(|| { - WeaverError::InvalidNotebook("Entry response missing CID".into()) - })?.into_static()) + .cid( + record + .cid + .ok_or_else(|| WeaverError::InvalidNotebook("Entry response missing CID".into()))? + .into_static(), + ) .build(); Ok(LoadedEntry { @@ -153,18 +165,42 @@ pub async fn publish_entry( // Get images from the document let editor_images = doc.images(); - // Build embeds if we have images - let entry_embeds = if editor_images.is_empty() { + // Resolve AT embed URIs to StrongRefs + let at_embed_uris = doc.at_embed_uris(); + let mut record_embeds: Vec> = Vec::new(); + for uri in at_embed_uris { + match fetcher.confirm_record_ref(&uri).await { + Ok(strong_ref) => { + record_embeds.push(RecordEmbed::new().record(strong_ref).build()); + } + Err(e) => { + tracing::warn!("Failed to resolve embed {}: {}", uri, e); + } + } + } + + // Build embeds if we have images or records + let entry_embeds = if editor_images.is_empty() && record_embeds.is_empty() { None } else { - // Extract Image types from EditorImage wrappers - let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect(); + let images = if editor_images.is_empty() { + None + } else { + Some(Images { + images: editor_images.iter().map(|ei| ei.image.clone()).collect(), + extra_data: None, + }) + }; + + let records = if record_embeds.is_empty() { + None + } else { + Some(Records::new().records(record_embeds).build()) + }; Some(EntryEmbeds { - images: Some(Images { - images, - extra_data: None, - }), + images, + records, ..Default::default() }) }; @@ -192,10 +228,11 @@ pub async fn publish_entry( let client = fetcher.get_client(); let result = if let Some(notebook) = notebook_title { // Publish to a notebook via upsert_entry - // TODO: Need to handle path rewriting for notebook case - // For now, use content as-is (notebook entries use different path scheme anyway) + // Rewrite draft image paths to notebook paths: /image/{notebook}/{name} + let content = rewrite_draft_paths_for_notebook(&doc.content(), notebook); + let entry = Entry::new() - .content(doc.content()) + .content(content) .title(doc.title()) .path(path) .created_at(Datetime::now()) @@ -203,7 +240,17 @@ pub async fn publish_entry( .maybe_embeds(entry_embeds) .build(); - let (entry_ref, was_created) = client.upsert_entry(notebook, &doc.title(), entry).await?; + // Pass existing rkey if re-publishing (to allow title changes without creating new entry) + let doc_entry_ref = doc.entry_ref(); + let existing_rkey = doc_entry_ref.as_ref().and_then(|r| r.uri.rkey()); + let (entry_ref, was_created) = client + .upsert_entry( + notebook, + &doc.title(), + entry, + existing_rkey.map(|r| r.0.as_str()), + ) + .await?; let uri = entry_ref.uri.clone(); // Set entry_ref so subsequent publishes update this record @@ -368,6 +415,9 @@ pub struct PublishButtonProps { pub document: EditorDocument, /// Storage key for the draft pub draft_key: String, + /// Pre-selected notebook (from URL param) + #[props(optional)] + pub target_notebook: Option, } /// Publish button component with notebook selection. @@ -377,8 +427,10 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { let auth_state = use_context::>(); 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 notebook_title = use_signal(|| { + props.target_notebook.clone().unwrap_or_else(|| String::from("Default")) + }); + let mut use_notebook = use_signal(|| props.target_notebook.is_some()); let mut is_publishing = use_signal(|| false); let mut error_message: Signal> = use_signal(|| None); let mut success_uri: Signal>> = use_signal(|| None); @@ -420,7 +472,8 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { error_message.set(None); let mut doc_snapshot = doc_snapshot; - match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await { + match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await + { Ok(result) => { success_uri.set(Some(result.uri().clone())); } @@ -460,17 +513,32 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { h2 { "Publish Entry" } if let Some(uri) = success_uri() { - div { class: "publish-success", - p { "Entry published successfully!" } - a { - href: "{uri}", - target: "_blank", - "View entry →" - } - button { - class: "publish-done", - onclick: close_dialog, - "Done" + { + // Construct web URL from AT-URI + let did = uri.authority(); + let web_url = if use_notebook() { + // Notebook entry: /{did}/{notebook}/{entry_path} + format!("/{}/{}/{}", did, notebook_title(), doc.path()) + } else { + // Standalone entry: /{did}/e/{rkey} + let rkey = uri.rkey().map(|r| r.0.as_str()).unwrap_or(""); + format!("/{}/e/{}", did, rkey) + }; + + rsx! { + div { class: "publish-success", + p { "Entry published successfully!" } + a { + href: "{web_url}", + target: "_blank", + "View entry → " + } + button { + class: "publish-done", + onclick: close_dialog, + "Done" + } + } } } } else { diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 2ac3c5b..56bb842 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -11,6 +11,7 @@ use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpan use loro::LoroText; use markdown_weaver::Parser; use std::ops::Range; +use weaver_common::{EntryIndex, ResolvedContent}; /// Cache for incremental paragraph rendering. /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. @@ -104,12 +105,25 @@ fn adjust_paragraph_positions( /// /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. +/// +/// # Parameters +/// - `entry_index`: Optional index for wikilink validation (adds link-valid/link-broken classes) +/// - `resolved_content`: Pre-resolved embed content for sync rendering +/// +/// # Returns +/// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render pub fn render_paragraphs_incremental( text: &LoroText, cache: Option<&RenderCache>, edit: Option<&EditInfo>, image_resolver: Option<&EditorImageResolver>, -) -> (Vec, RenderCache) { + entry_index: Option<&EntryIndex>, + resolved_content: &ResolvedContent, +) -> ( + Vec, + RenderCache, + Vec, +) { let source = text.to_string(); // Handle empty document @@ -139,7 +153,7 @@ pub fn render_paragraphs_incremental( next_syn_id: 0, }; - return (vec![para], new_cache); + return (vec![para], new_cache, vec![]); } // Determine if we can use fast path (skip boundary discovery) @@ -218,7 +232,7 @@ pub fn render_paragraphs_incremental( .run() { Ok(result) => result.paragraph_ranges, - Err(_) => return (Vec::new(), RenderCache::default()), + Err(_) => return (Vec::new(), RenderCache::default(), vec![]), } }; @@ -241,6 +255,7 @@ pub fn render_paragraphs_incremental( // Render paragraphs, reusing cache where possible let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); + let mut all_refs: Vec = Vec::new(); let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); @@ -286,63 +301,64 @@ pub fn render_paragraphs_incremental( // 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( + // Build writer with optional entry index for wikilink validation + // Pass paragraph's document-level offsets so all embedded char/byte positions are absolute + let mut writer = + EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver>::new_with_all_offsets( ¶_source, ¶_text, parser, &mut output, node_id_offset, syn_id_offset, + char_range.start, + byte_range.start, ) .with_image_resolver(&resolver) - .run() - { - Ok(result) => { - // Update node ID offset - let max_node_id = result - .offset_maps - .iter() - .filter_map(|m| { - m.node_id - .strip_prefix("n") - .and_then(|s| s.parse::().ok()) - }) - .max() - .unwrap_or(node_id_offset); - node_id_offset = max_node_id + 1; - - // Update syn ID offset - let max_syn_id = result - .syntax_spans - .iter() - .filter_map(|s| { - s.syn_id - .strip_prefix("s") - .and_then(|id| id.parse::().ok()) - }) - .max() - .unwrap_or(syn_id_offset.saturating_sub(1)); - syn_id_offset = max_syn_id + 1; - - (result.offset_maps, result.syntax_spans) - } - Err(_) => (Vec::new(), Vec::new()), - }; - - // Adjust offsets to document coordinates - let para_char_start = char_range.start; - let para_byte_start = byte_range.start; - for mapping in &mut offset_map { - mapping.byte_range.start += para_byte_start; - mapping.byte_range.end += para_byte_start; - mapping.char_range.start += para_char_start; - mapping.char_range.end += para_char_start; - } - for span in &mut syntax_spans { - span.adjust_positions(para_char_start as isize); + .with_embed_provider(resolved_content); + + if let Some(idx) = entry_index { + writer = writer.with_entry_index(idx); } + let (mut offset_map, mut syntax_spans) = match writer.run() { + Ok(result) => { + // Update node ID offset + let max_node_id = result + .offset_maps + .iter() + .filter_map(|m| { + m.node_id + .strip_prefix("n") + .and_then(|s| s.parse::().ok()) + }) + .max() + .unwrap_or(node_id_offset); + node_id_offset = max_node_id + 1; + + // Update syn ID offset + let max_syn_id = result + .syntax_spans + .iter() + .filter_map(|s| { + s.syn_id + .strip_prefix("s") + .and_then(|id| id.parse::().ok()) + }) + .max() + .unwrap_or(syn_id_offset.saturating_sub(1)); + syn_id_offset = max_syn_id + 1; + + // Collect refs from this paragraph + all_refs.extend(result.collected_refs); + + (result.offset_maps, result.syntax_spans) + } + Err(_) => (Vec::new(), Vec::new()), + }; + + // Offsets are already document-absolute since we pass char_range.start/byte_range.start + // to the writer constructor (output, offset_map, syntax_spans) }; @@ -448,5 +464,5 @@ pub fn render_paragraphs_incremental( next_syn_id: syn_id_offset, }; - (paragraphs_with_gaps, new_cache) + (paragraphs_with_gaps, new_cache, all_refs) } diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index b6c75a2..19579b2 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -5,6 +5,7 @@ use super::paragraph::ParagraphRender; use super::render::render_paragraphs_incremental; use loro::LoroDoc; use serde::Serialize; +use weaver_common::ResolvedContent; /// Serializable version of ParagraphRender for snapshot testing. #[derive(Debug, Serialize)] @@ -57,7 +58,8 @@ 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, None); + let (paragraphs, _cache, _refs) = + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); paragraphs.iter().map(TestParagraph::from).collect() } @@ -645,7 +647,8 @@ 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, None); + let (paras1, cache1, _refs1) = + render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); eprintln!("State 1 ('#'): {}", paras1[0].html); assert!(paras1[0].html.contains("