atproto blogging
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}