@byline/richtext-lexical 5.2.0 → 6.0.1
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,348 @@
|
|
|
1
|
+
import { act, createRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { defineAdminConfig } from '@byline/core'
|
|
4
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
5
|
+
import { $getRoot, type LexicalEditor, type SerializedEditorState } from 'lexical'
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import { ApplyValuePlugin } from '../apply-value-plugin'
|
|
10
|
+
import { builtInExtensions } from '../config/built-in-extension-names'
|
|
11
|
+
import { defaultClientEditorConfig } from '../config/default-extensions'
|
|
12
|
+
import { EditorContext } from '../editor-context'
|
|
13
|
+
import { hashSerializedState } from '../utils/hashSerializedState'
|
|
14
|
+
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: React act environment flag
|
|
16
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
17
|
+
|
|
18
|
+
defineAdminConfig({
|
|
19
|
+
i18n: {
|
|
20
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
21
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
22
|
+
},
|
|
23
|
+
collections: [
|
|
24
|
+
{
|
|
25
|
+
path: 'pages',
|
|
26
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
27
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const text = (value: string) => ({
|
|
33
|
+
detail: 0,
|
|
34
|
+
format: 0,
|
|
35
|
+
mode: 'normal',
|
|
36
|
+
style: '',
|
|
37
|
+
text: value,
|
|
38
|
+
type: 'text',
|
|
39
|
+
version: 1,
|
|
40
|
+
})
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: serialized fixtures are structural
|
|
42
|
+
const doc = (...children: unknown[]): any => ({
|
|
43
|
+
root: { children, direction: null, format: '', indent: 0, type: 'root', version: 1 },
|
|
44
|
+
})
|
|
45
|
+
const heading = (value: string) => ({
|
|
46
|
+
children: [text(value)],
|
|
47
|
+
direction: null,
|
|
48
|
+
format: '',
|
|
49
|
+
indent: 0,
|
|
50
|
+
type: 'heading',
|
|
51
|
+
version: 1,
|
|
52
|
+
tag: 'h1',
|
|
53
|
+
})
|
|
54
|
+
const image = () => ({
|
|
55
|
+
children: [
|
|
56
|
+
{
|
|
57
|
+
type: 'inline-image',
|
|
58
|
+
version: 1,
|
|
59
|
+
targetDocumentId: 'doc-1',
|
|
60
|
+
targetCollectionPath: 'media',
|
|
61
|
+
src: '/cat.png',
|
|
62
|
+
position: 'full',
|
|
63
|
+
altText: 'a cat',
|
|
64
|
+
width: 10,
|
|
65
|
+
height: 10,
|
|
66
|
+
showCaption: false,
|
|
67
|
+
caption: {
|
|
68
|
+
editorState: {
|
|
69
|
+
root: { children: [], direction: null, format: '', indent: 0, type: 'root', version: 1 },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
direction: null,
|
|
75
|
+
format: '',
|
|
76
|
+
indent: 0,
|
|
77
|
+
type: 'paragraph',
|
|
78
|
+
version: 1,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
for (const { root, container } of mounted.splice(0)) {
|
|
85
|
+
await act(async () => {
|
|
86
|
+
root.unmount()
|
|
87
|
+
})
|
|
88
|
+
container.remove()
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
interface Harness {
|
|
93
|
+
container: HTMLDivElement
|
|
94
|
+
editor: () => LexicalEditor
|
|
95
|
+
render: (value: SerializedEditorState) => Promise<void>
|
|
96
|
+
/** Record a value as one the editor emitted, as the parent does on change. */
|
|
97
|
+
markAsEmitted: (value: SerializedEditorState) => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mounts the real EditorContext + ApplyValuePlugin pair, composed the
|
|
102
|
+
* way `editor-component.tsx` composes them. Exercising the wiring is the
|
|
103
|
+
* point: calling `normalizeValue` directly here would prove nothing
|
|
104
|
+
* about the path a stored value actually travels.
|
|
105
|
+
*/
|
|
106
|
+
async function mount(removals: string[], initial?: SerializedEditorState): Promise<Harness> {
|
|
107
|
+
const extensions = defaultClientEditorConfig.extensions?.clone()
|
|
108
|
+
for (const name of removals) extensions.remove(name)
|
|
109
|
+
|
|
110
|
+
const lastEmittedHashRef = createRef<string | undefined>() as React.RefObject<string | undefined>
|
|
111
|
+
const normalizedIncomingHashRef = createRef<string | undefined>() as React.RefObject<
|
|
112
|
+
string | undefined
|
|
113
|
+
>
|
|
114
|
+
const hasNormalizedBaselineRef = { current: false }
|
|
115
|
+
|
|
116
|
+
let instance: LexicalEditor | undefined
|
|
117
|
+
function Capture(): null {
|
|
118
|
+
const [editor] = useLexicalComposerContext()
|
|
119
|
+
instance = editor
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const container = document.createElement('div')
|
|
124
|
+
document.body.appendChild(container)
|
|
125
|
+
const root = createRoot(container)
|
|
126
|
+
mounted.push({ root, container })
|
|
127
|
+
|
|
128
|
+
const paint = async (value?: SerializedEditorState) => {
|
|
129
|
+
await act(async () => {
|
|
130
|
+
root.render(
|
|
131
|
+
<EditorContext
|
|
132
|
+
composerKey="wiring"
|
|
133
|
+
editorConfig={{ ...defaultClientEditorConfig, extensions }}
|
|
134
|
+
onChange={() => {}}
|
|
135
|
+
readOnly={false}
|
|
136
|
+
value={value}
|
|
137
|
+
>
|
|
138
|
+
<ApplyValuePlugin
|
|
139
|
+
value={value}
|
|
140
|
+
incomingHash={value != null ? hashSerializedState(value) : undefined}
|
|
141
|
+
lastEmittedHashRef={lastEmittedHashRef}
|
|
142
|
+
normalizedIncomingHashRef={normalizedIncomingHashRef}
|
|
143
|
+
hasNormalizedBaselineRef={hasNormalizedBaselineRef}
|
|
144
|
+
/>
|
|
145
|
+
<Capture />
|
|
146
|
+
</EditorContext>
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await paint(initial)
|
|
152
|
+
return {
|
|
153
|
+
markAsEmitted: (value: SerializedEditorState) => {
|
|
154
|
+
lastEmittedHashRef.current = hashSerializedState(value)
|
|
155
|
+
},
|
|
156
|
+
container,
|
|
157
|
+
editor: () => {
|
|
158
|
+
if (instance == null) throw new Error('editor never captured')
|
|
159
|
+
return instance
|
|
160
|
+
},
|
|
161
|
+
render: paint,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function editorText(editor: LexicalEditor): string {
|
|
166
|
+
let value = ''
|
|
167
|
+
editor.read(() => {
|
|
168
|
+
value = $getRoot().getTextContent()
|
|
169
|
+
})
|
|
170
|
+
return value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function blockTypes(editor: LexicalEditor): string[] {
|
|
174
|
+
let types: string[] = []
|
|
175
|
+
editor.read(() => {
|
|
176
|
+
types = $getRoot()
|
|
177
|
+
.getChildren()
|
|
178
|
+
.map((node) => node.getType())
|
|
179
|
+
})
|
|
180
|
+
return types
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe('normalization wiring', () => {
|
|
184
|
+
it('mounts with unsupported initial content instead of throwing', async () => {
|
|
185
|
+
// Before this wiring existed the editor build itself threw
|
|
186
|
+
// `parseEditorState: type "heading" + not found` and the field
|
|
187
|
+
// opened blank.
|
|
188
|
+
const harness = await mount([builtInExtensions.Heading], doc(heading('Legacy title')))
|
|
189
|
+
expect(blockTypes(harness.editor())).toEqual(['paragraph'])
|
|
190
|
+
expect(editorText(harness.editor())).toBe('Legacy title')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('shows the adapted notice', async () => {
|
|
194
|
+
const harness = await mount([builtInExtensions.Heading], doc(heading('Legacy title')))
|
|
195
|
+
expect(harness.container.textContent).toContain('no longer supports')
|
|
196
|
+
expect(harness.container.querySelector('.byline-richtext-notice--adapted')).not.toBeNull()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('shows no notice when nothing needed adapting', async () => {
|
|
200
|
+
const harness = await mount([], doc(heading('Ordinary title')))
|
|
201
|
+
expect(harness.container.querySelector('.byline-richtext-notice')).toBeNull()
|
|
202
|
+
expect(blockTypes(harness.editor())).toEqual(['heading'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('normalizes a replacement value applied after mount', async () => {
|
|
206
|
+
const harness = await mount([builtInExtensions.Heading])
|
|
207
|
+
await harness.render(doc(heading('Applied later')))
|
|
208
|
+
expect(blockTypes(harness.editor())).toEqual(['paragraph'])
|
|
209
|
+
expect(editorText(harness.editor())).toBe('Applied later')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('opens read-only and names the content when there is no conversion', async () => {
|
|
213
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(image()))
|
|
214
|
+
const notice = harness.container.querySelector('.byline-richtext-notice--refused')
|
|
215
|
+
expect(notice).not.toBeNull()
|
|
216
|
+
// A readable name, not the raw node type.
|
|
217
|
+
expect(notice?.textContent).toContain('an image')
|
|
218
|
+
expect(notice?.textContent).not.toContain('inline-image')
|
|
219
|
+
expect(notice?.textContent).toContain('read-only')
|
|
220
|
+
expect(notice?.textContent).toContain('administrator')
|
|
221
|
+
// The editing surface is withheld rather than showing partial content.
|
|
222
|
+
expect(harness.container.querySelector('.editor-container')).toBeNull()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('recovers when a valid value arrives after a refusal', async () => {
|
|
226
|
+
// The apply plugin lives in `children`, so an earlier version that
|
|
227
|
+
// dropped children on refusal stranded the field: nothing was left
|
|
228
|
+
// mounted to apply a later value or clear the notice.
|
|
229
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(image()))
|
|
230
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).not.toBeNull()
|
|
231
|
+
|
|
232
|
+
await harness.render(doc(heading('Recovered')))
|
|
233
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).toBeNull()
|
|
234
|
+
expect(harness.container.querySelector('.editor-container')).not.toBeNull()
|
|
235
|
+
expect(editorText(harness.editor())).toBe('Recovered')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('survives valid, then refused, then valid again', async () => {
|
|
239
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(heading('First')))
|
|
240
|
+
expect(editorText(harness.editor())).toBe('First')
|
|
241
|
+
|
|
242
|
+
await harness.render(doc(image()))
|
|
243
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).not.toBeNull()
|
|
244
|
+
|
|
245
|
+
await harness.render(doc(heading('Third')))
|
|
246
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).toBeNull()
|
|
247
|
+
expect(editorText(harness.editor())).toBe('Third')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('clears the field after a refusal', async () => {
|
|
251
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(image()))
|
|
252
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).not.toBeNull()
|
|
253
|
+
|
|
254
|
+
const empty = doc({
|
|
255
|
+
children: [],
|
|
256
|
+
direction: null,
|
|
257
|
+
format: '',
|
|
258
|
+
indent: 0,
|
|
259
|
+
type: 'paragraph',
|
|
260
|
+
version: 1,
|
|
261
|
+
})
|
|
262
|
+
await harness.render(empty)
|
|
263
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).toBeNull()
|
|
264
|
+
expect(editorText(harness.editor())).toBe('')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('recovers when the SAME value returns after a refusal', async () => {
|
|
268
|
+
// The plugin remembers the last value it applied, and a refusal
|
|
269
|
+
// applies nothing — so returning to a previously applied value could
|
|
270
|
+
// be mistaken for "already applied" and skipped, leaving the notice
|
|
271
|
+
// stuck. Toggling between two values is the case a user hits first.
|
|
272
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(heading('Kept')))
|
|
273
|
+
expect(editorText(harness.editor())).toBe('Kept')
|
|
274
|
+
|
|
275
|
+
await harness.render(doc(image()))
|
|
276
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).not.toBeNull()
|
|
277
|
+
|
|
278
|
+
// The same value as before the refusal, not merely a different valid one.
|
|
279
|
+
await harness.render(doc(heading('Kept')))
|
|
280
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).toBeNull()
|
|
281
|
+
expect(harness.container.querySelector('.editor-container')).not.toBeNull()
|
|
282
|
+
expect(editorText(harness.editor())).toBe('Kept')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('survives repeated toggling between a refused and a valid value', async () => {
|
|
286
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(image()))
|
|
287
|
+
for (let cycle = 0; cycle < 3; cycle++) {
|
|
288
|
+
await harness.render(doc(heading('Toggled')))
|
|
289
|
+
expect(
|
|
290
|
+
harness.container.querySelector('.byline-richtext-notice--refused'),
|
|
291
|
+
`cycle ${cycle}: expected the notice to clear`
|
|
292
|
+
).toBeNull()
|
|
293
|
+
expect(editorText(harness.editor()), `cycle ${cycle}`).toBe('Toggled')
|
|
294
|
+
|
|
295
|
+
await harness.render(doc(image()))
|
|
296
|
+
expect(
|
|
297
|
+
harness.container.querySelector('.byline-richtext-notice--refused'),
|
|
298
|
+
`cycle ${cycle}: expected the notice to return`
|
|
299
|
+
).not.toBeNull()
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('recovers via the echo path, where the value returned to was emitted by the editor', async () => {
|
|
304
|
+
// The parent records the hash of every value the editor emits, so
|
|
305
|
+
// ApplyValuePlugin can ignore its own echo. That shortcut runs BEFORE
|
|
306
|
+
// the applied-value memo, so clearing the memo alone does not save
|
|
307
|
+
// this case: edit A (the editor emits it), receive refused B, return
|
|
308
|
+
// to A, and the echo shortcut would skip normalization and leave the
|
|
309
|
+
// notice up.
|
|
310
|
+
const harness = await mount([builtInExtensions.InlineImage], doc(heading('Edited')))
|
|
311
|
+
expect(editorText(harness.editor())).toBe('Edited')
|
|
312
|
+
|
|
313
|
+
// Simulate the parent having recorded this value as editor-emitted.
|
|
314
|
+
harness.markAsEmitted(doc(heading('Edited')))
|
|
315
|
+
|
|
316
|
+
await harness.render(doc(image()))
|
|
317
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).not.toBeNull()
|
|
318
|
+
|
|
319
|
+
await harness.render(doc(heading('Edited')))
|
|
320
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).toBeNull()
|
|
321
|
+
expect(harness.container.querySelector('.editor-container')).not.toBeNull()
|
|
322
|
+
expect(editorText(harness.editor())).toBe('Edited')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('names an unknown node type by its id rather than failing', async () => {
|
|
326
|
+
// A site or plugin contributing its own node supplies no label. The
|
|
327
|
+
// id is developer vocabulary, but it is accurate and searchable —
|
|
328
|
+
// and reaching this path at all is rare.
|
|
329
|
+
const custom = doc({
|
|
330
|
+
children: [],
|
|
331
|
+
direction: null,
|
|
332
|
+
format: '',
|
|
333
|
+
indent: 0,
|
|
334
|
+
type: 'acme-callout',
|
|
335
|
+
version: 1,
|
|
336
|
+
})
|
|
337
|
+
const harness = await mount([], custom)
|
|
338
|
+
const notice = harness.container.querySelector('.byline-richtext-notice--refused')
|
|
339
|
+
expect(notice).not.toBeNull()
|
|
340
|
+
expect(notice?.textContent).toContain('acme-callout')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('still edits normally when the image is supported', async () => {
|
|
344
|
+
const harness = await mount([], doc(image()))
|
|
345
|
+
expect(harness.container.querySelector('.byline-richtext-notice--refused')).toBeNull()
|
|
346
|
+
expect(harness.container.querySelector('.editor-container')).not.toBeNull()
|
|
347
|
+
})
|
|
348
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { builtInExtensions } from '../../config/built-in-extension-names'
|
|
4
|
+
import { buildFullEditor, buildRestrictedEditor } from '../../test-support/build-test-editor'
|
|
5
|
+
import { availableBlockFormats } from './index'
|
|
6
|
+
|
|
7
|
+
describe('availableBlockFormats', () => {
|
|
8
|
+
it('offers every format when the full set is present', () => {
|
|
9
|
+
const editor = buildFullEditor()
|
|
10
|
+
const formats = availableBlockFormats(editor, true)
|
|
11
|
+
for (const format of ['h1', 'h2', 'h3', 'h4', 'bullet', 'number', 'check', 'quote', 'code']) {
|
|
12
|
+
expect(formats, format).toContain(format)
|
|
13
|
+
}
|
|
14
|
+
editor.dispose()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('offers only paragraph when everything structural is removed', () => {
|
|
18
|
+
const editor = buildRestrictedEditor()
|
|
19
|
+
expect(availableBlockFormats(editor, false)).toEqual(['paragraph'])
|
|
20
|
+
editor.dispose()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('drops heading entries when the heading extension is removed', () => {
|
|
24
|
+
const editor = buildRestrictedEditor([builtInExtensions.Heading])
|
|
25
|
+
const formats = availableBlockFormats(editor, true)
|
|
26
|
+
expect(formats).not.toContain('h1')
|
|
27
|
+
expect(formats).toContain('quote')
|
|
28
|
+
editor.dispose()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('drops the quote entry when the quote extension is removed', () => {
|
|
32
|
+
const editor = buildRestrictedEditor([builtInExtensions.Quote])
|
|
33
|
+
const formats = availableBlockFormats(editor, true)
|
|
34
|
+
expect(formats).not.toContain('quote')
|
|
35
|
+
expect(formats).toContain('h1')
|
|
36
|
+
editor.dispose()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('offers bullets but not check lists when only CheckListExtension is removed', () => {
|
|
40
|
+
// The list NODES are still registered — ListExtension owns them — so a
|
|
41
|
+
// node-only rule would keep offering a control whose behaviour is gone.
|
|
42
|
+
const editor = buildRestrictedEditor([builtInExtensions.CheckList])
|
|
43
|
+
const formats = availableBlockFormats(editor, false)
|
|
44
|
+
expect(formats).toContain('bullet')
|
|
45
|
+
expect(formats).toContain('number')
|
|
46
|
+
expect(formats).not.toContain('check')
|
|
47
|
+
editor.dispose()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('never offers a format whose node the editor cannot create', () => {
|
|
51
|
+
const editor = buildRestrictedEditor()
|
|
52
|
+
for (const format of availableBlockFormats(editor, true)) {
|
|
53
|
+
expect(format).toBe('paragraph')
|
|
54
|
+
}
|
|
55
|
+
editor.dispose()
|
|
56
|
+
})
|
|
57
|
+
})
|