@verdant-web/tiptap 0.1.5 → 1.0.0
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/dist/esm/__browserTests__/react.test.js +108 -0
- package/dist/esm/__browserTests__/react.test.js.map +1 -1
- package/dist/esm/fields.js +26 -8
- package/dist/esm/fields.js.map +1 -1
- package/dist/esm/plugins.d.ts +26 -3
- package/dist/esm/plugins.js +202 -21
- package/dist/esm/plugins.js.map +1 -1
- package/dist/esm/react.d.ts +4 -6
- package/dist/esm/react.js +10 -70
- package/dist/esm/react.js.map +1 -1
- package/package.json +9 -9
- package/src/__browserTests__/react.test.tsx +152 -0
- package/src/fields.ts +29 -8
- package/src/plugins.ts +297 -50
- package/src/react.ts +23 -110
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@verdant-web/tiptap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"access": "public",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"@tiptap/pm": "^2.11.5",
|
|
27
27
|
"@tiptap/react": "^2.11.5",
|
|
28
28
|
"react": "^19.0.0",
|
|
29
|
-
"@verdant-web/react": "
|
|
30
|
-
"@verdant-web/store": "4.
|
|
29
|
+
"@verdant-web/react": "41.0.0",
|
|
30
|
+
"@verdant-web/store": "4.2.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependenciesMeta": {
|
|
33
33
|
"@verdant-web/react": {
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@verdant-web/common": "2.
|
|
44
|
+
"@verdant-web/common": "2.8.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"typescript": "5.7.3",
|
|
48
|
-
"vitest": "3.0.
|
|
48
|
+
"vitest": "3.0.8",
|
|
49
49
|
"@tiptap/core": "^2.11.5",
|
|
50
50
|
"@tiptap/pm": "^2.11.5",
|
|
51
51
|
"@tiptap/react": "^2.11.5",
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"@types/react": "19.0.10",
|
|
55
55
|
"vitest-browser-react": "0.1.1",
|
|
56
56
|
"@vitest/browser": "3.0.6",
|
|
57
|
-
"playwright": "1.
|
|
57
|
+
"playwright": "1.51.0",
|
|
58
58
|
"vite": "6.1.1",
|
|
59
59
|
"@vitejs/plugin-react": "4.3.4",
|
|
60
60
|
"react-dom": "19.0.0",
|
|
61
61
|
"@types/react-dom": "19.0.4",
|
|
62
|
+
"@verdant-web/react": "41.0.0",
|
|
63
|
+
"@verdant-web/store": "4.2.0",
|
|
62
64
|
"@verdant-web/cli": "4.8.2",
|
|
63
|
-
"@verdant-web/
|
|
64
|
-
"@verdant-web/server": "3.3.8",
|
|
65
|
-
"@verdant-web/react": "40.2.4"
|
|
65
|
+
"@verdant-web/server": "3.3.9"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"test": "vitest",
|
|
@@ -193,3 +193,155 @@ it('should support nullable tiptap schema fields with a specified default doc',
|
|
|
193
193
|
text: null,
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
it('should support Verdant undo and redo', async () => {
|
|
198
|
+
const testPost = await client.posts.put({});
|
|
199
|
+
|
|
200
|
+
// reset history
|
|
201
|
+
await client.entities.flushAllBatches();
|
|
202
|
+
client.undoHistory.clear();
|
|
203
|
+
|
|
204
|
+
const TipTapTest = () => {
|
|
205
|
+
const editor = useSyncedEditor(testPost, 'requiredBody', {
|
|
206
|
+
editorOptions: {
|
|
207
|
+
extensions: [
|
|
208
|
+
StarterKit.configure({
|
|
209
|
+
// using Verdant history.
|
|
210
|
+
history: false,
|
|
211
|
+
}),
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
// NOTE: this config is not required in regular usage, I'm tweaking batching
|
|
215
|
+
// to be sure it captures the entire testing change in one batch for undo to
|
|
216
|
+
// make the test predictable.
|
|
217
|
+
extensionOptions: {
|
|
218
|
+
batchConfig: {
|
|
219
|
+
// capture up to 100 changes in the batch.
|
|
220
|
+
max: 100,
|
|
221
|
+
timeout: null,
|
|
222
|
+
batchName: 'tiptap',
|
|
223
|
+
undoable: true,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div>
|
|
230
|
+
<div>Text editor:</div>
|
|
231
|
+
<EditorContent
|
|
232
|
+
style={{
|
|
233
|
+
width: 500,
|
|
234
|
+
height: 300,
|
|
235
|
+
}}
|
|
236
|
+
editor={editor}
|
|
237
|
+
id="#editor"
|
|
238
|
+
data-testid="editor"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const screen = await renderWithProvider(<TipTapTest />);
|
|
245
|
+
await expect.element(screen.getByTestId('editor')).toBeVisible();
|
|
246
|
+
|
|
247
|
+
const editor = screen.getByTestId('editor').getByRole('textbox');
|
|
248
|
+
await expect.element(editor).toHaveTextContent('');
|
|
249
|
+
|
|
250
|
+
// send keystrokes to the editor
|
|
251
|
+
await userEvent.type(editor, 'Hello, world!');
|
|
252
|
+
await expect.element(editor).toHaveTextContent('Hello, world!');
|
|
253
|
+
|
|
254
|
+
await client.entities.flushAllBatches();
|
|
255
|
+
|
|
256
|
+
expect(client.undoHistory.undoLength).toBe(1);
|
|
257
|
+
await client.undoHistory.undo();
|
|
258
|
+
|
|
259
|
+
expect(testPost.get('requiredBody').getSnapshot()).toEqual({
|
|
260
|
+
type: 'doc',
|
|
261
|
+
attrs: {},
|
|
262
|
+
from: null,
|
|
263
|
+
to: null,
|
|
264
|
+
content: [],
|
|
265
|
+
marks: [],
|
|
266
|
+
text: null,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should support TipTap undo and redo', async () => {
|
|
271
|
+
const testPost = await client.posts.put({});
|
|
272
|
+
|
|
273
|
+
// reset history
|
|
274
|
+
await client.entities.flushAllBatches();
|
|
275
|
+
client.undoHistory.clear();
|
|
276
|
+
|
|
277
|
+
const TipTapTest = () => {
|
|
278
|
+
const editor = useSyncedEditor(testPost, 'requiredBody', {
|
|
279
|
+
editorOptions: {
|
|
280
|
+
extensions: [StarterKit],
|
|
281
|
+
},
|
|
282
|
+
extensionOptions: {
|
|
283
|
+
batchConfig: {
|
|
284
|
+
undoable: false,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div>
|
|
291
|
+
<div>Text editor:</div>
|
|
292
|
+
<EditorContent
|
|
293
|
+
style={{
|
|
294
|
+
width: 500,
|
|
295
|
+
height: 300,
|
|
296
|
+
}}
|
|
297
|
+
editor={editor}
|
|
298
|
+
id="#editor"
|
|
299
|
+
data-testid="editor"
|
|
300
|
+
/>
|
|
301
|
+
<button data-testid="undo" onClick={() => editor!.commands.undo()}>
|
|
302
|
+
Undo
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const screen = await renderWithProvider(<TipTapTest />);
|
|
309
|
+
await expect.element(screen.getByTestId('editor')).toBeVisible();
|
|
310
|
+
|
|
311
|
+
const editor = screen.getByTestId('editor').getByRole('textbox');
|
|
312
|
+
await expect.element(editor).toHaveTextContent('');
|
|
313
|
+
|
|
314
|
+
// send keystrokes to the editor
|
|
315
|
+
await userEvent.type(editor, 'Hello, world!');
|
|
316
|
+
await expect.element(editor).toHaveTextContent('Hello, world!');
|
|
317
|
+
|
|
318
|
+
await client.entities.flushAllBatches();
|
|
319
|
+
|
|
320
|
+
// Verdant did not capture the change in undo history.
|
|
321
|
+
expect(client.undoHistory.undoLength).toBe(0);
|
|
322
|
+
|
|
323
|
+
await userEvent.click(screen.getByTestId('undo'));
|
|
324
|
+
await expect.element(editor).toHaveTextContent('');
|
|
325
|
+
|
|
326
|
+
// notably, TipTap history doesn't seem to remove the paragraph node,
|
|
327
|
+
// but to the user this behaves as expected.
|
|
328
|
+
expect(testPost.get('requiredBody').getSnapshot()).toEqual({
|
|
329
|
+
type: 'doc',
|
|
330
|
+
attrs: {},
|
|
331
|
+
from: null,
|
|
332
|
+
to: null,
|
|
333
|
+
content: [
|
|
334
|
+
{
|
|
335
|
+
attrs: {},
|
|
336
|
+
content: [],
|
|
337
|
+
from: null,
|
|
338
|
+
marks: [],
|
|
339
|
+
text: null,
|
|
340
|
+
to: null,
|
|
341
|
+
type: 'paragraph',
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
marks: [],
|
|
345
|
+
text: null,
|
|
346
|
+
});
|
|
347
|
+
});
|
package/src/fields.ts
CHANGED
|
@@ -39,17 +39,11 @@ export function createTipTapFieldSchema(options: {
|
|
|
39
39
|
'createTiptapFieldSchema requires a default value. Specify "null" to make the field nullable.',
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
|
+
|
|
42
43
|
const baseField = schema.fields.object({
|
|
43
44
|
fields: {},
|
|
44
|
-
default: () => {
|
|
45
|
-
if (options.default === null) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
return structuredClone(options.default);
|
|
49
|
-
},
|
|
50
|
-
nullable: options.default === null,
|
|
51
45
|
});
|
|
52
|
-
|
|
46
|
+
const nestedContent = schema.fields.replaceObjectFields(baseField, {
|
|
53
47
|
type: schema.fields.string(),
|
|
54
48
|
from: schema.fields.number({ nullable: true }),
|
|
55
49
|
to: schema.fields.number({ nullable: true }),
|
|
@@ -64,4 +58,31 @@ export function createTipTapFieldSchema(options: {
|
|
|
64
58
|
items: baseField,
|
|
65
59
|
}),
|
|
66
60
|
});
|
|
61
|
+
|
|
62
|
+
const rootField = schema.fields.object({
|
|
63
|
+
fields: {
|
|
64
|
+
type: schema.fields.string(),
|
|
65
|
+
from: schema.fields.number({ nullable: true }),
|
|
66
|
+
to: schema.fields.number({ nullable: true }),
|
|
67
|
+
attrs: schema.fields.map({
|
|
68
|
+
values: schema.fields.any(),
|
|
69
|
+
}),
|
|
70
|
+
content: schema.fields.array({
|
|
71
|
+
items: nestedContent,
|
|
72
|
+
}),
|
|
73
|
+
text: schema.fields.string({ nullable: true }),
|
|
74
|
+
marks: schema.fields.array({
|
|
75
|
+
items: nestedContent,
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
default: () => {
|
|
79
|
+
if (options.default === null) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return structuredClone(options.default);
|
|
83
|
+
},
|
|
84
|
+
nullable: options.default === null,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return rootField as any;
|
|
67
88
|
}
|
package/src/plugins.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { Extension } from '@tiptap/core';
|
|
1
|
+
import { Extension, JSONContent } from '@tiptap/core';
|
|
2
2
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
3
|
-
import {
|
|
3
|
+
import { assignOid, cloneDeep, maybeGetOid } from '@verdant-web/common';
|
|
4
|
+
import {
|
|
5
|
+
AnyEntity,
|
|
6
|
+
getEntityClient,
|
|
7
|
+
id,
|
|
8
|
+
ObjectEntity,
|
|
9
|
+
} from '@verdant-web/store';
|
|
4
10
|
|
|
5
11
|
const NodeIdPlugin = new Plugin({
|
|
6
12
|
key: new PluginKey('node-ids'),
|
|
@@ -11,13 +17,21 @@ const NodeIdPlugin = new Plugin({
|
|
|
11
17
|
// force replacement of any duplicates, too
|
|
12
18
|
const usedIds = new Set<string>();
|
|
13
19
|
newState.doc.descendants((node, pos) => {
|
|
14
|
-
if (
|
|
20
|
+
if (
|
|
21
|
+
!node.isText &&
|
|
22
|
+
(!node.attrs.id || usedIds.has(node.attrs.id)) &&
|
|
23
|
+
node !== newState.doc
|
|
24
|
+
) {
|
|
15
25
|
const nodeId = id();
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
try {
|
|
27
|
+
tr.setNodeMarkup(pos, null, {
|
|
28
|
+
...node.attrs,
|
|
29
|
+
id: nodeId,
|
|
30
|
+
});
|
|
31
|
+
usedIds.add(nodeId);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('Error assigning node ID', err);
|
|
34
|
+
}
|
|
21
35
|
} else if (node.attrs?.id) {
|
|
22
36
|
usedIds.add(node.attrs.id);
|
|
23
37
|
}
|
|
@@ -25,50 +39,283 @@ const NodeIdPlugin = new Plugin({
|
|
|
25
39
|
return tr;
|
|
26
40
|
},
|
|
27
41
|
});
|
|
42
|
+
export const NodeIdExtension = Extension.create({
|
|
43
|
+
name: 'nodeId',
|
|
44
|
+
addProseMirrorPlugins() {
|
|
45
|
+
return [NodeIdPlugin];
|
|
46
|
+
},
|
|
47
|
+
addOptions() {
|
|
48
|
+
return {
|
|
49
|
+
types: [],
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
addGlobalAttributes() {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
types: this.options.types,
|
|
56
|
+
attributes: {
|
|
57
|
+
id: {
|
|
58
|
+
default: null,
|
|
59
|
+
keepOnSplit: false,
|
|
60
|
+
parseHTML: (element) => element.getAttribute('data-id'),
|
|
61
|
+
renderHTML: (attributes) => {
|
|
62
|
+
if (!attributes.id) return {};
|
|
63
|
+
return {
|
|
64
|
+
'data-id': attributes.id,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
},
|
|
72
|
+
});
|
|
28
73
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
export const verdantIdAttribute = 'data-verdant-oid';
|
|
75
|
+
export interface VerdantExtensionOptions {
|
|
76
|
+
parent: AnyEntity<any, any, any>;
|
|
77
|
+
fieldName: string | number;
|
|
78
|
+
nullDocumentDefault?: any;
|
|
79
|
+
batchConfig?: {
|
|
80
|
+
undoable?: boolean;
|
|
81
|
+
batchName?: string;
|
|
82
|
+
max?: number | null;
|
|
83
|
+
timeout?: number | null;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export const VerdantExtension = Extension.create<
|
|
87
|
+
VerdantExtensionOptions,
|
|
88
|
+
{ updating: boolean; unsubscribe: (() => void) | null }
|
|
89
|
+
>({
|
|
90
|
+
name: 'verdant',
|
|
91
|
+
addOptions() {
|
|
92
|
+
return {
|
|
93
|
+
parent: null as any,
|
|
94
|
+
fieldName: '',
|
|
95
|
+
nullDocumentDefault: null,
|
|
96
|
+
batchConfig: {
|
|
97
|
+
undoable: true,
|
|
98
|
+
batchName: 'tiptap',
|
|
99
|
+
max: null,
|
|
100
|
+
timeout: 600,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
addStorage() {
|
|
105
|
+
return {
|
|
106
|
+
updating: false,
|
|
107
|
+
unsubscribe: null,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
addGlobalAttributes() {
|
|
111
|
+
const nodeTypes = this.extensions
|
|
112
|
+
.filter((ext) => ext.type === 'node')
|
|
113
|
+
.filter((ext) => ext.config.group !== 'inline')
|
|
114
|
+
.map((ext) => ext.name)
|
|
115
|
+
.filter((s) => s !== null);
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
types: nodeTypes,
|
|
119
|
+
attributes: {
|
|
120
|
+
[verdantIdAttribute]: {
|
|
121
|
+
default: null,
|
|
122
|
+
keepOnSplit: false,
|
|
123
|
+
parseHTML: (element) => element.getAttribute(verdantIdAttribute),
|
|
124
|
+
renderHTML: (attributes) => {
|
|
125
|
+
if (!attributes[verdantIdAttribute]) return {};
|
|
126
|
+
return {
|
|
127
|
+
[verdantIdAttribute]: attributes[verdantIdAttribute],
|
|
128
|
+
};
|
|
69
129
|
},
|
|
70
130
|
},
|
|
71
131
|
},
|
|
72
|
-
|
|
73
|
-
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
},
|
|
135
|
+
onBeforeCreate() {
|
|
136
|
+
const { parent, fieldName, nullDocumentDefault } = this.options;
|
|
137
|
+
// validate options
|
|
138
|
+
if (!parent) {
|
|
139
|
+
throw new Error('VerdantOidExtension requires a parent entity');
|
|
140
|
+
}
|
|
141
|
+
if (!fieldName) {
|
|
142
|
+
throw new Error('VerdantOidExtension requires a field name');
|
|
143
|
+
}
|
|
144
|
+
const fieldSchema = parent.getFieldSchema(fieldName);
|
|
145
|
+
if (!fieldSchema) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`VerdantOidExtension error: ${fieldName} is not a valid field of the parent entity`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (fieldSchema.type !== 'object') {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`VerdantOidExtension requires an object field for the document, ${fieldName} is a ${fieldSchema.type}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (fieldSchema.nullable && !fieldSchema.default && !nullDocumentDefault) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'VerdantOidExtension requires a nullDocumentDefault for a nullable document field',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// subscribe to field changes
|
|
162
|
+
let unsubscribe: (() => void) | null = null;
|
|
163
|
+
const updateFromField = (field: ObjectEntity<any, any> | null) => {
|
|
164
|
+
if (this.editor && !this.editor.isDestroyed) {
|
|
165
|
+
this.storage.updating = true;
|
|
166
|
+
const { from, to } = this.editor.state.selection;
|
|
167
|
+
this.editor.commands.setContent(
|
|
168
|
+
ensureDocShape(
|
|
169
|
+
getFieldSnapshot(field, nullDocumentDefault, fieldName),
|
|
170
|
+
),
|
|
171
|
+
false,
|
|
172
|
+
);
|
|
173
|
+
this.editor.commands.setTextSelection({ from, to });
|
|
174
|
+
this.storage.updating = false;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const subscribeToDocumentChanges = () => {
|
|
178
|
+
const field = parent.get(fieldName) as ObjectEntity<any, any> | null;
|
|
179
|
+
if (field) {
|
|
180
|
+
unsubscribe = field.subscribe('changeDeep', (target, info) => {
|
|
181
|
+
if (!info.isLocal || target === field) {
|
|
182
|
+
updateFromField(field);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
updateFromField(field);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
this.storage.unsubscribe = parent.subscribe('change', (info) => {
|
|
189
|
+
unsubscribe?.();
|
|
190
|
+
subscribeToDocumentChanges();
|
|
191
|
+
});
|
|
192
|
+
subscribeToDocumentChanges();
|
|
193
|
+
},
|
|
194
|
+
onUpdate() {
|
|
195
|
+
// this flag is set synchronously while applying changes from the entity
|
|
196
|
+
// to the editor. if it's true, we don't want to apply changes from the editor
|
|
197
|
+
// back to the entity again, making an infinite cycle.
|
|
198
|
+
if (this.storage.updating) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const newData = this.editor.getJSON();
|
|
203
|
+
const value = this.options.parent.get(
|
|
204
|
+
this.options.fieldName,
|
|
205
|
+
) as ObjectEntity<any, any> | null;
|
|
206
|
+
if (!value) {
|
|
207
|
+
this.options.parent.set(this.options.fieldName as any, newData);
|
|
208
|
+
} else {
|
|
209
|
+
// re-assign oids to data objects so they can be diffed more effectively
|
|
210
|
+
// against existing data
|
|
211
|
+
consumeOidsAndAssignToSnapshots(newData);
|
|
212
|
+
// printAllOids(newData);
|
|
213
|
+
const client = getEntityClient(value);
|
|
214
|
+
client.batch(this.options.batchConfig).run(() => {
|
|
215
|
+
value.update(newData, {
|
|
216
|
+
merge: false,
|
|
217
|
+
dangerouslyDisableMerge: true,
|
|
218
|
+
replaceSubObjects: false,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
onDestroy() {
|
|
224
|
+
this.storage.unsubscribe?.();
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
export type ValidEntityKey<Ent extends AnyEntity<any, any, any>> =
|
|
229
|
+
Ent extends never
|
|
230
|
+
? string
|
|
231
|
+
: Ent extends AnyEntity<any, any, infer Shape>
|
|
232
|
+
? keyof Shape
|
|
233
|
+
: never;
|
|
234
|
+
|
|
235
|
+
export type EntitySnapshot<
|
|
236
|
+
Ent extends AnyEntity<any, any, any>,
|
|
237
|
+
Key extends ValidEntityKey<Ent>,
|
|
238
|
+
> =
|
|
239
|
+
Ent extends AnyEntity<any, any, infer Snap>
|
|
240
|
+
? Key extends keyof Snap
|
|
241
|
+
? Snap[Key]
|
|
242
|
+
: any
|
|
243
|
+
: never;
|
|
244
|
+
|
|
245
|
+
export function createVerdantExtension<
|
|
246
|
+
TEnt extends AnyEntity<any, any, any>,
|
|
247
|
+
Key extends ValidEntityKey<TEnt>,
|
|
248
|
+
>(
|
|
249
|
+
parent: TEnt,
|
|
250
|
+
fieldName: Key,
|
|
251
|
+
options?: Omit<
|
|
252
|
+
VerdantExtensionOptions,
|
|
253
|
+
'parent' | 'fieldName' | 'nullDocumentDefault'
|
|
254
|
+
> & {
|
|
255
|
+
nullDocumentDefault?: EntitySnapshot<TEnt, Key>;
|
|
256
|
+
},
|
|
257
|
+
) {
|
|
258
|
+
return VerdantExtension.configure({
|
|
259
|
+
parent,
|
|
260
|
+
fieldName: fieldName as string | number,
|
|
261
|
+
...options,
|
|
74
262
|
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function consumeOidsAndAssignToSnapshots(doc: JSONContent) {
|
|
266
|
+
if (doc.attrs?.[verdantIdAttribute] !== undefined) {
|
|
267
|
+
assignOid(doc, doc.attrs[verdantIdAttribute]);
|
|
268
|
+
delete doc.attrs[verdantIdAttribute];
|
|
269
|
+
}
|
|
270
|
+
if (doc.content) {
|
|
271
|
+
doc.content.forEach(consumeOidsAndAssignToSnapshots);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// since the schema doesn't enforce this shape but it's
|
|
276
|
+
// needed for the editor to work, we'll ensure it here
|
|
277
|
+
function ensureDocShape(json: any) {
|
|
278
|
+
for (const node of json.content ?? []) {
|
|
279
|
+
// remove undefined nodes
|
|
280
|
+
node.content = node.content.filter((n: any) => !!n).map(ensureDocShape);
|
|
281
|
+
}
|
|
282
|
+
return json;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getFieldSnapshot(
|
|
286
|
+
field: ObjectEntity<any, any> | undefined | null,
|
|
287
|
+
nullDocumentDefault: any,
|
|
288
|
+
fieldName: string | symbol | number,
|
|
289
|
+
) {
|
|
290
|
+
const content = field
|
|
291
|
+
? cloneDeep(field.getSnapshot())
|
|
292
|
+
: (nullDocumentDefault ?? null);
|
|
293
|
+
if (content === null) {
|
|
294
|
+
throw new Error(`The provided field "${String(fieldName)}" is null and a default document was not provided.
|
|
295
|
+
Please provide a default document or ensure the field is not null when calling useSyncedEditor, or make your
|
|
296
|
+
field schema non-null and specify a default document there.`);
|
|
297
|
+
}
|
|
298
|
+
addOidAttrs(content);
|
|
299
|
+
return content;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function addOidAttrs(doc: JSONContent) {
|
|
303
|
+
const oid = maybeGetOid(doc);
|
|
304
|
+
if (oid) {
|
|
305
|
+
doc.attrs = doc.attrs ?? {};
|
|
306
|
+
doc.attrs[verdantIdAttribute] = oid;
|
|
307
|
+
}
|
|
308
|
+
if (doc.content) {
|
|
309
|
+
doc.content.forEach(addOidAttrs);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function printAllOids(obj: any) {
|
|
314
|
+
if (obj && typeof obj === 'object') {
|
|
315
|
+
const oid = maybeGetOid(obj);
|
|
316
|
+
if (oid) console.log(oid, obj);
|
|
317
|
+
for (const key in obj) {
|
|
318
|
+
printAllOids(obj[key]);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|