···11+//! Rendering traits for the editor.
22+//!
33+//! These traits abstract over external concerns during rendering:
44+//! - Resolving embed URLs to HTML content
55+//! - Resolving image URLs to CDN paths
66+//! - Validating wikilinks
77+//!
88+//! Implementations are provided by the consuming application (e.g., weaver-app).
99+1010+/// Provides HTML content for embedded resources.
1111+///
1212+/// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait
1313+/// is consulted to get the pre-rendered HTML for the embed.
1414+pub trait EmbedContentProvider {
1515+ /// Get HTML content for an embed URL.
1616+ ///
1717+ /// Returns `Some(html)` if the embed content is available,
1818+ /// `None` to render a placeholder.
1919+ fn get_embed_html(&self, url: &str) -> Option<&str>;
2020+}
2121+2222+/// Unit type implementation - no embeds available.
2323+impl EmbedContentProvider for () {
2424+ fn get_embed_html(&self, _url: &str) -> Option<&str> {
2525+ None
2626+ }
2727+}
2828+2929+/// Resolves image URLs from markdown to actual paths.
3030+///
3131+/// Markdown may reference images by name (e.g., `/image/photo.jpg`).
3232+/// This trait maps those to actual CDN URLs or data URLs.
3333+pub trait ImageResolver {
3434+ /// Resolve an image URL from markdown to an actual URL.
3535+ ///
3636+ /// Returns `Some(resolved_url)` if the image is found,
3737+ /// `None` to use the original URL unchanged.
3838+ fn resolve_image_url(&self, url: &str) -> Option<String>;
3939+}
4040+4141+/// Unit type implementation - no image resolution.
4242+impl ImageResolver for () {
4343+ fn resolve_image_url(&self, _url: &str) -> Option<String> {
4444+ None
4545+ }
4646+}
4747+4848+/// Validates wikilinks during rendering.
4949+///
5050+/// Used to add CSS classes indicating whether a wikilink target exists.
5151+pub trait WikilinkValidator {
5252+ /// Check if a wikilink target is valid (exists).
5353+ fn is_valid_link(&self, target: &str) -> bool;
5454+}
5555+5656+/// Unit type implementation - all links are valid.
5757+impl WikilinkValidator for () {
5858+ fn is_valid_link(&self, _target: &str) -> bool {
5959+ true
6060+ }
6161+}
6262+6363+/// Reference implementations for common patterns.
6464+6565+impl<T: EmbedContentProvider> EmbedContentProvider for &T {
6666+ fn get_embed_html(&self, url: &str) -> Option<&str> {
6767+ (*self).get_embed_html(url)
6868+ }
6969+}
7070+7171+impl<T: ImageResolver> ImageResolver for &T {
7272+ fn resolve_image_url(&self, url: &str) -> Option<String> {
7373+ (*self).resolve_image_url(url)
7474+ }
7575+}
7676+7777+impl<T: WikilinkValidator> WikilinkValidator for &T {
7878+ fn is_valid_link(&self, target: &str) -> bool {
7979+ (*self).is_valid_link(target)
8080+ }
8181+}
8282+8383+impl<T: EmbedContentProvider> EmbedContentProvider for Option<T> {
8484+ fn get_embed_html(&self, url: &str) -> Option<&str> {
8585+ self.as_ref().and_then(|p| p.get_embed_html(url))
8686+ }
8787+}
8888+8989+impl<T: ImageResolver> ImageResolver for Option<T> {
9090+ fn resolve_image_url(&self, url: &str) -> Option<String> {
9191+ self.as_ref().and_then(|r| r.resolve_image_url(url))
9292+ }
9393+}
9494+9595+impl<T: WikilinkValidator> WikilinkValidator for Option<T> {
9696+ fn is_valid_link(&self, target: &str) -> bool {
9797+ self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true)
9898+ }
9999+}
100100+101101+#[cfg(test)]
102102+mod tests {
103103+ use super::*;
104104+105105+ struct TestEmbedProvider;
106106+107107+ impl EmbedContentProvider for TestEmbedProvider {
108108+ fn get_embed_html(&self, url: &str) -> Option<&str> {
109109+ if url == "at://test/embed" {
110110+ Some("<div>Test Embed</div>")
111111+ } else {
112112+ None
113113+ }
114114+ }
115115+ }
116116+117117+ struct TestImageResolver;
118118+119119+ impl ImageResolver for TestImageResolver {
120120+ fn resolve_image_url(&self, url: &str) -> Option<String> {
121121+ if url.starts_with("/image/") {
122122+ Some(format!("https://cdn.example.com{}", url))
123123+ } else {
124124+ None
125125+ }
126126+ }
127127+ }
128128+129129+ struct TestWikilinkValidator {
130130+ valid: Vec<String>,
131131+ }
132132+133133+ impl WikilinkValidator for TestWikilinkValidator {
134134+ fn is_valid_link(&self, target: &str) -> bool {
135135+ self.valid.iter().any(|v| v == target)
136136+ }
137137+ }
138138+139139+ #[test]
140140+ fn test_embed_provider() {
141141+ let provider = TestEmbedProvider;
142142+ assert_eq!(
143143+ provider.get_embed_html("at://test/embed"),
144144+ Some("<div>Test Embed</div>")
145145+ );
146146+ assert_eq!(provider.get_embed_html("at://other"), None);
147147+ }
148148+149149+ #[test]
150150+ fn test_image_resolver() {
151151+ let resolver = TestImageResolver;
152152+ assert_eq!(
153153+ resolver.resolve_image_url("/image/photo.jpg"),
154154+ Some("https://cdn.example.com/image/photo.jpg".to_string())
155155+ );
156156+ assert_eq!(resolver.resolve_image_url("https://other.com/img.png"), None);
157157+ }
158158+159159+ #[test]
160160+ fn test_wikilink_validator() {
161161+ let validator = TestWikilinkValidator {
162162+ valid: vec!["Home".to_string(), "About".to_string()],
163163+ };
164164+ assert!(validator.is_valid_link("Home"));
165165+ assert!(validator.is_valid_link("About"));
166166+ assert!(!validator.is_valid_link("Missing"));
167167+ }
168168+169169+ #[test]
170170+ fn test_unit_impls() {
171171+ let embed: () = ();
172172+ assert_eq!(embed.get_embed_html("anything"), None);
173173+174174+ let image: () = ();
175175+ assert_eq!(image.resolve_image_url("anything"), None);
176176+177177+ let wiki: () = ();
178178+ assert!(wiki.is_valid_link("anything")); // default true
179179+ }
180180+181181+ #[test]
182182+ fn test_option_impls() {
183183+ let some_provider: Option<TestEmbedProvider> = Some(TestEmbedProvider);
184184+ assert_eq!(
185185+ some_provider.get_embed_html("at://test/embed"),
186186+ Some("<div>Test Embed</div>")
187187+ );
188188+189189+ let none_provider: Option<TestEmbedProvider> = None;
190190+ assert_eq!(none_provider.get_embed_html("at://test/embed"), None);
191191+ }
192192+}
+13
crates/weaver-editor-core/src/types.rs
···33//! These types are framework-agnostic and can be used with any text buffer implementation.
4455use std::ops::Range;
66+77+use jacquard::types::string::AtUri;
88+use weaver_api::sh_weaver::embed::images::Image;
69use web_time::Instant;
1010+1111+/// Image stored in the editor, with optional publish state tracking.
1212+#[derive(Clone, Debug)]
1313+pub struct EditorImage {
1414+ /// The lexicon Image type (deserialized via from_json_value)
1515+ pub image: Image<'static>,
1616+ /// AT-URI of the PublishedBlob record (for cleanup on publish/delete).
1717+ /// None for existing images that are already in an entry record.
1818+ pub published_blob_uri: Option<AtUri<'static>>,
1919+}
720821/// Cursor state including position and affinity.
922#[derive(Clone, Debug, Copy, PartialEq, Eq)]
+333
crates/weaver-editor-core/src/undo.rs
···11+//! Undo/redo management for editor operations.
22+//!
33+//! Provides:
44+//! - `UndoManager` trait for abstracting undo implementations
55+//! - `UndoableBuffer<T>` - wraps a TextBuffer and provides undo/redo
66+77+use std::ops::Range;
88+99+use smol_str::{SmolStr, ToSmolStr};
1010+1111+use crate::text::TextBuffer;
1212+1313+/// Trait for managing undo/redo operations.
1414+///
1515+/// Implementations must actually perform the undo/redo, not just track state.
1616+/// For local editing, use `UndoableBuffer<T>` which wraps a TextBuffer.
1717+/// For Loro, wrap LoroText + loro::UndoManager together.
1818+pub trait UndoManager {
1919+ /// Check if undo is available.
2020+ fn can_undo(&self) -> bool;
2121+2222+ /// Check if redo is available.
2323+ fn can_redo(&self) -> bool;
2424+2525+ /// Perform undo. Returns true if successful.
2626+ fn undo(&mut self) -> bool;
2727+2828+ /// Perform redo. Returns true if successful.
2929+ fn redo(&mut self) -> bool;
3030+3131+ /// Clear all undo/redo history.
3232+ fn clear_history(&mut self);
3333+}
3434+3535+/// A recorded edit operation for undo/redo.
3636+#[derive(Debug, Clone)]
3737+struct EditOperation {
3838+ /// Character position where edit occurred
3939+ pos: usize,
4040+ /// Text that was deleted (empty for pure insertions)
4141+ deleted: SmolStr,
4242+ /// Text that was inserted (empty for pure deletions)
4343+ inserted: SmolStr,
4444+}
4545+4646+/// A TextBuffer wrapper that tracks edits and provides undo/redo.
4747+///
4848+/// This is the standard way to get undo support for local editing.
4949+/// All mutations go through this wrapper, which records them for undo.
5050+#[derive(Clone)]
5151+pub struct UndoableBuffer<T: TextBuffer> {
5252+ buffer: T,
5353+ undo_stack: Vec<EditOperation>,
5454+ redo_stack: Vec<EditOperation>,
5555+ max_steps: usize,
5656+}
5757+5858+impl<T: TextBuffer> Default for UndoableBuffer<T> {
5959+ fn default() -> Self {
6060+ Self::new(T::default(), 100)
6161+ }
6262+}
6363+6464+impl<T: TextBuffer> UndoableBuffer<T> {
6565+ /// Create a new undoable buffer wrapping the given buffer.
6666+ pub fn new(buffer: T, max_steps: usize) -> Self {
6767+ Self {
6868+ buffer,
6969+ undo_stack: Vec::new(),
7070+ redo_stack: Vec::new(),
7171+ max_steps,
7272+ }
7373+ }
7474+7575+ /// Get a reference to the inner buffer.
7676+ pub fn inner(&self) -> &T {
7777+ &self.buffer
7878+ }
7979+8080+ /// Get a mutable reference to the inner buffer.
8181+ /// WARNING: Edits made directly bypass undo tracking!
8282+ pub fn inner_mut(&mut self) -> &mut T {
8383+ &mut self.buffer
8484+ }
8585+8686+ /// Record an operation (called internally by TextBuffer impl).
8787+ fn record_op(&mut self, pos: usize, deleted: &str, inserted: &str) {
8888+ // Clear redo stack on new edit
8989+ self.redo_stack.clear();
9090+9191+ let op = EditOperation {
9292+ pos,
9393+ deleted: deleted.to_smolstr(),
9494+ inserted: inserted.to_smolstr(),
9595+ };
9696+9797+ self.undo_stack.push(op);
9898+9999+ // Trim if over max
100100+ while self.undo_stack.len() > self.max_steps {
101101+ self.undo_stack.remove(0);
102102+ }
103103+ }
104104+}
105105+106106+// Implement TextBuffer by delegating to inner buffer + recording operations
107107+impl<T: TextBuffer> TextBuffer for UndoableBuffer<T> {
108108+ fn len_bytes(&self) -> usize {
109109+ self.buffer.len_bytes()
110110+ }
111111+112112+ fn len_chars(&self) -> usize {
113113+ self.buffer.len_chars()
114114+ }
115115+116116+ fn insert(&mut self, char_offset: usize, text: &str) {
117117+ self.record_op(char_offset, "", text);
118118+ self.buffer.insert(char_offset, text);
119119+ }
120120+121121+ fn delete(&mut self, char_range: Range<usize>) {
122122+ // Get the text being deleted for undo
123123+ let deleted = self
124124+ .buffer
125125+ .slice(char_range.clone())
126126+ .map(|s| s.to_string())
127127+ .unwrap_or_default();
128128+ self.record_op(char_range.start, &deleted, "");
129129+ self.buffer.delete(char_range);
130130+ }
131131+132132+ fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> {
133133+ self.buffer.slice(char_range)
134134+ }
135135+136136+ fn char_at(&self, char_offset: usize) -> Option<char> {
137137+ self.buffer.char_at(char_offset)
138138+ }
139139+140140+ fn to_string(&self) -> String {
141141+ self.buffer.to_string()
142142+ }
143143+144144+ fn char_to_byte(&self, char_offset: usize) -> usize {
145145+ self.buffer.char_to_byte(char_offset)
146146+ }
147147+148148+ fn byte_to_char(&self, byte_offset: usize) -> usize {
149149+ self.buffer.byte_to_char(byte_offset)
150150+ }
151151+}
152152+153153+impl<T: TextBuffer> UndoManager for UndoableBuffer<T> {
154154+ fn can_undo(&self) -> bool {
155155+ !self.undo_stack.is_empty()
156156+ }
157157+158158+ fn can_redo(&self) -> bool {
159159+ !self.redo_stack.is_empty()
160160+ }
161161+162162+ fn undo(&mut self) -> bool {
163163+ let Some(op) = self.undo_stack.pop() else {
164164+ return false;
165165+ };
166166+167167+ // Apply inverse: delete what was inserted, insert what was deleted
168168+ let inserted_chars = op.inserted.chars().count();
169169+ if inserted_chars > 0 {
170170+ self.buffer.delete(op.pos..op.pos + inserted_chars);
171171+ }
172172+ if !op.deleted.is_empty() {
173173+ self.buffer.insert(op.pos, &op.deleted);
174174+ }
175175+176176+ self.redo_stack.push(op);
177177+ true
178178+ }
179179+180180+ fn redo(&mut self) -> bool {
181181+ let Some(op) = self.redo_stack.pop() else {
182182+ return false;
183183+ };
184184+185185+ // Re-apply original: delete what was deleted, insert what was inserted
186186+ let deleted_chars = op.deleted.chars().count();
187187+ if deleted_chars > 0 {
188188+ self.buffer.delete(op.pos..op.pos + deleted_chars);
189189+ }
190190+ if !op.inserted.is_empty() {
191191+ self.buffer.insert(op.pos, &op.inserted);
192192+ }
193193+194194+ self.undo_stack.push(op);
195195+ true
196196+ }
197197+198198+ fn clear_history(&mut self) {
199199+ self.undo_stack.clear();
200200+ self.redo_stack.clear();
201201+ }
202202+}
203203+204204+#[cfg(test)]
205205+mod tests {
206206+ use super::*;
207207+ use crate::EditorRope;
208208+209209+ #[test]
210210+ fn test_undoable_buffer_insert_undo() {
211211+ let rope = EditorRope::from_str("hello");
212212+ let mut buf = UndoableBuffer::new(rope, 100);
213213+214214+ assert_eq!(buf.to_string(), "hello");
215215+ assert!(!buf.can_undo());
216216+217217+ // Insert " world"
218218+ buf.insert(5, " world");
219219+ assert_eq!(buf.to_string(), "hello world");
220220+ assert!(buf.can_undo());
221221+222222+ // Undo
223223+ assert!(buf.undo());
224224+ assert_eq!(buf.to_string(), "hello");
225225+ assert!(!buf.can_undo());
226226+ assert!(buf.can_redo());
227227+228228+ // Redo
229229+ assert!(buf.redo());
230230+ assert_eq!(buf.to_string(), "hello world");
231231+ assert!(buf.can_undo());
232232+ assert!(!buf.can_redo());
233233+ }
234234+235235+ #[test]
236236+ fn test_undoable_buffer_delete_undo() {
237237+ let rope = EditorRope::from_str("hello world");
238238+ let mut buf = UndoableBuffer::new(rope, 100);
239239+240240+ // Delete " world"
241241+ buf.delete(5..11);
242242+ assert_eq!(buf.to_string(), "hello");
243243+ assert!(buf.can_undo());
244244+245245+ // Undo
246246+ assert!(buf.undo());
247247+ assert_eq!(buf.to_string(), "hello world");
248248+ }
249249+250250+ #[test]
251251+ fn test_undoable_buffer_replace_undo() {
252252+ let rope = EditorRope::from_str("hello world");
253253+ let mut buf = UndoableBuffer::new(rope, 100);
254254+255255+ // Replace "world" with "rust"
256256+ buf.delete(6..11);
257257+ buf.insert(6, "rust");
258258+ assert_eq!(buf.to_string(), "hello rust");
259259+260260+ // Undo insert
261261+ assert!(buf.undo());
262262+ assert_eq!(buf.to_string(), "hello ");
263263+264264+ // Undo delete
265265+ assert!(buf.undo());
266266+ assert_eq!(buf.to_string(), "hello world");
267267+ }
268268+269269+ #[test]
270270+ fn test_new_edit_clears_redo() {
271271+ let rope = EditorRope::from_str("abc");
272272+ let mut buf = UndoableBuffer::new(rope, 100);
273273+274274+ buf.insert(3, "d");
275275+ assert!(buf.undo());
276276+ assert!(buf.can_redo());
277277+278278+ // New edit should clear redo
279279+ buf.insert(3, "e");
280280+ assert!(!buf.can_redo());
281281+ }
282282+283283+ #[test]
284284+ fn test_max_steps() {
285285+ let rope = EditorRope::from_str("");
286286+ let mut buf = UndoableBuffer::new(rope, 3);
287287+288288+ buf.insert(0, "a");
289289+ buf.insert(1, "b");
290290+ buf.insert(2, "c");
291291+ buf.insert(3, "d"); // should evict "a"
292292+293293+ assert_eq!(buf.to_string(), "abcd");
294294+295295+ // Should only be able to undo 3 times
296296+ assert!(buf.undo()); // removes d
297297+ assert!(buf.undo()); // removes c
298298+ assert!(buf.undo()); // removes b
299299+ assert!(!buf.undo()); // a was evicted
300300+301301+ assert_eq!(buf.to_string(), "a");
302302+ }
303303+304304+ #[test]
305305+ fn test_multiple_undo_redo_cycles() {
306306+ let rope = EditorRope::from_str("");
307307+ let mut buf = UndoableBuffer::new(rope, 100);
308308+309309+ buf.insert(0, "a");
310310+ buf.insert(1, "b");
311311+ buf.insert(2, "c");
312312+ assert_eq!(buf.to_string(), "abc");
313313+314314+ // Undo all
315315+ assert!(buf.undo());
316316+ assert!(buf.undo());
317317+ assert!(buf.undo());
318318+ assert_eq!(buf.to_string(), "");
319319+320320+ // Redo all
321321+ assert!(buf.redo());
322322+ assert!(buf.redo());
323323+ assert!(buf.redo());
324324+ assert_eq!(buf.to_string(), "abc");
325325+326326+ // Partial undo then new edit
327327+ assert!(buf.undo()); // "ab"
328328+ assert!(buf.undo()); // "a"
329329+ buf.insert(1, "x");
330330+ assert_eq!(buf.to_string(), "ax");
331331+ assert!(!buf.can_redo()); // redo cleared
332332+ }
333333+}
+321
crates/weaver-editor-core/src/writer/embed.rs
···11+//! Embed rendering and image resolution for EditorWriter.
22+33+use core::fmt;
44+use std::collections::HashMap;
55+use std::ops::Range;
66+77+use jacquard::IntoStatic;
88+use jacquard::types::{ident::AtIdentifier, string::Rkey};
99+use markdown_weaver::{CowStr, EmbedType, Event};
1010+use markdown_weaver_escape::{StrWrite, escape_html};
1111+1212+use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
1313+use crate::syntax::{SyntaxSpanInfo, SyntaxType};
1414+use crate::types::EditorImage;
1515+1616+use super::EditorWriter;
1717+1818+/// Resolved image path type.
1919+#[derive(Clone, Debug)]
2020+enum ResolvedImage {
2121+ /// Data URL for immediate preview (still uploading)
2222+ Pending(String),
2323+ /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}`
2424+ Draft {
2525+ blob_rkey: Rkey<'static>,
2626+ ident: AtIdentifier<'static>,
2727+ },
2828+ /// Published image: `/image/{ident}/{entry_rkey}/{name}`
2929+ Published {
3030+ entry_rkey: Rkey<'static>,
3131+ ident: AtIdentifier<'static>,
3232+ },
3333+}
3434+3535+/// Resolves image paths in the editor.
3636+///
3737+/// Supports three states for images:
3838+/// - Pending: uses data URL for immediate preview while upload is in progress
3939+/// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}`
4040+/// - Published: uses path format `/image/{did}/{entry_rkey}/{name}`
4141+///
4242+/// Image URLs in markdown use the format `/image/{name}`.
4343+#[derive(Clone, Default)]
4444+pub struct EditorImageResolver {
4545+ /// All resolved images: name -> resolved path info
4646+ images: HashMap<String, ResolvedImage>,
4747+}
4848+4949+impl EditorImageResolver {
5050+ pub fn new() -> Self {
5151+ Self::default()
5252+ }
5353+5454+ /// Add a pending image with a data URL for immediate preview.
5555+ pub fn add_pending(&mut self, name: String, data_url: String) {
5656+ self.images.insert(name, ResolvedImage::Pending(data_url));
5757+ }
5858+5959+ /// Promote a pending image to uploaded (draft) status.
6060+ pub fn promote_to_uploaded(
6161+ &mut self,
6262+ name: &str,
6363+ blob_rkey: Rkey<'static>,
6464+ ident: AtIdentifier<'static>,
6565+ ) {
6666+ self.images
6767+ .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident });
6868+ }
6969+7070+ /// Add an already-uploaded draft image.
7171+ pub fn add_uploaded(
7272+ &mut self,
7373+ name: String,
7474+ blob_rkey: Rkey<'static>,
7575+ ident: AtIdentifier<'static>,
7676+ ) {
7777+ self.images
7878+ .insert(name, ResolvedImage::Draft { blob_rkey, ident });
7979+ }
8080+8181+ /// Add a published image.
8282+ pub fn add_published(
8383+ &mut self,
8484+ name: String,
8585+ entry_rkey: Rkey<'static>,
8686+ ident: AtIdentifier<'static>,
8787+ ) {
8888+ self.images
8989+ .insert(name, ResolvedImage::Published { entry_rkey, ident });
9090+ }
9191+9292+ /// Check if an image is pending upload.
9393+ pub fn is_pending(&self, name: &str) -> bool {
9494+ matches!(self.images.get(name), Some(ResolvedImage::Pending(_)))
9595+ }
9696+9797+ /// Build a resolver from editor images and user identifier.
9898+ ///
9999+ /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included.
100100+ /// For published mode (entry_rkey=Some), all images are included.
101101+ pub fn from_images<'a>(
102102+ images: impl IntoIterator<Item = &'a EditorImage>,
103103+ ident: AtIdentifier<'static>,
104104+ entry_rkey: Option<Rkey<'static>>,
105105+ ) -> Self {
106106+ let mut resolver = Self::new();
107107+ for editor_image in images {
108108+ // Get the name from the Image (use alt text as fallback if name is empty)
109109+ let name = editor_image
110110+ .image
111111+ .name
112112+ .as_ref()
113113+ .map(|n| n.to_string())
114114+ .unwrap_or_else(|| editor_image.image.alt.to_string());
115115+116116+ if name.is_empty() {
117117+ continue;
118118+ }
119119+120120+ match &entry_rkey {
121121+ // Published mode: use entry rkey for all images
122122+ Some(rkey) => {
123123+ resolver.add_published(name, rkey.clone(), ident.clone());
124124+ }
125125+ // Draft mode: use published_blob_uri rkey
126126+ None => {
127127+ let blob_rkey = match &editor_image.published_blob_uri {
128128+ Some(uri) => match uri.rkey() {
129129+ Some(rkey) => rkey.0.clone().into_static(),
130130+ None => continue,
131131+ },
132132+ None => continue,
133133+ };
134134+ resolver.add_uploaded(name, blob_rkey, ident.clone());
135135+ }
136136+ }
137137+ }
138138+ resolver
139139+ }
140140+}
141141+142142+impl ImageResolver for EditorImageResolver {
143143+ fn resolve_image_url(&self, url: &str) -> Option<String> {
144144+ // Extract image name from /image/{name} format
145145+ let name = url.strip_prefix("/image/").unwrap_or(url);
146146+147147+ let resolved = self.images.get(name)?;
148148+ match resolved {
149149+ ResolvedImage::Pending(data_url) => Some(data_url.clone()),
150150+ ResolvedImage::Draft { blob_rkey, ident } => {
151151+ Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name))
152152+ }
153153+ ResolvedImage::Published { entry_rkey, ident } => {
154154+ Some(format!("/image/{}/{}/{}", ident, entry_rkey, name))
155155+ }
156156+ }
157157+ }
158158+}
159159+160160+// write_embed implementation
161161+impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
162162+where
163163+ I: Iterator<Item = (Event<'a>, Range<usize>)>,
164164+ E: EmbedContentProvider,
165165+ R: ImageResolver,
166166+ W: WikilinkValidator,
167167+{
168168+ pub(crate) fn write_embed(
169169+ &mut self,
170170+ range: Range<usize>,
171171+ _embed_type: EmbedType,
172172+ dest_url: CowStr<'_>,
173173+ title: CowStr<'_>,
174174+ _id: CowStr<'_>,
175175+ attrs: Option<markdown_weaver::WeaverAttributes<'_>>,
176176+ ) -> Result<(), fmt::Error> {
177177+ // Embed rendering: all syntax elements share one syn_id for visibility toggling
178178+ // Structure: ![[ url-as-link ]] <embed-content>
179179+ let raw_text = &self.source[range.clone()];
180180+ let syn_id = self.gen_syn_id();
181181+ let opening_char_start = self.last_char_offset;
182182+183183+ // Extract the URL from raw text (between ![[ and ]])
184184+ let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") {
185185+ &raw_text[3..raw_text.len() - 2]
186186+ } else {
187187+ dest_url.as_ref()
188188+ };
189189+190190+ // Calculate char positions
191191+ let url_char_len = url_text.chars().count();
192192+ let opening_char_end = opening_char_start + 3; // "![["
193193+ let url_char_start = opening_char_end;
194194+ let url_char_end = url_char_start + url_char_len;
195195+ let closing_char_start = url_char_end;
196196+ let closing_char_end = closing_char_start + 2; // "]]"
197197+ let formatted_range = opening_char_start..closing_char_end;
198198+199199+ // 1. Emit opening ![[ syntax span
200200+ if raw_text.starts_with("![[") {
201201+ write!(
202202+ &mut self.writer,
203203+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>",
204204+ syn_id, opening_char_start, opening_char_end
205205+ )?;
206206+207207+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
208208+ syn_id: syn_id.clone(),
209209+ char_range: opening_char_start..opening_char_end,
210210+ syntax_type: SyntaxType::Inline,
211211+ formatted_range: Some(formatted_range.clone()),
212212+ });
213213+214214+ self.record_mapping(
215215+ range.start..range.start + 3,
216216+ opening_char_start..opening_char_end,
217217+ );
218218+ }
219219+220220+ // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax)
221221+ let url = dest_url.as_ref();
222222+ let link_href = if url.starts_with("at://") {
223223+ format!("https://alpha.weaver.sh/record/{}", url)
224224+ } else {
225225+ url.to_string()
226226+ };
227227+228228+ write!(
229229+ &mut self.writer,
230230+ "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">",
231231+ link_href, syn_id, url_char_start, url_char_end
232232+ )?;
233233+ escape_html(&mut self.writer, url_text)?;
234234+ self.write("</a>")?;
235235+236236+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
237237+ syn_id: syn_id.clone(),
238238+ char_range: url_char_start..url_char_end,
239239+ syntax_type: SyntaxType::Inline,
240240+ formatted_range: Some(formatted_range.clone()),
241241+ });
242242+243243+ self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end);
244244+245245+ // 3. Emit closing ]] syntax span
246246+ if raw_text.ends_with("]]") {
247247+ write!(
248248+ &mut self.writer,
249249+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
250250+ syn_id, closing_char_start, closing_char_end
251251+ )?;
252252+253253+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
254254+ syn_id: syn_id.clone(),
255255+ char_range: closing_char_start..closing_char_end,
256256+ syntax_type: SyntaxType::Inline,
257257+ formatted_range: Some(formatted_range.clone()),
258258+ });
259259+260260+ self.record_mapping(
261261+ range.end - 2..range.end,
262262+ closing_char_start..closing_char_end,
263263+ );
264264+ }
265265+266266+ // Collect AT URI for later resolution
267267+ if url.starts_with("at://") || url.starts_with("did:") {
268268+ self.ref_collector.add_at_embed(
269269+ url,
270270+ if title.is_empty() {
271271+ None
272272+ } else {
273273+ Some(title.as_ref())
274274+ },
275275+ );
276276+ }
277277+278278+ // 4. Emit the actual embed content
279279+ // Try to get content from attributes first
280280+ let content_from_attrs = if let Some(ref attrs) = attrs {
281281+ attrs
282282+ .attrs
283283+ .iter()
284284+ .find(|(k, _)| k.as_ref() == "content")
285285+ .map(|(_, v)| v.as_ref().to_string())
286286+ } else {
287287+ None
288288+ };
289289+290290+ // If no content in attrs, try provider
291291+ // Convert to owned to avoid borrow checker issues with self.write()
292292+ // TODO: figure out a way to do this that doesn't involve cloning
293293+ let content: Option<String> = if content_from_attrs.is_some() {
294294+ content_from_attrs
295295+ } else if let Some(ref provider) = self.embed_provider {
296296+ provider.get_embed_html(url).map(|s| s.to_string())
297297+ } else {
298298+ None
299299+ };
300300+301301+ if let Some(ref html_content) = content {
302302+ // Write the pre-rendered content directly
303303+ self.write(html_content)?;
304304+ } else {
305305+ // Fallback: render as placeholder div
306306+ self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?;
307307+ self.write("<span class=\"embed-loading\">Loading embed...</span>")?;
308308+ self.write("</div>")?;
309309+ }
310310+311311+ // Consume the text events for the URL (they're still in the iterator)
312312+ // Use consume_until_end() since we already wrote the URL from source
313313+ self.consume_until_end();
314314+315315+ // Update offsets
316316+ self.last_char_offset = closing_char_end;
317317+ self.last_byte_offset = range.end;
318318+319319+ Ok(())
320320+ }
321321+}
+686
crates/weaver-editor-core/src/writer/events.rs
···11+//! Event processing for EditorWriter - the main run loop and event dispatch.
22+33+use core::fmt;
44+use std::fmt::Write as _;
55+use std::ops::Range;
66+77+use markdown_weaver::{Event, TagEnd};
88+use markdown_weaver_escape::{escape_html, escape_html_body_text_with_char_count};
99+1010+use crate::offset_map::OffsetMapping;
1111+use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
1212+use crate::syntax::{SyntaxSpanInfo, SyntaxType};
1313+1414+use super::{EditorWriter, WriterResult};
1515+1616+// Main run loop
1717+impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
1818+where
1919+ I: Iterator<Item = (Event<'a>, Range<usize>)>,
2020+ E: EmbedContentProvider,
2121+ R: ImageResolver,
2222+ W: WikilinkValidator,
2323+{
2424+ /// Process markdown events and write HTML.
2525+ ///
2626+ /// Returns offset mappings and paragraph boundaries. The HTML is written
2727+ /// to the writer passed in the constructor.
2828+ pub fn run(mut self) -> Result<WriterResult, fmt::Error> {
2929+ while let Some((event, range)) = self.events.next() {
3030+ tracing::trace!(
3131+ target: "weaver::writer",
3232+ event = ?event,
3333+ byte_range = ?range,
3434+ "processing event"
3535+ );
3636+3737+ // For End events, emit any trailing content within the event's range
3838+ // BEFORE calling end_tag (which calls end_node and clears current_node_id)
3939+ //
4040+ // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough),
4141+ // the closing syntax must be emitted AFTER the closing HTML tag, not before.
4242+ // Otherwise the closing `**` span ends up INSIDE the <strong> element.
4343+ // These tags handle their own closing syntax in end_tag().
4444+ // Image and Embed handle ALL their syntax in the Start event, so exclude them too.
4545+ let is_self_handled_end = matches!(
4646+ &event,
4747+ Event::End(
4848+ TagEnd::Strong
4949+ | TagEnd::Emphasis
5050+ | TagEnd::Strikethrough
5151+ | TagEnd::Image
5252+ | TagEnd::Embed
5353+ )
5454+ );
5555+5656+ if matches!(&event, Event::End(_)) && !is_self_handled_end {
5757+ // Emit gap from last_byte_offset to range.end
5858+ self.emit_gap_before(range.end)?;
5959+ } else if !matches!(&event, Event::End(_)) {
6060+ // For other events, emit any gap before range.start
6161+ // (emit_syntax handles char offset tracking)
6262+ self.emit_gap_before(range.start)?;
6363+ }
6464+ // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML
6565+6666+ // Store last_byte before processing
6767+ let last_byte_before = self.last_byte_offset;
6868+6969+ // Process the event (passing range for tag syntax)
7070+ self.process_event(event, range.clone())?;
7171+7272+ // Update tracking - but don't override if start_tag manually updated it
7373+ // (for inline formatting tags that emit opening syntax)
7474+ if self.last_byte_offset == last_byte_before {
7575+ // Event didn't update offset, so we update it
7676+ self.last_byte_offset = range.end;
7777+ }
7878+ // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
7979+ }
8080+8181+ // Emit any trailing syntax
8282+ self.emit_gap_before(self.source.len())?;
8383+8484+ // Handle unmapped trailing content (stripped by parser)
8585+ // This includes trailing spaces that markdown ignores
8686+ let doc_byte_len = self.source.len();
8787+ let doc_char_len = self.source_len_chars;
8888+8989+ if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
9090+ // Emit the trailing content as visible syntax
9191+ if self.last_byte_offset < doc_byte_len {
9292+ let trailing = &self.source[self.last_byte_offset..];
9393+ if !trailing.is_empty() {
9494+ let char_start = self.last_char_offset;
9595+ let trailing_char_len = trailing.chars().count();
9696+9797+ let char_end = char_start + trailing_char_len;
9898+ let syn_id = self.gen_syn_id();
9999+100100+ write!(
101101+ &mut self.writer,
102102+ "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
103103+ syn_id, char_start, char_end
104104+ )?;
105105+ escape_html(&mut self.writer, trailing)?;
106106+ self.write("</span>")?;
107107+108108+ // Record mapping if we have a node
109109+ if let Some(ref node_id) = self.current_node.id {
110110+ let mapping = OffsetMapping {
111111+ byte_range: self.last_byte_offset..doc_byte_len,
112112+ char_range: char_start..char_end,
113113+ node_id: node_id.clone(),
114114+ char_offset_in_node: self.current_node.char_offset,
115115+ child_index: None,
116116+ utf16_len: trailing_char_len, // visible
117117+ };
118118+ self.current_para.offset_maps.push(mapping);
119119+ self.current_node.char_offset += trailing_char_len;
120120+ }
121121+122122+ self.last_char_offset = char_start + trailing_char_len;
123123+ }
124124+ }
125125+ }
126126+127127+ // Add any remaining accumulated data for the last paragraph
128128+ // (content that wasn't followed by a paragraph boundary)
129129+ if !self.current_para.offset_maps.is_empty()
130130+ || !self.current_para.syntax_spans.is_empty()
131131+ || !self.ref_collector.refs.is_empty()
132132+ {
133133+ self.offset_maps_by_para
134134+ .push(std::mem::take(&mut self.current_para.offset_maps));
135135+ self.syntax_spans_by_para
136136+ .push(std::mem::take(&mut self.current_para.syntax_spans));
137137+ self.refs_by_para
138138+ .push(std::mem::take(&mut self.ref_collector.refs));
139139+ }
140140+141141+ // Get HTML segments from writer
142142+ let html_segments = self.writer.into_segments();
143143+144144+ Ok(WriterResult {
145145+ html_segments,
146146+ offset_maps_by_paragraph: self.offset_maps_by_para,
147147+ paragraph_ranges: self.paragraphs.ranges,
148148+ syntax_spans_by_paragraph: self.syntax_spans_by_para,
149149+ collected_refs_by_paragraph: self.refs_by_para,
150150+ })
151151+ }
152152+153153+ fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> {
154154+ use Event::*;
155155+156156+ match event {
157157+ Start(tag) => self.start_tag(tag, range)?,
158158+ End(tag) => self.end_tag(tag, range)?,
159159+ Text(text) => {
160160+ // If buffering code, append to buffer instead of writing
161161+ if let Some((_, ref mut content)) = self.code_block.buffer {
162162+ content.push_str(&text);
163163+164164+ // Track byte and char ranges for code block content
165165+ let text_char_len = text.chars().count();
166166+ let text_byte_len = text.len();
167167+ if let Some(ref mut code_byte_range) = self.code_block.byte_range {
168168+ // Extend existing ranges
169169+ code_byte_range.end = range.end;
170170+ if let Some(ref mut code_char_range) = self.code_block.char_range {
171171+ code_char_range.end = self.last_char_offset + text_char_len;
172172+ }
173173+ } else {
174174+ // First text in code block - start tracking
175175+ self.code_block.byte_range = Some(range.clone());
176176+ self.code_block.char_range =
177177+ Some(self.last_char_offset..self.last_char_offset + text_char_len);
178178+ }
179179+ // Update offsets so paragraph boundary is correct
180180+ self.last_char_offset += text_char_len;
181181+ self.last_byte_offset += text_byte_len;
182182+ } else if !self.in_non_writing_block {
183183+ // Escape HTML and count chars in one pass
184184+ let char_start = self.last_char_offset;
185185+ let text_char_len =
186186+ escape_html_body_text_with_char_count(&mut self.writer, &text)?;
187187+ let char_end = char_start + text_char_len;
188188+189189+ // Text becomes a text node child of the current container
190190+ if text_char_len > 0 {
191191+ self.current_node.child_count += 1;
192192+ }
193193+194194+ // Record offset mapping
195195+ self.record_mapping(range.clone(), char_start..char_end);
196196+197197+ // Update char offset tracking
198198+ self.last_char_offset = char_end;
199199+ self.end_newline = text.ends_with('\n');
200200+ }
201201+ }
202202+ Code(text) => {
203203+ let format_start = self.last_char_offset;
204204+ let raw_text = &self.source[range.clone()];
205205+206206+ // Track opening span index so we can set formatted_range later
207207+ let opening_span_idx = if raw_text.starts_with('`') {
208208+ let syn_id = self.gen_syn_id();
209209+ let char_start = self.last_char_offset;
210210+ let backtick_char_end = char_start + 1;
211211+ write!(
212212+ &mut self.writer,
213213+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">`</span>",
214214+ syn_id, char_start, backtick_char_end
215215+ )?;
216216+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
217217+ syn_id,
218218+ char_range: char_start..backtick_char_end,
219219+ syntax_type: SyntaxType::Inline,
220220+ formatted_range: None, // Set after we know the full range
221221+ });
222222+ self.last_char_offset += 1;
223223+ Some(self.current_para.syntax_spans.len() - 1)
224224+ } else {
225225+ None
226226+ };
227227+228228+ self.write("<code>")?;
229229+230230+ // Track offset mapping for code content
231231+ let content_char_start = self.last_char_offset;
232232+ let text_char_len =
233233+ escape_html_body_text_with_char_count(&mut self.writer, &text)?;
234234+ let content_char_end = content_char_start + text_char_len;
235235+236236+ // Record offset mapping (code content is visible)
237237+ self.record_mapping(range.clone(), content_char_start..content_char_end);
238238+ self.last_char_offset = content_char_end;
239239+240240+ self.write("</code>")?;
241241+242242+ // Emit closing backtick and track it
243243+ if raw_text.ends_with('`') {
244244+ let syn_id = self.gen_syn_id();
245245+ let backtick_char_start = self.last_char_offset;
246246+ let backtick_char_end = backtick_char_start + 1;
247247+ write!(
248248+ &mut self.writer,
249249+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">`</span>",
250250+ syn_id, backtick_char_start, backtick_char_end
251251+ )?;
252252+253253+ // Now we know the full formatted range
254254+ let formatted_range = format_start..backtick_char_end;
255255+256256+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
257257+ syn_id,
258258+ char_range: backtick_char_start..backtick_char_end,
259259+ syntax_type: SyntaxType::Inline,
260260+ formatted_range: Some(formatted_range.clone()),
261261+ });
262262+263263+ // Update opening span with formatted_range
264264+ if let Some(idx) = opening_span_idx {
265265+ self.current_para.syntax_spans[idx].formatted_range =
266266+ Some(formatted_range);
267267+ }
268268+269269+ self.last_char_offset += 1;
270270+ }
271271+ }
272272+ InlineMath(text) => {
273273+ self.process_inline_math(&text, range)?;
274274+ }
275275+ DisplayMath(text) => {
276276+ self.process_display_math(&text, range)?;
277277+ }
278278+ Html(html) => {
279279+ // Track offset mapping for raw HTML
280280+ let char_start = self.last_char_offset;
281281+ let html_char_len = html.chars().count();
282282+ let char_end = char_start + html_char_len;
283283+284284+ self.write(&html)?;
285285+286286+ // Record mapping for inline HTML
287287+ self.record_mapping(range.clone(), char_start..char_end);
288288+ self.last_char_offset = char_end;
289289+ }
290290+ InlineHtml(html) => {
291291+ // Track offset mapping for raw HTML
292292+ let char_start = self.last_char_offset;
293293+ let html_char_len = html.chars().count();
294294+ let char_end = char_start + html_char_len;
295295+ self.write(r#"<span class="html-embed html-embed-inline">"#)?;
296296+ self.write(&html)?;
297297+ self.write("</span>")?;
298298+ // Record mapping for inline HTML
299299+ self.record_mapping(range.clone(), char_start..char_end);
300300+ self.last_char_offset = char_end;
301301+ }
302302+ SoftBreak => {
303303+ // Emit <br> for visual line break, plus a space for cursor positioning.
304304+ // This space maps to the \n so the cursor can land here when navigating.
305305+ let char_start = self.last_char_offset;
306306+307307+ // Emit <br>
308308+ self.write("<br />")?;
309309+ self.current_node.child_count += 1;
310310+311311+ // Emit space for cursor positioning - this gives the browser somewhere
312312+ // to place the cursor when navigating to this line
313313+ self.write("\u{200B}")?;
314314+ self.current_node.child_count += 1;
315315+316316+ // Map the space to the newline position - cursor landing here means
317317+ // we're at the end of the line (after the \n)
318318+ if let Some(ref node_id) = self.current_node.id {
319319+ let mapping = OffsetMapping {
320320+ byte_range: range.clone(),
321321+ char_range: char_start..char_start + 1,
322322+ node_id: node_id.clone(),
323323+ char_offset_in_node: self.current_node.char_offset,
324324+ child_index: None,
325325+ utf16_len: 1, // the space we emitted
326326+ };
327327+ self.current_para.offset_maps.push(mapping);
328328+ self.current_node.char_offset += 1;
329329+ }
330330+331331+ self.last_char_offset = char_start + 1; // +1 for the \n
332332+ }
333333+ HardBreak => {
334334+ // Emit the two spaces as visible (dimmed) text, then <br>
335335+ let gap = &self.source[range.clone()];
336336+ if gap.ends_with('\n') {
337337+ let spaces = &gap[..gap.len() - 1]; // everything except the \n
338338+ let char_start = self.last_char_offset;
339339+ let spaces_char_len = spaces.chars().count();
340340+ let char_end = char_start + spaces_char_len;
341341+342342+ // Emit and map the visible spaces
343343+ let syn_id = self.gen_syn_id();
344344+ write!(
345345+ &mut self.writer,
346346+ "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
347347+ syn_id, char_start, char_end
348348+ )?;
349349+ escape_html(&mut self.writer, spaces)?;
350350+ self.write("</span>")?;
351351+352352+ // Count this span as a child
353353+ self.current_node.child_count += 1;
354354+355355+ self.record_mapping(
356356+ range.start..range.start + spaces.len(),
357357+ char_start..char_end,
358358+ );
359359+360360+ // Now the actual line break <br>
361361+ self.write("<br />")?;
362362+363363+ // Count the <br> as a child
364364+ self.current_node.child_count += 1;
365365+366366+ // After <br>, emit plain zero-width space for cursor positioning
367367+ self.write("\u{200B}")?;
368368+369369+ // Count the zero-width space text node as a child
370370+ self.current_node.child_count += 1;
371371+372372+ // Map the newline position to the zero-width space text node
373373+ if let Some(ref node_id) = self.current_node.id {
374374+ let newline_char_offset = char_start + spaces_char_len;
375375+ let mapping = OffsetMapping {
376376+ byte_range: range.start + spaces.len()..range.end,
377377+ char_range: newline_char_offset..newline_char_offset + 1,
378378+ node_id: node_id.clone(),
379379+ char_offset_in_node: self.current_node.char_offset,
380380+ child_index: None, // text node - TreeWalker will find it
381381+ utf16_len: 1, // zero-width space is 1 UTF-16 unit
382382+ };
383383+ self.current_para.offset_maps.push(mapping);
384384+385385+ // Increment char offset - TreeWalker will encounter this text node
386386+ self.current_node.char_offset += 1;
387387+ }
388388+389389+ self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n
390390+ } else {
391391+ // Fallback: just <br>
392392+ self.write("<br />")?;
393393+ }
394394+ }
395395+ Rule => {
396396+ if !self.end_newline {
397397+ self.write("\n")?;
398398+ }
399399+400400+ // Emit syntax span before the rendered element
401401+ if range.start < range.end {
402402+ let raw_text = &self.source[range];
403403+ let trimmed = raw_text.trim();
404404+ if !trimmed.is_empty() {
405405+ let syn_id = self.gen_syn_id();
406406+ let char_start = self.last_char_offset;
407407+ let char_len = trimmed.chars().count();
408408+ let char_end = char_start + char_len;
409409+410410+ write!(
411411+ &mut self.writer,
412412+ "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
413413+ syn_id, char_start, char_end
414414+ )?;
415415+ escape_html(&mut self.writer, trimmed)?;
416416+ self.write("</span>")?;
417417+418418+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
419419+ syn_id,
420420+ char_range: char_start..char_end,
421421+ syntax_type: SyntaxType::Block,
422422+ formatted_range: None,
423423+ });
424424+ }
425425+ }
426426+427427+ // Wrap <hr /> in toggle-block for future cursor-based toggling
428428+ self.write("<div class=\"toggle-block\"><hr /></div>")?;
429429+ }
430430+ FootnoteReference(name) => {
431431+ // Emit [^name] as styled (but NOT hidden) inline span
432432+ let raw_text = &self.source[range.clone()];
433433+ let char_start = self.last_char_offset;
434434+ let syntax_char_len = raw_text.chars().count();
435435+ let char_end = char_start + syntax_char_len;
436436+437437+ // Use footnote-ref class for styling, not md-syntax-inline (which hides)
438438+ write!(
439439+ &mut self.writer,
440440+ "<span class=\"footnote-ref\" data-char-start=\"{}\" data-char-end=\"{}\" data-footnote=\"{}\">",
441441+ char_start, char_end, name
442442+ )?;
443443+ escape_html(&mut self.writer, raw_text)?;
444444+ self.write("</span>")?;
445445+446446+ // Record offset mapping
447447+ self.record_mapping(range.clone(), char_start..char_end);
448448+449449+ // Count as child
450450+ self.current_node.child_count += 1;
451451+452452+ // Update tracking
453453+ self.last_char_offset = char_end;
454454+ self.last_byte_offset = range.end;
455455+ }
456456+ TaskListMarker(checked) => {
457457+ // Emit the [ ] or [x] syntax
458458+ if range.start < range.end {
459459+ let raw_text = &self.source[range];
460460+ if let Some(bracket_pos) = raw_text.find('[') {
461461+ let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3);
462462+ let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())];
463463+464464+ let syn_id = self.gen_syn_id();
465465+ let char_start = self.last_char_offset;
466466+ let syntax_char_len = syntax.chars().count();
467467+ let char_end = char_start + syntax_char_len;
468468+469469+ write!(
470470+ &mut self.writer,
471471+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
472472+ syn_id, char_start, char_end
473473+ )?;
474474+ escape_html(&mut self.writer, syntax)?;
475475+ self.write("</span> ")?;
476476+477477+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
478478+ syn_id,
479479+ char_range: char_start..char_end,
480480+ syntax_type: SyntaxType::Inline,
481481+ formatted_range: None,
482482+ });
483483+ }
484484+ }
485485+486486+ if checked {
487487+ self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>")?;
488488+ } else {
489489+ self.write("<input disabled=\"\" type=\"checkbox\"/>")?;
490490+ }
491491+ }
492492+ WeaverBlock(text) => {
493493+ // Buffer WeaverBlock content for parsing on End
494494+ self.weaver_block.buffer.push_str(&text);
495495+ }
496496+ }
497497+ Ok(())
498498+ }
499499+500500+ /// Process inline math ($...$)
501501+ fn process_inline_math(&mut self, text: &str, range: Range<usize>) -> Result<(), fmt::Error> {
502502+ let raw_text = &self.source[range.clone()];
503503+ let syn_id = self.gen_syn_id();
504504+ let opening_char_start = self.last_char_offset;
505505+506506+ // Calculate char positions
507507+ let text_char_len = text.chars().count();
508508+ let opening_char_end = opening_char_start + 1; // "$"
509509+ let content_char_start = opening_char_end;
510510+ let content_char_end = content_char_start + text_char_len;
511511+ let closing_char_start = content_char_end;
512512+ let closing_char_end = closing_char_start + 1; // "$"
513513+ let formatted_range = opening_char_start..closing_char_end;
514514+515515+ // 1. Emit opening $ syntax span
516516+ if raw_text.starts_with('$') {
517517+ write!(
518518+ &mut self.writer,
519519+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$</span>",
520520+ syn_id, opening_char_start, opening_char_end
521521+ )?;
522522+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
523523+ syn_id: syn_id.clone(),
524524+ char_range: opening_char_start..opening_char_end,
525525+ syntax_type: SyntaxType::Inline,
526526+ formatted_range: Some(formatted_range.clone()),
527527+ });
528528+ self.record_mapping(
529529+ range.start..range.start + 1,
530530+ opening_char_start..opening_char_end,
531531+ );
532532+ }
533533+534534+ // 2. Emit raw LaTeX content (hidden with syntax when cursor outside)
535535+ write!(
536536+ &mut self.writer,
537537+ "<span class=\"math-source\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
538538+ syn_id, content_char_start, content_char_end
539539+ )?;
540540+ escape_html(&mut self.writer, text)?;
541541+ self.write("</span>")?;
542542+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
543543+ syn_id: syn_id.clone(),
544544+ char_range: content_char_start..content_char_end,
545545+ syntax_type: SyntaxType::Inline,
546546+ formatted_range: Some(formatted_range.clone()),
547547+ });
548548+ self.record_mapping(
549549+ range.start + 1..range.end - 1,
550550+ content_char_start..content_char_end,
551551+ );
552552+553553+ // 3. Emit closing $ syntax span
554554+ if raw_text.ends_with('$') {
555555+ write!(
556556+ &mut self.writer,
557557+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$</span>",
558558+ syn_id, closing_char_start, closing_char_end
559559+ )?;
560560+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
561561+ syn_id: syn_id.clone(),
562562+ char_range: closing_char_start..closing_char_end,
563563+ syntax_type: SyntaxType::Inline,
564564+ formatted_range: Some(formatted_range.clone()),
565565+ });
566566+ self.record_mapping(
567567+ range.end - 1..range.end,
568568+ closing_char_start..closing_char_end,
569569+ );
570570+ }
571571+572572+ // 4. Emit rendered MathML (always visible, not tied to syn_id)
573573+ // Include data-char-target so clicking moves cursor into the math region
574574+ // contenteditable="false" so DOM walker skips this for offset counting
575575+ match weaver_renderer::math::render_math(text, false) {
576576+ weaver_renderer::math::MathResult::Success(mathml) => {
577577+ write!(
578578+ &mut self.writer,
579579+ "<span class=\"math math-inline math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>",
580580+ content_char_start, mathml
581581+ )?;
582582+ }
583583+ weaver_renderer::math::MathResult::Error { html, .. } => {
584584+ // Show error indicator (also always visible)
585585+ self.write(&html)?;
586586+ }
587587+ }
588588+589589+ self.last_char_offset = closing_char_end;
590590+ Ok(())
591591+ }
592592+593593+ /// Process display math ($$...$$)
594594+ fn process_display_math(&mut self, text: &str, range: Range<usize>) -> Result<(), fmt::Error> {
595595+ let raw_text = &self.source[range.clone()];
596596+ let syn_id = self.gen_syn_id();
597597+ let opening_char_start = self.last_char_offset;
598598+599599+ // Calculate char positions
600600+ let text_char_len = text.chars().count();
601601+ let opening_char_end = opening_char_start + 2; // "$$"
602602+ let content_char_start = opening_char_end;
603603+ let content_char_end = content_char_start + text_char_len;
604604+ let closing_char_start = content_char_end;
605605+ let closing_char_end = closing_char_start + 2; // "$$"
606606+ let formatted_range = opening_char_start..closing_char_end;
607607+608608+ // 1. Emit opening $$ syntax span
609609+ // Use Block syntax type so visibility is based on "cursor in same paragraph"
610610+ if raw_text.starts_with("$$") {
611611+ write!(
612612+ &mut self.writer,
613613+ "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$$</span>",
614614+ syn_id, opening_char_start, opening_char_end
615615+ )?;
616616+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
617617+ syn_id: syn_id.clone(),
618618+ char_range: opening_char_start..opening_char_end,
619619+ syntax_type: SyntaxType::Block,
620620+ formatted_range: Some(formatted_range.clone()),
621621+ });
622622+ self.record_mapping(
623623+ range.start..range.start + 2,
624624+ opening_char_start..opening_char_end,
625625+ );
626626+ }
627627+628628+ // 2. Emit raw LaTeX content (hidden with syntax when cursor outside)
629629+ write!(
630630+ &mut self.writer,
631631+ "<span class=\"math-source\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
632632+ syn_id, content_char_start, content_char_end
633633+ )?;
634634+ escape_html(&mut self.writer, text)?;
635635+ self.write("</span>")?;
636636+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
637637+ syn_id: syn_id.clone(),
638638+ char_range: content_char_start..content_char_end,
639639+ syntax_type: SyntaxType::Block,
640640+ formatted_range: Some(formatted_range.clone()),
641641+ });
642642+ self.record_mapping(
643643+ range.start + 2..range.end - 2,
644644+ content_char_start..content_char_end,
645645+ );
646646+647647+ // 3. Emit closing $$ syntax span
648648+ if raw_text.ends_with("$$") {
649649+ write!(
650650+ &mut self.writer,
651651+ "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$$</span>",
652652+ syn_id, closing_char_start, closing_char_end
653653+ )?;
654654+ self.current_para.syntax_spans.push(SyntaxSpanInfo {
655655+ syn_id: syn_id.clone(),
656656+ char_range: closing_char_start..closing_char_end,
657657+ syntax_type: SyntaxType::Block,
658658+ formatted_range: Some(formatted_range.clone()),
659659+ });
660660+ self.record_mapping(
661661+ range.end - 2..range.end,
662662+ closing_char_start..closing_char_end,
663663+ );
664664+ }
665665+666666+ // 4. Emit rendered MathML (always visible, not tied to syn_id)
667667+ // Include data-char-target so clicking moves cursor into the math region
668668+ // contenteditable="false" so DOM walker skips this for offset counting
669669+ match weaver_renderer::math::render_math(text, true) {
670670+ weaver_renderer::math::MathResult::Success(mathml) => {
671671+ write!(
672672+ &mut self.writer,
673673+ "<span class=\"math math-display math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>",
674674+ content_char_start, mathml
675675+ )?;
676676+ }
677677+ weaver_renderer::math::MathResult::Error { html, .. } => {
678678+ // Show error indicator (also always visible)
679679+ self.write(&html)?;
680680+ }
681681+ }
682682+683683+ self.last_char_offset = closing_char_end;
684684+ Ok(())
685685+ }
686686+}