···144144145145 /// Pending snap direction for cursor restoration after edits.
146146 /// Set by input handlers, consumed by cursor restoration.
147147- pub pending_snap: Signal<Option<super::offset_map::SnapDirection>>,
147147+ pub pending_snap: Signal<Option<weaver_editor_core::SnapDirection>>,
148148149149 /// Collected refs (wikilinks, AT embeds) from the most recent render.
150150 /// Updated by the render pipeline, read by publish for populating records.
···6677use super::document::EditorDocument;
88use super::formatting::{self, FormatAction};
99-use super::offset_map::SnapDirection;
99+use weaver_editor_core::SnapDirection;
10101111/// Check if we need to intercept this key event.
1212/// Returns true for content-modifying operations, false for navigation.
···33//! Paragraphs are discovered during markdown rendering by tracking
44//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
5566-use super::offset_map::OffsetMapping;
77-use super::writer::SyntaxSpanInfo;
86use loro::LoroText;
97use std::ops::Range;
88+use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo};
1091110/// A rendered paragraph with its source range and offset mappings.
1211#[derive(Debug, Clone, PartialEq)]
+18-21
crates/weaver-app/src/components/editor/render.rs
···55//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
6677use super::document::EditInfo;
88-#[allow(unused_imports)]
99-use super::offset_map::{OffsetMapping, RenderResult};
108use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string};
1111-#[allow(unused_imports)]
1212-use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo};
99+use super::writer::embed::EditorImageResolver;
1310use loro::LoroText;
1411use markdown_weaver::Parser;
1512use std::ops::Range;
1613use weaver_common::{EntryIndex, ResolvedContent};
1414+use weaver_editor_core::{
1515+ EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SyntaxSpanInfo,
1616+};
17171818/// Cache for incremental paragraph rendering.
1919/// Stores previously rendered paragraphs to avoid re-rendering unchanged content.
···413413 let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options())
414414 .into_offset_iter();
415415416416- let para_doc = loro::LoroDoc::new();
417417- let para_text = para_doc.get_text("content");
418418- let _ = para_text.insert(0, ¶_source);
416416+ let para_rope = EditorRope::from(para_source.as_str());
419417420420- let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new(
421421- ¶_source,
422422- ¶_text,
423423- parser,
424424- )
425425- .with_node_id_prefix(&cached_para.id)
426426- .with_image_resolver(&resolver)
427427- .with_embed_provider(resolved_content);
418418+ let mut writer =
419419+ EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new(
420420+ ¶_source,
421421+ ¶_rope,
422422+ parser,
423423+ )
424424+ .with_node_id_prefix(&cached_para.id)
425425+ .with_image_resolver(&resolver)
426426+ .with_embed_provider(resolved_content);
428427429428 if let Some(idx) = entry_index {
430429 writer = writer.with_entry_index(idx);
···613612 // Use provided resolver or empty default
614613 let resolver = image_resolver.cloned().unwrap_or_default();
615614616616- // Create a temporary LoroText for the slice (needed by writer)
617617- let slice_doc = loro::LoroDoc::new();
618618- let slice_text = slice_doc.get_text("content");
619619- let _ = slice_text.insert(0, parse_slice);
615615+ // Create EditorRope for efficient offset conversions
616616+ let slice_rope = EditorRope::from(parse_slice);
620617621618 // Determine starting paragraph ID for freshly parsed paragraphs
622619 // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML
···665662 });
666663667664 // Build writer with all resolvers and auto-incrementing paragraph prefixes
668668- let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new(
665665+ let mut writer = EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new(
669666 parse_slice,
670670- &slice_text,
667667+ &slice_rope,
671668 parser,
672669 )
673670 .with_auto_incrementing_prefix(parsed_para_id_start)
···11-use core::fmt;
22-use std::ops::Range;
33-44-use markdown_weaver::{Alignment, BlockQuoteKind, CodeBlockKind, EmbedType, Event, LinkType, Tag};
55-use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text};
66-77-use crate::components::editor::{
88- OffsetMapping, SyntaxSpanInfo, SyntaxType,
99- writer::{
1010- EditorWriter, TableState,
1111- embed::{EmbedContentProvider, ImageResolver},
1212- syntax::classify_syntax,
1313- },
1414-};
1515-1616-impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
1717- EditorWriter<'a, I, E, R>
1818-{
1919- /// Detect text direction by scanning source from a byte offset.
2020- /// Looks for the first strong directional character.
2121- /// Returns Some("rtl") for RTL scripts, Some("ltr") for LTR, None if no strong char found.
2222- fn detect_paragraph_direction(&self, start_byte: usize) -> Option<&'static str> {
2323- if start_byte >= self.source.len() {
2424- return None;
2525- }
2626-2727- // Scan from start_byte through the source looking for first strong directional char
2828- let text = &self.source[start_byte..];
2929- weaver_renderer::utils::detect_text_direction(text)
3030- }
3131-3232- pub(crate) fn start_tag(
3333- &mut self,
3434- tag: Tag<'_>,
3535- range: Range<usize>,
3636- ) -> Result<(), fmt::Error> {
3737- // Check if this is a block-level tag that should have syntax inside
3838- let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_));
3939-4040- // For inline tags, emit syntax before tag
4141- if !is_block_tag && range.start < range.end {
4242- let raw_text = &self.source[range.clone()];
4343- let opening_syntax = match &tag {
4444- Tag::Strong => {
4545- if raw_text.starts_with("**") {
4646- Some("**")
4747- } else if raw_text.starts_with("__") {
4848- Some("__")
4949- } else {
5050- None
5151- }
5252- }
5353- Tag::Emphasis => {
5454- if raw_text.starts_with("*") {
5555- Some("*")
5656- } else if raw_text.starts_with("_") {
5757- Some("_")
5858- } else {
5959- None
6060- }
6161- }
6262- Tag::Strikethrough => {
6363- if raw_text.starts_with("~~") {
6464- Some("~~")
6565- } else {
6666- None
6767- }
6868- }
6969- Tag::Link { link_type, .. } => {
7070- if matches!(link_type, LinkType::WikiLink { .. }) {
7171- if raw_text.starts_with("[[") {
7272- Some("[[")
7373- } else {
7474- None
7575- }
7676- } else if raw_text.starts_with('[') {
7777- Some("[")
7878- } else {
7979- None
8080- }
8181- }
8282- // Note: Tag::Image and Tag::Embed handle their own syntax spans
8383- // in their respective handlers, so don't emit here
8484- _ => None,
8585- };
8686-8787- if let Some(syntax) = opening_syntax {
8888- let syntax_type = classify_syntax(syntax);
8989- let class = match syntax_type {
9090- SyntaxType::Inline => "md-syntax-inline",
9191- SyntaxType::Block => "md-syntax-block",
9292- };
9393-9494- let char_start = self.last_char_offset;
9595- let syntax_char_len = syntax.chars().count();
9696- let char_end = char_start + syntax_char_len;
9797- let syntax_byte_len = syntax.len();
9898-9999- // Generate unique ID for this syntax span
100100- let syn_id = self.gen_syn_id();
101101-102102- write!(
103103- &mut self.writer,
104104- "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
105105- class, syn_id, char_start, char_end
106106- )?;
107107- escape_html(&mut self.writer, syntax)?;
108108- self.write("</span>")?;
109109-110110- // Record syntax span info for visibility toggling
111111- self.syntax_spans.push(SyntaxSpanInfo {
112112- syn_id: syn_id.clone(),
113113- char_range: char_start..char_end,
114114- syntax_type,
115115- formatted_range: None, // Will be updated when closing tag is emitted
116116- });
117117-118118- // Record offset mapping for cursor positioning
119119- // This is critical - without it, current_node_char_offset is wrong
120120- // and all subsequent cursor positions are shifted
121121- let byte_start = range.start;
122122- let byte_end = range.start + syntax_byte_len;
123123- self.record_mapping(byte_start..byte_end, char_start..char_end);
124124-125125- // For paired inline syntax, track opening span for formatted_range
126126- if matches!(
127127- tag,
128128- Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. }
129129- ) {
130130- self.pending_inline_formats.push((syn_id, char_start));
131131- }
132132-133133- // Update tracking - we've consumed this opening syntax
134134- self.last_char_offset = char_end;
135135- self.last_byte_offset = range.start + syntax_byte_len;
136136- }
137137- }
138138-139139- // Emit the opening tag
140140- match tag {
141141- // HTML blocks get their own paragraph to try and corral them better
142142- Tag::HtmlBlock => {
143143- // Record paragraph start for boundary tracking
144144- // Skip if inside a list or footnote def - they own their paragraph boundary
145145- if self.list_depth == 0 && !self.in_footnote_def {
146146- self.current_paragraph_start =
147147- Some((self.last_byte_offset, self.last_char_offset));
148148- }
149149- let node_id = self.gen_node_id();
150150-151151- if self.end_newline {
152152- write!(
153153- &mut self.writer,
154154- r#"<p id="{}" class="html-embed html-embed-block">"#,
155155- node_id
156156- )?;
157157- } else {
158158- write!(
159159- &mut self.writer,
160160- r#"<p id="{}" class="html-embed html-embed-block">"#,
161161- node_id
162162- )?;
163163- }
164164- self.begin_node(node_id.clone());
165165-166166- // Map the start position of the paragraph (before any content)
167167- // This allows cursor to be placed at the very beginning
168168- let para_start_char = self.last_char_offset;
169169- let mapping = OffsetMapping {
170170- byte_range: range.start..range.start,
171171- char_range: para_start_char..para_start_char,
172172- node_id,
173173- char_offset_in_node: 0,
174174- child_index: Some(0), // position before first child
175175- utf16_len: 0,
176176- };
177177- self.offset_maps.push(mapping);
178178-179179- Ok(())
180180- }
181181- Tag::Paragraph(_) => {
182182- // Handle wrapper before block
183183- self.emit_wrapper_start()?;
184184-185185- // Record paragraph start for boundary tracking
186186- // Skip if inside a list or footnote def - they own their paragraph boundary
187187- if self.list_depth == 0 && !self.in_footnote_def {
188188- self.current_paragraph_start =
189189- Some((self.last_byte_offset, self.last_char_offset));
190190- }
191191-192192- let node_id = self.gen_node_id();
193193-194194- // Detect text direction for this paragraph
195195- let dir = self.detect_paragraph_direction(self.last_byte_offset);
196196-197197- if self.end_newline {
198198- if let Some(dir_value) = dir {
199199- write!(&mut self.writer, "<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?;
200200- } else {
201201- write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
202202- }
203203- } else {
204204- if let Some(dir_value) = dir {
205205- write!(&mut self.writer, "<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?;
206206- } else {
207207- write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
208208- }
209209- }
210210- self.begin_node(node_id.clone());
211211-212212- // Map the start position of the paragraph (before any content)
213213- // This allows cursor to be placed at the very beginning
214214- let para_start_char = self.last_char_offset;
215215- let mapping = OffsetMapping {
216216- byte_range: range.start..range.start,
217217- char_range: para_start_char..para_start_char,
218218- node_id,
219219- char_offset_in_node: 0,
220220- child_index: Some(0), // position before first child
221221- utf16_len: 0,
222222- };
223223- self.offset_maps.push(mapping);
224224-225225- // Emit > syntax if we're inside a blockquote
226226- if let Some(bq_range) = self.pending_blockquote_range.take() {
227227- if bq_range.start < bq_range.end {
228228- let raw_text = &self.source[bq_range.clone()];
229229- if let Some(gt_pos) = raw_text.find('>') {
230230- // Extract > [!NOTE] or just >
231231- let after_gt = &raw_text[gt_pos + 1..];
232232- let syntax_end = if after_gt.trim_start().starts_with("[!") {
233233- // Find the closing ]
234234- if let Some(close_bracket) = after_gt.find(']') {
235235- gt_pos + 1 + close_bracket + 1
236236- } else {
237237- gt_pos + 1
238238- }
239239- } else {
240240- // Just > and maybe a space
241241- (gt_pos + 1).min(raw_text.len())
242242- };
243243-244244- let syntax = &raw_text[gt_pos..syntax_end];
245245- let syntax_byte_start = bq_range.start + gt_pos;
246246- self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
247247- }
248248- }
249249- }
250250- Ok(())
251251- }
252252- Tag::Heading {
253253- level,
254254- id,
255255- classes,
256256- attrs,
257257- } => {
258258- // Emit wrapper if pending (but don't close on heading end - wraps following block too)
259259- self.emit_wrapper_start()?;
260260-261261- // Record paragraph start for boundary tracking
262262- // Treat headings as paragraph-level blocks
263263- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
264264-265265- if !self.end_newline {
266266- self.write_newline()?;
267267- }
268268-269269- // Generate node ID for offset tracking
270270- let node_id = self.gen_node_id();
271271-272272- // Detect text direction for this heading
273273- let dir = self.detect_paragraph_direction(self.last_byte_offset);
274274-275275- self.write("<")?;
276276- write!(&mut self.writer, "{}", level)?;
277277-278278- // Add our tracking ID as data attribute (preserve user's id if present)
279279- self.write(" data-node-id=\"")?;
280280- self.write(&node_id)?;
281281- self.write("\"")?;
282282-283283- if let Some(id) = id {
284284- self.write(" id=\"")?;
285285- escape_html(&mut self.writer, &id)?;
286286- self.write("\"")?;
287287- }
288288- if !classes.is_empty() {
289289- self.write(" class=\"")?;
290290- for (i, class) in classes.iter().enumerate() {
291291- if i > 0 {
292292- self.write(" ")?;
293293- }
294294- escape_html(&mut self.writer, class)?;
295295- }
296296- self.write("\"")?;
297297- }
298298-299299- // Add dir attribute if text direction was detected
300300- if let Some(dir_value) = dir {
301301- self.write(" dir=\"")?;
302302- self.write(dir_value)?;
303303- self.write("\"")?;
304304- }
305305-306306- for (attr, value) in attrs {
307307- self.write(" ")?;
308308- escape_html(&mut self.writer, &attr)?;
309309- if let Some(val) = value {
310310- self.write("=\"")?;
311311- escape_html(&mut self.writer, &val)?;
312312- self.write("\"")?;
313313- } else {
314314- self.write("=\"\"")?;
315315- }
316316- }
317317- self.write(">")?;
318318-319319- // Begin node tracking for offset mapping
320320- self.begin_node(node_id.clone());
321321-322322- // Map the start position of the heading (before any content)
323323- // This allows cursor to be placed at the very beginning
324324- let heading_start_char = self.last_char_offset;
325325- let mapping = OffsetMapping {
326326- byte_range: range.start..range.start,
327327- char_range: heading_start_char..heading_start_char,
328328- node_id: node_id.clone(),
329329- char_offset_in_node: 0,
330330- child_index: Some(0), // position before first child
331331- utf16_len: 0,
332332- };
333333- self.offset_maps.push(mapping);
334334-335335- // Emit # syntax inside the heading tag
336336- if range.start < range.end {
337337- let raw_text = &self.source[range.clone()];
338338- let count = level as usize;
339339- let pattern = "#".repeat(count);
340340-341341- // Find where the # actually starts (might have leading whitespace)
342342- if let Some(hash_pos) = raw_text.find(&pattern) {
343343- // Extract "# " or "## " etc
344344- let syntax_end = (hash_pos + count + 1).min(raw_text.len());
345345- let syntax = &raw_text[hash_pos..syntax_end];
346346- let syntax_byte_start = range.start + hash_pos;
347347-348348- self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
349349- }
350350- }
351351- Ok(())
352352- }
353353- Tag::Table(alignments) => {
354354- if self.render_tables_as_markdown {
355355- // Store start offset and skip HTML rendering
356356- self.table_start_offset = Some(range.start);
357357- self.in_non_writing_block = true; // Suppress content output
358358- Ok(())
359359- } else {
360360- self.emit_wrapper_start()?;
361361- self.table_alignments = alignments;
362362- self.write("<table>")
363363- }
364364- }
365365- Tag::TableHead => {
366366- if self.render_tables_as_markdown {
367367- Ok(()) // Skip HTML rendering
368368- } else {
369369- self.table_state = TableState::Head;
370370- self.table_cell_index = 0;
371371- self.write("<thead><tr>")
372372- }
373373- }
374374- Tag::TableRow => {
375375- if self.render_tables_as_markdown {
376376- Ok(()) // Skip HTML rendering
377377- } else {
378378- self.table_cell_index = 0;
379379- self.write("<tr>")
380380- }
381381- }
382382- Tag::TableCell => {
383383- if self.render_tables_as_markdown {
384384- Ok(()) // Skip HTML rendering
385385- } else {
386386- match self.table_state {
387387- TableState::Head => self.write("<th")?,
388388- TableState::Body => self.write("<td")?,
389389- }
390390- match self.table_alignments.get(self.table_cell_index) {
391391- Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
392392- Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
393393- Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
394394- _ => self.write(">"),
395395- }
396396- }
397397- }
398398- Tag::BlockQuote(kind) => {
399399- self.emit_wrapper_start()?;
400400-401401- let class_str = match kind {
402402- None => "",
403403- Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"",
404404- Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"",
405405- Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"",
406406- Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"",
407407- Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"",
408408- };
409409- if self.end_newline {
410410- write!(&mut self.writer, "<blockquote{}>", class_str)?;
411411- } else {
412412- write!(&mut self.writer, "<blockquote{}>", class_str)?;
413413- }
414414-415415- // Store range for emitting > inside the next paragraph
416416- self.pending_blockquote_range = Some(range);
417417- Ok(())
418418- }
419419- Tag::CodeBlock(info) => {
420420- self.emit_wrapper_start()?;
421421-422422- // Track code block as paragraph-level block
423423- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
424424-425425- if !self.end_newline {
426426- self.write_newline()?;
427427- }
428428-429429- // Generate node ID for code block
430430- let node_id = self.gen_node_id();
431431-432432- match info {
433433- CodeBlockKind::Fenced(info) => {
434434- // Emit opening ```language and track both char and byte offsets
435435- if range.start < range.end {
436436- let raw_text = &self.source[range.clone()];
437437- if let Some(fence_pos) = raw_text.find("```") {
438438- let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len());
439439- let syntax = &raw_text[fence_pos..fence_end];
440440- let syntax_char_len = syntax.chars().count() + 1; // +1 for newline
441441- let syntax_byte_len = syntax.len() + 1; // +1 for newline
442442-443443- let syn_id = self.gen_syn_id();
444444- let char_start = self.last_char_offset;
445445- let char_end = char_start + syntax_char_len;
446446-447447- write!(
448448- &mut self.writer,
449449- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
450450- syn_id, char_start, char_end
451451- )?;
452452- escape_html(&mut self.writer, syntax)?;
453453- self.write("</span>")?;
454454-455455- // Track opening span index for formatted_range update later
456456- self.code_block_opening_span_idx = Some(self.syntax_spans.len());
457457- self.code_block_char_start = Some(char_start);
458458-459459- self.syntax_spans.push(SyntaxSpanInfo {
460460- syn_id,
461461- char_range: char_start..char_end,
462462- syntax_type: SyntaxType::Block,
463463- formatted_range: None, // Will be set in TagEnd::CodeBlock
464464- });
465465-466466- self.last_char_offset += syntax_char_len;
467467- self.last_byte_offset = range.start + fence_pos + syntax_byte_len;
468468- }
469469- }
470470-471471- let lang = info.split(' ').next().unwrap();
472472- let lang_opt = if lang.is_empty() {
473473- None
474474- } else {
475475- Some(lang.to_string())
476476- };
477477- // Start buffering
478478- self.code_buffer = Some((lang_opt, String::new()));
479479-480480- // Begin node tracking for offset mapping
481481- self.begin_node(node_id);
482482- Ok(())
483483- }
484484- CodeBlockKind::Indented => {
485485- // Ignore indented code blocks (as per executive decision)
486486- self.code_buffer = Some((None, String::new()));
487487-488488- // Begin node tracking for offset mapping
489489- self.begin_node(node_id);
490490- Ok(())
491491- }
492492- }
493493- }
494494- Tag::List(Some(1)) => {
495495- self.emit_wrapper_start()?;
496496- // Track list as paragraph-level block
497497- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
498498- self.list_depth += 1;
499499- if self.end_newline {
500500- self.write("<ol>")
501501- } else {
502502- self.write("<ol>")
503503- }
504504- }
505505- Tag::List(Some(start)) => {
506506- self.emit_wrapper_start()?;
507507- // Track list as paragraph-level block
508508- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
509509- self.list_depth += 1;
510510- if self.end_newline {
511511- self.write("<ol start=\"")?;
512512- } else {
513513- self.write("<ol start=\"")?;
514514- }
515515- write!(&mut self.writer, "{}", start)?;
516516- self.write("\">")
517517- }
518518- Tag::List(None) => {
519519- self.emit_wrapper_start()?;
520520- // Track list as paragraph-level block
521521- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
522522- self.list_depth += 1;
523523- if self.end_newline {
524524- self.write("<ul>")
525525- } else {
526526- self.write("<ul>")
527527- }
528528- }
529529- Tag::Item => {
530530- // Generate node ID for list item
531531- let node_id = self.gen_node_id();
532532-533533- if self.end_newline {
534534- write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?;
535535- } else {
536536- write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?;
537537- }
538538-539539- // Begin node tracking
540540- self.begin_node(node_id);
541541-542542- // Emit list marker syntax inside the <li> tag and track both offsets
543543- if range.start < range.end {
544544- let raw_text = &self.source[range.clone()];
545545-546546- // Try to find the list marker (-, *, or digit.)
547547- let trimmed = raw_text.trim_start();
548548- let leading_ws_bytes = raw_text.len() - trimmed.len();
549549- let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count();
550550-551551- if let Some(marker) = trimmed.chars().next() {
552552- if marker == '-' || marker == '*' {
553553- // Unordered list: extract "- " or "* "
554554- let marker_end = trimmed
555555- .find(|c: char| c != '-' && c != '*')
556556- .map(|pos| pos + 1)
557557- .unwrap_or(1);
558558- let syntax = &trimmed[..marker_end.min(trimmed.len())];
559559- let char_start = self.last_char_offset;
560560- let syntax_char_len = leading_ws_chars + syntax.chars().count();
561561- let syntax_byte_len = leading_ws_bytes + syntax.len();
562562- let char_end = char_start + syntax_char_len;
563563-564564- let syn_id = self.gen_syn_id();
565565- write!(
566566- &mut self.writer,
567567- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
568568- syn_id, char_start, char_end
569569- )?;
570570- escape_html(&mut self.writer, syntax)?;
571571- self.write("</span>")?;
572572-573573- self.syntax_spans.push(SyntaxSpanInfo {
574574- syn_id,
575575- char_range: char_start..char_end,
576576- syntax_type: SyntaxType::Block,
577577- formatted_range: None,
578578- });
579579-580580- // Record offset mapping for cursor positioning
581581- self.record_mapping(
582582- range.start..range.start + syntax_byte_len,
583583- char_start..char_end,
584584- );
585585- self.last_char_offset = char_end;
586586- self.last_byte_offset = range.start + syntax_byte_len;
587587- } else if marker.is_ascii_digit() {
588588- // Ordered list: extract "1. " or similar (including trailing space)
589589- if let Some(dot_pos) = trimmed.find('.') {
590590- let syntax_end = (dot_pos + 2).min(trimmed.len());
591591- let syntax = &trimmed[..syntax_end];
592592- let char_start = self.last_char_offset;
593593- let syntax_char_len = leading_ws_chars + syntax.chars().count();
594594- let syntax_byte_len = leading_ws_bytes + syntax.len();
595595- let char_end = char_start + syntax_char_len;
596596-597597- let syn_id = self.gen_syn_id();
598598- write!(
599599- &mut self.writer,
600600- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
601601- syn_id, char_start, char_end
602602- )?;
603603- escape_html(&mut self.writer, syntax)?;
604604- self.write("</span>")?;
605605-606606- self.syntax_spans.push(SyntaxSpanInfo {
607607- syn_id,
608608- char_range: char_start..char_end,
609609- syntax_type: SyntaxType::Block,
610610- formatted_range: None,
611611- });
612612-613613- // Record offset mapping for cursor positioning
614614- self.record_mapping(
615615- range.start..range.start + syntax_byte_len,
616616- char_start..char_end,
617617- );
618618- self.last_char_offset = char_end;
619619- self.last_byte_offset = range.start + syntax_byte_len;
620620- }
621621- }
622622- }
623623- }
624624- Ok(())
625625- }
626626- Tag::DefinitionList => {
627627- self.emit_wrapper_start()?;
628628- if self.end_newline {
629629- self.write("<dl>")
630630- } else {
631631- self.write("<dl>")
632632- }
633633- }
634634- Tag::DefinitionListTitle => {
635635- let node_id = self.gen_node_id();
636636-637637- if self.end_newline {
638638- write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?;
639639- } else {
640640- write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?;
641641- }
642642-643643- self.begin_node(node_id);
644644- Ok(())
645645- }
646646- Tag::DefinitionListDefinition => {
647647- let node_id = self.gen_node_id();
648648-649649- if self.end_newline {
650650- write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?;
651651- } else {
652652- write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?;
653653- }
654654-655655- self.begin_node(node_id);
656656- Ok(())
657657- }
658658- Tag::Subscript => self.write("<sub>"),
659659- Tag::Superscript => self.write("<sup>"),
660660- Tag::Emphasis => self.write("<em>"),
661661- Tag::Strong => self.write("<strong>"),
662662- Tag::Strikethrough => self.write("<s>"),
663663- Tag::Link {
664664- link_type: LinkType::Email,
665665- dest_url,
666666- title,
667667- ..
668668- } => {
669669- self.write("<a href=\"mailto:")?;
670670- escape_href(&mut self.writer, &dest_url)?;
671671- if !title.is_empty() {
672672- self.write("\" title=\"")?;
673673- escape_html(&mut self.writer, &title)?;
674674- }
675675- self.write("\">")
676676- }
677677- Tag::Link {
678678- link_type,
679679- dest_url,
680680- title,
681681- ..
682682- } => {
683683- // Collect refs for later resolution
684684- let url = dest_url.as_ref();
685685- if matches!(link_type, LinkType::WikiLink { .. }) {
686686- let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url);
687687- self.ref_collector.add_wikilink(target, fragment, None);
688688- } else if url.starts_with("at://") {
689689- self.ref_collector.add_at_link(url);
690690- }
691691-692692- // Determine link validity class for wikilinks
693693- let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) {
694694- if let Some(index) = &self.entry_index {
695695- if index.resolve(dest_url.as_ref()).is_some() {
696696- " link-valid"
697697- } else {
698698- " link-broken"
699699- }
700700- } else {
701701- ""
702702- }
703703- } else {
704704- ""
705705- };
706706-707707- self.write("<a class=\"link")?;
708708- self.write(validity_class)?;
709709- self.write("\" href=\"")?;
710710- escape_href(&mut self.writer, &dest_url)?;
711711- if !title.is_empty() {
712712- self.write("\" title=\"")?;
713713- escape_html(&mut self.writer, &title)?;
714714- }
715715- self.write("\">")
716716- }
717717- Tag::Image {
718718- link_type,
719719- dest_url,
720720- title,
721721- id,
722722- attrs,
723723- } => {
724724- // Check if this is actually an AT embed disguised as a wikilink image
725725- // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type)
726726- let url = dest_url.as_ref();
727727- if matches!(link_type, LinkType::WikiLink { .. })
728728- && (url.starts_with("at://") || url.starts_with("did:"))
729729- {
730730- return self.write_embed(
731731- range,
732732- EmbedType::Other, // AT embeds - disambiguated via NSID later
733733- dest_url,
734734- title,
735735- id,
736736- attrs,
737737- );
738738- }
739739-740740- // Image rendering: all syntax elements share one syn_id for visibility toggling
741741- // Structure:  <img> cursor-landing
742742- let raw_text = &self.source[range.clone()];
743743- let syn_id = self.gen_syn_id();
744744- let opening_char_start = self.last_char_offset;
745745-746746- // Find the alt text and closing syntax positions
747747- let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len());
748748- let alt_text = if raw_text.starts_with("![") && paren_pos > 2 {
749749- &raw_text[2..paren_pos]
750750- } else {
751751- ""
752752- };
753753- let closing_syntax = if paren_pos < raw_text.len() {
754754- &raw_text[paren_pos..]
755755- } else {
756756- ""
757757- };
758758-759759- // Calculate char positions
760760- let alt_char_len = alt_text.chars().count();
761761- let closing_char_len = closing_syntax.chars().count();
762762- let opening_char_end = opening_char_start + 2; // " syntax span
816816- if !closing_syntax.is_empty() {
817817- write!(
818818- &mut self.writer,
819819- "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
820820- syn_id, closing_char_start, closing_char_end
821821- )?;
822822- escape_html(&mut self.writer, closing_syntax)?;
823823- self.write("</span>")?;
824824-825825- self.syntax_spans.push(SyntaxSpanInfo {
826826- syn_id: syn_id.clone(),
827827- char_range: closing_char_start..closing_char_end,
828828- syntax_type: SyntaxType::Inline,
829829- formatted_range: Some(formatted_range.clone()),
830830- });
831831-832832- // Record offset mapping for ](url)
833833- self.record_mapping(
834834- range.start + paren_pos..range.end,
835835- closing_char_start..closing_char_end,
836836- );
837837- }
838838-839839- // 4. Emit <img> element (no syn_id - always visible)
840840- self.write("<img src=\"")?;
841841- let resolved_url = self
842842- .image_resolver
843843- .as_ref()
844844- .and_then(|r| r.resolve_image_url(&dest_url));
845845- if let Some(ref cdn_url) = resolved_url {
846846- escape_href(&mut self.writer, cdn_url)?;
847847- } else {
848848- escape_href(&mut self.writer, &dest_url)?;
849849- }
850850- self.write("\" alt=\"")?;
851851- escape_html(&mut self.writer, alt_text)?;
852852- self.write("\"")?;
853853- if !title.is_empty() {
854854- self.write(" title=\"")?;
855855- escape_html(&mut self.writer, &title)?;
856856- self.write("\"")?;
857857- }
858858- if let Some(attrs) = attrs {
859859- if !attrs.classes.is_empty() {
860860- self.write(" class=\"")?;
861861- for (i, class) in attrs.classes.iter().enumerate() {
862862- if i > 0 {
863863- self.write(" ")?;
864864- }
865865- escape_html(&mut self.writer, class)?;
866866- }
867867- self.write("\"")?;
868868- }
869869- for (attr, value) in &attrs.attrs {
870870- self.write(" ")?;
871871- escape_html(&mut self.writer, attr)?;
872872- self.write("=\"")?;
873873- escape_html(&mut self.writer, value)?;
874874- self.write("\"")?;
875875- }
876876- }
877877- self.write(" />")?;
878878-879879- // Consume the text events for alt (they're still in the iterator)
880880- // Use consume_until_end() since we already wrote alt text from source
881881- self.consume_until_end();
882882-883883- // Update offsets
884884- self.last_char_offset = closing_char_end;
885885- self.last_byte_offset = range.end;
886886-887887- Ok(())
888888- }
889889- Tag::Embed {
890890- embed_type,
891891- dest_url,
892892- title,
893893- id,
894894- attrs,
895895- } => self.write_embed(range, embed_type, dest_url, title, id, attrs),
896896- Tag::WeaverBlock(_, attrs) => {
897897- self.in_non_writing_block = true;
898898- self.weaver_block_buffer.clear();
899899- self.weaver_block_char_start = Some(self.last_char_offset);
900900- // Store attrs from Start tag, will merge with parsed text on End
901901- if !attrs.classes.is_empty() || !attrs.attrs.is_empty() {
902902- self.pending_block_attrs = Some(attrs.into_static());
903903- }
904904- Ok(())
905905- }
906906- Tag::FootnoteDefinition(name) => {
907907- // Track as paragraph-level block for incremental rendering
908908- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
909909- // Suppress inner paragraph boundaries (footnote def owns its paragraph)
910910- self.in_footnote_def = true;
911911-912912- if !self.end_newline {
913913- self.write_newline()?;
914914- }
915915-916916- // Generate node ID for cursor tracking
917917- let node_id = self.gen_node_id();
918918-919919- // Emit wrapper div with NEW class (not footnote-definition which has order:9999)
920920- // This keeps footnotes in-place instead of reordering to bottom
921921- write!(
922922- &mut self.writer,
923923- "<div class=\"footnote-def-editor\" data-node-id=\"{}\">",
924924- node_id
925925- )?;
926926-927927- // Begin node tracking BEFORE emitting prefix
928928- self.begin_node(node_id.clone());
929929-930930- // Map the start position (before any content)
931931- let fn_start_char = self.last_char_offset;
932932- let mapping = OffsetMapping {
933933- byte_range: range.start..range.start,
934934- char_range: fn_start_char..fn_start_char,
935935- node_id,
936936- char_offset_in_node: 0,
937937- child_index: Some(0),
938938- utf16_len: 0,
939939- };
940940- self.offset_maps.push(mapping);
941941-942942- // Extract ACTUAL prefix from source (not constructed string)
943943- // This ensures byte offsets match reality
944944- let raw_text = &self.source[range.clone()];
945945- let prefix_end = raw_text
946946- .find("]:")
947947- .map(|p| {
948948- // Include ]: and any single trailing space
949949- let after_colon = p + 2;
950950- if raw_text.get(after_colon..after_colon + 1) == Some(" ") {
951951- after_colon + 1
952952- } else {
953953- after_colon
954954- }
955955- })
956956- .unwrap_or(0);
957957- let prefix = &raw_text[..prefix_end];
958958- let prefix_byte_len = prefix.len();
959959- let prefix_char_len = prefix.chars().count();
960960-961961- let char_start = self.last_char_offset;
962962- let char_end = char_start + prefix_char_len;
963963-964964- write!(
965965- &mut self.writer,
966966- "<span class=\"footnote-def-syntax\" data-char-start=\"{}\" data-char-end=\"{}\">",
967967- char_start, char_end
968968- )?;
969969- escape_html(&mut self.writer, prefix)?;
970970- self.write("</span>")?;
971971-972972- // Store the definition info (no longer tracking syntax spans for hide/show)
973973- self.current_footnote_def = Some((name.to_string(), 0, char_start));
974974-975975- // Record offset mapping for the prefix
976976- self.record_mapping(
977977- range.start..range.start + prefix_byte_len,
978978- char_start..char_end,
979979- );
980980-981981- // Update tracking for the prefix
982982- self.last_char_offset = char_end;
983983- self.last_byte_offset = range.start + prefix_byte_len;
984984-985985- Ok(())
986986- }
987987- Tag::MetadataBlock(_) => {
988988- self.in_non_writing_block = true;
989989- Ok(())
990990- }
991991- }
992992- }
993993-994994- pub(crate) fn end_tag(
995995- &mut self,
996996- tag: markdown_weaver::TagEnd,
997997- range: Range<usize>,
998998- ) -> Result<(), fmt::Error> {
999999- use markdown_weaver::TagEnd;
10001000-10011001- // Emit tag HTML first
10021002- let result = match tag {
10031003- TagEnd::HtmlBlock => {
10041004- // Capture paragraph boundary info BEFORE writing closing HTML
10051005- // Skip if inside a list or footnote def - they own their paragraph boundary
10061006- let para_boundary = if self.list_depth == 0 && !self.in_footnote_def {
10071007- self.current_paragraph_start
10081008- .take()
10091009- .map(|(byte_start, char_start)| {
10101010- (
10111011- byte_start..self.last_byte_offset,
10121012- char_start..self.last_char_offset,
10131013- )
10141014- })
10151015- } else {
10161016- None
10171017- };
10181018-10191019- // Write closing HTML to current segment
10201020- self.end_node();
10211021- self.write("</p>")?;
10221022-10231023- // Now finalize paragraph (starts new segment)
10241024- if let Some((byte_range, char_range)) = para_boundary {
10251025- self.finalize_paragraph(byte_range, char_range);
10261026- }
10271027- Ok(())
10281028- }
10291029- TagEnd::Paragraph(_) => {
10301030- // Capture paragraph boundary info BEFORE writing closing HTML
10311031- // Skip if inside a list or footnote def - they own their paragraph boundary
10321032- let para_boundary = if self.list_depth == 0 && !self.in_footnote_def {
10331033- self.current_paragraph_start
10341034- .take()
10351035- .map(|(byte_start, char_start)| {
10361036- (
10371037- byte_start..self.last_byte_offset,
10381038- char_start..self.last_char_offset,
10391039- )
10401040- })
10411041- } else {
10421042- None
10431043- };
10441044-10451045- // Write closing HTML to current segment
10461046- self.end_node();
10471047- self.write("</p>")?;
10481048- self.close_wrapper()?;
10491049-10501050- // Now finalize paragraph (starts new segment)
10511051- if let Some((byte_range, char_range)) = para_boundary {
10521052- self.finalize_paragraph(byte_range, char_range);
10531053- }
10541054- Ok(())
10551055- }
10561056- TagEnd::Heading(level) => {
10571057- // Capture paragraph boundary info BEFORE writing closing HTML
10581058- let para_boundary =
10591059- self.current_paragraph_start
10601060- .take()
10611061- .map(|(byte_start, char_start)| {
10621062- (
10631063- byte_start..self.last_byte_offset,
10641064- char_start..self.last_char_offset,
10651065- )
10661066- });
10671067-10681068- // Write closing HTML to current segment
10691069- self.end_node();
10701070- self.write("</")?;
10711071- write!(&mut self.writer, "{}", level)?;
10721072- self.write(">")?;
10731073- // Note: Don't close wrapper here - headings typically go with following block
10741074-10751075- // Now finalize paragraph (starts new segment)
10761076- if let Some((byte_range, char_range)) = para_boundary {
10771077- self.finalize_paragraph(byte_range, char_range);
10781078- }
10791079- Ok(())
10801080- }
10811081- TagEnd::Table => {
10821082- if self.render_tables_as_markdown {
10831083- // Emit the raw markdown table
10841084- if let Some(start) = self.table_start_offset.take() {
10851085- let table_text = &self.source[start..range.end];
10861086- self.in_non_writing_block = false;
10871087-10881088- // Wrap in a pre or div for styling
10891089- self.write("<pre class=\"table-markdown\">")?;
10901090- escape_html(&mut self.writer, table_text)?;
10911091- self.write("</pre>")?;
10921092- }
10931093- Ok(())
10941094- } else {
10951095- self.write("</tbody></table>")
10961096- }
10971097- }
10981098- TagEnd::TableHead => {
10991099- if self.render_tables_as_markdown {
11001100- Ok(()) // Skip HTML rendering
11011101- } else {
11021102- self.write("</tr></thead><tbody>")?;
11031103- self.table_state = TableState::Body;
11041104- Ok(())
11051105- }
11061106- }
11071107- TagEnd::TableRow => {
11081108- if self.render_tables_as_markdown {
11091109- Ok(()) // Skip HTML rendering
11101110- } else {
11111111- self.write("</tr>")
11121112- }
11131113- }
11141114- TagEnd::TableCell => {
11151115- if self.render_tables_as_markdown {
11161116- Ok(()) // Skip HTML rendering
11171117- } else {
11181118- match self.table_state {
11191119- TableState::Head => self.write("</th>")?,
11201120- TableState::Body => self.write("</td>")?,
11211121- }
11221122- self.table_cell_index += 1;
11231123- Ok(())
11241124- }
11251125- }
11261126- TagEnd::BlockQuote(_) => {
11271127- // If pending_blockquote_range is still set, the blockquote was empty
11281128- // (no paragraph inside). Emit the > as its own minimal paragraph.
11291129- let mut para_boundary = None;
11301130- if let Some(bq_range) = self.pending_blockquote_range.take() {
11311131- if bq_range.start < bq_range.end {
11321132- let raw_text = &self.source[bq_range.clone()];
11331133- if let Some(gt_pos) = raw_text.find('>') {
11341134- let para_byte_start = bq_range.start + gt_pos;
11351135- let para_char_start = self.last_char_offset;
11361136-11371137- // Create a minimal paragraph for the empty blockquote
11381138- let node_id = self.gen_node_id();
11391139- write!(&mut self.writer, "<div id=\"{}\"", node_id)?;
11401140-11411141- // Record start-of-node mapping for cursor positioning
11421142- self.offset_maps.push(OffsetMapping {
11431143- byte_range: para_byte_start..para_byte_start,
11441144- char_range: para_char_start..para_char_start,
11451145- node_id: node_id.clone(),
11461146- char_offset_in_node: gt_pos,
11471147- child_index: Some(0),
11481148- utf16_len: 0,
11491149- });
11501150-11511151- // Emit the > as block syntax
11521152- let syntax = &raw_text[gt_pos..gt_pos + 1];
11531153- self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?;
11541154-11551155- self.write("</div>")?;
11561156- self.end_node();
11571157-11581158- // Capture paragraph boundary for later finalization
11591159- let byte_range = para_byte_start..bq_range.end;
11601160- let char_range = para_char_start..self.last_char_offset;
11611161- para_boundary = Some((byte_range, char_range));
11621162- }
11631163- }
11641164- }
11651165- self.write("</blockquote>")?;
11661166- self.close_wrapper()?;
11671167-11681168- // Now finalize paragraph if we had one
11691169- if let Some((byte_range, char_range)) = para_boundary {
11701170- self.finalize_paragraph(byte_range, char_range);
11711171- }
11721172- Ok(())
11731173- }
11741174- TagEnd::CodeBlock => {
11751175- use std::sync::LazyLock;
11761176- use syntect::parsing::SyntaxSet;
11771177- static SYNTAX_SET: LazyLock<SyntaxSet> =
11781178- LazyLock::new(|| SyntaxSet::load_defaults_newlines());
11791179-11801180- if let Some((lang, buffer)) = self.code_buffer.take() {
11811181- // Create offset mapping for code block content if we tracked ranges
11821182- if let (Some(code_byte_range), Some(code_char_range)) = (
11831183- self.code_buffer_byte_range.take(),
11841184- self.code_buffer_char_range.take(),
11851185- ) {
11861186- // Record mapping before writing HTML
11871187- // (current_node_id should be set by start_tag for CodeBlock)
11881188- self.record_mapping(code_byte_range, code_char_range);
11891189- }
11901190-11911191- // Get node_id for data-node-id attribute (needed for cursor positioning)
11921192- let node_id = self.current_node_id.clone();
11931193-11941194- if let Some(ref lang_str) = lang {
11951195- // Use a temporary String buffer for syntect
11961196- let mut temp_output = String::new();
11971197- match weaver_renderer::code_pretty::highlight(
11981198- &SYNTAX_SET,
11991199- Some(lang_str),
12001200- &buffer,
12011201- &mut temp_output,
12021202- ) {
12031203- Ok(_) => {
12041204- // Inject data-node-id into the <pre> tag for cursor positioning
12051205- if let Some(ref nid) = node_id {
12061206- let injected = temp_output.replacen(
12071207- "<pre>",
12081208- &format!("<pre data-node-id=\"{}\">", nid),
12091209- 1,
12101210- );
12111211- self.write(&injected)?;
12121212- } else {
12131213- self.write(&temp_output)?;
12141214- }
12151215- }
12161216- Err(_) => {
12171217- // Fallback to plain code block
12181218- if let Some(ref nid) = node_id {
12191219- write!(
12201220- &mut self.writer,
12211221- "<pre data-node-id=\"{}\"><code class=\"language-",
12221222- nid
12231223- )?;
12241224- } else {
12251225- self.write("<pre><code class=\"language-")?;
12261226- }
12271227- escape_html(&mut self.writer, lang_str)?;
12281228- self.write("\">")?;
12291229- escape_html_body_text(&mut self.writer, &buffer)?;
12301230- self.write("</code></pre>")?;
12311231- }
12321232- }
12331233- } else {
12341234- if let Some(ref nid) = node_id {
12351235- write!(&mut self.writer, "<pre data-node-id=\"{}\"><code>", nid)?;
12361236- } else {
12371237- self.write("<pre><code>")?;
12381238- }
12391239- escape_html_body_text(&mut self.writer, &buffer)?;
12401240- self.write("</code></pre>")?;
12411241- }
12421242-12431243- // End node tracking
12441244- self.end_node();
12451245- } else {
12461246- self.write("</code></pre>")?;
12471247- }
12481248-12491249- // Emit closing ``` (emit_gap_before is skipped while buffering)
12501250- // Track the opening span index and char start before we potentially clear them
12511251- let opening_span_idx = self.code_block_opening_span_idx.take();
12521252- let code_block_start = self.code_block_char_start.take();
12531253-12541254- if range.start < range.end {
12551255- let raw_text = &self.source[range.clone()];
12561256- if let Some(fence_line) = raw_text.lines().last() {
12571257- if fence_line.trim().starts_with("```") {
12581258- let fence = fence_line.trim();
12591259- let fence_char_len = fence.chars().count();
12601260-12611261- let syn_id = self.gen_syn_id();
12621262- let char_start = self.last_char_offset;
12631263- let char_end = char_start + fence_char_len;
12641264-12651265- write!(
12661266- &mut self.writer,
12671267- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
12681268- syn_id, char_start, char_end
12691269- )?;
12701270- escape_html(&mut self.writer, fence)?;
12711271- self.write("</span>")?;
12721272-12731273- self.last_char_offset += fence_char_len;
12741274- self.last_byte_offset += fence.len();
12751275-12761276- // Compute formatted_range for entire code block (opening fence to closing fence)
12771277- let formatted_range =
12781278- code_block_start.map(|start| start..self.last_char_offset);
12791279-12801280- // Update opening fence span with formatted_range
12811281- if let (Some(idx), Some(fr)) =
12821282- (opening_span_idx, formatted_range.as_ref())
12831283- {
12841284- if let Some(span) = self.syntax_spans.get_mut(idx) {
12851285- span.formatted_range = Some(fr.clone());
12861286- }
12871287- }
12881288-12891289- // Push closing fence span with formatted_range
12901290- self.syntax_spans.push(SyntaxSpanInfo {
12911291- syn_id,
12921292- char_range: char_start..char_end,
12931293- syntax_type: SyntaxType::Block,
12941294- formatted_range,
12951295- });
12961296- }
12971297- }
12981298- }
12991299-13001300- // Finalize code block paragraph
13011301- if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
13021302- let byte_range = byte_start..self.last_byte_offset;
13031303- let char_range = char_start..self.last_char_offset;
13041304- self.finalize_paragraph(byte_range, char_range);
13051305- }
13061306-13071307- Ok(())
13081308- }
13091309- TagEnd::List(true) => {
13101310- self.list_depth = self.list_depth.saturating_sub(1);
13111311- // Capture paragraph boundary BEFORE writing closing HTML
13121312- let para_boundary =
13131313- self.current_paragraph_start
13141314- .take()
13151315- .map(|(byte_start, char_start)| {
13161316- (
13171317- byte_start..self.last_byte_offset,
13181318- char_start..self.last_char_offset,
13191319- )
13201320- });
13211321-13221322- self.write("</ol>")?;
13231323- self.close_wrapper()?;
13241324-13251325- // Finalize paragraph after closing HTML
13261326- if let Some((byte_range, char_range)) = para_boundary {
13271327- self.finalize_paragraph(byte_range, char_range);
13281328- }
13291329- Ok(())
13301330- }
13311331- TagEnd::List(false) => {
13321332- self.list_depth = self.list_depth.saturating_sub(1);
13331333- // Capture paragraph boundary BEFORE writing closing HTML
13341334- let para_boundary =
13351335- self.current_paragraph_start
13361336- .take()
13371337- .map(|(byte_start, char_start)| {
13381338- (
13391339- byte_start..self.last_byte_offset,
13401340- char_start..self.last_char_offset,
13411341- )
13421342- });
13431343-13441344- self.write("</ul>")?;
13451345- self.close_wrapper()?;
13461346-13471347- // Finalize paragraph after closing HTML
13481348- if let Some((byte_range, char_range)) = para_boundary {
13491349- self.finalize_paragraph(byte_range, char_range);
13501350- }
13511351- Ok(())
13521352- }
13531353- TagEnd::Item => {
13541354- self.end_node();
13551355- self.write("</li>")
13561356- }
13571357- TagEnd::DefinitionList => {
13581358- self.write("</dl>")?;
13591359- self.close_wrapper()
13601360- }
13611361- TagEnd::DefinitionListTitle => {
13621362- self.end_node();
13631363- self.write("</dt>")
13641364- }
13651365- TagEnd::DefinitionListDefinition => {
13661366- self.end_node();
13671367- self.write("</dd>")
13681368- }
13691369- TagEnd::Emphasis => {
13701370- // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
13711371- self.write("</em>")?;
13721372- self.emit_gap_before(range.end)?;
13731373- self.finalize_paired_inline_format();
13741374- Ok(())
13751375- }
13761376- TagEnd::Superscript => self.write("</sup>"),
13771377- TagEnd::Subscript => self.write("</sub>"),
13781378- TagEnd::Strong => {
13791379- // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
13801380- self.write("</strong>")?;
13811381- self.emit_gap_before(range.end)?;
13821382- self.finalize_paired_inline_format();
13831383- Ok(())
13841384- }
13851385- TagEnd::Strikethrough => {
13861386- // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
13871387- self.write("</s>")?;
13881388- self.emit_gap_before(range.end)?;
13891389- self.finalize_paired_inline_format();
13901390- Ok(())
13911391- }
13921392- TagEnd::Link => {
13931393- self.write("</a>")?;
13941394- // Check if this is a wiki link (ends with ]]) vs regular link (ends with ))
13951395- let raw_text = &self.source[range.clone()];
13961396- if raw_text.ends_with("]]") {
13971397- // WikiLink: emit ]] as closing syntax
13981398- let syn_id = self.gen_syn_id();
13991399- let char_start = self.last_char_offset;
14001400- let char_end = char_start + 2;
14011401-14021402- write!(
14031403- &mut self.writer,
14041404- "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
14051405- syn_id, char_start, char_end
14061406- )?;
14071407-14081408- self.syntax_spans.push(SyntaxSpanInfo {
14091409- syn_id,
14101410- char_range: char_start..char_end,
14111411- syntax_type: SyntaxType::Inline,
14121412- formatted_range: None, // Will be set by finalize
14131413- });
14141414-14151415- self.last_char_offset = char_end;
14161416- self.last_byte_offset = range.end;
14171417- } else {
14181418- self.emit_gap_before(range.end)?;
14191419- }
14201420- self.finalize_paired_inline_format();
14211421- Ok(())
14221422- }
14231423- TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event
14241424- TagEnd::Embed => Ok(()),
14251425- TagEnd::WeaverBlock(_) => {
14261426- self.in_non_writing_block = false;
14271427-14281428- // Emit the { content } as a hideable syntax span
14291429- if let Some(char_start) = self.weaver_block_char_start.take() {
14301430- // Build the full syntax text: { buffered_content }
14311431- let syntax_text = format!("{{{}}}", self.weaver_block_buffer);
14321432- let syntax_char_len = syntax_text.chars().count();
14331433- let char_end = char_start + syntax_char_len;
14341434-14351435- let syn_id = self.gen_syn_id();
14361436-14371437- write!(
14381438- &mut self.writer,
14391439- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
14401440- syn_id, char_start, char_end
14411441- )?;
14421442- escape_html(&mut self.writer, &syntax_text)?;
14431443- self.write("</span>")?;
14441444-14451445- // Track the syntax span
14461446- self.syntax_spans.push(SyntaxSpanInfo {
14471447- syn_id,
14481448- char_range: char_start..char_end,
14491449- syntax_type: SyntaxType::Block,
14501450- formatted_range: None,
14511451- });
14521452-14531453- // Record offset mapping for the syntax span
14541454- self.record_mapping(range.clone(), char_start..char_end);
14551455-14561456- // Update tracking
14571457- self.last_char_offset = char_end;
14581458- self.last_byte_offset = range.end;
14591459- }
14601460-14611461- // Parse the buffered text for attrs and store for next block
14621462- if !self.weaver_block_buffer.is_empty() {
14631463- let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer);
14641464- self.weaver_block_buffer.clear();
14651465- // Merge with any existing pending attrs or set new
14661466- if let Some(ref mut existing) = self.pending_block_attrs {
14671467- existing.classes.extend(parsed.classes);
14681468- existing.attrs.extend(parsed.attrs);
14691469- } else {
14701470- self.pending_block_attrs = Some(parsed);
14711471- }
14721472- }
14731473-14741474- Ok(())
14751475- }
14761476- TagEnd::FootnoteDefinition => {
14771477- // End node tracking (inner paragraphs may have already cleared it)
14781478- self.end_node();
14791479- self.write("</div>")?;
14801480-14811481- // Clear footnote tracking
14821482- self.current_footnote_def.take();
14831483- self.in_footnote_def = false;
14841484-14851485- // Finalize paragraph boundary for incremental rendering
14861486- if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
14871487- let byte_range = byte_start..self.last_byte_offset;
14881488- let char_range = char_start..self.last_char_offset;
14891489- self.finalize_paragraph(byte_range, char_range);
14901490- }
14911491-14921492- Ok(())
14931493- }
14941494- TagEnd::MetadataBlock(_) => {
14951495- self.in_non_writing_block = false;
14961496- Ok(())
14971497- }
14981498- };
14991499-15001500- result?;
15011501-15021502- // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough)
15031503- // is handled INSIDE their respective match arms above, AFTER writing the closing HTML.
15041504- // This ensures the closing syntax span appears OUTSIDE the formatted element.
15051505- // Other End events have their closing syntax emitted by emit_gap_before() in the main loop.
15061506-15071507- Ok(())
15081508- }
15091509-}
+54-19
crates/weaver-editor-core/src/render.rs
···77//!
88//! Implementations are provided by the consuming application (e.g., weaver-app).
991010+use markdown_weaver::Tag;
1111+1012/// Provides HTML content for embedded resources.
1113///
1214/// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait
1315/// is consulted to get the pre-rendered HTML for the embed.
1616+///
1717+/// The full `Tag::Embed` is provided so implementations can access all context:
1818+/// embed_type, dest_url, title, id, and attrs.
1419pub trait EmbedContentProvider {
1515- /// Get HTML content for an embed URL.
2020+ /// Get HTML content for an embed tag.
1621 ///
1722 /// Returns `Some(html)` if the embed content is available,
1823 /// `None` to render a placeholder.
1919- fn get_embed_html(&self, url: &str) -> Option<&str>;
2424+ fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>;
2025}
21262227/// Unit type implementation - no embeds available.
2328impl EmbedContentProvider for () {
2424- fn get_embed_html(&self, _url: &str) -> Option<&str> {
2929+ fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> {
2530 None
2631 }
2732}
···6368/// Reference implementations for common patterns.
64696570impl<T: EmbedContentProvider> EmbedContentProvider for &T {
6666- fn get_embed_html(&self, url: &str) -> Option<&str> {
6767- (*self).get_embed_html(url)
7171+ fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
7272+ (*self).get_embed_content(tag)
6873 }
6974}
7075···8186}
82878388impl<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))
8989+ fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
9090+ self.as_ref().and_then(|p| p.get_embed_content(tag))
8691 }
8792}
8893···95100impl<T: WikilinkValidator> WikilinkValidator for Option<T> {
96101 fn is_valid_link(&self, target: &str) -> bool {
97102 self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true)
103103+ }
104104+}
105105+106106+/// Implementation for ResolvedContent from weaver-common.
107107+///
108108+/// Resolves AT Protocol embeds by looking up the content in the ResolvedContent map.
109109+impl EmbedContentProvider for weaver_common::ResolvedContent {
110110+ fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
111111+ if let Tag::Embed { dest_url, .. } = tag {
112112+ let url = dest_url.as_ref();
113113+ if url.starts_with("at://") {
114114+ if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) {
115115+ return weaver_common::ResolvedContent::get_embed_content(self, &at_uri)
116116+ .map(|s| s.to_string());
117117+ }
118118+ }
119119+ }
120120+ None
98121 }
99122}
100123101124#[cfg(test)]
102125mod tests {
103126 use super::*;
127127+ use markdown_weaver::EmbedType;
104128105129 struct TestEmbedProvider;
106130107131 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
132132+ fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
133133+ if let Tag::Embed { dest_url, .. } = tag {
134134+ if dest_url.as_ref() == "at://test/embed" {
135135+ return Some("<div>Test Embed</div>".to_string());
136136+ }
113137 }
138138+ None
114139 }
115140 }
116141···136161 }
137162 }
138163164164+ fn make_embed_tag(url: &str) -> Tag<'_> {
165165+ Tag::Embed {
166166+ embed_type: EmbedType::Other,
167167+ dest_url: url.into(),
168168+ title: "".into(),
169169+ id: "".into(),
170170+ attrs: None,
171171+ }
172172+ }
173173+139174 #[test]
140175 fn test_embed_provider() {
141176 let provider = TestEmbedProvider;
142177 assert_eq!(
143143- provider.get_embed_html("at://test/embed"),
144144- Some("<div>Test Embed</div>")
178178+ provider.get_embed_content(&make_embed_tag("at://test/embed")),
179179+ Some("<div>Test Embed</div>".to_string())
145180 );
146146- assert_eq!(provider.get_embed_html("at://other"), None);
181181+ assert_eq!(provider.get_embed_content(&make_embed_tag("at://other")), None);
147182 }
148183149184 #[test]
···169204 #[test]
170205 fn test_unit_impls() {
171206 let embed: () = ();
172172- assert_eq!(embed.get_embed_html("anything"), None);
207207+ assert_eq!(embed.get_embed_content(&make_embed_tag("anything")), None);
173208174209 let image: () = ();
175210 assert_eq!(image.resolve_image_url("anything"), None);
···182217 fn test_option_impls() {
183218 let some_provider: Option<TestEmbedProvider> = Some(TestEmbedProvider);
184219 assert_eq!(
185185- some_provider.get_embed_html("at://test/embed"),
186186- Some("<div>Test Embed</div>")
220220+ some_provider.get_embed_content(&make_embed_tag("at://test/embed")),
221221+ Some("<div>Test Embed</div>".to_string())
187222 );
188223189224 let none_provider: Option<TestEmbedProvider> = None;
190190- assert_eq!(none_provider.get_embed_html("at://test/embed"), None);
225225+ assert_eq!(none_provider.get_embed_content(&make_embed_tag("at://test/embed")), None);
191226 }
192227}
+1-1
crates/weaver-editor-core/src/text.rs
···1010/// A text buffer that supports efficient editing and offset conversion.
1111///
1212/// All offsets are in Unicode scalar values (chars), not bytes or UTF-16.
1313-pub trait TextBuffer: Default + Clone {
1313+pub trait TextBuffer {
1414 /// Total length in bytes (UTF-8).
1515 fn len_bytes(&self) -> usize;
1616
+13-3
crates/weaver-editor-core/src/undo.rs
···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> {
5050+pub struct UndoableBuffer<T> {
5251 buffer: T,
5352 undo_stack: Vec<EditOperation>,
5453 redo_stack: Vec<EditOperation>,
5554 max_steps: usize,
5655}
57565858-impl<T: TextBuffer> Default for UndoableBuffer<T> {
5757+impl<T: Clone> Clone for UndoableBuffer<T> {
5858+ fn clone(&self) -> Self {
5959+ Self {
6060+ buffer: self.buffer.clone(),
6161+ undo_stack: self.undo_stack.clone(),
6262+ redo_stack: self.redo_stack.clone(),
6363+ max_steps: self.max_steps,
6464+ }
6565+ }
6666+}
6767+6868+impl<T: TextBuffer + Default> Default for UndoableBuffer<T> {
5969 fn default() -> Self {
6070 Self::new(T::default(), 100)
6171 }
+20-13
crates/weaver-editor-core/src/writer/embed.rs
···6677use jacquard::IntoStatic;
88use jacquard::types::{ident::AtIdentifier, string::Rkey};
99-use markdown_weaver::{CowStr, EmbedType, Event};
99+use markdown_weaver::{CowStr, Event, Tag};
1010use markdown_weaver_escape::{StrWrite, escape_html};
1111use smol_str::SmolStr;
1212···6565 blob_rkey: Rkey<'static>,
6666 ident: AtIdentifier<'static>,
6767 ) {
6868- self.images
6969- .insert(SmolStr::new(name), ResolvedImage::Draft { blob_rkey, ident });
6868+ self.images.insert(
6969+ SmolStr::new(name),
7070+ ResolvedImage::Draft { blob_rkey, ident },
7171+ );
7072 }
71737274 /// Add an already-uploaded draft image.
···160162}
161163162164// write_embed implementation
163163-impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
165165+impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
164166where
167167+ T: crate::TextBuffer,
165168 I: Iterator<Item = (Event<'a>, Range<usize>)>,
166169 E: EmbedContentProvider,
167170 R: ImageResolver,
···170173 pub(crate) fn write_embed(
171174 &mut self,
172175 range: Range<usize>,
173173- _embed_type: EmbedType,
174174- dest_url: CowStr<'_>,
175175- title: CowStr<'_>,
176176- _id: CowStr<'_>,
177177- attrs: Option<markdown_weaver::WeaverAttributes<'_>>,
176176+ tag: Tag<'_>,
178177 ) -> Result<(), fmt::Error> {
178178+ let Tag::Embed {
179179+ dest_url,
180180+ title,
181181+ attrs,
182182+ ..
183183+ } = &tag
184184+ else {
185185+ return Ok(());
186186+ };
187187+179188 // Embed rendering: all syntax elements share one syn_id for visibility toggling
180189 // Structure: ![[ url-as-link ]] <embed-content>
181190 let raw_text = &self.source[range.clone()];
···279288280289 // 4. Emit the actual embed content
281290 // Try to get content from attributes first
282282- let content_from_attrs = if let Some(ref attrs) = attrs {
291291+ let content_from_attrs = if let Some(attrs) = attrs {
283292 attrs
284293 .attrs
285294 .iter()
···290299 };
291300292301 // If no content in attrs, try provider
293293- // Convert to owned to avoid borrow checker issues with self.write()
294294- // TODO: figure out a way to do this that doesn't involve cloning
295302 let content: Option<String> = if content_from_attrs.is_some() {
296303 content_from_attrs
297304 } else if let Some(ref provider) = self.embed_provider {
298298- provider.get_embed_html(url).map(|s| s.to_string())
305305+ provider.get_embed_content(&tag)
299306 } else {
300307 None
301308 };
+3-2
crates/weaver-editor-core/src/writer/events.rs
···1414use super::{EditorWriter, WriterResult};
15151616// Main run loop
1717-impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
1717+impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
1818where
1919+ T: crate::TextBuffer,
1920 I: Iterator<Item = (Event<'a>, Range<usize>)>,
2021 E: EmbedContentProvider,
2122 R: ImageResolver,
···8485 // Handle unmapped trailing content (stripped by parser)
8586 // This includes trailing spaces that markdown ignores
8687 let doc_byte_len = self.source.len();
8787- let doc_char_len = self.source_len_chars;
8888+ let doc_char_len = self.text_buffer.len_chars();
88898990 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
9091 // Emit the trailing content as visible syntax
+18-14
crates/weaver-editor-core/src/writer/mod.rs
···9494/// HTML writer that preserves markdown formatting characters.
9595///
9696/// Generic over:
9797+/// - `T`: Text buffer for efficient offset conversions
9798/// - `I`: Iterator of markdown events with byte ranges
9899/// - `E`: Embed content provider (optional)
99100/// - `R`: Image resolver (optional)
100101/// - `W`: Wikilink validator (optional)
101101-pub struct EditorWriter<'a, I, E = (), R = (), W = ()>
102102+pub struct EditorWriter<'a, T, I, E = (), R = (), W = ()>
102103where
104104+ T: crate::TextBuffer,
103105 I: Iterator<Item = (Event<'a>, Range<usize>)>,
104106{
105107 // === Input ===
106108 source: &'a str,
107107- source_len_chars: usize,
109109+ text_buffer: &'a T,
108110 events: I,
109111110112 // === Output ===
···146148 ref_collector: weaver_common::RefCollector,
147149}
148150149149-impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
151151+impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
150152where
153153+ T: crate::TextBuffer,
151154 I: Iterator<Item = (Event<'a>, Range<usize>)>,
152155{
153156 /// Create a new EditorWriter.
154157 ///
155155- /// `source` is the markdown source text.
156156- /// `source_len_chars` is the length in Unicode chars (for bounds checking).
158158+ /// `source` is the markdown source text (should match text_buffer content).
159159+ /// `text_buffer` provides efficient offset conversions.
157160 /// `events` is the markdown parser event iterator.
158158- pub fn new(source: &'a str, source_len_chars: usize, events: I) -> Self {
161161+ pub fn new(source: &'a str, text_buffer: &'a T, events: I) -> Self {
159162 Self {
160163 source,
161161- source_len_chars,
164164+ text_buffer,
162165 events,
163166 writer: SegmentedWriter::new(),
164167 last_byte_offset: 0,
···232235 pub fn with_embed_provider<E2: EmbedContentProvider>(
233236 self,
234237 provider: E2,
235235- ) -> EditorWriter<'a, I, E2, R, W> {
238238+ ) -> EditorWriter<'a, T, I, E2, R, W> {
236239 EditorWriter {
237240 source: self.source,
238238- source_len_chars: self.source_len_chars,
241241+ text_buffer: self.text_buffer,
239242 events: self.events,
240243 writer: self.writer,
241244 last_byte_offset: self.last_byte_offset,
···268271 pub fn with_image_resolver<R2: ImageResolver>(
269272 self,
270273 resolver: R2,
271271- ) -> EditorWriter<'a, I, E, R2, W> {
274274+ ) -> EditorWriter<'a, T, I, E, R2, W> {
272275 EditorWriter {
273276 source: self.source,
274274- source_len_chars: self.source_len_chars,
277277+ text_buffer: self.text_buffer,
275278 events: self.events,
276279 writer: self.writer,
277280 last_byte_offset: self.last_byte_offset,
···304307 pub fn with_wikilink_validator<W2: WikilinkValidator>(
305308 self,
306309 validator: W2,
307307- ) -> EditorWriter<'a, I, E, R, W2> {
310310+ ) -> EditorWriter<'a, T, I, E, R, W2> {
308311 EditorWriter {
309312 source: self.source,
310310- source_len_chars: self.source_len_chars,
313313+ text_buffer: self.text_buffer,
311314 events: self.events,
312315 writer: self.writer,
313316 last_byte_offset: self.last_byte_offset,
···344347}
345348346349// Core helper methods
347347-impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
350350+impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
348351where
352352+ T: crate::TextBuffer,
349353 I: Iterator<Item = (Event<'a>, Range<usize>)>,
350354{
351355 /// Write a string to the output.
+2-1
crates/weaver-editor-core/src/writer/syntax.rs
···11111212use super::EditorWriter;
13131414-impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
1414+impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
1515where
1616+ T: crate::TextBuffer,
1617 I: Iterator<Item = (Event<'a>, Range<usize>)>,
1718 E: EmbedContentProvider,
1819 R: ImageResolver,
+8-12
crates/weaver-editor-core/src/writer/tags.rs
···13131414use super::{EditorWriter, TableState};
15151616-impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W>
1616+impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
1717where
1818+ T: crate::TextBuffer,
1819 I: Iterator<Item = (Event<'a>, Range<usize>)>,
1920 E: EmbedContentProvider,
2021 R: ImageResolver,
···747748 if matches!(link_type, LinkType::WikiLink { .. })
748749 && (url.starts_with("at://") || url.starts_with("did:"))
749750 {
750750- return self.write_embed(
751751- range,
752752- EmbedType::Other, // AT embeds - disambiguated via NSID later
751751+ // Construct an Embed tag from the Image fields
752752+ let embed_tag = Tag::Embed {
753753+ embed_type: EmbedType::Other,
753754 dest_url,
754755 title,
755756 id,
756757 attrs,
757757- );
758758+ };
759759+ return self.write_embed(range, embed_tag);
758760 }
759761760762 // Image rendering: all syntax elements share one syn_id for visibility toggling
···906908907909 Ok(())
908910 }
909909- Tag::Embed {
910910- embed_type,
911911- dest_url,
912912- title,
913913- id,
914914- attrs,
915915- } => self.write_embed(range, embed_type, dest_url, title, id, attrs),
911911+ tag @ Tag::Embed { .. } => self.write_embed(range, tag),
916912 Tag::WeaverBlock(_, attrs) => {
917913 self.in_non_writing_block = true;
918914 self.weaver_block.buffer.clear();