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

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 (30) hide show
  1. package/README.md +18 -0
  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 +36 -1
  11. package/dist/NativeEditorBridge.js +173 -94
  12. package/dist/NativeRichTextEditor.d.ts +2 -0
  13. package/dist/NativeRichTextEditor.js +160 -53
  14. package/dist/YjsCollaboration.d.ts +2 -0
  15. package/dist/YjsCollaboration.js +142 -20
  16. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  17. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  18. package/ios/EditorLayoutManager.swift +3 -3
  19. package/ios/Generated_editor_core.swift +41 -0
  20. package/ios/NativeEditorExpoView.swift +43 -11
  21. package/ios/NativeEditorModule.swift +6 -0
  22. package/ios/PositionBridge.swift +310 -75
  23. package/ios/RenderBridge.swift +362 -27
  24. package/ios/RichTextEditorView.swift +1983 -187
  25. package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
  26. package/package.json +11 -2
  27. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  28. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  29. package/rust/android/x86_64/libeditor_core.so +0 -0
  30. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +63 -0
@@ -99,9 +99,43 @@ function serializeRemoteSelections(remoteSelections) {
99
99
  if (!remoteSelections || remoteSelections.length === 0) {
100
100
  return undefined;
101
101
  }
102
- return JSON.stringify(remoteSelections);
102
+ return stringifyCachedJson(remoteSelections);
103
103
  }
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) {
104
+ const serializedJsonCache = new WeakMap();
105
+ function stringifyCachedJson(value) {
106
+ if (value != null && typeof value === 'object') {
107
+ const cached = serializedJsonCache.get(value);
108
+ if (cached != null) {
109
+ return cached;
110
+ }
111
+ const serialized = JSON.stringify(value);
112
+ serializedJsonCache.set(value, serialized);
113
+ return serialized;
114
+ }
115
+ return JSON.stringify(value);
116
+ }
117
+ function useSerializedValue(value, serialize, revision) {
118
+ const cacheRef = (0, react_1.useRef)(null);
119
+ const hasRevision = revision !== undefined;
120
+ const cached = cacheRef.current;
121
+ if (cached) {
122
+ if (hasRevision && cached.hasRevision && Object.is(cached.revision, revision)) {
123
+ return cached.serialized;
124
+ }
125
+ if (Object.is(cached.value, value) && cached.hasRevision === hasRevision) {
126
+ return cached.serialized;
127
+ }
128
+ }
129
+ const serialized = value == null ? undefined : serialize(value);
130
+ cacheRef.current = {
131
+ value,
132
+ revision,
133
+ hasRevision,
134
+ serialized,
135
+ };
136
+ return serialized;
137
+ }
138
+ 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
139
  const bridgeRef = (0, react_1.useRef)(null);
106
140
  const nativeViewRef = (0, react_1.useRef)(null);
107
141
  const [isReady, setIsReady] = (0, react_1.useState)(false);
@@ -129,7 +163,9 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
129
163
  // Selection and rendered text length refs (non-rendering state)
130
164
  const selectionRef = (0, react_1.useRef)({ type: 'text', anchor: 0, head: 0 });
131
165
  const renderedTextLengthRef = (0, react_1.useRef)(0);
166
+ const documentVersionRef = (0, react_1.useRef)(null);
132
167
  const toolbarRef = (0, react_1.useRef)(null);
168
+ const toolbarItemsSerializationCacheRef = (0, react_1.useRef)(null);
133
169
  // Stable callback refs to avoid re-renders
134
170
  const onContentChangeRef = (0, react_1.useRef)(onContentChange);
135
171
  onContentChangeRef.current = onContentChange;
@@ -152,6 +188,12 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
152
188
  : undefined;
153
189
  const mentionSuggestionsByKeyRef = (0, react_1.useRef)(new Map());
154
190
  mentionSuggestionsByKeyRef.current = new Map((addons?.mentions?.suggestions ?? []).map((suggestion) => [suggestion.key, suggestion]));
191
+ const serializedSchemaJson = useSerializedValue(addons?.mentions != null ? (0, addons_1.withMentionsSchema)(schema ?? schemas_1.tiptapSchema) : schema, (nextSchema) => stringifyCachedJson(nextSchema));
192
+ const serializedInitialJson = useSerializedValue(initialJSON, stringifyCachedJson);
193
+ const serializedValueJson = useSerializedValue(valueJSON, stringifyCachedJson, valueJSONRevision);
194
+ const themeJson = useSerializedValue(theme, EditorTheme_1.serializeEditorTheme);
195
+ const addonsJson = useSerializedValue(addons, addons_1.serializeEditorAddons);
196
+ const remoteSelectionsJson = useSerializedValue(remoteSelections, (selections) => serializeRemoteSelections(selections));
155
197
  const syncStateFromUpdate = (0, react_1.useCallback)((update) => {
156
198
  if (!update)
157
199
  return;
@@ -159,6 +201,44 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
159
201
  setHistoryState(update.historyState);
160
202
  selectionRef.current = update.selection;
161
203
  renderedTextLengthRef.current = computeRenderedTextLength(update.renderElements);
204
+ if (typeof update.documentVersion === 'number') {
205
+ documentVersionRef.current = update.documentVersion;
206
+ }
207
+ }, []);
208
+ const syncSelectionStateFromUpdate = (0, react_1.useCallback)((update) => {
209
+ if (!update)
210
+ return;
211
+ setActiveState(update.activeState);
212
+ setHistoryState(update.historyState);
213
+ selectionRef.current = update.selection;
214
+ if (typeof update.documentVersion === 'number') {
215
+ documentVersionRef.current = update.documentVersion;
216
+ }
217
+ }, []);
218
+ const emitContentCallbacksForUpdate = (0, react_1.useCallback)((update, previousDocumentVersion) => {
219
+ if (!update || !bridgeRef.current || bridgeRef.current.isDestroyed)
220
+ return;
221
+ const wantsHtml = typeof onContentChangeRef.current === 'function';
222
+ const wantsJson = typeof onContentChangeJSONRef.current === 'function';
223
+ if (!wantsHtml && !wantsJson)
224
+ return;
225
+ if (previousDocumentVersion != null &&
226
+ typeof update.documentVersion === 'number' &&
227
+ update.documentVersion === previousDocumentVersion) {
228
+ return;
229
+ }
230
+ if (wantsHtml && wantsJson) {
231
+ const snapshot = bridgeRef.current.getContentSnapshot();
232
+ onContentChangeRef.current?.(snapshot.html);
233
+ onContentChangeJSONRef.current?.(snapshot.json);
234
+ return;
235
+ }
236
+ if (wantsHtml) {
237
+ onContentChangeRef.current?.(bridgeRef.current.getHtml());
238
+ }
239
+ if (wantsJson) {
240
+ onContentChangeJSONRef.current?.(bridgeRef.current.getJson());
241
+ }
162
242
  }, []);
163
243
  // Warn if both value and valueJSON are set
164
244
  if (__DEV__ && value != null && valueJSON != null) {
@@ -166,13 +246,8 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
166
246
  'Only value will be used.');
167
247
  }
