at main 802 lines 26 kB view raw
1//! JsCollabEditor - collaborative editor with Loro CRDT and iroh P2P. 2//! 3//! This wraps the core editor with a Loro-backed buffer and manages 4//! the EditorReactor worker for off-main-thread collab networking. 5 6use std::collections::HashMap; 7 8use wasm_bindgen::prelude::*; 9use web_sys::HtmlElement; 10 11use weaver_editor_browser::{ 12 BrowserClipboard, BrowserCursor, ParagraphRender, update_paragraph_dom, 13 update_syntax_visibility, 14}; 15use weaver_editor_core::{ 16 CursorPlatform, EditorDocument, EditorImageResolver, PlainEditor, RenderCache, TextBuffer, 17 apply_formatting, execute_action_with_clipboard, render_paragraphs_incremental, 18}; 19use weaver_editor_crdt::{LoroTextBuffer, VersionVector}; 20 21use crate::actions::{ActionKind, parse_action}; 22use crate::types::{ 23 EntryEmbeds, EntryJson, FinalizedImage, JsParagraphRender, JsResolvedContent, PendingImage, 24}; 25 26type InnerEditor = PlainEditor<LoroTextBuffer>; 27 28/// Collaborative editor with Loro CRDT backend and iroh P2P networking. 29/// 30/// The host app is responsible for: 31/// - Creating/refreshing/deleting session records on PDS 32/// - Discovering peers via index or backlinks 33/// - Calling `addPeers` with discovered peer node IDs 34/// 35/// The editor handles: 36/// - Loro CRDT document sync 37/// - iroh gossip networking (via web worker) 38/// - Presence tracking 39#[wasm_bindgen] 40pub struct JsCollabEditor { 41 doc: InnerEditor, 42 cache: RenderCache, 43 resolved_content: weaver_common::ResolvedContent, 44 image_resolver: EditorImageResolver, 45 entry_index: weaver_common::EntryIndex, 46 paragraphs: Vec<ParagraphRender>, 47 48 // Mount state 49 editor_id: Option<String>, 50 on_change: Option<js_sys::Function>, 51 52 // Collab state 53 resource_uri: String, 54 collab_topic: Option<[u8; 32]>, 55 56 // Callbacks for host to handle PDS operations 57 on_session_needed: Option<js_sys::Function>, 58 on_session_refresh: Option<js_sys::Function>, 59 on_session_end: Option<js_sys::Function>, 60 on_peers_needed: Option<js_sys::Function>, 61 on_presence_changed: Option<js_sys::Function>, 62 on_remote_update: Option<js_sys::Function>, 63 64 // Metadata 65 title: String, 66 path: String, 67 tags: Vec<String>, 68 created_at: String, 69 70 // Image tracking 71 pending_images: HashMap<String, PendingImage>, 72 finalized_images: HashMap<String, FinalizedImage>, 73} 74 75#[wasm_bindgen] 76impl JsCollabEditor { 77 /// Create a new empty collab editor. 78 #[wasm_bindgen(constructor)] 79 pub fn new(resource_uri: &str) -> Self { 80 let buffer = LoroTextBuffer::new(); 81 let doc = PlainEditor::new(buffer); 82 let topic = weaver_editor_crdt::compute_collab_topic(resource_uri); 83 84 Self { 85 doc, 86 cache: RenderCache::default(), 87 resolved_content: weaver_common::ResolvedContent::new(), 88 image_resolver: EditorImageResolver::new(), 89 entry_index: weaver_common::EntryIndex::new(), 90 paragraphs: Vec::new(), 91 editor_id: None, 92 on_change: None, 93 resource_uri: resource_uri.to_string(), 94 collab_topic: Some(topic), 95 on_session_needed: None, 96 on_session_refresh: None, 97 on_session_end: None, 98 on_peers_needed: None, 99 on_presence_changed: None, 100 on_remote_update: None, 101 title: String::new(), 102 path: String::new(), 103 tags: Vec::new(), 104 created_at: now_iso(), 105 pending_images: HashMap::new(), 106 finalized_images: HashMap::new(), 107 } 108 } 109 110 /// Create from markdown content. 111 #[wasm_bindgen(js_name = fromMarkdown)] 112 pub fn from_markdown(resource_uri: &str, content: &str) -> Self { 113 let mut buffer = LoroTextBuffer::new(); 114 buffer.push(content); 115 let doc = PlainEditor::new(buffer); 116 let topic = weaver_editor_crdt::compute_collab_topic(resource_uri); 117 118 Self { 119 doc, 120 cache: RenderCache::default(), 121 resolved_content: weaver_common::ResolvedContent::new(), 122 image_resolver: EditorImageResolver::new(), 123 entry_index: weaver_common::EntryIndex::new(), 124 paragraphs: Vec::new(), 125 editor_id: None, 126 on_change: None, 127 resource_uri: resource_uri.to_string(), 128 collab_topic: Some(topic), 129 on_session_needed: None, 130 on_session_refresh: None, 131 on_session_end: None, 132 on_peers_needed: None, 133 on_presence_changed: None, 134 on_remote_update: None, 135 title: String::new(), 136 path: String::new(), 137 tags: Vec::new(), 138 created_at: now_iso(), 139 pending_images: HashMap::new(), 140 finalized_images: HashMap::new(), 141 } 142 } 143 144 /// Create from a Loro snapshot. 145 #[wasm_bindgen(js_name = fromSnapshot)] 146 pub fn from_snapshot(resource_uri: &str, snapshot: &[u8]) -> Result<JsCollabEditor, JsError> { 147 let buffer = LoroTextBuffer::from_snapshot(snapshot) 148 .map_err(|e| JsError::new(&format!("Invalid snapshot: {}", e)))?; 149 let doc = PlainEditor::new(buffer); 150 let topic = weaver_editor_crdt::compute_collab_topic(resource_uri); 151 152 Ok(Self { 153 doc, 154 cache: RenderCache::default(), 155 resolved_content: weaver_common::ResolvedContent::new(), 156 image_resolver: EditorImageResolver::new(), 157 entry_index: weaver_common::EntryIndex::new(), 158 paragraphs: Vec::new(), 159 editor_id: None, 160 on_change: None, 161 resource_uri: resource_uri.to_string(), 162 collab_topic: Some(topic), 163 on_session_needed: None, 164 on_session_refresh: None, 165 on_session_end: None, 166 on_peers_needed: None, 167 on_presence_changed: None, 168 on_remote_update: None, 169 title: String::new(), 170 path: String::new(), 171 tags: Vec::new(), 172 created_at: now_iso(), 173 pending_images: HashMap::new(), 174 finalized_images: HashMap::new(), 175 }) 176 } 177 178 // === Callbacks === 179 180 /// Set callback for when a session record needs to be created. 181 /// 182 /// Called with: { nodeId: string, relayUrl: string | null } 183 /// Should return: Promise<string> (the session record URI) 184 #[wasm_bindgen(js_name = setOnSessionNeeded)] 185 pub fn set_on_session_needed(&mut self, callback: js_sys::Function) { 186 self.on_session_needed = Some(callback); 187 } 188 189 /// Set callback for periodic session refresh. 190 /// 191 /// Called with: { sessionUri: string } 192 /// Should return: Promise<void> 193 #[wasm_bindgen(js_name = setOnSessionRefresh)] 194 pub fn set_on_session_refresh(&mut self, callback: js_sys::Function) { 195 self.on_session_refresh = Some(callback); 196 } 197 198 /// Set callback for when the session ends. 199 /// 200 /// Called with: { sessionUri: string } 201 /// Should return: Promise<void> 202 #[wasm_bindgen(js_name = setOnSessionEnd)] 203 pub fn set_on_session_end(&mut self, callback: js_sys::Function) { 204 self.on_session_end = Some(callback); 205 } 206 207 /// Set callback for peer discovery. 208 /// 209 /// Called with: { resourceUri: string } 210 /// Should return: Promise<string[]> (array of node IDs) 211 #[wasm_bindgen(js_name = setOnPeersNeeded)] 212 pub fn set_on_peers_needed(&mut self, callback: js_sys::Function) { 213 self.on_peers_needed = Some(callback); 214 } 215 216 /// Set callback for presence changes. 217 /// 218 /// Called with: PresenceSnapshot 219 #[wasm_bindgen(js_name = setOnPresenceChanged)] 220 pub fn set_on_presence_changed(&mut self, callback: js_sys::Function) { 221 self.on_presence_changed = Some(callback); 222 } 223 224 /// Set callback for remote updates (for debugging/logging). 225 #[wasm_bindgen(js_name = setOnRemoteUpdate)] 226 pub fn set_on_remote_update(&mut self, callback: js_sys::Function) { 227 self.on_remote_update = Some(callback); 228 } 229 230 // === Loro sync methods === 231 232 /// Export a full Loro snapshot. 233 #[wasm_bindgen(js_name = exportSnapshot)] 234 pub fn export_snapshot(&self) -> Vec<u8> { 235 self.doc.buffer().export_snapshot() 236 } 237 238 /// Export updates since a given version. 239 /// 240 /// Returns null if no changes since that version. 241 #[wasm_bindgen(js_name = exportUpdatesSince)] 242 pub fn export_updates_since(&self, version: &[u8]) -> Option<Vec<u8>> { 243 let vv = VersionVector::decode(version).ok()?; 244 self.doc.buffer().export_updates_since(&vv) 245 } 246 247 /// Import remote Loro updates. 248 #[wasm_bindgen(js_name = importUpdates)] 249 pub fn import_updates(&mut self, data: &[u8]) -> Result<(), JsError> { 250 self.doc 251 .buffer_mut() 252 .import(data) 253 .map_err(|e| JsError::new(&format!("Import failed: {}", e)))?; 254 255 // Re-render after importing remote changes 256 self.render_and_update_dom(); 257 self.notify_change(); 258 259 Ok(()) 260 } 261 262 /// Get the current version vector as bytes. 263 #[wasm_bindgen(js_name = getVersion)] 264 pub fn get_version(&self) -> Vec<u8> { 265 self.doc.buffer().version().encode() 266 } 267 268 /// Get the collab topic (blake3 hash of resource URI). 269 #[wasm_bindgen(js_name = getCollabTopic)] 270 pub fn get_collab_topic(&self) -> Option<Vec<u8>> { 271 self.collab_topic.map(|t| t.to_vec()) 272 } 273 274 /// Get the resource URI. 275 #[wasm_bindgen(js_name = getResourceUri)] 276 pub fn get_resource_uri(&self) -> String { 277 self.resource_uri.clone() 278 } 279 280 // === Content access (same as JsEditor) === 281 282 #[wasm_bindgen(js_name = getMarkdown)] 283 pub fn get_markdown(&self) -> String { 284 self.doc.content_string() 285 } 286 287 #[wasm_bindgen(js_name = getSnapshot)] 288 pub fn get_entry_snapshot(&self) -> Result<JsValue, JsError> { 289 let entry = EntryJson { 290 title: self.title.clone(), 291 path: self.path.clone(), 292 content: self.doc.content_string(), 293 created_at: self.created_at.clone(), 294 updated_at: Some(now_iso()), 295 tags: if self.tags.is_empty() { 296 None 297 } else { 298 Some(self.tags.clone()) 299 }, 300 embeds: self.build_embeds(), 301 authors: None, 302 content_warnings: None, 303 rating: None, 304 }; 305 306 serde_wasm_bindgen::to_value(&entry) 307 .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 308 } 309 310 #[wasm_bindgen(js_name = toEntry)] 311 pub fn to_entry(&self) -> Result<JsValue, JsError> { 312 if self.title.is_empty() { 313 return Err(JsError::new("Title is required")); 314 } 315 if self.path.is_empty() { 316 return Err(JsError::new("Path is required")); 317 } 318 if !self.pending_images.is_empty() { 319 return Err(JsError::new( 320 "Pending images must be finalized before publishing", 321 )); 322 } 323 324 self.get_entry_snapshot() 325 } 326 327 // === Metadata === 328 329 #[wasm_bindgen(js_name = getTitle)] 330 pub fn get_title(&self) -> String { 331 self.title.clone() 332 } 333 334 #[wasm_bindgen(js_name = setTitle)] 335 pub fn set_title(&mut self, title: &str) { 336 self.title = title.to_string(); 337 } 338 339 #[wasm_bindgen(js_name = getPath)] 340 pub fn get_path(&self) -> String { 341 self.path.clone() 342 } 343 344 #[wasm_bindgen(js_name = setPath)] 345 pub fn set_path(&mut self, path: &str) { 346 self.path = path.to_string(); 347 } 348 349 #[wasm_bindgen(js_name = getTags)] 350 pub fn get_tags(&self) -> Vec<String> { 351 self.tags.clone() 352 } 353 354 #[wasm_bindgen(js_name = setTags)] 355 pub fn set_tags(&mut self, tags: Vec<String>) { 356 self.tags = tags; 357 } 358 359 // === Actions === 360 361 #[wasm_bindgen(js_name = executeAction)] 362 pub fn execute_action(&mut self, action: JsValue) -> Result<(), JsError> { 363 let js_action = parse_action(action)?; 364 let kind = js_action.to_action_kind(); 365 366 let clipboard = BrowserClipboard::empty(); 367 match kind { 368 ActionKind::Editor(editor_action) => { 369 execute_action_with_clipboard(&mut self.doc, &editor_action, &clipboard); 370 } 371 ActionKind::Format(format_action) => { 372 apply_formatting(&mut self.doc, format_action); 373 } 374 } 375 376 self.render_and_update_dom(); 377 self.notify_change(); 378 379 Ok(()) 380 } 381 382 // === Image handling === 383 384 #[wasm_bindgen(js_name = addPendingImage)] 385 pub fn add_pending_image(&mut self, image: JsValue, data_url: &str) -> Result<(), JsError> { 386 let pending: PendingImage = serde_wasm_bindgen::from_value(image) 387 .map_err(|e| JsError::new(&format!("Invalid pending image: {}", e)))?; 388 389 self.image_resolver 390 .add_pending(&pending.local_id, data_url.to_string()); 391 392 self.pending_images 393 .insert(pending.local_id.clone(), pending); 394 Ok(()) 395 } 396 397 #[wasm_bindgen(js_name = finalizeImage)] 398 pub fn finalize_image( 399 &mut self, 400 local_id: &str, 401 finalized: JsValue, 402 blob_rkey: &str, 403 ident: &str, 404 ) -> Result<(), JsError> { 405 use weaver_common::jacquard::IntoStatic; 406 use weaver_common::jacquard::types::ident::AtIdentifier; 407 use weaver_common::jacquard::types::string::Rkey; 408 409 let finalized_data: FinalizedImage = serde_wasm_bindgen::from_value(finalized) 410 .map_err(|e| JsError::new(&format!("Invalid finalized image: {}", e)))?; 411 412 let rkey = Rkey::new(blob_rkey) 413 .map_err(|e| JsError::new(&format!("Invalid rkey: {}", e)))? 414 .into_static(); 415 let identifier = AtIdentifier::new(ident) 416 .map_err(|e| JsError::new(&format!("Invalid identifier: {}", e)))? 417 .into_static(); 418 419 self.image_resolver 420 .promote_to_uploaded(local_id, rkey, identifier); 421 422 self.pending_images.remove(local_id); 423 self.finalized_images 424 .insert(local_id.to_string(), finalized_data); 425 Ok(()) 426 } 427 428 #[wasm_bindgen(js_name = removeImage)] 429 pub fn remove_image(&mut self, local_id: &str) { 430 self.pending_images.remove(local_id); 431 self.finalized_images.remove(local_id); 432 } 433 434 #[wasm_bindgen(js_name = getPendingImages)] 435 pub fn get_pending_images(&self) -> Result<JsValue, JsError> { 436 let pending: Vec<_> = self.pending_images.values().cloned().collect(); 437 serde_wasm_bindgen::to_value(&pending) 438 .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 439 } 440 441 #[wasm_bindgen(js_name = getStagingUris)] 442 pub fn get_staging_uris(&self) -> Vec<String> { 443 self.finalized_images 444 .values() 445 .map(|f| f.staging_uri.clone()) 446 .collect() 447 } 448 449 // === Entry index === 450 451 #[wasm_bindgen(js_name = addEntryToIndex)] 452 pub fn add_entry_to_index(&mut self, title: &str, path: &str, canonical_url: &str) { 453 self.entry_index 454 .add_entry(title, path, canonical_url.to_string()); 455 } 456 457 #[wasm_bindgen(js_name = clearEntryIndex)] 458 pub fn clear_entry_index(&mut self) { 459 self.entry_index = weaver_common::EntryIndex::new(); 460 } 461 462 // === Cursor/selection === 463 464 #[wasm_bindgen(js_name = getCursorOffset)] 465 pub fn get_cursor_offset(&self) -> usize { 466 self.doc.cursor_offset() 467 } 468 469 /// Get the current selection, or null if no selection. 470 #[wasm_bindgen(js_name = getSelection)] 471 pub fn get_selection(&self) -> JsValue { 472 match self.doc.selection() { 473 Some(s) => { 474 #[derive(serde::Serialize)] 475 struct JsSelection { 476 anchor: usize, 477 head: usize, 478 } 479 serde_wasm_bindgen::to_value(&JsSelection { 480 anchor: s.anchor, 481 head: s.head, 482 }) 483 .unwrap_or(JsValue::NULL) 484 } 485 None => JsValue::NULL, 486 } 487 } 488 489 #[wasm_bindgen(js_name = setCursorOffset)] 490 pub fn set_cursor_offset(&mut self, offset: usize) { 491 self.doc.set_cursor_offset(offset); 492 // Sync Loro cursor for CRDT-aware tracking 493 self.doc.buffer().sync_cursor(offset); 494 } 495 496 #[wasm_bindgen(js_name = getLength)] 497 pub fn get_length(&self) -> usize { 498 self.doc.len_chars() 499 } 500 501 // === Undo/redo === 502 503 #[wasm_bindgen(js_name = canUndo)] 504 pub fn can_undo(&self) -> bool { 505 self.doc.can_undo() 506 } 507 508 #[wasm_bindgen(js_name = canRedo)] 509 pub fn can_redo(&self) -> bool { 510 self.doc.can_redo() 511 } 512 513 // === Mounting === 514 515 #[wasm_bindgen] 516 pub fn mount( 517 &mut self, 518 container: &HtmlElement, 519 on_change: Option<js_sys::Function>, 520 ) -> Result<(), JsError> { 521 let window = web_sys::window().ok_or_else(|| JsError::new("No window"))?; 522 let document = window 523 .document() 524 .ok_or_else(|| JsError::new("No document"))?; 525 526 let editor_id = format!("weaver-collab-editor-{}", js_sys::Math::random().to_bits()); 527 528 let editor_el = document 529 .create_element("div") 530 .map_err(|e| JsError::new(&format!("Failed to create element: {:?}", e)))?; 531 532 editor_el 533 .set_attribute("id", &editor_id) 534 .map_err(|e| JsError::new(&format!("Failed to set id: {:?}", e)))?; 535 editor_el 536 .set_attribute("contenteditable", "true") 537 .map_err(|e| JsError::new(&format!("Failed to set contenteditable: {:?}", e)))?; 538 editor_el 539 .set_attribute("class", "weaver-editor-content") 540 .map_err(|e| JsError::new(&format!("Failed to set class: {:?}", e)))?; 541 542 container 543 .append_child(&editor_el) 544 .map_err(|e| JsError::new(&format!("Failed to append child: {:?}", e)))?; 545 546 self.editor_id = Some(editor_id); 547 self.on_change = on_change; 548 549 self.render_and_update_dom(); 550 551 Ok(()) 552 } 553 554 #[wasm_bindgen(js_name = isMounted)] 555 pub fn is_mounted(&self) -> bool { 556 self.editor_id.is_some() 557 } 558 559 #[wasm_bindgen] 560 pub fn unmount(&mut self) { 561 if let Some(ref editor_id) = self.editor_id { 562 if let Some(window) = web_sys::window() { 563 if let Some(document) = window.document() { 564 if let Some(element) = document.get_element_by_id(editor_id) { 565 let _ = element.remove(); 566 } 567 } 568 } 569 } 570 self.editor_id = None; 571 self.on_change = None; 572 } 573 574 #[wasm_bindgen] 575 pub fn focus(&self) { 576 use wasm_bindgen::JsCast; 577 if let Some(ref editor_id) = self.editor_id { 578 if let Some(window) = web_sys::window() { 579 if let Some(document) = window.document() { 580 if let Some(element) = document.get_element_by_id(editor_id) { 581 if let Ok(html_el) = element.dyn_into::<HtmlElement>() { 582 let _ = html_el.focus(); 583 } 584 } 585 } 586 } 587 } 588 } 589 590 #[wasm_bindgen] 591 pub fn blur(&self) { 592 use wasm_bindgen::JsCast; 593 if let Some(ref editor_id) = self.editor_id { 594 if let Some(window) = web_sys::window() { 595 if let Some(document) = window.document() { 596 if let Some(element) = document.get_element_by_id(editor_id) { 597 if let Ok(html_el) = element.dyn_into::<HtmlElement>() { 598 let _ = html_el.blur(); 599 } 600 } 601 } 602 } 603 } 604 } 605 606 // === Rendering === 607 608 #[wasm_bindgen(js_name = getParagraphs)] 609 pub fn get_paragraphs(&self) -> Result<JsValue, JsError> { 610 let js_paras: Vec<JsParagraphRender> = self 611 .paragraphs 612 .iter() 613 .map(JsParagraphRender::from) 614 .collect(); 615 serde_wasm_bindgen::to_value(&js_paras) 616 .map_err(|e| JsError::new(&format!("Serialization error: {}", e))) 617 } 618 619 #[wasm_bindgen(js_name = setResolvedContent)] 620 pub fn set_resolved_content(&mut self, content: JsResolvedContent) { 621 self.resolved_content = content.into_inner(); 622 } 623 624 #[wasm_bindgen(js_name = renderAndUpdateDom)] 625 pub fn render_and_update_dom_js(&mut self) { 626 self.render_and_update_dom(); 627 } 628 629 // === Remote cursor positioning === 630 631 /// Get cursor rect relative to editor for a given character position. 632 /// 633 /// Returns { x, y, height } or null if position can't be mapped. 634 #[wasm_bindgen(js_name = getCursorRectRelative)] 635 pub fn get_cursor_rect_relative(&self, position: usize) -> JsValue { 636 let Some(ref editor_id) = self.editor_id else { 637 return JsValue::NULL; 638 }; 639 640 // Flatten offset maps from all paragraphs. 641 let offset_map: Vec<_> = self 642 .paragraphs 643 .iter() 644 .flat_map(|p| p.offset_map.iter().cloned()) 645 .collect(); 646 647 let Some(rect) = 648 weaver_editor_browser::get_cursor_rect_relative(position, &offset_map, editor_id) 649 else { 650 return JsValue::NULL; 651 }; 652 653 #[derive(serde::Serialize)] 654 struct JsCursorRect { 655 x: f64, 656 y: f64, 657 height: f64, 658 } 659 660 serde_wasm_bindgen::to_value(&JsCursorRect { 661 x: rect.x, 662 y: rect.y, 663 height: rect.height, 664 }) 665 .unwrap_or(JsValue::NULL) 666 } 667 668 /// Get selection rects relative to editor for a given range. 669 /// 670 /// Returns array of { x, y, width, height } for each line of selection. 671 #[wasm_bindgen(js_name = getSelectionRectsRelative)] 672 pub fn get_selection_rects_relative(&self, start: usize, end: usize) -> JsValue { 673 let Some(ref editor_id) = self.editor_id else { 674 return JsValue::from(js_sys::Array::new()); 675 }; 676 677 // Flatten offset maps from all paragraphs. 678 let offset_map: Vec<_> = self 679 .paragraphs 680 .iter() 681 .flat_map(|p| p.offset_map.iter().cloned()) 682 .collect(); 683 684 let rects = 685 weaver_editor_browser::get_selection_rects_relative(start, end, &offset_map, editor_id); 686 687 #[derive(serde::Serialize)] 688 struct JsSelectionRect { 689 x: f64, 690 y: f64, 691 width: f64, 692 height: f64, 693 } 694 695 let js_rects: Vec<JsSelectionRect> = rects 696 .into_iter() 697 .map(|r| JsSelectionRect { 698 x: r.x, 699 y: r.y, 700 width: r.width, 701 height: r.height, 702 }) 703 .collect(); 704 705 serde_wasm_bindgen::to_value(&js_rects).unwrap_or(JsValue::from(js_sys::Array::new())) 706 } 707 708 /// Convert RGBA u32 color (0xRRGGBBAA) to CSS rgba() string. 709 #[wasm_bindgen(js_name = rgbaToCss)] 710 pub fn rgba_to_css(color: u32) -> String { 711 weaver_editor_browser::rgba_u32_to_css(color) 712 } 713 714 /// Convert RGBA u32 color to CSS rgba() string with custom alpha. 715 #[wasm_bindgen(js_name = rgbaToCssAlpha)] 716 pub fn rgba_to_css_alpha(color: u32, alpha: f32) -> String { 717 weaver_editor_browser::rgba_u32_to_css_alpha(color, alpha) 718 } 719} 720 721impl JsCollabEditor { 722 pub(crate) fn render_and_update_dom(&mut self) { 723 let Some(ref editor_id) = self.editor_id else { 724 return; 725 }; 726 727 let cursor_offset = self.doc.cursor_offset(); 728 let last_edit = self.doc.last_edit(); 729 730 let result = render_paragraphs_incremental( 731 self.doc.buffer(), 732 Some(&self.cache), 733 cursor_offset, 734 last_edit.as_ref(), 735 Some(&self.image_resolver), 736 Some(&self.entry_index), 737 &self.resolved_content, 738 ); 739 740 let old_paragraphs = std::mem::replace(&mut self.paragraphs, result.paragraphs); 741 self.cache = result.cache; 742 self.doc.set_last_edit(None); 743 744 let cursor_para_updated = update_paragraph_dom( 745 editor_id, 746 &old_paragraphs, 747 &self.paragraphs, 748 cursor_offset, 749 false, 750 ); 751 752 let syntax_spans: Vec<_> = self 753 .paragraphs 754 .iter() 755 .flat_map(|p| p.syntax_spans.iter().cloned()) 756 .collect(); 757 update_syntax_visibility(cursor_offset, None, &syntax_spans, &self.paragraphs); 758 759 if cursor_para_updated { 760 let cursor = BrowserCursor::new(editor_id); 761 let snap_direction = self.doc.pending_snap(); 762 let _ = cursor.restore_cursor(cursor_offset, &self.paragraphs, snap_direction); 763 } 764 } 765 766 pub(crate) fn notify_change(&self) { 767 if let Some(ref callback) = self.on_change { 768 let this = JsValue::null(); 769 let _ = callback.call0(&this); 770 } 771 } 772 773 fn build_embeds(&self) -> Option<EntryEmbeds> { 774 if self.finalized_images.is_empty() { 775 return None; 776 } 777 778 use crate::types::{ImageEmbed, ImagesEmbed}; 779 780 let images: Vec<ImageEmbed> = self 781 .finalized_images 782 .values() 783 .map(|f| ImageEmbed { 784 image: f.blob_ref.clone(), 785 alt: String::new(), 786 aspect_ratio: None, 787 }) 788 .collect(); 789 790 Some(EntryEmbeds { 791 images: Some(ImagesEmbed { images }), 792 records: None, 793 externals: None, 794 videos: None, 795 }) 796 } 797} 798 799fn now_iso() -> String { 800 let date = js_sys::Date::new_0(); 801 date.to_iso_string().into() 802}