@verdant-web/tiptap 1.0.1 → 2.1.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.
@@ -4,10 +4,13 @@ import { createMigration, schema } from '@verdant-web/common';
4
4
  import { createHooks } from '@verdant-web/react';
5
5
  import { ClientDescriptor, ClientWithCollections } from '@verdant-web/store';
6
6
  import { userEvent } from '@vitest/browser/context';
7
- import { ReactNode, Suspense } from 'react';
7
+ import { ChangeEvent, ReactNode, Suspense } from 'react';
8
8
  import { afterAll, beforeAll, expect, it } from 'vitest';
9
9
  import { render } from 'vitest-browser-react';
10
- import { createTipTapFieldSchema } from '../fields.js';
10
+ import {
11
+ createTipTapFieldSchema,
12
+ createTipTapFileMapSchema,
13
+ } from '../fields.js';
11
14
  import { useSyncedEditor } from '../react.js';
12
15
 
13
16
  const testSchema = schema({
@@ -25,6 +28,7 @@ const testSchema = schema({
25
28
  content: [],
26
29
  },
27
30
  }),
31
+ files: createTipTapFileMapSchema(),
28
32
  },
29
33
  }),
30
34
  },
@@ -32,6 +36,19 @@ const testSchema = schema({
32
36
 
33
37
  const hooks = createHooks(testSchema);
34
38
 
39
+ function safeLogger(level: string, ...args: any[]) {
40
+ try {
41
+ if (args.some((arg) => typeof arg === 'string' && arg.includes('Redo')))
42
+ console.debug(
43
+ ...args.map((arg) =>
44
+ typeof arg === 'object' ? JSON.stringify(arg, undefined, ' ') : arg,
45
+ ),
46
+ );
47
+ } catch (err) {
48
+ console.debug(...args);
49
+ }
50
+ }
51
+
35
52
  let clientDesc: ClientDescriptor;
36
53
  let client: ClientWithCollections;
37
54
  beforeAll(async () => {
@@ -40,6 +57,7 @@ beforeAll(async () => {
40
57
  oldSchemas: [testSchema],
41
58
  migrations: [createMigration(testSchema)],
42
59
  namespace: 'tiptatp-test',
60
+ // log: safeLogger,
43
61
  });
44
62
  client = await (clientDesc.open() as Promise<ClientWithCollections>);
45
63
  });
@@ -107,20 +125,20 @@ it('should support non-nullable tiptap schema fields', async () => {
107
125
  {
108
126
  type: 'text',
109
127
  attrs: {},
110
- content: [],
111
- marks: [],
128
+ content: null,
129
+ marks: null,
112
130
  from: null,
113
131
  to: null,
114
132
  text: 'Hello, world!',
115
133
  },
116
134
  ],
117
- marks: [],
135
+ marks: null,
118
136
  from: null,
119
137
  to: null,
120
138
  text: null,
121
139
  },
122
140
  ],
123
- marks: [],
141
+ marks: null,
124
142
  text: null,
125
143
  });
126
144
  });
@@ -176,20 +194,20 @@ it('should support nullable tiptap schema fields with a specified default doc',
176
194
  {
177
195
  type: 'text',
178
196
  attrs: {},
179
- content: [],
180
- marks: [],
197
+ content: null,
198
+ marks: null,
181
199
  from: null,
182
200
  to: null,
183
201
  text: 'Hello, world!',
184
202
  },
185
203
  ],
186
- marks: [],
204
+ marks: null,
187
205
  from: null,
188
206
  to: null,
189
207
  text: null,
190
208
  },
191
209
  ],
192
- marks: [],
210
+ marks: null,
193
211
  text: null,
194
212
  });
195
213
  });
@@ -262,7 +280,7 @@ it('should support Verdant undo and redo', async () => {
262
280
  from: null,
263
281
  to: null,
264
282
  content: [],
265
- marks: [],
283
+ marks: null,
266
284
  text: null,
267
285
  });
268
286
  });
@@ -333,15 +351,15 @@ it('should support TipTap undo and redo', async () => {
333
351
  content: [
334
352
  {
335
353
  attrs: {},
336
- content: [],
354
+ content: null,
337
355
  from: null,
338
- marks: [],
356
+ marks: null,
339
357
  text: null,
340
358
  to: null,
341
359
  type: 'paragraph',
342
360
  },
343
361
  ],
344
- marks: [],
362
+ marks: null,
345
363
  text: null,
346
364
  });
347
365
  });
