@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.
Files changed (62) hide show
  1. package/dist/field/context/shared-on-change-context.js +1 -1
  2. package/dist/field/extensions/code-highlight/code-highlight-extension.js +2 -1
  3. package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
  4. package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
  5. package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
  6. package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
  7. package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
  8. package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
  9. package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
  10. package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
  11. package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
  12. package/dist/field/extensions/inline-image/populate.d.ts +22 -0
  13. package/dist/field/extensions/inline-image/populate.js +19 -1
  14. package/dist/field/extensions/inline-image/types.d.ts +7 -0
  15. package/dist/field/extensions/link/link-href.d.ts +30 -0
  16. package/dist/field/extensions/link/link-href.js +14 -0
  17. package/dist/field/extensions/link/link-modal.js +96 -288
  18. package/dist/field/extensions/link/link-target-fields.css +59 -0
  19. package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
  20. package/dist/field/extensions/link/link-target-fields.js +227 -0
  21. package/dist/field/extensions/link/link-target-state.d.ts +54 -0
  22. package/dist/field/extensions/link/link-target-state.js +70 -0
  23. package/dist/field/extensions/link/populate.d.ts +15 -1
  24. package/dist/field/extensions/link/populate.js +55 -50
  25. package/dist/field/markdown/lexical-to-markdown.js +6 -1
  26. package/dist/field/plugins/toolbar-plugin/index.js +2 -1
  27. package/dist/field/utils/getSelectedNode.js +1 -1
  28. package/dist/field/utils/rect.js +1 -2
  29. package/dist/richtext-field.js +1 -1
  30. package/dist/server.d.ts +2 -2
  31. package/dist/server.js +4 -2
  32. package/dist/validate/createEmptyEditorState.d.ts +2 -2
  33. package/dist/validate/hasText.d.ts +2 -2
  34. package/package.json +27 -26
  35. package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
  36. package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
  37. package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
  38. package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
  39. package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
  40. package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
  41. package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
  42. package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
  43. package/src/field/extensions/inline-image/node-types.ts +19 -0
  44. package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
  45. package/src/field/extensions/inline-image/populate.ts +44 -0
  46. package/src/field/extensions/inline-image/types.ts +7 -0
  47. package/src/field/extensions/link/link-href.test.node.ts +78 -0
  48. package/src/field/extensions/link/link-href.ts +52 -0
  49. package/src/field/extensions/link/link-modal.tsx +86 -325
  50. package/src/field/extensions/link/link-target-fields.css +81 -0
  51. package/src/field/extensions/link/link-target-fields.tsx +275 -0
  52. package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
  53. package/src/field/extensions/link/link-target-state.ts +158 -0
  54. package/src/field/extensions/link/populate.ts +94 -67
  55. package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
  56. package/src/field/markdown/lexical-to-markdown.ts +10 -1
  57. package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
  58. package/src/richtext-field.tsx +1 -1
  59. package/src/server.ts +11 -5
  60. package/src/validate/createEmptyEditorState.ts +2 -7
  61. package/src/validate/hasText.test.ts +1 -2
  62. 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
- const className = `${config.theme.inlineImage} position-${this.__position}`
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
- const position = this.__position
272
- if (position !== prevNode.__position) {
273
- const className = `${config.theme.inlineImage} position-${position}`
274
- if (className !== undefined) {
275
- dom.className = className
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(relation, src, position, altText, width, height, showCaption, caption, key)
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