@byline/richtext-lexical 6.3.0 → 6.5.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/context/shared-on-change-context.js +1 -1
- 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/field/utils/getSelectedNode.js +1 -1
- package/dist/field/utils/rect.js +1 -2
- package/dist/richtext-field.js +1 -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 +27 -26
- 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/richtext-field.tsx +1 -1
- 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
|
@@ -43,9 +43,11 @@ import PlaceholderInline from '../../ui/placeholder-inline'
|
|
|
43
43
|
import { FloatingTextFormatToolbarPlugin } from '../floating-text-format'
|
|
44
44
|
import { FloatingLinkEditorPlugin } from '../link/floating-link-editor'
|
|
45
45
|
import { LinkPlugin } from '../link/link-extension'
|
|
46
|
+
import { resolveLinkHref } from '../link/link-href'
|
|
46
47
|
import { OPEN_INLINE_IMAGE_MODAL_COMMAND } from './inline-image-extension'
|
|
47
48
|
import { $isInlineImageNode } from './inline-image-node'
|
|
48
49
|
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
50
|
+
import type { LinkAttributes } from '../link'
|
|
49
51
|
import type { Position } from './node-types'
|
|
50
52
|
|
|
51
53
|
import './inline-image-node-component.css'
|
|
@@ -116,6 +118,7 @@ export default function InlineImageComponent({
|
|
|
116
118
|
height,
|
|
117
119
|
showCaption,
|
|
118
120
|
caption,
|
|
121
|
+
link,
|
|
119
122
|
nodeKey,
|
|
120
123
|
}: {
|
|
121
124
|
relation: DocumentRelation
|
|
@@ -126,6 +129,7 @@ export default function InlineImageComponent({
|
|
|
126
129
|
width?: number | string
|
|
127
130
|
showCaption: boolean
|
|
128
131
|
caption: LexicalEditor
|
|
132
|
+
link?: LinkAttributes
|
|
129
133
|
nodeKey: NodeKey
|
|
130
134
|
}): React.JSX.Element {
|
|
131
135
|
const { targetDocumentId: id, targetCollectionPath: collection } = relation
|
|
@@ -141,6 +145,12 @@ export default function InlineImageComponent({
|
|
|
141
145
|
|
|
142
146
|
const activeEditorRef = useRef<LexicalEditor | null>(null)
|
|
143
147
|
|
|
148
|
+
// Held in a ref so the CLICK_COMMAND registration (which must not
|
|
149
|
+
// re-register on every link change) always reads the current target.
|
|
150
|
+
const linkHref = resolveLinkHref(link)
|
|
151
|
+
const linkHrefRef = useRef<string | null>(linkHref)
|
|
152
|
+
linkHrefRef.current = linkHref
|
|
153
|
+
|
|
144
154
|
const onDelete = useCallback(
|
|
145
155
|
(payload: KeyboardEvent) => {
|
|
146
156
|
if (isSelected && $isNodeSelection($getSelection())) {
|
|
@@ -226,6 +236,17 @@ export default function InlineImageComponent({
|
|
|
226
236
|
(payload) => {
|
|
227
237
|
const event = payload
|
|
228
238
|
if (event.target === imageRef.current) {
|
|
239
|
+
// Cmd/ctrl-click opens the click target, matching the
|
|
240
|
+
// convention of every other editor that shows a pointer
|
|
241
|
+
// cursor over linked content. Without this the `has-link`
|
|
242
|
+
// cursor would promise something a plain click never does —
|
|
243
|
+
// a plain click must keep selecting the node.
|
|
244
|
+
const href = linkHrefRef.current
|
|
245
|
+
if (href != null && (event.metaKey || event.ctrlKey)) {
|
|
246
|
+
event.preventDefault()
|
|
247
|
+
window.open(href, '_blank', 'noopener,noreferrer')
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
229
250
|
if (event.shiftKey) {
|
|
230
251
|
setSelected(!isSelected)
|
|
231
252
|
} else {
|
|
@@ -268,6 +289,22 @@ export default function InlineImageComponent({
|
|
|
268
289
|
|
|
269
290
|
// Open the plugin-hosted modal in edit mode for this node. The plugin
|
|
270
291
|
// reads the current attributes via `nodeKey` and pre-fills the form.
|
|
292
|
+
// Short, human-readable description of where the image points. Falls
|
|
293
|
+
// back to a plain warning when the target is gone, which mirrors what
|
|
294
|
+
// the public renderer does (it drops the anchor entirely).
|
|
295
|
+
const linkBadgeLabel = ((): string => {
|
|
296
|
+
if (link == null) return ''
|
|
297
|
+
if (link.linkType === 'internal') {
|
|
298
|
+
if (link.document?._resolved === false) return 'Missing target'
|
|
299
|
+
return link.document?.title ?? link.targetCollectionPath
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
return new URL(link.url ?? '').hostname
|
|
303
|
+
} catch {
|
|
304
|
+
return link.url ?? ''
|
|
305
|
+
}
|
|
306
|
+
})()
|
|
307
|
+
|
|
271
308
|
const handleToggleModal = (): void => {
|
|
272
309
|
editor.dispatchCommand(OPEN_INLINE_IMAGE_MODAL_COMMAND, { nodeKey })
|
|
273
310
|
}
|
|
@@ -292,6 +329,21 @@ export default function InlineImageComponent({
|
|
|
292
329
|
>
|
|
293
330
|
Edit
|
|
294
331
|
</button>
|
|
332
|
+
{link != null && (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
className="image-link-badge"
|
|
336
|
+
title={
|
|
337
|
+
linkHref != null
|
|
338
|
+
? `Links to ${linkHref} — ⌘/Ctrl-click the image to open`
|
|
339
|
+
: 'Link target could not be resolved'
|
|
340
|
+
}
|
|
341
|
+
onClick={handleToggleModal}
|
|
342
|
+
>
|
|
343
|
+
<span aria-hidden="true">🔗</span>
|
|
344
|
+
<span className="image-link-badge__label">{linkBadgeLabel}</span>
|
|
345
|
+
</button>
|
|
346
|
+
)}
|
|
295
347
|
<LazyImage
|
|
296
348
|
id={id}
|
|
297
349
|
collection={collection}
|
|
@@ -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
|
|