@byline/richtext-lexical 6.4.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 (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
@@ -45,7 +45,99 @@
45
45
 
46
46
  import { getCollectionDefinition, getLogger } from '@byline/core'
47
47
 
48
- import type { LexicalNodeLike, LexicalNodeVisitor } from '../../lexical-populate-shared'
48
+ import type {
49
+ LexicalNodeLike,
50
+ LexicalNodeVisitor,
51
+ PendingHydration,
52
+ } from '../../lexical-populate-shared'
53
+
54
+ /**
55
+ * Build the `apply` / `applyMissing` pair that refreshes an internal-link
56
+ * relation envelope in place.
57
+ *
58
+ * Shared by `linkVisitor` (where the envelope is `node.attributes`) and by
59
+ * the inline-image plugin's `inlineImageLinkVisitor` (where it is
60
+ * `node.link`). Both carry the same `DocumentRelation` shape and want the
61
+ * same three branches, so the resolution rules documented above live here
62
+ * once rather than being copied per node type.
63
+ *
64
+ * `envelope` is mutated in place — the caller owns where it hangs off the
65
+ * node.
66
+ */
67
+ export function createInternalLinkHydration(
68
+ envelope: Record<string, any>,
69
+ collectionPath: string,
70
+ documentId: string
71
+ ): Pick<PendingHydration, 'apply' | 'applyMissing'> {
72
+ return {
73
+ apply(target: Record<string, any>) {
74
+ const definition = getCollectionDefinition(collectionPath)
75
+ const useAsTitle = definition?.useAsTitle ?? 'title'
76
+ const targetFields = (target.fields ?? {}) as Record<string, any>
77
+ const next: Record<string, any> = { ...(envelope.document ?? {}) }
78
+
79
+ // Title — `useAsTitle` lookup with `title` fallback.
80
+ const title = targetFields[useAsTitle]
81
+ if (typeof title === 'string' && title.length > 0) {
82
+ next.title = title
83
+ }
84
+
85
+ // Path — buildDocumentPath, then generic compose fallback.
86
+ // Branch A: hook threw — leave any existing `document.path`
87
+ // untouched and surface a log line so operators can find the
88
+ // bug without it taking the save / read down with it.
89
+ let pathThrew = false
90
+ let built: string | null | undefined
91
+ if (definition?.buildDocumentPath != null) {
92
+ try {
93
+ built = definition.buildDocumentPath(
94
+ {
95
+ id: target.id as string,
96
+ path: target.path as string,
97
+ status: target.status as string,
98
+ fields: targetFields,
99
+ },
100
+ { collectionPath }
101
+ )
102
+ } catch (err) {
103
+ pathThrew = true
104
+ getLogger().info({ collectionPath, documentId, err }, 'buildDocumentPath threw')
105
+ }
106
+ }
107
+
108
+ if (!pathThrew) {
109
+ if (typeof built === 'string') {
110
+ next.path = built
111
+ } else {
112
+ // Generic compose fallback. Only fires when the target has a
113
+ // non-empty `path` — otherwise we'd produce `/${collectionPath}/`
114
+ // or `/${collectionPath}/undefined`, both of which are worse
115
+ // than leaving the previous value alone.
116
+ const targetPath = target.path as string | undefined
117
+ if (typeof targetPath === 'string' && targetPath.length > 0) {
118
+ next.path = `/${collectionPath}/${targetPath}`
119
+ }
120
+ }
121
+ }
122
+
123
+ // Found-and-resolved: clear any stale miss flag from a prior pass.
124
+ if ('_resolved' in next) {
125
+ delete next._resolved
126
+ }
127
+
128
+ envelope.document = next
129
+ },
130
+ applyMissing() {
131
+ // Branch B — target deleted between picker and walker.
132
+ getLogger().warn({ collectionPath, documentId }, 'internal link target not found')
133
+ const next: Record<string, any> = { ...(envelope.document ?? {}) }
134
+ delete next.title
135
+ delete next.path
136
+ next._resolved = false
137
+ envelope.document = next
138
+ },
139
+ }
140
+ }
49
141
 
50
142
  export const linkVisitor: LexicalNodeVisitor = {
51
143
  match(node: LexicalNodeLike) {
@@ -61,72 +153,7 @@ export const linkVisitor: LexicalNodeVisitor = {
61
153
  node,
62
154
  collectionPath,
63
155
  documentId,
64
- apply(target: Record<string, any>) {
65
- const definition = getCollectionDefinition(collectionPath)
66
- const useAsTitle = definition?.useAsTitle ?? 'title'
67
- const targetFields = (target.fields ?? {}) as Record<string, any>
68
- const next: Record<string, any> = { ...(attributes.document ?? {}) }
69
-
70
- // Title — `useAsTitle` lookup with `title` fallback.
71
- const title = targetFields[useAsTitle]
72
- if (typeof title === 'string' && title.length > 0) {
73
- next.title = title
74
- }
75
-
76
- // Path — buildDocumentPath, then generic compose fallback.
77
- // Branch A: hook threw — leave any existing `document.path`
78
- // untouched and surface a log line so operators can find the
79
- // bug without it taking the save / read down with it.
80
- let pathThrew = false
81
- let built: string | null | undefined
82
- if (definition?.buildDocumentPath != null) {
83
- try {
84
- built = definition.buildDocumentPath(
85
- {
86
- id: target.id as string,
87
- path: target.path as string,
88
- status: target.status as string,
89
- fields: targetFields,
90
- },
91
- { collectionPath }
92
- )
93
- } catch (err) {
94
- pathThrew = true
95
- getLogger().info({ collectionPath, documentId, err }, 'buildDocumentPath threw')
96
- }
97
- }
98
-
99
- if (!pathThrew) {
100
- if (typeof built === 'string') {
101
- next.path = built
102
- } else {
103
- // Generic compose fallback. Only fires when the target has a
104
- // non-empty `path` — otherwise we'd produce `/${collectionPath}/`
105
- // or `/${collectionPath}/undefined`, both of which are worse
106
- // than leaving the previous value alone.
107
- const targetPath = target.path as string | undefined
108
- if (typeof targetPath === 'string' && targetPath.length > 0) {
109
- next.path = `/${collectionPath}/${targetPath}`
110
- }
111
- }
112
- }
113
-
114
- // Found-and-resolved: clear any stale miss flag from a prior pass.
115
- if ('_resolved' in next) {
116
- delete next._resolved
117
- }
118
-
119
- attributes.document = next
120
- },
121
- applyMissing() {
122
- // Branch B — target deleted between picker and walker.
123
- getLogger().warn({ collectionPath, documentId }, 'internal link target not found')
124
- const next: Record<string, any> = { ...(attributes.document ?? {}) }
125
- delete next.title
126
- delete next.path
127
- next._resolved = false
128
- attributes.document = next
129
- },
156
+ ...createInternalLinkHydration(attributes, collectionPath, documentId),
130
157
  }
131
158
  },
132
159
  }
@@ -309,6 +309,74 @@ describe('lexicalToMarkdown — inline', () => {
309
309
  )
310
310
  })
