at main 330 lines 11 kB view raw
1//! Embed rendering and image resolution for EditorWriter. 2 3use core::fmt; 4use std::collections::HashMap; 5use std::ops::Range; 6 7use jacquard::IntoStatic; 8use jacquard::types::{ident::AtIdentifier, string::Rkey}; 9use markdown_weaver::{Event, Tag}; 10use markdown_weaver_escape::{StrWrite, escape_html}; 11use smol_str::SmolStr; 12 13use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; 14use crate::syntax::{SyntaxSpanInfo, SyntaxType}; 15use crate::types::EditorImage; 16 17use super::EditorWriter; 18 19/// Resolved image path type. 20#[derive(Clone, Debug)] 21enum ResolvedImage { 22 /// Data URL for immediate preview (still uploading) 23 Pending(String), 24 /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}` 25 Draft { 26 blob_rkey: Rkey<'static>, 27 ident: AtIdentifier<'static>, 28 }, 29 /// Published image: `/image/{ident}/{entry_rkey}/{name}` 30 Published { 31 entry_rkey: Rkey<'static>, 32 ident: AtIdentifier<'static>, 33 }, 34} 35 36/// Resolves image paths in the editor. 37/// 38/// Supports three states for images: 39/// - Pending: uses data URL for immediate preview while upload is in progress 40/// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}` 41/// - Published: uses path format `/image/{did}/{entry_rkey}/{name}` 42/// 43/// Image URLs in markdown use the format `/image/{name}`. 44#[derive(Clone, Default)] 45pub struct EditorImageResolver { 46 /// All resolved images: name -> resolved path info 47 images: HashMap<SmolStr, ResolvedImage>, 48} 49 50impl EditorImageResolver { 51 pub fn new() -> Self { 52 Self::default() 53 } 54 55 /// Add a pending image with a data URL for immediate preview. 56 pub fn add_pending(&mut self, name: impl Into<SmolStr>, data_url: String) { 57 self.images 58 .insert(name.into(), ResolvedImage::Pending(data_url)); 59 } 60 61 /// Promote a pending image to uploaded (draft) status. 62 pub fn promote_to_uploaded( 63 &mut self, 64 name: &str, 65 blob_rkey: Rkey<'static>, 66 ident: AtIdentifier<'static>, 67 ) { 68 self.images.insert( 69 SmolStr::new(name), 70 ResolvedImage::Draft { blob_rkey, ident }, 71 ); 72 } 73 74 /// Add an already-uploaded draft image. 75 pub fn add_uploaded( 76 &mut self, 77 name: impl Into<SmolStr>, 78 blob_rkey: Rkey<'static>, 79 ident: AtIdentifier<'static>, 80 ) { 81 self.images 82 .insert(name.into(), ResolvedImage::Draft { blob_rkey, ident }); 83 } 84 85 /// Add a published image. 86 pub fn add_published( 87 &mut self, 88 name: impl Into<SmolStr>, 89 entry_rkey: Rkey<'static>, 90 ident: AtIdentifier<'static>, 91 ) { 92 self.images 93 .insert(name.into(), ResolvedImage::Published { entry_rkey, ident }); 94 } 95 96 /// Check if an image is pending upload. 97 pub fn is_pending(&self, name: &str) -> bool { 98 matches!(self.images.get(name), Some(ResolvedImage::Pending(_))) 99 } 100 101 /// Build a resolver from editor images and user identifier. 102 /// 103 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 104 /// For published mode (entry_rkey=Some), all images are included. 105 pub fn from_images<'a>( 106 images: impl IntoIterator<Item = &'a EditorImage>, 107 ident: AtIdentifier<'static>, 108 entry_rkey: Option<Rkey<'static>>, 109 ) -> Self { 110 let mut resolver = Self::new(); 111 for editor_image in images { 112 // Get the name from the Image (use alt text as fallback if name is empty) 113 let name = editor_image 114 .image 115 .name 116 .as_ref() 117 .map(|n| n.to_string()) 118 .unwrap_or_else(|| editor_image.image.alt.to_string()); 119 120 if name.is_empty() { 121 continue; 122 } 123 124 match &entry_rkey { 125 // Published mode: use entry rkey for all images 126 Some(rkey) => { 127 resolver.add_published(name, rkey.clone(), ident.clone()); 128 } 129 // Draft mode: use published_blob_uri rkey 130 None => { 131 let blob_rkey = match &editor_image.published_blob_uri { 132 Some(uri) => match uri.rkey() { 133 Some(rkey) => rkey.0.clone().into_static(), 134 None => continue, 135 }, 136 None => continue, 137 }; 138 resolver.add_uploaded(name, blob_rkey, ident.clone()); 139 } 140 } 141 } 142 resolver 143 } 144} 145 146impl ImageResolver for EditorImageResolver { 147 fn resolve_image_url(&self, url: &str) -> Option<String> { 148 // Extract image name from /image/{name} format 149 let name = url.strip_prefix("/image/").unwrap_or(url); 150 151 let resolved = self.images.get(name)?; 152 match resolved { 153 ResolvedImage::Pending(data_url) => Some(data_url.clone()), 154 ResolvedImage::Draft { blob_rkey, ident } => { 155 Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name)) 156 } 157 ResolvedImage::Published { entry_rkey, ident } => { 158 Some(format!("/image/{}/{}/{}", ident, entry_rkey, name)) 159 } 160 } 161 } 162} 163 164// write_embed implementation 165impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 166where 167 T: crate::TextBuffer, 168 I: Iterator<Item = (Event<'a>, Range<usize>)>, 169 E: EmbedContentProvider, 170 R: ImageResolver, 171 W: WikilinkValidator, 172{ 173 pub(crate) fn write_embed( 174 &mut self, 175 range: Range<usize>, 176 tag: Tag<'_>, 177 ) -> Result<(), fmt::Error> { 178 let Tag::Embed { 179 dest_url, 180 title, 181 attrs, 182 .. 183 } = &tag 184 else { 185 return Ok(()); 186 }; 187 188 // Embed rendering: all syntax elements share one syn_id for visibility toggling 189 // Structure: ![[ url-as-link ]] <embed-content> 190 let raw_text = &self.source[range.clone()]; 191 let syn_id = self.gen_syn_id(); 192 let opening_char_start = self.last_char_offset; 193 194 // Extract the URL from raw text (between ![[ and ]]) 195 let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { 196 &raw_text[3..raw_text.len() - 2] 197 } else { 198 dest_url.as_ref() 199 }; 200 201 // Calculate char positions 202 let url_char_len = url_text.chars().count(); 203 let opening_char_end = opening_char_start + 3; // "![[" 204 let url_char_start = opening_char_end; 205 let url_char_end = url_char_start + url_char_len; 206 let closing_char_start = url_char_end; 207 let closing_char_end = closing_char_start + 2; // "]]" 208 let formatted_range = opening_char_start..closing_char_end; 209 210 // 1. Emit opening ![[ syntax span 211 if raw_text.starts_with("![[") { 212 write!( 213 &mut self.writer, 214 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>", 215 syn_id, opening_char_start, opening_char_end 216 )?; 217 218 self.current_para.syntax_spans.push(SyntaxSpanInfo { 219 syn_id: syn_id.clone(), 220 char_range: opening_char_start..opening_char_end, 221 syntax_type: SyntaxType::Inline, 222 formatted_range: Some(formatted_range.clone()), 223 }); 224 225 self.record_mapping( 226 range.start..range.start + 3, 227 opening_char_start..opening_char_end, 228 ); 229 } 230 231 // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) 232 let url = dest_url.as_ref(); 233 let link_href = if url.starts_with("at://") { 234 format!("https://alpha.weaver.sh/record/{}", url) 235 } else { 236 url.to_string() 237 }; 238 239 write!( 240 &mut self.writer, 241 "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">", 242 link_href, syn_id, url_char_start, url_char_end 243 )?; 244 escape_html(&mut self.writer, url_text)?; 245 self.write("</a>")?; 246 247 self.current_para.syntax_spans.push(SyntaxSpanInfo { 248 syn_id: syn_id.clone(), 249 char_range: url_char_start..url_char_end, 250 syntax_type: SyntaxType::Inline, 251 formatted_range: Some(formatted_range.clone()), 252 }); 253 254 self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); 255 256 // 3. Emit closing ]] syntax span 257 if raw_text.ends_with("]]") { 258 write!( 259 &mut self.writer, 260 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 261 syn_id, closing_char_start, closing_char_end 262 )?; 263 264 self.current_para.syntax_spans.push(SyntaxSpanInfo { 265 syn_id: syn_id.clone(), 266 char_range: closing_char_start..closing_char_end, 267 syntax_type: SyntaxType::Inline, 268 formatted_range: Some(formatted_range.clone()), 269 }); 270 271 self.record_mapping( 272 range.end - 2..range.end, 273 closing_char_start..closing_char_end, 274 ); 275 } 276 277 // Collect AT URI for later resolution 278 if url.starts_with("at://") || url.starts_with("did:") { 279 self.ref_collector.add_at_embed( 280 url, 281 if title.is_empty() { 282 None 283 } else { 284 Some(title.as_ref()) 285 }, 286 ); 287 } 288 289 // 4. Emit the actual embed content 290 // Try to get content from attributes first 291 let content_from_attrs = if let Some(attrs) = attrs { 292 attrs 293 .attrs 294 .iter() 295 .find(|(k, _)| k.as_ref() == "content") 296 .map(|(_, v)| v.as_ref().to_string()) 297 } else { 298 None 299 }; 300 301 // If no content in attrs, try provider 302 let content: Option<String> = if content_from_attrs.is_some() { 303 content_from_attrs 304 } else if let Some(ref provider) = self.embed_provider { 305 provider.get_embed_content(&tag) 306 } else { 307 None 308 }; 309 310 if let Some(ref html_content) = content { 311 // Write the pre-rendered content directly 312 self.write(html_content)?; 313 } else { 314 // Fallback: render as placeholder div 315 self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?; 316 self.write("<span class=\"embed-loading\">Loading embed...</span>")?; 317 self.write("</div>")?; 318 } 319 320 // Consume the text events for the URL (they're still in the iterator) 321 // Use consume_until_end() since we already wrote the URL from source 322 self.consume_until_end(); 323 324 // Update offsets 325 self.last_char_offset = closing_char_end; 326 self.last_byte_offset = range.end; 327 328 Ok(()) 329 } 330}