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