@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.
- package/README.md +21 -2
- 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 +37 -1
- package/dist/NativeEditorBridge.js +192 -97
- package/dist/NativeRichTextEditor.d.ts +3 -2
- package/dist/NativeRichTextEditor.js +164 -56
- package/dist/YjsCollaboration.d.ts +2 -0
- package/dist/YjsCollaboration.js +142 -20
- package/dist/schemas.d.ts +2 -0
- package/dist/schemas.js +63 -0
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- 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
|
@@ -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(
|
|
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
|
|
101
|
+
return stringifyCachedJson(remoteSelections);
|
|
103
102
|
}
|
|
104
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!
|
|
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
|
-
|
|
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
|
|
232
|
-
?
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 (
|
|
246
|
-
bridge.
|
|
316
|
+
else if (serializedValueJson != null) {
|
|
317
|
+
bridge.setJsonString(serializedValueJson);
|
|
247
318
|
}
|
|
248
|
-
else if (
|
|
249
|
-
bridge.
|
|
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
|
-
}, [
|
|
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 (
|
|
355
|
+
if (serializedValueJson == null || value != null)
|
|
280
356
|
return;
|
|
281
357
|
if (!bridgeRef.current || bridgeRef.current.isDestroyed)
|
|
282
358
|
return;
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
362
|
+
runAndApply(() => bridgeRef.current.replaceJsonString(serializedValueJson), {
|
|
288
363
|
suppressContentCallbacks: true,
|
|
289
364
|
preserveLiveTextSelection: true,
|
|
290
365
|
});
|
|
291
|
-
}, [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
}, [
|
|
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,
|
|
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
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
const
|
|
592
|
-
const
|
|
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;
|
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,
|
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;
|