311
311
 
312
+ it('wraps a linked inline image in a markdown link', () => {
313
+ const image = {
314
+ type: 'inline-image',
315
+ src: '/uploads/media/photo.avif',
316
+ altText: 'A photo',
317
+ showCaption: false,
318
+ link: { linkType: 'custom', url: 'https://example.com/report', newTab: true },
319
+ version: 1,
320
+ }
321
+ expect(md(root(paragraph(image)))).toBe(
322
+ '[![A photo](/uploads/media/photo.avif)](https://example.com/report)'
323
+ )
324
+ })
325
+
326
+ it('resolves an internal link target on a linked inline image', () => {
327
+ const image = {
328
+ type: 'inline-image',
329
+ src: '/uploads/media/photo.avif',
330
+ altText: 'A photo',
331
+ showCaption: false,
332
+ link: {
333
+ linkType: 'internal',
334
+ targetDocumentId: 'doc-1',
335
+ targetCollectionPath: 'pages',
336
+ document: { title: 'About Us', path: '/pages/about' },
337
+ },
338
+ version: 1,
339
+ }
340
+ expect(md(root(paragraph(image)))).toBe('[![A photo](/uploads/media/photo.avif)](/pages/about)')
341
+ })
342
+
343
+ // The link wraps the image only. The caption is separate content and
344
+ // may carry its own links, so nesting it inside the image's link would
345
+ // produce a nested-anchor shape in every downstream renderer.
346
+ it('links the image but not its caption', () => {
347
+ const image = {
348
+ type: 'inline-image',
349
+ src: '/uploads/media/photo.avif',
350
+ altText: 'A photo',
351
+ showCaption: true,
352
+ caption: { editorState: root(paragraph(text('Taken in 2026.'))) },
353
+ link: { linkType: 'custom', url: 'https://example.com/report' },
354
+ version: 1,
355
+ }
356
+ expect(md(root(paragraph(image)))).toBe(
357
+ '[![A photo](/uploads/media/photo.avif)](https://example.com/report)\n*Taken in 2026.*'
358
+ )
359
+ })
360
+
361
+ it('drops the link but keeps the image when the target is unresolved', () => {
362
+ const image = {
363
+ type: 'inline-image',
364
+ src: '/uploads/media/photo.avif',
365
+ altText: 'A photo',
366
+ showCaption: false,
367
+ link: {
368
+ linkType: 'internal',
369
+ targetDocumentId: 'doc-1',
370
+ targetCollectionPath: 'pages',
371
+ document: { _resolved: false },
372
+ },
373
+ version: 1,
374
+ }
375
+ const result = lexicalToMarkdown(root(paragraph(image)))
376
+ expect(result.markdown).toBe('![A photo](/uploads/media/photo.avif)')
377
+ expect(result.warnings).toContainEqual(expect.objectContaining({ kind: 'unresolved-link' }))
378
+ })
379
+
312
380
  it('serializes hard line breaks', () => {
313
381
  expect(
314
382
  md(root(paragraph(text('line one'), { type: 'linebreak', version: 1 }, text('line two'))))
@@ -424,7 +424,16 @@ function serializeImage(node: AnyNode, ctx: Ctx): string {
424
424
  const src = typeof node.src === 'string' ? node.src : ''
425
425
  if (src.length === 0) return ''
426
426
  const alt = typeof node.altText === 'string' ? node.altText : ''
427
- const image = `![${alt.replace(/[[\]]/g, '')}](${src})`
427
+ let image = `![${alt.replace(/[[\]]/g, '')}](${src})`
428
+
429
+ // Optional click-through target. Resolved through the same rules as a
430
+ // text link, so an unresolved internal target drops the link and keeps
431
+ // the image rather than emitting a broken href.
432
+ const link = node.link as Record<string, unknown> | undefined
433
+ if (link != null) {
434
+ const href = resolveLinkUrl(link, ctx)
435
+ if (href != null) image = `[${image}](${href})`
436
+ }
428
437
  // The caption is a nested SerializedEditor — flatten to emphasized text.
429
438
  const caption = node.caption as { editorState?: unknown } | undefined
430
439
  if (node.showCaption === true && caption?.editorState != null) {
@@ -18,14 +18,17 @@
18
18
  import type * as React from 'react'
19
19
  import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
20
20
 
21
+ import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code'
22
+ // Lexical 0.51 moved the Prism language tables out of `@lexical/code` and
23
+ // into `@lexical/code-prism`, alongside the tokenizer that populates them.
24
+ // `@lexical/code-shiki` is the other highlighter and exposes
25
+ // `getCodeLanguageOptions()` instead of these maps, so a switch to it would
26
+ // change this dropdown's source of truth, not just its import path.
21
27
  import {
22
- $createCodeNode,
23
- $isCodeNode,
24
28
  CODE_LANGUAGE_FRIENDLY_NAME_MAP,
25
29
  CODE_LANGUAGE_MAP,
26
- CodeNode,
27
30
  getLanguageFriendlyName,
28
- } from '@lexical/code'
31
+ } from '@lexical/code-prism'
29
32
  import {
30
33
  $isListNode,
31
34
  CheckListExtension,
package/src/server.ts CHANGED
@@ -47,7 +47,10 @@ import type {
47
47
  RichTextToTextFn,
48
48
  } from '@byline/core'
49
49
 
50
- import { inlineImageVisitor } from './field/extensions/inline-image/populate'
50
+ import {
51
+ inlineImageLinkVisitor,
52
+ inlineImageVisitor,
53
+ } from './field/extensions/inline-image/populate'
51
54
  import { linkVisitor } from './field/extensions/link/populate'
52
55
  import { type LexicalNodeVisitor, runLexicalPopulate } from './field/lexical-populate-shared'
53
56
 
@@ -72,7 +75,10 @@ import {
72
75
  lexicalToMarkdown,
73
76
  } from './field/markdown/lexical-to-markdown'
74
77
 
75
- export { inlineImageVisitor } from './field/extensions/inline-image/populate'
78
+ export {
79
+ inlineImageLinkVisitor,
80
+ inlineImageVisitor,
81
+ } from './field/extensions/inline-image/populate'
76
82
  export { linkVisitor } from './field/extensions/link/populate'
77
83
  export type { EditorConfig, EditorSettings, EditorSettingsOverride } from './field/config/types'
78
84
  export type {
@@ -91,7 +97,7 @@ export interface LexicalServerOptions {
91
97
  getClient: () => BylineClient<any>
92
98
  /**
93
99
  * Override the visitor list. Defaults to every visitor the package
94
- * ships — currently `[inlineImageVisitor, linkVisitor]`. Useful when a
100
+ * ships — currently `[inlineImageVisitor, inlineImageLinkVisitor, linkVisitor]`. Useful when a
95
101
  * host wants to register additional custom visitors alongside the
96
102
  * built-ins, or temporarily disable a built-in:
97
103
  *
@@ -112,7 +118,7 @@ export interface LexicalServerOptions {
112
118
  * document tree, gated by each leaf field's `populateRelationsOnRead`.
113
119
  */
114
120
  export function lexicalEditorPopulateServer(options: LexicalServerOptions): RichTextPopulateFn {
115
- const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
121
+ const visitors = options.visitors ?? [inlineImageVisitor, inlineImageLinkVisitor, linkVisitor]
116
122
  return async (ctx: RichTextPopulateContext): Promise<void> => {
117
123
  await runLexicalPopulate({
118
124
  readContext: ctx.readContext,
@@ -162,7 +168,7 @@ export function lexicalEditorToTextServer(): RichTextToTextFn {
162
168
  }
163
169
 
164
170
  export function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn {
165
- const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
171
+ const visitors = options.visitors ?? [inlineImageVisitor, inlineImageLinkVisitor, linkVisitor]
166
172
  return async (ctx: RichTextEmbedContext): Promise<void> => {
167
173
  await runLexicalPopulate({
168
174
  readContext: ctx.readContext,
@@ -1,9 +1,4 @@
1
- import type {
2
- SerializedEditorState,
3
- SerializedLexicalNode,
4
- SerializedParagraphNode,
5
- SerializedRootNode,
6
- } from 'lexical'
1
+ import type { SerializedEditorState, SerializedParagraphNode, SerializedRootNode } from 'lexical'
7
2
 
8
3
  /**
9
4
  * createEmptyEditorState
@@ -27,7 +22,7 @@ import type {
27
22
  "version": 1
28
23
  }
29
24
  */
30
- export function createEmptyEditorState(): SerializedEditorState<SerializedLexicalNode> {
25
+ export function createEmptyEditorState(): SerializedEditorState {
31
26
  const emptyParagraphNode: SerializedParagraphNode = {
32
27
  children: [],
33
28
  direction: null,
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  SerializedEditorState,
3
- SerializedLexicalNode,
4
3
  SerializedParagraphNode,
5
4
  SerializedRootNode,
6
5
  SerializedTextNode,
@@ -9,7 +8,7 @@ import { describe, expect, it } from 'vitest'
9
8
 
10
9
  import { hasText } from './hasText'
11
10
 
12
- type State = SerializedEditorState<SerializedLexicalNode>
11
+ type State = SerializedEditorState
13
12
 
14
13
  import { createEmptyEditorState } from './createEmptyEditorState'
15
14
 
@@ -1,13 +1,6 @@
1
- import type {
2
- SerializedEditorState,
3
- SerializedLexicalNode,
4
- SerializedParagraphNode,
5
- SerializedTextNode,
6
- } from 'lexical'
1
+ import type { SerializedEditorState, SerializedParagraphNode, SerializedTextNode } from 'lexical'
7
2
 
8
- export function hasText(
9
- value: SerializedEditorState<SerializedLexicalNode> | null | undefined
10
- ): boolean {
3
+ export function hasText(value: SerializedEditorState | null | undefined): boolean {
11
4
  if (value === null || value === undefined) return false
12
5
 
13
6
  const rootChildren = value.root?.children