@@ -394,7 +412,202 @@ it('should initialize from an existing document', async () => {
394
412
  const screen = await renderWithProvider(<TipTapTest />);
395
413
  await expect.element(screen.getByTestId('editor')).toBeVisible();
396
414
 
415
+ const editor = screen.getByTestId('editor').getByRole('textbox');
416
+ await expect.element(editor).toHaveTextContent('Hello, world!');
417
+ });
418
+
419
+ function testLog(...args: any[]) {
420
+ console.debug('[TEST]', ...args);
421
+ }
422
+
423
+ it('should withstand multiple edits, undo, and redo consistently', async () => {
424
+ const testPost = await client.posts.put({
425
+ requiredBody: {
426
+ type: 'doc',
427
+ content: [
428
+ {
429
+ type: 'paragraph',
430
+ content: [
431
+ {
432
+ type: 'text',
433
+ text: 'Hello, world!',
434
+ },
435
+ ],
436
+ },
437
+ ],
438
+ },
439
+ });
440
+
441
+ // reset history
442
+ await client.entities.flushAllBatches();
443
+ client.undoHistory.clear();
444
+
445
+ const TipTapTest = () => {
446
+ const editor = useSyncedEditor(testPost, 'requiredBody', {
447
+ editorOptions: {
448
+ extensions: [StarterKit.configure({ history: false })],
449
+ },
450
+ });
451
+
452
+ return (
453
+ <div>
454
+ <div>Text editor:</div>
455
+ <EditorContent
456
+ style={{
457
+ width: 500,
458
+ height: 300,
459
+ }}
460
+ editor={editor}
461
+ id="#editor"
462
+ data-testid="editor"
463
+ />
464
+ </div>
465
+ );
466
+ };
467
+
468
+ const screen = await renderWithProvider(<TipTapTest />);
469
+ await expect.element(screen.getByTestId('editor')).toBeVisible();
470
+
471
+ const editor = screen.getByTestId('editor').getByRole('textbox');
472
+ await expect.element(editor).toHaveTextContent('Hello, world!');
473
+
474
+ await userEvent.type(editor, '[pageDown]');
475
+ await userEvent.type(editor, '\nLine 2!');
476
+
477
+ await client.entities.flushAllBatches();
478
+
479
+ await userEvent.type(editor, '\nLine 3!');
480
+
481
+ await client.entities.flushAllBatches();
482
+
483
+ expect(client.undoHistory.canUndo).toBe(true);
484
+
485
+ await client.undoHistory.undo();
486
+ await client.entities.flushAllBatches();
487
+
488
+ await expect.element(editor).toHaveTextContent(/^Hello, world!Line 2!$/);
489
+
490
+ await client.undoHistory.redo();
491
+ await client.entities.flushAllBatches();
492
+
493
+ await expect
494
+ .element(editor)
495
+ .toHaveTextContent(/^Hello, world!Line 2!Line 3!$/);
496
+
497
+ await client.undoHistory.undo();
498
+ await client.entities.flushAllBatches();
499
+
500
+ await expect.element(editor).toHaveTextContent(/^Hello, world!Line 2!$/);
501
+
502
+ // I've noticed some strangeness when restoring a deleted block
503
+ // when the editor is not on the same line as the deletion,
504
+ // not sure if this is actually related to cursor position but
505
+ // testing anyway.
506
+ await userEvent.type(editor, '[arrowUp]');
507
+
508
+ await client.undoHistory.redo();
509
+ await client.entities.flushAllBatches();
510
+
511
+ await expect
512
+ .element(editor)
513
+ .toHaveTextContent(/^Hello, world!Line 2!Line 3!$/);
514
+ });
515
+
516
+ it('should support media nodes', async () => {
517
+ const testPost = await client.posts.put({});
518
+
519
+ // reset history
520
+ await client.entities.flushAllBatches();
521
+ client.undoHistory.clear();
522
+
523
+ const user = userEvent.setup();
524
+
525
+ const TipTapTest = () => {
526
+ const editor = useSyncedEditor(testPost, 'requiredBody', {
527
+ editorOptions: {
528
+ extensions: [StarterKit.configure({ history: false })],
529
+ },
530
+ files: testPost.get('files'),
531
+ });
532
+
533
+ const insertMedia = async (ev: ChangeEvent<HTMLInputElement>) => {
534
+ const file = ev.target.files?.[0];
535
+ if (!file) return;
536
+ editor?.chain().insertMedia(file).run();
537
+ };
538
+
539
+ return (
540
+ <div>
541
+ <input type="file" onChange={insertMedia} data-testid="insert-media" />
542
+ <div>Text editor:</div>
543
+ <EditorContent
544
+ style={{
545
+ width: 500,
546
+ height: 300,
547
+ }}
548
+ editor={editor}
549
+ id="#editor"
550
+ data-testid="editor"
551
+ />
552
+ </div>
553
+ );
554
+ };
555
+
556
+ // load the image fixture to a file
557
+ const imageRes = await fetch('/cat.jpg', { method: 'GET' });
558
+ const imageBlob = await imageRes.blob();
559
+
560
+ const screen = await renderWithProvider(<TipTapTest />);
561
+ await expect.element(screen.getByTestId('editor')).toBeVisible();
562
+
397
563
  const editor = screen.getByTestId('editor').getByRole('textbox');
398
564
  await expect.element(editor).toHaveTextContent('');
565
+
566
+ // send keystrokes to the editor
567
+ await user.type(editor, 'Hello, world!');
399
568
  await expect.element(editor).toHaveTextContent('Hello, world!');
569
+
570
+ // simulate paste of an image
571
+ // load the image into the clipboard
572
+ await user.upload(
573
+ screen.getByTestId('insert-media'),
574
+ new File([imageBlob], 'cat.jpg', { type: 'image/jpeg' }),
575
+ );
576
+
577
+ // the image should be visible in the editor
578
+ await expect.element(screen.getByRole('img')).toBeVisible();
579
+
580
+ const audioRes = await fetch('/cat.m4a', { method: 'GET' });
581
+ const audioBlob = await audioRes.blob();
582
+ await user.upload(
583
+ screen.getByTestId('insert-media'),
584
+ new File([audioBlob], 'cat.m4a', { type: 'audio/m4a' }),
585
+ );
586
+
587
+ // the audio should be visible in the editor
588
+ const audio = screen.container.querySelector('audio');
589
+ expect(audio).not.toBe(null);
590
+ await expect.element(audio!).toBeVisible();
591
+
592
+ const videoRes = await fetch('/cat.mp4', { method: 'GET' });
593
+ const videoBlob = await videoRes.blob();
594
+ await user.upload(
595
+ screen.getByTestId('insert-media'),
596
+ new File([videoBlob], 'cat.mp4', { type: 'video/mp4' }),
597
+ );
598
+
599
+ // the video should be visible in the editor
600
+ const video = screen.container.querySelector('video');
601
+ expect(video).not.toBe(null);
602
+ await expect.element(video!).toBeVisible();
603
+
604
+ const files = testPost.get('files');
605
+ expect(files.values().length).toBe(3);
606
+
607
+ // delete all media nodes; the files should also be removed from Verdant.
608
+ await user.keyboard('{Delete}{Delete}{Delete}');
609
+ await expect
610
+ .element(screen.getByTestId('editor'))
611
+ .toHaveTextContent('Hello, world!');
612
+ expect(files.values().length).toBe(0);
400
613
  });