168
248
  const runAndApply = (0, react_1.useCallback)((mutate, options) => {
249
+ const previousDocumentVersion = documentVersionRef.current;
169
250
  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
251
  const update = mutate();
177
252
  if (!update)
178
253
  return null;
@@ -188,10 +263,10 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
188
263
  head: preservedSelection.head,
189
264
  };
190
265
  }
191
- const htmlAfter = shouldCheckForNoopNativeApply
192
- ? bridgeRef.current.getHtml()
193
- : null;
194
- if (!shouldCheckForNoopNativeApply || htmlBefore !== htmlAfter) {
266
+ const contentChanged = previousDocumentVersion == null ||
267
+ typeof update.documentVersion !== 'number' ||
268
+ update.documentVersion !== previousDocumentVersion;
269
+ if (!options?.skipNativeApplyIfContentUnchanged || contentChanged) {
195
270
  const updateJson = JSON.stringify(update);
196
271
  if (react_native_1.Platform.OS === 'android') {
197
272
  setPendingNativeUpdate((current) => ({
@@ -217,23 +292,18 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
217
292
  onActiveStateChangeRef.current?.(update.activeState);
218
293
  onHistoryStateChangeRef.current?.(update.historyState);
219
294
  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
- }
295
+ emitContentCallbacksForUpdate(update, previousDocumentVersion);
226
296
  }
227
297
  onSelectionChangeRef.current?.(update.selection);
228
298
  return update;
229
- }, [syncStateFromUpdate]);
299
+ }, [emitContentCallbacksForUpdate, syncStateFromUpdate]);
230
300
  (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 }
301
+ const bridgeConfig = maxLength != null || serializedSchemaJson || allowBase64Images
302
+ ? {
303
+ maxLength,
304
+ schemaJson: serializedSchemaJson,
305
+ allowBase64Images,
306
+ }
237
307
  : undefined;
