@puredesktop/platform-editor 1.0.0-beta.1

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.
Files changed (45) hide show
  1. package/package.json +63 -0
  2. package/src/CollapsePanel.tsx +35 -0
  3. package/src/DocumentDiffView.tsx +130 -0
  4. package/src/DocumentEditor.test.ts +118 -0
  5. package/src/DocumentEditor.tsx +2631 -0
  6. package/src/alignedLineDiff.test.ts +52 -0
  7. package/src/alignedLineDiff.ts +67 -0
  8. package/src/collectionAssetSrc.ts +2 -0
  9. package/src/commentUtils.test.ts +350 -0
  10. package/src/commentUtils.ts +546 -0
  11. package/src/constants/toolKeys.ts +14 -0
  12. package/src/contentFormat.test.ts +27 -0
  13. package/src/contentFormat.ts +18 -0
  14. package/src/editorExtensions.ts +155 -0
  15. package/src/extensions/appliedChangeMark.ts +115 -0
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -0
  17. package/src/extensions/autoReviewPrompts.ts +1442 -0
  18. package/src/extensions/collectionImage.ts +26 -0
  19. package/src/extensions/collectionImagePaste.ts +215 -0
  20. package/src/extensions/commentMark.ts +223 -0
  21. package/src/extensions/documentAssetIdentity.ts +54 -0
  22. package/src/extensions/figure.ts +92 -0
  23. package/src/extensions/footnote.ts +186 -0
  24. package/src/extensions/indexMarker.ts +88 -0
  25. package/src/extensions/mathEditing.ts +239 -0
  26. package/src/extensions/mermaidBlock.tsx +297 -0
  27. package/src/extensions/slashCommands.test.ts +170 -0
  28. package/src/extensions/slashCommands.ts +746 -0
  29. package/src/extensions/smartTypography.test.ts +74 -0
  30. package/src/extensions/smartTypography.ts +120 -0
  31. package/src/index.ts +137 -0
  32. package/src/insertCollectionImage.test.ts +60 -0
  33. package/src/insertCollectionImage.ts +63 -0
  34. package/src/insertInlineAssetKind.test.ts +67 -0
  35. package/src/insertInlineAssetKind.ts +49 -0
  36. package/src/mermaidPreview.test.ts +54 -0
  37. package/src/mermaidPreview.ts +115 -0
  38. package/src/styled.ts +676 -0
  39. package/src/toolbar/ToolBtn.tsx +63 -0
  40. package/src/toolbar/Toolbar.test.tsx +325 -0
  41. package/src/toolbar/Toolbar.tsx +624 -0
  42. package/src/toolbar/groups/HeadingGroup.tsx +153 -0
  43. package/src/toolbar/overlayTypes.ts +28 -0
  44. package/src/useEditorExtensions.ts +22 -0
  45. package/src/utils/markdownUtils.ts +331 -0
