@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verdant-web/tiptap",
3
- "version": "0.1.5",
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": "40.2.4",
30
- "@verdant-web/store": "4.1.6"
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.7.3"
44
+ "@verdant-web/common": "2.8.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "typescript": "5.7.3",
48
- "vitest": "3.0.6",
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.50.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/store": "4.1.6",
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
- return schema.fields.replaceObjectFields(baseField, {
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 { id } from '@verdant-web/store';
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 (!node.isText && (!node.attrs.id || usedIds.has(node.attrs.id))) {
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
- tr.setNodeMarkup(pos, null, {
17
- ...node.attrs,
18
- id: nodeId,
19
- });
20
- usedIds.add(nodeId);
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 defaultAllNodes = [
30
- 'blockquote',
31
- 'bulletList',
32
- 'codeBlock',
33
- 'heading',
34
- 'listItem',
35
- 'orderedList',
36
- 'paragraph',
37
- 'image',
38
- 'mention',
39
- 'table',
40
- 'taskList',
41
- 'taskItem',
42
- 'youtube',
43
- ];
44
- export const NodeIdExtension = (
45
- options: {
46
- nodeTypes?: string[];
47
- } = {},
48
- ) =>
49
- Extension.create({
50
- name: 'nodeId',
51
- addProseMirrorPlugins() {
52
- return [NodeIdPlugin];
53
- },
54
- addGlobalAttributes() {
55
- return [
56
- {
57
- types: options.nodeTypes || defaultAllNodes,
58
- attributes: {
59
- id: {
60
- default: null,
61
- keepOnSplit: false,
62
- parseHTML: (element) => element.getAttribute('data-id'),
63
- renderHTML: (attributes) => {
64
- if (!attributes.id) return {};
65
- return {
66
- 'data-id': attributes.id,
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
+ }