238
308
  const bridge = NativeEditorBridge_1.NativeEditorBridge.create(bridgeConfig);
239
309
  bridgeRef.current = bridge;
@@ -242,11 +312,11 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
242
312
  if (value != null) {
243
313
  bridge.setHtml(value);
244
314
  }
245
- else if (valueJSON != null) {
246
- bridge.setJson(valueJSON);
315
+ else if (serializedValueJson != null) {
316
+ bridge.setJsonString(serializedValueJson);
247
317
  }
248
- else if (initialJSON) {
249
- bridge.setJson(initialJSON);
318
+ else if (serializedInitialJson != null) {
319
+ bridge.setJsonString(serializedInitialJson);
250
320
  }
251
321
  else if (initialContent) {
252
322
  bridge.setHtml(initialContent);
@@ -261,7 +331,12 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
261
331
  setIsReady(false);
262
332
  };
263
333
  // eslint-disable-next-line react-hooks/exhaustive-deps
264
- }, [schema, maxLength, syncStateFromUpdate, Boolean(addons?.mentions), allowBase64Images]);
334
+ }, [
335
+ maxLength,
336
+ syncStateFromUpdate,
337
+ allowBase64Images,
338
+ serializedSchemaJson,
339
+ ]);
265
340
  (0, react_1.useEffect)(() => {
266
341
  if (value == null)
267
342
  return;
@@ -276,19 +351,18 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
276
351
  });
277
352
  }, [value, runAndApply]);
278
353
  (0, react_1.useEffect)(() => {
279
- if (valueJSON == null || value != null)
354
+ if (serializedValueJson == null || value != null)
280
355
  return;
281
356
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
282
357
  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))
358
+ const currentJson = bridgeRef.current.getJsonString();
359
+ if (currentJson === serializedValueJson)
286
360
  return;
287
- runAndApply(() => bridgeRef.current.replaceJson(valueJSON), {
361
+ runAndApply(() => bridgeRef.current.replaceJsonString(serializedValueJson), {
288
362
  suppressContentCallbacks: true,
289
363
  preserveLiveTextSelection: true,
290
364
  });
291
- }, [valueJSON, value, runAndApply]);
365
+ }, [serializedValueJson, value, runAndApply]);
292
366
  const updateToolbarFrame = (0, react_1.useCallback)(() => {
293
367
  const toolbar = toolbarRef.current;
294
368
  if (!toolbar) {
@@ -323,28 +397,24 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
323
397
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
324
398
  return;
325
399
  try {
326
- const update = (0, NativeEditorBridge_1.parseEditorUpdateJson)(event.nativeEvent.updateJson);
400
+ const previousDocumentVersion = documentVersionRef.current;
401
+ const update = bridgeRef.current.parseUpdateJson(event.nativeEvent.updateJson);
327
402
  if (!update)
328
403
  return;
329
404
  syncStateFromUpdate(update);
330
405
  onActiveStateChangeRef.current?.(update.activeState);
331
406
  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
- }
407
+ emitContentCallbacksForUpdate(update, previousDocumentVersion);
338
408
  onSelectionChangeRef.current?.(update.selection);
339
409
  }
340
410
  catch {
341
411
  // Invalid JSON from native — skip
342
412
  }
343
- }, [syncStateFromUpdate]);
413
+ }, [emitContentCallbacksForUpdate, syncStateFromUpdate]);
344
414
  const handleSelectionChange = (0, react_1.useCallback)((event) => {
345
415
  if (!bridgeRef.current || bridgeRef.current.isDestroyed)
346
416
  return;
347
- const { anchor, head } = event.nativeEvent;
417
+ const { anchor, head, stateJson } = event.nativeEvent;
348
418
  let selection;
349
419
  if (anchor === 0 &&
350
420
  head >= renderedTextLengthRef.current &&
@@ -355,8 +425,19 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
355
425
  selection = { type: 'text', anchor, head };
356
426
  }
357
427
  bridgeRef.current.updateSelectionFromNative(anchor, head);
358
- const currentState = bridgeRef.current.getCurrentState();
359
- syncStateFromUpdate(currentState);
428
+ let currentState = null;
429
+ if (typeof stateJson === 'string' && stateJson.length > 0) {
430
+ try {
431
+ currentState = bridgeRef.current.parseUpdateJson(stateJson);
432
+ }
433
+ catch {
434
+ currentState = bridgeRef.current.getSelectionState();
435
+ }
436
+ }
437
+ else {
438
+ currentState = bridgeRef.current.getSelectionState();
439
+ }
440
+ syncSelectionStateFromUpdate(currentState);
360
441
  const nextSelection = selection.type === 'all' ? selection : (currentState?.selection ?? selection);
361
442
  selectionRef.current = nextSelection;
362
443
  if (currentState) {
@@ -364,7 +445,7 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
364
445
  onHistoryStateChangeRef.current?.(currentState.historyState);
365
446
  }
366
447
  onSelectionChangeRef.current?.(nextSelection);
367
- }, [syncStateFromUpdate]);
448
+ }, [syncSelectionStateFromUpdate]);
368
449
  const handleFocusChange = (0, react_1.useCallback)((event) => {
369
450
  const { isFocused: focused } = event.nativeEvent;
370
451
  setIsFocused(focused);
@@ -585,11 +666,37 @@ exports.NativeRichTextEditor = (0, react_1.forwardRef)(function NativeRichTextEd
585
666
  }), [insertImage, runAndApply]);
