@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.
Files changed (57) hide show
  1. package/dist/field/extensions/code-highlight/code-highlight-extension.js +2 -1
  2. package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
  3. package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
  4. package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
  5. package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
  6. package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
  7. package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
  8. package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
  9. package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
  10. package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
  11. package/dist/field/extensions/inline-image/populate.d.ts +22 -0
  12. package/dist/field/extensions/inline-image/populate.js +19 -1
  13. package/dist/field/extensions/inline-image/types.d.ts +7 -0
  14. package/dist/field/extensions/link/link-href.d.ts +30 -0
  15. package/dist/field/extensions/link/link-href.js +14 -0
  16. package/dist/field/extensions/link/link-modal.js +96 -288
  17. package/dist/field/extensions/link/link-target-fields.css +59 -0
  18. package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
  19. package/dist/field/extensions/link/link-target-fields.js +227 -0
  20. package/dist/field/extensions/link/link-target-state.d.ts +54 -0
  21. package/dist/field/extensions/link/link-target-state.js +70 -0
  22. package/dist/field/extensions/link/populate.d.ts +15 -1
  23. package/dist/field/extensions/link/populate.js +55 -50
  24. package/dist/field/markdown/lexical-to-markdown.js +6 -1
  25. package/dist/field/plugins/toolbar-plugin/index.js +2 -1
  26. package/dist/server.d.ts +2 -2
  27. package/dist/server.js +4 -2
  28. package/dist/validate/createEmptyEditorState.d.ts +2 -2
  29. package/dist/validate/hasText.d.ts +2 -2
  30. package/package.json +25 -24
  31. package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
  32. package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
  33. package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
  34. package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
  35. package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
  36. package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
  37. package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
  38. package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
  39. package/src/field/extensions/inline-image/node-types.ts +19 -0
  40. package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
  41. package/src/field/extensions/inline-image/populate.ts +44 -0
  42. package/src/field/extensions/inline-image/types.ts +7 -0
  43. package/src/field/extensions/link/link-href.test.node.ts +78 -0
  44. package/src/field/extensions/link/link-href.ts +52 -0
  45. package/src/field/extensions/link/link-modal.tsx +86 -325
  46. package/src/field/extensions/link/link-target-fields.css +81 -0
  47. package/src/field/extensions/link/link-target-fields.tsx +275 -0
  48. package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
  49. package/src/field/extensions/link/link-target-state.ts +158 -0
  50. package/src/field/extensions/link/populate.ts +94 -67
  51. package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
  52. package/src/field/markdown/lexical-to-markdown.ts +10 -1
  53. package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
  54. package/src/server.ts +11 -5
  55. package/src/validate/createEmptyEditorState.ts +2 -7
  56. package/src/validate/hasText.test.ts +1 -2
  57. package/src/validate/hasText.ts +2 -9
@@ -8,7 +8,14 @@
8
8
  * Portions Copyright (c) Meta Platforms, Inc. and affiliates.
9
9
  */
10
10
 
11
- import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from '@lexical/code'
11
+ import { CodeHighlightNode, CodeNode } from '@lexical/code'
12
+ // Lexical 0.51 removed the Prism re-exports from `@lexical/code`, which is
13
+ // now a thin facade over `@lexical/code-core` with no highlighter of its
14
+ // own. The tokenizer lives in a sibling package per highlighter —
15
+ // `@lexical/code-prism` keeps the Prism behaviour this editor already had;
16
+ // `@lexical/code-shiki` is the alternative and does not ship the language
17
+ // maps the toolbar reads.
18
+ import { registerCodeHighlighting } from '@lexical/code-prism'
12
19
  import { defineExtension } from 'lexical'
13
20
 
14
21
  export const CodeHighlightExtension = defineExtension({
@@ -105,6 +105,7 @@ function readNodeAsInitialData(
105
105
  altText: node.getAltText(),
106
106
  position: node.getPosition(),
107
107
  showCaption: node.getShowCaption(),
108
+ link: node.getLink(),
108
109
  }
109
110
  })
110
111
  return data
