@byline/richtext-lexical 4.13.0 → 4.14.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.
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { deriveImageSizes } from "./utils.js";
|
|
1
|
+
import { deriveImageSizes, getPreferredSize } from "./utils.js";
|
|
2
2
|
const inlineImageVisitor = {
|
|
3
3
|
match (node) {
|
|
4
4
|
if ('inline-image' !== node.type) return null;
|
|
5
5
|
const collectionPath = node.targetCollectionPath;
|
|
6
6
|
const documentId = node.targetDocumentId;
|
|
7
7
|
if (!collectionPath || !documentId) return null;
|
|
8
|
+
const imageNode = node;
|
|
8
9
|
return {
|
|
9
10
|
node,
|
|
10
11
|
collectionPath,
|
|
@@ -21,6 +22,13 @@ const inlineImageVisitor = {
|
|
|
21
22
|
if (null != image) next.image = image;
|
|
22
23
|
if (sizes.length > 0) next.sizes = sizes;
|
|
23
24
|
node.document = next;
|
|
25
|
+
const preferred = getPreferredSize(imageNode.position, image);
|
|
26
|
+
const url = preferred?.url?.trim();
|
|
27
|
+
if (url) {
|
|
28
|
+
imageNode.src = url;
|
|
29
|
+
if (preferred?.width != null) imageNode.width = preferred.width;
|
|
30
|
+
if (preferred?.height != null) imageNode.height = preferred.height;
|
|
31
|
+
}
|
|
24
32
|
}
|
|
25
33
|
};
|
|
26
34
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.14.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
"clsx": "^2.1.1",
|
|
73
73
|
"lexical": "0.49.0",
|
|
74
74
|
"react-error-boundary": "^6.1.3",
|
|
75
|
-
"@byline/
|
|
76
|
-
"@byline/core": "4.
|
|
77
|
-
"@byline/
|
|
78
|
-
"@byline/
|
|
75
|
+
"@byline/admin": "4.14.0",
|
|
76
|
+
"@byline/core": "4.14.0",
|
|
77
|
+
"@byline/client": "4.14.0",
|
|
78
|
+
"@byline/ui": "4.14.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
81
|
"react": "^19.0.0",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"@types/node": "^26.2.0",
|
|
89
89
|
"@types/react": "19.2.18",
|
|
90
90
|
"@types/react-dom": "19.2.4",
|
|
91
|
-
"@vitejs/plugin-react": "^6.0
|
|
91
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
92
92
|
"lightningcss": "^1.33.0",
|
|
93
93
|
"npm-run-all": "^4.1.5",
|
|
94
94
|
"react": "^19.2.8",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"rimraf": "^6.1.3",
|
|
97
97
|
"typescript": "^7.0.2",
|
|
98
98
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
99
|
-
"vite": "^8.2.
|
|
99
|
+
"vite": "^8.2.2",
|
|
100
100
|
"vitest": "^4.1.11"
|
|
101
101
|
},
|
|
102
102
|
"publishConfig": {
|
|
@@ -13,7 +13,7 @@ This README is a navigation aid only. The full reference and authoring contract
|
|
|
13
13
|
| `floating-text-format/` | Standalone extension that contributes the selection-format pop-over to `BylineFloatingUIExtension`. |
|
|
14
14
|
| `link/` | `LinkExtension`, `AutoLinkExtension`, link node + paste handling + `FloatingLinkEditorPlugin` (contributed back via peer dep), and the link populate visitor. |
|
|
15
15
|
| `table/` | `TableExtension` (Byline wrapper over `@lexical/table`), the table modal, and the `TableActionMenuPlugin` (contributed to the floating-UI registry). |
|
|
16
|
-
| `inline-image/` | `InlineImageExtension`, node + decorator + modal, picker-time embed, and the populate visitor for `{ title, altText, image, sizes }
|
|
16
|
+
| `inline-image/` | `InlineImageExtension`, node + decorator + modal, picker-time embed, and the populate visitor for `{ title, altText, image, sizes }` plus the position-aware editor preview (`src` / `width` / `height`). |
|
|
17
17
|
| `layout/` | `LayoutExtension`, columns layout node + modal. |
|
|
18
18
|
| `admonition/` | `AdmonitionExtension`, admonition node + modal. |
|
|
19
19
|
| `auto-embed/` | `AutoEmbedExtension` — paste-to-embed UX shared by the YouTube/Vimeo embedders. |
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
import { createReadContext, type StoredFileValue } from '@byline/core'
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { type LexicalNodeLike, runLexicalPopulate } from '../../lexical-populate-shared'
|
|
13
|
+
import { inlineImageVisitor } from './populate'
|
|
14
|
+
import type { Position } from './node-types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A re-keyed media document: every URL below differs from the stale ones
|
|
18
|
+
* the fixture node carries, so any assertion that the node was refreshed
|
|
19
|
+
* is also an assertion that it was refreshed from *this* document.
|
|
20
|
+
* Variant names and dimensions mirror the `media` collection's declared
|
|
21
|
+
* `sizes` (see `apps/webapp/byline/collections/media/schema.ts`).
|
|
22
|
+
*/
|
|
23
|
+
const currentImage = {
|
|
24
|
+
fileId: 'file-1',
|
|
25
|
+
filename: 'current.jpg',
|
|
26
|
+
originalFilename: 'current.jpg',
|
|
27
|
+
mimeType: 'image/jpeg',
|
|
28
|
+
fileSize: 123,
|
|
29
|
+
storageProvider: 's3',
|
|
30
|
+
storagePath: 'media/current.jpg',
|
|
31
|
+
storageUrl: 'https://cdn.example.com/media/current.jpg',
|
|
32
|
+
imageWidth: 4000,
|
|
33
|
+
imageHeight: 3000,
|
|
34
|
+
processingStatus: 'complete',
|
|
35
|
+
variants: [
|
|
36
|
+
{
|
|
37
|
+
name: 'thumbnail',
|
|
38
|
+
storagePath: 'media/current-thumbnail.avif',
|
|
39
|
+
storageUrl: 'https://cdn.example.com/media/current-thumbnail.avif',
|
|
40
|
+
width: 400,
|
|
41
|
+
height: 400,
|
|
42
|
+
format: 'avif',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'card',
|
|
46
|
+
storagePath: 'media/current-card.avif',
|
|
47
|
+
storageUrl: 'https://cdn.example.com/media/current-card.avif',
|
|
48
|
+
width: 600,
|
|
49
|
+
height: 450,
|
|
50
|
+
format: 'avif',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'tablet',
|
|
54
|
+
storagePath: 'media/current-tablet.avif',
|
|
55
|
+
storageUrl: 'https://cdn.example.com/media/current-tablet.avif',
|
|
56
|
+
width: 1024,
|
|
57
|
+
height: 768,
|
|
58
|
+
format: 'avif',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'desktop',
|
|
62
|
+
storagePath: 'media/current-desktop.avif',
|
|
63
|
+
storageUrl: 'https://cdn.example.com/media/current-desktop.avif',
|
|
64
|
+
width: 1600,
|
|
65
|
+
height: 1200,
|
|
66
|
+
format: 'avif',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
} satisfies StoredFileValue
|
|
70
|
+
|
|
71
|
+
/** The same upload with no variants generated — the fallback-to-original path. */
|
|
72
|
+
const variantlessImage = {
|
|
73
|
+
...currentImage,
|
|
74
|
+
variants: undefined,
|
|
75
|
+
} satisfies StoredFileValue
|
|
76
|
+
|
|
77
|
+
const STALE_SRC = 'https://cdn.example.com/media/stale-card.avif'
|
|
78
|
+
const STALE_WIDTH = 600
|
|
79
|
+
const STALE_HEIGHT = 450
|
|
80
|
+
|
|
81
|
+
interface InlineImageNodeLike extends LexicalNodeLike {
|
|
82
|
+
src?: string
|
|
83
|
+
position?: Position
|
|
84
|
+
width?: number | string
|
|
85
|
+
height?: number | string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeNode(position: Position): InlineImageNodeLike {
|
|
89
|
+
return {
|
|
90
|
+
type: 'inline-image',
|
|
91
|
+
src: STALE_SRC,
|
|
92
|
+
position,
|
|
93
|
+
width: STALE_WIDTH,
|
|
94
|
+
height: STALE_HEIGHT,
|
|
95
|
+
targetCollectionPath: 'media',
|
|
96
|
+
targetDocumentId: 'media-1',
|
|
97
|
+
document: { title: 'Stale title' },
|
|
98
|
+
children: [{ type: 'caption', children: [{ type: 'text' }] }],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function applyTo(node: InlineImageNodeLike, image: StoredFileValue | undefined, title?: string) {
|
|
103
|
+
inlineImageVisitor.match(node)?.apply({ fields: { image, ...(title ? { title } : {}) } })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('inlineImageVisitor', () => {
|
|
107
|
+
describe('refreshes the editor preview from the resolved document', () => {
|
|
108
|
+
it('refreshes a stale src and the document envelope', () => {
|
|
109
|
+
const node = makeNode('left')
|
|
110
|
+
|
|
111
|
+
applyTo(node, currentImage, 'Current title')
|
|
112
|
+
|
|
113
|
+
expect(node.src).toBe('https://cdn.example.com/media/current-card.avif')
|
|
114
|
+
expect(node.document).toEqual(
|
|
115
|
+
expect.objectContaining({ title: 'Current title', image: currentImage })
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// The preview is position-aware by construction — the picker writes
|
|
120
|
+
// `getPreferredSize(position, image)` into src/width/height at insert
|
|
121
|
+
// time (inline-image-modal.tsx). Refreshing from `image.storageUrl`
|
|
122
|
+
// instead would swap each variant for the full-size original.
|
|
123
|
+
it.each([
|
|
124
|
+
['left' as Position, 'card', 600, 450],
|
|
125
|
+
['right' as Position, 'card', 600, 450],
|
|
126
|
+
['wide' as Position, 'desktop', 1600, 1200],
|
|
127
|
+
['full' as Position, 'tablet', 1024, 768],
|
|
128
|
+
['default' as Position, 'tablet', 1024, 768],
|
|
129
|
+
[undefined, 'tablet', 1024, 768],
|
|
130
|
+
])(
|
|
131
|
+
'position %s refreshes from the %s variant with its dimensions',
|
|
132
|
+
(position, variant, width, height) => {
|
|
133
|
+
const node = makeNode(position)
|
|
134
|
+
|
|
135
|
+
applyTo(node, currentImage)
|
|
136
|
+
|
|
137
|
+
expect(node.src).toBe(`https://cdn.example.com/media/current-${variant}.avif`)
|
|
138
|
+
expect(node.width).toBe(width)
|
|
139
|
+
expect(node.height).toBe(height)
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
it('never refreshes src to the full-size original when a variant exists', () => {
|
|
144
|
+
const node = makeNode('left')
|
|
145
|
+
|
|
146
|
+
applyTo(node, currentImage)
|
|
147
|
+
|
|
148
|
+
expect(node.src).not.toBe(currentImage.storageUrl)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('falls back to the original when the preferred variant is missing', () => {
|
|
152
|
+
const node = makeNode('left')
|
|
153
|
+
|
|
154
|
+
applyTo(node, variantlessImage)
|
|
155
|
+
|
|
156
|
+
expect(node.src).toBe(currentImage.storageUrl)
|
|
157
|
+
expect(node.width).toBe(4000)
|
|
158
|
+
expect(node.height).toBe(3000)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('uses the original for SVGs, which have no variants', () => {
|
|
162
|
+
const node = makeNode('left')
|
|
163
|
+
const svg = {
|
|
164
|
+
...variantlessImage,
|
|
165
|
+
mimeType: 'image/svg+xml',
|
|
166
|
+
storageUrl: 'https://cdn.example.com/media/current.svg',
|
|
167
|
+
} satisfies StoredFileValue
|
|
168
|
+
|
|
169
|
+
applyTo(node, svg)
|
|
170
|
+
|
|
171
|
+
expect(node.src).toBe('https://cdn.example.com/media/current.svg')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('never leaves the node emptier than it found it', () => {
|
|
176
|
+
it('preserves src, width and height when the target cannot be resolved', async () => {
|
|
177
|
+
const node = makeNode('left')
|
|
178
|
+
const readDocuments = vi.fn().mockResolvedValue([])
|
|
179
|
+
|
|
180
|
+
await runLexicalPopulate({
|
|
181
|
+
readContext: createReadContext(),
|
|
182
|
+
readDocuments,
|
|
183
|
+
visitors: [inlineImageVisitor],
|
|
184
|
+
values: [{ root: { type: 'root', children: [node] } }],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(readDocuments).toHaveBeenCalledWith({
|
|
188
|
+
collectionPath: 'media',
|
|
189
|
+
documentIds: ['media-1'],
|
|
190
|
+
})
|
|
191
|
+
expect(node.src).toBe(STALE_SRC)
|
|
192
|
+
expect(node.width).toBe(STALE_WIDTH)
|
|
193
|
+
expect(node.height).toBe(STALE_HEIGHT)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('preserves the preview when the target resolves without an image', () => {
|
|
197
|
+
const node = makeNode('left')
|
|
198
|
+
|
|
199
|
+
applyTo(node, undefined, 'Current title')
|
|
200
|
+
|
|
201
|
+
expect(node.src).toBe(STALE_SRC)
|
|
202
|
+
expect(node.width).toBe(STALE_WIDTH)
|
|
203
|
+
expect(node.height).toBe(STALE_HEIGHT)
|
|
204
|
+
expect(node.document).toEqual(expect.objectContaining({ title: 'Current title' }))
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Variant-less, so there is no usable variant URL to fall back to and
|
|
208
|
+
// the original is the only candidate — exactly the case the guard is for.
|
|
209
|
+
it.each([undefined, '', ' '])(
|
|
210
|
+
'preserves the preview when the resolved storageUrl is %j',
|
|
211
|
+
(storageUrl) => {
|
|
212
|
+
const node = makeNode('left')
|
|
213
|
+
|
|
214
|
+
applyTo(node, { ...variantlessImage, storageUrl })
|
|
215
|
+
|
|
216
|
+
expect(node.src).toBe(STALE_SRC)
|
|
217
|
+
expect(node.width).toBe(STALE_WIDTH)
|
|
218
|
+
expect(node.height).toBe(STALE_HEIGHT)
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('is idempotent and leaves unrelated node state untouched', () => {
|
|
224
|
+
const node = makeNode('left')
|
|
225
|
+
const children = node.children
|
|
226
|
+
const pending = inlineImageVisitor.match(node)
|
|
227
|
+
|
|
228
|
+
pending?.apply({ fields: { image: currentImage } })
|
|
229
|
+
const afterFirst = { src: node.src, width: node.width, height: node.height }
|
|
230
|
+
pending?.apply({ fields: { image: currentImage } })
|
|
231
|
+
|
|
232
|
+
expect({ src: node.src, width: node.width, height: node.height }).toEqual(afterFirst)
|
|
233
|
+
expect(node.position).toBe('left')
|
|
234
|
+
expect(node.children).toBe(children)
|
|
235
|
+
expect(node.children).toEqual([{ type: 'caption', children: [{ type: 'text' }] }])
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -11,16 +11,40 @@
|
|
|
11
11
|
* framework-agnostic — imported only from the package's `server` entry.
|
|
12
12
|
*
|
|
13
13
|
* Refreshes `node.document` with `{ title, altText, image, sizes }` from
|
|
14
|
-
* the source media document.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* the source media document. Also refreshes the node's top-level `src` /
|
|
15
|
+
* `width` / `height` — Lexical's admin-editor preview state — from the
|
|
16
|
+
* resolved image, so a re-keyed or regenerated upload stops serving a
|
|
17
|
+
* stale URL to the editor.
|
|
18
|
+
*
|
|
19
|
+
* That preview is position-aware: `getPreferredSize` picks the same
|
|
20
|
+
* variant the picker chose at insert time (`left`/`right` → `card`,
|
|
21
|
+
* `wide` → `desktop`, otherwise `tablet`, with the original as the
|
|
22
|
+
* fallback), so the refresh must go through it rather than assigning
|
|
23
|
+
* `image.storageUrl` directly — otherwise every save would swap the
|
|
24
|
+
* variant for the full-size original and leave the variant's dimensions
|
|
25
|
+
* describing different bytes. Missing targets, missing images, and
|
|
26
|
+
* blank URLs leave all three fields untouched: the visitor never leaves
|
|
27
|
+
* a node emptier than it found it.
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
30
|
import type { StoredFileValue } from '@byline/core'
|
|
21
31
|
|
|
22
|
-
import { deriveImageSizes } from './utils'
|
|
32
|
+
import { deriveImageSizes, getPreferredSize } from './utils'
|
|
23
33
|
import type { LexicalNodeLike, LexicalNodeVisitor } from '../../lexical-populate-shared'
|
|
34
|
+
import type { Position } from './node-types'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The inline-image node's own fields, spread flat onto the Lexical node
|
|
38
|
+
* alongside the shared relation envelope. Narrowed here rather than on
|
|
39
|
+
* `LexicalNodeLike` — each visitor knows its own shape, and the shared
|
|
40
|
+
* type stays free of per-plugin fields.
|
|
41
|
+
*/
|
|
42
|
+
interface InlineImageNodeLike extends LexicalNodeLike {
|
|
43
|
+
src?: string
|
|
44
|
+
position?: Position
|
|
45
|
+
width?: number | string
|
|
46
|
+
height?: number | string
|
|
47
|
+
}
|
|
24
48
|
|
|
25
49
|
export const inlineImageVisitor: LexicalNodeVisitor = {
|
|
26
50
|
match(node: LexicalNodeLike) {
|
|
@@ -28,6 +52,7 @@ export const inlineImageVisitor: LexicalNodeVisitor = {
|
|
|
28
52
|
const collectionPath = node.targetCollectionPath
|
|
29
53
|
const documentId = node.targetDocumentId
|
|
30
54
|
if (!collectionPath || !documentId) return null
|
|
55
|
+
const imageNode = node as InlineImageNodeLike
|
|
31
56
|
return {
|
|
32
57
|
node,
|
|
33
58
|
collectionPath,
|
|
@@ -42,6 +67,13 @@ export const inlineImageVisitor: LexicalNodeVisitor = {
|
|
|
42
67
|
if (image != null) next.image = image
|
|
43
68
|
if (sizes.length > 0) next.sizes = sizes
|
|
44
69
|
node.document = next
|
|
70
|
+
const preferred = getPreferredSize(imageNode.position, image)
|
|
71
|
+
const url = preferred?.url?.trim()
|
|
72
|
+
if (url) {
|
|
73
|
+
imageNode.src = url
|
|
74
|
+
if (preferred?.width != null) imageNode.width = preferred.width
|
|
75
|
+
if (preferred?.height != null) imageNode.height = preferred.height
|
|
76
|
+
}
|
|
45
77
|
},
|
|
46
78
|
}
|
|
47
79
|
},
|