js editor collab

Orual 7c8aa7c4 3217944a

+3243 -33
+60 -5
crates/weaver-editor-js/build.sh
··· 5 5 cd "$SCRIPT_DIR" 6 6 7 7 PKG_NAME="@weaver.sh/editor" 8 - PKG_VERSION="0.1.0" 8 + PKG_VERSION="0.1.1" 9 9 10 10 # Targets to build 11 11 TARGETS=(bundler web nodejs deno) ··· 60 60 description="Weaver markdown editor (local editing, lightweight)" 61 61 fi 62 62 63 + # Worker export only for collab variant 64 + local worker_export="" 65 + local worker_files="" 66 + if [[ "$variant" == "collab" ]]; then 67 + worker_export=', 68 + "./worker": { 69 + "import": "./worker/editor_worker.js" 70 + }' 71 + worker_files=', 72 + "worker/"' 73 + fi 74 + 63 75 cat > "${out_dir}/package.json" << EOF 64 76 { 65 77 "name": "${PKG_NAME}${pkg_suffix}", ··· 100 112 "import": "./deno/weaver_editor.js", 101 113 "types": "./deno/weaver_editor.d.ts" 102 114 }, 103 - "./weaver-editor.css": "./weaver-editor.css" 115 + "./weaver-editor.css": "./weaver-editor.css"${worker_export} 104 116 }, 105 117 "files": [ 106 118 "index.js", ··· 112 124 "web/", 113 125 "nodejs/", 114 126 "deno/", 115 - "README.md" 127 + "README.md"${worker_files} 116 128 ] 117 129 } 118 130 EOF ··· 193 205 EOF 194 206 } 195 207 208 + build_worker() { 209 + echo "Building editor worker WASM..." 210 + 211 + # Build the worker binary from weaver-editor-crdt 212 + # Must be in workspace root for cargo to find the crate 213 + local workspace_root="$(cd ../.. && pwd)" 214 + 215 + export RUSTFLAGS='--cfg getrandom_backend="wasm_js"' 216 + 217 + (cd "$workspace_root" && cargo build \ 218 + -p weaver-editor-crdt \ 219 + --bin editor_worker \ 220 + --target wasm32-unknown-unknown \ 221 + --release \ 222 + --features collab) 223 + 224 + # Create worker output directory 225 + local worker_out="pkg/collab/worker" 226 + mkdir -p "$worker_out" 227 + 228 + # Run wasm-bindgen with no-modules target for web worker compatibility 229 + wasm-bindgen \ 230 + "$workspace_root/target/wasm32-unknown-unknown/release/editor_worker.wasm" \ 231 + --out-dir "$worker_out" \ 232 + --target no-modules \ 233 + --no-typescript 234 + 235 + # Report size 236 + local wasm_file="${worker_out}/editor_worker_bg.wasm" 237 + if [[ -f "$wasm_file" ]]; then 238 + local size=$(ls -lh "$wasm_file" | awk '{print $5}') 239 + echo " → Worker WASM: ${size}" 240 + fi 241 + } 242 + 196 243 build_typescript() { 197 244 echo "Building TypeScript wrapper..." 198 245 ··· 202 249 fi 203 250 204 251 # Link WASM output so TypeScript can find it during compilation 205 - # Use core/bundler as the source (all variants have same API) 252 + # Use collab/bundler as source - it has all exports (JsCollabEditor + JsEditor) 253 + # Core variant users who import collab will get runtime error, which is expected 206 254 rm -rf ts/bundler 207 - ln -s ../pkg/core/bundler ts/bundler 255 + ln -s ../pkg/collab/bundler ts/bundler 208 256 209 257 # Compile TypeScript 210 258 (cd ts && npm run build) ··· 246 294 find "pkg/${variant}" -name "package.json" -path "*/deno/*" -delete 247 295 done 248 296 297 + # Build worker WASM for collab variant 298 + build_worker 299 + 249 300 # Build TypeScript wrapper 250 301 build_typescript 251 302 252 303 echo "" 253 304 echo "Build complete!" 254 305 echo "" 306 + echo "Editor WASM:" 255 307 ls -lh pkg/core/web/*.wasm pkg/collab/web/*.wasm 2>/dev/null || true 308 + echo "" 309 + echo "Worker WASM (collab only):" 310 + ls -lh pkg/collab/worker/*.wasm 2>/dev/null || true 256 311 echo "" 257 312 echo "Packages:" 258 313 echo " pkg/core/ - @weaver.sh/editor-core (local editing)"
+782 -23
crates/weaver-editor-js/src/collab.rs
··· 1 - //! JsCollabEditor - collaborative editor with Loro CRDT. 1 + //! JsCollabEditor - collaborative editor with Loro CRDT and iroh P2P. 2 2 //! 3 - //! Only available with the `collab` feature. 3 + //! This wraps the core editor with a Loro-backed buffer and manages 4 + //! the EditorReactor worker for off-main-thread collab networking. 5 + 6 + use std::collections::HashMap; 4 7 5 8 use wasm_bindgen::prelude::*; 9 + use web_sys::HtmlElement; 6 10 7 - use weaver_editor_crdt::LoroTextBuffer; 11 + use weaver_editor_browser::{ 12 + BrowserClipboard, BrowserCursor, ParagraphRender, update_paragraph_dom, 13 + update_syntax_visibility, 14 + }; 15 + use weaver_editor_core::{ 16 + CursorPlatform, EditorDocument, EditorImageResolver, PlainEditor, RenderCache, TextBuffer, 17 + apply_formatting, execute_action_with_clipboard, render_paragraphs_incremental, 18 + }; 19 + use weaver_editor_crdt::{LoroTextBuffer, VersionVector}; 8 20 9 - /// Collaborative editor with CRDT sync. 21 + use crate::actions::{ActionKind, parse_action}; 22 + use crate::types::{ 23 + EntryEmbeds, EntryJson, FinalizedImage, JsParagraphRender, JsResolvedContent, PendingImage, 24 + }; 25 + 26 + type 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 10 34 /// 11 - /// Wraps LoroTextBuffer for collaborative editing with iroh P2P transport. 35 + /// The editor handles: 36 + /// - Loro CRDT document sync 37 + /// - iroh gossip networking (via web worker) 38 + /// - Presence tracking 12 39 #[wasm_bindgen] 13 40 pub struct JsCollabEditor { 14 - // TODO: Implement collab editor 15 - // - LoroTextBuffer for CRDT-backed text 16 - // - iroh node for P2P transport 17 - // - Session management callbacks 18 - _marker: std::marker::PhantomData<LoroTextBuffer>, 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>, 19 73 } 20 74 21 75 #[wasm_bindgen] 22 76 impl JsCollabEditor { 23 - /// Create a new collaborative editor. 77 + /// Create a new empty collab editor. 24 78 #[wasm_bindgen(constructor)] 25 - pub fn new() -> Result<JsCollabEditor, JsError> { 26 - Err(JsError::new("CollabEditor not yet implemented")) 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) 27 718 } 28 719 } 29 720 30 - impl Default for JsCollabEditor { 31 - fn default() -> Self { 32 - Self { 33 - _marker: std::marker::PhantomData, 721 + impl 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); 34 763 } 35 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 + } 36 797 } 37 798 38 - // TODO: Implement these when ready: 39 - // - from_snapshot / from_loro_doc 40 - // - export_updates / import_updates 41 - // - get_version 42 - // - add_peer / remove_peer / get_connected_peers 43 - // - Session callbacks (onSessionNeeded, onSessionRefresh, onSessionEnd, onPeersNeeded) 799 + fn now_iso() -> String { 800 + let date = js_sys::Date::new_0(); 801 + date.to_iso_string().into() 802 + }
+1 -1
crates/weaver-editor-js/ts/bundler
··· 1 - ../pkg/core/bundler 1 + ../pkg/collab/bundler
+1092
crates/weaver-editor-js/ts/collab.ts
··· 1 + /** 2 + * Collaborative editor with Loro CRDT and iroh P2P. 3 + * 4 + * Usage: 5 + * ```typescript 6 + * import { createCollabEditor } from '@weaver.sh/editor-collab'; 7 + * 8 + * const editor = await createCollabEditor({ 9 + * container: document.getElementById('editor')!, 10 + * resourceUri: 'at://did:plc:abc/sh.weaver.notebook.entry/xyz', 11 + * onChange: () => console.log('changed'), 12 + * onSessionNeeded: async (session) => { 13 + * // Create session record on PDS, return URI 14 + * return 'at://did:plc:abc/sh.weaver.edit.session/123'; 15 + * }, 16 + * onPeersNeeded: async (resourceUri) => { 17 + * // Query index/backlinks for peer session records 18 + * return [{ nodeId: 'peer-node-id' }]; 19 + * }, 20 + * }); 21 + * 22 + * // Get Loro snapshot for saving 23 + * const snapshot = editor.exportSnapshot(); 24 + * 25 + * // Cleanup 26 + * await editor.stopCollab(); 27 + * editor.destroy(); 28 + * ``` 29 + */ 30 + 31 + import type { 32 + CollabEditor, 33 + CollabEditorConfig, 34 + CursorRect, 35 + EditorAction, 36 + EntryJson, 37 + EventResult, 38 + FinalizedImage, 39 + ParagraphRender, 40 + PeerInfo, 41 + PendingImage, 42 + PresenceSnapshot, 43 + Selection, 44 + SelectionRect, 45 + SessionInfo, 46 + UserInfo, 47 + } from "./types"; 48 + 49 + // ============================================================ 50 + // Color utilities 51 + // ============================================================ 52 + 53 + /** Convert RGBA u32 (0xRRGGBBAA) to CSS rgba() string. */ 54 + function rgbaToCss(color: number): string { 55 + const r = (color >>> 24) & 0xff; 56 + const g = (color >>> 16) & 0xff; 57 + const b = (color >>> 8) & 0xff; 58 + const a = (color & 0xff) / 255; 59 + return `rgba(${r}, ${g}, ${b}, ${a})`; 60 + } 61 + 62 + /** Convert RGBA u32 to CSS rgba() string with custom alpha. */ 63 + function rgbaToCssAlpha(color: number, alpha: number): string { 64 + const r = (color >>> 24) & 0xff; 65 + const g = (color >>> 16) & 0xff; 66 + const b = (color >>> 8) & 0xff; 67 + return `rgba(${r}, ${g}, ${b}, ${alpha})`; 68 + } 69 + 70 + // ============================================================ 71 + // Worker message types (must match Rust WorkerInput/WorkerOutput) 72 + // ============================================================ 73 + 74 + type WorkerInput = 75 + | { type: "Init"; snapshot: number[]; draft_key: string } 76 + | { type: "ApplyUpdates"; updates: number[] } 77 + | { 78 + type: "ExportSnapshot"; 79 + cursor_offset: number; 80 + editing_uri: string | null; 81 + editing_cid: string | null; 82 + notebook_uri: string | null; 83 + } 84 + | { type: "StartCollab"; topic: number[]; bootstrap_peers: string[] } 85 + | { type: "BroadcastUpdate"; data: number[] } 86 + | { type: "AddPeers"; peers: string[] } 87 + | { type: "BroadcastJoin"; did: string; display_name: string } 88 + | { type: "BroadcastCursor"; position: number; selection: [number, number] | null } 89 + | { type: "StopCollab" }; 90 + 91 + type WorkerOutput = 92 + | { type: "Ready" } 93 + | { 94 + type: "Snapshot"; 95 + draft_key: string; 96 + b64_snapshot: string; 97 + content: string; 98 + title: string; 99 + cursor_offset: number; 100 + editing_uri: string | null; 101 + editing_cid: string | null; 102 + notebook_uri: string | null; 103 + export_ms: number; 104 + encode_ms: number; 105 + } 106 + | { type: "Error"; message: string } 107 + | { type: "CollabReady"; node_id: string; relay_url: string | null } 108 + | { type: "CollabJoined" } 109 + | { type: "RemoteUpdates"; data: number[] } 110 + | { type: "PresenceUpdate"; collaborators: PresenceSnapshot["collaborators"]; peer_count: number } 111 + | { type: "CollabStopped" } 112 + | { type: "PeerConnected" }; 113 + 114 + // ============================================================ 115 + // Worker Bridge 116 + // ============================================================ 117 + 118 + /** 119 + * Bridge to communicate with the EditorReactor web worker. 120 + * 121 + * The worker handles: 122 + * - CPU-intensive Loro operations off main thread 123 + * - iroh P2P networking for real-time collaboration 124 + */ 125 + class WorkerBridge { 126 + private worker: Worker | null = null; 127 + private messageHandlers: ((msg: WorkerOutput) => void)[] = []; 128 + private pendingReady: ((value: void) => void) | null = null; 129 + 130 + /** 131 + * Spawn the worker. Must be called before any other methods. 132 + * 133 + * @param workerUrl URL to the worker JS file (editor_worker.js) 134 + */ 135 + async spawn(workerUrl: string): Promise<void> { 136 + if (this.worker) { 137 + throw new Error("Worker already spawned"); 138 + } 139 + 140 + return new Promise((resolve, reject) => { 141 + try { 142 + this.worker = new Worker(workerUrl); 143 + 144 + this.worker.onmessage = (e: MessageEvent) => { 145 + const msg = e.data as WorkerOutput; 146 + this.handleMessage(msg); 147 + }; 148 + 149 + this.worker.onerror = (e: ErrorEvent) => { 150 + console.error("Worker error:", e); 151 + reject(new Error(`Worker error: ${e.message}`)); 152 + }; 153 + 154 + // Wait for Ready message 155 + this.pendingReady = resolve; 156 + } catch (err) { 157 + reject(err); 158 + } 159 + }); 160 + } 161 + 162 + /** 163 + * Send a message to the worker. 164 + */ 165 + send(msg: WorkerInput): void { 166 + if (!this.worker) { 167 + throw new Error("Worker not spawned"); 168 + } 169 + this.worker.postMessage(msg); 170 + } 171 + 172 + /** 173 + * Register a handler for worker messages. 174 + */ 175 + onMessage(handler: (msg: WorkerOutput) => void): () => void { 176 + this.messageHandlers.push(handler); 177 + return () => { 178 + const idx = this.messageHandlers.indexOf(handler); 179 + if (idx >= 0) { 180 + this.messageHandlers.splice(idx, 1); 181 + } 182 + }; 183 + } 184 + 185 + /** 186 + * Terminate the worker. 187 + */ 188 + terminate(): void { 189 + if (this.worker) { 190 + this.worker.terminate(); 191 + this.worker = null; 192 + } 193 + this.messageHandlers = []; 194 + } 195 + 196 + private handleMessage(msg: WorkerOutput): void { 197 + // Handle Ready specially to resolve spawn promise 198 + if (msg.type === "Ready" && this.pendingReady) { 199 + this.pendingReady(); 200 + this.pendingReady = null; 201 + } 202 + 203 + // Dispatch to all handlers 204 + for (const handler of this.messageHandlers) { 205 + try { 206 + handler(msg); 207 + } catch (err) { 208 + console.error("Error in worker message handler:", err); 209 + } 210 + } 211 + } 212 + } 213 + 214 + // Internal types for WASM module 215 + interface JsCollabEditor { 216 + mount(container: HTMLElement, onChange?: () => void): void; 217 + unmount(): void; 218 + isMounted(): boolean; 219 + focus(): void; 220 + blur(): void; 221 + getMarkdown(): string; 222 + getSnapshot(): unknown; 223 + toEntry(): unknown; 224 + setResolvedContent(content: JsResolvedContent): void; 225 + getTitle(): string; 226 + setTitle(title: string): void; 227 + getPath(): string; 228 + setPath(path: string): void; 229 + getTags(): string[]; 230 + setTags(tags: string[]): void; 231 + executeAction(action: unknown): void; 232 + addPendingImage(image: unknown, dataUrl: string): void; 233 + finalizeImage(localId: string, finalized: unknown, blobRkey: string, ident: string): void; 234 + removeImage(localId: string): void; 235 + getPendingImages(): unknown; 236 + getStagingUris(): string[]; 237 + addEntryToIndex(title: string, path: string, canonicalUrl: string): void; 238 + clearEntryIndex(): void; 239 + getCursorOffset(): number; 240 + getSelection(): Selection | null; 241 + setCursorOffset(offset: number): void; 242 + getLength(): number; 243 + canUndo(): boolean; 244 + canRedo(): boolean; 245 + getParagraphs(): unknown; 246 + renderAndUpdateDom(): void; 247 + 248 + // Remote cursor positioning 249 + getCursorRectRelative(position: number): CursorRect | null; 250 + getSelectionRectsRelative(start: number, end: number): SelectionRect[]; 251 + handleBeforeInput( 252 + inputType: string, 253 + data: string | null, 254 + targetStart: number | null, 255 + targetEnd: number | null, 256 + isComposing: boolean, 257 + ): EventResult; 258 + handleKeydown(key: string, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean): EventResult; 259 + handleKeyup(key: string): void; 260 + handlePaste(text: string): void; 261 + handleCut(): string | null; 262 + handleCopy(): string | null; 263 + handleBlur(): void; 264 + handleCompositionStart(data: string | null): void; 265 + handleCompositionUpdate(data: string | null): void; 266 + handleCompositionEnd(data: string | null): void; 267 + handleAndroidEnter(): void; 268 + syncCursor(): void; 269 + 270 + // Loro sync methods 271 + exportSnapshot(): Uint8Array; 272 + exportUpdatesSince(version: Uint8Array): Uint8Array | null; 273 + importUpdates(data: Uint8Array): void; 274 + getVersion(): Uint8Array; 275 + getCollabTopic(): Uint8Array | null; 276 + getResourceUri(): string; 277 + 278 + // Callbacks 279 + setOnSessionNeeded(callback: (info: SessionInfo) => Promise<string>): void; 280 + setOnSessionRefresh(callback: (uri: string) => Promise<void>): void; 281 + setOnSessionEnd(callback: (uri: string) => Promise<void>): void; 282 + setOnPeersNeeded(callback: (uri: string) => Promise<PeerInfo[]>): void; 283 + setOnPresenceChanged(callback: (presence: PresenceSnapshot) => void): void; 284 + setOnRemoteUpdate(callback: () => void): void; 285 + } 286 + 287 + interface JsCollabEditorConstructor { 288 + new (resourceUri: string): JsCollabEditor; 289 + fromMarkdown(resourceUri: string, content: string): JsCollabEditor; 290 + fromSnapshot(resourceUri: string, snapshot: Uint8Array): JsCollabEditor; 291 + } 292 + 293 + interface JsResolvedContent { 294 + addEmbed(atUri: string, html: string): void; 295 + } 296 + 297 + interface CollabWasmModule { 298 + JsCollabEditor: JsCollabEditorConstructor; 299 + create_resolved_content: () => JsResolvedContent; 300 + } 301 + 302 + let wasmModule: CollabWasmModule | null = null; 303 + 304 + /** 305 + * Initialize the collab WASM module. 306 + */ 307 + export async function initCollabWasm(): Promise<CollabWasmModule> { 308 + if (wasmModule) return wasmModule; 309 + 310 + // The collab module is built separately with the collab feature 311 + const mod = await import("./bundler/weaver_editor.js"); 312 + wasmModule = mod as unknown as CollabWasmModule; 313 + return wasmModule; 314 + } 315 + 316 + /** 317 + * Create a new collaborative editor instance. 318 + * 319 + * @param config Editor configuration 320 + * @param workerUrl URL to the editor_worker.js file (default: "/worker/editor_worker.js") 321 + */ 322 + export async function createCollabEditor( 323 + config: CollabEditorConfig, 324 + workerUrl = "/worker/editor_worker.js", 325 + ): Promise<CollabEditor> { 326 + const wasm = await initCollabWasm(); 327 + 328 + // Create the inner WASM editor 329 + let inner: JsCollabEditor; 330 + if (config.initialLoroSnapshot) { 331 + inner = wasm.JsCollabEditor.fromSnapshot(config.resourceUri, config.initialLoroSnapshot); 332 + } else if (config.initialMarkdown) { 333 + inner = wasm.JsCollabEditor.fromMarkdown(config.resourceUri, config.initialMarkdown); 334 + } else { 335 + inner = new wasm.JsCollabEditor(config.resourceUri); 336 + } 337 + 338 + // Set up resolved content if provided 339 + if (config.resolvedContent) { 340 + const resolved = wasm.create_resolved_content(); 341 + for (const [uri, html] of config.resolvedContent.embeds) { 342 + resolved.addEmbed(uri, html); 343 + } 344 + inner.setResolvedContent(resolved); 345 + } 346 + 347 + // Create wrapper with worker URL 348 + const editor = new CollabEditorImpl(inner, config, workerUrl); 349 + 350 + // Mount to container 351 + editor.mountToContainer(config.container); 352 + 353 + return editor; 354 + } 355 + 356 + /** 357 + * Internal collab editor implementation. 358 + */ 359 + class CollabEditorImpl implements CollabEditor { 360 + private inner: JsCollabEditor; 361 + private config: CollabEditorConfig; 362 + private container: HTMLElement | null = null; 363 + private editorElement: HTMLElement | null = null; 364 + private destroyed = false; 365 + 366 + // Worker bridge for P2P collab 367 + private workerBridge: WorkerBridge | null = null; 368 + private workerUrl: string; 369 + private sessionUri: string | null = null; 370 + private collabStarted = false; 371 + private unsubscribeWorker: (() => void) | null = null; 372 + private lastSyncedVersion: Uint8Array | null = null; 373 + private lastBroadcastCursor: number = -1; 374 + 375 + // Remote cursor overlay 376 + private currentPresence: PresenceSnapshot | null = null; 377 + private cursorOverlay: HTMLElement | null = null; 378 + 379 + // Event handler refs for cleanup 380 + private boundHandlers: { 381 + beforeinput: (e: InputEvent) => void; 382 + keydown: (e: KeyboardEvent) => void; 383 + keyup: (e: KeyboardEvent) => void; 384 + paste: (e: ClipboardEvent) => void; 385 + cut: (e: ClipboardEvent) => void; 386 + copy: (e: ClipboardEvent) => void; 387 + blur: () => void; 388 + compositionstart: (e: CompositionEvent) => void; 389 + compositionupdate: (e: CompositionEvent) => void; 390 + compositionend: (e: CompositionEvent) => void; 391 + mouseup: () => void; 392 + touchend: () => void; 393 + }; 394 + 395 + constructor(inner: JsCollabEditor, config: CollabEditorConfig, workerUrl: string) { 396 + this.inner = inner; 397 + this.config = config; 398 + this.workerUrl = workerUrl; 399 + 400 + // Bind event handlers 401 + this.boundHandlers = { 402 + beforeinput: this.onBeforeInput.bind(this), 403 + keydown: this.onKeydown.bind(this), 404 + keyup: this.onKeyup.bind(this), 405 + paste: this.onPaste.bind(this), 406 + cut: this.onCut.bind(this), 407 + copy: this.onCopy.bind(this), 408 + blur: this.onBlur.bind(this), 409 + compositionstart: this.onCompositionStart.bind(this), 410 + compositionupdate: this.onCompositionUpdate.bind(this), 411 + compositionend: this.onCompositionEnd.bind(this), 412 + mouseup: this.onMouseUp.bind(this), 413 + touchend: this.onTouchEnd.bind(this), 414 + }; 415 + } 416 + 417 + /** Mount to container and set up event listeners. */ 418 + mountToContainer(container: HTMLElement): void { 419 + this.container = container; 420 + 421 + // Wrap onChange to also sync updates to worker 422 + const wrappedOnChange = () => { 423 + this.syncToWorker(); 424 + this.config.onChange?.(); 425 + // Re-render remote cursors after content changes (positions may shift) 426 + this.renderRemoteCursors(); 427 + }; 428 + 429 + this.inner.mount(container, wrappedOnChange); 430 + 431 + const editorEl = container.querySelector(".weaver-editor-content") as HTMLElement; 432 + if (!editorEl) { 433 + throw new Error("Failed to find editor element after mount"); 434 + } 435 + this.editorElement = editorEl; 436 + this.attachEventListeners(); 437 + 438 + // Create remote cursors overlay 439 + this.cursorOverlay = document.createElement("div"); 440 + this.cursorOverlay.className = "remote-cursors-overlay"; 441 + container.appendChild(this.cursorOverlay); 442 + 443 + // Initialize synced version 444 + this.lastSyncedVersion = this.inner.getVersion(); 445 + } 446 + 447 + /** 448 + * Sync local changes to the worker for broadcast. 449 + */ 450 + private syncToWorker(): void { 451 + if (!this.workerBridge || !this.collabStarted || !this.lastSyncedVersion) { 452 + return; 453 + } 454 + 455 + // Export updates since last sync 456 + const updates = this.inner.exportUpdatesSince(this.lastSyncedVersion); 457 + if (updates) { 458 + // Send to worker for broadcast 459 + this.workerBridge.send({ 460 + type: "BroadcastUpdate", 461 + data: Array.from(updates), 462 + }); 463 + 464 + // Also send to worker to keep shadow doc in sync 465 + this.workerBridge.send({ 466 + type: "ApplyUpdates", 467 + updates: Array.from(updates), 468 + }); 469 + 470 + // Update synced version 471 + this.lastSyncedVersion = this.inner.getVersion(); 472 + } 473 + 474 + // Also sync cursor 475 + this.broadcastCursor(); 476 + } 477 + 478 + /** 479 + * Render remote collaborator cursors. 480 + */ 481 + private renderRemoteCursors(): void { 482 + if (!this.cursorOverlay || !this.currentPresence) { 483 + return; 484 + } 485 + 486 + // Clear existing cursors 487 + this.cursorOverlay.innerHTML = ""; 488 + 489 + for (const collab of this.currentPresence.collaborators) { 490 + if (collab.cursorPosition === undefined) { 491 + continue; 492 + } 493 + 494 + const rect = this.inner.getCursorRectRelative(collab.cursorPosition); 495 + if (!rect) { 496 + continue; 497 + } 498 + 499 + // Convert color to CSS 500 + const colorCss = rgbaToCss(collab.color); 501 + const selectionColorCss = rgbaToCssAlpha(collab.color, 0.25); 502 + 503 + // Render selection highlights first (behind cursor) 504 + if (collab.selection) { 505 + const [start, end] = collab.selection; 506 + const [selStart, selEnd] = start <= end ? [start, end] : [end, start]; 507 + const selRects = this.inner.getSelectionRectsRelative(selStart, selEnd); 508 + 509 + for (const selRect of selRects) { 510 + const selDiv = document.createElement("div"); 511 + selDiv.className = "remote-selection"; 512 + selDiv.style.cssText = ` 513 + left: ${selRect.x}px; 514 + top: ${selRect.y}px; 515 + width: ${selRect.width}px; 516 + height: ${selRect.height}px; 517 + background-color: ${selectionColorCss}; 518 + `; 519 + this.cursorOverlay.appendChild(selDiv); 520 + } 521 + } 522 + 523 + // Create cursor element 524 + const cursorDiv = document.createElement("div"); 525 + cursorDiv.className = "remote-cursor"; 526 + cursorDiv.style.cssText = ` 527 + left: ${rect.x}px; 528 + top: ${rect.y}px; 529 + --cursor-height: ${rect.height}px; 530 + --cursor-color: ${colorCss}; 531 + `; 532 + 533 + // Caret line 534 + const caretDiv = document.createElement("div"); 535 + caretDiv.className = "remote-cursor-caret"; 536 + cursorDiv.appendChild(caretDiv); 537 + 538 + // Name label 539 + const labelDiv = document.createElement("div"); 540 + labelDiv.className = "remote-cursor-label"; 541 + labelDiv.textContent = collab.displayName; 542 + cursorDiv.appendChild(labelDiv); 543 + 544 + this.cursorOverlay.appendChild(cursorDiv); 545 + } 546 + } 547 + 548 + /** 549 + * Broadcast cursor position to peers. 550 + */ 551 + private broadcastCursor(): void { 552 + if (!this.workerBridge || !this.collabStarted) { 553 + return; 554 + } 555 + 556 + const cursor = this.inner.getCursorOffset(); 557 + const sel = this.inner.getSelection(); 558 + 559 + // Only broadcast if cursor changed 560 + if (cursor === this.lastBroadcastCursor && !sel) { 561 + return; 562 + } 563 + 564 + this.lastBroadcastCursor = cursor; 565 + 566 + this.workerBridge.send({ 567 + type: "BroadcastCursor", 568 + position: cursor, 569 + selection: sel ? [sel.anchor, sel.head] : null, 570 + }); 571 + } 572 + 573 + private attachEventListeners(): void { 574 + const el = this.editorElement; 575 + if (!el) return; 576 + 577 + el.addEventListener("beforeinput", this.boundHandlers.beforeinput); 578 + el.addEventListener("keydown", this.boundHandlers.keydown); 579 + el.addEventListener("keyup", this.boundHandlers.keyup); 580 + el.addEventListener("paste", this.boundHandlers.paste); 581 + el.addEventListener("cut", this.boundHandlers.cut); 582 + el.addEventListener("copy", this.boundHandlers.copy); 583 + el.addEventListener("blur", this.boundHandlers.blur); 584 + el.addEventListener("compositionstart", this.boundHandlers.compositionstart); 585 + el.addEventListener("compositionupdate", this.boundHandlers.compositionupdate); 586 + el.addEventListener("compositionend", this.boundHandlers.compositionend); 587 + el.addEventListener("mouseup", this.boundHandlers.mouseup); 588 + el.addEventListener("touchend", this.boundHandlers.touchend); 589 + } 590 + 591 + private detachEventListeners(): void { 592 + const el = this.editorElement; 593 + if (!el) return; 594 + 595 + el.removeEventListener("beforeinput", this.boundHandlers.beforeinput); 596 + el.removeEventListener("keydown", this.boundHandlers.keydown); 597 + el.removeEventListener("keyup", this.boundHandlers.keyup); 598 + el.removeEventListener("paste", this.boundHandlers.paste); 599 + el.removeEventListener("cut", this.boundHandlers.cut); 600 + el.removeEventListener("copy", this.boundHandlers.copy); 601 + el.removeEventListener("blur", this.boundHandlers.blur); 602 + el.removeEventListener("compositionstart", this.boundHandlers.compositionstart); 603 + el.removeEventListener("compositionupdate", this.boundHandlers.compositionupdate); 604 + el.removeEventListener("compositionend", this.boundHandlers.compositionend); 605 + el.removeEventListener("mouseup", this.boundHandlers.mouseup); 606 + el.removeEventListener("touchend", this.boundHandlers.touchend); 607 + } 608 + 609 + // === Event handlers (same as EditorImpl) === 610 + 611 + private onBeforeInput(e: InputEvent): void { 612 + const inputType = e.inputType; 613 + const data = e.data ?? null; 614 + 615 + let targetStart: number | null = null; 616 + let targetEnd: number | null = null; 617 + const ranges = e.getTargetRanges?.(); 618 + if (ranges && ranges.length > 0) { 619 + const range = ranges[0]; 620 + targetStart = this.domOffsetToChar(range.startContainer, range.startOffset); 621 + targetEnd = this.domOffsetToChar(range.endContainer, range.endOffset); 622 + } 623 + 624 + const isComposing = e.isComposing; 625 + const result = this.inner.handleBeforeInput(inputType, data, targetStart, targetEnd, isComposing); 626 + 627 + if (result === "Handled" || result === "HandledAsync") { 628 + e.preventDefault(); 629 + } 630 + } 631 + 632 + private onKeydown(e: KeyboardEvent): void { 633 + const result = this.inner.handleKeydown(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey); 634 + 635 + if (result === "Handled") { 636 + e.preventDefault(); 637 + } 638 + } 639 + 640 + private onKeyup(e: KeyboardEvent): void { 641 + this.inner.handleKeyup(e.key); 642 + } 643 + 644 + private onPaste(e: ClipboardEvent): void { 645 + e.preventDefault(); 646 + const text = e.clipboardData?.getData("text/plain") ?? ""; 647 + this.inner.handlePaste(text); 648 + } 649 + 650 + private onCut(e: ClipboardEvent): void { 651 + e.preventDefault(); 652 + const text = this.inner.handleCut(); 653 + if (text && e.clipboardData) { 654 + e.clipboardData.setData("text/plain", text); 655 + } 656 + } 657 + 658 + private onCopy(e: ClipboardEvent): void { 659 + e.preventDefault(); 660 + const text = this.inner.handleCopy(); 661 + if (text && e.clipboardData) { 662 + e.clipboardData.setData("text/plain", text); 663 + } 664 + } 665 + 666 + private onBlur(): void { 667 + this.inner.handleBlur(); 668 + } 669 + 670 + private onCompositionStart(e: CompositionEvent): void { 671 + this.inner.handleCompositionStart(e.data ?? null); 672 + } 673 + 674 + private onCompositionUpdate(e: CompositionEvent): void { 675 + this.inner.handleCompositionUpdate(e.data ?? null); 676 + } 677 + 678 + private onCompositionEnd(e: CompositionEvent): void { 679 + this.inner.handleCompositionEnd(e.data ?? null); 680 + } 681 + 682 + private onMouseUp(): void { 683 + this.inner.syncCursor(); 684 + this.broadcastCursor(); 685 + } 686 + 687 + private onTouchEnd(): void { 688 + this.inner.syncCursor(); 689 + this.broadcastCursor(); 690 + } 691 + 692 + private domOffsetToChar(node: Node, offset: number): number | null { 693 + const editor = this.editorElement; 694 + if (!editor) return null; 695 + 696 + let charOffset = 0; 697 + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); 698 + 699 + let currentNode = walker.nextNode(); 700 + while (currentNode) { 701 + if (currentNode === node) { 702 + return charOffset + offset; 703 + } 704 + charOffset += currentNode.textContent?.length ?? 0; 705 + currentNode = walker.nextNode(); 706 + } 707 + 708 + if (node.nodeType === Node.ELEMENT_NODE) { 709 + for (let i = 0; i < offset && i < node.childNodes.length; i++) { 710 + charOffset += node.childNodes[i].textContent?.length ?? 0; 711 + } 712 + return charOffset; 713 + } 714 + 715 + return null; 716 + } 717 + 718 + // === Loro sync methods === 719 + 720 + exportSnapshot(): Uint8Array { 721 + this.checkDestroyed(); 722 + return this.inner.exportSnapshot(); 723 + } 724 + 725 + exportUpdatesSince(version: Uint8Array): Uint8Array | null { 726 + this.checkDestroyed(); 727 + return this.inner.exportUpdatesSince(version); 728 + } 729 + 730 + importUpdates(data: Uint8Array): void { 731 + this.checkDestroyed(); 732 + this.inner.importUpdates(data); 733 + } 734 + 735 + getVersion(): Uint8Array { 736 + this.checkDestroyed(); 737 + return this.inner.getVersion(); 738 + } 739 + 740 + getCollabTopic(): Uint8Array | null { 741 + this.checkDestroyed(); 742 + return this.inner.getCollabTopic(); 743 + } 744 + 745 + getResourceUri(): string { 746 + this.checkDestroyed(); 747 + return this.inner.getResourceUri(); 748 + } 749 + 750 + // === Collab lifecycle === 751 + 752 + async startCollab(bootstrapPeers?: string[]): Promise<void> { 753 + this.checkDestroyed(); 754 + 755 + if (this.collabStarted) { 756 + console.warn("Collab already started"); 757 + return; 758 + } 759 + 760 + // Spawn worker 761 + this.workerBridge = new WorkerBridge(); 762 + await this.workerBridge.spawn(this.workerUrl); 763 + 764 + // Set up message handler 765 + this.unsubscribeWorker = this.workerBridge.onMessage((msg) => { 766 + this.handleWorkerMessage(msg); 767 + }); 768 + 769 + // Initialize worker with current Loro snapshot 770 + const snapshot = this.inner.exportSnapshot(); 771 + this.workerBridge.send({ 772 + type: "Init", 773 + snapshot: Array.from(snapshot), 774 + draft_key: this.config.resourceUri, 775 + }); 776 + 777 + // Start collab session 778 + const topic = this.inner.getCollabTopic(); 779 + if (!topic) { 780 + throw new Error("No collab topic available"); 781 + } 782 + 783 + this.workerBridge.send({ 784 + type: "StartCollab", 785 + topic: Array.from(topic), 786 + bootstrap_peers: bootstrapPeers ?? [], 787 + }); 788 + 789 + this.collabStarted = true; 790 + } 791 + 792 + async stopCollab(): Promise<void> { 793 + this.checkDestroyed(); 794 + 795 + if (!this.collabStarted || !this.workerBridge) { 796 + return; 797 + } 798 + 799 + // Send stop to worker 800 + this.workerBridge.send({ type: "StopCollab" }); 801 + 802 + // Delete session record via callback 803 + if (this.sessionUri && this.config.onSessionEnd) { 804 + try { 805 + await this.config.onSessionEnd(this.sessionUri); 806 + } catch (err) { 807 + console.error("Failed to delete session record:", err); 808 + } 809 + } 810 + 811 + // Clean up 812 + if (this.unsubscribeWorker) { 813 + this.unsubscribeWorker(); 814 + this.unsubscribeWorker = null; 815 + } 816 + this.workerBridge.terminate(); 817 + this.workerBridge = null; 818 + this.sessionUri = null; 819 + this.collabStarted = false; 820 + } 821 + 822 + addPeers(nodeIds: string[]): void { 823 + this.checkDestroyed(); 824 + 825 + if (!this.workerBridge || !this.collabStarted) { 826 + console.warn("Cannot add peers - collab not started"); 827 + return; 828 + } 829 + 830 + this.workerBridge.send({ 831 + type: "AddPeers", 832 + peers: nodeIds, 833 + }); 834 + } 835 + 836 + /** 837 + * Handle messages from the worker. 838 + */ 839 + private async handleWorkerMessage(msg: WorkerOutput): Promise<void> { 840 + switch (msg.type) { 841 + case "CollabReady": { 842 + // Worker has node ID and relay URL, create session record 843 + if (this.config.onSessionNeeded) { 844 + try { 845 + const sessionInfo: SessionInfo = { 846 + nodeId: msg.node_id, 847 + relayUrl: msg.relay_url, 848 + }; 849 + this.sessionUri = await this.config.onSessionNeeded(sessionInfo); 850 + 851 + // Discover peers now that we have a session 852 + if (this.config.onPeersNeeded) { 853 + const peers = await this.config.onPeersNeeded(this.config.resourceUri); 854 + if (peers.length > 0) { 855 + this.addPeers(peers.map((p) => p.nodeId)); 856 + } 857 + } 858 + } catch (err) { 859 + console.error("Failed to create session record:", err); 860 + } 861 + } 862 + break; 863 + } 864 + 865 + case "CollabJoined": 866 + // Successfully joined the gossip session 867 + break; 868 + 869 + case "RemoteUpdates": { 870 + // Apply remote Loro updates to main document 871 + const data = new Uint8Array(msg.data); 872 + this.inner.importUpdates(data); 873 + break; 874 + } 875 + 876 + case "PresenceUpdate": { 877 + // Store presence and render remote cursors 878 + const presence: PresenceSnapshot = { 879 + collaborators: msg.collaborators, 880 + peerCount: msg.peer_count, 881 + }; 882 + this.currentPresence = presence; 883 + this.renderRemoteCursors(); 884 + 885 + // Forward to callback 886 + this.config.onPresenceChanged?.(presence); 887 + break; 888 + } 889 + 890 + case "PeerConnected": { 891 + // A new peer connected, send our Join message with user info 892 + if (this.config.onUserInfoNeeded && this.workerBridge) { 893 + try { 894 + const userInfo = await this.config.onUserInfoNeeded(); 895 + this.workerBridge.send({ 896 + type: "BroadcastJoin", 897 + did: userInfo.did, 898 + display_name: userInfo.displayName, 899 + }); 900 + } catch (err) { 901 + console.error("Failed to get user info for Join:", err); 902 + } 903 + } 904 + break; 905 + } 906 + 907 + case "CollabStopped": 908 + // Worker confirmed collab stopped 909 + break; 910 + 911 + case "Error": 912 + console.error("Worker error:", msg.message); 913 + break; 914 + 915 + case "Ready": 916 + case "Snapshot": 917 + // Handled elsewhere or not needed for collab 918 + break; 919 + } 920 + } 921 + 922 + // === Public API (same as Editor) === 923 + 924 + getMarkdown(): string { 925 + this.checkDestroyed(); 926 + return this.inner.getMarkdown(); 927 + } 928 + 929 + getSnapshot(): EntryJson { 930 + this.checkDestroyed(); 931 + return this.inner.getSnapshot() as EntryJson; 932 + } 933 + 934 + toEntry(): EntryJson { 935 + this.checkDestroyed(); 936 + return this.inner.toEntry() as EntryJson; 937 + } 938 + 939 + getTitle(): string { 940 + this.checkDestroyed(); 941 + return this.inner.getTitle(); 942 + } 943 + 944 + setTitle(title: string): void { 945 + this.checkDestroyed(); 946 + this.inner.setTitle(title); 947 + } 948 + 949 + getPath(): string { 950 + this.checkDestroyed(); 951 + return this.inner.getPath(); 952 + } 953 + 954 + setPath(path: string): void { 955 + this.checkDestroyed(); 956 + this.inner.setPath(path); 957 + } 958 + 959 + getTags(): string[] { 960 + this.checkDestroyed(); 961 + return this.inner.getTags(); 962 + } 963 + 964 + setTags(tags: string[]): void { 965 + this.checkDestroyed(); 966 + this.inner.setTags(tags); 967 + } 968 + 969 + executeAction(action: EditorAction): void { 970 + this.checkDestroyed(); 971 + this.inner.executeAction(action); 972 + } 973 + 974 + addPendingImage(image: PendingImage, dataUrl: string): void { 975 + this.checkDestroyed(); 976 + this.inner.addPendingImage(image, dataUrl); 977 + this.config.onImageAdd?.(image); 978 + } 979 + 980 + finalizeImage(localId: string, finalized: FinalizedImage, blobRkey: string, identifier: string): void { 981 + this.checkDestroyed(); 982 + this.inner.finalizeImage(localId, finalized, blobRkey, identifier); 983 + } 984 + 985 + removeImage(localId: string): void { 986 + this.checkDestroyed(); 987 + this.inner.removeImage(localId); 988 + } 989 + 990 + getPendingImages(): PendingImage[] { 991 + this.checkDestroyed(); 992 + return this.inner.getPendingImages() as PendingImage[]; 993 + } 994 + 995 + getStagingUris(): string[] { 996 + this.checkDestroyed(); 997 + return this.inner.getStagingUris(); 998 + } 999 + 1000 + addEntryToIndex(title: string, path: string, canonicalUrl: string): void { 1001 + this.checkDestroyed(); 1002 + this.inner.addEntryToIndex(title, path, canonicalUrl); 1003 + } 1004 + 1005 + clearEntryIndex(): void { 1006 + this.checkDestroyed(); 1007 + this.inner.clearEntryIndex(); 1008 + } 1009 + 1010 + getCursorOffset(): number { 1011 + this.checkDestroyed(); 1012 + return this.inner.getCursorOffset(); 1013 + } 1014 + 1015 + setCursorOffset(offset: number): void { 1016 + this.checkDestroyed(); 1017 + this.inner.setCursorOffset(offset); 1018 + } 1019 + 1020 + getLength(): number { 1021 + this.checkDestroyed(); 1022 + return this.inner.getLength(); 1023 + } 1024 + 1025 + canUndo(): boolean { 1026 + this.checkDestroyed(); 1027 + return this.inner.canUndo(); 1028 + } 1029 + 1030 + canRedo(): boolean { 1031 + this.checkDestroyed(); 1032 + return this.inner.canRedo(); 1033 + } 1034 + 1035 + focus(): void { 1036 + this.checkDestroyed(); 1037 + this.inner.focus(); 1038 + } 1039 + 1040 + blur(): void { 1041 + this.checkDestroyed(); 1042 + this.inner.blur(); 1043 + } 1044 + 1045 + getParagraphs(): ParagraphRender[] { 1046 + this.checkDestroyed(); 1047 + return this.inner.getParagraphs() as ParagraphRender[]; 1048 + } 1049 + 1050 + renderAndUpdateDom(): void { 1051 + this.checkDestroyed(); 1052 + this.inner.renderAndUpdateDom(); 1053 + } 1054 + 1055 + // === Remote cursor positioning === 1056 + 1057 + getCursorRectRelative(position: number): CursorRect | null { 1058 + this.checkDestroyed(); 1059 + return this.inner.getCursorRectRelative(position) as CursorRect | null; 1060 + } 1061 + 1062 + getSelectionRectsRelative(start: number, end: number): SelectionRect[] { 1063 + this.checkDestroyed(); 1064 + return this.inner.getSelectionRectsRelative(start, end) as SelectionRect[]; 1065 + } 1066 + 1067 + destroy(): void { 1068 + if (this.destroyed) return; 1069 + this.destroyed = true; 1070 + 1071 + // Stop collab if active (fire and forget) 1072 + if (this.collabStarted && this.workerBridge) { 1073 + this.workerBridge.send({ type: "StopCollab" }); 1074 + if (this.unsubscribeWorker) { 1075 + this.unsubscribeWorker(); 1076 + } 1077 + this.workerBridge.terminate(); 1078 + } 1079 + 1080 + this.detachEventListeners(); 1081 + this.inner.unmount(); 1082 + this.container = null; 1083 + this.editorElement = null; 1084 + this.workerBridge = null; 1085 + } 1086 + 1087 + private checkDestroyed(): void { 1088 + if (this.destroyed) { 1089 + throw new Error("CollabEditor has been destroyed"); 1090 + } 1091 + } 1092 + }
+114
crates/weaver-editor-js/ts/dist/collab.d.ts
··· 1 + /** 2 + * Collaborative editor with Loro CRDT and iroh P2P. 3 + * 4 + * Usage: 5 + * ```typescript 6 + * import { createCollabEditor } from '@weaver.sh/editor-collab'; 7 + * 8 + * const editor = await createCollabEditor({ 9 + * container: document.getElementById('editor')!, 10 + * resourceUri: 'at://did:plc:abc/sh.weaver.notebook.entry/xyz', 11 + * onChange: () => console.log('changed'), 12 + * onSessionNeeded: async (session) => { 13 + * // Create session record on PDS, return URI 14 + * return 'at://did:plc:abc/sh.weaver.edit.session/123'; 15 + * }, 16 + * onPeersNeeded: async (resourceUri) => { 17 + * // Query index/backlinks for peer session records 18 + * return [{ nodeId: 'peer-node-id' }]; 19 + * }, 20 + * }); 21 + * 22 + * // Get Loro snapshot for saving 23 + * const snapshot = editor.exportSnapshot(); 24 + * 25 + * // Cleanup 26 + * await editor.stopCollab(); 27 + * editor.destroy(); 28 + * ``` 29 + */ 30 + import type { CollabEditor, CollabEditorConfig, CursorRect, EventResult, PeerInfo, PresenceSnapshot, Selection, SelectionRect, SessionInfo } from "./types"; 31 + interface JsCollabEditor { 32 + mount(container: HTMLElement, onChange?: () => void): void; 33 + unmount(): void; 34 + isMounted(): boolean; 35 + focus(): void; 36 + blur(): void; 37 + getMarkdown(): string; 38 + getSnapshot(): unknown; 39 + toEntry(): unknown; 40 + setResolvedContent(content: JsResolvedContent): void; 41 + getTitle(): string; 42 + setTitle(title: string): void; 43 + getPath(): string; 44 + setPath(path: string): void; 45 + getTags(): string[]; 46 + setTags(tags: string[]): void; 47 + executeAction(action: unknown): void; 48 + addPendingImage(image: unknown, dataUrl: string): void; 49 + finalizeImage(localId: string, finalized: unknown, blobRkey: string, ident: string): void; 50 + removeImage(localId: string): void; 51 + getPendingImages(): unknown; 52 + getStagingUris(): string[]; 53 + addEntryToIndex(title: string, path: string, canonicalUrl: string): void; 54 + clearEntryIndex(): void; 55 + getCursorOffset(): number; 56 + getSelection(): Selection | null; 57 + setCursorOffset(offset: number): void; 58 + getLength(): number; 59 + canUndo(): boolean; 60 + canRedo(): boolean; 61 + getParagraphs(): unknown; 62 + renderAndUpdateDom(): void; 63 + getCursorRectRelative(position: number): CursorRect | null; 64 + getSelectionRectsRelative(start: number, end: number): SelectionRect[]; 65 + handleBeforeInput(inputType: string, data: string | null, targetStart: number | null, targetEnd: number | null, isComposing: boolean): EventResult; 66 + handleKeydown(key: string, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean): EventResult; 67 + handleKeyup(key: string): void; 68 + handlePaste(text: string): void; 69 + handleCut(): string | null; 70 + handleCopy(): string | null; 71 + handleBlur(): void; 72 + handleCompositionStart(data: string | null): void; 73 + handleCompositionUpdate(data: string | null): void; 74 + handleCompositionEnd(data: string | null): void; 75 + handleAndroidEnter(): void; 76 + syncCursor(): void; 77 + exportSnapshot(): Uint8Array; 78 + exportUpdatesSince(version: Uint8Array): Uint8Array | null; 79 + importUpdates(data: Uint8Array): void; 80 + getVersion(): Uint8Array; 81 + getCollabTopic(): Uint8Array | null; 82 + getResourceUri(): string; 83 + setOnSessionNeeded(callback: (info: SessionInfo) => Promise<string>): void; 84 + setOnSessionRefresh(callback: (uri: string) => Promise<void>): void; 85 + setOnSessionEnd(callback: (uri: string) => Promise<void>): void; 86 + setOnPeersNeeded(callback: (uri: string) => Promise<PeerInfo[]>): void; 87 + setOnPresenceChanged(callback: (presence: PresenceSnapshot) => void): void; 88 + setOnRemoteUpdate(callback: () => void): void; 89 + } 90 + interface JsCollabEditorConstructor { 91 + new (resourceUri: string): JsCollabEditor; 92 + fromMarkdown(resourceUri: string, content: string): JsCollabEditor; 93 + fromSnapshot(resourceUri: string, snapshot: Uint8Array): JsCollabEditor; 94 + } 95 + interface JsResolvedContent { 96 + addEmbed(atUri: string, html: string): void; 97 + } 98 + interface CollabWasmModule { 99 + JsCollabEditor: JsCollabEditorConstructor; 100 + create_resolved_content: () => JsResolvedContent; 101 + } 102 + /** 103 + * Initialize the collab WASM module. 104 + */ 105 + export declare function initCollabWasm(): Promise<CollabWasmModule>; 106 + /** 107 + * Create a new collaborative editor instance. 108 + * 109 + * @param config Editor configuration 110 + * @param workerUrl URL to the editor_worker.js file (default: "/worker/editor_worker.js") 111 + */ 112 + export declare function createCollabEditor(config: CollabEditorConfig, workerUrl?: string): Promise<CollabEditor>; 113 + export {}; 114 + //# sourceMappingURL=collab.d.ts.map
+1
crates/weaver-editor-js/ts/dist/collab.d.ts.map
··· 1 + {"version":3,"file":"collab.d.ts","sourceRoot":"","sources":["../collab.ts"],"names":[],"mappings":"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"}
+784
crates/weaver-editor-js/ts/dist/collab.js
··· 1 + /** 2 + * Collaborative editor with Loro CRDT and iroh P2P. 3 + * 4 + * Usage: 5 + * ```typescript 6 + * import { createCollabEditor } from '@weaver.sh/editor-collab'; 7 + * 8 + * const editor = await createCollabEditor({ 9 + * container: document.getElementById('editor')!, 10 + * resourceUri: 'at://did:plc:abc/sh.weaver.notebook.entry/xyz', 11 + * onChange: () => console.log('changed'), 12 + * onSessionNeeded: async (session) => { 13 + * // Create session record on PDS, return URI 14 + * return 'at://did:plc:abc/sh.weaver.edit.session/123'; 15 + * }, 16 + * onPeersNeeded: async (resourceUri) => { 17 + * // Query index/backlinks for peer session records 18 + * return [{ nodeId: 'peer-node-id' }]; 19 + * }, 20 + * }); 21 + * 22 + * // Get Loro snapshot for saving 23 + * const snapshot = editor.exportSnapshot(); 24 + * 25 + * // Cleanup 26 + * await editor.stopCollab(); 27 + * editor.destroy(); 28 + * ``` 29 + */ 30 + // ============================================================ 31 + // Color utilities 32 + // ============================================================ 33 + /** Convert RGBA u32 (0xRRGGBBAA) to CSS rgba() string. */ 34 + function rgbaToCss(color) { 35 + const r = (color >>> 24) & 0xff; 36 + const g = (color >>> 16) & 0xff; 37 + const b = (color >>> 8) & 0xff; 38 + const a = (color & 0xff) / 255; 39 + return `rgba(${r}, ${g}, ${b}, ${a})`; 40 + } 41 + /** Convert RGBA u32 to CSS rgba() string with custom alpha. */ 42 + function rgbaToCssAlpha(color, alpha) { 43 + const r = (color >>> 24) & 0xff; 44 + const g = (color >>> 16) & 0xff; 45 + const b = (color >>> 8) & 0xff; 46 + return `rgba(${r}, ${g}, ${b}, ${alpha})`; 47 + } 48 + // ============================================================ 49 + // Worker Bridge 50 + // ============================================================ 51 + /** 52 + * Bridge to communicate with the EditorReactor web worker. 53 + * 54 + * The worker handles: 55 + * - CPU-intensive Loro operations off main thread 56 + * - iroh P2P networking for real-time collaboration 57 + */ 58 + class WorkerBridge { 59 + constructor() { 60 + this.worker = null; 61 + this.messageHandlers = []; 62 + this.pendingReady = null; 63 + } 64 + /** 65 + * Spawn the worker. Must be called before any other methods. 66 + * 67 + * @param workerUrl URL to the worker JS file (editor_worker.js) 68 + */ 69 + async spawn(workerUrl) { 70 + if (this.worker) { 71 + throw new Error("Worker already spawned"); 72 + } 73 + return new Promise((resolve, reject) => { 74 + try { 75 + this.worker = new Worker(workerUrl); 76 + this.worker.onmessage = (e) => { 77 + const msg = e.data; 78 + this.handleMessage(msg); 79 + }; 80 + this.worker.onerror = (e) => { 81 + console.error("Worker error:", e); 82 + reject(new Error(`Worker error: ${e.message}`)); 83 + }; 84 + // Wait for Ready message 85 + this.pendingReady = resolve; 86 + } 87 + catch (err) { 88 + reject(err); 89 + } 90 + }); 91 + } 92 + /** 93 + * Send a message to the worker. 94 + */ 95 + send(msg) { 96 + if (!this.worker) { 97 + throw new Error("Worker not spawned"); 98 + } 99 + this.worker.postMessage(msg); 100 + } 101 + /** 102 + * Register a handler for worker messages. 103 + */ 104 + onMessage(handler) { 105 + this.messageHandlers.push(handler); 106 + return () => { 107 + const idx = this.messageHandlers.indexOf(handler); 108 + if (idx >= 0) { 109 + this.messageHandlers.splice(idx, 1); 110 + } 111 + }; 112 + } 113 + /** 114 + * Terminate the worker. 115 + */ 116 + terminate() { 117 + if (this.worker) { 118 + this.worker.terminate(); 119 + this.worker = null; 120 + } 121 + this.messageHandlers = []; 122 + } 123 + handleMessage(msg) { 124 + // Handle Ready specially to resolve spawn promise 125 + if (msg.type === "Ready" && this.pendingReady) { 126 + this.pendingReady(); 127 + this.pendingReady = null; 128 + } 129 + // Dispatch to all handlers 130 + for (const handler of this.messageHandlers) { 131 + try { 132 + handler(msg); 133 + } 134 + catch (err) { 135 + console.error("Error in worker message handler:", err); 136 + } 137 + } 138 + } 139 + } 140 + let wasmModule = null; 141 + /** 142 + * Initialize the collab WASM module. 143 + */ 144 + export async function initCollabWasm() { 145 + if (wasmModule) 146 + return wasmModule; 147 + // The collab module is built separately with the collab feature 148 + const mod = await import("./bundler/weaver_editor.js"); 149 + wasmModule = mod; 150 + return wasmModule; 151 + } 152 + /** 153 + * Create a new collaborative editor instance. 154 + * 155 + * @param config Editor configuration 156 + * @param workerUrl URL to the editor_worker.js file (default: "/worker/editor_worker.js") 157 + */ 158 + export async function createCollabEditor(config, workerUrl = "/worker/editor_worker.js") { 159 + const wasm = await initCollabWasm(); 160 + // Create the inner WASM editor 161 + let inner; 162 + if (config.initialLoroSnapshot) { 163 + inner = wasm.JsCollabEditor.fromSnapshot(config.resourceUri, config.initialLoroSnapshot); 164 + } 165 + else if (config.initialMarkdown) { 166 + inner = wasm.JsCollabEditor.fromMarkdown(config.resourceUri, config.initialMarkdown); 167 + } 168 + else { 169 + inner = new wasm.JsCollabEditor(config.resourceUri); 170 + } 171 + // Set up resolved content if provided 172 + if (config.resolvedContent) { 173 + const resolved = wasm.create_resolved_content(); 174 + for (const [uri, html] of config.resolvedContent.embeds) { 175 + resolved.addEmbed(uri, html); 176 + } 177 + inner.setResolvedContent(resolved); 178 + } 179 + // Create wrapper with worker URL 180 + const editor = new CollabEditorImpl(inner, config, workerUrl); 181 + // Mount to container 182 + editor.mountToContainer(config.container); 183 + return editor; 184 + } 185 + /** 186 + * Internal collab editor implementation. 187 + */ 188 + class CollabEditorImpl { 189 + constructor(inner, config, workerUrl) { 190 + this.container = null; 191 + this.editorElement = null; 192 + this.destroyed = false; 193 + // Worker bridge for P2P collab 194 + this.workerBridge = null; 195 + this.sessionUri = null; 196 + this.collabStarted = false; 197 + this.unsubscribeWorker = null; 198 + this.lastSyncedVersion = null; 199 + this.lastBroadcastCursor = -1; 200 + // Remote cursor overlay 201 + this.currentPresence = null; 202 + this.cursorOverlay = null; 203 + this.inner = inner; 204 + this.config = config; 205 + this.workerUrl = workerUrl; 206 + // Bind event handlers 207 + this.boundHandlers = { 208 + beforeinput: this.onBeforeInput.bind(this), 209 + keydown: this.onKeydown.bind(this), 210 + keyup: this.onKeyup.bind(this), 211 + paste: this.onPaste.bind(this), 212 + cut: this.onCut.bind(this), 213 + copy: this.onCopy.bind(this), 214 + blur: this.onBlur.bind(this), 215 + compositionstart: this.onCompositionStart.bind(this), 216 + compositionupdate: this.onCompositionUpdate.bind(this), 217 + compositionend: this.onCompositionEnd.bind(this), 218 + mouseup: this.onMouseUp.bind(this), 219 + touchend: this.onTouchEnd.bind(this), 220 + }; 221 + } 222 + /** Mount to container and set up event listeners. */ 223 + mountToContainer(container) { 224 + this.container = container; 225 + // Wrap onChange to also sync updates to worker 226 + const wrappedOnChange = () => { 227 + this.syncToWorker(); 228 + this.config.onChange?.(); 229 + // Re-render remote cursors after content changes (positions may shift) 230 + this.renderRemoteCursors(); 231 + }; 232 + this.inner.mount(container, wrappedOnChange); 233 + const editorEl = container.querySelector(".weaver-editor-content"); 234 + if (!editorEl) { 235 + throw new Error("Failed to find editor element after mount"); 236 + } 237 + this.editorElement = editorEl; 238 + this.attachEventListeners(); 239 + // Create remote cursors overlay 240 + this.cursorOverlay = document.createElement("div"); 241 + this.cursorOverlay.className = "remote-cursors-overlay"; 242 + container.appendChild(this.cursorOverlay); 243 + // Initialize synced version 244 + this.lastSyncedVersion = this.inner.getVersion(); 245 + } 246 + /** 247 + * Sync local changes to the worker for broadcast. 248 + */ 249 + syncToWorker() { 250 + if (!this.workerBridge || !this.collabStarted || !this.lastSyncedVersion) { 251 + return; 252 + } 253 + // Export updates since last sync 254 + const updates = this.inner.exportUpdatesSince(this.lastSyncedVersion); 255 + if (updates) { 256 + // Send to worker for broadcast 257 + this.workerBridge.send({ 258 + type: "BroadcastUpdate", 259 + data: Array.from(updates), 260 + }); 261 + // Also send to worker to keep shadow doc in sync 262 + this.workerBridge.send({ 263 + type: "ApplyUpdates", 264 + updates: Array.from(updates), 265 + }); 266 + // Update synced version 267 + this.lastSyncedVersion = this.inner.getVersion(); 268 + } 269 + // Also sync cursor 270 + this.broadcastCursor(); 271 + } 272 + /** 273 + * Render remote collaborator cursors. 274 + */ 275 + renderRemoteCursors() { 276 + if (!this.cursorOverlay || !this.currentPresence) { 277 + return; 278 + } 279 + // Clear existing cursors 280 + this.cursorOverlay.innerHTML = ""; 281 + for (const collab of this.currentPresence.collaborators) { 282 + if (collab.cursorPosition === undefined) { 283 + continue; 284 + } 285 + const rect = this.inner.getCursorRectRelative(collab.cursorPosition); 286 + if (!rect) { 287 + continue; 288 + } 289 + // Convert color to CSS 290 + const colorCss = rgbaToCss(collab.color); 291 + const selectionColorCss = rgbaToCssAlpha(collab.color, 0.25); 292 + // Render selection highlights first (behind cursor) 293 + if (collab.selection) { 294 + const [start, end] = collab.selection; 295 + const [selStart, selEnd] = start <= end ? [start, end] : [end, start]; 296 + const selRects = this.inner.getSelectionRectsRelative(selStart, selEnd); 297 + for (const selRect of selRects) { 298 + const selDiv = document.createElement("div"); 299 + selDiv.className = "remote-selection"; 300 + selDiv.style.cssText = ` 301 + left: ${selRect.x}px; 302 + top: ${selRect.y}px; 303 + width: ${selRect.width}px; 304 + height: ${selRect.height}px; 305 + background-color: ${selectionColorCss}; 306 + `; 307 + this.cursorOverlay.appendChild(selDiv); 308 + } 309 + } 310 + // Create cursor element 311 + const cursorDiv = document.createElement("div"); 312 + cursorDiv.className = "remote-cursor"; 313 + cursorDiv.style.cssText = ` 314 + left: ${rect.x}px; 315 + top: ${rect.y}px; 316 + --cursor-height: ${rect.height}px; 317 + --cursor-color: ${colorCss}; 318 + `; 319 + // Caret line 320 + const caretDiv = document.createElement("div"); 321 + caretDiv.className = "remote-cursor-caret"; 322 + cursorDiv.appendChild(caretDiv); 323 + // Name label 324 + const labelDiv = document.createElement("div"); 325 + labelDiv.className = "remote-cursor-label"; 326 + labelDiv.textContent = collab.displayName; 327 + cursorDiv.appendChild(labelDiv); 328 + this.cursorOverlay.appendChild(cursorDiv); 329 + } 330 + } 331 + /** 332 + * Broadcast cursor position to peers. 333 + */ 334 + broadcastCursor() { 335 + if (!this.workerBridge || !this.collabStarted) { 336 + return; 337 + } 338 + const cursor = this.inner.getCursorOffset(); 339 + const sel = this.inner.getSelection(); 340 + // Only broadcast if cursor changed 341 + if (cursor === this.lastBroadcastCursor && !sel) { 342 + return; 343 + } 344 + this.lastBroadcastCursor = cursor; 345 + this.workerBridge.send({ 346 + type: "BroadcastCursor", 347 + position: cursor, 348 + selection: sel ? [sel.anchor, sel.head] : null, 349 + }); 350 + } 351 + attachEventListeners() { 352 + const el = this.editorElement; 353 + if (!el) 354 + return; 355 + el.addEventListener("beforeinput", this.boundHandlers.beforeinput); 356 + el.addEventListener("keydown", this.boundHandlers.keydown); 357 + el.addEventListener("keyup", this.boundHandlers.keyup); 358 + el.addEventListener("paste", this.boundHandlers.paste); 359 + el.addEventListener("cut", this.boundHandlers.cut); 360 + el.addEventListener("copy", this.boundHandlers.copy); 361 + el.addEventListener("blur", this.boundHandlers.blur); 362 + el.addEventListener("compositionstart", this.boundHandlers.compositionstart); 363 + el.addEventListener("compositionupdate", this.boundHandlers.compositionupdate); 364 + el.addEventListener("compositionend", this.boundHandlers.compositionend); 365 + el.addEventListener("mouseup", this.boundHandlers.mouseup); 366 + el.addEventListener("touchend", this.boundHandlers.touchend); 367 + } 368 + detachEventListeners() { 369 + const el = this.editorElement; 370 + if (!el) 371 + return; 372 + el.removeEventListener("beforeinput", this.boundHandlers.beforeinput); 373 + el.removeEventListener("keydown", this.boundHandlers.keydown); 374 + el.removeEventListener("keyup", this.boundHandlers.keyup); 375 + el.removeEventListener("paste", this.boundHandlers.paste); 376 + el.removeEventListener("cut", this.boundHandlers.cut); 377 + el.removeEventListener("copy", this.boundHandlers.copy); 378 + el.removeEventListener("blur", this.boundHandlers.blur); 379 + el.removeEventListener("compositionstart", this.boundHandlers.compositionstart); 380 + el.removeEventListener("compositionupdate", this.boundHandlers.compositionupdate); 381 + el.removeEventListener("compositionend", this.boundHandlers.compositionend); 382 + el.removeEventListener("mouseup", this.boundHandlers.mouseup); 383 + el.removeEventListener("touchend", this.boundHandlers.touchend); 384 + } 385 + // === Event handlers (same as EditorImpl) === 386 + onBeforeInput(e) { 387 + const inputType = e.inputType; 388 + const data = e.data ?? null; 389 + let targetStart = null; 390 + let targetEnd = null; 391 + const ranges = e.getTargetRanges?.(); 392 + if (ranges && ranges.length > 0) { 393 + const range = ranges[0]; 394 + targetStart = this.domOffsetToChar(range.startContainer, range.startOffset); 395 + targetEnd = this.domOffsetToChar(range.endContainer, range.endOffset); 396 + } 397 + const isComposing = e.isComposing; 398 + const result = this.inner.handleBeforeInput(inputType, data, targetStart, targetEnd, isComposing); 399 + if (result === "Handled" || result === "HandledAsync") { 400 + e.preventDefault(); 401 + } 402 + } 403 + onKeydown(e) { 404 + const result = this.inner.handleKeydown(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey); 405 + if (result === "Handled") { 406 + e.preventDefault(); 407 + } 408 + } 409 + onKeyup(e) { 410 + this.inner.handleKeyup(e.key); 411 + } 412 + onPaste(e) { 413 + e.preventDefault(); 414 + const text = e.clipboardData?.getData("text/plain") ?? ""; 415 + this.inner.handlePaste(text); 416 + } 417 + onCut(e) { 418 + e.preventDefault(); 419 + const text = this.inner.handleCut(); 420 + if (text && e.clipboardData) { 421 + e.clipboardData.setData("text/plain", text); 422 + } 423 + } 424 + onCopy(e) { 425 + e.preventDefault(); 426 + const text = this.inner.handleCopy(); 427 + if (text && e.clipboardData) { 428 + e.clipboardData.setData("text/plain", text); 429 + } 430 + } 431 + onBlur() { 432 + this.inner.handleBlur(); 433 + } 434 + onCompositionStart(e) { 435 + this.inner.handleCompositionStart(e.data ?? null); 436 + } 437 + onCompositionUpdate(e) { 438 + this.inner.handleCompositionUpdate(e.data ?? null); 439 + } 440 + onCompositionEnd(e) { 441 + this.inner.handleCompositionEnd(e.data ?? null); 442 + } 443 + onMouseUp() { 444 + this.inner.syncCursor(); 445 + this.broadcastCursor(); 446 + } 447 + onTouchEnd() { 448 + this.inner.syncCursor(); 449 + this.broadcastCursor(); 450 + } 451 + domOffsetToChar(node, offset) { 452 + const editor = this.editorElement; 453 + if (!editor) 454 + return null; 455 + let charOffset = 0; 456 + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); 457 + let currentNode = walker.nextNode(); 458 + while (currentNode) { 459 + if (currentNode === node) { 460 + return charOffset + offset; 461 + } 462 + charOffset += currentNode.textContent?.length ?? 0; 463 + currentNode = walker.nextNode(); 464 + } 465 + if (node.nodeType === Node.ELEMENT_NODE) { 466 + for (let i = 0; i < offset && i < node.childNodes.length; i++) { 467 + charOffset += node.childNodes[i].textContent?.length ?? 0; 468 + } 469 + return charOffset; 470 + } 471 + return null; 472 + } 473 + // === Loro sync methods === 474 + exportSnapshot() { 475 + this.checkDestroyed(); 476 + return this.inner.exportSnapshot(); 477 + } 478 + exportUpdatesSince(version) { 479 + this.checkDestroyed(); 480 + return this.inner.exportUpdatesSince(version); 481 + } 482 + importUpdates(data) { 483 + this.checkDestroyed(); 484 + this.inner.importUpdates(data); 485 + } 486 + getVersion() { 487 + this.checkDestroyed(); 488 + return this.inner.getVersion(); 489 + } 490 + getCollabTopic() { 491 + this.checkDestroyed(); 492 + return this.inner.getCollabTopic(); 493 + } 494 + getResourceUri() { 495 + this.checkDestroyed(); 496 + return this.inner.getResourceUri(); 497 + } 498 + // === Collab lifecycle === 499 + async startCollab(bootstrapPeers) { 500 + this.checkDestroyed(); 501 + if (this.collabStarted) { 502 + console.warn("Collab already started"); 503 + return; 504 + } 505 + // Spawn worker 506 + this.workerBridge = new WorkerBridge(); 507 + await this.workerBridge.spawn(this.workerUrl); 508 + // Set up message handler 509 + this.unsubscribeWorker = this.workerBridge.onMessage((msg) => { 510 + this.handleWorkerMessage(msg); 511 + }); 512 + // Initialize worker with current Loro snapshot 513 + const snapshot = this.inner.exportSnapshot(); 514 + this.workerBridge.send({ 515 + type: "Init", 516 + snapshot: Array.from(snapshot), 517 + draft_key: this.config.resourceUri, 518 + }); 519 + // Start collab session 520 + const topic = this.inner.getCollabTopic(); 521 + if (!topic) { 522 + throw new Error("No collab topic available"); 523 + } 524 + this.workerBridge.send({ 525 + type: "StartCollab", 526 + topic: Array.from(topic), 527 + bootstrap_peers: bootstrapPeers ?? [], 528 + }); 529 + this.collabStarted = true; 530 + } 531 + async stopCollab() { 532 + this.checkDestroyed(); 533 + if (!this.collabStarted || !this.workerBridge) { 534 + return; 535 + } 536 + // Send stop to worker 537 + this.workerBridge.send({ type: "StopCollab" }); 538 + // Delete session record via callback 539 + if (this.sessionUri && this.config.onSessionEnd) { 540 + try { 541 + await this.config.onSessionEnd(this.sessionUri); 542 + } 543 + catch (err) { 544 + console.error("Failed to delete session record:", err); 545 + } 546 + } 547 + // Clean up 548 + if (this.unsubscribeWorker) { 549 + this.unsubscribeWorker(); 550 + this.unsubscribeWorker = null; 551 + } 552 + this.workerBridge.terminate(); 553 + this.workerBridge = null; 554 + this.sessionUri = null; 555 + this.collabStarted = false; 556 + } 557 + addPeers(nodeIds) { 558 + this.checkDestroyed(); 559 + if (!this.workerBridge || !this.collabStarted) { 560 + console.warn("Cannot add peers - collab not started"); 561 + return; 562 + } 563 + this.workerBridge.send({ 564 + type: "AddPeers", 565 + peers: nodeIds, 566 + }); 567 + } 568 + /** 569 + * Handle messages from the worker. 570 + */ 571 + async handleWorkerMessage(msg) { 572 + switch (msg.type) { 573 + case "CollabReady": { 574 + // Worker has node ID and relay URL, create session record 575 + if (this.config.onSessionNeeded) { 576 + try { 577 + const sessionInfo = { 578 + nodeId: msg.node_id, 579 + relayUrl: msg.relay_url, 580 + }; 581 + this.sessionUri = await this.config.onSessionNeeded(sessionInfo); 582 + // Discover peers now that we have a session 583 + if (this.config.onPeersNeeded) { 584 + const peers = await this.config.onPeersNeeded(this.config.resourceUri); 585 + if (peers.length > 0) { 586 + this.addPeers(peers.map((p) => p.nodeId)); 587 + } 588 + } 589 + } 590 + catch (err) { 591 + console.error("Failed to create session record:", err); 592 + } 593 + } 594 + break; 595 + } 596 + case "CollabJoined": 597 + // Successfully joined the gossip session 598 + break; 599 + case "RemoteUpdates": { 600 + // Apply remote Loro updates to main document 601 + const data = new Uint8Array(msg.data); 602 + this.inner.importUpdates(data); 603 + break; 604 + } 605 + case "PresenceUpdate": { 606 + // Store presence and render remote cursors 607 + const presence = { 608 + collaborators: msg.collaborators, 609 + peerCount: msg.peer_count, 610 + }; 611 + this.currentPresence = presence; 612 + this.renderRemoteCursors(); 613 + // Forward to callback 614 + this.config.onPresenceChanged?.(presence); 615 + break; 616 + } 617 + case "PeerConnected": { 618 + // A new peer connected, send our Join message with user info 619 + if (this.config.onUserInfoNeeded && this.workerBridge) { 620 + try { 621 + const userInfo = await this.config.onUserInfoNeeded(); 622 + this.workerBridge.send({ 623 + type: "BroadcastJoin", 624 + did: userInfo.did, 625 + display_name: userInfo.displayName, 626 + }); 627 + } 628 + catch (err) { 629 + console.error("Failed to get user info for Join:", err); 630 + } 631 + } 632 + break; 633 + } 634 + case "CollabStopped": 635 + // Worker confirmed collab stopped 636 + break; 637 + case "Error": 638 + console.error("Worker error:", msg.message); 639 + break; 640 + case "Ready": 641 + case "Snapshot": 642 + // Handled elsewhere or not needed for collab 643 + break; 644 + } 645 + } 646 + // === Public API (same as Editor) === 647 + getMarkdown() { 648 + this.checkDestroyed(); 649 + return this.inner.getMarkdown(); 650 + } 651 + getSnapshot() { 652 + this.checkDestroyed(); 653 + return this.inner.getSnapshot(); 654 + } 655 + toEntry() { 656 + this.checkDestroyed(); 657 + return this.inner.toEntry(); 658 + } 659 + getTitle() { 660 + this.checkDestroyed(); 661 + return this.inner.getTitle(); 662 + } 663 + setTitle(title) { 664 + this.checkDestroyed(); 665 + this.inner.setTitle(title); 666 + } 667 + getPath() { 668 + this.checkDestroyed(); 669 + return this.inner.getPath(); 670 + } 671 + setPath(path) { 672 + this.checkDestroyed(); 673 + this.inner.setPath(path); 674 + } 675 + getTags() { 676 + this.checkDestroyed(); 677 + return this.inner.getTags(); 678 + } 679 + setTags(tags) { 680 + this.checkDestroyed(); 681 + this.inner.setTags(tags); 682 + } 683 + executeAction(action) { 684 + this.checkDestroyed(); 685 + this.inner.executeAction(action); 686 + } 687 + addPendingImage(image, dataUrl) { 688 + this.checkDestroyed(); 689 + this.inner.addPendingImage(image, dataUrl); 690 + this.config.onImageAdd?.(image); 691 + } 692 + finalizeImage(localId, finalized, blobRkey, identifier) { 693 + this.checkDestroyed(); 694 + this.inner.finalizeImage(localId, finalized, blobRkey, identifier); 695 + } 696 + removeImage(localId) { 697 + this.checkDestroyed(); 698 + this.inner.removeImage(localId); 699 + } 700 + getPendingImages() { 701 + this.checkDestroyed(); 702 + return this.inner.getPendingImages(); 703 + } 704 + getStagingUris() { 705 + this.checkDestroyed(); 706 + return this.inner.getStagingUris(); 707 + } 708 + addEntryToIndex(title, path, canonicalUrl) { 709 + this.checkDestroyed(); 710 + this.inner.addEntryToIndex(title, path, canonicalUrl); 711 + } 712 + clearEntryIndex() { 713 + this.checkDestroyed(); 714 + this.inner.clearEntryIndex(); 715 + } 716 + getCursorOffset() { 717 + this.checkDestroyed(); 718 + return this.inner.getCursorOffset(); 719 + } 720 + setCursorOffset(offset) { 721 + this.checkDestroyed(); 722 + this.inner.setCursorOffset(offset); 723 + } 724 + getLength() { 725 + this.checkDestroyed(); 726 + return this.inner.getLength(); 727 + } 728 + canUndo() { 729 + this.checkDestroyed(); 730 + return this.inner.canUndo(); 731 + } 732 + canRedo() { 733 + this.checkDestroyed(); 734 + return this.inner.canRedo(); 735 + } 736 + focus() { 737 + this.checkDestroyed(); 738 + this.inner.focus(); 739 + } 740 + blur() { 741 + this.checkDestroyed(); 742 + this.inner.blur(); 743 + } 744 + getParagraphs() { 745 + this.checkDestroyed(); 746 + return this.inner.getParagraphs(); 747 + } 748 + renderAndUpdateDom() { 749 + this.checkDestroyed(); 750 + this.inner.renderAndUpdateDom(); 751 + } 752 + // === Remote cursor positioning === 753 + getCursorRectRelative(position) { 754 + this.checkDestroyed(); 755 + return this.inner.getCursorRectRelative(position); 756 + } 757 + getSelectionRectsRelative(start, end) { 758 + this.checkDestroyed(); 759 + return this.inner.getSelectionRectsRelative(start, end); 760 + } 761 + destroy() { 762 + if (this.destroyed) 763 + return; 764 + this.destroyed = true; 765 + // Stop collab if active (fire and forget) 766 + if (this.collabStarted && this.workerBridge) { 767 + this.workerBridge.send({ type: "StopCollab" }); 768 + if (this.unsubscribeWorker) { 769 + this.unsubscribeWorker(); 770 + } 771 + this.workerBridge.terminate(); 772 + } 773 + this.detachEventListeners(); 774 + this.inner.unmount(); 775 + this.container = null; 776 + this.editorElement = null; 777 + this.workerBridge = null; 778 + } 779 + checkDestroyed() { 780 + if (this.destroyed) { 781 + throw new Error("CollabEditor has been destroyed"); 782 + } 783 + } 784 + }
+1
crates/weaver-editor-js/ts/dist/index.d.ts
··· 88 88 * Create a new editor instance. 89 89 */ 90 90 export declare function createEditor(config: EditorConfig): Promise<Editor>; 91 + export { createCollabEditor, initCollabWasm } from "./collab"; 91 92 //# sourceMappingURL=index.d.ts.map
+1 -1
crates/weaver-editor-js/ts/dist/index.d.ts.map
··· 1 - {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"} 1 + {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"}
+2
crates/weaver-editor-js/ts/dist/index.js
··· 358 358 } 359 359 } 360 360 } 361 + // Re-export collab module 362 + export { createCollabEditor, initCollabWasm } from "./collab";
+81
crates/weaver-editor-js/ts/dist/types.d.ts
··· 85 85 contentWarnings?: string[]; 86 86 rating?: string; 87 87 } 88 + /** Selection range in the editor. */ 89 + export interface Selection { 90 + anchor: number; 91 + head: number; 92 + } 93 + /** Cursor rectangle for positioning. */ 94 + export interface CursorRect { 95 + x: number; 96 + y: number; 97 + height: number; 98 + } 99 + /** Selection rectangle for highlighting. */ 100 + export interface SelectionRect { 101 + x: number; 102 + y: number; 103 + width: number; 104 + height: number; 105 + } 88 106 /** Rendered paragraph data. */ 89 107 export interface ParagraphRender { 90 108 id: string; ··· 175 193 export interface ResolvedContent { 176 194 /** Map of AT URI -> rendered HTML. */ 177 195 embeds: Map<string, string>; 196 + } 197 + /** Session info for collab (from worker). */ 198 + export interface SessionInfo { 199 + nodeId: string; 200 + relayUrl: string | null; 201 + } 202 + /** Peer info for collab. */ 203 + export interface PeerInfo { 204 + nodeId: string; 205 + did?: string; 206 + displayName?: string; 207 + } 208 + /** Collaborator presence info. */ 209 + export interface CollaboratorInfo { 210 + nodeId: string; 211 + did: string; 212 + displayName: string; 213 + color: number; 214 + cursorPosition?: number; 215 + selection?: [number, number]; 216 + } 217 + /** Presence state snapshot. */ 218 + export interface PresenceSnapshot { 219 + collaborators: CollaboratorInfo[]; 220 + peerCount: number; 221 + } 222 + /** User info for collab presence. */ 223 + export interface UserInfo { 224 + did: string; 225 + displayName: string; 226 + } 227 + /** Configuration for creating a collab editor. */ 228 + export interface CollabEditorConfig extends EditorConfig { 229 + /** Resource URI (AT URI of entry/draft being edited). */ 230 + resourceUri: string; 231 + /** Initial Loro snapshot bytes (optional). */ 232 + initialLoroSnapshot?: Uint8Array; 233 + /** Called when a session record needs to be created on PDS. */ 234 + onSessionNeeded?: (session: SessionInfo) => Promise<string>; 235 + /** Called to refresh session record periodically. */ 236 + onSessionRefresh?: (sessionUri: string) => Promise<void>; 237 + /** Called when session ends (delete record). */ 238 + onSessionEnd?: (sessionUri: string) => Promise<void>; 239 + /** Called to discover peers from PDS/index. */ 240 + onPeersNeeded?: (resourceUri: string) => Promise<PeerInfo[]>; 241 + /** Called when presence state changes. */ 242 + onPresenceChanged?: (presence: PresenceSnapshot) => void; 243 + /** Called to get current user info for presence announcements. */ 244 + onUserInfoNeeded?: () => Promise<UserInfo>; 245 + } 246 + /** Collab editor interface (extends Editor). */ 247 + export interface CollabEditor extends Editor { 248 + exportSnapshot(): Uint8Array; 249 + exportUpdatesSince(version: Uint8Array): Uint8Array | null; 250 + importUpdates(data: Uint8Array): void; 251 + getVersion(): Uint8Array; 252 + getCollabTopic(): Uint8Array | null; 253 + getResourceUri(): string; 254 + startCollab(bootstrapPeers?: string[]): Promise<void>; 255 + stopCollab(): Promise<void>; 256 + addPeers(nodeIds: string[]): void; 257 + getCursorRectRelative(position: number): CursorRect | null; 258 + getSelectionRectsRelative(start: number, end: number): SelectionRect[]; 178 259 } 179 260 /** Editor interface. */ 180 261 export interface Editor {
+1 -1
crates/weaver-editor-js/ts/dist/types.d.ts.map
··· 1 - {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"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"} 1 + {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"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"}
+3
crates/weaver-editor-js/ts/index.ts
··· 520 520 } 521 521 } 522 522 } 523 + 524 + // Re-export collab module 525 + export { createCollabEditor, initCollabWasm } from "./collab";
+105
crates/weaver-editor-js/ts/types.ts
··· 87 87 rating?: string; 88 88 } 89 89 90 + /** Selection range in the editor. */ 91 + export interface Selection { 92 + anchor: number; 93 + head: number; 94 + } 95 + 96 + /** Cursor rectangle for positioning. */ 97 + export interface CursorRect { 98 + x: number; 99 + y: number; 100 + height: number; 101 + } 102 + 103 + /** Selection rectangle for highlighting. */ 104 + export interface SelectionRect { 105 + x: number; 106 + y: number; 107 + width: number; 108 + height: number; 109 + } 110 + 90 111 /** Rendered paragraph data. */ 91 112 export interface ParagraphRender { 92 113 id: string; ··· 141 162 export interface ResolvedContent { 142 163 /** Map of AT URI -> rendered HTML. */ 143 164 embeds: Map<string, string>; 165 + } 166 + 167 + /** Session info for collab (from worker). */ 168 + export interface SessionInfo { 169 + nodeId: string; 170 + relayUrl: string | null; 171 + } 172 + 173 + /** Peer info for collab. */ 174 + export interface PeerInfo { 175 + nodeId: string; 176 + did?: string; 177 + displayName?: string; 178 + } 179 + 180 + /** Collaborator presence info. */ 181 + export interface CollaboratorInfo { 182 + nodeId: string; 183 + did: string; 184 + displayName: string; 185 + color: number; 186 + cursorPosition?: number; 187 + selection?: [number, number]; 188 + } 189 + 190 + /** Presence state snapshot. */ 191 + export interface PresenceSnapshot { 192 + collaborators: CollaboratorInfo[]; 193 + peerCount: number; 194 + } 195 + 196 + /** User info for collab presence. */ 197 + export interface UserInfo { 198 + did: string; 199 + displayName: string; 200 + } 201 + 202 + /** Configuration for creating a collab editor. */ 203 + export interface CollabEditorConfig extends EditorConfig { 204 + /** Resource URI (AT URI of entry/draft being edited). */ 205 + resourceUri: string; 206 + 207 + /** Initial Loro snapshot bytes (optional). */ 208 + initialLoroSnapshot?: Uint8Array; 209 + 210 + /** Called when a session record needs to be created on PDS. */ 211 + onSessionNeeded?: (session: SessionInfo) => Promise<string>; 212 + 213 + /** Called to refresh session record periodically. */ 214 + onSessionRefresh?: (sessionUri: string) => Promise<void>; 215 + 216 + /** Called when session ends (delete record). */ 217 + onSessionEnd?: (sessionUri: string) => Promise<void>; 218 + 219 + /** Called to discover peers from PDS/index. */ 220 + onPeersNeeded?: (resourceUri: string) => Promise<PeerInfo[]>; 221 + 222 + /** Called when presence state changes. */ 223 + onPresenceChanged?: (presence: PresenceSnapshot) => void; 224 + 225 + /** Called to get current user info for presence announcements. */ 226 + onUserInfoNeeded?: () => Promise<UserInfo>; 227 + } 228 + 229 + /** Collab editor interface (extends Editor). */ 230 + export interface CollabEditor extends Editor { 231 + // Loro sync 232 + exportSnapshot(): Uint8Array; 233 + exportUpdatesSince(version: Uint8Array): Uint8Array | null; 234 + importUpdates(data: Uint8Array): void; 235 + getVersion(): Uint8Array; 236 + 237 + // Collab info 238 + getCollabTopic(): Uint8Array | null; 239 + getResourceUri(): string; 240 + 241 + // Collab lifecycle 242 + startCollab(bootstrapPeers?: string[]): Promise<void>; 243 + stopCollab(): Promise<void>; 244 + addPeers(nodeIds: string[]): void; 245 + 246 + // Remote cursor positioning 247 + getCursorRectRelative(position: number): CursorRect | null; 248 + getSelectionRectsRelative(start: number, end: number): SelectionRect[]; 144 249 } 145 250 146 251 /** Editor interface. */
+48
crates/weaver-editor-js/ts/weaver-editor.css
··· 270 270 margin: 0; 271 271 display: inline; 272 272 } 273 + 274 + /* ========================================================================== 275 + Remote Cursors (Collaborative Editing) 276 + ========================================================================== */ 277 + 278 + .remote-cursors-overlay { 279 + position: absolute; 280 + top: 0; 281 + left: 0; 282 + right: 0; 283 + bottom: 0; 284 + pointer-events: none; 285 + z-index: 10; 286 + } 287 + 288 + .remote-cursor { 289 + position: absolute; 290 + pointer-events: none; 291 + } 292 + 293 + .remote-cursor-caret { 294 + width: 2px; 295 + height: var(--cursor-height, 1.2em); 296 + background-color: var(--cursor-color, #907aa9); 297 + border-radius: 1px; 298 + } 299 + 300 + .remote-cursor-label { 301 + position: absolute; 302 + top: -1.4em; 303 + left: 0; 304 + background-color: var(--cursor-color, #907aa9); 305 + color: white; 306 + font-size: 0.7rem; 307 + font-family: var(--font-heading, system-ui, sans-serif); 308 + padding: 1px 4px; 309 + border-radius: 3px 3px 3px 0; 310 + white-space: nowrap; 311 + max-width: 120px; 312 + overflow: hidden; 313 + text-overflow: ellipsis; 314 + } 315 + 316 + .remote-selection { 317 + position: absolute; 318 + pointer-events: none; 319 + border-radius: 2px; 320 + }
+167 -2
docs/graph-data.json
··· 193 193 "node_type": "goal", 194 194 "title": "Extract editor for external embedding", 195 195 "description": null, 196 - "status": "pending", 196 + "status": "completed", 197 197 "created_at": "2026-01-06T09:31:48.503441901-05:00", 198 - "updated_at": "2026-01-06T09:31:48.503441901-05:00", 198 + "updated_at": "2026-01-07T23:38:42.714996004-05:00", 199 199 "metadata_json": "{\"confidence\":90,\"prompt\":\"Extract the weaver markdown editor into a standalone, embeddable package. Target consumers: external apps (MTG deckbuilder, etc.) via JS/WASM, weaver-app itself (dogfooding, potential framework migration), future native apps via Rust crate. Host app controls auth, blob uploads, collab transport, publishing. Clean crate boundary: core (pure Rust, no web_sys/dioxus/loro), crdt (optional Loro), browser (web_sys DOM layer), js (thin wrapper).\"}" 200 200 }, 201 201 { ··· 2342 2342 "created_at": "2026-01-07T21:16:07.366725128-05:00", 2343 2343 "updated_at": "2026-01-07T21:16:07.366725128-05:00", 2344 2344 "metadata_json": "{\"confidence\":20}" 2345 + }, 2346 + { 2347 + "id": 215, 2348 + "change_id": "272d70eb-369a-472a-ab0c-428307af6fdb", 2349 + "node_type": "goal", 2350 + "title": "weaver-editor-js: JS wrapper crate for embeddable markdown editor", 2351 + "description": null, 2352 + "status": "pending", 2353 + "created_at": "2026-01-07T21:33:34.663844477-05:00", 2354 + "updated_at": "2026-01-07T21:33:34.663844477-05:00", 2355 + "metadata_json": "{\"confidence\":80,\"prompt\":\"User: wrap weaver-editor-core and weaver-editor-browser to produce viable js markdown editor that someone can embed in their app. option to produce actual weaver entry records. doesn't have to have draft sync/crdt features initially, but good to add later.\"}" 2356 + }, 2357 + { 2358 + "id": 216, 2359 + "change_id": "ff9e4eb6-8e48-4100-9347-d56f43fc2f2c", 2360 + "node_type": "action", 2361 + "title": "Scaffold weaver-editor-js crate with WASM bindings and build infrastructure", 2362 + "description": null, 2363 + "status": "pending", 2364 + "created_at": "2026-01-07T21:38:46.201816829-05:00", 2365 + "updated_at": "2026-01-07T21:38:46.201816829-05:00", 2366 + "metadata_json": "{\"confidence\":85}" 2367 + }, 2368 + { 2369 + "id": 217, 2370 + "change_id": "9fe541b0-6ef1-45ed-b7ec-899cfff592fa", 2371 + "node_type": "action", 2372 + "title": "Implementing weaver-editor-js collab bindings", 2373 + "description": null, 2374 + "status": "pending", 2375 + "created_at": "2026-01-07T23:00:57.453624229-05:00", 2376 + "updated_at": "2026-01-07T23:00:57.453624229-05:00", 2377 + "metadata_json": "{\"confidence\":85,\"prompt\":\"User requested: add collab feature to weaver-editor-js package\"}" 2378 + }, 2379 + { 2380 + "id": 218, 2381 + "change_id": "3a37d12b-515d-489b-bada-5b68031d06ab", 2382 + "node_type": "outcome", 2383 + "title": "Collab feature implemented: JsCollabEditor with LoroTextBuffer, TypeScript wrapper, build.sh updated", 2384 + "description": null, 2385 + "status": "pending", 2386 + "created_at": "2026-01-07T23:13:44.341644498-05:00", 2387 + "updated_at": "2026-01-07T23:13:44.341644498-05:00", 2388 + "metadata_json": "{\"confidence\":95}" 2389 + }, 2390 + { 2391 + "id": 219, 2392 + "change_id": "0d728b4f-f6d0-4d9b-bef7-f25efde4ba3c", 2393 + "node_type": "outcome", 2394 + "title": "weaver-editor-js complete - WASM + TypeScript bindings with collab support", 2395 + "description": null, 2396 + "status": "completed", 2397 + "created_at": "2026-01-07T23:38:17.410248547-05:00", 2398 + "updated_at": "2026-01-07T23:38:47.052805556-05:00", 2399 + "metadata_json": "{\"confidence\":95}" 2400 + }, 2401 + { 2402 + "id": 220, 2403 + "change_id": "c644aef5-81e5-425e-8c68-ded06421a00c", 2404 + "node_type": "action", 2405 + "title": "Implemented WorkerBridge for EditorReactor P2P communication", 2406 + "description": null, 2407 + "status": "completed", 2408 + "created_at": "2026-01-07T23:38:17.429184305-05:00", 2409 + "updated_at": "2026-01-07T23:38:47.069726089-05:00", 2410 + "metadata_json": "{\"confidence\":95}" 2411 + }, 2412 + { 2413 + "id": 221, 2414 + "change_id": "7d545b61-3931-424a-843d-de9a94a02c76", 2415 + "node_type": "action", 2416 + "title": "Implemented remote cursor overlay with presence rendering", 2417 + "description": null, 2418 + "status": "completed", 2419 + "created_at": "2026-01-07T23:38:17.572928071-05:00", 2420 + "updated_at": "2026-01-07T23:38:47.085561079-05:00", 2421 + "metadata_json": "{\"confidence\":95}" 2422 + }, 2423 + { 2424 + "id": 222, 2425 + "change_id": "4f9d8d6e-7314-4469-9335-ce45cabaef3a", 2426 + "node_type": "observation", 2427 + "title": "Next step: atcute integration for ergonomic API (callbacks → automatic session/peer discovery)", 2428 + "description": null, 2429 + "status": "pending", 2430 + "created_at": "2026-01-07T23:38:17.591489488-05:00", 2431 + "updated_at": "2026-01-07T23:38:17.591489488-05:00", 2432 + "metadata_json": "{\"confidence\":80}" 2345 2433 } 2346 2434 ], 2347 2435 "edges": [ ··· 4643 4731 "weight": 1.0, 4644 4732 "rationale": "Fixed overflow", 4645 4733 "created_at": "2026-01-07T21:16:32.861314215-05:00" 4734 + }, 4735 + { 4736 + "id": 211, 4737 + "from_node_id": 18, 4738 + "to_node_id": 215, 4739 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 4740 + "to_change_id": "272d70eb-369a-472a-ab0c-428307af6fdb", 4741 + "edge_type": "leads_to", 4742 + "weight": 1.0, 4743 + "rationale": "JS wrapper is how we extract editor for external embedding", 4744 + "created_at": "2026-01-07T21:33:39.411164620-05:00" 4745 + }, 4746 + { 4747 + "id": 212, 4748 + "from_node_id": 18, 4749 + "to_node_id": 217, 4750 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 4751 + "to_change_id": "9fe541b0-6ef1-45ed-b7ec-899cfff592fa", 4752 + "edge_type": "leads_to", 4753 + "weight": 1.0, 4754 + "rationale": "Collab feature for JS editor package", 4755 + "created_at": "2026-01-07T23:01:02.947287990-05:00" 4756 + }, 4757 + { 4758 + "id": 213, 4759 + "from_node_id": 217, 4760 + "to_node_id": 218, 4761 + "from_change_id": "9fe541b0-6ef1-45ed-b7ec-899cfff592fa", 4762 + "to_change_id": "3a37d12b-515d-489b-bada-5b68031d06ab", 4763 + "edge_type": "leads_to", 4764 + "weight": 1.0, 4765 + "rationale": "Implementation completed successfully", 4766 + "created_at": "2026-01-07T23:13:47.972988882-05:00" 4767 + }, 4768 + { 4769 + "id": 214, 4770 + "from_node_id": 18, 4771 + "to_node_id": 219, 4772 + "from_change_id": "fa554b5d-8af7-42e4-b03f-e5bec837e31a", 4773 + "to_change_id": "0d728b4f-f6d0-4d9b-bef7-f25efde4ba3c", 4774 + "edge_type": "leads_to", 4775 + "weight": 1.0, 4776 + "rationale": "Goal achieved - editor now embeddable via npm package", 4777 + "created_at": "2026-01-07T23:38:25.139758976-05:00" 4778 + }, 4779 + { 4780 + "id": 215, 4781 + "from_node_id": 55, 4782 + "to_node_id": 220, 4783 + "from_change_id": "77a50102-1dc0-4009-9715-5c8644745be1", 4784 + "to_change_id": "c644aef5-81e5-425e-8c68-ded06421a00c", 4785 + "edge_type": "leads_to", 4786 + "weight": 1.0, 4787 + "rationale": "Worker design realized in TypeScript WorkerBridge", 4788 + "created_at": "2026-01-07T23:38:25.157249447-05:00" 4789 + }, 4790 + { 4791 + "id": 216, 4792 + "from_node_id": 77, 4793 + "to_node_id": 221, 4794 + "from_change_id": "ef6e7454-2d97-4fe9-97a8-76c2486cf076", 4795 + "to_change_id": "7d545b61-3931-424a-843d-de9a94a02c76", 4796 + "edge_type": "leads_to", 4797 + "weight": 1.0, 4798 + "rationale": "P2P collab now includes cursor/presence rendering", 4799 + "created_at": "2026-01-07T23:38:25.191178058-05:00" 4800 + }, 4801 + { 4802 + "id": 217, 4803 + "from_node_id": 219, 4804 + "to_node_id": 222, 4805 + "from_change_id": "0d728b4f-f6d0-4d9b-bef7-f25efde4ba3c", 4806 + "to_change_id": "4f9d8d6e-7314-4469-9335-ce45cabaef3a", 4807 + "edge_type": "leads_to", 4808 + "weight": 1.0, 4809 + "rationale": "Identifies next enhancement for editor-js", 4810 + "created_at": "2026-01-07T23:38:25.207053545-05:00" 4646 4811 } 4647 4812 ] 4648 4813 }