@@ -181,6 +182,10 @@ export function InlineImagePlugin(): React.JSX.Element {
181
182
  width: data.width,
182
183
  height: data.height,
183
184
  showCaption: data.showCaption,
185
+ // Spread unconditionally — `InlineImageNode.update()` keys on the
186
+ // presence of `link`, so omitting it would make "remove this link"
187
+ // a silent no-op.
188
+ link: data.link,
184
189
  }
185
190
 
186
191
  if (modalState.mode === 'edit' && modalState.nodeKey != null) {
@@ -57,3 +57,103 @@
57
57
  font-size: 0.875rem;
58
58
  color: var(--text-subtle);
59
59
  }
60
+
61
+ /**
62
+ * Tab strip — matched to the admin document editor's tabs.
63
+ *
64
+ * The shared `@byline/ui` Tabs ships a boxed look (bordered root, bordered
65
+ * list). Inside this modal we want the admin surface's idiom instead:
66
+ * borderless triggers on a hairline rule, muted by default, primary text
67
+ * plus a 2px primary underline when selected. Rather than change the
68
+ * shared component — which every other Tabs consumer would inherit — the
69
+ * overrides are scoped to `.inline-image-modal-tabs`.
70
+ *
71
+ * Base UI 1.8 marks the selected trigger with `data-active` (not
72
+ * `data-selected`). Colours, weights and transitions mirror
73
+ * `packages/admin/src/presentation/tabs.module.css` so the two strips stay
74
+ * recognisably the same control.
75
+ */
76
+
77
+ .inline-image-modal-tabs.byline-tabs-root {
78
+ padding: 0;
79
+ gap: 0;
80
+ border: none;
81
+ border-radius: 0;
82
+ }
83
+
84
+ .inline-image-modal-tabs .byline-tabs-list {
85
+ gap: 1.25rem;
86
+ padding: 0;
87
+ border: none;
88
+ border-bottom: 1px solid var(--border-color);
89
+ border-radius: 0;
90
+ }
91
+
92
+ .inline-image-modal-tabs .byline-tabs-trigger {
93
+ position: relative;
94
+ display: inline-flex;
95
+ flex: none;
96
+ width: auto;
97
+ justify-content: flex-start;
98
+ padding: 0.5rem 0;
99
+ /* Sits on top of the list's hairline so the active underline covers it. */
100
+ margin-bottom: -1px;
101
+ border: none;
102
+ border-bottom: 2px solid transparent;
103
+ background: none;
104
+ color: var(--gray-500);
105
+ font-size: 1rem;
106
+ font-weight: var(--font-weight-medium);
107
+ cursor: pointer;
108
+ transition:
109
+ color 150ms ease,
110
+ border-color 150ms ease;
111
+ }
112
+
113
+ .inline-image-modal-tabs .byline-tabs-trigger:hover {
114
+ color: var(--gray-800);
115
+ border-bottom-color: var(--gray-300);
116
+ }
117
+
118
+ .inline-image-modal-tabs .byline-tabs-trigger:focus-visible {
119
+ outline: none;
120
+ box-shadow: inset 0 0 0 2px var(--blue-500);
121
+ border-radius: var(--radius-sm, 3px);
122
+ }
123
+
124
+ .inline-image-modal-tabs .byline-tabs-trigger[data-active] {
125
+ color: var(--primary-600);
126
+ border-bottom-color: var(--primary-400);
127
+ }
128
+
129
+ .inline-image-modal-tabs .byline-tabs-trigger[data-active]:hover {
130
+ color: var(--primary-600);
131
+ border-bottom-color: var(--primary-400);
132
+ }
133
+
134
+ /*
135
+ * Reserve the height of the tallest link state. Measured: 118px with no
136
+ * link, 231px for a custom URL, 301px for a document pick — so choosing a
137
+ * link type would otherwise shift the dialog by ~180px under the cursor.
138
+ * The floor holds the panel steady across all three.
139
+ */
140
+ .inline-image-modal-tabs .inline-image-modal-link-panel {
141
+ min-height: 20rem;
142
+ }
143
+
144
+ :is([data-theme="dark"], .dark) {
145
+ .inline-image-modal-tabs .byline-tabs-trigger {
146
+ color: var(--gray-400);
147
+ }
148
+
149
+ .inline-image-modal-tabs .byline-tabs-trigger:hover {
150
+ color: var(--gray-200);
151
+ border-bottom-color: var(--gray-600);
152
+ }
153
+
154
+ .inline-image-modal-tabs .byline-tabs-trigger[data-active],
155
+ .inline-image-modal-tabs .byline-tabs-trigger[data-active]:hover {
156
+ color: var(--primary-200);
157
+ border-bottom-color: var(--primary-400);
158
+ }
159
+ }
@@ -13,7 +13,7 @@ import { useMemo, useState } from 'react'
13
13
 