@@ -0,0 +1,67 @@
1
+ import { Extension } from '@tiptap/core';
2
+
3
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
4
+ import { id } from '@verdant-web/store';
5
+
6
+ const NodeIdPlugin = new Plugin({
7
+ key: new PluginKey('node-ids'),
8
+ appendTransaction: (_, oldState, newState) => {
9
+ // no changes
10
+ if (newState.doc === oldState.doc) return;
11
+ const tr = newState.tr;
12
+ // force replacement of any duplicates, too
13
+ const usedIds = new Set<string>();
14
+ newState.doc.descendants((node, pos) => {
15
+ if (
16
+ !node.isText &&
17
+ (!node.attrs.id || usedIds.has(node.attrs.id)) &&
18
+ node !== newState.doc
19
+ ) {
20
+ const nodeId = id();
21
+ try {
22
+ tr.setNodeMarkup(pos, null, {
23
+ ...node.attrs,
24
+ id: nodeId,
25
+ });
26
+ usedIds.add(nodeId);
27
+ } catch (err) {
28
+ console.error('Error assigning node ID', err);
29
+ }
30
+ } else if (node.attrs?.id) {
31
+ usedIds.add(node.attrs.id);
32
+ }
33
+ });
34
+ return tr;
35
+ },
36
+ });
37
+ export const NodeIdExtension = Extension.create({
38
+ name: 'nodeId',
39
+ addProseMirrorPlugins() {
40
+ return [NodeIdPlugin];
41
+ },
42
+ addOptions() {
43
+ return {
44
+ types: [],
45
+ };
46
+ },
47
+ addGlobalAttributes() {
48
+ return [
49
+ {
50
+ types: this.options.types,
51
+ attributes: {
52
+ id: {
53
+ default: null,
54
+ keepOnSplit: false,
55
+ parseHTML: (element) => element.getAttribute('data-id'),
56
+ renderHTML: (attributes) => {
57
+ if (!attributes.id) return {};
58
+ return {
59
+ 'data-id': attributes.id,
60
+ };
61
+ },
62
+ },
63
+ },
64
+ },
65
+ ];
66
+ },
67
+ });
@@ -1,75 +1,6 @@
1
1
  import { Extension, JSONContent } from '@tiptap/core';
