@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.
Files changed (123) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/field/apply-value-plugin.js +31 -6
  3. package/dist/field/capabilities/filter-transformers.d.ts +29 -0
  4. package/dist/field/capabilities/filter-transformers.js +8 -0
  5. package/dist/field/capabilities/registered-node-types.d.ts +29 -0
  6. package/dist/field/capabilities/registered-node-types.js +7 -0
  7. package/dist/field/config/built-in-extension-names.d.ts +42 -0
  8. package/dist/field/config/built-in-extension-names.js +12 -2
  9. package/dist/field/config/default-extensions.js +4 -0
  10. package/dist/field/config/default.js +8 -8
  11. package/dist/field/config/editor-config-context.d.ts +2 -2
  12. package/dist/field/config/editor-config-context.js +10 -13
  13. package/dist/field/config/resolve-editor-config.js +3 -3
  14. package/dist/field/config/root-dependencies.d.ts +26 -0
  15. package/dist/field/config/root-dependencies.js +8 -0
  16. package/dist/field/config/types.d.ts +61 -8
  17. package/dist/field/editor-context.js +44 -27
  18. package/dist/field/editor.js +15 -8
  19. package/dist/field/extensions/core-nodes/core-nodes-extension.d.ts +24 -0
  20. package/dist/field/extensions/core-nodes/core-nodes-extension.js +11 -0
  21. package/dist/field/extensions/heading/heading-extension.d.ts +20 -0
  22. package/dist/field/extensions/heading/heading-extension.js +9 -0
  23. package/dist/field/extensions/quote/quote-extension.d.ts +16 -0
  24. package/dist/field/extensions/quote/quote-extension.js +9 -0
  25. package/dist/field/hooks/use-markdown-toggle.d.ts +15 -0
  26. package/dist/field/hooks/use-markdown-toggle.js +12 -4
  27. package/dist/field/hooks/use-platform-modifier.d.ts +16 -0
  28. package/dist/field/hooks/use-platform-modifier.js +8 -0
  29. package/dist/field/markdown/transformers.d.ts +1 -0
  30. package/dist/field/markdown/transformers.js +5 -4
  31. package/dist/field/nodes/index.d.ts +11 -1
  32. package/dist/field/nodes/index.js +2 -2
  33. package/dist/field/normalize/adapted-notice.css +34 -0
  34. package/dist/field/normalize/adapted-notice.d.ts +29 -0
  35. package/dist/field/normalize/adapted-notice.js +35 -0
  36. package/dist/field/normalize/content-labels.d.ts +15 -0
  37. package/dist/field/normalize/content-labels.js +19 -0
  38. package/dist/field/normalize/declared-conversions.d.ts +131 -0
  39. package/dist/field/normalize/declared-conversions.js +93 -0
  40. package/dist/field/normalize/normalization-status.d.ts +33 -0
  41. package/dist/field/normalize/normalization-status.js +28 -0
  42. package/dist/field/normalize/normalize-value.d.ts +38 -0
  43. package/dist/field/normalize/normalize-value.js +132 -0
  44. package/dist/field/normalize/use-notice-text.d.ts +11 -0
  45. package/dist/field/normalize/use-notice-text.js +34 -0
  46. package/dist/field/plugins/toolbar-plugin/index.d.ts +23 -0
  47. package/dist/field/plugins/toolbar-plugin/index.js +104 -78
  48. package/dist/field/test-support/build-test-editor.js +49 -0
  49. package/dist/field/test-support/capture-editor.js +7 -0
  50. package/dist/field/test-support/frame-control.js +40 -0
  51. package/dist/field/test-support/jsdom-setup.js +21 -0
  52. package/dist/index.d.ts +7 -1
  53. package/dist/index.js +6 -1
  54. package/dist/scan/capability-manifest.d.ts +30 -0
  55. package/dist/scan/capability-manifest.js +43 -0
  56. package/dist/scan/scan-documents.d.ts +29 -0
  57. package/dist/scan/scan-documents.js +39 -0
  58. package/dist/scan/types.d.ts +63 -0
  59. package/dist/scan/types.js +1 -0
  60. package/dist/scan.d.ts +25 -0
  61. package/dist/scan.js +1 -0
  62. package/package.json +14 -5
  63. package/src/config.ts +1 -1
  64. package/src/field/apply-value-plugin.tsx +71 -5
  65. package/src/field/capabilities/filter-transformers.test.tsx +71 -0
  66. package/src/field/capabilities/filter-transformers.ts +42 -0
  67. package/src/field/capabilities/markdown-behaviour.test.tsx +188 -0
  68. package/src/field/capabilities/no-blanket-registration.test.tsx +86 -0
  69. package/src/field/capabilities/registered-node-types.test.tsx +29 -0
  70. package/src/field/capabilities/registered-node-types.ts +36 -0
  71. package/src/field/config/built-in-extension-names.test.node.ts +38 -14
  72. package/src/field/config/built-in-extension-names.ts +29 -1
  73. package/src/field/config/default-extensions.ts +6 -0
  74. package/src/field/config/default.ts +8 -8
  75. package/src/field/config/editor-config-context.tsx +14 -9
  76. package/src/field/config/list-removal.test.tsx +44 -0
  77. package/src/field/config/resolve-editor-config.test.node.ts +27 -6
  78. package/src/field/config/resolve-editor-config.ts +3 -3
  79. package/src/field/config/root-dependencies.ts +32 -0
  80. package/src/field/config/types.ts +61 -17
  81. package/src/field/editor-component.test.tsx +18 -3
  82. package/src/field/editor-context.regression.test.tsx +124 -0
  83. package/src/field/editor-context.tsx +90 -22
  84. package/src/field/editor.tsx +22 -12
  85. package/src/field/extensions/core-nodes/core-nodes-extension.test.tsx +13 -0
  86. package/src/field/extensions/core-nodes/core-nodes-extension.ts +32 -0
  87. package/src/field/extensions/heading/heading-extension.test.tsx +32 -0
  88. package/src/field/extensions/heading/heading-extension.ts +27 -0
  89. package/src/field/extensions/quote/quote-extension.ts +23 -0
  90. package/src/field/hooks/markdown-source-mode.test.tsx +221 -0
  91. package/src/field/hooks/use-markdown-toggle.ts +39 -4
  92. package/src/field/hooks/use-platform-modifier.test.tsx +81 -0
  93. package/src/field/hooks/use-platform-modifier.ts +41 -0
  94. package/src/field/markdown/transformers.ts +18 -4
  95. package/src/field/nodes/index.ts +11 -1
  96. package/src/field/normalize/adapted-notice.css +52 -0
  97. package/src/field/normalize/adapted-notice.tsx +63 -0
  98. package/src/field/normalize/clipboard-equivalence.test.tsx +330 -0
  99. package/src/field/normalize/content-labels.ts +44 -0
  100. package/src/field/normalize/declared-conversions.test.node.ts +67 -0
  101. package/src/field/normalize/declared-conversions.ts +160 -0
  102. package/src/field/normalize/emission-gate.test.tsx +302 -0
  103. package/src/field/normalize/normalization-status.tsx +48 -0
  104. package/src/field/normalize/normalize-load.test.tsx +371 -0
  105. package/src/field/normalize/normalize-value.test.node.ts +530 -0
  106. package/src/field/normalize/normalize-value.ts +180 -0
  107. package/src/field/normalize/notice-i18n.test.tsx +128 -0
  108. package/src/field/normalize/use-notice-text.ts +82 -0
  109. package/src/field/normalize/wiring.test.tsx +348 -0
  110. package/src/field/plugins/toolbar-plugin/block-format-capabilities.test.tsx +57 -0
  111. package/src/field/plugins/toolbar-plugin/index.tsx +131 -68
  112. package/src/field/test-support/build-test-editor.test.tsx +17 -0
  113. package/src/field/test-support/build-test-editor.ts +99 -0
  114. package/src/field/test-support/capture-editor.tsx +20 -0
  115. package/src/field/test-support/frame-control.ts +91 -0
  116. package/src/field/test-support/jsdom-setup.ts +41 -0
  117. package/src/index.ts +12 -1
  118. package/src/scan/capability-manifest.test.tsx +82 -0
  119. package/src/scan/capability-manifest.ts +81 -0
  120. package/src/scan/scan-documents.test.node.ts +125 -0
  121. package/src/scan/scan-documents.ts +78 -0
  122. package/src/scan/types.ts +66 -0
  123. 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
+ })