14
14
  import { RelationPicker } from '@byline/admin/react'
15
15
  import type { MultiCollectionDefinition, StoredFileValue } from '@byline/core'
16
- import { getCollectionDefinition, isSingleton } from '@byline/core'
16
+ import { getAdminConfig, getCollectionDefinition, isSingleton } from '@byline/core'
17
17
  import {
18
18
  Button,
19
19
  Checkbox,
@@ -25,9 +25,17 @@ import {
25
25
  Modal,
26
26
  RadioGroup,
27
27
  RadioGroupItem,
28
+ Tabs,
28
29
  } from '@byline/ui/react'
29
30
 
30
31
  import { useModalFormState } from '../../shared/useModalFormState'
32
+ import { LinkTargetFields } from '../link/link-target-fields'
33
+ import {
34
+ fromLinkAttributes,
35
+ type LinkTargetState,
36
+ toLinkAttributes,
37
+ validateLinkTarget,
38
+ } from '../link/link-target-state'
31
39
  import { isAltTextValid, positionOptions } from './fields'
32
40
  import { deriveImageSizes, getPreferredSize } from './utils'
33
41
  import type { DocumentRelation } from '../../nodes/document-relation'
@@ -41,24 +49,38 @@ interface FormState {
41
49
  altText: string
42
50
  position: Position
43
51
  showCaption: boolean
52
+ /** Optional click-through target — see `../link/link-target-state.ts`. */
53
+ target: LinkTargetState
44
54
  }
45
55
 
46
- function emptyState(): FormState {
56
+ /**
57
+ * Unlike a link node, an inline image is perfectly valid with no click
58
+ * target, so the shared sub-form runs with `allowNone` and `toLinkAttributes`
59
+ * is allowed to return undefined.
60
+ */
61
+ const LINK_TARGET_OPTIONS = { allowNone: true } as const
62
+
63
+ function emptyState(linkable: MultiCollectionDefinition[]): FormState {
47
64
  return {
48
65
  documentRelation: null,
49
66
  altText: '',
50
67
  position: 'full',
51
68
  showCaption: false,
69
+ target: fromLinkAttributes(undefined, linkable, LINK_TARGET_OPTIONS),
52
70
  }
53
71
  }
54
72
 
55
- function fromInlineImageData(data: InlineImageData | undefined): FormState {
56
- if (!data) return emptyState()
73
+ function fromInlineImageData(
74
+ data: InlineImageData | undefined,
75
+ linkable: MultiCollectionDefinition[]
76
+ ): FormState {
77
+ if (!data) return emptyState(linkable)
57
78
  return {
58
79
  documentRelation: data.documentRelation ?? null,
59
80
  altText: data.altText ?? '',
60
81
  position: data.position ?? 'full',
61
82
  showCaption: data.showCaption ?? false,
83
+ target: fromLinkAttributes(data.link, linkable, LINK_TARGET_OPTIONS),
62
84
  }
63
85
  }
64
86
 
@@ -96,13 +118,29 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
96
118
  const [pickerOpen, setPickerOpen] = useState(false)
97
119
  const [altError, setAltError] = useState<string | null>(null)
98
120
  const [imageError, setImageError] = useState<string | null>(null)
121
+ const [linkError, setLinkError] = useState<string | null>(null)
122
+ // Which tab is showing. Validation can move this: an error on a hidden
123
+ // panel would otherwise be invisible, and the user would just see Save
124
+ // do nothing.
125
+ const [activeTab, setActiveTab] = useState<'image' | 'link'>('image')
126
+
127
+ const linkable = useMemo<MultiCollectionDefinition[]>(
128
+ () =>
129
+ getAdminConfig().collections.filter(
130
+ (collection): collection is MultiCollectionDefinition =>
131
+ !isSingleton(collection) && collection.linksInEditor === true
132
+ ),
133
+ []
134
+ )
99
135
 
100
136
  const [state, setState] = useModalFormState<FormState>(
101
137
  isOpen,
102
- () => fromInlineImageData(dataFromProps),
138
+ () => fromInlineImageData(dataFromProps, linkable),
103
139
  () => {
104
140
  setAltError(null)
105
141
  setImageError(null)
142
+ setLinkError(null)
143
+ setActiveTab('image')
106
144
  }
107
145
  )
108
146
 
@@ -175,10 +213,18 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
175
213
  const handleSave = () => {
176
214
  if (!state.documentRelation || !pickedImage) {
177
215
  setImageError('Pick an image')
216
+ setActiveTab('image')
178
217
  return
179
218
  }
180
219
  if (!isAltTextValid(state.altText)) {
181
220
  setAltError('Alt text is required')
221
+ setActiveTab('image')
222
+ return
223
+ }
224
+ const linkProblem = validateLinkTarget(state.target)
225
+ if (linkProblem != null) {
226
+ setLinkError(linkProblem)
227
+ setActiveTab('link')
182
228
  return
183
229
  }
184
230
 
@@ -196,6 +242,9 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
196
242
  width: preferred?.width,
197
243
  height: preferred?.height,
198
244
  showCaption: state.showCaption,
245
+ // Always present, so `InlineImageNode.update()` sees the key and can
246
+ // CLEAR an existing link when the editor chooses "No link".
247
+ link: toLinkAttributes(state.target),
199
248
  }
200
249
  onSubmit(data)
201
250
  onClose()
@@ -214,91 +263,135 @@ export const InlineImageModal: React.FC<InlineImageModalProps> = ({
214
263
  </IconButton>
215
264
  </Modal.Header>
216
265
  <Modal.Content>
217
- <div className="flex flex-col gap-4">
218
- <div className="flex flex-col gap-2">
219
- <span className="text-sm font-medium">Image</span>
220
- <div className="inline-image-modal-picker">
221
- {pickedThumbUrl ? (
222
- <img
223
- src={pickedThumbUrl}
224
- alt={pickedTitle ?? ''}
225
- className="inline-image-modal-thumb"
266
+ {/*
267
+ Two tabs rather than one long column. The link sub-form adds a
268
+ collection select, a picker row and a checkbox, which together
269
+ pushed the modal past the viewport on shorter screens. Tabs keep
270
+ the dialog a fixed, predictable height.
271
+
272
+ `Tabs.Content` keeps both panels mounted, so switching away and
273
+ back does not discard a half-filled form — and `handleSave`
274
+ switches `activeTab` to whichever panel holds a validation
275
+ error, so Save never appears to do nothing.
276
+ */}
277
+ <Tabs
278
+ className="inline-image-modal-tabs"
279
+ value={activeTab}
280
+ onValueChange={(value) => setActiveTab(value as 'image' | 'link')}
281
+ >
282
+ <Tabs.List className="mb-4">
283
+ <Tabs.Trigger value="image">Image</Tabs.Trigger>
284
+ <Tabs.Trigger value="link">Link</Tabs.Trigger>
285
+ </Tabs.List>
286
+
287
+ <Tabs.Content value="image">
288
+ <div className="flex flex-col gap-4">
289
+ <div className="flex flex-col gap-2">
290
+ <span className="text-sm font-medium">Image</span>
291
+ <div className="inline-image-modal-picker">
292
+ {pickedThumbUrl ? (
293
+ <img
294
+ src={pickedThumbUrl}
295
+ alt={pickedTitle ?? ''}
296
+ className="inline-image-modal-thumb"
297
+ />
298
+ ) : (
299
+ <div className="inline-image-modal-thumb-placeholder">—</div>
300
+ )}
301
+ <div className="inline-image-modal-picker-details">
302
+ {pickedTitle && (
303
+ <span className="inline-image-modal-title">{pickedTitle}</span>
304
+ )}
305
+ <Button
306
+ size="sm"
307
+ className="inline-image-modal-change-btn"
308
+ variant="outlined"
309
+ intent="noeffect"
310
+ type="button"
311
+ onClick={() => setPickerOpen(true)}
312
+ >
313
+ {state.documentRelation
314
+ ? 'Change image…'
315
+ : `Pick ${targetDef?.labels.singular ?? 'image'}…`}
316
+ </Button>
317
+ </div>
318
+ </div>
319
+ {imageError && <ErrorText id="image-error" text={imageError} />}
320
+ </div>
321
+
322
+ <Input
323
+ id="inline-image-alt"
324
+ name="altText"
325
+ label="Alt text"
326
+ required
327
+ placeholder="Describe the image for screen readers"
328
+ value={state.altText}
329
+ error={altError != null}
330
+ errorText={altError ?? undefined}
331
+ onChange={(e) => {
332
+ setAltError(null)
333
+ setState((s) => ({ ...s, altText: e.target.value }))
334
+ }}
335
+ />
336
+
337
+ <div className="flex flex-col gap-2 mb-3">
338
+ <Label
339
+ htmlFor="inline-image-position"
340
+ id="inline-image-position-label"
341
+ className="text-sm font-medium"
342
+ label="Position"
226
343
  />
227
- ) : (
228
- <div className="inline-image-modal-thumb-placeholder">—</div>
229
- )}
230
- <div className="inline-image-modal-picker-details">
231
- {pickedTitle && <span className="inline-image-modal-title">{pickedTitle}</span>}
232
- <Button
233
- size="sm"
234
- className="inline-image-modal-change-btn"
235
- variant="outlined"
236
- intent="noeffect"
237
- type="button"
238
- onClick={() => setPickerOpen(true)}
344
+ <RadioGroup
345
+ id="inline-image-position"
346
+ name="position"
347
+ aria-labelledby="inline-image-position-label"
348
+ direction="row"
349
+ value={state.position ?? 'full'}
350
+ onValueChange={(value) =>
351
+ setState((s) => ({ ...s, position: value as Position }))
352
+ }
239
353
  >
240
- {state.documentRelation
241
- ? 'Change image…'
242
- : `Pick ${targetDef?.labels.singular ?? 'image'}…`}
243
- </Button>
354
+ {positionOptions.map((opt) => (
355
+ <RadioGroupItem
356
+ key={String(opt.value)}
357
+ id={`inline-image-position-${opt.value}`}
358
+ value={String(opt.value)}
359
+ label={opt.label}
360
+ />
361
+ ))}
362
+ </RadioGroup>
244
363
  </div>
364
+
365
+ <Checkbox
366
+ id="inline-image-caption"
367
+ name="showCaption"
368
+ label="Show caption"
369
+ checked={state.showCaption}
370
+ onCheckedChange={(checked) =>
371
+ setState((s) => ({ ...s, showCaption: checked === true }))
372
+ }
373
+ />
245
374
  </div>
246
- {imageError && <ErrorText id="image-error" text={imageError} />}
247
- </div>
248
-
249
- <Input
250
- id="inline-image-alt"
251
- name="altText"
252
- label="Alt text"
253
- required
254
- placeholder="Describe the image for screen readers"
255
- value={state.altText}
256
- error={altError != null}
257
- errorText={altError ?? undefined}
258
- onChange={(e) => {
259
- setAltError(null)
260
- setState((s) => ({ ...s, altText: e.target.value }))
261
- }}
262
- />
263
-
264
- <div className="flex flex-col gap-2 mb-3">
265
- <Label
266
- htmlFor="inline-image-position"
267
- id="inline-image-position-label"
268
- className="text-sm font-medium"
269
- label="Position"
375
+ </Tabs.Content>
376
+
377
+ <Tabs.Content value="link" className="inline-image-modal-link-panel">
378
+ <p className="mt-0 mb-3 text-sm text-accent-400">
379
+ Give the image a click target. Leave this as “No link” for a plain image.
380
+ </p>
381
+ <LinkTargetFields
382
+ idPrefix="inline-image-link"
383
+ state={state.target}
384
+ onChange={(target) => {
385
+ setLinkError(null)
386
+ setState((s) => ({ ...s, target }))
387
+ }}
388
+ linkable={linkable}
389
+ allowNone
390
+ error={linkError}
391
+ legend="Link to"
270
392
  />
271
- <RadioGroup
272
- id="inline-image-position"
273
- name="position"
274
- aria-labelledby="inline-image-position-label"
275
- direction="row"
276
- value={state.position ?? 'full'}
277
- onValueChange={(value) =>
278
- setState((s) => ({ ...s, position: value as Position }))
279
- }
280
- >
281
- {positionOptions.map((opt) => (
282
- <RadioGroupItem
283
- key={String(opt.value)}
284
- id={`inline-image-position-${opt.value}`}
285
- value={String(opt.value)}
286
- label={opt.label}
287
- />
288
- ))}
289
- </RadioGroup>
290
- </div>
291
-
292
- <Checkbox
293
- id="inline-image-caption"
294
- name="showCaption"
295
- label="Show caption"
296
- checked={state.showCaption}
297
- onCheckedChange={(checked) =>
298
- setState((s) => ({ ...s, showCaption: checked === true }))
299
- }
300
- />
301
- </div>
393
+ </Tabs.Content>
394
+ </Tabs>
302
395
  </Modal.Content>
303
396
  <Modal.Actions className="flex gap-3">
304
397
  <Button
@@ -11,6 +11,22 @@
11
11
  cursor: default;
12
12
  }
13
13
 
14
+ /*
15
+ * A linked image gets the pointer cursor, which is honest because
16
+ * cmd/ctrl-click opens the target (see the CLICK_COMMAND handler in
17
+ * inline-image-node-component.tsx). A plain click still selects the node,
18
+ * so the hover hint is paired with a real action rather than promising
19
+ * navigation the editor never performs.
20
+ */
21
+ .LexicalEditor__inline-image.has-link img {
22
+ cursor: pointer;
23
+ }
24
+
25
+ /* Selection and dragging outrank the link hint. */
26
+ .LexicalEditor__inline-image.has-link img.focused.draggable {
27
+ cursor: grab;
28
+ }
29
+
14
30
  .LexicalEditor__inline-image img.focused {
15
31
  outline: 2px solid rgb(60, 132, 244);
16
32
  /* user-select: none; */
@@ -69,6 +85,34 @@
69
85
  background-color: rgba(60, 132, 244, 0.5);
70
86
  }
71
87
 
88
+ .LexicalEditor__inline-image .image-link-badge {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 4px;
92
+ position: absolute;
93
+ top: 12px;
94
+ left: 12px;
95
+ max-width: calc(100% - 90px);
96
+ padding: 1px 6px;
97
+ border: 1px solid rgba(255, 255, 255, 0.3);
98
+ border-radius: 5px;
99
+ background-color: rgba(0, 0, 0, 0.5);
100
+ font-size: 0.8rem;
101
+ color: #fff;
102
+ cursor: pointer;
103
+ user-select: none;
104
+ }
105
+
106
+ .LexicalEditor__inline-image .image-link-badge:hover {
107
+ background-color: rgba(60, 132, 244, 0.5);
108
+ }
109
+
110
+ .LexicalEditor__inline-image .image-link-badge__label {
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ white-space: nowrap;
114
+ }
115
+
72
116
  .LexicalEditor__inline-image .InlineImageNode__caption_container {
73
117
  display: block;
74
118
  position: relative;
@@ -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}