586
667
  if (!isReady)
587
668
  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);
669
+ const isLinkActive = activeState.marks.link === true;
670
+ const allowsLink = activeState.allowedMarks.includes('link');
671
+ const canInsertImage = activeState.insertableNodes.includes(schemas_2.IMAGE_NODE_NAME);
672
+ const canRequestLink = typeof onRequestLink === 'function';
673
+ const canRequestImage = typeof onRequestImage === 'function';
674
+ const cachedToolbarItems = toolbarItemsSerializationCacheRef.current;
675
+ let toolbarItemsJson;
676
+ if (cachedToolbarItems?.toolbarItems === toolbarItems &&
677
+ cachedToolbarItems.editable === editable &&
678
+ cachedToolbarItems.isLinkActive === isLinkActive &&
679
+ cachedToolbarItems.allowsLink === allowsLink &&
680
+ cachedToolbarItems.canRequestLink === canRequestLink &&
681
+ cachedToolbarItems.canRequestImage === canRequestImage &&
682
+ cachedToolbarItems.canInsertImage === canInsertImage) {
683
+ toolbarItemsJson = cachedToolbarItems.serialized;
684
+ }
685
+ else {
686
+ const mappedItems = mapToolbarItemsForNative(toolbarItems, activeState, editable, onRequestLink, onRequestImage);
687
+ toolbarItemsJson = stringifyCachedJson(mappedItems);
688
+ toolbarItemsSerializationCacheRef.current = {
689
+ toolbarItems,
690
+ editable,
691
+ isLinkActive,
692
+ allowsLink,
693
+ canRequestLink,
694
+ canRequestImage,
695
+ canInsertImage,
696
+ mappedItems,
697
+ serialized: toolbarItemsJson,
698
+ };
699
+ }
593
700
  const usesNativeKeyboardToolbar = toolbarPlacement === 'keyboard' && (react_native_1.Platform.OS === 'ios' || react_native_1.Platform.OS === 'android');
594
701
  const shouldRenderJsToolbar = !usesNativeKeyboardToolbar && showToolbar && editable;
595
702
  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,
@@ -23,7 +23,7 @@ final class EditorLayoutManager: NSLayoutManager {
23
23
  textStorage.enumerateAttribute(
24
24
  RenderBridgeAttributes.blockquoteBorderColor,
25
25
  in: characterRange,
26
- options: []
26
+ options: [.longestEffectiveRangeNotRequired]
27
27
  ) { value, range, _ in
28
28
  guard range.length > 0, let color = value as? UIColor else { return }
29
29
 
@@ -68,7 +68,7 @@ final class EditorLayoutManager: NSLayoutManager {
68
68
  textStorage.enumerateAttribute(
69
69
  RenderBridgeAttributes.listMarkerContext,
70
70
  in: characterRange,
71
- options: []
71
+ options: [.longestEffectiveRangeNotRequired]
72
72
  ) { value, range, _ in
73
73
  guard range.length > 0, let listContext = value as? [String: Any] else { return }
74
74
 
@@ -90,7 +90,7 @@ final class EditorLayoutManager: NSLayoutManager {
90
90
  textStorage.enumerateAttribute(
91
91
  RenderBridgeAttributes.blockquoteBorderColor,
92
92
  in: characterRange,
93
- options: []
93
+ options: [.longestEffectiveRangeNotRequired]
94
94
  ) { value, range, _ in
95
95
  guard range.length > 0, let color = value as? UIColor else { return }
96
96