@byline/richtext-lexical 6.4.0 → 6.6.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/field/extensions/code-highlight/code-highlight-extension.js +2 -1
- package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
- package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
- package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
- package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
- package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
- package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
- package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
- package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
- package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
- package/dist/field/extensions/inline-image/populate.d.ts +22 -0
- package/dist/field/extensions/inline-image/populate.js +19 -1
- package/dist/field/extensions/inline-image/types.d.ts +7 -0
- package/dist/field/extensions/link/link-href.d.ts +30 -0
- package/dist/field/extensions/link/link-href.js +14 -0
- package/dist/field/extensions/link/link-modal.js +96 -288
- package/dist/field/extensions/link/link-target-fields.css +59 -0
- package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
- package/dist/field/extensions/link/link-target-fields.js +227 -0
- package/dist/field/extensions/link/link-target-state.d.ts +54 -0
- package/dist/field/extensions/link/link-target-state.js +70 -0
- package/dist/field/extensions/link/populate.d.ts +15 -1
- package/dist/field/extensions/link/populate.js +55 -50
- package/dist/field/markdown/lexical-to-markdown.js +6 -1
- package/dist/field/plugins/toolbar-plugin/index.js +2 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +4 -2
- package/dist/validate/createEmptyEditorState.d.ts +2 -2
- package/dist/validate/hasText.d.ts +2 -2
- package/package.json +25 -24
- package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
- package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
- package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
- package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
- package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
- package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
- package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
- package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
- package/src/field/extensions/inline-image/node-types.ts +19 -0
- package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
- package/src/field/extensions/inline-image/populate.ts +44 -0
- package/src/field/extensions/inline-image/types.ts +7 -0
- package/src/field/extensions/link/link-href.test.node.ts +78 -0
- package/src/field/extensions/link/link-href.ts +52 -0
- package/src/field/extensions/link/link-modal.tsx +86 -325
- package/src/field/extensions/link/link-target-fields.css +81 -0
- package/src/field/extensions/link/link-target-fields.tsx +275 -0
- package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
- package/src/field/extensions/link/link-target-state.ts +158 -0
- package/src/field/extensions/link/populate.ts +94 -67
- package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
- package/src/field/markdown/lexical-to-markdown.ts +10 -1
- package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
- package/src/server.ts +11 -5
- package/src/validate/createEmptyEditorState.ts +2 -7
- package/src/validate/hasText.test.ts +1 -2
- package/src/validate/hasText.ts +2 -9
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serialization coverage for the inline image's optional click-through
|
|
11
|
+
* link.
|
|
12
|
+
*
|
|
13
|
+
* The JSON round-trip is the load-bearing one: copying an inline image in
|
|
14
|
+
* the editor and pasting it elsewhere goes through
|
|
15
|
+
* `application/x-lexical-editor` (priority 0, ahead of `text/html` at 10 —
|
|
16
|
+
* see `@lexical/clipboard`'s `DEFAULT_IMPORT_MIME_TYPE_PRIORITY`), which is
|
|
17
|
+
* `exportJSON` → `importJSON`, NOT `exportDOM` / `importDOM`. So the link
|
|
18
|
+
* surviving `exportJSON`/`importJSON` is what makes copy-paste keep it.
|
|
19
|
+
*
|
|
20
|
+
* The DOM round-trip is the secondary path — pasting out to a non-Lexical
|
|
21
|
+
* target and back, or between editors whose namespaces differ. It encodes
|
|
22
|
+
* the relation on the `<img>` as `data-link-*` attributes rather than
|
|
23
|
+
* wrapping in an `<a>`, because an `<a>` would make Lexical's own link
|
|
24
|
+
* conversion produce a link node wrapping an inline-image node on import.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createEditor, type LexicalEditor } from 'lexical'
|
|
28
|
+
import { describe, expect, it } from 'vitest'
|
|
29
|
+
|
|
30
|
+
import { $createInlineImageNode, $isInlineImageNode, InlineImageNode } from './inline-image-node'
|
|
31
|
+
import type { LinkAttributes } from '../link'
|
|
32
|
+
import type { InlineImageAttributes, SerializedInlineImageNode } from './node-types'
|
|
33
|
+
|
|
34
|
+
function editorWithImageNode(): LexicalEditor {
|
|
35
|
+
return createEditor({
|
|
36
|
+
namespace: 'test',
|
|
37
|
+
nodes: [InlineImageNode],
|
|
38
|
+
onError: (e) => {
|
|
39
|
+
throw e
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const baseAttributes: InlineImageAttributes = {
|
|
45
|
+
targetDocumentId: 'media-1',
|
|
46
|
+
targetCollectionId: 'coll-media',
|
|
47
|
+
targetCollectionPath: 'media',
|
|
48
|
+
src: 'https://cdn.example.com/media/shot-card.avif',
|
|
49
|
+
altText: 'A photograph of a heron',
|
|
50
|
+
position: 'left',
|
|
51
|
+
width: 600,
|
|
52
|
+
height: 450,
|
|
53
|
+
showCaption: false,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const internalLink: LinkAttributes = {
|
|
57
|
+
linkType: 'internal',
|
|
58
|
+
newTab: false,
|
|
59
|
+
targetDocumentId: 'doc-1',
|
|
60
|
+
targetCollectionId: 'coll-pages',
|
|
61
|
+
targetCollectionPath: 'pages',
|
|
62
|
+
document: { title: 'About Us', path: '/pages/about' },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const customLink: LinkAttributes = {
|
|
66
|
+
linkType: 'custom',
|
|
67
|
+
url: 'https://example.com/report',
|
|
68
|
+
newTab: true,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Serialize a node built from `attributes`, then parse it back. */
|
|
72
|
+
function jsonRoundTrip(attributes: InlineImageAttributes): SerializedInlineImageNode {
|
|
73
|
+
const editor = editorWithImageNode()
|
|
74
|
+
let serialized: SerializedInlineImageNode | undefined
|
|
75
|
+
editor.update(
|
|
76
|
+
() => {
|
|
77
|
+
const exported = $createInlineImageNode(attributes).exportJSON()
|
|
78
|
+
const reimported = InlineImageNode.importJSON(exported)
|
|
79
|
+
serialized = reimported.exportJSON()
|
|
80
|
+
},
|
|
81
|
+
{ discrete: true }
|
|
82
|
+
)
|
|
83
|
+
if (serialized == null) throw new Error('round-trip produced nothing')
|
|
84
|
+
return serialized
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('InlineImageNode link serialization', () => {
|
|
88
|
+
describe('exportJSON', () => {
|
|
89
|
+
it('omits link entirely when the image has no click target', () => {
|
|
90
|
+
const editor = editorWithImageNode()
|
|
91
|
+
let out: SerializedInlineImageNode | undefined
|
|
92
|
+
editor.update(
|
|
93
|
+
() => {
|
|
94
|
+
out = $createInlineImageNode(baseAttributes).exportJSON()
|
|
95
|
+
},
|
|
96
|
+
{ discrete: true }
|
|
97
|
+
)
|
|
98
|
+
expect(out?.link).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('carries an internal link', () => {
|
|
102
|
+
const editor = editorWithImageNode()
|
|
103
|
+
let out: SerializedInlineImageNode | undefined
|
|
104
|
+
editor.update(
|
|
105
|
+
() => {
|
|
106
|
+
out = $createInlineImageNode({ ...baseAttributes, link: internalLink }).exportJSON()
|
|
107
|
+
},
|
|
108
|
+
{ discrete: true }
|
|
109
|
+
)
|
|
110
|
+
expect(out?.link).toEqual(internalLink)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('JSON round-trip (the copy-paste path)', () => {
|
|
115
|
+
it('preserves an internal link', () => {
|
|
116
|
+
expect(jsonRoundTrip({ ...baseAttributes, link: internalLink }).link).toEqual(internalLink)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('preserves a custom URL link with its newTab flag', () => {
|
|
120
|
+
expect(jsonRoundTrip({ ...baseAttributes, link: customLink }).link).toEqual(customLink)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('leaves an unlinked image unlinked', () => {
|
|
124
|
+
expect(jsonRoundTrip(baseAttributes).link).toBeUndefined()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('keeps the media relation and the link relation distinct', () => {
|
|
128
|
+
const out = jsonRoundTrip({ ...baseAttributes, link: internalLink })
|
|
129
|
+
expect(out.targetDocumentId).toBe('media-1')
|
|
130
|
+
expect(out.targetCollectionPath).toBe('media')
|
|
131
|
+
expect(out.link?.linkType === 'internal' && out.link.targetDocumentId).toBe('doc-1')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Nodes written before this feature have no `link` key at all; they
|
|
135
|
+
// must import without complaint and stay unlinked.
|
|
136
|
+
it('imports a pre-feature node that has no link key', () => {
|
|
137
|
+
const editor = editorWithImageNode()
|
|
138
|
+
let node: InlineImageNode | undefined
|
|
139
|
+
editor.update(
|
|
140
|
+
() => {
|
|
141
|
+
const legacy = { ...baseAttributes, type: 'inline-image', version: 1 } as any
|
|
142
|
+
delete legacy.link
|
|
143
|
+
legacy.caption = { editorState: { root: { children: [], type: 'root', version: 1 } } }
|
|
144
|
+
node = InlineImageNode.importJSON(legacy)
|
|
145
|
+
},
|
|
146
|
+
{ discrete: true }
|
|
147
|
+
)
|
|
148
|
+
expect(node && $isInlineImageNode(node)).toBe(true)
|
|
149
|
+
expect(node?.getLink()).toBeUndefined()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('update()', () => {
|
|
154
|
+
it('sets a link on an image that had none', () => {
|
|
155
|
+
const editor = editorWithImageNode()
|
|
156
|
+
let link: LinkAttributes | undefined
|
|
157
|
+
editor.update(
|
|
158
|
+
() => {
|
|
159
|
+
const node = $createInlineImageNode(baseAttributes)
|
|
160
|
+
node.update({ ...baseAttributes, link: internalLink })
|
|
161
|
+
link = node.getLink()
|
|
162
|
+
},
|
|
163
|
+
{ discrete: true }
|
|
164
|
+
)
|
|
165
|
+
expect(link).toEqual(internalLink)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('replaces an existing link', () => {
|
|
169
|
+
const editor = editorWithImageNode()
|
|
170
|
+
let link: LinkAttributes | undefined
|
|
171
|
+
editor.update(
|
|
172
|
+
() => {
|
|
173
|
+
const node = $createInlineImageNode({ ...baseAttributes, link: internalLink })
|
|
174
|
+
node.update({ ...baseAttributes, link: customLink })
|
|
175
|
+
link = node.getLink()
|
|
176
|
+
},
|
|
177
|
+
{ discrete: true }
|
|
178
|
+
)
|
|
179
|
+
expect(link).toEqual(customLink)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// The rest of `update()` uses `!= null` guards, so an explicit
|
|
183
|
+
// undefined would be a silent no-op. Removing a link has to work.
|
|
184
|
+
it('clears the link when the key is present and undefined', () => {
|
|
185
|
+
const editor = editorWithImageNode()
|
|
186
|
+
let link: LinkAttributes | undefined = internalLink
|
|
187
|
+
editor.update(
|
|
188
|
+
() => {
|
|
189
|
+
const node = $createInlineImageNode({ ...baseAttributes, link: internalLink })
|
|
190
|
+
node.update({ ...baseAttributes, link: undefined })
|
|
191
|
+
link = node.getLink()
|
|
192
|
+
},
|
|
193
|
+
{ discrete: true }
|
|
194
|
+
)
|
|
195
|
+
expect(link).toBeUndefined()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('leaves the link untouched when the key is absent', () => {
|
|
199
|
+
const editor = editorWithImageNode()
|
|
200
|
+
let link: LinkAttributes | undefined
|
|
201
|
+
editor.update(
|
|
202
|
+
() => {
|
|
203
|
+
const node = $createInlineImageNode({ ...baseAttributes, link: internalLink })
|
|
204
|
+
node.update({ altText: 'Changed alt' } as InlineImageAttributes)
|
|
205
|
+
link = node.getLink()
|
|
206
|
+
},
|
|
207
|
+
{ discrete: true }
|
|
208
|
+
)
|
|
209
|
+
expect(link).toEqual(internalLink)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('DOM round-trip', () => {
|
|
214
|
+
it('encodes a custom link as data attributes without wrapping in an anchor', () => {
|
|
215
|
+
const editor = editorWithImageNode()
|
|
216
|
+
let element: HTMLElement | undefined
|
|
217
|
+
editor.update(
|
|
218
|
+
() => {
|
|
219
|
+
element = $createInlineImageNode({ ...baseAttributes, link: customLink }).exportDOM()
|
|
220
|
+
.element as HTMLElement
|
|
221
|
+
},
|
|
222
|
+
{ discrete: true }
|
|
223
|
+
)
|
|
224
|
+
expect(element?.tagName).toBe('IMG')
|
|
225
|
+
expect(element?.getAttribute('data-link-type')).toBe('custom')
|
|
226
|
+
expect(element?.getAttribute('data-link-url')).toBe('https://example.com/report')
|
|
227
|
+
expect(element?.getAttribute('data-link-new-tab')).toBe('true')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('encodes an internal link relation as data attributes', () => {
|
|
231
|
+
const editor = editorWithImageNode()
|
|
232
|
+
let element: HTMLElement | undefined
|
|
233
|
+
editor.update(
|
|
234
|
+
() => {
|
|
235
|
+
element = $createInlineImageNode({ ...baseAttributes, link: internalLink }).exportDOM()
|
|
236
|
+
.element as HTMLElement
|
|
237
|
+
},
|
|
238
|
+
{ discrete: true }
|
|
239
|
+
)
|
|
240
|
+
expect(element?.getAttribute('data-link-type')).toBe('internal')
|
|
241
|
+
expect(element?.getAttribute('data-link-target-document-id')).toBe('doc-1')
|
|
242
|
+
expect(element?.getAttribute('data-link-target-collection-id')).toBe('coll-pages')
|
|
243
|
+
expect(element?.getAttribute('data-link-target-collection-path')).toBe('pages')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('emits no link attributes for an unlinked image', () => {
|
|
247
|
+
const editor = editorWithImageNode()
|
|
248
|
+
let element: HTMLElement | undefined
|
|
249
|
+
editor.update(
|
|
250
|
+
() => {
|
|
251
|
+
element = $createInlineImageNode(baseAttributes).exportDOM().element as HTMLElement
|
|
252
|
+
},
|
|
253
|
+
{ discrete: true }
|
|
254
|
+
)
|
|
255
|
+
expect(element?.hasAttribute('data-link-type')).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('reads a custom link back off the data attributes', () => {
|
|
259
|
+
const editor = editorWithImageNode()
|
|
260
|
+
const img = document.createElement('img')
|
|
261
|
+
img.setAttribute('src', baseAttributes.src)
|
|
262
|
+
img.setAttribute('alt', 'A photograph of a heron')
|
|
263
|
+
img.setAttribute('data-id', 'media-1')
|
|
264
|
+
img.setAttribute('data-collection', 'media')
|
|
265
|
+
img.setAttribute('data-link-type', 'custom')
|
|
266
|
+
img.setAttribute('data-link-url', 'https://example.com/report')
|
|
267
|
+
img.setAttribute('data-link-new-tab', 'true')
|
|
268
|
+
|
|
269
|
+
let link: LinkAttributes | undefined
|
|
270
|
+
editor.update(
|
|
271
|
+
() => {
|
|
272
|
+
const conversion = InlineImageNode.importDOM()?.img?.(img)
|
|
273
|
+
const output = conversion?.conversion(img)
|
|
274
|
+
const node = output?.node
|
|
275
|
+
link = $isInlineImageNode(node) ? node.getLink() : undefined
|
|
276
|
+
},
|
|
277
|
+
{ discrete: true }
|
|
278
|
+
)
|
|
279
|
+
expect(link).toEqual({ linkType: 'custom', url: 'https://example.com/report', newTab: true })
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// The denormalised `{ title, path }` bag is deliberately NOT encoded —
|
|
283
|
+
// it is re-derived by `inlineImageLinkVisitor` on the next save or
|
|
284
|
+
// read, so the relation ids are all the markup needs to carry.
|
|
285
|
+
it('reads an internal link relation back without the denormalised document bag', () => {
|
|
286
|
+
const editor = editorWithImageNode()
|
|
287
|
+
const img = document.createElement('img')
|
|
288
|
+
img.setAttribute('src', baseAttributes.src)
|
|
289
|
+
img.setAttribute('alt', 'A photograph of a heron')
|
|
290
|
+
img.setAttribute('data-link-type', 'internal')
|
|
291
|
+
img.setAttribute('data-link-target-document-id', 'doc-1')
|
|
292
|
+
img.setAttribute('data-link-target-collection-id', 'coll-pages')
|
|
293
|
+
img.setAttribute('data-link-target-collection-path', 'pages')
|
|
294
|
+
|
|
295
|
+
let link: LinkAttributes | undefined
|
|
296
|
+
editor.update(
|
|
297
|
+
() => {
|
|
298
|
+
const output = InlineImageNode.importDOM()?.img?.(img)?.conversion(img)
|
|
299
|
+
const node = output?.node
|
|
300
|
+
link = $isInlineImageNode(node) ? node.getLink() : undefined
|
|
301
|
+
},
|
|
302
|
+
{ discrete: true }
|
|
303
|
+
)
|
|
304
|
+
expect(link).toEqual({
|
|
305
|
+
linkType: 'internal',
|
|
306
|
+
newTab: false,
|
|
307
|
+
targetDocumentId: 'doc-1',
|
|
308
|
+
targetCollectionId: 'coll-pages',
|
|
309
|
+
targetCollectionPath: 'pages',
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('produces no link for an ordinary pasted image', () => {
|
|
314
|
+
const editor = editorWithImageNode()
|
|
315
|
+
const img = document.createElement('img')
|
|
316
|
+
img.setAttribute('src', 'https://example.com/plain.jpg')
|
|
317
|
+
img.setAttribute('alt', 'Plain')
|
|
318
|
+
|
|
319
|
+
let link: LinkAttributes | undefined = customLink
|
|
320
|
+
editor.update(
|
|
321
|
+
() => {
|
|
322
|
+
const output = InlineImageNode.importDOM()?.img?.(img)?.conversion(img)
|
|
323
|
+
const node = output?.node
|
|
324
|
+
link = $isInlineImageNode(node) ? node.getLink() : undefined
|
|
325
|
+
},
|
|
326
|
+
{ discrete: true }
|
|
327
|
+
)
|
|
328
|
+
expect(link).toBeUndefined()
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
})
|
|
@@ -24,10 +24,60 @@ import type {
|
|
|
24
24
|
import { $applyNodeReplacement, createEditor, DecoratorNode } from 'lexical'
|
|
25
25
|
|
|
26
26
|
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
27
|
+
import type { LinkAttributes } from '../link'
|
|
27
28
|
import type { InlineImageAttributes, Position, SerializedInlineImageNode } from './node-types'
|
|
28
29
|
|
|
29
30
|
const InlineImageComponent = React.lazy(async () => await import('./inline-image-node-component'))
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Encode an optional click-through target onto the exported `<img>`.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT an `<a>` wrapper: Lexical's own link conversion would
|
|
36
|
+
* claim that anchor on re-import and produce a link node wrapping an
|
|
37
|
+
* inline-image node — a shape this plugin does not model. Discrete
|
|
38
|
+
* `data-link-*` attributes survive the existing `img` conversion instead.
|
|
39
|
+
*
|
|
40
|
+
* The denormalised `{ title, path }` bag is not encoded. It is re-derived
|
|
41
|
+
* by `inlineImageLinkVisitor` on the next save or read, so the relation
|
|
42
|
+
* ids are all the markup needs to carry.
|
|
43
|
+
*/
|
|
44
|
+
function writeLinkAttributes(element: HTMLElement, link: LinkAttributes | undefined): void {
|
|
45
|
+
if (link == null) return
|
|
46
|
+
element.setAttribute('data-link-type', link.linkType ?? 'custom')
|
|
47
|
+
if (link.newTab === true) element.setAttribute('data-link-new-tab', 'true')
|
|
48
|
+
if (link.linkType === 'internal') {
|
|
49
|
+
element.setAttribute('data-link-target-document-id', link.targetDocumentId)
|
|
50
|
+
element.setAttribute('data-link-target-collection-id', link.targetCollectionId)
|
|
51
|
+
element.setAttribute('data-link-target-collection-path', link.targetCollectionPath)
|
|
52
|
+
} else if (link.url != null) {
|
|
53
|
+
element.setAttribute('data-link-url', link.url)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Inverse of `writeLinkAttributes`. Returns undefined for an unlinked image. */
|
|
58
|
+
function readLinkAttributes(domNode: HTMLElement): LinkAttributes | undefined {
|
|
59
|
+
const linkType = domNode.dataset.linkType
|
|
60
|
+
if (linkType !== 'custom' && linkType !== 'internal') return undefined
|
|
61
|
+
const newTab = domNode.dataset.linkNewTab === 'true'
|
|
62
|
+
if (linkType === 'internal') {
|
|
63
|
+
const targetDocumentId = domNode.dataset.linkTargetDocumentId
|
|
64
|
+
const targetCollectionPath = domNode.dataset.linkTargetCollectionPath
|
|
65
|
+
// Without both ids there is nothing to resolve; drop the link rather
|
|
66
|
+
// than carry an envelope the visitor can never hydrate.
|
|
67
|
+
if (!targetDocumentId || !targetCollectionPath) return undefined
|
|
68
|
+
return {
|
|
69
|
+
linkType: 'internal',
|
|
70
|
+
newTab,
|
|
71
|
+
targetDocumentId,
|
|
72
|
+
targetCollectionId: domNode.dataset.linkTargetCollectionId ?? '',
|
|
73
|
+
targetCollectionPath,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const url = domNode.dataset.linkUrl
|
|
77
|
+
if (!url) return undefined
|
|
78
|
+
return { linkType: 'custom', url, newTab }
|
|
79
|
+
}
|
|
80
|
+
|
|
31
81
|
function convertInlineImageElement(domNode: Node): null | DOMConversionOutput {
|
|
32
82
|
if (domNode instanceof HTMLImageElement) {
|
|
33
83
|
const { alt: altText, src, width, height } = domNode
|
|
@@ -42,6 +92,7 @@ function convertInlineImageElement(domNode: Node): null | DOMConversionOutput {
|
|
|
42
92
|
altText,
|
|
43
93
|
height,
|
|
44
94
|
width,
|
|
95
|
+
link: readLinkAttributes(domNode),
|
|
45
96
|
})
|
|
46
97
|
return { node }
|
|
47
98
|
}
|
|
@@ -57,6 +108,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
57
108
|
__height: number | string | undefined
|
|
58
109
|
__showCaption: boolean
|
|
59
110
|
__caption: LexicalEditor
|
|
111
|
+
__link: LinkAttributes | undefined
|
|
60
112
|
|
|
61
113
|
static getType(): string {
|
|
62
114
|
return 'inline-image'
|
|
@@ -72,6 +124,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
72
124
|
node.__height,
|
|
73
125
|
node.__showCaption,
|
|
74
126
|
node.__caption,
|
|
127
|
+
node.__link,
|
|
75
128
|
node.__key
|
|
76
129
|
)
|
|
77
130
|
}
|
|
@@ -85,6 +138,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
85
138
|
width,
|
|
86
139
|
showCaption,
|
|
87
140
|
caption,
|
|
141
|
+
link,
|
|
88
142
|
targetDocumentId,
|
|
89
143
|
targetCollectionId,
|
|
90
144
|
targetCollectionPath,
|
|
@@ -101,6 +155,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
101
155
|
width,
|
|
102
156
|
height,
|
|
103
157
|
showCaption,
|
|
158
|
+
link,
|
|
104
159
|
})
|
|
105
160
|
const nestedEditor = node.__caption
|
|
106
161
|
const editorState = nestedEditor.parseEditorState(caption.editorState)
|
|
@@ -128,6 +183,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
128
183
|
height?: number | string,
|
|
129
184
|
showCaption?: boolean,
|
|
130
185
|
caption?: LexicalEditor,
|
|
186
|
+
link?: LinkAttributes,
|
|
131
187
|
key?: NodeKey
|
|
132
188
|
) {
|
|
133
189
|
super(key)
|
|
@@ -139,6 +195,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
139
195
|
this.__height = height
|
|
140
196
|
this.__showCaption = showCaption ?? false
|
|
141
197
|
this.__caption = caption ?? createEditor()
|
|
198
|
+
this.__link = link
|
|
142
199
|
}
|
|
143
200
|
|
|
144
201
|
exportDOM(): DOMExportOutput {
|
|
@@ -155,6 +212,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
155
212
|
if (this.__height != null) {
|
|
156
213
|
element.setAttribute('height', this.__height.toString())
|
|
157
214
|
}
|
|
215
|
+
writeLinkAttributes(element, this.__link)
|
|
158
216
|
return { element }
|
|
159
217
|
}
|
|
160
218
|
|
|
@@ -168,6 +226,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
168
226
|
width: this.__width,
|
|
169
227
|
showCaption: this.__showCaption,
|
|
170
228
|
caption: this.__caption.toJSON(),
|
|
229
|
+
link: this.__link,
|
|
171
230
|
type: 'inline-image',
|
|
172
231
|
version: 1,
|
|
173
232
|
}
|
|
@@ -205,6 +264,15 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
205
264
|
writable.__showCaption = showCaption
|
|
206
265
|
}
|
|
207
266
|
|
|
267
|
+
getLink(): LinkAttributes | undefined {
|
|
268
|
+
return this.__link
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
setLink(link: LinkAttributes | undefined): void {
|
|
272
|
+
const writable = this.getWritable()
|
|
273
|
+
writable.__link = link
|
|
274
|
+
}
|
|
275
|
+
|
|
208
276
|
getPosition(): Position {
|
|
209
277
|
return this.__position
|
|
210
278
|
}
|
|
@@ -228,6 +296,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
228
296
|
width,
|
|
229
297
|
showCaption,
|
|
230
298
|
} = payload
|
|
299
|
+
|
|
231
300
|
if (targetDocumentId != null) {
|
|
232
301
|
writable.__relation = {
|
|
233
302
|
targetDocumentId,
|
|
@@ -254,29 +323,44 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
254
323
|
if (showCaption != null) {
|
|
255
324
|
writable.__showCaption = showCaption
|
|
256
325
|
}
|
|
326
|
+
// `link` is keyed on PRESENCE, not on null-ness, unlike every field
|
|
327
|
+
// above. The `!= null` convention cannot express "remove the link" —
|
|
328
|
+
// an explicit `link: undefined` would be a silent no-op — and
|
|
329
|
+
// clearing a click target has to work.
|
|
330
|
+
if ('link' in payload) {
|
|
331
|
+
writable.__link = payload.link
|
|
332
|
+
}
|
|
257
333
|
}
|
|
258
334
|
|
|
259
335
|
// View
|
|
260
336
|
|
|
337
|
+
/**
|
|
338
|
+
* `has-link` drives the editor's cursor affordance — see
|
|
339
|
+
* `inline-image-node-component.css`. It is a class rather than a
|
|
340
|
+
* decorator-side style so the hover state is correct before React has
|
|
341
|
+
* hydrated the decorator.
|
|
342
|
+
*/
|
|
343
|
+
private buildClassName(config: EditorConfig): string {
|
|
344
|
+
const linked = this.__link != null ? ' has-link' : ''
|
|
345
|
+
return `${config.theme.inlineImage} position-${this.__position}${linked}`
|
|
346
|
+
}
|
|
347
|
+
|
|
261
348
|
createDOM(config: EditorConfig): HTMLElement {
|
|
262
349
|
const span = document.createElement('span')
|
|
263
|
-
|
|
264
|
-
if (className !== undefined) {
|
|
265
|
-
span.className = className
|
|
266
|
-
}
|
|
350
|
+
span.className = this.buildClassName(config)
|
|
267
351
|
return span
|
|
268
352
|
}
|
|
269
353
|
|
|
270
354
|
updateDOM(prevNode: InlineImageNode, dom: HTMLElement, config: EditorConfig): boolean {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
355
|
+
if (
|
|
356
|
+
this.__position !== prevNode.__position ||
|
|
357
|
+
(this.__link != null) !== (prevNode.__link != null)
|
|
358
|
+
) {
|
|
359
|
+
dom.className = this.buildClassName(config)
|
|
277
360
|
return true
|
|
278
361
|
}
|
|
279
362
|
if (this.__showCaption !== prevNode.__showCaption) return true
|
|
363
|
+
if (this.__link !== prevNode.__link) return true
|
|
280
364
|
return false
|
|
281
365
|
}
|
|
282
366
|
|
|
@@ -292,6 +376,7 @@ export class InlineImageNode extends DecoratorNode<React.JSX.Element> {
|
|
|
292
376
|
height={this.__height}
|
|
293
377
|
showCaption={this.__showCaption}
|
|
294
378
|
caption={this.__caption}
|
|
379
|
+
link={this.__link}
|
|
295
380
|
nodeKey={this.getKey()}
|
|
296
381
|
/>
|
|
297
382
|
</Suspense>
|
|
@@ -311,6 +396,7 @@ export function $createInlineImageNode({
|
|
|
311
396
|
width,
|
|
312
397
|
showCaption,
|
|
313
398
|
caption,
|
|
399
|
+
link,
|
|
314
400
|
key,
|
|
315
401
|
}: InlineImageAttributes): InlineImageNode {
|
|
316
402
|
const relation: DocumentRelation = {
|
|
@@ -320,7 +406,18 @@ export function $createInlineImageNode({
|
|
|
320
406
|
document,
|
|
321
407
|
}
|
|
322
408
|
return $applyNodeReplacement(
|
|
323
|
-
new InlineImageNode(
|
|
409
|
+
new InlineImageNode(
|
|
410
|
+
relation,
|
|
411
|
+
src,
|
|
412
|
+
position,
|
|
413
|
+
altText,
|
|
414
|
+
width,
|
|
415
|
+
height,
|
|
416
|
+
showCaption,
|
|
417
|
+
caption,
|
|
418
|
+
link,
|
|
419
|
+
key
|
|
420
|
+
)
|
|
324
421
|
)
|
|
325
422
|
}
|
|
326
423
|
|
|
@@ -15,9 +15,25 @@ import type {
|
|
|
15
15
|
} from 'lexical'
|
|
16
16
|
|
|
17
17
|
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
18
|
+
import type { LinkAttributes } from '../link'
|
|
18
19
|
|
|
19
20
|
export type Position = 'left' | 'right' | 'full' | 'wide' | 'default' | undefined
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* An inline image carries **two** independent document references, and the
|
|
24
|
+
* distinction matters at every layer:
|
|
25
|
+
*
|
|
26
|
+
* - the flat `DocumentRelation` this interface extends — the *media*
|
|
27
|
+
* document the image is sourced from. Always present.
|
|
28
|
+
* - `link` — an optional *click-through target*. Nested rather than
|
|
29
|
+
* flattened precisely because the flat envelope is already spent on
|
|
30
|
+
* the media pick, and the two resolve against different collections.
|
|
31
|
+
*
|
|
32
|
+
* `link` reuses the link extension's discriminated union, so an image can
|
|
33
|
+
* point at a Byline document (`linkType: 'internal'`, relation refreshed
|
|
34
|
+
* by `inlineImageLinkVisitor`) or at an arbitrary URL
|
|
35
|
+
* (`linkType: 'custom'`). Absent means the image is not clickable.
|
|
36
|
+
*/
|
|
21
37
|
export interface InlineImageAttributes extends DocumentRelation {
|
|
22
38
|
src: string
|
|
23
39
|
altText?: string
|
|
@@ -27,6 +43,7 @@ export interface InlineImageAttributes extends DocumentRelation {
|
|
|
27
43
|
key?: NodeKey
|
|
28
44
|
showCaption?: boolean
|
|
29
45
|
caption?: LexicalEditor
|
|
46
|
+
link?: LinkAttributes
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
export type SerializedInlineImageNode = Spread<
|
|
@@ -38,6 +55,8 @@ export type SerializedInlineImageNode = Spread<
|
|
|
38
55
|
width?: number | string
|
|
39
56
|
showCaption: boolean
|
|
40
57
|
caption: SerializedEditor
|
|
58
|
+
/** Optional click-through target; absent on unlinked images. */
|
|
59
|
+
link?: LinkAttributes
|
|
41
60
|
},
|
|
42
61
|
SerializedLexicalNode
|
|
43
62
|
>
|