2
- import { Plugin, PluginKey } from '@tiptap/pm/state';
3
2
  import { assignOid, cloneDeep, maybeGetOid } from '@verdant-web/common';
4
- import {
5
- AnyEntity,
6
- getEntityClient,
7
- id,
8
- ObjectEntity,
9
- } from '@verdant-web/store';
10
-
11
- const NodeIdPlugin = new Plugin({
12
- key: new PluginKey('node-ids'),
13
- appendTransaction: (_, oldState, newState) => {
14
- // no changes
15
- if (newState.doc === oldState.doc) return;
16
- const tr = newState.tr;
17
- // force replacement of any duplicates, too
18
- const usedIds = new Set<string>();
19
- newState.doc.descendants((node, pos) => {
20
- if (
21
- !node.isText &&
22
- (!node.attrs.id || usedIds.has(node.attrs.id)) &&
23
- node !== newState.doc
24
- ) {
25
- const nodeId = id();
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
- }
35
- } else if (node.attrs?.id) {
36
- usedIds.add(node.attrs.id);
37
- }
38
- });
39
- return tr;
40
- },
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
- });
3
+ import { AnyEntity, getEntityClient, ObjectEntity } from '@verdant-web/store';
73
4
 
74
5
  export const verdantIdAttribute = 'data-verdant-oid';
75
6
  export interface VerdantExtensionOptions {
@@ -180,7 +111,7 @@ export const VerdantExtension = Extension.create<
180
111
  const field = parent.get(fieldName) as ObjectEntity<any, any> | null;
181
112
  if (field) {
182
113
  unsubscribe = field.subscribe('changeDeep', (target, info) => {
183
- if (!info.isLocal || target === field) {
114
+ if (!field.deleted && (!info.isLocal || target === field)) {
184
115
  updateFromField(field);
185
116
  }
186
117
  });
@@ -210,7 +141,7 @@ export const VerdantExtension = Extension.create<
210
141
  } else {
211
142
  // re-assign oids to data objects so they can be diffed more effectively
212
143
  // against existing data
213
- consumeOidsAndAssignToSnapshots(newData);
144
+ formatSnapshotForVerdantAndAssignOids(newData);
214
145
  // printAllOids(newData);
215
146
  const client = getEntityClient(value);
216
147
  client.batch(this.options.batchConfig).run(() => {
@@ -264,13 +195,29 @@ export function createVerdantExtension<
264
195
  });
265
196
  }
266
197
 
267
- function consumeOidsAndAssignToSnapshots(doc: JSONContent) {
198
+ function formatSnapshotForVerdantAndAssignOids(doc: JSONContent) {
268
199
  if (doc.attrs?.[verdantIdAttribute] !== undefined) {
269
200
  assignOid(doc, doc.attrs[verdantIdAttribute]);
270
201
  delete doc.attrs[verdantIdAttribute];
271
202
  }
203
+ // make sure all fields are present, even if null.
204
+ if (doc.from === undefined) {
205
+ doc.from = null;
206
+ }
207
+ if (doc.to === undefined) {
208
+ doc.to = null;
209
+ }
210
+ if (doc.text === undefined) {
211
+ doc.text = null as any;
212
+ }
213
+ if (doc.attrs === undefined) {
214
+ doc.attrs = {};
215
+ }
216
+ if (doc.marks === undefined) {
217
+ doc.marks = null as any;
218
+ }
272
219
  if (doc.content) {
273
- doc.content.forEach(consumeOidsAndAssignToSnapshots);
220
+ doc.content.forEach(formatSnapshotForVerdantAndAssignOids);
274
221
  }
275
222
  }
276
223
 
@@ -279,7 +226,7 @@ function consumeOidsAndAssignToSnapshots(doc: JSONContent) {
279
226
  function ensureDocShape(json: any) {
280
227
  for (const node of json.content ?? []) {
281
228
  // remove undefined nodes
282
- node.content = node.content.filter((n: any) => !!n).map(ensureDocShape);
229
+ node.content = node.content?.filter((n: any) => !!n).map(ensureDocShape);
283
230
  }
284
231
  return json;
285
232
  }
@@ -311,13 +258,3 @@ function addOidAttrs(doc: JSONContent) {
311
258
  doc.content.forEach(addOidAttrs);
312
259
  }
313
260
  }
314
-
315
- function printAllOids(obj: any) {
316
- if (obj && typeof obj === 'object') {
317
- const oid = maybeGetOid(obj);
318
- if (oid) console.log(oid, obj);
319
- for (const key in obj) {
320
- printAllOids(obj[key]);
321
- }
322
- }
323
- }