at main 253 lines 11 kB view raw
1//! Syntax span emission methods for EditorWriter. 2 3use core::fmt; 4use std::ops::Range; 5 6use markdown_weaver::Event; 7use markdown_weaver_escape::{StrWrite, escape_html}; 8 9use crate::offset_map::OffsetMapping; 10use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; 11use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; 12 13use super::EditorWriter; 14 15impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 16where 17 T: crate::TextBuffer, 18 I: Iterator<Item = (Event<'a>, Range<usize>)>, 19 E: EmbedContentProvider, 20 R: ImageResolver, 21 W: WikilinkValidator, 22{ 23 /// Emit syntax span for a given range and record offset mapping. 24 pub(crate) fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> { 25 if range.start < range.end { 26 let syntax = &self.source[range.clone()]; 27 if !syntax.is_empty() { 28 let char_start = self.last_char_offset; 29 let syntax_char_len = syntax.chars().count(); 30 let char_end = char_start + syntax_char_len; 31 32 tracing::trace!( 33 target: "weaver::writer", 34 byte_range = ?range, 35 char_range = ?(char_start..char_end), 36 syntax = %syntax.escape_debug(), 37 "emit_syntax" 38 ); 39 40 // Whitespace-only content (trailing spaces, newlines) should be emitted 41 // as plain text, not wrapped in a hideable syntax span 42 let is_whitespace_only = syntax.trim().is_empty(); 43 44 if is_whitespace_only { 45 // Check if we need to create a wrapper for standalone gap content. 46 let created_node = if self.current_node.id.is_none() { 47 let node_id = self.gen_node_id(); 48 write!(&mut self.writer, "<span id=\"{}\">", node_id)?; 49 self.begin_node(node_id); 50 true 51 } else { 52 false 53 }; 54 55 // Only convert newlines to <br /> when this is standalone gap content 56 // (created_node = true). Inside paragraphs, keep newlines as-is. 57 if created_node { 58 // Gap content: the first newline is just the paragraph break (already 59 // visual from div structure), so emit only ZWSP. Additional newlines 60 // are actual blank lines, so emit <br /> + ZWSP for those. 61 let mut byte_offset = range.start; 62 let mut char_offset = char_start; 63 let mut newline_count = 0usize; 64 for ch in syntax.chars() { 65 let char_byte_len = ch.len_utf8(); 66 if ch == '\n' { 67 newline_count += 1; 68 let utf16_len = if newline_count == 1 { 69 // First newline: just ZWSP (paragraph break is already visual). 70 self.write("\u{200B}")?; 71 1 72 } else { 73 // Additional newlines: literal \n + ZWSP. 74 // CSS white-space-collapse: break-spaces handles the visual break. 75 self.write("\n\u{200B}")?; 76 2 77 }; 78 if let Some(ref node_id) = self.current_node.id { 79 let mapping = OffsetMapping { 80 byte_range: byte_offset..byte_offset + char_byte_len, 81 char_range: char_offset..char_offset + 1, 82 node_id: node_id.clone(), 83 char_offset_in_node: self.current_node.char_offset, 84 child_index: None, 85 utf16_len, 86 }; 87 self.current_para.offset_maps.push(mapping); 88 self.current_node.char_offset += utf16_len; 89 } 90 } else { 91 escape_html(&mut self.writer, &ch.to_string())?; 92 if let Some(ref node_id) = self.current_node.id { 93 let mapping = OffsetMapping { 94 byte_range: byte_offset..byte_offset + char_byte_len, 95 char_range: char_offset..char_offset + 1, 96 node_id: node_id.clone(), 97 char_offset_in_node: self.current_node.char_offset, 98 child_index: None, 99 utf16_len: 1, 100 }; 101 self.current_para.offset_maps.push(mapping); 102 self.current_node.char_offset += 1; 103 } 104 } 105 byte_offset += char_byte_len; 106 char_offset += 1; 107 } 108 } else { 109 // Inside a paragraph: emit whitespace as plain text. 110 escape_html(&mut self.writer, syntax)?; 111 self.record_mapping(range.clone(), char_start..char_end); 112 } 113 114 self.last_char_offset = char_end; 115 self.last_byte_offset = range.end; 116 117 if created_node { 118 self.write("</span>")?; 119 self.end_node(); 120 } 121 } else { 122 // Real syntax - wrap in hideable span 123 let syntax_type = classify_syntax(syntax); 124 let class = match syntax_type { 125 SyntaxType::Inline => "md-syntax-inline", 126 SyntaxType::Block => "md-syntax-block", 127 }; 128 129 // Generate unique ID for this syntax span 130 let syn_id = self.gen_syn_id(); 131 132 // If we're outside any node, create a wrapper span for tracking 133 let created_node = if self.current_node.id.is_none() { 134 let node_id = self.gen_node_id(); 135 write!( 136 &mut self.writer, 137 "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 138 node_id, class, syn_id, char_start, char_end 139 )?; 140 self.begin_node(node_id); 141 true 142 } else { 143 write!( 144 &mut self.writer, 145 "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 146 class, syn_id, char_start, char_end 147 )?; 148 false 149 }; 150 151 escape_html(&mut self.writer, syntax)?; 152 self.write("</span>")?; 153 154 // Record syntax span info for visibility toggling 155 self.current_para.syntax_spans.push(SyntaxSpanInfo { 156 syn_id, 157 char_range: char_start..char_end, 158 syntax_type, 159 formatted_range: None, 160 }); 161 162 // Record offset mapping for this syntax 163 self.record_mapping(range.clone(), char_start..char_end); 164 self.last_char_offset = char_end; 165 self.last_byte_offset = range.end; 166 167 // Close wrapper if we created one 168 if created_node { 169 self.write("</span>")?; 170 self.end_node(); 171 } 172 } 173 } 174 } 175 Ok(()) 176 } 177 178 /// Emit syntax span inside current node with full offset tracking. 179 /// 180 /// Use this for syntax markers that appear inside block elements (headings, lists, 181 /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates 182 /// wrapper nodes, this assumes we're already inside a tracked node. 183 /// 184 /// - Writes `<span class="md-syntax-{class}">{syntax}</span>` 185 /// - Records offset mapping (for cursor positioning) 186 /// - Updates both `last_char_offset` and `last_byte_offset` 187 pub(crate) fn emit_inner_syntax( 188 &mut self, 189 syntax: &str, 190 byte_start: usize, 191 syntax_type: SyntaxType, 192 ) -> Result<(), fmt::Error> { 193 if syntax.is_empty() { 194 return Ok(()); 195 } 196 197 let char_start = self.last_char_offset; 198 let syntax_char_len = syntax.chars().count(); 199 let char_end = char_start + syntax_char_len; 200 let byte_end = byte_start + syntax.len(); 201 202 let class_str = match syntax_type { 203 SyntaxType::Inline => "md-syntax-inline", 204 SyntaxType::Block => "md-syntax-block", 205 }; 206 207 // Generate unique ID for this syntax span 208 let syn_id = self.gen_syn_id(); 209 210 write!( 211 &mut self.writer, 212 "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 213 class_str, syn_id, char_start, char_end 214 )?; 215 escape_html(&mut self.writer, syntax)?; 216 self.write("</span>")?; 217 218 // Record syntax span info for visibility toggling 219 self.current_para.syntax_spans.push(SyntaxSpanInfo { 220 syn_id, 221 char_range: char_start..char_end, 222 syntax_type, 223 formatted_range: None, 224 }); 225 226 // Record offset mapping for cursor positioning 227 self.record_mapping(byte_start..byte_end, char_start..char_end); 228 229 self.last_char_offset = char_end; 230 self.last_byte_offset = byte_end; 231 232 Ok(()) 233 } 234 235 /// Emit any gap between last position and next offset. 236 pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { 237 // Skip gap emission if we're inside a table being rendered as markdown 238 if self.table.start_offset.is_some() && self.table.render_as_markdown { 239 return Ok(()); 240 } 241 242 // Skip gap emission if we're buffering code block content 243 // The code block handler manages its own syntax emission 244 if self.code_block.is_active() { 245 return Ok(()); 246 } 247 248 if next_offset > self.last_byte_offset { 249 self.emit_syntax(self.last_byte_offset..next_offset)?; 250 } 251 Ok(()) 252 } 253}