@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.
- package/README.md +18 -0
- package/android/build.gradle +23 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +502 -39
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
- package/dist/NativeEditorBridge.d.ts +36 -1
- package/dist/NativeEditorBridge.js +173 -94
- package/dist/NativeRichTextEditor.d.ts +2 -0
- package/dist/NativeRichTextEditor.js +160 -53
- package/dist/YjsCollaboration.d.ts +2 -0
- package/dist/YjsCollaboration.js +142 -20
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +3 -3
- package/ios/Generated_editor_core.swift +41 -0
- package/ios/NativeEditorExpoView.swift +43 -11
- package/ios/NativeEditorModule.swift +6 -0
- package/ios/PositionBridge.swift +310 -75
- package/ios/RenderBridge.swift +362 -27
- package/ios/RichTextEditorView.swift +1983 -187
- package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
- package/package.json +11 -2
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- 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
|
|
102
|
+
return stringifyCachedJson(remoteSelections);
|
|
103
103
|
}
|
|
104
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!
|
|
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
|
-
|
|
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
|
|
232
|
-
?
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 (
|
|
246
|
-
bridge.
|
|
315
|
+
else if (serializedValueJson != null) {
|
|
316
|
+
bridge.setJsonString(serializedValueJson);
|
|
247
317
|
}
|
|
248
|
-
else if (
|
|
249
|
-
bridge.
|
|
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
|
-
}, [
|
|
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 (
|
|
354
|
+
if (serializedValueJson == null || value != null)
|
|
280
355
|
return;
|
|
281
356
|
if (!bridgeRef.current || bridgeRef.current.isDestroyed)
|
|
282
357
|
return;
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
361
|
+
runAndApply(() => bridgeRef.current.replaceJsonString(serializedValueJson), {
|
|
288
362
|
suppressContentCallbacks: true,
|
|
289
363
|
preserveLiveTextSelection: true,
|
|
290
364
|
});
|
|
291
|
-
}, [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
}, [
|
|
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
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
const
|
|
592
|
-
const
|
|
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;
|
package/dist/YjsCollaboration.js
CHANGED
|
@@ -17,11 +17,119 @@ const EMPTY_DOCUMENT = {
|
|
|
17
17
|
],
|
|
18
18
|
};
|
|
19
19
|
const SELECTION_AWARENESS_DEBOUNCE_MS = 40;
|
|
20
|
-
function
|
|
21
|
-
|
|
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
|
|
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:
|
|
144
|
-
?
|
|
145
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
}, [
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|