@apollohg/react-native-prose-editor 0.4.0 → 0.4.2

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.
Files changed (33) hide show
  1. package/README.md +21 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +502 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  7. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  8. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  9. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  10. package/dist/NativeEditorBridge.d.ts +37 -1
  11. package/dist/NativeEditorBridge.js +192 -97
  12. package/dist/NativeRichTextEditor.d.ts +3 -2
  13. package/dist/NativeRichTextEditor.js +164 -56
  14. package/dist/YjsCollaboration.d.ts +2 -0
  15. package/dist/YjsCollaboration.js +142 -20
  16. package/dist/schemas.d.ts +2 -0
  17. package/dist/schemas.js +63 -0
  18. package/ios/EditorCore.xcframework/Info.plist +5 -5
  19. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  20. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  21. package/ios/EditorLayoutManager.swift +3 -3
  22. package/ios/Generated_editor_core.swift +41 -0
  23. package/ios/NativeEditorExpoView.swift +43 -11
  24. package/ios/NativeEditorModule.swift +6 -0
  25. package/ios/PositionBridge.swift +310 -75
  26. package/ios/RenderBridge.swift +362 -27
  27. package/ios/RichTextEditorView.swift +1983 -187
  28. package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
  29. package/package.json +11 -2
  30. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  31. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  32. package/rust/android/x86_64/libeditor_core.so +0 -0
  33. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +63 -0
@@ -10,7 +10,6 @@ const EditorToolbar_1 = require("./EditorToolbar");
10
10
  const EditorTheme_1 = require("./EditorTheme");
11
11
  const addons_1 = require("./addons");
12
12
  const schemas_1 = require("./schemas");
13
- const schemas_2 = require("./schemas");
14
13
  const NativeEditorView = (0, expo_modules_core_1.requireNativeViewManager)('NativeEditor');
15
14
  const DEV_NATIVE_VIEW_KEY = __DEV__
16
15
  ? `native-editor-dev:${Math.random().toString(36).slice(2)}`
@@ -35,7 +34,7 @@ function mapToolbarChildForNative(item, activeState, editable, onRequestLink, on
35
34
  label: item.label,
36
35
  icon: item.icon,
37
36
  isActive: false,
38
- isDisabled: !editable || !onRequestImage || !activeState.insertableNodes.includes(schemas_2.IMAGE_NODE_NAME),
37
+ isDisabled: !editable || !onRequestImage || !activeState.insertableNodes.includes(schemas_1.IMAGE_NODE_NAME),
39
38
  };
40
39
  }
41
40
  return item;
@@ -99,9 +98,43 @@ function serializeRemoteSelections(remoteSelections) {
99
98
  if (!remoteSelections || remoteSelections.length === 0) {
100
99
  return undefined;
101
100
  }
102
- return JSON.stringify(remoteSelections);
101
+ return stringifyCachedJson(remoteSelections);
103
102
  }
