@datalayer/lexical-loro 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/Editor.js +3 -2
- package/lib/appSettings.js +1 -1
- package/lib/collab/loro/Bindings.js +7 -7
- package/lib/collab/loro/Debug.js +35 -35
- package/lib/collab/loro/LexicalCollaborationContext.js +1 -1
- package/lib/collab/loro/LexicalCollaborationPlugin.d.ts +6 -1
- package/lib/collab/loro/LexicalCollaborationPlugin.js +41 -13
- package/lib/collab/loro/State.js +1 -1
- package/lib/collab/loro/components/LoroCollaborators.js +7 -19
- package/lib/collab/loro/components/index.js +1 -1
- package/lib/collab/loro/index.js +1 -1
- package/lib/collab/loro/integrators/BaseIntegrator.js +1 -1
- package/lib/collab/loro/integrators/CounterIntegrator.js +1 -1
- package/lib/collab/loro/integrators/ListIntegrator.js +4 -4
- package/lib/collab/loro/integrators/MapIntegrator.js +4 -4
- package/lib/collab/loro/integrators/TextIntegrator.js +2 -2
- package/lib/collab/loro/integrators/TreeIntegrator.d.ts +5 -0
- package/lib/collab/loro/integrators/TreeIntegrator.js +184 -142
- package/lib/collab/loro/nodes/NodeFactory.js +26 -47
- package/lib/collab/loro/nodes/NodesMapper.js +19 -7
- package/lib/collab/loro/propagators/DecoratorNodePropagator.js +1 -1
- package/lib/collab/loro/propagators/ElementNodePropagator.d.ts +1 -1
- package/lib/collab/loro/propagators/ElementNodePropagator.js +102 -35
- package/lib/collab/loro/propagators/LineBreakNodePropagator.js +1 -1
- package/lib/collab/loro/propagators/RootNodePropagator.js +1 -1
- package/lib/collab/loro/propagators/TextNodePropagator.js +55 -45
- package/lib/collab/loro/propagators/index.d.ts +1 -1
- package/lib/collab/loro/propagators/index.js +1 -1
- package/lib/collab/loro/provider/websocket.d.ts +1 -0
- package/lib/collab/loro/provider/websocket.js +47 -12
- package/lib/collab/loro/servers/index.js +1 -1
- package/lib/collab/loro/servers/ws/callback.js +1 -1
- package/lib/collab/loro/servers/ws/server.js +1 -1
- package/lib/collab/loro/servers/ws/utils.js +35 -35
- package/lib/collab/loro/sync/SyncCursors.js +42 -16
- package/lib/collab/loro/sync/SyncLexicalToLoro.js +76 -37
- package/lib/collab/loro/sync/SyncLoroToLexical.js +32 -26
- package/lib/collab/loro/types/LexicalNodeData.js +1 -1
- package/lib/collab/loro/useCollaboration.js +173 -28
- package/lib/collab/loro/utils/InitialContent.js +1 -1
- package/lib/collab/loro/utils/Invariant.d.ts +15 -0
- package/lib/collab/loro/utils/Invariant.js +24 -0
- package/lib/collab/loro/utils/LexicalToLoro.js +1 -1
- package/lib/collab/loro/utils/Utils.js +1 -1
- package/lib/collab/loro/wsProvider.js +21 -4
- package/lib/collab/utils/invariant.js +1 -1
- package/lib/collab/utils/simpleDiffWithCursor.js +1 -1
- package/lib/commenting/index.js +1 -1
- package/lib/demo.js +1 -1
- package/lib/hooks/useFlashMessage.js +1 -1
- package/lib/hooks/useReport.js +1 -1
- package/lib/index.js +1 -1
- package/lib/nodes/AutocompleteNode.js +1 -1
- package/lib/nodes/EmojiNode.js +1 -1
- package/lib/nodes/KeywordNode.js +1 -1
- package/lib/nodes/LayoutContainerNode.js +1 -1
- package/lib/nodes/LayoutItemNode.js +1 -1
- package/lib/nodes/MentionNode.js +1 -1
- package/lib/nodes/PlaygroundNodes.js +1 -1
- package/lib/nodes/SpecialTextNode.js +1 -1
- package/lib/plugins/AutocompletePlugin/index.js +1 -1
- package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.js +1 -1
- package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.js +1 -1
- package/lib/plugins/CodeActionMenuPlugin/utils.js +1 -1
- package/lib/plugins/CodeHighlightPrismPlugin/index.js +1 -1
- package/lib/plugins/CodeHighlightShikiPlugin/index.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.js +1 -1
- package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.js +1 -1
- package/lib/plugins/CollapsiblePlugin/index.js +1 -1
- package/lib/plugins/CounterPlugin/index.js +1 -1
- package/lib/plugins/DateTimePlugin/index.js +1 -1
- package/lib/plugins/DebugPlugin/index.js +59 -3
- package/lib/plugins/DragDropPastePlugin/index.js +1 -1
- package/lib/plugins/EmojiPickerPlugin/index.js +1 -1
- package/lib/plugins/EmojisPlugin/index.js +1 -1
- package/lib/plugins/FigmaPlugin/index.js +1 -1
- package/lib/plugins/KeywordsPlugin/index.js +1 -1
- package/lib/plugins/LayoutPlugin/LayoutPlugin.js +1 -1
- package/lib/plugins/MarkdownTransformers/index.js +1 -1
- package/lib/plugins/MaxLengthPlugin/index.js +1 -1
- package/lib/plugins/PageBreakPlugin/index.js +1 -1
- package/lib/plugins/ShortcutsPlugin/index.js +1 -1
- package/lib/plugins/ShortcutsPlugin/shortcuts.js +1 -1
- package/lib/plugins/SpecialTextPlugin/index.js +1 -1
- package/lib/plugins/SpeechToTextPlugin/index.js +1 -1
- package/lib/plugins/StickyPlugin/index.js +1 -1
- package/lib/plugins/TabFocusPlugin/index.js +1 -1
- package/lib/plugins/ToolbarPlugin/fontSize.js +1 -1
- package/lib/plugins/ToolbarPlugin/utils.js +1 -1
- package/lib/plugins/TwitterPlugin/index.js +1 -1
- package/lib/plugins/TypingPerfPlugin/index.js +1 -1
- package/lib/plugins/YouTubePlugin/index.js +1 -1
- package/lib/server/validation.js +1 -1
- package/lib/setupEnv.js +1 -1
- package/lib/themes/CommentEditorTheme.js +1 -1
- package/lib/themes/PlaygroundEditorTheme.js +1 -1
- package/lib/themes/StickyEditorTheme.js +1 -1
- package/lib/tyes.dt.js +1 -1
- package/lib/utils/docSerialization.js +1 -1
- package/lib/utils/emoji-list.js +1 -1
- package/lib/utils/getDOMRangeRect.js +1 -1
- package/lib/utils/getSelectedNode.js +1 -1
- package/lib/utils/getThemeSelector.js +1 -1
- package/lib/utils/isMobileWidth.js +1 -1
- package/lib/utils/joinClasses.js +1 -1
- package/lib/utils/setFloatingElemPosition.js +1 -1
- package/lib/utils/setFloatingElemPositionForLinkEditor.js +1 -1
- package/lib/utils/swipe.js +1 -1
- package/lib/utils/url.js +1 -1
- package/package.json +4 -4
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/*
|
|
3
|
-
* Copyright (c)
|
|
3
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
4
4
|
* Distributed under the terms of the MIT License.
|
|
5
5
|
*/
|
|
6
6
|
import * as React from 'react';
|
|
7
7
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
8
8
|
import { createPortal } from 'react-dom';
|
|
9
9
|
import { mergeRegister } from '@lexical/utils';
|
|
10
|
-
import { $getRoot, BLUR_COMMAND, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND, HISTORY_MERGE_TAG, REDO_COMMAND, SKIP_COLLAB_TAG, UNDO_COMMAND, } from 'lexical';
|
|
10
|
+
import { $getNodeByKey, $getRoot, $parseSerializedNode, $setSelection, BLUR_COMMAND, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_EDITOR, FOCUS_COMMAND, HISTORY_MERGE_TAG, REDO_COMMAND, SKIP_COLLAB_TAG, UNDO_COMMAND, } from 'lexical';
|
|
11
11
|
import { CONNECTED_COMMAND, createUndoManager, initLocalState, setLocalStateFocus, TOGGLE_CONNECT_COMMAND, } from './State';
|
|
12
12
|
import { syncLexicalToLoro } from './sync/SyncLexicalToLoro';
|
|
13
13
|
import { syncLoroToLexical } from './sync/SyncLoroToLexical';
|
|
@@ -29,6 +29,7 @@ export function useCollaboration(editor, id, provider, docMap, name, color, shou
|
|
|
29
29
|
editor.dispatchCommand(CONNECTED_COMMAND, status === 'connected');
|
|
30
30
|
};
|
|
31
31
|
const onSync = (isSynced) => {
|
|
32
|
+
console.log('[SEED-DEBUG] onSync: isSynced=', isSynced, 'shouldBootstrap=', shouldBootstrap, 'isReloadingDoc=', isReloadingDoc.current);
|
|
32
33
|
if (shouldBootstrap &&
|
|
33
34
|
isSynced &&
|
|
34
35
|
isReloadingDoc.current === false) {
|
|
@@ -67,7 +68,41 @@ export function useCollaboration(editor, id, provider, docMap, name, color, shou
|
|
|
67
68
|
};
|
|
68
69
|
// This updates the local editor state when we receive updates from other clients.
|
|
69
70
|
const unsubscribe = binding.doc.subscribe(onLoroUpdates);
|
|
71
|
+
// Lexical only includes a node type in `update.mutatedNodes` when that type
|
|
72
|
+
// has at least one registered mutation listener. Our Lexical→Loro sync
|
|
73
|
+
// relies entirely on `mutatedNodes`, so any node type without a listener is
|
|
74
|
+
// invisible to collaboration. In particular, the bootstrap seed runs before
|
|
75
|
+
// other plugins mount and register their own mutation listeners, so its
|
|
76
|
+
// commits produced an empty `mutatedNodes` and never propagated to peers.
|
|
77
|
+
// Register a no-op mutation listener for every node type registered on this
|
|
78
|
+
// editor so `mutatedNodes` is always fully populated (this mirrors how the
|
|
79
|
+
// Yjs binding stays independent of other plugins).
|
|
80
|
+
const mutationListenerCleanups = [];
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
+
const registeredNodes = editor._nodes;
|
|
83
|
+
console.log('[SEED-DEBUG] registering mutation listeners; registeredNodes=', registeredNodes ? registeredNodes.size : 'undefined');
|
|
84
|
+
if (registeredNodes) {
|
|
85
|
+
registeredNodes.forEach((registered) => {
|
|
86
|
+
if (registered && registered.klass) {
|
|
87
|
+
mutationListenerCleanups.push(
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
89
|
+
editor.registerMutationListener(registered.klass, () => { }, {
|
|
90
|
+
skipInitialization: true,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
console.log('[SEED-DEBUG] mutation listeners registered=', mutationListenerCleanups.length, 'editor._listeners.mutation.size=',
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
97
|
+
editor._listeners?.mutation?.size);
|
|
70
98
|
const removeListener = editor.registerUpdateListener((update) => {
|
|
99
|
+
let __mn = 0;
|
|
100
|
+
if (update.mutatedNodes) {
|
|
101
|
+
update.mutatedNodes.forEach((m) => {
|
|
102
|
+
__mn += m.size;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
console.log('[SEED-DEBUG] updateListener fired; tags=', Array.from(update.tags), 'mutatedNodes=', update.mutatedNodes ? 'map' : 'null', 'count=', __mn, 'dirtyElements=', update.dirtyElements?.size, 'dirtyLeaves=', update.dirtyLeaves?.size);
|
|
71
106
|
if (update.tags.has(SKIP_COLLAB_TAG) === false) {
|
|
72
107
|
syncLexicalToLoro(binding, provider, update);
|
|
73
108
|
}
|
|
@@ -97,6 +132,7 @@ export function useCollaboration(editor, id, provider, docMap, name, color, shou
|
|
|
97
132
|
unsubscribe?.();
|
|
98
133
|
docMap.delete(id);
|
|
99
134
|
removeListener();
|
|
135
|
+
mutationListenerCleanups.forEach((cleanup) => cleanup());
|
|
100
136
|
};
|
|
101
137
|
}, [
|
|
102
138
|
binding,
|
|
@@ -193,34 +229,143 @@ export function useHistory(editor, binding) {
|
|
|
193
229
|
return clearHistory;
|
|
194
230
|
}
|
|
195
231
|
function initializeEditor(editor, initialEditorState) {
|
|
196
|
-
|
|
232
|
+
// Text used by the server-side default scaffold that fresh rooms are
|
|
233
|
+
// pre-seeded with. When a room only contains this scaffold it should be
|
|
234
|
+
// treated as empty so the host app's real initial content can replace it.
|
|
235
|
+
const PLACEHOLDER_TEXTS = new Set([
|
|
236
|
+
'Lexical with Loro',
|
|
237
|
+
'Welcome to Lexical with Loro',
|
|
238
|
+
'Type something...',
|
|
239
|
+
]);
|
|
240
|
+
const hasMeaningfulContent = () => {
|
|
197
241
|
const root = $getRoot();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
const children = root.getChildren();
|
|
243
|
+
if (children.length === 0) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
for (const child of children) {
|
|
247
|
+
const type = child.getType();
|
|
248
|
+
const text = child.getTextContent().trim();
|
|
249
|
+
if (text.length > 0) {
|
|
250
|
+
// Ignore the known default scaffold text so it can be overwritten by
|
|
251
|
+
// the host app's initial content on a freshly created room.
|
|
252
|
+
if (!PLACEHOLDER_TEXTS.has(text)) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// Ignore known scaffold placeholders used by some host apps before
|
|
258
|
+
// collaboration bootstrap fills the real initial document.
|
|
259
|
+
if (type !== 'paragraph' && type !== 'jupyter-output') {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false;
|
|
264
|
+
};
|
|
265
|
+
// Only seed a room whose sole content is the (empty/scaffold) default.
|
|
266
|
+
let shouldSeed = false;
|
|
267
|
+
editor.getEditorState().read(() => {
|
|
268
|
+
shouldSeed = !hasMeaningfulContent();
|
|
269
|
+
});
|
|
270
|
+
console.log('[SEED-DEBUG] initializeEditor: shouldSeed=', shouldSeed, 'hasInitialState=', !!initialEditorState, 'type=', typeof initialEditorState);
|
|
271
|
+
if (!shouldSeed || !initialEditorState) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Preferred path: a serialized editor state (string). Seed it INCREMENTALLY,
|
|
275
|
+
// exactly as a human collaborator would build the document — append each
|
|
276
|
+
// top-level block in its own commit, then remove the scaffold blocks one by
|
|
277
|
+
// one. Each small, well-formed commit produces clean per-node "created"
|
|
278
|
+
// mutations that the Loro binding propagates reliably. A single bulk
|
|
279
|
+
// clear()+append emitted one large, malformed batch that failed to integrate
|
|
280
|
+
// on remote peers (inline nodes arriving without a resolved parent), which is
|
|
281
|
+
// why the second editor never received the initial content.
|
|
282
|
+
if (typeof initialEditorState === 'string') {
|
|
283
|
+
let serializedChildren = [];
|
|
284
|
+
try {
|
|
285
|
+
const serialized = JSON.parse(initialEditorState);
|
|
286
|
+
serializedChildren = serialized?.root?.children ?? [];
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
// Not a rebuildable serialized state → fall back to a whole-state swap.
|
|
290
|
+
editor.update(() => {
|
|
291
|
+
editor.setEditorState(editor.parseEditorState(initialEditorState), {
|
|
292
|
+
tag: HISTORY_MERGE_TAG,
|
|
293
|
+
});
|
|
294
|
+
}, { tag: HISTORY_MERGE_TAG });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// IMPORTANT: every seed update below is committed with `discrete: true`.
|
|
298
|
+
// The remote snapshot import runs `editor.update({tag: SKIP_COLLAB_TAG})`
|
|
299
|
+
// and that update is still queued when this bootstrap runs. Without
|
|
300
|
+
// `discrete`, Lexical coalesces the queued remote update together with our
|
|
301
|
+
// seed updates into a single reconciliation and unions their tags, so the
|
|
302
|
+
// seed batch inherits `skip-collab` and the collab sync ignores it. A
|
|
303
|
+
// leading discrete no-op flushes the pending remote (skip-collab) update on
|
|
304
|
+
// its own, and each discrete seed update then commits as its own
|
|
305
|
+
// reconciliation carrying only `history-merge`, so it propagates to peers.
|
|
306
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
307
|
+
editor.update(() => { }, { tag: HISTORY_MERGE_TAG, discrete: true });
|
|
308
|
+
// Clear any pending selection first: it may point at scaffold nodes we are
|
|
309
|
+
// about to remove, which would otherwise crash selection-reading plugins
|
|
310
|
+
// (e.g. the typeahead menu).
|
|
311
|
+
editor.update(() => {
|
|
312
|
+
$setSelection(null);
|
|
313
|
+
}, { tag: HISTORY_MERGE_TAG, discrete: true });
|
|
314
|
+
// Capture the scaffold blocks AFTER the leading discrete flush, so any
|
|
315
|
+
// late-arriving remote scaffold nodes are included. We remove them BEFORE
|
|
316
|
+
// appending the real content: the scaffold's position in the Loro tree does
|
|
317
|
+
// not necessarily line up with its Lexical index, so appending after it
|
|
318
|
+
// makes the computed Loro insertion index drift out of range. Removing the
|
|
319
|
+
// scaffold first leaves both trees at an aligned empty root, after which
|
|
320
|
+
// each appended block sits at a matching index in Lexical and Loro.
|
|
321
|
+
let scaffoldKeys = [];
|
|
322
|
+
editor.getEditorState().read(() => {
|
|
323
|
+
scaffoldKeys = $getRoot()
|
|
324
|
+
.getChildren()
|
|
325
|
+
.map(child => child.getKey());
|
|
326
|
+
});
|
|
327
|
+
console.log('[SEED-DEBUG] initializeEditor: seeding', serializedChildren.length, 'blocks, removing', scaffoldKeys.length, 'scaffold blocks first');
|
|
328
|
+
// Remove the scaffold blocks one by one so both trees reach an aligned
|
|
329
|
+
// empty root before we append the real content.
|
|
330
|
+
for (const key of scaffoldKeys) {
|
|
331
|
+
editor.update(() => {
|
|
332
|
+
const node = $getNodeByKey(key);
|
|
333
|
+
if (node) {
|
|
334
|
+
node.remove();
|
|
223
335
|
}
|
|
336
|
+
}, { tag: HISTORY_MERGE_TAG, discrete: true });
|
|
337
|
+
}
|
|
338
|
+
// Append the real content block by block into the now-empty, aligned root
|
|
339
|
+
// (each commit ≈ one collaborator action).
|
|
340
|
+
let __blockIndex = 0;
|
|
341
|
+
for (const serializedNode of serializedChildren) {
|
|
342
|
+
const __idx = __blockIndex++;
|
|
343
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
344
|
+
const __type = serializedNode?.type;
|
|
345
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
346
|
+
const __childTypes = Array.isArray(serializedNode?.children)
|
|
347
|
+
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
348
|
+
serializedNode.children.map((c) => c?.type)
|
|
349
|
+
: [];
|
|
350
|
+
editor.update(() => {
|
|
351
|
+
const root = $getRoot();
|
|
352
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
353
|
+
root.append($parseSerializedNode(serializedNode));
|
|
354
|
+
}, { tag: HISTORY_MERGE_TAG, discrete: true });
|
|
355
|
+
console.log('[SEED-DEBUG] appended block', __idx, 'type=', __type, 'children=', __childTypes);
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// Non-string fallbacks (EditorState object or builder function).
|
|
360
|
+
editor.update(() => {
|
|
361
|
+
if (!hasMeaningfulContent()) {
|
|
362
|
+
if (typeof initialEditorState === 'object') {
|
|
363
|
+
editor.setEditorState(initialEditorState, {
|
|
364
|
+
tag: HISTORY_MERGE_TAG,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
else if (typeof initialEditorState === 'function') {
|
|
368
|
+
initialEditorState(editor);
|
|
224
369
|
}
|
|
225
370
|
}
|
|
226
371
|
}, { tag: HISTORY_MERGE_TAG });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assert a collaboration invariant.
|
|
3
|
+
*
|
|
4
|
+
* The Loro <-> Lexical sync engine relies on a set of structural invariants
|
|
5
|
+
* (a node's parent is already mapped, an insertion index is within range, a
|
|
6
|
+
* text node always has a parent, ...). When one of these does not hold the
|
|
7
|
+
* engine is in a state it cannot resolve correctly.
|
|
8
|
+
*
|
|
9
|
+
* Historically such cases were "handled" by logging a warning and skipping the
|
|
10
|
+
* operation (or clamping an index). That silently corrupts the shared document
|
|
11
|
+
* and hides the underlying bug. Instead we throw: the failure surfaces
|
|
12
|
+
* immediately, the surrounding `editor.update()` rolls back cleanly, and the
|
|
13
|
+
* root cause can be found and fixed rather than masked.
|
|
14
|
+
*/
|
|
15
|
+
export declare function invariant(condition: unknown, message: string, context?: Record<string, unknown>): asserts condition;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Assert a collaboration invariant.
|
|
7
|
+
*
|
|
8
|
+
* The Loro <-> Lexical sync engine relies on a set of structural invariants
|
|
9
|
+
* (a node's parent is already mapped, an insertion index is within range, a
|
|
10
|
+
* text node always has a parent, ...). When one of these does not hold the
|
|
11
|
+
* engine is in a state it cannot resolve correctly.
|
|
12
|
+
*
|
|
13
|
+
* Historically such cases were "handled" by logging a warning and skipping the
|
|
14
|
+
* operation (or clamping an index). That silently corrupts the shared document
|
|
15
|
+
* and hides the underlying bug. Instead we throw: the failure surfaces
|
|
16
|
+
* immediately, the surrounding `editor.update()` rolls back cleanly, and the
|
|
17
|
+
* root cause can be found and fixed rather than masked.
|
|
18
|
+
*/
|
|
19
|
+
export function invariant(condition, message, context) {
|
|
20
|
+
if (!condition) {
|
|
21
|
+
const details = context ? ` ${JSON.stringify(context)}` : '';
|
|
22
|
+
throw new Error(`[loro-collab] Invariant failed: ${message}${details}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Copyright (c)
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the MIT License.
|
|
4
4
|
*/
|
|
5
5
|
import { LoroDoc } from 'loro-crdt';
|
|
@@ -19,17 +19,34 @@ export function createWebsocketProvider(id, docMap, websocketUrl) {
|
|
|
19
19
|
docMap.set(id, doc);
|
|
20
20
|
}
|
|
21
21
|
const providerInstanceId = Math.random().toString(36).substr(2, 9);
|
|
22
|
-
console.log(
|
|
22
|
+
console.log(` Creating WebsocketProvider instance (ID: ${providerInstanceId}) for docId: ${id}`);
|
|
23
23
|
// Use provided websocketUrl or fallback to URL parameters/defaults
|
|
24
|
-
const
|
|
24
|
+
const rawWebsocketUrl = websocketUrl || (() => {
|
|
25
25
|
const url = new URL(window.location.href);
|
|
26
26
|
const params = new URLSearchParams(url.search);
|
|
27
27
|
return params.get('collabEndpoint') || 'ws://localhost:3002';
|
|
28
28
|
})();
|
|
29
|
+
// The WebsocketProvider builds the connection URL as
|
|
30
|
+
// `serverUrl + '/' + docId + '?' + params`. If the caller embedded a query
|
|
31
|
+
// string (for example `?token=<jwt>`) directly in the URL, the docId would be
|
|
32
|
+
// appended *after* the query string, corrupting the URL. Split any query
|
|
33
|
+
// string off the base URL and forward it through the provider `params` so the
|
|
34
|
+
// final URL is `serverUrl/docId?token=<jwt>` as the server expects.
|
|
35
|
+
let finalWebsocketUrl = rawWebsocketUrl;
|
|
36
|
+
const params = {};
|
|
37
|
+
const queryIndex = finalWebsocketUrl.indexOf('?');
|
|
38
|
+
if (queryIndex !== -1) {
|
|
39
|
+
const query = finalWebsocketUrl.slice(queryIndex + 1);
|
|
40
|
+
finalWebsocketUrl = finalWebsocketUrl.slice(0, queryIndex);
|
|
41
|
+
new URLSearchParams(query).forEach((value, key) => {
|
|
42
|
+
params[key] = value;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
29
45
|
const websocketProvider = new WebsocketProvider(finalWebsocketUrl, id, doc, {
|
|
30
46
|
connect: false,
|
|
47
|
+
params,
|
|
31
48
|
resyncInterval: RESYNC_INTERVAL, // Poll ephemeral state periodically to prevent stale user accumulation
|
|
32
49
|
});
|
|
33
|
-
console.log(
|
|
50
|
+
console.log(` WebsocketProvider created for: ${finalWebsocketUrl}/${id} with resyncInterval: ${RESYNC_INTERVAL}ms`);
|
|
34
51
|
return websocketProvider;
|
|
35
52
|
}
|
package/lib/commenting/index.js
CHANGED
package/lib/demo.js
CHANGED
package/lib/hooks/useReport.js
CHANGED
package/lib/index.js
CHANGED
package/lib/nodes/EmojiNode.js
CHANGED
package/lib/nodes/KeywordNode.js
CHANGED
package/lib/nodes/MentionNode.js
CHANGED