at main 608 lines 20 kB view raw
1//! JsEditor - the main editor wrapper for JavaScript. 2 3use std::collections::HashMap; 4 5use wasm_bindgen::JsCast; 6use wasm_bindgen::prelude::*; 7use web_sys::HtmlElement; 8 9use weaver_editor_browser::{ 10 BrowserClipboard, BrowserCursor, ParagraphRender, update_paragraph_dom, 11 update_syntax_visibility, 12}; 13use weaver_editor_core::{ 14 CursorPlatform, EditorDocument, EditorImageResolver, EditorRope, PlainEditor, RenderCache, 15 UndoableBuffer, apply_formatting, execute_action_with_clipboard, render_paragraphs_incremental, 16}; 17 18use crate::actions::{ActionKind, parse_action}; 19use crate::types::{ 20 EntryEmbeds, EntryJson, FinalizedImage, JsParagraphRender, JsResolvedContent, PendingImage, 21}; 22 23type InnerEditor = PlainEditor<UndoableBuffer<EditorRope>>; 24 25/// The main editor instance exposed to JavaScript. 26/// 27/// Wraps the core editor with WASM bindings for browser use. 28#[wasm_bindgen] 29pub struct JsEditor { 30 pub(crate) doc: InnerEditor, 31 pub(crate) cache: RenderCache, 32 pub(crate) resolved_content: weaver_common::ResolvedContent, 33 pub(crate) image_resolver: EditorImageResolver, 34 pub(crate) entry_index: weaver_common::EntryIndex, 35 pub(crate) paragraphs: Vec<ParagraphRender>, 36 37 // Mount state 38 pub(crate) editor_id: Option<String>, 39 pub(crate) on_change: Option<js_sys::Function>, 40 41 // Metadata 42 title: String, 43 path: String, 44 tags: Vec<String>, 45 created_at: String, 46 47 // Image tracking 48 pending_images: HashMap<String, PendingImage>, 49 finalized_images: HashMap<String, FinalizedImage>, 50} 51 52#[wasm_bindgen] 53impl JsEditor { 54 /// Create a new empty editor. 55 #[wasm_bindgen(constructor)] 56 pub fn new() -> Self { 57 let rope = EditorRope::new(); 58 let buffer = UndoableBuffer::new(rope, 100); 59 let doc = PlainEditor::new(buffer); 60 61 Self { 62 doc, 63 cache: RenderCache::default(), 64 resolved_content: weaver_common::ResolvedContent::new(), 65 image_resolver: EditorImageResolver::new(), 66 entry_index: weaver_common::EntryIndex::new(), 67 paragraphs: Vec::new(), 68 editor_id: None, 69 on_change: None, 70 title: String::new(), 71 path: String::new(), 72 tags: Vec::new(), 73 created_at: now_iso(), 74 pending_images: HashMap::new(), 75 finalized_images: HashMap::new(), 76 } 77 } 78 79 /// Create an editor from markdown content. 80 #[wasm_bindgen(js_name = fromMarkdown)] 81 pub fn from_markdown(content: &str) -> Self { 82 let rope = EditorRope::from_str(content); 83 let buffer = UndoableBuffer::new(rope, 100); 84 let doc = PlainEditor::new(buffer); 85 86 Self { 87 doc, 88 cache: RenderCache::default(), 89 resolved_content: weaver_common::ResolvedContent::new(), 90 image_resolver: EditorImageResolver::new(), 91 entry_index: weaver_common::EntryIndex::new(), 92 paragraphs: Vec::new(), 93 editor_id: None, 94 on_change: None, 95 title: String::new(), 96 path: String::new(), 97 tags: Vec::new(), 98 created_at: now_iso(), 99 pending_images: HashMap::new(), 100 finalized_images: HashMap::new(), 101 } 102 } 103 104 /// Create an editor from a snapshot (EntryJson). 105 #[wasm_bindgen(js_name = fromSnapshot)] 106 pub fn from_snapshot(snapshot: JsValue) -> Result<JsEditor, JsError> { 107 let entry: EntryJson = serde_wasm_bindgen::from_value(snapshot) 108 .map_err(|e| JsError::new(&format!("Invalid snapshot: {}", e)))?; 109 110 let rope = EditorRope::from_str(&entry.content); 111 let buffer = UndoableBuffer::new(rope, 100); 112 let doc = PlainEditor::new(buffer); 113 114 Ok(Self { 115 doc, 116 cache: RenderCache::default(), 117 resolved_content: weaver_common::ResolvedContent::new(), 118 image_resolver: EditorImageResolver::new(), 119 entry_index: weaver_common::EntryIndex::new(), 120 paragraphs: Vec::new(), 121 editor_id: None, 122 on_change: None, 123 title: entry.title, 124 path: entry.path, 125 tags: entry.tags.unwrap_or_default(), 126 created_at: entry.created_at, 127 pending_images: HashMap::new(), 128 finalized_images: HashMap::new(), 129 }) 130 } 131 132 /// Set pre-resolved embed content. 133 #[wasm_bindgen(js_name = setResolvedContent)] 134 pub fn set_resolved_content(&mut self, content: JsResolvedContent) { 135 self.resolved_content = content.into_inner(); 136 } 137 138 // === Content access === 139 140 /// Get the markdown content. 141 #[wasm_bindgen(js_name = getMarkdown)] 142 pub fn get_markdown(&self) -> String { 143 self.doc.content_string() 144 } 145 146 /// Get the current state as a snapshot (EntryJson). 147 #[wasm_bindgen(js_name = getSnapshot)] 148 pub fn get_snapshot(&self) -> Result<JsValue, JsError> { 149 let entry = EntryJson { 150 title: self.title.clone(), 151 path: self.path.clone(), 152 content: self.doc.content_string(), 153 created_at: self.created_at.clone(), 154 updated_at: Some(now_iso()), 155 tags: if self.tags.is_empty() { 156 None 157 } else { 158 Some(self.tags.clone()) 159 }, 160 embeds: self.build_embeds(), 161 authors: None, 162 content_warnings: None, 163 rating: None, 164 }; 165 166 serde_wasm_bindgen::to_value(&entry) 167 .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 168 } 169 170 /// Get the entry JSON, validating required fields. 171 /// 172 /// Throws if title or path is empty, or if there are pending images. 173 #[wasm_bindgen(js_name = toEntry)] 174 pub fn to_entry(&self) -> Result<JsValue, JsError> { 175 if self.title.is_empty() { 176 return Err(JsError::new("Title is required")); 177 } 178 if self.path.is_empty() { 179 return Err(JsError::new("Path is required")); 180 } 181 if !self.pending_images.is_empty() { 182 return Err(JsError::new( 183 "Pending images must be finalized before publishing", 184 )); 185 } 186 187 self.get_snapshot() 188 } 189 190 // === Metadata === 191 192 /// Get the title. 193 #[wasm_bindgen(js_name = getTitle)] 194 pub fn get_title(&self) -> String { 195 self.title.clone() 196 } 197 198 /// Set the title. 199 #[wasm_bindgen(js_name = setTitle)] 200 pub fn set_title(&mut self, title: &str) { 201 self.title = title.to_string(); 202 } 203 204 /// Get the path. 205 #[wasm_bindgen(js_name = getPath)] 206 pub fn get_path(&self) -> String { 207 self.path.clone() 208 } 209 210 /// Set the path. 211 #[wasm_bindgen(js_name = setPath)] 212 pub fn set_path(&mut self, path: &str) { 213 self.path = path.to_string(); 214 } 215 216 /// Get the tags. 217 #[wasm_bindgen(js_name = getTags)] 218 pub fn get_tags(&self) -> Vec<String> { 219 self.tags.clone() 220 } 221 222 /// Set the tags. 223 #[wasm_bindgen(js_name = setTags)] 224 pub fn set_tags(&mut self, tags: Vec<String>) { 225 self.tags = tags; 226 } 227 228 // === Actions === 229 230 /// Execute an editor action. 231 /// 232 /// Automatically re-renders and updates the DOM after the action. 233 #[wasm_bindgen(js_name = executeAction)] 234 pub fn execute_action(&mut self, action: JsValue) -> Result<(), JsError> { 235 let js_action = parse_action(action)?; 236 let kind = js_action.to_action_kind(); 237 238 let clipboard = BrowserClipboard::empty(); 239 match kind { 240 ActionKind::Editor(editor_action) => { 241 execute_action_with_clipboard(&mut self.doc, &editor_action, &clipboard); 242 } 243 ActionKind::Format(format_action) => { 244 apply_formatting(&mut self.doc, format_action); 245 } 246 } 247 248 // Update DOM and notify 249 self.render_and_update_dom(); 250 self.notify_change(); 251 252 Ok(()) 253 } 254 255 // === Image handling === 256 257 /// Add a pending image (called when user adds an image). 258 /// 259 /// The `data_url` is used for preview rendering until uploaded. 260 #[wasm_bindgen(js_name = addPendingImage)] 261 pub fn add_pending_image(&mut self, image: JsValue, data_url: &str) -> Result<(), JsError> { 262 let pending: PendingImage = serde_wasm_bindgen::from_value(image) 263 .map_err(|e| JsError::new(&format!("Invalid pending image: {}", e)))?; 264 265 // Add to image resolver for preview rendering 266 self.image_resolver 267 .add_pending(&pending.local_id, data_url.to_string()); 268 269 self.pending_images 270 .insert(pending.local_id.clone(), pending); 271 Ok(()) 272 } 273 274 /// Finalize an image after upload. 275 /// 276 /// Requires the blob rkey (from sh.weaver.publish.blob) and the user's identifier. 277 #[wasm_bindgen(js_name = finalizeImage)] 278 pub fn finalize_image( 279 &mut self, 280 local_id: &str, 281 finalized: JsValue, 282 blob_rkey: &str, 283 ident: &str, 284 ) -> Result<(), JsError> { 285 use weaver_common::jacquard::IntoStatic; 286 use weaver_common::jacquard::types::ident::AtIdentifier; 287 use weaver_common::jacquard::types::string::Rkey; 288 289 let finalized_data: FinalizedImage = serde_wasm_bindgen::from_value(finalized) 290 .map_err(|e| JsError::new(&format!("Invalid finalized image: {}", e)))?; 291 292 let rkey = Rkey::new(blob_rkey) 293 .map_err(|e| JsError::new(&format!("Invalid rkey: {}", e)))? 294 .into_static(); 295 let identifier = AtIdentifier::new(ident) 296 .map_err(|e| JsError::new(&format!("Invalid identifier: {}", e)))? 297 .into_static(); 298 299 // Promote pending to uploaded in image resolver 300 self.image_resolver 301 .promote_to_uploaded(local_id, rkey, identifier); 302 303 self.pending_images.remove(local_id); 304 self.finalized_images 305 .insert(local_id.to_string(), finalized_data); 306 Ok(()) 307 } 308 309 /// Remove an image from tracking. 310 /// 311 /// Note: The image resolver does not support removal, so images remain 312 /// until the editor is destroyed. 313 #[wasm_bindgen(js_name = removeImage)] 314 pub fn remove_image(&mut self, local_id: &str) { 315 self.pending_images.remove(local_id); 316 self.finalized_images.remove(local_id); 317 } 318 319 /// Get pending images that need upload. 320 #[wasm_bindgen(js_name = getPendingImages)] 321 pub fn get_pending_images(&self) -> Result<JsValue, JsError> { 322 let pending: Vec<_> = self.pending_images.values().cloned().collect(); 323 serde_wasm_bindgen::to_value(&pending) 324 .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 325 } 326 327 /// Get staging URIs for cleanup after publish. 328 #[wasm_bindgen(js_name = getStagingUris)] 329 pub fn get_staging_uris(&self) -> Vec<String> { 330 self.finalized_images 331 .values() 332 .map(|f| f.staging_uri.clone()) 333 .collect() 334 } 335 336 // === Entry index (for wikilinks) === 337 338 /// Add an entry to the wikilink index. 339 /// 340 /// This allows wikilinks to resolve correctly in the editor. 341 /// - `title`: The entry title (matched case-insensitively) 342 /// - `path`: The entry path slug (matched case-insensitively) 343 /// - `canonical_url`: The URL to link to (e.g., "/my-notebook/my-entry") 344 #[wasm_bindgen(js_name = addEntryToIndex)] 345 pub fn add_entry_to_index(&mut self, title: &str, path: &str, canonical_url: &str) { 346 self.entry_index 347 .add_entry(title, path, canonical_url.to_string()); 348 } 349 350 /// Clear the entry index. 351 #[wasm_bindgen(js_name = clearEntryIndex)] 352 pub fn clear_entry_index(&mut self) { 353 self.entry_index = weaver_common::EntryIndex::new(); 354 } 355 356 // === Cursor/selection === 357 358 /// Get the current cursor offset. 359 #[wasm_bindgen(js_name = getCursorOffset)] 360 pub fn get_cursor_offset(&self) -> usize { 361 self.doc.cursor_offset() 362 } 363 364 /// Set the cursor offset. 365 #[wasm_bindgen(js_name = setCursorOffset)] 366 pub fn set_cursor_offset(&mut self, offset: usize) { 367 self.doc.set_cursor_offset(offset); 368 } 369 370 /// Get the document length in characters. 371 #[wasm_bindgen(js_name = getLength)] 372 pub fn get_length(&self) -> usize { 373 self.doc.len_chars() 374 } 375 376 // === Undo/redo === 377 378 /// Check if undo is available. 379 #[wasm_bindgen(js_name = canUndo)] 380 pub fn can_undo(&self) -> bool { 381 self.doc.can_undo() 382 } 383 384 /// Check if redo is available. 385 #[wasm_bindgen(js_name = canRedo)] 386 pub fn can_redo(&self) -> bool { 387 self.doc.can_redo() 388 } 389 390 // === Mounting === 391 392 /// Mount the editor into a container element. 393 /// 394 /// Creates a contenteditable div inside the container and sets up event handlers. 395 /// The onChange callback is called after each edit. 396 #[wasm_bindgen] 397 pub fn mount( 398 &mut self, 399 container: &HtmlElement, 400 on_change: Option<js_sys::Function>, 401 ) -> Result<(), JsError> { 402 let window = web_sys::window().ok_or_else(|| JsError::new("No window"))?; 403 let document = window 404 .document() 405 .ok_or_else(|| JsError::new("No document"))?; 406 407 // Generate unique ID for the editor element 408 let editor_id = format!("weaver-editor-{}", js_sys::Math::random().to_bits()); 409 410 // Create the contenteditable element 411 let editor_el = document 412 .create_element("div") 413 .map_err(|e| JsError::new(&format!("Failed to create element: {:?}", e)))?; 414 415 editor_el 416 .set_attribute("id", &editor_id) 417 .map_err(|e| JsError::new(&format!("Failed to set id: {:?}", e)))?; 418 editor_el 419 .set_attribute("contenteditable", "true") 420 .map_err(|e| JsError::new(&format!("Failed to set contenteditable: {:?}", e)))?; 421 editor_el 422 .set_attribute("class", "weaver-editor-content") 423 .map_err(|e| JsError::new(&format!("Failed to set class: {:?}", e)))?; 424 425 container 426 .append_child(&editor_el) 427 .map_err(|e| JsError::new(&format!("Failed to append child: {:?}", e)))?; 428 429 self.editor_id = Some(editor_id); 430 self.on_change = on_change; 431 432 // Initial render 433 self.render_and_update_dom(); 434 435 Ok(()) 436 } 437 438 /// Check if the editor is mounted. 439 #[wasm_bindgen(js_name = isMounted)] 440 pub fn is_mounted(&self) -> bool { 441 self.editor_id.is_some() 442 } 443 444 /// Unmount the editor and clean up. 445 #[wasm_bindgen] 446 pub fn unmount(&mut self) { 447 if let Some(ref editor_id) = self.editor_id { 448 if let Some(window) = web_sys::window() { 449 if let Some(document) = window.document() { 450 if let Some(element) = document.get_element_by_id(editor_id) { 451 let _ = element.remove(); 452 } 453 } 454 } 455 } 456 self.editor_id = None; 457 self.on_change = None; 458 } 459 460 /// Focus the editor. 461 #[wasm_bindgen] 462 pub fn focus(&self) { 463 if let Some(ref editor_id) = self.editor_id { 464 if let Some(window) = web_sys::window() { 465 if let Some(document) = window.document() { 466 if let Some(element) = document.get_element_by_id(editor_id) { 467 if let Ok(html_el) = element.dyn_into::<HtmlElement>() { 468 let _ = html_el.focus(); 469 } 470 } 471 } 472 } 473 } 474 } 475 476 /// Blur the editor. 477 #[wasm_bindgen] 478 pub fn blur(&self) { 479 if let Some(ref editor_id) = self.editor_id { 480 if let Some(window) = web_sys::window() { 481 if let Some(document) = window.document() { 482 if let Some(element) = document.get_element_by_id(editor_id) { 483 if let Ok(html_el) = element.dyn_into::<HtmlElement>() { 484 let _ = html_el.blur(); 485 } 486 } 487 } 488 } 489 } 490 } 491 492 // === Rendering === 493 494 /// Get rendered paragraphs as JS objects. 495 /// 496 /// For use when host needs to inspect render state. 497 #[wasm_bindgen(js_name = getParagraphs)] 498 pub fn get_paragraphs(&self) -> Result<JsValue, JsError> { 499 let js_paras: Vec<JsParagraphRender> = self 500 .paragraphs 501 .iter() 502 .map(JsParagraphRender::from) 503 .collect(); 504 serde_wasm_bindgen::to_value(&js_paras) 505 .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 506 } 507} 508 509impl Default for JsEditor { 510 fn default() -> Self { 511 Self::new() 512 } 513} 514 515// Internal methods (not exposed to JS) 516impl JsEditor { 517 /// Render the document and update the DOM. 518 pub fn render_and_update_dom(&mut self) { 519 let Some(ref editor_id) = self.editor_id else { 520 return; 521 }; 522 523 let cursor_offset = self.doc.cursor_offset(); 524 let last_edit = self.doc.last_edit(); 525 526 // Render with incremental caching 527 let result = render_paragraphs_incremental( 528 self.doc.buffer(), 529 Some(&self.cache), 530 cursor_offset, 531 last_edit.as_ref(), 532 Some(&self.image_resolver), 533 Some(&self.entry_index), 534 &self.resolved_content, 535 ); 536 537 let old_paragraphs = std::mem::replace(&mut self.paragraphs, result.paragraphs); 538 self.cache = result.cache; 539 self.doc.set_last_edit(None); // Clear after using 540 541 // Update DOM 542 let cursor_para_updated = update_paragraph_dom( 543 editor_id, 544 &old_paragraphs, 545 &self.paragraphs, 546 cursor_offset, 547 false, 548 ); 549 550 // Update syntax visibility 551 let syntax_spans: Vec<_> = self 552 .paragraphs 553 .iter() 554 .flat_map(|p| p.syntax_spans.iter().cloned()) 555 .collect(); 556 update_syntax_visibility(cursor_offset, None, &syntax_spans, &self.paragraphs); 557 558 // Restore cursor position after DOM update 559 if cursor_para_updated { 560 let cursor = BrowserCursor::new(editor_id); 561 let snap_direction = self.doc.pending_snap(); 562 let _ = cursor.restore_cursor(cursor_offset, &self.paragraphs, snap_direction); 563 } 564 } 565 566 /// Notify the onChange callback. 567 pub(crate) fn notify_change(&self) { 568 if let Some(ref callback) = self.on_change { 569 let this = JsValue::null(); 570 let _ = callback.call0(&this); 571 } 572 } 573} 574 575impl JsEditor { 576 /// Build embeds from finalized images. 577 fn build_embeds(&self) -> Option<EntryEmbeds> { 578 if self.finalized_images.is_empty() { 579 return None; 580 } 581 582 use crate::types::{ImageEmbed, ImagesEmbed}; 583 584 let images: Vec<ImageEmbed> = self 585 .finalized_images 586 .values() 587 .map(|f| ImageEmbed { 588 image: f.blob_ref.clone(), 589 alt: String::new(), // TODO: track alt text 590 aspect_ratio: None, 591 }) 592 .collect(); 593 594 Some(EntryEmbeds { 595 images: Some(ImagesEmbed { images }), 596 records: None, 597 externals: None, 598 videos: None, 599 }) 600 } 601} 602 603/// Get current time as ISO string. 604fn now_iso() -> String { 605 // Use js_sys::Date for browser-compatible time 606 let date = js_sys::Date::new_0(); 607 date.to_iso_string().into() 608}