@byline/richtext-lexical 5.2.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +1 -1
- package/dist/field/apply-value-plugin.js +31 -6
- package/dist/field/capabilities/filter-transformers.d.ts +29 -0
- package/dist/field/capabilities/filter-transformers.js +8 -0
- package/dist/field/capabilities/registered-node-types.d.ts +29 -0
- package/dist/field/capabilities/registered-node-types.js +7 -0
- package/dist/field/config/built-in-extension-names.d.ts +42 -0
- package/dist/field/config/built-in-extension-names.js +12 -2
- package/dist/field/config/default-extensions.js +4 -0
- package/dist/field/config/default.js +8 -8
- package/dist/field/config/editor-config-context.d.ts +2 -2
- package/dist/field/config/editor-config-context.js +10 -13
- package/dist/field/config/resolve-editor-config.js +3 -3
- package/dist/field/config/root-dependencies.d.ts +26 -0
- package/dist/field/config/root-dependencies.js +8 -0
- package/dist/field/config/types.d.ts +61 -8
- package/dist/field/editor-context.js +44 -27
- package/dist/field/editor.js +15 -8
- package/dist/field/extensions/core-nodes/core-nodes-extension.d.ts +24 -0
- package/dist/field/extensions/core-nodes/core-nodes-extension.js +11 -0
- package/dist/field/extensions/heading/heading-extension.d.ts +20 -0
- package/dist/field/extensions/heading/heading-extension.js +9 -0
- package/dist/field/extensions/quote/quote-extension.d.ts +16 -0
- package/dist/field/extensions/quote/quote-extension.js +9 -0
- package/dist/field/hooks/use-markdown-toggle.d.ts +15 -0
- package/dist/field/hooks/use-markdown-toggle.js +12 -4
- package/dist/field/hooks/use-platform-modifier.d.ts +16 -0
- package/dist/field/hooks/use-platform-modifier.js +8 -0
- package/dist/field/markdown/transformers.d.ts +1 -0
- package/dist/field/markdown/transformers.js +5 -4
- package/dist/field/nodes/index.d.ts +11 -1
- package/dist/field/nodes/index.js +2 -2
- package/dist/field/normalize/adapted-notice.css +34 -0
- package/dist/field/normalize/adapted-notice.d.ts +29 -0
- package/dist/field/normalize/adapted-notice.js +35 -0
- package/dist/field/normalize/content-labels.d.ts +15 -0
- package/dist/field/normalize/content-labels.js +19 -0
- package/dist/field/normalize/declared-conversions.d.ts +131 -0
- package/dist/field/normalize/declared-conversions.js +93 -0
- package/dist/field/normalize/normalization-status.d.ts +33 -0
- package/dist/field/normalize/normalization-status.js +28 -0
- package/dist/field/normalize/normalize-value.d.ts +38 -0
- package/dist/field/normalize/normalize-value.js +132 -0
- package/dist/field/normalize/use-notice-text.d.ts +11 -0
- package/dist/field/normalize/use-notice-text.js +34 -0
- package/dist/field/plugins/toolbar-plugin/index.d.ts +23 -0
- package/dist/field/plugins/toolbar-plugin/index.js +104 -78
- package/dist/field/test-support/build-test-editor.js +49 -0
- package/dist/field/test-support/capture-editor.js +7 -0
- package/dist/field/test-support/frame-control.js +40 -0
- package/dist/field/test-support/jsdom-setup.js +21 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -1
- package/dist/scan/capability-manifest.d.ts +30 -0
- package/dist/scan/capability-manifest.js +43 -0
- package/dist/scan/scan-documents.d.ts +29 -0
- package/dist/scan/scan-documents.js +39 -0
- package/dist/scan/types.d.ts +63 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan.d.ts +25 -0
- package/dist/scan.js +1 -0
- package/package.json +14 -5
- package/src/config.ts +1 -1
- package/src/field/apply-value-plugin.tsx +71 -5
- package/src/field/capabilities/filter-transformers.test.tsx +71 -0
- package/src/field/capabilities/filter-transformers.ts +42 -0
- package/src/field/capabilities/markdown-behaviour.test.tsx +188 -0
- package/src/field/capabilities/no-blanket-registration.test.tsx +86 -0
- package/src/field/capabilities/registered-node-types.test.tsx +29 -0
- package/src/field/capabilities/registered-node-types.ts +36 -0
- package/src/field/config/built-in-extension-names.test.node.ts +38 -14
- package/src/field/config/built-in-extension-names.ts +29 -1
- package/src/field/config/default-extensions.ts +6 -0
- package/src/field/config/default.ts +8 -8
- package/src/field/config/editor-config-context.tsx +14 -9
- package/src/field/config/list-removal.test.tsx +44 -0
- package/src/field/config/resolve-editor-config.test.node.ts +27 -6
- package/src/field/config/resolve-editor-config.ts +3 -3
- package/src/field/config/root-dependencies.ts +32 -0
- package/src/field/config/types.ts +61 -17
- package/src/field/editor-component.test.tsx +18 -3
- package/src/field/editor-context.regression.test.tsx +124 -0
- package/src/field/editor-context.tsx +90 -22
- package/src/field/editor.tsx +22 -12
- package/src/field/extensions/core-nodes/core-nodes-extension.test.tsx +13 -0
- package/src/field/extensions/core-nodes/core-nodes-extension.ts +32 -0
- package/src/field/extensions/heading/heading-extension.test.tsx +32 -0
- package/src/field/extensions/heading/heading-extension.ts +27 -0
- package/src/field/extensions/quote/quote-extension.ts +23 -0
- package/src/field/hooks/markdown-source-mode.test.tsx +221 -0
- package/src/field/hooks/use-markdown-toggle.ts +39 -4
- package/src/field/hooks/use-platform-modifier.test.tsx +81 -0
- package/src/field/hooks/use-platform-modifier.ts +41 -0
- package/src/field/markdown/transformers.ts +18 -4
- package/src/field/nodes/index.ts +11 -1
- package/src/field/normalize/adapted-notice.css +52 -0
- package/src/field/normalize/adapted-notice.tsx +63 -0
- package/src/field/normalize/clipboard-equivalence.test.tsx +330 -0
- package/src/field/normalize/content-labels.ts +44 -0
- package/src/field/normalize/declared-conversions.test.node.ts +67 -0
- package/src/field/normalize/declared-conversions.ts +160 -0
- package/src/field/normalize/emission-gate.test.tsx +302 -0
- package/src/field/normalize/normalization-status.tsx +48 -0
- package/src/field/normalize/normalize-load.test.tsx +371 -0
- package/src/field/normalize/normalize-value.test.node.ts +530 -0
- package/src/field/normalize/normalize-value.ts +180 -0
- package/src/field/normalize/notice-i18n.test.tsx +128 -0
- package/src/field/normalize/use-notice-text.ts +82 -0
- package/src/field/normalize/wiring.test.tsx +348 -0
- package/src/field/plugins/toolbar-plugin/block-format-capabilities.test.tsx +57 -0
- package/src/field/plugins/toolbar-plugin/index.tsx +131 -68
- package/src/field/test-support/build-test-editor.test.tsx +17 -0
- package/src/field/test-support/build-test-editor.ts +99 -0
- package/src/field/test-support/capture-editor.tsx +20 -0
- package/src/field/test-support/frame-control.ts +91 -0
- package/src/field/test-support/jsdom-setup.ts +41 -0
- package/src/index.ts +12 -1
- package/src/scan/capability-manifest.test.tsx +82 -0
- package/src/scan/capability-manifest.ts +81 -0
- package/src/scan/scan-documents.test.node.ts +125 -0
- package/src/scan/scan-documents.ts +78 -0
- package/src/scan/types.ts +66 -0
- package/src/scan.ts +27 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { $insertDataTransferForRichText } from '@lexical/clipboard'
|
|
2
|
+
import { registerRichText } from '@lexical/rich-text'
|
|
3
|
+
import {
|
|
4
|
+
$createParagraphNode,
|
|
5
|
+
$getRoot,
|
|
6
|
+
$getSelection,
|
|
7
|
+
type LexicalEditor,
|
|
8
|
+
type SerializedEditorState,
|
|
9
|
+
} from 'lexical'
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { registeredNodeTypes } from '../capabilities/registered-node-types'
|
|
13
|
+
import { builtInExtensions } from '../config/built-in-extension-names'
|
|
14
|
+
import { buildRestrictedEditor } from '../test-support/build-test-editor'
|
|
15
|
+
import { normalizeValue } from './normalize-value'
|
|
16
|
+
|
|
17
|
+
const NAMESPACE = 'LexicalRichText'
|
|
18
|
+
|
|
19
|
+
const text = (value: string) => ({
|
|
20
|
+
detail: 0,
|
|
21
|
+
format: 0,
|
|
22
|
+
mode: 'normal',
|
|
23
|
+
style: '',
|
|
24
|
+
text: value,
|
|
25
|
+
type: 'text',
|
|
26
|
+
version: 1,
|
|
27
|
+
})
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: serialized fixtures are structural
|
|
29
|
+
const doc = (...children: unknown[]): any => ({
|
|
30
|
+
root: { children, direction: null, format: '', indent: 0, type: 'root', version: 1 },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function snapshot(editor: LexicalEditor): { types: string[]; text: string } {
|
|
34
|
+
let types: string[] = []
|
|
35
|
+
let content = ''
|
|
36
|
+
editor.getEditorState().read(() => {
|
|
37
|
+
types = $getRoot()
|
|
38
|
+
.getChildren()
|
|
39
|
+
.map((node) => node.getType())
|
|
40
|
+
content = $getRoot().getTextContent()
|
|
41
|
+
})
|
|
42
|
+
return { types, text: content }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Load `saved` through the normalizer and commit it. */
|
|
46
|
+
function viaStorage(removals: string[] | undefined, saved: SerializedEditorState) {
|
|
47
|
+
const editor = buildRestrictedEditor(removals)
|
|
48
|
+
const result = normalizeValue(saved, registeredNodeTypes(editor))
|
|
49
|
+
if (result.status !== 'adapted') throw new Error(`expected adapted, got ${result.status}`)
|
|
50
|
+
editor.setEditorState(editor.parseEditorState(JSON.stringify(result.value)))
|
|
51
|
+
const out = snapshot(editor)
|
|
52
|
+
editor.dispose()
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stubDataTransfer = (data: Record<string, string>) => ({
|
|
57
|
+
types: Object.keys(data),
|
|
58
|
+
getData: (type: string) => data[type] ?? '',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** Paste a real clipboard payload through registerRichText's handler. */
|
|
62
|
+
function pasteClipboard(
|
|
63
|
+
removals: string[] | undefined,
|
|
64
|
+
data: Record<string, string>
|
|
65
|
+
): { types: string[]; text: string; threw: string; consoleErrors: string[] } {
|
|
66
|
+
const editor = buildRestrictedEditor(removals)
|
|
67
|
+
const consoleErrors: string[] = []
|
|
68
|
+
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
69
|
+
consoleErrors.push(String((args[0] as Error)?.message ?? args[0]))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
registerRichText(editor)
|
|
73
|
+
editor.update(
|
|
74
|
+
() => {
|
|
75
|
+
$getRoot().clear().append($createParagraphNode())
|
|
76
|
+
$getRoot().selectEnd()
|
|
77
|
+
},
|
|
78
|
+
{ discrete: true }
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
let threw = 'none'
|
|
82
|
+
try {
|
|
83
|
+
editor.update(
|
|
84
|
+
() => {
|
|
85
|
+
// biome-ignore lint/suspicious/noExplicitAny: stub data transfer
|
|
86
|
+
$insertDataTransferForRichText(stubDataTransfer(data) as any, $getSelection()!, editor)
|
|
87
|
+
},
|
|
88
|
+
{ discrete: true }
|
|
89
|
+
)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
threw = (error as Error).message
|
|
92
|
+
}
|
|
93
|
+
spy.mockRestore()
|
|
94
|
+
|
|
95
|
+
const out = snapshot(editor)
|
|
96
|
+
editor.dispose()
|
|
97
|
+
return { ...out, threw, consoleErrors }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Paste HTML the way a real paste does: through insertion, not parsing. */
|
|
101
|
+
function viaClipboardHtml(removals: string[] | undefined, html: string) {
|
|
102
|
+
return pasteClipboard(removals, { 'text/html': html, 'text/plain': '' })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const block = (type: string, children: unknown[], extra: Record<string, unknown> = {}) => ({
|
|
106
|
+
children,
|
|
107
|
+
direction: null,
|
|
108
|
+
format: '',
|
|
109
|
+
indent: 0,
|
|
110
|
+
type,
|
|
111
|
+
version: 1,
|
|
112
|
+
...extra,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('clipboard and storage reach equivalent outcomes', () => {
|
|
116
|
+
// Every structure the specification lists as convertible, compared
|
|
117
|
+
// across both routes. Both go through real insertion — inspecting
|
|
118
|
+
// imported nodes before insertion would not evidence what a paste
|
|
119
|
+
// actually leaves in the document.
|
|
120
|
+
const cases: Array<{
|
|
121
|
+
name: string
|
|
122
|
+
saved: SerializedEditorState
|
|
123
|
+
html: string
|
|
124
|
+
/**
|
|
125
|
+
* The ordered block structure BOTH routes must produce. Pinned
|
|
126
|
+
* explicitly rather than compared route-to-route as sets: a set
|
|
127
|
+
* comparison treats one paragraph and three as equal, which is
|
|
128
|
+
* exactly the boundary loss this work fixed — two adjacent headings
|
|
129
|
+
* merging into a single paragraph would pass unnoticed.
|
|
130
|
+
*/
|
|
131
|
+
expectedTypes: string[]
|
|
132
|
+
expectedText: string
|
|
133
|
+
}> = [
|
|
134
|
+
{
|
|
135
|
+
name: 'heading',
|
|
136
|
+
saved: doc(block('heading', [text('Title')], { tag: 'h1' })),
|
|
137
|
+
html: '<h1>Title</h1>',
|
|
138
|
+
expectedTypes: ['paragraph'],
|
|
139
|
+
expectedText: 'Title',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'quote',
|
|
143
|
+
saved: doc(block('quote', [text('Quoted words')])),
|
|
144
|
+
html: '<blockquote>Quoted words</blockquote>',
|
|
145
|
+
expectedTypes: ['paragraph'],
|
|
146
|
+
expectedText: 'Quoted words',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'code block',
|
|
150
|
+
saved: doc(block('code', [text('const x = 1')], { language: 'js' })),
|
|
151
|
+
html: '<pre>const x = 1</pre>',
|
|
152
|
+
expectedTypes: ['paragraph'],
|
|
153
|
+
expectedText: 'const x = 1',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'bullet list',
|
|
157
|
+
saved: doc(
|
|
158
|
+
block(
|
|
159
|
+
'list',
|
|
160
|
+
[
|
|
161
|
+
block('listitem', [text('item one')], { value: 1 }),
|
|
162
|
+
block('listitem', [text('item two')], { value: 2 }),
|
|
163
|
+
],
|
|
164
|
+
{ listType: 'bullet', start: 1, tag: 'ul' }
|
|
165
|
+
)
|
|
166
|
+
),
|
|
167
|
+
html: '<ul><li>item one</li><li>item two</li></ul>',
|
|
168
|
+
// TWO paragraphs, not one: each item keeps its own boundary.
|
|
169
|
+
expectedTypes: ['paragraph', 'paragraph'],
|
|
170
|
+
expectedText: 'item one\n\nitem two',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'table',
|
|
174
|
+
saved: doc(
|
|
175
|
+
block(
|
|
176
|
+
'table',
|
|
177
|
+
[
|
|
178
|
+
block('tablerow', [
|
|
179
|
+
block('tablecell', [block('paragraph', [text('cell text')])], {
|
|
180
|
+
colSpan: 1,
|
|
181
|
+
rowSpan: 1,
|
|
182
|
+
headerState: 0,
|
|
183
|
+
}),
|
|
184
|
+
]),
|
|
185
|
+
],
|
|
186
|
+
{}
|
|
187
|
+
)
|
|
188
|
+
),
|
|
189
|
+
html: '<table><tr><td>cell text</td></tr></table>',
|
|
190
|
+
expectedTypes: ['paragraph'],
|
|
191
|
+
expectedText: 'cell text',
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
for (const { name, saved, html, expectedTypes, expectedText } of cases) {
|
|
196
|
+
it(`degrades a ${name} equivalently by either route`, () => {
|
|
197
|
+
const storage = viaStorage(undefined, saved)
|
|
198
|
+
const clipboard = viaClipboardHtml(undefined, html)
|
|
199
|
+
|
|
200
|
+
expect(clipboard.threw).toBe('none')
|
|
201
|
+
// Ordered structure, pinned — block count and order both matter.
|
|
202
|
+
expect(storage.types, `${name} via storage`).toEqual(expectedTypes)
|
|
203
|
+
expect(clipboard.types, `${name} via clipboard`).toEqual(expectedTypes)
|
|
204
|
+
expect(storage.text, `${name} via storage`).toBe(expectedText)
|
|
205
|
+
expect(clipboard.text, `${name} via clipboard`).toBe(expectedText)
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
it('drops a horizontal rule on both routes and keeps its neighbours', () => {
|
|
210
|
+
const storage = viaStorage(
|
|
211
|
+
undefined,
|
|
212
|
+
doc(
|
|
213
|
+
block('paragraph', [text('before')]),
|
|
214
|
+
{ type: 'horizontalrule', version: 1 },
|
|
215
|
+
block('paragraph', [text('after')])
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
const clipboard = viaClipboardHtml(undefined, '<p>before</p><hr /><p>after</p>')
|
|
219
|
+
|
|
220
|
+
// Exactly the two neighbours, in order — the rule is gone and took
|
|
221
|
+
// nothing with it.
|
|
222
|
+
expect(storage.types).toEqual(['paragraph', 'paragraph'])
|
|
223
|
+
expect(clipboard.types).toEqual(['paragraph', 'paragraph'])
|
|
224
|
+
expect(storage.text).toBe('before\n\nafter')
|
|
225
|
+
expect(clipboard.text).toBe('before\n\nafter')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('preserves the link URL on the storage route only, by design', () => {
|
|
229
|
+
const link = block('link', [text('link text')], {
|
|
230
|
+
version: 2,
|
|
231
|
+
attributes: { url: 'https://example.test/a' },
|
|
232
|
+
})
|
|
233
|
+
const storage = viaStorage([builtInExtensions.Link], doc(block('paragraph', [link])))
|
|
234
|
+
const clipboard = viaClipboardHtml(
|
|
235
|
+
[builtInExtensions.Link],
|
|
236
|
+
'<p><a href="https://example.test/a">link text</a></p>'
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// Equivalent structure and text — NOT identical content.
|
|
240
|
+
expect(storage.types).toEqual(['paragraph'])
|
|
241
|
+
expect(clipboard.types).toEqual(['paragraph'])
|
|
242
|
+
expect(storage.text).toContain('link text')
|
|
243
|
+
expect(clipboard.text).toContain('link text')
|
|
244
|
+
|
|
245
|
+
// The intended difference, asserted from both sides so it cannot
|
|
246
|
+
// drift into either a "bug" or a silent regression.
|
|
247
|
+
expect(storage.text).toContain('https://example.test/a')
|
|
248
|
+
expect(clipboard.text).not.toContain('https://example.test/a')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('preserves the admonition title on the storage route only', () => {
|
|
252
|
+
const storage = viaStorage(
|
|
253
|
+
[builtInExtensions.Admonition],
|
|
254
|
+
doc(block('admonition', [block('paragraph', [text('body copy')])], { title: 'Note title' }))
|
|
255
|
+
)
|
|
256
|
+
const clipboard = viaClipboardHtml(
|
|
257
|
+
[builtInExtensions.Admonition],
|
|
258
|
+
'<div data-type="note" data-title="Note title" class="admonition"><p>body copy</p></div>'
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
expect(clipboard.threw).toBe('none')
|
|
262
|
+
// Both keep the body...
|
|
263
|
+
expect(storage.text).toContain('body copy')
|
|
264
|
+
expect(clipboard.text).toContain('body copy')
|
|
265
|
+
|
|
266
|
+
// ...and only storage keeps the title, as its own leading line.
|
|
267
|
+
expect(storage.text).toContain('Note title')
|
|
268
|
+
expect(storage.text.indexOf('Note title')).toBeLessThan(storage.text.indexOf('body copy'))
|
|
269
|
+
expect(clipboard.text).not.toContain('Note title')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('Lexical-to-Lexical paste', () => {
|
|
274
|
+
const headingPayload = JSON.stringify({
|
|
275
|
+
namespace: NAMESPACE,
|
|
276
|
+
nodes: [
|
|
277
|
+
{
|
|
278
|
+
children: [text('Copied title')],
|
|
279
|
+
direction: null,
|
|
280
|
+
format: '',
|
|
281
|
+
indent: 0,
|
|
282
|
+
type: 'heading',
|
|
283
|
+
version: 1,
|
|
284
|
+
tag: 'h1',
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('falls through to the HTML handler and degrades', () => {
|
|
290
|
+
// application/x-lexical-editor is tried FIRST (priority 0, ahead of
|
|
291
|
+
// text/html at 10) and does not use importDOM at all — it calls the
|
|
292
|
+
// same $parseSerializedNode that throws on an unregistered type.
|
|
293
|
+
// Byline gives every field the same namespace, so a copy between two
|
|
294
|
+
// Byline fields takes this branch.
|
|
295
|
+
const result = pasteClipboard(undefined, {
|
|
296
|
+
'application/x-lexical-editor': headingPayload,
|
|
297
|
+
'text/html': '<h1>Copied title</h1>',
|
|
298
|
+
'text/plain': 'Copied title',
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
expect(result.threw).toBe('none')
|
|
302
|
+
expect(result.types).toEqual(['paragraph'])
|
|
303
|
+
expect(result.text).toContain('Copied title')
|
|
304
|
+
// The fallthrough is internal, not a fault — assert it rather than
|
|
305
|
+
// suppress it, so a future Lexical change that makes it fatal is
|
|
306
|
+
// caught here.
|
|
307
|
+
expect(result.consoleErrors.join(' ')).toContain('not found')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('survives a clipboard with no text/html at all', () => {
|
|
311
|
+
// A source application may offer only Lexical JSON and plain text.
|
|
312
|
+
// The guarantee must not depend on HTML being present.
|
|
313
|
+
const result = pasteClipboard(undefined, {
|
|
314
|
+
'application/x-lexical-editor': headingPayload,
|
|
315
|
+
'text/plain': 'Copied title',
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
expect(result.threw).toBe('none')
|
|
319
|
+
expect(result.types).toEqual(['paragraph'])
|
|
320
|
+
// Text preservation is the guarantee on this route; inline formatting
|
|
321
|
+
// is not, because plain text carries none.
|
|
322
|
+
expect(result.text).toContain('Copied title')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('pastes plain text normally when that is all the clipboard holds', () => {
|
|
326
|
+
const result = pasteClipboard(undefined, { 'text/plain': 'just words' })
|
|
327
|
+
expect(result.threw).toBe('none')
|
|
328
|
+
expect(result.text).toContain('just words')
|
|
329
|
+
})
|
|
330
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
* Translation keys for the node types a reader may be told about.
|
|
11
|
+
*
|
|
12
|
+
* Only types with NO declared conversion can reach a notice, which is
|
|
13
|
+
* why this map is small: everything else is adapted silently.
|
|
14
|
+
*
|
|
15
|
+
* A type absent here falls back to its own id. That is deliberate — a
|
|
16
|
+
* site or plugin contributing its own node should not have to supply a
|
|
17
|
+
* display name to get a usable message, and reaching this path at all is
|
|
18
|
+
* rare. The id is developer vocabulary, but it is accurate and
|
|
19
|
+
* searchable, which beats inventing a label for a node Byline knows
|
|
20
|
+
* nothing about.
|
|
21
|
+
*/
|
|
22
|
+
const CONTENT_LABEL_KEYS: Readonly<Record<string, string>> = {
|
|
23
|
+
'inline-image': 'richtext.content.image',
|
|
24
|
+
youtube: 'richtext.content.youtube',
|
|
25
|
+
vimeo: 'richtext.content.vimeo',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function contentLabelKey(type: string): string | undefined {
|
|
29
|
+
return CONTENT_LABEL_KEYS[type]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Join labels the way the active locale writes a list.
|
|
34
|
+
*
|
|
35
|
+
* `Intl.ListFormat` is not available in every runtime this may render
|
|
36
|
+
* in, so fall back to a comma join rather than throwing over a notice.
|
|
37
|
+
*/
|
|
38
|
+
export function joinLabels(labels: string[], locale: string): string {
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(labels)
|
|
41
|
+
} catch {
|
|
42
|
+
return labels.join(', ')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { conversionFor, DECLARED_CONVERSIONS } from './declared-conversions'
|
|
4
|
+
|
|
5
|
+
describe('declared conversions', () => {
|
|
6
|
+
it('declares one for every structure the spec lists as supported', () => {
|
|
7
|
+
for (const type of [
|
|
8
|
+
'heading',
|
|
9
|
+
'quote',
|
|
10
|
+
'code',
|
|
11
|
+
'listitem',
|
|
12
|
+
'table',
|
|
13
|
+
'tablerow',
|
|
14
|
+
'tablecell',
|
|
15
|
+
'list',
|
|
16
|
+
'layout-container',
|
|
17
|
+
'layout-item',
|
|
18
|
+
'admonition',
|
|
19
|
+
'link',
|
|
20
|
+
'autolink',
|
|
21
|
+
'horizontalrule',
|
|
22
|
+
]) {
|
|
23
|
+
expect(conversionFor(type), type).toBeDefined()
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('declares none for media, embeds or unknown types', () => {
|
|
28
|
+
for (const type of ['inline-image', 'youtube', 'vimeo', 'acme-custom-node']) {
|
|
29
|
+
expect(conversionFor(type), type).toBeUndefined()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('classifies each structure by what it contains', () => {
|
|
34
|
+
expect(DECLARED_CONVERSIONS.heading.kind).toBe('to-paragraph')
|
|
35
|
+
expect(DECLARED_CONVERSIONS.listitem.kind).toBe('to-paragraph')
|
|
36
|
+
expect(DECLARED_CONVERSIONS.table.kind).toBe('lift-blocks')
|
|
37
|
+
expect(DECLARED_CONVERSIONS.list.kind).toBe('lift-blocks')
|
|
38
|
+
expect(DECLARED_CONVERSIONS.link.kind).toBe('unwrap-inline')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('preserves the admonition title', () => {
|
|
42
|
+
expect(
|
|
43
|
+
DECLARED_CONVERSIONS.admonition.preserveText({ type: 'admonition', title: 'Note title' })
|
|
44
|
+
).toBe('Note title')
|
|
45
|
+
expect(DECLARED_CONVERSIONS.admonition.preserveText({ type: 'admonition' })).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('preserves the link URL from either serialized shape', () => {
|
|
49
|
+
// v1 stored `url` at the top level; v2 nests it under `attributes`.
|
|
50
|
+
expect(DECLARED_CONVERSIONS.link.preserveText({ type: 'link', url: 'https://a.test' })).toBe(
|
|
51
|
+
'https://a.test'
|
|
52
|
+
)
|
|
53
|
+
expect(
|
|
54
|
+
DECLARED_CONVERSIONS.link.preserveText({
|
|
55
|
+
type: 'link',
|
|
56
|
+
attributes: { url: 'https://b.test' },
|
|
57
|
+
})
|
|
58
|
+
).toBe('https://b.test')
|
|
59
|
+
expect(DECLARED_CONVERSIONS.link.preserveText({ type: 'link' })).toBeUndefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('drops a horizontal rule without inventing text', () => {
|
|
63
|
+
const rule = conversionFor('horizontalrule')
|
|
64
|
+
expect(rule?.kind).toBe('drop')
|
|
65
|
+
expect(rule?.preserveText).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
/** A serialized Lexical node, as stored. */
|
|
10
|
+
export interface SerializedNode {
|
|
11
|
+
type: string
|
|
12
|
+
children?: SerializedNode[]
|
|
13
|
+
[key: string]: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a convertible structure CONTAINS, which is what decides a
|
|
18
|
+
* structurally valid replacement for it.
|
|
19
|
+
*
|
|
20
|
+
* - `to-paragraph` — a block holding inline content. Becomes exactly one
|
|
21
|
+
* paragraph, so two adjacent headings stay two paragraphs and an empty
|
|
22
|
+
* heading stays an empty paragraph instead of vanishing.
|
|
23
|
+
* - `lift-blocks` — a block holding blocks. Its children are lifted in
|
|
24
|
+
* place, already block-level and already valid where the parent sat.
|
|
25
|
+
* - `unwrap-inline` — inline content. Its children are lifted inline and
|
|
26
|
+
* stay inside the paragraph that held them.
|
|
27
|
+
* - `to-text` — a text-carrying leaf whose class is unsupported, such as
|
|
28
|
+
* a syntax-highlighted code token. Becomes a plain text node with its
|
|
29
|
+
* text and inline formats intact.
|
|
30
|
+
* - `drop` — removed entirely; neighbours are untouched.
|
|
31
|
+
*/
|
|
32
|
+
export type ConversionKind = 'to-paragraph' | 'lift-blocks' | 'unwrap-inline' | 'to-text' | 'drop'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serialized types Byline knows to be block-level.
|
|
36
|
+
*
|
|
37
|
+
* Needed because a converted node's children are not always inline: a
|
|
38
|
+
* `listitem` may hold a nested `list`, so turning every item straight
|
|
39
|
+
* into a paragraph would nest a block inside a paragraph.
|
|
40
|
+
*/
|
|
41
|
+
export const BLOCK_TYPES: ReadonlySet<string> = new Set([
|
|
42
|
+
'paragraph',
|
|
43
|
+
'heading',
|
|
44
|
+
'quote',
|
|
45
|
+
'code',
|
|
46
|
+
'list',
|
|
47
|
+
'listitem',
|
|
48
|
+
'table',
|
|
49
|
+
'tablerow',
|
|
50
|
+
'tablecell',
|
|
51
|
+
'layout-container',
|
|
52
|
+
'layout-item',
|
|
53
|
+
'admonition',
|
|
54
|
+
'horizontalrule',
|
|
55
|
+
'youtube',
|
|
56
|
+
'vimeo',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
export function isBlockType(type: string): boolean {
|
|
60
|
+
return BLOCK_TYPES.has(type)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialized types Byline knows to be inline.
|
|
65
|
+
*
|
|
66
|
+
* The counterpart to {@link BLOCK_TYPES}, and the reason both lists
|
|
67
|
+
* exist rather than one: a node's structural role cannot be recovered
|
|
68
|
+
* from its serialized shape. An inline `link` and a block `quote` are
|
|
69
|
+
* both elements with `children`, `direction`, `format` and `indent`, so
|
|
70
|
+
* there is no reliable metadata to infer the role from.
|
|
71
|
+
*
|
|
72
|
+
* A SUPPORTED node can reach this test — it arrives as the child of an
|
|
73
|
+
* unsupported parent being converted — so "not a known block" cannot be
|
|
74
|
+
* read as "inline". A custom block from a downstream site would be
|
|
75
|
+
* wrapped into a paragraph and produce a tree Lexical rejects. When a
|
|
76
|
+
* type appears in neither list its role is unknown, and normalization
|
|
77
|
+
* refuses the document rather than guessing at it.
|
|
78
|
+
*/
|
|
79
|
+
export const INLINE_TYPES: ReadonlySet<string> = new Set([
|
|
80
|
+
'text',
|
|
81
|
+
'linebreak',
|
|
82
|
+
'tab',
|
|
83
|
+
'link',
|
|
84
|
+
'autolink',
|
|
85
|
+
'mark',
|
|
86
|
+
'overflow',
|
|
87
|
+
'code-highlight',
|
|
88
|
+
'inline-image',
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
export function isInlineType(type: string): boolean {
|
|
92
|
+
return INLINE_TYPES.has(type)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DeclaredConversion {
|
|
96
|
+
kind: ConversionKind
|
|
97
|
+
/**
|
|
98
|
+
* Information carried on the node's own properties rather than in its
|
|
99
|
+
* children, which would otherwise be lost silently. Emitted as a
|
|
100
|
+
* leading paragraph for `lift-blocks` (an admonition title becomes the
|
|
101
|
+
* first line) and appended to the text for `unwrap-inline` (a link URL
|
|
102
|
+
* follows its text).
|
|
103
|
+
*/
|
|
104
|
+
preserveText?: (node: SerializedNode) => string | undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const urlText = (node: SerializedNode): string | undefined => {
|
|
108
|
+
const attributes = node.attributes as { url?: unknown } | undefined
|
|
109
|
+
const url = typeof node.url === 'string' ? node.url : attributes?.url
|
|
110
|
+
return typeof url === 'string' && url.length > 0 ? url : undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How each convertible structure degrades when a field does not support
|
|
115
|
+
* it. Type strings are the node classes' own `getType()` values.
|
|
116
|
+
*
|
|
117
|
+
* A type absent from this table has NO conversion and is never guessed
|
|
118
|
+
* at — normalization refuses the document instead. That covers custom
|
|
119
|
+
* nodes from downstream sites as well as inline images and embeds, whose
|
|
120
|
+
* media relations and nested-editor captions cannot survive any
|
|
121
|
+
* structural rewrite.
|
|
122
|
+
*/
|
|
123
|
+
export const DECLARED_CONVERSIONS = {
|
|
124
|
+
// Blocks holding inline content.
|
|
125
|
+
heading: { kind: 'to-paragraph' },
|
|
126
|
+
quote: { kind: 'to-paragraph' },
|
|
127
|
+
code: { kind: 'to-paragraph' },
|
|
128
|
+
listitem: { kind: 'to-paragraph' },
|
|
129
|
+
|
|
130
|
+
// Blocks holding blocks.
|
|
131
|
+
table: { kind: 'lift-blocks' },
|
|
132
|
+
tablerow: { kind: 'lift-blocks' },
|
|
133
|
+
tablecell: { kind: 'lift-blocks' },
|
|
134
|
+
list: { kind: 'lift-blocks' },
|
|
135
|
+
'layout-container': { kind: 'lift-blocks' },
|
|
136
|
+
'layout-item': { kind: 'lift-blocks' },
|
|
137
|
+
admonition: {
|
|
138
|
+
kind: 'lift-blocks',
|
|
139
|
+
preserveText: (node) =>
|
|
140
|
+
typeof node.title === 'string' && node.title.length > 0 ? node.title : undefined,
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Text-carrying leaves. CodeHighlightNode is a TextNode subclass owned
|
|
144
|
+
// by the same extension as CodeNode, so removing code-highlight
|
|
145
|
+
// unregisters BOTH: without this entry a highlighted code block would
|
|
146
|
+
// be refused rather than adapted, contradicting the promise that code
|
|
147
|
+
// degrades to text.
|
|
148
|
+
'code-highlight': { kind: 'to-text' },
|
|
149
|
+
|
|
150
|
+
// Inline content.
|
|
151
|
+
link: { kind: 'unwrap-inline', preserveText: urlText },
|
|
152
|
+
autolink: { kind: 'unwrap-inline', preserveText: urlText },
|
|
153
|
+
|
|
154
|
+
// Decorative, with nothing to preserve.
|
|
155
|
+
horizontalrule: { kind: 'drop' },
|
|
156
|
+
} as const satisfies Record<string, DeclaredConversion>
|
|
157
|
+
|
|
158
|
+
export function conversionFor(type: string): DeclaredConversion | undefined {
|
|
159
|
+
return (DECLARED_CONVERSIONS as Record<string, DeclaredConversion | undefined>)[type]
|
|
160
|
+
}
|