104
- exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEditor({ initialContent, initialJSON, value, valueJSON, schema, placeholder, editable = true, maxLength, autoFocus = false, heightBehavior = 'autoGrow', showToolbar = true, toolbarPlacement = 'keyboard', toolbarItems = EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS, onToolbarAction, onRequestLink, onRequestImage, onContentChange, onContentChangeJSON, onSelectionChange, onActiveStateChange, onHistoryStateChange, onFocus, onBlur, style, containerStyle, theme, addons, remoteSelections, allowBase64Images = false, allowImageResizing = true, }, ref) {
103
+ const serializedJsonCache = new WeakMap();
104
+ function stringifyCachedJson(value) {
105
+ if (value != null && typeof value === 'object') {
106
+ const cached = serializedJsonCache.get(value);
107
+ if (cached != null) {
108
+ return cached;
109
+ }
110
+ const serialized = JSON.stringify(value);
111
+ serializedJsonCache.set(value, serialized);
112
+ return serialized;
113
+ }
114
+ return JSON.stringify(value);
115
+ }
116
+ function useSerializedValue(value, serialize, revision) {
117
+ const cacheRef = (0, react_1.useRef)(null);
118
+ const hasRevision = revision !== undefined;
119
+ const cached = cacheRef.current;
120
+ if (cached) {
121
+ if (hasRevision && cached.hasRevision && Object.is(cached.revision, revision)) {
122
+ return cached.serialized;
123
+ }
124
+ if (Object.is(cached.value, value) && cached.hasRevision === hasRevision) {
125
+ return cached.serialized;
126
+ }
127
+ }
128
+ const serialized = value == null ? undefined : serialize(value);
129
+ cacheRef.current = {
130
+ value,
131
+ revision,
132
+ hasRevision,
133
+ serialized,
134
+ };
135
+ return serialized;
136
+ }
137
+ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEditor({ initialContent, initialJSON, value, valueJSON, valueJSONRevision, schema, placeholder, editable = true, maxLength, autoFocus = false, heightBehavior = 'autoGrow', showToolbar = true, toolbarPlacement = 'keyboard', toolbarItems = EditorToolbar_1.DEFAULT_EDITOR_TOOLBAR_ITEMS, onToolbarAction, onRequestLink, onRequestImage, onContentChange, onContentChangeJSON, onSelectionChange, onActiveStateChange, onHistoryStateChange, onFocus, onBlur, style, containerStyle, theme, addons, remoteSelections, allowBase64Images = false, allowImageResizing = true, }, ref) {
105
138
  const bridgeRef = (0, react_1.useRef)(null);
106
139
  const nativeViewRef = (0, react_1.useRef)(null);
107
140
  const [isReady, setIsReady] = (0, react_1.useState)(false);
@@ -129,7 +162,9 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
129
162
  // Selection and rendered text length refs (non-rendering state)
130
163
  const selectionRef = (0, react_1.useRef)({ type: 'text', anchor: 0, head: 0 });
131
164
  const renderedTextLengthRef = (0, react_1.useRef)(0);
165
+ const documentVersionRef = (0, react_1.useRef)(null);
132
166
  const toolbarRef = (0, react_1.useRef)(null);
167
+ const toolbarItemsSerializationCacheRef = (0, react_1.useRef)(null);
133
168
  // Stable callback refs to avoid re-renders
134
169
  const onContentChangeRef = (0, react_1.useRef)(onContentChange);
135
170
  onContentChangeRef.current = onContentChange;
@@ -152,6 +187,14 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
152
187
  : undefined;
153
188
  const mentionSuggestionsByKeyRef = (0, react_1.useRef)(new Map());
154
189
  mentionSuggestionsByKeyRef.current = new Map((addons?.mentions?.suggestions ?? []).map((suggestion) => [suggestion.key, suggestion]));
190
+ const bridgeSchema = addons?.mentions != null ? (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema) : schema;
191
+ const documentSchema = bridgeSchema ?? schemas_1.tiptapSchema;
192
+ const serializedSchemaJson = useSerializedValue(bridgeSchema, (nextSchema) => stringifyCachedJson(nextSchema));
193
+ const serializedInitialJson = useSerializedValue(initialJSON, (doc) => stringifyCachedJson((0, schemas_1.normalizeDocumentJson)(doc, documentSchema)));
194
+ const serializedValueJson = useSerializedValue(valueJSON, (doc) => stringifyCachedJson((0, schemas_1.normalizeDocumentJson)(doc, documentSchema)), valueJSONRevision);
195
+ const themeJson = useSerializedValue(theme, EditorTheme_1.serializeEditorTheme);
196
+ const addonsJson = useSerializedValue(addons, addons_1.serializeEditorAddons);
197
+ const remoteSelectionsJson = useSerializedValue(remoteSelections, (selections) => serializeRemoteSelections(selections));
155
198
  const syncStateFromUpdate = (0, react_1.useCallback)((update) => {
156
199
  if (!update)
157
200
  return;
@@ -159,6 +202,44 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
159
202
  setHistoryState(update.historyState);
160
203
  selectionRef.current = update.selection;
161
204
  renderedTextLengthRef.current = computeRenderedTextLength(update.renderElements);
205
+ if (typeof update.documentVersion === 'number') {
206
+ documentVersionRef.current = update.documentVersion;
207
+ }
208
+ }, []);
209
+ const syncSelectionStateFromUpdate = (0, react_1.useCallback)((update) => {
210
+ if (!update)
211
+ return;
212
+ setActiveState(update.activeState);
213
+ setHistoryState(update.historyState);
214
+ selectionRef.current = update.selection;
215
+ if (typeof update.documentVersion === 'number') {
216
+ documentVersionRef.current = update.documentVersion;
217
+ }
218
+ }, []);
219
+ const emitContentCallbacksForUpdate = (0, react_1.useCallback)((update, previousDocumentVersion) => {
220
+ if (!update || !bridgeRef.current || bridgeRef.current.isDestroyed)
221
+ return;
222
+ const wantsHtml = typeof onContentChangeRef.current === 'function';
223
+ const wantsJson = typeof onContentChangeJSONRef.current === 'function';
224
+ if (!wantsHtml && !wantsJson)
225
+ return;
226
+ if (previousDocumentVersion != null &&
227
+ typeof update.documentVersion === 'number' &&
228
+ update.documentVersion === previousDocumentVersion) {
229
+ return;
230
+ }
231
+ if (wantsHtml && wantsJson) {
232
+ const snapshot = bridgeRef.current.getContentSnapshot();
233
+ onContentChangeRef.current?.(snapshot.html);
234
+ onContentChangeJSONRef.current?.(snapshot.json);
235
+ return;
236
+ }
237
+ if (wantsHtml) {
238
+ onContentChangeRef.current?.(bridgeRef.current.getHtml());
239
+ }
240
+ if (wantsJson) {
241
+ onContentChangeJSONRef.current?.(bridgeRef.current.getJson());
242
+ }
162
243
  }, []);
163
244
  // Warn if both value and valueJSON are set
164
245
  if (__DEV__ && value != null && valueJSON != null) {
@@ -166,13 +247,8 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
166
247
  'Only value will be used.');
167
248
  }
168
249
  const runAndApply = (0, react_1.useCallback)((mutate, options) => {
250
+ const previousDocumentVersion = documentVersionRef.current;
169
251
  const preservedSelection = options?.preserveLiveTextSelection === true ? selectionRef.current : null;
170
- const shouldCheckForNoopNativeApply = options?.skipNativeApplyIfContentUnchanged === true &&
171
- bridgeRef.current != null &&
172
- !bridgeRef.current.isDestroyed;
173
- const htmlBefore = shouldCheckForNoopNativeApply
174
- ? bridgeRef.current.getHtml()
175
- : null;
176
252
  const update = mutate();
177
253
  if (!update)
178
254
  return null;
@@ -188,10 +264,10 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
188
264
  head: preservedSelection.head,
189
265
  };
190
266
  }
191
- const htmlAfter = shouldCheckForNoopNativeApply
192
- ? bridgeRef.current.getHtml()
193
- : null;
194
- if (!shouldCheckForNoopNativeApply || htmlBefore !== htmlAfter) {
267
+ const contentChanged = previousDocumentVersion == null ||
268
+ typeof update.documentVersion !== 'number' ||
269
+ update.documentVersion !== previousDocumentVersion;
270
+ if (!options?.skipNativeApplyIfContentUnchanged || contentChanged) {
195
271
  const updateJson = JSON.stringify(update);
196
272
  if (react_native_1.Platform.OS === 'android') {
197
273
  setPendingNativeUpdate((current) => ({
@@ -217,23 +293,18 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
217
293
  onActiveStateChangeRef.current?.(update.activeState);
218
294
  onHistoryStateChangeRef.current?.(update.historyState);
219
295
  if (!options?.suppressContentCallbacks) {
220
- if (onContentChangeRef.current && bridgeRef.current) {
221
- onContentChangeRef.current(bridgeRef.current.getHtml());
222
- }
223
- if (onContentChangeJSONRef.current && bridgeRef.current) {
224
- onContentChangeJSONRef.current(bridgeRef.current.getJson());
225
- }
296
+ emitContentCallbacksForUpdate(update, previousDocumentVersion);
226
297
  }
227
298
  onSelectionChangeRef.current?.(update.selection);
228
299
  return update;
229
- }, [syncStateFromUpdate]);
300
+ }, [emitContentCallbacksForUpdate, syncStateFromUpdate]);
230
301
  (0, react_1.useEffect)(() => {
231
- const effectiveSchema = addonsRef.current?.mentions != null
232
- ? (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema)
233
- : schema;
234
- const schemaJson = effectiveSchema ? JSON.stringify(effectiveSchema) : undefined;
235
- const bridgeConfig = maxLength != null || schemaJson || allowBase64Images
236
- ? { maxLength, schemaJson, allowBase64Images }
302
+ const bridgeConfig = maxLength != null || serializedSchemaJson || allowBase64Images
303
+ ? {
304
+ maxLength,
305
+ schemaJson: serializedSchemaJson,
306
+ allowBase64Images,
307
+ }
237
308
  : undefined;
238
309
  const bridge = NativeEditorBridge_1.NativeEditorBridge.create(bridgeConfig);
239
310
  bridgeRef.current = bridge;
@@ -242,11 +313,11 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
242
313
  if (value != null) {
243
314
  bridge.setHtml(value);
244
315
  }
245
- else if (valueJSON != null) {
246
- bridge.setJson(valueJSON);
316
+ else if (serializedValueJson != null) {
317
+ bridge.setJsonString(serializedValueJson);
247
318
  }
248
- else if (initialJSON) {
249
- bridge.setJson(initialJSON);
319
+ else if (serializedInitialJson != null) {
320
+ bridge.setJsonString(serializedInitialJson);
250
321
  }
251
322
  else if (initialContent) {
252
323
  bridge.setHtml(initialContent);
@@ -261,7 +332,12 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
261
332
  setIsReady(false);
262
333
  };
263
334
  // eslint-disable-next-line react-hooks/exhaustive-deps
264
- }, [schema, maxLength, syncStateFromUpdate, Boolean(addons?.mentions), allowBase64Images]);
335
+ }, [
336
+ maxLength,
337
+ syncStateFromUpdate,
338
+ allowBase64Images,
339
+ serializedSchemaJson,
340
+ ]);
265
341
  (0, react_1.useEffect)(() => {
266
342
  if (value == null)
267
343
  return;
@@ -276,19 +352,18 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
276
352
  });
277
353
  }, [value, runAndApply]);
278
354
  (0, react_1.useEffect)(() => {
279
- if (valueJSON == null || value != null)
355
+ if (serializedValueJson == null || value != null)
280
356
  return;
281
357
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
282
358
  return;
283
- // No-op if JSON content is identical (avoids churning undo history)
284
- const currentJson = bridgeRef.current.getJson();
285
- if (JSON.stringify(currentJson) === JSON.stringify(valueJSON))
359
+ const currentJson = bridgeRef.current.getJsonString();
360
+ if (currentJson === serializedValueJson)
286
361
  return;
287
- runAndApply(() => bridgeRef.current.replaceJson(valueJSON), {
362
+ runAndApply(() => bridgeRef.current.replaceJsonString(serializedValueJson), {
288
363
  suppressContentCallbacks: true,
289
364
  preserveLiveTextSelection: true,
290
365
  });
291
- }, [valueJSON, value, runAndApply]);
366
+ }, [serializedValueJson, value, runAndApply]);
292
367
  const updateToolbarFrame = (0, react_1.useCallback)(() => {
293
368
  const toolbar = toolbarRef.current;
294
369
  if (!toolbar) {
@@ -323,28 +398,24 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
323
398
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
324
399
  return;
325
400
  try {
326
- const update = (0, NativeEditorBridge_1.parseEditorUpdateJson)(event.nativeEvent.updateJson);
401
+ const previousDocumentVersion = documentVersionRef.current;
402
+ const update = bridgeRef.current.parseUpdateJson(event.nativeEvent.updateJson);
327
403
  if (!update)
328
404
  return;
329
405
  syncStateFromUpdate(update);
330
406
  onActiveStateChangeRef.current?.(update.activeState);
331
407
  onHistoryStateChangeRef.current?.(update.historyState);
332
- if (onContentChangeRef.current) {
333
- onContentChangeRef.current(bridgeRef.current.getHtml());
334
- }
335
- if (onContentChangeJSONRef.current) {
336
- onContentChangeJSONRef.current(bridgeRef.current.getJson());
337
- }
408
+ emitContentCallbacksForUpdate(update, previousDocumentVersion);
338
409
  onSelectionChangeRef.current?.(update.selection);
339
410
  }
340
411
  catch {
341
412
  // Invalid JSON from native — skip
342
413
  }
343
- }, [syncStateFromUpdate]);
414
+ }, [emitContentCallbacksForUpdate, syncStateFromUpdate]);
344
415
  const handleSelectionChange = (0, react_1.useCallback)((event) => {
345
416
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
346
417
  return;
347
- const { anchor, head } = event.nativeEvent;
418
+ const { anchor, head, stateJson } = event.nativeEvent;
348
419
  let selection;
349
420
  if (anchor === 0 &&
350
421
  head >= renderedTextLengthRef.current &&
@@ -355,8 +426,19 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
355
426
  selection = { type: 'text', anchor, head };
356
427
  }
357
428
  bridgeRef.current.updateSelectionFromNative(anchor, head);
358
- const currentState = bridgeRef.current.getCurrentState();
359
- syncStateFromUpdate(currentState);
429
+ let currentState = null;
430
+ if (typeof stateJson === 'string' && stateJson.length > 0) {
431
+ try {
432
+ currentState = bridgeRef.current.parseUpdateJson(stateJson);
433
+ }
434
+ catch {
435
+ currentState = bridgeRef.current.getSelectionState();
436
+ }
437
+ }
438
+ else {
439
+ currentState = bridgeRef.current.getSelectionState();
440
+ }
441
+ syncSelectionStateFromUpdate(currentState);
360
442
  const nextSelection = selection.type === 'all' ? selection : (currentState?.selection ?? selection);
361
443
  selectionRef.current = nextSelection;
362
444
  if (currentState) {
@@ -364,7 +446,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
364
446
  onHistoryStateChangeRef.current?.(currentState.historyState);
365
447
  }
366
448
  onSelectionChangeRef.current?.(nextSelection);
367
- }, [syncStateFromUpdate]);
449
+ }, [syncSelectionStateFromUpdate]);
368
450
  const handleFocusChange = (0, react_1.useCallback)((event) => {
369
451
  const { isFocused: focused } = event.nativeEvent;
370
452
  setIsFocused(focused);
@@ -412,7 +494,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
412
494
  if (selection) {
413
495
  restoreSelection(selection);
414
496
  }
415
- return (bridgeRef.current?.insertContentJson((0, schemas_2.buildImageFragmentJson)({
497
+ return (bridgeRef.current?.insertContentJson((0, schemas_1.buildImageFragmentJson)({
416
498
  src: trimmedSrc,
417
499
  ...(attrs ?? {}),
418
500
  })) ?? null);
@@ -585,11 +667,37 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
585
667
  }), [insertImage, runAndApply]);
586
668
  if (!isReady)
587
669
  return null;
588
- const toolbarItemsForNative = mapToolbarItemsForNative(toolbarItems, activeState, editable, onRequestLink, onRequestImage);
589
- const themeJson = (0, EditorTheme_1.serializeEditorTheme)(theme);
590
- const addonsJson = (0, addons_1.serializeEditorAddons)(addons);
591
- const toolbarItemsJson = JSON.stringify(toolbarItemsForNative);
592
- const remoteSelectionsJson = serializeRemoteSelections(remoteSelections);
670
+ const isLinkActive = activeState.marks.link === true;
671
+ const allowsLink = activeState.allowedMarks.includes('link');
672
+ const canInsertImage = activeState.insertableNodes.includes(schemas_1.IMAGE_NODE_NAME);
673
+ const canRequestLink = typeof onRequestLink === 'function';
674
+ const canRequestImage = typeof onRequestImage === 'function';
675
+ const cachedToolbarItems = toolbarItemsSerializationCacheRef.current;
676
+ let toolbarItemsJson;
677
+ if (cachedToolbarItems?.toolbarItems === toolbarItems &&
678
+ cachedToolbarItems.editable === editable &&
679
+ cachedToolbarItems.isLinkActive === isLinkActive &&
680
+ cachedToolbarItems.allowsLink === allowsLink &&
681
+ cachedToolbarItems.canRequestLink === canRequestLink &&
682
+ cachedToolbarItems.canRequestImage === canRequestImage &&
683
+ cachedToolbarItems.canInsertImage === canInsertImage) {
684
+ toolbarItemsJson = cachedToolbarItems.serialized;
685
+ }
686
+ else {
687
+ const mappedItems = mapToolbarItemsForNative(toolbarItems, activeState, editable, onRequestLink, onRequestImage);
688
+ toolbarItemsJson = stringifyCachedJson(mappedItems);
689
+ toolbarItemsSerializationCacheRef.current = {
690
+ toolbarItems,
691
+ editable,
692
+ isLinkActive,
693
+ allowsLink,
694
+ canRequestLink,
695
+ canRequestImage,
696
+ canInsertImage,
697
+ mappedItems,
698
+ serialized: toolbarItemsJson,
699
+ };
700
+ }
593
701
  const usesNativeKeyboardToolbar = toolbarPlacement === 'keyboard' && (react_native_1.Platform.OS === 'ios' || react_native_1.Platform.OS === 'android');
594
702
  const shouldRenderJsToolbar = !usesNativeKeyboardToolbar && showToolbar && editable;
595
703
  const inlineToolbarChrome = {
@@ -1,5 +1,6 @@
1
1
  import { type CollaborationPeer, type DocumentJSON, type EncodedCollaborationStateInput, type Selection } from './NativeEditorBridge';
2
2
  import type { RemoteSelectionDecoration } from './NativeRichTextEditor';
3
+ import type { SchemaDefinition } from './schemas';
3
4
  export type YjsTransportStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
4
5
  export interface YjsRetryContext {
5
6
  attempt: number;
@@ -35,6 +36,7 @@ export interface YjsCollaborationOptions {
35
36
  connect?: boolean;
36
37
  retryIntervalMs?: YjsRetryInterval | false;
37
38
  fragmentName?: string;
39
+ schema?: SchemaDefinition;
38
40
  initialDocumentJson?: DocumentJSON;
39
41
  initialEncodedState?: EncodedCollaborationStateInput;
40
42
  localAwareness: LocalAwarenessUser;
@@ -17,11 +17,119 @@ const EMPTY_DOCUMENT = {
17
17
  ],
18
18
  };
19
19
  const SELECTION_AWARENESS_DEBOUNCE_MS = 40;
20
- function cloneDocument(doc) {
21
- return JSON.parse(JSON.stringify(doc ?? EMPTY_DOCUMENT));
20
+ function cloneJsonValue(value) {
21
+ if (Array.isArray(value)) {
22
+ return value.map((item) => cloneJsonValue(item));
23
+ }
24
+ if (value != null && typeof value === 'object') {
25
+ const clone = {};
26
+ for (const [key, nestedValue] of Object.entries(value)) {
27
+ clone[key] = cloneJsonValue(nestedValue);
28
+ }
29
+ return clone;
30
+ }
31
+ return value;
32
+ }
33
+ function acceptingGroupsForContent(content, existingChildCount) {
34
+ const tokens = content
35
+ .trim()
36
+ .split(/\s+/)
37
+ .filter(Boolean)
38
+ .map((token) => {
39
+ const quantifier = token[token.length - 1];
40
+ if (quantifier === '+' || quantifier === '*' || quantifier === '?') {
41
+ return {
42
+ group: token.slice(0, -1),
43
+ min: quantifier === '+' ? 1 : 0,
44
+ max: quantifier === '?' ? 1 : null,
45
+ };
46
+ }
47
+ return {
48
+ group: token,
49
+ min: 1,
50
+ max: 1,
51
+ };
52
+ });
53
+ let remaining = existingChildCount;
54
+ const acceptingGroups = [];
55
+ for (const token of tokens) {
56
+ if (remaining >= token.min) {
57
+ const consumed = token.max == null ? remaining : Math.min(remaining, token.max);
58
+ remaining = Math.max(0, remaining - consumed);
59
+ const atMax = token.max != null && consumed >= token.max;
60
+ if (!atMax) {
61
+ acceptingGroups.push(token.group);
62
+ }
63
+ continue;
64
+ }
65
+ acceptingGroups.push(token.group);
66
+ break;
67
+ }
68
+ return acceptingGroups;
69
+ }
70
+ function defaultEmptyDocument(schema) {
71
+ if (!schema) {
72
+ return {
73
+ type: 'doc',
74
+ content: [{ type: 'paragraph' }],
75
+ };
76
+ }
77
+ const docNode = schema.nodes.find((node) => node.role === 'doc' || node.name === 'doc');
78
+ const acceptingGroups = docNode == null ? [] : acceptingGroupsForContent(docNode.content ?? '', 0);
79
+ const matchingTextBlocks = schema.nodes.filter((node) => node.role === 'textBlock' &&
80
+ acceptingGroups.some((group) => node.name === group || node.group === group));
81
+ const preferredTextBlock = matchingTextBlocks.find((node) => node.htmlTag === 'p' || node.name === 'paragraph') ??
82
+ matchingTextBlocks[0] ??
83
+ schema.nodes.find((node) => node.htmlTag === 'p' || node.name === 'paragraph') ??
84
+ schema.nodes.find((node) => node.role === 'textBlock');
85
+ if (!preferredTextBlock) {
86
+ return {
87
+ type: 'doc',
88
+ content: [{ type: 'paragraph' }],
89
+ };
90
+ }
91
+ return {
92
+ type: 'doc',
93
+ content: [{ type: preferredTextBlock.name }],
94
+ };
95
+ }
96
+ function initialFallbackDocument(options) {
97
+ return options.initialDocumentJson
98
+ ? cloneJsonValue(options.initialDocumentJson)
99
+ : defaultEmptyDocument(options.schema);
100
+ }
101
+ function shouldUseFallbackForNativeDocument(doc, options) {
102
+ if (options.initialDocumentJson != null || options.initialEncodedState != null) {
103
+ return false;
104
+ }
105
+ if (doc.type !== 'doc') {
106
+ return false;
107
+ }
108
+ return !Array.isArray(doc.content) || doc.content.length === 0;
22
109
  }
23
110
  function awarenessToRecord(awareness) {
24
- return JSON.parse(JSON.stringify(awareness));
111
+ return awareness;
112
+ }
113
+ function localAwarenessEquals(left, right) {
114
+ const leftSelection = left.selection;
115
+ const rightSelection = right.selection;
116
+ if (leftSelection == null || rightSelection == null) {
117
+ if (leftSelection !== rightSelection) {
118
+ return false;
119
+ }
120
+ }
121
+ else if (leftSelection.anchor !== rightSelection.anchor ||
122
+ leftSelection.head !== rightSelection.head) {
123
+ return false;
124
+ }
125
+ const leftUser = left.user;
126
+ const rightUser = right.user;
127
+ return (left.focused === right.focused &&
128
+ leftUser.userId === rightUser.userId &&
129
+ leftUser.name === rightUser.name &&
130
+ leftUser.color === rightUser.color &&
131
+ leftUser.avatarUrl === rightUser.avatarUrl &&
132
+ JSON.stringify(leftUser.extra ?? null) === JSON.stringify(rightUser.extra ?? null));
25
133
  }
26
134
  function normalizeMessageBytes(data) {
27
135
  if (data instanceof ArrayBuffer) {
@@ -109,11 +217,6 @@ function encodeInitialStateKey(encodedState) {
109
217
  return '';
110
218
  return (0, NativeEditorBridge_1.encodeCollaborationStateBase64)(encodedState);
111
219
  }
112
- function encodeDocumentKey(doc) {
113
- if (doc == null)
114
- return '';
115
- return JSON.stringify(doc);
116
- }
117
220
  class YjsCollaborationControllerImpl {
118
221
  constructor(options, callbacks = {}) {
119
222
  this.socket = null;
@@ -126,23 +229,26 @@ class YjsCollaborationControllerImpl {
126
229
  this.callbacks = callbacks;
127
230
  this.createWebSocket = options.createWebSocket;
128
231
  this.retryIntervalMs = options.retryIntervalMs;
129
- const hasInitialEncodedState = options.initialEncodedState != null;
130
232
  this.localAwarenessState = {
131
233
  user: options.localAwareness,
132
234
  focused: false,
133
235
  };
134
236
  this.bridge = NativeEditorBridge_1.NativeCollaborationBridge.create({
135
237
  fragmentName: options.fragmentName ?? DEFAULT_YJS_FRAGMENT_NAME,
238
+ schema: options.schema,
136
239
  initialEncodedState: options.initialEncodedState,
137
240
  localAwareness: awarenessToRecord(this.localAwarenessState),
138
241
  });
242
+ const nativeDocumentJson = this.bridge.getDocumentJson();
139
243
  this._state = {
140
244
  documentId: options.documentId,
141
245
  status: 'idle',
142
246
  isConnected: false,
143
- documentJson: hasInitialEncodedState
144
- ? this.bridge.getDocumentJson()
145
- : cloneDocument(options.initialDocumentJson),
247
+ documentJson: options.initialDocumentJson != null
248
+ ? cloneJsonValue(options.initialDocumentJson)
249
+ : shouldUseFallbackForNativeDocument(nativeDocumentJson, options)
250
+ ? defaultEmptyDocument(options.schema)
251
+ : nativeDocumentJson,
146
252
  };
147
253
  this._peers = this.bridge.getPeers();
148
254
  if (options.connect !== false) {
@@ -314,10 +420,10 @@ class YjsCollaborationControllerImpl {
314
420
  destroy() {
315
421
  if (this.destroyed)
316
422
  return;
317
- this.destroyed = true;
318
423
  this.cancelRetry();
319
424
  this.cancelPendingAwarenessSync();
320
425
  this.disconnect();
426
+ this.destroyed = true;
321
427
  this.bridge.destroy();
322
428
  }
323
429
  updateLocalAwareness(partial) {
@@ -336,22 +442,33 @@ class YjsCollaborationControllerImpl {
336
442
  handleSelectionChange(selection) {
337
443
  if (this.destroyed)
338
444
  return;
339
- this.localAwarenessState = this.mergeLocalAwareness({
445
+ const nextAwareness = this.mergeLocalAwareness({
340
446
  focused: true,
341
447
  selection: selectionToAwarenessRange(selection),
342
448
  });
449
+ if (localAwarenessEquals(nextAwareness, this.localAwarenessState)) {
450
+ return;
451
+ }
452
+ this.localAwarenessState = nextAwareness;
343
453
  this.scheduleAwarenessSync();
344
454
  }
345
455
  handleFocusChange(focused) {
346
456
  if (this.destroyed)
347
457
  return;
348
- this.localAwarenessState = this.mergeLocalAwareness({ focused });
458
+ const nextAwareness = this.mergeLocalAwareness({ focused });
459
+ if (localAwarenessEquals(nextAwareness, this.localAwarenessState)) {
460
+ if (this.pendingAwarenessTimer != null) {
461
+ this.commitLocalAwareness();
462
+ }
463
+ return;
464
+ }
465
+ this.localAwarenessState = nextAwareness;
349
466
  this.commitLocalAwareness();
350
467
  }
351
468
  applyResult(result) {
352
469
  if (result.documentChanged && result.documentJson) {
353
470
  this.setState({
354
- documentJson: cloneDocument(result.documentJson),
471
+ documentJson: result.documentJson,
355
472
  });
356
473
  }
357
474
  if (result.peersChanged && result.peers) {
@@ -489,14 +606,14 @@ function useYjsCollaboration(options) {
489
606
  const createWebSocketRef = (0, react_1.useRef)(options.createWebSocket);
490
607
  createWebSocketRef.current = options.createWebSocket;
491
608
  const controllerRef = (0, react_1.useRef)(null);
492
- const initialDocumentKey = encodeDocumentKey(options.initialDocumentJson);
493
609
  const initialEncodedStateKey = encodeInitialStateKey(options.initialEncodedState);
494
610
  const localAwarenessKey = JSON.stringify(options.localAwareness);
611
+ const schemaKey = JSON.stringify(options.schema ?? null);
495
612
  const [state, setState] = (0, react_1.useState)({
496
613
  documentId: options.documentId,
497
614
  status: 'idle',
498
615
  isConnected: false,
499
- documentJson: cloneDocument(options.initialDocumentJson),
616
+ documentJson: initialFallbackDocument(options),
500
617
  });
501
618
  const [peers, setPeers] = (0, react_1.useState)([]);
502
619
  (0, react_1.useEffect)(() => {
@@ -529,7 +646,7 @@ function useYjsCollaboration(options) {
529
646
  documentId: options.documentId,
530
647
  status: 'error',
531
648
  isConnected: false,
532
- documentJson: cloneDocument(options.initialDocumentJson),
649
+ documentJson: initialFallbackDocument(options),
533
650
  lastError: nextError,
534
651
  };
535
652
  controllerRef.current = null;
@@ -543,7 +660,12 @@ function useYjsCollaboration(options) {
543
660
  controllerRef.current = null;
544
661
  };
545
662
  // eslint-disable-next-line react-hooks/exhaustive-deps
546
- }, [options.documentId, options.fragmentName, initialDocumentKey, initialEncodedStateKey]);
663
+ }, [
664
+ options.documentId,
665
+ options.fragmentName,
666
+ schemaKey,
667
+ initialEncodedStateKey,
668
+ ]);
547
669
  (0, react_1.useEffect)(() => {
548
670
  controllerRef.current?.updateLocalAwareness({
549
671
  user: options.localAwareness,
package/dist/schemas.d.ts CHANGED
@@ -32,4 +32,6 @@ export declare function imageNodeSpec(name?: string): NodeSpec;
32
32
  export declare function withImagesSchema(schema: SchemaDefinition): SchemaDefinition;
33
33
  export declare function buildImageFragmentJson(attrs: ImageNodeAttributes): DocumentJSON;
34
34
  export declare const tiptapSchema: SchemaDefinition;
35
+ export declare function defaultEmptyDocument(schema?: SchemaDefinition): DocumentJSON;
36
+ export declare function normalizeDocumentJson(doc: DocumentJSON, schema?: SchemaDefinition): DocumentJSON;
35
37
  export declare const prosemirrorSchema: SchemaDefinition;