@verdant-web/tiptap 4.0.0 → 4.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.
@@ -1,10 +1,15 @@
1
1
  import { mergeAttributes, Node } from '@tiptap/core';
2
- import {
2
+ import type {
3
3
  EntityFile,
4
4
  EntityFileSnapshot,
5
- id,
6
5
  ObjectEntity,
7
6
  } from '@verdant-web/store';
7
+ import {
8
+ fileIdAttribute,
9
+ fileKeyAttribute,
10
+ fileNameAttribute,
11
+ fileTypeAttribute,
12
+ } from './attributes.js';
8
13
 
9
14
  export type VerdantMediaFileMap = ObjectEntity<
10
15
  Record<string, File>,
@@ -31,7 +36,6 @@ declare module '@tiptap/core' {
31
36
  }
32
37
  }
33
38
 
34
- const fileIdAttribute = 'data-verdant-file';
35
39
  export const VerdantMediaExtension = Node.create<VerdantMediaExtensionOptions>({
36
40
  name: 'verdant-media',
37
41
  group: 'block',
@@ -43,21 +47,59 @@ export const VerdantMediaExtension = Node.create<VerdantMediaExtensionOptions>({
43
47
  },
44
48
  addAttributes() {
45
49
  return {
50
+ [fileKeyAttribute]: {
51
+ default: null,
52
+ keepOnSplit: false,
53
+ parseHTML: (element) => element.getAttribute(fileKeyAttribute),
54
+ rendered: true,
55
+ },
46
56
  [fileIdAttribute]: {
47
57
  default: null,
58
+ keepOnSplit: false,
59
+ parseHTML: (element) => element.getAttribute(fileIdAttribute),
60
+ rendered: true,
48
61
  },
49
62
  alt: {
50
63
  default: null,
64
+ keepOnSplit: false,
65
+ parseHTML: (element) => element.getAttribute('alt'),
66
+ },
67
+ [fileTypeAttribute]: {
68
+ default: null,
69
+ keepOnSplit: false,
70
+ parseHTML: (element) => element.getAttribute(fileTypeAttribute),
71
+ rendered: true,
72
+ },
73
+ [fileNameAttribute]: {
74
+ default: null,
75
+ keepOnSplit: false,
76
+ parseHTML: (element) => element.getAttribute(fileNameAttribute),
77
+ rendered: true,
51
78
  },
52
79
  };
53
80
  },
54
81
  parseHTML() {
55
82
  return [
56
83
  {
57
- tag: `[${fileIdAttribute}]`,
84
+ tag: `[${fileKeyAttribute}]`,
58
85
  getAttrs: (element) => {
86
+ let fileKey = element.getAttribute(fileKeyAttribute);
87
+
88
+ // back compat
89
+ if (!fileKey && element.getAttribute('data-verdant-file')) {
90
+ fileKey = element.getAttribute('data-verdant-file');
91
+ }
92
+
59
93
  const fileId = element.getAttribute(fileIdAttribute);
94
+ const alt = element.getAttribute('alt');
95
+ const type = element.getAttribute(fileTypeAttribute);
96
+ const name = element.getAttribute(fileNameAttribute);
97
+
60
98
  return {
99
+ [fileKeyAttribute]: fileKey,
100
+ alt,
101
+ [fileTypeAttribute]: type,
102
+ [fileNameAttribute]: name,
61
103
  [fileIdAttribute]: fileId,
62
104
  };
63
105
  },
@@ -65,38 +107,52 @@ export const VerdantMediaExtension = Node.create<VerdantMediaExtensionOptions>({
65
107
  ];
66
108
  },
67
109
  renderHTML(props) {
68
- const fileId = props.node.attrs[fileIdAttribute];
69
- if (!fileId) {
110
+ const fileKey = props.node.attrs[fileKeyAttribute];
111
+ if (!fileKey) {
70
112
  return ['div', props.HTMLAttributes, 'Missing file'];
71
113
  }
72
- const file = this.options.fileMap.get(fileId);
114
+ const file = this.options.fileMap.get(fileKey);
73
115
  if (!file) {
74
116
  return ['div', props.HTMLAttributes, 'Missing file'];
75
117
  }
118
+ const fileId = file.id;
119
+ const baseAttrs = {
120
+ [fileKeyAttribute]: fileKey,
121
+ [fileIdAttribute]: fileId,
122
+ [fileTypeAttribute]: file.type,
123
+ [fileNameAttribute]: file.name,
124
+ };
76
125
  if (file.loading) {
77
126
  // this means the user didn't preload files before rendering...
78
127
  return [
79
128
  'div',
80
- props.HTMLAttributes,
129
+ mergeAttributes(baseAttrs, props.HTMLAttributes),
81
130
  'Loading file. This file was not preloaded before rendering the document.',
82
131
  ];
83
132
  }
84
133
  const type = file.type;
85
134
  if (type?.startsWith('image/')) {
86
- return ['img', mergeAttributes({ src: file.url }, props.HTMLAttributes)];
135
+ return [
136
+ 'img',
137
+ mergeAttributes({ src: file.url, ...baseAttrs }, props.HTMLAttributes),
138
+ ];
87
139
  } else if (type?.startsWith('video/')) {
88
140
  return [
89
141
  'video',
90
- mergeAttributes({ src: file.url }, props.HTMLAttributes),
142
+ mergeAttributes({ src: file.url, ...baseAttrs }, props.HTMLAttributes),
91
143
  ];
92
144
  } else if (type?.startsWith('audio/')) {
93
145
  return [
94
146
  'audio',
95
- mergeAttributes({ src: file.url }, props.HTMLAttributes),
147
+ mergeAttributes({ src: file.url, ...baseAttrs }, props.HTMLAttributes),
96
148
  ];
97
149
  } else {
98
150
  // TODO: render file download
99
- return ['div', props.HTMLAttributes, 'Unsupported file type'];
151
+ return [
152
+ 'div',
153
+ mergeAttributes(baseAttrs, props.HTMLAttributes),
154
+ 'Unsupported file type',
155
+ ];
100
156
  }
101
157
  },
102
158
  onBeforeCreate() {
@@ -123,12 +179,12 @@ export const VerdantMediaExtension = Node.create<VerdantMediaExtensionOptions>({
123
179
  this.editor.on('update', ({ transaction }) => {
124
180
  const fileIds = new Set<string>();
125
181
  transaction.doc.forEach((node) => {
126
- if (node.attrs[fileIdAttribute]) {
127
- fileIds.add(node.attrs[fileIdAttribute]);
182
+ if (node.attrs[fileKeyAttribute]) {
183
+ fileIds.add(node.attrs[fileKeyAttribute]);
128
184
  }
129
185
  });
130
186
  transaction.before.forEach((node) => {
131
- const fileId = node.attrs[fileIdAttribute];
187
+ const fileId = node.attrs[fileKeyAttribute];
132
188
  if (fileId && !fileIds.has(fileId)) {
133
189
  // the file was removed from the document
134
190
  this.options.fileMap.delete(fileId);
@@ -141,13 +197,20 @@ export const VerdantMediaExtension = Node.create<VerdantMediaExtensionOptions>({
141
197
  insertMedia:
142
198
  (file: File) =>
143
199
  ({ commands }) => {
144
- const fileId = id();
145
- this.options.fileMap.set(fileId, file);
200
+ const fileKey = crypto.randomUUID();
201
+ this.options.fileMap.set(fileKey, file);
202
+ const storedFile = this.options.fileMap.get(fileKey);
203
+ if (!storedFile) {
204
+ throw new Error('Failed to store file in file map');
205
+ }
146
206
  return commands.insertContent(
147
207
  {
148
208
  type: this.name,
149
209
  attrs: {
150
- [fileIdAttribute]: fileId,
210
+ [fileKeyAttribute]: fileKey,
211
+ [fileTypeAttribute]: file.type,
212
+ [fileNameAttribute]: file.name,
213
+ [fileIdAttribute]: storedFile.id,
151
214
  },
152
215
  } as any,
153
216
  {
@@ -171,7 +234,7 @@ export const VerdantMediaExtension = Node.create<VerdantMediaExtensionOptions>({
171
234
  });
172
235
 
173
236
  // get the file ID from attrs
174
- const fileId = node.attrs[fileIdAttribute];
237
+ const fileId = node.attrs[fileKeyAttribute];
175
238
  if (!fileId) {
176
239
  root.textContent = 'Missing file';
177
240
  return {
@@ -297,3 +360,127 @@ export async function preloadMedia(files: VerdantMediaFileMap) {
297
360
  }),
298
361
  );
299
362
  }
363
+
364
+ export interface VerdantMediaRendererExtensionOptions {}
365
+
366
+ /**
367
+ * An extension for rendering TipTap documents with Verdant media nodes.
368
+ * - ONLY works if the files being rendered are already synced to a Verdant server.
369
+ * - ONLY works if you process the document JSON with `@verdant-web/tiptap/server`'s `attachFileUrls` function on your server.
370
+ * It's good for static sites or server-side rendering of documents you authored
371
+ * with Verdant.
372
+ */
373
+ export const VerdantMediaRendererExtension =
374
+ Node.create<VerdantMediaRendererExtensionOptions>({
375
+ name: 'verdant-media',
376
+ group: 'block',
377
+ addAttributes() {
378
+ return {
379
+ [fileKeyAttribute]: {
380
+ default: null,
381
+ keepOnSplit: false,
382
+ parseHTML: (element) => element.getAttribute(fileKeyAttribute),
383
+ rendered: true,
384
+ },
385
+ [fileIdAttribute]: {
386
+ default: null,
387
+ keepOnSplit: false,
388
+ parseHTML: (element) => element.getAttribute(fileIdAttribute),
389
+ rendered: true,
390
+ },
391
+ alt: {
392
+ default: null,
393
+ keepOnSplit: false,
394
+ parseHTML: (element) => element.getAttribute('alt'),
395
+ },
396
+ [fileTypeAttribute]: {
397
+ default: null,
398
+ keepOnSplit: false,
399
+ parseHTML: (element) => element.getAttribute(fileTypeAttribute),
400
+ rendered: true,
401
+ },
402
+ [fileNameAttribute]: {
403
+ default: null,
404
+ keepOnSplit: false,
405
+ parseHTML: (element) => element.getAttribute(fileNameAttribute),
406
+ rendered: true,
407
+ },
408
+ src: {
409
+ default: null,
410
+ keepOnSplit: false,
411
+ parseHTML: (element) => element.getAttribute('src'),
412
+ rendered: true,
413
+ },
414
+ };
415
+ },
416
+ parseHTML() {
417
+ return [
418
+ {
419
+ tag: `[${fileKeyAttribute}]`,
420
+ getAttrs: (element) => {
421
+ const fileKey = element.getAttribute(fileKeyAttribute);
422
+ const fileId = element.getAttribute(fileIdAttribute);
423
+ const alt = element.getAttribute('alt');
424
+ const type = element.getAttribute(fileTypeAttribute);
425
+ const name = element.getAttribute(fileNameAttribute);
426
+ const src = element.getAttribute('src');
427
+ return {
428
+ [fileKeyAttribute]: fileKey,
429
+ [fileIdAttribute]: fileId,
430
+ alt,
431
+ src,
432
+ [fileTypeAttribute]: type,
433
+ [fileNameAttribute]: name,
434
+ };
435
+ },
436
+ },
437
+ ];
438
+ },
439
+ renderHTML(props) {
440
+ const src = props.node.attrs.src;
441
+ const fileKey = props.node.attrs[fileKeyAttribute];
442
+ const fileId = props.node.attrs[fileIdAttribute];
443
+ const alt = props.node.attrs.alt;
444
+ const name = props.node.attrs[fileNameAttribute];
445
+ const type = props.node.attrs[fileTypeAttribute];
446
+
447
+ const baseAttrs = {
448
+ [fileKeyAttribute]: fileKey,
449
+ [fileIdAttribute]: fileId,
450
+ alt,
451
+ [fileTypeAttribute]: type,
452
+ [fileNameAttribute]: name,
453
+ };
454
+
455
+ if (!src) {
456
+ return [
457
+ 'div',
458
+ mergeAttributes(baseAttrs, props.HTMLAttributes),
459
+ 'Missing file',
460
+ ];
461
+ }
462
+
463
+ if (type?.startsWith('image/')) {
464
+ return [
465
+ 'img',
466
+ mergeAttributes({ src, ...baseAttrs }, props.HTMLAttributes),
467
+ ];
468
+ } else if (type?.startsWith('video/')) {
469
+ return [
470
+ 'video',
471
+ mergeAttributes({ src, ...baseAttrs }, props.HTMLAttributes),
472
+ ];
473
+ } else if (type?.startsWith('audio/')) {
474
+ return [
475
+ 'audio',
476
+ mergeAttributes({ src, ...baseAttrs }, props.HTMLAttributes),
477
+ ];
478
+ } else {
479
+ return [
480
+ 'a',
481
+ mergeAttributes({ href: src, ...baseAttrs }, props.HTMLAttributes),
482
+ 'Download',
483
+ ];
484
+ }
485
+ },
486
+ });
@@ -0,0 +1,6 @@
1
+ export const verdantIdAttribute = 'data-verdant-oid';
2
+
3
+ export const fileKeyAttribute = 'data-verdant-file-key';
4
+ export const fileTypeAttribute = 'data-verdant-file-type';
5
+ export const fileNameAttribute = 'data-verdant-file-name';
6
+ export const fileIdAttribute = 'data-verdant-file-id';
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './extensions/attributes.js';
1
2
  export * from './extensions/NodeId.js';
2
3
  export * from './extensions/Verdant.js';
3
4
  export * from './extensions/VerdantMedia.js';
@@ -0,0 +1,38 @@
1
+ import type { Server } from '@verdant-web/server';
2
+ import {
3
+ fileIdAttribute,
4
+ fileNameAttribute,
5
+ fileTypeAttribute,
6
+ } from '../extensions/attributes.js';
7
+
8
+ /**
9
+ * Attaches file URLs to all Verdant media nodes in the document, according
10
+ * to the file service attached to your Verdant server.
11
+ */
12
+ export async function attachFileUrls(
13
+ document: any,
14
+ libraryId: string,
15
+ server: Server,
16
+ ) {
17
+ await visitNode(document, libraryId, server);
18
+ return document;
19
+ }
20
+
21
+ async function visitNode(node: any, libraryId: string, server: Server) {
22
+ if (node.type === 'verdant-media') {
23
+ const fileId = node.attrs[fileIdAttribute];
24
+ if (fileId) {
25
+ const file = await server.getFileData(libraryId, fileId);
26
+ if (file) {
27
+ node.attrs.src = file.url;
28
+ node.attrs[fileTypeAttribute] = file.type;
29
+ node.attrs[fileNameAttribute] = file.name;
30
+ node.attrs[fileIdAttribute] = file.id;
31
+ }
32
+ }
33
+ } else if (node.content) {
34
+ await Promise.all(
35
+ node.content.map((child: any) => visitNode(child, libraryId, server)),
36
+ );
37
+ }
38
+ }
@@ -1 +0,0 @@
1
- export {};