@@ -0,0 +1,26 @@
1
+ import Image from '@tiptap/extension-image'
2
+ import { COLLECTION_ASSET_SRC_ATTR } from '../collectionAssetSrc.js'
3
+
4
+ /**
5
+ * Collection document images: data URLs in the editor, relative `assets/…` paths on save
6
+ * via {@link COLLECTION_ASSET_SRC_ATTR} (see platform-ui `normalizeCollectionDocumentHtml`).
7
+ */
8
+ export const CollectionImage = Image.extend({
9
+ addAttributes() {
10
+ return {
11
+ ...this.parent?.(),
12
+ collectionAssetSrc: {
13
+ default: null,
14
+ parseHTML: element => element.getAttribute(COLLECTION_ASSET_SRC_ATTR),
15
+ renderHTML: attributes => {
16
+ const stored = attributes.collectionAssetSrc
17
+ if (!stored || typeof stored !== 'string') return {}
18
+ return { [COLLECTION_ASSET_SRC_ATTR]: stored }
19
+ },
20
+ },
21
+ }
22
+ },
23
+ }).configure({
24
+ inline: false,
25
+ allowBase64: true,
26
+ })
@@ -0,0 +1,215 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
3
+
4
+ const SUPPORTED_MIME = new Set([
5
+ 'image/png',
6
+ 'image/jpeg',
7
+ 'image/gif',
8
+ 'image/webp',
9
+ 'image/svg+xml',
10
+ ])
11
+
12
+ const EXT_FOR_MIME: Record<string, string> = {
13
+ 'image/png': 'png',
14
+ 'image/jpeg': 'jpg',
15
+ 'image/gif': 'gif',
16
+ 'image/webp': 'webp',
17
+ 'image/svg+xml': 'svg',
18
+ }
19
+
20
+ export interface CollectionImagePastePayload {
21
+ name: string
22
+ bytes: Uint8Array
23
+ mimeType: string
24
+ }
25
+
26
+ export interface CollectionImagePasteExtensionOptions {
27
+ onError?: (message: string) => void
28
+ handleImage: (payload: CollectionImagePastePayload) => Promise<void>
29
+ /** Read bytes from an absolute path (external file:// drops). */
30
+ readBinary?: (absolutePath: string) => Promise<Uint8Array>
31
+ }
32
+
33
+ function normalizeMimeType(
34
+ mimeType: string | undefined,
35
+ name: string,
36
+ ): string | undefined {
37
+ const trimmed = mimeType?.trim().toLowerCase()
38
+ if (trimmed && SUPPORTED_MIME.has(trimmed)) return trimmed
39
+ const ext = name.split('.').pop()?.toLowerCase()
40
+ if (ext === 'png') return 'image/png'
41
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'
42
+ if (ext === 'gif') return 'image/gif'
43
+ if (ext === 'webp') return 'image/webp'
44
+ if (ext === 'svg') return 'image/svg+xml'
45
+ return undefined
46
+ }
47
+
48
+ function decodeDataImage(dataUrl: string): CollectionImagePastePayload {
49
+ const match = dataUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i)
50
+ if (!match) throw new Error('Unsupported data URL format.')
51
+ const [, mimeType, payload] = match
52
+ const binary = atob(payload)
53
+ const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
54
+ return {
55
+ bytes,
56
+ mimeType: mimeType.toLowerCase(),
57
+ name: `image.${EXT_FOR_MIME[mimeType.toLowerCase()] ?? 'png'}`,
58
+ }
59
+ }
60
+
61
+ function isSupportedImageFile(file: File): boolean {
62
+ return Boolean(normalizeMimeType(file.type, file.name))
63
+ }
64
+
65
+ function collectImageFiles(dt: DataTransfer): File[] {
66
+ const seen = new Set<File>()
67
+ const add = (file: File | null) => {
68
+ if (file && isSupportedImageFile(file)) seen.add(file)
69
+ }
70
+ for (const file of Array.from(dt.files)) add(file)
71
+ for (const item of Array.from(dt.items)) {
72
+ if (item.kind === 'file') add(item.getAsFile())
73
+ }
74
+ return [...seen]
75
+ }
76
+
77
+ function filePathFromDataTransfer(dt: DataTransfer): string | null {
78
+ const uri =
79
+ dt
80
+ .getData('text/uri-list')
81
+ ?.split('\n')
82
+ .map(line => line.trim())
83
+ .find(line => line && !line.startsWith('#')) ?? null
84
+ if (!uri) return null
85
+ try {
86
+ if (uri.startsWith('file://')) {
87
+ return decodeURIComponent(new URL(uri).pathname)
88
+ }
89
+ if (uri.startsWith('/')) return uri
90
+ } catch {
91
+ return null
92
+ }
93
+ return null
94
+ }
95
+
96
+ function nameFromLocalPath(filePath: string): string {
97
+ const normalized = filePath.replace(/\\/g, '/')
98
+ return normalized.split('/').pop() || 'image.png'
99
+ }
100
+
101
+ /** TipTap paste/drop hook — persistence is injected via `handleImage`. */
102
+ export function createCollectionImagePasteExtension(
103
+ options: CollectionImagePasteExtensionOptions,
104
+ ): Extension {
105
+ const { onError, handleImage, readBinary } = options
106
+
107
+ return Extension.create({
108
+ name: 'collectionImagePaste',
109
+
110
+ addProseMirrorPlugins() {
111
+ const handleFiles = async (files: File[]): Promise<boolean> => {
112
+ const images = files.filter(isSupportedImageFile)
113
+ if (images.length === 0) {
114
+ if (files.length > 0) {
115
+ onError?.(
116
+ 'Unsupported file type. Supported: PNG, JPEG, GIF, WebP, SVG.',
117
+ )
118
+ }
119
+ return false
120
+ }
121
+ for (const file of images) {
122
+ try {
123
+ await handleImage({
124
+ name: file.name,
125
+ bytes: new Uint8Array(await file.arrayBuffer()),
126
+ mimeType: normalizeMimeType(file.type, file.name) ?? 'image/png',
127
+ })
128
+ } catch (error) {
129
+ const message =
130
+ error instanceof Error ? error.message : String(error)
131
+ onError?.(`Could not save image: ${message}`)
132
+ }
133
+ }
134
+ return true
135
+ }
136
+
137
+ const handleExternalFilePath = async (
138
+ absolutePath: string,
139
+ ): Promise<boolean> => {
140
+ if (!readBinary) return false
141
+ const lower = absolutePath.toLowerCase()
142
+ if (!/\.(png|jpe?g|gif|webp|svg)$/.test(lower)) return false
143
+ try {
144
+ const bytes = await readBinary(absolutePath)
145
+ await handleImage({
146
+ name: nameFromLocalPath(absolutePath),
147
+ bytes,
148
+ mimeType: normalizeMimeType(undefined, absolutePath) ?? 'image/png',
149
+ })
150
+ return true
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error)
153
+ onError?.(`Could not import image: ${message}`)
154
+ return false
155
+ }
156
+ }
157
+
158
+ return [
159
+ new Plugin({
160
+ key: new PluginKey('collectionImagePaste'),
161
+ props: {
162
+ handlePaste(_view, event) {
163
+ const dt = event.clipboardData
164
+ if (!dt) return false
165
+
166
+ const files = collectImageFiles(dt)
167
+ if (files.length > 0) {
168
+ event.preventDefault()
169
+ void handleFiles(files)
170
+ return true
171
+ }
172
+
173
+ const text = dt.getData('text/plain')?.trim()
174
+ if (text?.startsWith('data:image/')) {
175
+ event.preventDefault()
176
+ void (async () => {
177
+ try {
178
+ await handleImage(decodeDataImage(text))
179
+ } catch (error) {
180
+ const message =
181
+ error instanceof Error ? error.message : String(error)
182
+ onError?.(`Could not save image: ${message}`)
183
+ }
184
+ })()
185
+ return true
186
+ }
187
+
188
+ return false
189
+ },
190
+ handleDrop(_view, event) {
191
+ const dt = event.dataTransfer
192
+ if (!dt) return false
193
+
194
+ const files = collectImageFiles(dt)
195
+ if (files.length > 0) {
196
+ event.preventDefault()
197
+ void handleFiles(files)
198
+ return true
199
+ }
200
+
201
+ const externalPath = filePathFromDataTransfer(dt)
202
+ if (externalPath) {
203
+ event.preventDefault()
204
+ void handleExternalFilePath(externalPath)
205
+ return true
206
+ }
207
+
208
+ return false
209
+ },
210
+ },
211
+ }),
212
+ ]
213
+ },
214
+ })
215
+ }
@@ -0,0 +1,223 @@
1
+ import { Mark, mergeAttributes } from '@tiptap/core'
2
+ import {
3
+ normalizeCommentReviewStatus,
4
+ normalizeCommentSeverity,
5
+ normalizeCommentType,
6
+ type CommentReviewStatus,
7
+ type CommentSeverity,
8
+ type CommentType,
9
+ } from '../commentUtils.js'
10
+
11
+ declare module '@tiptap/core' {
12
+ interface Commands<ReturnType> {
13
+ commentMark: {
14
+ setCommentMark: (attrs: {
15
+ commentId: string
16
+ commentText: string
17
+ author?: string
18
+ createdAt?: string
19
+ commentType?: CommentType
20
+ subjectText?: string
21
+ reviewStatus?: CommentReviewStatus
22
+ severity?: CommentSeverity
23
+ replies?: string
24
+ resolvedAt?: string
25
+ resolvedBy?: string
26
+ }) => ReturnType
27
+ unsetCommentMark: () => ReturnType
28
+ }
29
+ }
30
+ }
31
+
32
+ export const CommentMark = Mark.create({
33
+ name: 'commentMark',
34
+ inclusive: false,
35
+ excludes: '',
36
+
37
+ addAttributes() {
38
+ return {
39
+ commentId: {
40
+ default: null,
41
+ parseHTML: el => el.getAttribute('data-comment-id'),
42
+ renderHTML: (attrs: { commentId?: string }) =>
43
+ attrs.commentId ? { 'data-comment-id': attrs.commentId } : {},
44
+ },
45
+ commentText: {
46
+ default: null,
47
+ parseHTML: el => el.getAttribute('data-comment-text'),
48
+ renderHTML: (attrs: { commentText?: string }) =>
49
+ attrs.commentText ? { 'data-comment-text': attrs.commentText } : {},
50
+ },
51
+ author: {
52
+ default: null,
53
+ parseHTML: el => el.getAttribute('data-comment-author'),
54
+ renderHTML: (attrs: { author?: string }) =>
55
+ attrs.author ? { 'data-comment-author': attrs.author } : {},
56
+ },
57
+ createdAt: {
58
+ default: null,
59
+ parseHTML: el => el.getAttribute('data-comment-created-at'),
60
+ renderHTML: (attrs: { createdAt?: string }) =>
61
+ attrs.createdAt ? { 'data-comment-created-at': attrs.createdAt } : {},
62
+ },
63
+ commentType: {
64
+ default: 'general',
65
+ parseHTML: el =>
66
+ normalizeCommentType(el.getAttribute('data-comment-type')),
67
+ renderHTML: (attrs: { commentType?: string }) => ({
68
+ 'data-comment-type': normalizeCommentType(attrs.commentType),
69
+ }),
70
+ },
71
+ subjectText: {
72
+ default: null,
73
+ parseHTML: el => el.getAttribute('data-comment-subject'),
74
+ renderHTML: (attrs: { subjectText?: string }) =>
75
+ attrs.subjectText ? { 'data-comment-subject': attrs.subjectText } : {},
76
+ },
77
+ reviewStatus: {
78
+ default: null,
79
+ parseHTML: el => {
80
+ const type = normalizeCommentType(el.getAttribute('data-comment-type'))
81
+ return normalizeCommentReviewStatus(
82
+ el.getAttribute('data-comment-review-status'),
83
+ type,
84
+ )
85
+ },
86
+ renderHTML: (attrs: {
87
+ commentType?: string
88
+ reviewStatus?: string
89
+ }) => {
90
+ const type = normalizeCommentType(attrs.commentType)
91
+ const status =
92
+ attrs.reviewStatus ??
93
+ normalizeCommentReviewStatus(undefined, type)
94
+ return { 'data-comment-review-status': status }
95
+ },
96
+ },
97
+ severity: {
98
+ default: null,
99
+ parseHTML: el => {
100
+ const type = normalizeCommentType(el.getAttribute('data-comment-type'))
101
+ return normalizeCommentSeverity(
102
+ el.getAttribute('data-comment-severity'),
103
+ type,
104
+ )
105
+ },
106
+ renderHTML: (attrs: { commentType?: string; severity?: string }) => {
107
+ const type = normalizeCommentType(attrs.commentType)
108
+ const severity =
109
+ attrs.severity ?? normalizeCommentSeverity(undefined, type)
110
+ return { 'data-comment-severity': severity }
111
+ },
112
+ },
113
+ replies: {
114
+ default: null,
115
+ parseHTML: el => el.getAttribute('data-comment-replies'),
116
+ renderHTML: (attrs: { replies?: string }) =>
117
+ attrs.replies ? { 'data-comment-replies': attrs.replies } : {},
118
+ },
119
+ resolvedAt: {
120
+ default: null,
121
+ parseHTML: el => el.getAttribute('data-comment-resolved-at'),
122
+ renderHTML: (attrs: { resolvedAt?: string }) =>
123
+ attrs.resolvedAt
124
+ ? { 'data-comment-resolved-at': attrs.resolvedAt }
125
+ : {},
126
+ },
127
+ resolvedBy: {
128
+ default: null,
129
+ parseHTML: el => el.getAttribute('data-comment-resolved-by'),
130
+ renderHTML: (attrs: { resolvedBy?: string }) =>
131
+ attrs.resolvedBy
132
+ ? { 'data-comment-resolved-by': attrs.resolvedBy }
133
+ : {},
134
+ },
135
+ }
136
+ },
137
+
138
+ parseHTML() {
139
+ return [
140
+ {
141
+ tag: 'span[data-comment-id]',
142
+ getAttrs: el => ({
143
+ commentId: (el as HTMLElement).getAttribute('data-comment-id'),
144
+ commentText: (el as HTMLElement).getAttribute('data-comment-text'),
145
+ author: (el as HTMLElement).getAttribute('data-comment-author'),
146
+ createdAt: (el as HTMLElement).getAttribute(
147
+ 'data-comment-created-at',
148
+ ),
149
+ commentType: normalizeCommentType(
150
+ (el as HTMLElement).getAttribute('data-comment-type'),
151
+ ),
152
+ subjectText: (el as HTMLElement).getAttribute(
153
+ 'data-comment-subject',
154
+ ),
155
+ reviewStatus: normalizeCommentReviewStatus(
156
+ (el as HTMLElement).getAttribute('data-comment-review-status'),
157
+ normalizeCommentType(
158
+ (el as HTMLElement).getAttribute('data-comment-type'),
159
+ ),
160
+ ),
161
+ severity: normalizeCommentSeverity(
162
+ (el as HTMLElement).getAttribute('data-comment-severity'),
163
+ normalizeCommentType(
164
+ (el as HTMLElement).getAttribute('data-comment-type'),
165
+ ),
166
+ ),
167
+ replies: (el as HTMLElement).getAttribute('data-comment-replies'),
168
+ resolvedAt: (el as HTMLElement).getAttribute(
169
+ 'data-comment-resolved-at',
170
+ ),
171
+ resolvedBy: (el as HTMLElement).getAttribute(
172
+ 'data-comment-resolved-by',
173
+ ),
174
+ }),
175
+ },
176
+ ]
177
+ },
178
+
179
+ renderHTML({ HTMLAttributes, mark }) {
180
+ const attrs = mark.attrs as {
181
+ commentText?: string
182
+ author?: string
183
+ commentType?: string
184
+ resolvedAt?: string
185
+ reviewStatus?: string
186
+ severity?: string
187
+ }
188
+ const commentType = normalizeCommentType(attrs.commentType)
189
+ const title = attrs.author
190
+ ? `${attrs.author}: ${attrs.commentText ?? ''}`
191
+ : attrs.commentText ?? ''
192
+ return [
193
+ 'span',
194
+ mergeAttributes(HTMLAttributes, {
195
+ class: [
196
+ 'comment-mark',
197
+ `comment-mark--${commentType}`,
198
+ attrs.resolvedAt ? 'comment-mark--resolved' : '',
199
+ ]
200
+ .filter(Boolean)
201
+ .join(' '),
202
+ title,
203
+ }),
204
+ 0,
205
+ ]
206
+ },
207
+
208
+ addCommands() {
209
+ return {
210
+ setCommentMark:
211
+ attrs =>
212
+ ({ commands, state }) => {
213
+ const { from, to } = state.selection
214
+ if (from === to) return false
215
+ return commands.setMark(this.name, attrs)
216
+ },
217
+ unsetCommentMark:
218
+ () =>
219
+ ({ commands }) =>
220
+ commands.unsetMark(this.name),
221
+ }
222
+ },
223
+ })
@@ -0,0 +1,54 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { Plugin } from '@tiptap/pm/state'
3
+
4
+ const ASSET_NODE_TYPES = new Set(['table', 'inlineMath', 'blockMath'])
5
+
6
+ function createDocumentAssetId(kind: string): string {
7
+ const id =
8
+ globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)
9
+ return `asset_${kind}_${id}`
10
+ }
11
+
12
+ export const DocumentAssetIdentity = Extension.create({
13
+ name: 'documentAssetIdentity',
14
+
15
+ addGlobalAttributes() {
16
+ return [
17
+ {
18
+ types: [...ASSET_NODE_TYPES],
19
+ attributes: {
20
+ assetId: {
21
+ default: null,
22
+ parseHTML: element =>
23
+ element.getAttribute('data-asset-id')?.trim() || null,
24
+ renderHTML: attributes => {
25
+ const assetId = String(attributes.assetId ?? '').trim()
26
+ return assetId ? { 'data-asset-id': assetId } : {}
27
+ },
28
+ },
29
+ },
30
+ },
31
+ ]
32
+ },
33
+
34
+ addProseMirrorPlugins() {
35
+ return [
36
+ new Plugin({
37
+ appendTransaction: (_transactions, _oldState, newState) => {
38
+ let tr = newState.tr
39
+ let changed = false
40
+ newState.doc.descendants((node, pos) => {
41
+ if (!ASSET_NODE_TYPES.has(node.type.name)) return
42
+ if (node.attrs.assetId) return
43
+ tr = tr.setNodeMarkup(pos, undefined, {
44
+ ...node.attrs,
45
+ assetId: createDocumentAssetId(node.type.name),
46
+ })
47
+ changed = true
48
+ })
49
+ return changed ? tr : null
50
+ },
51
+ }),
52
+ ]
53
+ },
54
+ })
@@ -0,0 +1,92 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core'
2
+
3
+ export interface FigureOptions {
4
+ HTMLAttributes: Record<string, unknown>
5
+ }
6
+
7
+ declare module '@tiptap/core' {
8
+ interface Commands<ReturnType> {
9
+ figure: {
10
+ /** Insert a figure block with an image and an editable caption. */
11
+ insertFigure: (opts: {
12
+ src: string
13
+ alt?: string
14
+ caption?: string
15
+ }) => ReturnType
16
+ }
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Figure block — wraps an image and an editable caption. Markdown
22
+ * round-trip is via `![caption](src)` (CommonMark image syntax). The
23
+ * book's render pipeline already wraps such images in
24
+ * `<figure><img/><figcaption/></figure>` for PDF output, so the
25
+ * caption survives end-to-end even though it serializes through the
26
+ * standard image syntax.
27
+ *
28
+ * Image-only nodes (no caption) are still produced by Image nodes directly
29
+ * for inline images that don't deserve a figure wrapper.
30
+ */
31
+ export const Figure = Node.create<FigureOptions>({
32
+ name: 'figure',
33
+ group: 'block',
34
+ content: 'inline*',
35
+ defining: true,
36
+ isolating: true,
37
+ draggable: true,
38
+
39
+ addOptions() {
40
+ return { HTMLAttributes: {} }
41
+ },
42
+
43
+ addAttributes() {
44
+ return {
45
+ src: {
46
+ default: null,
47
+ parseHTML: el => el.querySelector('img')?.getAttribute('src') ?? null,
48
+ },
49
+ alt: {
50
+ default: '',
51
+ parseHTML: el => el.querySelector('img')?.getAttribute('alt') ?? '',
52
+ },
53
+ }
54
+ },
55
+
56
+ parseHTML() {
57
+ return [
58
+ {
59
+ tag: 'figure',
60
+ getAttrs: element =>
61
+ element instanceof HTMLElement &&
62
+ element.getAttribute('data-type') === 'mermaid'
63
+ ? false
64
+ : null,
65
+ contentElement: 'figcaption',
66
+ },
67
+ ]
68
+ },
69
+
70
+ renderHTML({ node, HTMLAttributes }) {
71
+ const { src, alt } = node.attrs as { src: string | null; alt: string }
72
+ return [
73
+ 'figure',
74
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
75
+ ['img', { src, alt }],
76
+ ['figcaption', {}, 0],
77
+ ]
78
+ },
79
+
80
+ addCommands() {
81
+ return {
82
+ insertFigure:
83
+ ({ src, alt = '', caption = '' }) =>
84
+ ({ commands }) =>
85
+ commands.insertContent({
86
+ type: this.name,
87
+ attrs: { src, alt },
88
+ content: caption ? [{ type: 'text', text: caption }] : [],
89
+ }),
90
+ }
91
+ },
92
+ })