@openeditor/react-native-prose-editor 0.0.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/LICENSE +160 -0
- package/README.md +180 -0
- package/android/build.gradle +69 -0
- package/android/consumer-rules.pro +18 -0
- package/android/src/androidTest/AndroidManifest.xml +8 -0
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
- package/app.plugin.js +62 -0
- package/dist/EditorTheme.d.ts +113 -0
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +191 -0
- package/dist/EditorToolbar.js +1095 -0
- package/dist/NativeEditorBridge.d.ts +325 -0
- package/dist/NativeEditorBridge.js +933 -0
- package/dist/NativeProseViewer.d.ts +53 -0
- package/dist/NativeProseViewer.js +480 -0
- package/dist/NativeRichTextEditor.d.ts +183 -0
- package/dist/NativeRichTextEditor.js +2574 -0
- package/dist/YjsCollaboration.d.ts +90 -0
- package/dist/YjsCollaboration.js +743 -0
- package/dist/addons.d.ts +100 -0
- package/dist/addons.js +82 -0
- package/dist/heightCache.d.ts +6 -0
- package/dist/heightCache.js +45 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +30 -0
- package/dist/schemas.d.ts +37 -0
- package/dist/schemas.js +263 -0
- package/dist/useNativeEditor.d.ts +42 -0
- package/dist/useNativeEditor.js +124 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +327 -0
- package/ios/EditorCore.xcframework/Info.plist +44 -0
- 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 +774 -0
- package/ios/EditorTheme.swift +515 -0
- package/ios/Generated_editor_core.swift +1513 -0
- package/ios/NativeEditorExpoView.swift +3593 -0
- package/ios/NativeEditorModule.swift +624 -0
- package/ios/NativeProseViewerExpoView.swift +276 -0
- package/ios/PositionBridge.swift +558 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +1708 -0
- package/ios/RichTextEditorView.swift +6150 -0
- package/ios/Tests/NativePerformanceTests.swift +687 -0
- package/ios/Tests/PositionBridgeTests.swift +706 -0
- package/ios/Tests/RenderBridgeTests.swift +2236 -0
- package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
- package/ios/editor_coreFFI/module.modulemap +7 -0
- package/ios/editor_coreFFI.h +904 -0
- package/ios/editor_coreFFI.modulemap +7 -0
- package/package.json +74 -0
- 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 +2563 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createYjsCollaborationController = createYjsCollaborationController;
|
|
4
|
+
exports.useYjsCollaboration = useYjsCollaboration;
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const NativeEditorBridge_1 = require("./NativeEditorBridge");
|
|
7
|
+
const DEFAULT_RETRY_BASE_INTERVAL_MS = 500;
|
|
8
|
+
const DEFAULT_RETRY_MAX_INTERVAL_MS = 30000;
|
|
9
|
+
const Y_WEBSOCKET_MESSAGE_QUERY_AWARENESS = 3;
|
|
10
|
+
const DEFAULT_YJS_FRAGMENT_NAME = 'default';
|
|
11
|
+
const EMPTY_DOCUMENT = {
|
|
12
|
+
type: 'doc',
|
|
13
|
+
content: [
|
|
14
|
+
{
|
|
15
|
+
type: 'paragraph',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const SELECTION_AWARENESS_DEBOUNCE_MS = 40;
|
|
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;
|
|
109
|
+
}
|
|
110
|
+
function awarenessToRecord(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));
|
|
133
|
+
}
|
|
134
|
+
function normalizeMessageBytes(data) {
|
|
135
|
+
if (data instanceof ArrayBuffer) {
|
|
136
|
+
return Array.from(new Uint8Array(data));
|
|
137
|
+
}
|
|
138
|
+
if (ArrayBuffer.isView(data)) {
|
|
139
|
+
return Array.from(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
140
|
+
}
|
|
141
|
+
if (typeof data === 'string') {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(data);
|
|
144
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function sendBinaryMessages(socket, messages) {
|
|
153
|
+
if (!socket || socket.readyState !== WebSocket.OPEN)
|
|
154
|
+
return;
|
|
155
|
+
for (const message of messages) {
|
|
156
|
+
socket.send(Uint8Array.from(message).buffer);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function readFirstVarUint(bytes) {
|
|
160
|
+
let value = 0;
|
|
161
|
+
let shift = 0;
|
|
162
|
+
for (let index = 0; index < bytes.length && index < 5; index += 1) {
|
|
163
|
+
const byte = bytes[index];
|
|
164
|
+
value |= (byte & 0x7f) << shift;
|
|
165
|
+
if ((byte & 0x80) === 0) {
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
shift += 7;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function selectionToAwarenessRange(selection) {
|
|
173
|
+
if (selection.type !== 'text')
|
|
174
|
+
return undefined;
|
|
175
|
+
return {
|
|
176
|
+
anchor: selection.anchor ?? 0,
|
|
177
|
+
head: selection.head ?? selection.anchor ?? 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function selectionFromPeerState(state) {
|
|
181
|
+
if (!state || typeof state !== 'object')
|
|
182
|
+
return undefined;
|
|
183
|
+
const selection = state.selection;
|
|
184
|
+
if (!selection || typeof selection !== 'object')
|
|
185
|
+
return undefined;
|
|
186
|
+
const anchor = Number(selection.anchor);
|
|
187
|
+
const head = Number(selection.head);
|
|
188
|
+
if (!Number.isFinite(anchor) || !Number.isFinite(head))
|
|
189
|
+
return undefined;
|
|
190
|
+
return { type: 'text', anchor, head };
|
|
191
|
+
}
|
|
192
|
+
function localSelectionFromPeers(peers) {
|
|
193
|
+
const localPeer = peers.find((peer) => peer.isLocal);
|
|
194
|
+
return localPeer ? selectionFromPeerState(localPeer.state) : undefined;
|
|
195
|
+
}
|
|
196
|
+
function peersToRemoteSelections(peers) {
|
|
197
|
+
return peers.flatMap((peer) => {
|
|
198
|
+
if (peer.isLocal || !peer.state || typeof peer.state !== 'object') {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const state = peer.state;
|
|
202
|
+
const selection = state.selection;
|
|
203
|
+
if (!selection || typeof selection !== 'object') {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const anchor = Number(selection.anchor);
|
|
207
|
+
const head = Number(selection.head);
|
|
208
|
+
if (!Number.isFinite(anchor) || !Number.isFinite(head)) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const user = state.user && typeof state.user === 'object'
|
|
212
|
+
? state.user
|
|
213
|
+
: null;
|
|
214
|
+
return [
|
|
215
|
+
{
|
|
216
|
+
clientId: peer.clientId,
|
|
217
|
+
anchor,
|
|
218
|
+
head,
|
|
219
|
+
color: typeof user?.color === 'string' && user.color.length > 0
|
|
220
|
+
? user.color
|
|
221
|
+
: '#007AFF',
|
|
222
|
+
name: typeof user?.name === 'string' && user.name.length > 0 ? user.name : undefined,
|
|
223
|
+
avatarUrl: typeof user?.avatarUrl === 'string' && user.avatarUrl.length > 0
|
|
224
|
+
? user.avatarUrl
|
|
225
|
+
: undefined,
|
|
226
|
+
isFocused: state.focused !== false,
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function encodeInitialStateKey(encodedState) {
|
|
232
|
+
if (encodedState == null)
|
|
233
|
+
return '';
|
|
234
|
+
return (0, NativeEditorBridge_1.encodeCollaborationStateBase64)(encodedState);
|
|
235
|
+
}
|
|
236
|
+
class YjsCollaborationControllerImpl {
|
|
237
|
+
constructor(options, callbacks = {}) {
|
|
238
|
+
this.socket = null;
|
|
239
|
+
this.destroyed = false;
|
|
240
|
+
this.retryAttempt = 0;
|
|
241
|
+
this.retryTimer = null;
|
|
242
|
+
this.isManuallyDisconnected = false;
|
|
243
|
+
this.pendingAwarenessTimer = null;
|
|
244
|
+
this._peers = [];
|
|
245
|
+
this.callbacks = callbacks;
|
|
246
|
+
this.createWebSocket = options.createWebSocket;
|
|
247
|
+
this.retryIntervalMs = options.retryIntervalMs;
|
|
248
|
+
this.localAwarenessState = {
|
|
249
|
+
user: options.localAwareness,
|
|
250
|
+
focused: false,
|
|
251
|
+
};
|
|
252
|
+
this.bridge = NativeEditorBridge_1.NativeCollaborationBridge.create({
|
|
253
|
+
fragmentName: options.fragmentName ?? DEFAULT_YJS_FRAGMENT_NAME,
|
|
254
|
+
schema: options.schema,
|
|
255
|
+
initialEncodedState: options.initialEncodedState,
|
|
256
|
+
localAwareness: awarenessToRecord(this.localAwarenessState),
|
|
257
|
+
});
|
|
258
|
+
const nativeDocumentJson = this.bridge.getDocumentJson();
|
|
259
|
+
this._peers = this.bridge.getPeers();
|
|
260
|
+
let initialDocumentJson;
|
|
261
|
+
if (options.initialDocumentJson != null) {
|
|
262
|
+
initialDocumentJson = cloneJsonValue(options.initialDocumentJson);
|
|
263
|
+
}
|
|
264
|
+
else if (shouldUseFallbackForNativeDocument(nativeDocumentJson, options)) {
|
|
265
|
+
initialDocumentJson = defaultEmptyDocument(options.schema);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
initialDocumentJson = nativeDocumentJson;
|
|
269
|
+
}
|
|
270
|
+
this._state = {
|
|
271
|
+
documentId: options.documentId,
|
|
272
|
+
status: 'idle',
|
|
273
|
+
isConnected: false,
|
|
274
|
+
documentJson: initialDocumentJson,
|
|
275
|
+
selectionOnValueJSONReset: localSelectionFromPeers(this._peers),
|
|
276
|
+
};
|
|
277
|
+
if (options.connect !== false) {
|
|
278
|
+
this.connect();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
get state() {
|
|
282
|
+
return this._state;
|
|
283
|
+
}
|
|
284
|
+
get peers() {
|
|
285
|
+
return this._peers;
|
|
286
|
+
}
|
|
287
|
+
connect() {
|
|
288
|
+
if (this.destroyed)
|
|
289
|
+
return;
|
|
290
|
+
this.isManuallyDisconnected = false;
|
|
291
|
+
this.cancelRetry();
|
|
292
|
+
if (this.socket &&
|
|
293
|
+
(this.socket.readyState === WebSocket.OPEN ||
|
|
294
|
+
this.socket.readyState === WebSocket.CONNECTING)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.setState({
|
|
298
|
+
status: 'connecting',
|
|
299
|
+
isConnected: false,
|
|
300
|
+
lastError: undefined,
|
|
301
|
+
});
|
|
302
|
+
let socket;
|
|
303
|
+
try {
|
|
304
|
+
socket = this.createWebSocket();
|
|
305
|
+
}
|
|
306
|
+
catch (cause) {
|
|
307
|
+
const error = cause instanceof Error
|
|
308
|
+
? cause
|
|
309
|
+
: new Error('Yjs collaboration transport initialization failed');
|
|
310
|
+
this.setState({
|
|
311
|
+
status: 'error',
|
|
312
|
+
isConnected: false,
|
|
313
|
+
lastError: error,
|
|
314
|
+
});
|
|
315
|
+
this.callbacks.onError?.(error);
|
|
316
|
+
this.scheduleRetry(error);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
this.socket = socket;
|
|
320
|
+
const binarySocket = socket;
|
|
321
|
+
try {
|
|
322
|
+
binarySocket.binaryType = 'arraybuffer';
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// React Native WebSocket implementations may ignore this.
|
|
326
|
+
}
|
|
327
|
+
socket.onopen = () => {
|
|
328
|
+
if (this.destroyed || this.socket !== socket)
|
|
329
|
+
return;
|
|
330
|
+
this.retryAttempt = 0;
|
|
331
|
+
this.cancelRetry();
|
|
332
|
+
this.setState({
|
|
333
|
+
status: 'connected',
|
|
334
|
+
isConnected: true,
|
|
335
|
+
lastError: undefined,
|
|
336
|
+
});
|
|
337
|
+
const result = this.bridge.start();
|
|
338
|
+
this.applyResult(result);
|
|
339
|
+
sendBinaryMessages(socket, result.messages);
|
|
340
|
+
};
|
|
341
|
+
socket.onmessage = (event) => {
|
|
342
|
+
if (this.destroyed || this.socket !== socket)
|
|
343
|
+
return;
|
|
344
|
+
const bytes = normalizeMessageBytes(event.data);
|
|
345
|
+
if (!bytes)
|
|
346
|
+
return;
|
|
347
|
+
if (readFirstVarUint(bytes) === Y_WEBSOCKET_MESSAGE_QUERY_AWARENESS) {
|
|
348
|
+
try {
|
|
349
|
+
this.commitLocalAwareness();
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
this.handleTransportFailure(error instanceof Error
|
|
353
|
+
? error
|
|
354
|
+
: new Error('Yjs collaboration protocol error'), socket);
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
if (this.pendingAwarenessTimer != null) {
|
|
360
|
+
this.commitLocalAwareness();
|
|
361
|
+
}
|
|
362
|
+
const result = this.bridge.handleMessage(bytes);
|
|
363
|
+
this.applyResult(result);
|
|
364
|
+
sendBinaryMessages(socket, result.messages);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
this.handleTransportFailure(error instanceof Error ? error : new Error('Yjs collaboration protocol error'), socket);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
socket.onerror = () => {
|
|
371
|
+
if (this.destroyed || this.socket !== socket)
|
|
372
|
+
return;
|
|
373
|
+
this.handleTransportFailure(new Error('Yjs collaboration transport error'), socket, false);
|
|
374
|
+
};
|
|
375
|
+
socket.onclose = () => {
|
|
376
|
+
if (this.destroyed || this.socket !== socket)
|
|
377
|
+
return;
|
|
378
|
+
this.socket = null;
|
|
379
|
+
this.clearPeers();
|
|
380
|
+
this.setState({
|
|
381
|
+
status: this._state.status === 'error' ? 'error' : 'disconnected',
|
|
382
|
+
isConnected: false,
|
|
383
|
+
});
|
|
384
|
+
this.scheduleRetry(this._state.lastError);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
disconnect() {
|
|
388
|
+
if (this.destroyed)
|
|
389
|
+
return;
|
|
390
|
+
this.isManuallyDisconnected = true;
|
|
391
|
+
this.retryAttempt = 0;
|
|
392
|
+
this.cancelRetry();
|
|
393
|
+
const socket = this.socket;
|
|
394
|
+
this.socket = null;
|
|
395
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
396
|
+
const result = this.bridge.clearLocalAwareness();
|
|
397
|
+
this.applyResult(result);
|
|
398
|
+
sendBinaryMessages(socket, result.messages);
|
|
399
|
+
socket.close();
|
|
400
|
+
}
|
|
401
|
+
if (socket &&
|
|
402
|
+
socket.readyState !== WebSocket.CLOSED &&
|
|
403
|
+
socket.readyState !== WebSocket.CLOSING) {
|
|
404
|
+
try {
|
|
405
|
+
socket.close();
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Ignore close failures while disconnecting locally.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
this.clearPeers();
|
|
412
|
+
this.setState({
|
|
413
|
+
status: 'disconnected',
|
|
414
|
+
isConnected: false,
|
|
415
|
+
lastError: undefined,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
reconnect() {
|
|
419
|
+
this.disconnect();
|
|
420
|
+
this.connect();
|
|
421
|
+
}
|
|
422
|
+
getEncodedState() {
|
|
423
|
+
if (this.destroyed)
|
|
424
|
+
return new Uint8Array();
|
|
425
|
+
return this.bridge.getEncodedState();
|
|
426
|
+
}
|
|
427
|
+
getEncodedStateBase64() {
|
|
428
|
+
if (this.destroyed)
|
|
429
|
+
return '';
|
|
430
|
+
return this.bridge.getEncodedStateBase64();
|
|
431
|
+
}
|
|
432
|
+
applyEncodedState(encodedState) {
|
|
433
|
+
if (this.destroyed)
|
|
434
|
+
return;
|
|
435
|
+
const result = this.bridge.applyEncodedState(encodedState);
|
|
436
|
+
this.applyResult(result);
|
|
437
|
+
sendBinaryMessages(this.socket, result.messages);
|
|
438
|
+
}
|
|
439
|
+
replaceEncodedState(encodedState) {
|
|
440
|
+
if (this.destroyed)
|
|
441
|
+
return;
|
|
442
|
+
const result = this.bridge.replaceEncodedState(encodedState);
|
|
443
|
+
this.applyResult(result);
|
|
444
|
+
sendBinaryMessages(this.socket, result.messages);
|
|
445
|
+
}
|
|
446
|
+
destroy() {
|
|
447
|
+
if (this.destroyed)
|
|
448
|
+
return;
|
|
449
|
+
this.cancelRetry();
|
|
450
|
+
this.cancelPendingAwarenessSync();
|
|
451
|
+
this.disconnect();
|
|
452
|
+
this.destroyed = true;
|
|
453
|
+
this.bridge.destroy();
|
|
454
|
+
}
|
|
455
|
+
updateLocalAwareness(partial) {
|
|
456
|
+
if (this.destroyed)
|
|
457
|
+
return;
|
|
458
|
+
this.localAwarenessState = this.mergeLocalAwareness(partial);
|
|
459
|
+
this.commitLocalAwareness();
|
|
460
|
+
}
|
|
461
|
+
handleLocalDocumentChange(doc) {
|
|
462
|
+
if (this.destroyed)
|
|
463
|
+
return;
|
|
464
|
+
const result = this.bridge.applyLocalDocumentJson(doc);
|
|
465
|
+
this.applyResult(result);
|
|
466
|
+
sendBinaryMessages(this.socket, result.messages);
|
|
467
|
+
if (this.pendingAwarenessTimer != null) {
|
|
468
|
+
this.commitLocalAwareness();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
handleSelectionChange(selection) {
|
|
472
|
+
if (this.destroyed)
|
|
473
|
+
return;
|
|
474
|
+
const nextAwareness = this.mergeLocalAwareness({
|
|
475
|
+
focused: true,
|
|
476
|
+
selection: selectionToAwarenessRange(selection),
|
|
477
|
+
});
|
|
478
|
+
if (localAwarenessEquals(nextAwareness, this.localAwarenessState)) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this.localAwarenessState = nextAwareness;
|
|
482
|
+
this.scheduleAwarenessSync();
|
|
483
|
+
}
|
|
484
|
+
handleFocusChange(focused) {
|
|
485
|
+
if (this.destroyed)
|
|
486
|
+
return;
|
|
487
|
+
const nextAwareness = this.mergeLocalAwareness({ focused });
|
|
488
|
+
if (localAwarenessEquals(nextAwareness, this.localAwarenessState)) {
|
|
489
|
+
if (this.pendingAwarenessTimer != null) {
|
|
490
|
+
this.commitLocalAwareness();
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.localAwarenessState = nextAwareness;
|
|
495
|
+
this.commitLocalAwareness();
|
|
496
|
+
}
|
|
497
|
+
applyResult(result) {
|
|
498
|
+
const didPeersChange = result.peersChanged && result.peers != null;
|
|
499
|
+
if (didPeersChange) {
|
|
500
|
+
this._peers = result.peers;
|
|
501
|
+
}
|
|
502
|
+
if (result.documentChanged && result.documentJson) {
|
|
503
|
+
this.setState({
|
|
504
|
+
documentJson: result.documentJson,
|
|
505
|
+
selectionOnValueJSONReset: localSelectionFromPeers(this._peers),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (didPeersChange) {
|
|
509
|
+
this.callbacks.onPeersChange?.(this._peers);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
clearPeers() {
|
|
513
|
+
if (this._peers.length === 0)
|
|
514
|
+
return;
|
|
515
|
+
this._peers = [];
|
|
516
|
+
this.callbacks.onPeersChange?.(this._peers);
|
|
517
|
+
}
|
|
518
|
+
setState(patch) {
|
|
519
|
+
this._state = {
|
|
520
|
+
...this._state,
|
|
521
|
+
...patch,
|
|
522
|
+
};
|
|
523
|
+
this.callbacks.onStateChange?.(this._state);
|
|
524
|
+
}
|
|
525
|
+
mergeLocalAwareness(partial) {
|
|
526
|
+
return {
|
|
527
|
+
...this.localAwarenessState,
|
|
528
|
+
...partial,
|
|
529
|
+
user: {
|
|
530
|
+
...this.localAwarenessState.user,
|
|
531
|
+
...(partial.user ?? {}),
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
scheduleAwarenessSync() {
|
|
536
|
+
this.cancelPendingAwarenessSync();
|
|
537
|
+
this.pendingAwarenessTimer = setTimeout(() => {
|
|
538
|
+
this.pendingAwarenessTimer = null;
|
|
539
|
+
this.commitLocalAwareness();
|
|
540
|
+
}, SELECTION_AWARENESS_DEBOUNCE_MS);
|
|
541
|
+
}
|
|
542
|
+
cancelPendingAwarenessSync() {
|
|
543
|
+
if (this.pendingAwarenessTimer == null)
|
|
544
|
+
return;
|
|
545
|
+
clearTimeout(this.pendingAwarenessTimer);
|
|
546
|
+
this.pendingAwarenessTimer = null;
|
|
547
|
+
}
|
|
548
|
+
commitLocalAwareness() {
|
|
549
|
+
this.cancelPendingAwarenessSync();
|
|
550
|
+
const result = this.bridge.setLocalAwareness(awarenessToRecord(this.localAwarenessState));
|
|
551
|
+
this.applyResult(result);
|
|
552
|
+
sendBinaryMessages(this.socket, result.messages);
|
|
553
|
+
}
|
|
554
|
+
handleTransportFailure(error, socket, closeSocket = true) {
|
|
555
|
+
if (this.destroyed || this.socket !== socket)
|
|
556
|
+
return;
|
|
557
|
+
this.socket = null;
|
|
558
|
+
if (closeSocket && socket.readyState !== WebSocket.CLOSED) {
|
|
559
|
+
try {
|
|
560
|
+
socket.close();
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// Ignore close failures while reporting the original transport error.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
this.clearPeers();
|
|
567
|
+
this.setState({
|
|
568
|
+
status: 'error',
|
|
569
|
+
isConnected: false,
|
|
570
|
+
lastError: error,
|
|
571
|
+
});
|
|
572
|
+
this.callbacks.onError?.(error);
|
|
573
|
+
this.scheduleRetry(error);
|
|
574
|
+
}
|
|
575
|
+
scheduleRetry(lastError) {
|
|
576
|
+
if (this.destroyed || this.isManuallyDisconnected)
|
|
577
|
+
return;
|
|
578
|
+
const delayMs = this.resolveRetryDelay(lastError);
|
|
579
|
+
if (delayMs == null)
|
|
580
|
+
return;
|
|
581
|
+
this.cancelRetry();
|
|
582
|
+
this.retryAttempt += 1;
|
|
583
|
+
this.retryTimer = setTimeout(() => {
|
|
584
|
+
this.retryTimer = null;
|
|
585
|
+
if (this.destroyed || this.isManuallyDisconnected)
|
|
586
|
+
return;
|
|
587
|
+
this.connect();
|
|
588
|
+
}, delayMs);
|
|
589
|
+
}
|
|
590
|
+
resolveRetryDelay(lastError) {
|
|
591
|
+
if (this.retryIntervalMs === false)
|
|
592
|
+
return null;
|
|
593
|
+
const attempt = this.retryAttempt + 1;
|
|
594
|
+
const value = this.retryIntervalMs == null
|
|
595
|
+
? defaultRetryIntervalMs(attempt)
|
|
596
|
+
: typeof this.retryIntervalMs === 'function'
|
|
597
|
+
? this.retryIntervalMs({
|
|
598
|
+
attempt,
|
|
599
|
+
documentId: this._state.documentId,
|
|
600
|
+
lastError,
|
|
601
|
+
})
|
|
602
|
+
: this.retryIntervalMs;
|
|
603
|
+
if (value === false || value == null) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return value;
|
|
610
|
+
}
|
|
611
|
+
cancelRetry() {
|
|
612
|
+
if (this.retryTimer == null)
|
|
613
|
+
return;
|
|
614
|
+
clearTimeout(this.retryTimer);
|
|
615
|
+
this.retryTimer = null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function defaultRetryIntervalMs(attempt) {
|
|
619
|
+
return Math.min(DEFAULT_RETRY_BASE_INTERVAL_MS * 2 ** Math.max(0, attempt - 1), DEFAULT_RETRY_MAX_INTERVAL_MS);
|
|
620
|
+
}
|
|
621
|
+
function createYjsCollaborationController(options) {
|
|
622
|
+
return new YjsCollaborationControllerImpl(options, {
|
|
623
|
+
onStateChange: options.onStateChange,
|
|
624
|
+
onPeersChange: options.onPeersChange,
|
|
625
|
+
onError: options.onError,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
function useYjsCollaboration(options) {
|
|
629
|
+
const callbacksRef = (0, react_1.useRef)({
|
|
630
|
+
onPeersChange: options.onPeersChange,
|
|
631
|
+
onStateChange: options.onStateChange,
|
|
632
|
+
onError: options.onError,
|
|
633
|
+
});
|
|
634
|
+
callbacksRef.current = {
|
|
635
|
+
onPeersChange: options.onPeersChange,
|
|
636
|
+
onStateChange: options.onStateChange,
|
|
637
|
+
onError: options.onError,
|
|
638
|
+
};
|
|
639
|
+
const createWebSocketRef = (0, react_1.useRef)(options.createWebSocket);
|
|
640
|
+
createWebSocketRef.current = options.createWebSocket;
|
|
641
|
+
const controllerRef = (0, react_1.useRef)(null);
|
|
642
|
+
const initialEncodedStateKey = encodeInitialStateKey(options.initialEncodedState);
|
|
643
|
+
const localAwarenessKey = JSON.stringify(options.localAwareness);
|
|
644
|
+
const schemaKey = JSON.stringify(options.schema ?? null);
|
|
645
|
+
const [state, setState] = (0, react_1.useState)({
|
|
646
|
+
documentId: options.documentId,
|
|
647
|
+
status: 'idle',
|
|
648
|
+
isConnected: false,
|
|
649
|
+
documentJson: initialFallbackDocument(options),
|
|
650
|
+
});
|
|
651
|
+
const [peers, setPeers] = (0, react_1.useState)([]);
|
|
652
|
+
(0, react_1.useEffect)(() => {
|
|
653
|
+
try {
|
|
654
|
+
const controller = new YjsCollaborationControllerImpl({
|
|
655
|
+
...options,
|
|
656
|
+
createWebSocket: () => createWebSocketRef.current(),
|
|
657
|
+
}, {
|
|
658
|
+
onStateChange: (nextState) => {
|
|
659
|
+
setState({ ...nextState });
|
|
660
|
+
callbacksRef.current.onStateChange?.(nextState);
|
|
661
|
+
},
|
|
662
|
+
onPeersChange: (nextPeers) => {
|
|
663
|
+
setPeers([...nextPeers]);
|
|
664
|
+
callbacksRef.current.onPeersChange?.(nextPeers);
|
|
665
|
+
},
|
|
666
|
+
onError: (error) => {
|
|
667
|
+
callbacksRef.current.onError?.(error);
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
controllerRef.current = controller;
|
|
671
|
+
setState({ ...controller.state });
|
|
672
|
+
setPeers([...controller.peers]);
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
const nextError = error instanceof Error
|
|
676
|
+
? error
|
|
677
|
+
: new Error('Yjs collaboration initialization failed');
|
|
678
|
+
const nextState = {
|
|
679
|
+
documentId: options.documentId,
|
|
680
|
+
status: 'error',
|
|
681
|
+
isConnected: false,
|
|
682
|
+
documentJson: initialFallbackDocument(options),
|
|
683
|
+
lastError: nextError,
|
|
684
|
+
};
|
|
685
|
+
controllerRef.current = null;
|
|
686
|
+
setState(nextState);
|
|
687
|
+
setPeers([]);
|
|
688
|
+
callbacksRef.current.onStateChange?.(nextState);
|
|
689
|
+
callbacksRef.current.onError?.(nextError);
|
|
690
|
+
}
|
|
691
|
+
return () => {
|
|
692
|
+
controllerRef.current?.destroy();
|
|
693
|
+
controllerRef.current = null;
|
|
694
|
+
};
|
|
695
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
696
|
+
}, [
|
|
697
|
+
options.documentId,
|
|
698
|
+
options.fragmentName,
|
|
699
|
+
schemaKey,
|
|
700
|
+
initialEncodedStateKey,
|
|
701
|
+
]);
|
|
702
|
+
(0, react_1.useEffect)(() => {
|
|
703
|
+
controllerRef.current?.updateLocalAwareness({
|
|
704
|
+
user: options.localAwareness,
|
|
705
|
+
});
|
|
706
|
+
}, [localAwarenessKey, options.localAwareness]);
|
|
707
|
+
(0, react_1.useEffect)(() => {
|
|
708
|
+
const controller = controllerRef.current;
|
|
709
|
+
if (!controller)
|
|
710
|
+
return;
|
|
711
|
+
if (options.connect === false) {
|
|
712
|
+
controller.disconnect();
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
controller.connect();
|
|
716
|
+
}
|
|
717
|
+
}, [options.connect, options.documentId]);
|
|
718
|
+
return {
|
|
719
|
+
state,
|
|
720
|
+
peers,
|
|
721
|
+
isConnected: state.isConnected,
|
|
722
|
+
connect: () => controllerRef.current?.connect(),
|
|
723
|
+
disconnect: () => controllerRef.current?.disconnect(),
|
|
724
|
+
reconnect: () => controllerRef.current?.reconnect(),
|
|
725
|
+
getEncodedState: () => controllerRef.current?.getEncodedState() ?? new Uint8Array(),
|
|
726
|
+
getEncodedStateBase64: () => controllerRef.current?.getEncodedStateBase64() ??
|
|
727
|
+
(0, NativeEditorBridge_1.encodeCollaborationStateBase64)(new Uint8Array()),
|
|
728
|
+
applyEncodedState: (encodedState) => controllerRef.current?.applyEncodedState(encodedState),
|
|
729
|
+
replaceEncodedState: (encodedState) => controllerRef.current?.replaceEncodedState(encodedState),
|
|
730
|
+
updateLocalAwareness: (partial) => controllerRef.current?.updateLocalAwareness(partial),
|
|
731
|
+
editorBindings: {
|
|
732
|
+
valueJSON: state.documentJson,
|
|
733
|
+
valueJSONUpdateMode: 'reset',
|
|
734
|
+
preserveSelectionOnValueJSONReset: true,
|
|
735
|
+
selectionOnValueJSONReset: state.selectionOnValueJSONReset,
|
|
736
|
+
remoteSelections: peersToRemoteSelections(peers),
|
|
737
|
+
onContentChangeJSON: (doc) => controllerRef.current?.handleLocalDocumentChange(doc),
|
|
738
|
+
onSelectionChange: (selection) => controllerRef.current?.handleSelectionChange(selection),
|
|
739
|
+
onFocus: () => controllerRef.current?.handleFocusChange(true),
|
|
740
|
+
onBlur: () => controllerRef.current?.handleFocusChange(false),
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
}
|