@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
@@ -32,7 +32,7 @@ import {
32
32
  INSERT_CHECK_LIST_COMMAND,
33
33
  INSERT_ORDERED_LIST_COMMAND,
34
34
  INSERT_UNORDERED_LIST_COMMAND,
35
- ListExtension,
35
+ ListItemNode,
36
36
  ListNode,
37
37
  REMOVE_LIST_COMMAND,
38
38
  } from '@lexical/list'
@@ -47,7 +47,9 @@ import {
47
47
  $createQuoteNode,
48
48
  $isHeadingNode,
49
49
  $isQuoteNode,
50
+ HeadingNode,
50
51
  type HeadingTagType,
52
+ QuoteNode,
51
53
  } from '@lexical/rich-text'
52
54
  import { $setBlocksType } from '@lexical/selection'
53
55
  import { $isTableNode } from '@lexical/table'
@@ -92,8 +94,8 @@ import {
92
94
  OPEN_LINK_MODAL_COMMAND,
93
95
  TOGGLE_LINK_COMMAND,
94
96
  } from '../../extensions/link'
95
- import { useMarkdownToggle } from '../../hooks/use-markdown-toggle'
96
- import { IS_APPLE } from '../../shared/environment'
97
+ import { canUseMarkdownSourceMode, useMarkdownToggle } from '../../hooks/use-markdown-toggle'
98
+ import { useIsApplePlatform } from '../../hooks/use-platform-modifier'
97
99
  import { DropDown, DropDownItem } from '../../ui/dropdown'
98
100
  import { getSelectedNode } from '../../utils/getSelectedNode'
99
101
  import { sanitizeUrl } from '../../utils/url'
@@ -136,6 +138,53 @@ function dropDownActiveClass(active: boolean): string {
136
138
  return ''
137
139
  }
138
140
 
141
+ export type BlockFormat =
142
+ | 'paragraph'
143
+ | 'h1'
144
+ | 'h2'
145
+ | 'h3'
146
+ | 'h4'
147
+ | 'bullet'
148
+ | 'number'
149
+ | 'check'
150
+ | 'quote'
151
+ | 'code'
152
+
153
+ /**
154
+ * Block formats this editor can actually produce. Paragraph is always
155
+ * available.
156
+ *
157
+ * Structural availability comes from REGISTERED NODES, not from whether
158
+ * an extension appears in the configuration list: table and list nodes
159
+ * arrive through `@lexical/table` and `@lexical/list` dependencies, so a
160
+ * membership test would be wrong for exactly those cases.
161
+ *
162
+ * Registered nodes are necessary but not always sufficient.
163
+ * `hasCheckListBehaviour` is passed in because check lists need
164
+ * `CheckListExtension` registered as well as the list nodes — removing
165
+ * that extension alone leaves `ListNode` and `ListItemNode` in place, so
166
+ * a node-only rule would keep offering a control that no longer works.
167
+ *
168
+ * This is a correctness concern rather than a cosmetic one: creating an
169
+ * unregistered node throws "Attempted to create node X that was not
170
+ * configured to be used on the editor", so an offered-but-unsupported
171
+ * format is a crash.
172
+ */
173
+ export function availableBlockFormats(
174
+ editor: LexicalEditor,
175
+ hasCheckListBehaviour: boolean
176
+ ): BlockFormat[] {
177
+ const formats: BlockFormat[] = ['paragraph']
178
+ if (editor.hasNodes([HeadingNode])) formats.push('h1', 'h2', 'h3', 'h4')
179
+ if (editor.hasNodes([ListNode, ListItemNode])) {
180
+ formats.push('bullet', 'number')
181
+ if (hasCheckListBehaviour) formats.push('check')
182
+ }
183
+ if (editor.hasNodes([QuoteNode])) formats.push('quote')
184
+ if (editor.hasNodes([CodeNode])) formats.push('code')
185
+ return formats
186
+ }
187
+
139
188
  function BlockFormatDropDown({
140
189
  editor,
141
190
  blockType,
@@ -146,15 +195,13 @@ function BlockFormatDropDown({
146
195
  rootType: keyof typeof rootTypeToRootName
147
196
  editor: LexicalEditor
148
197
  disabled?: boolean
149
- }): React.JSX.Element {
150
- // Block-format dropdown entries are gated on whether the underlying
151
- // extensions are present in the editor's extension graph. Removing
152
- // ListExtension via lexicalEditor((c) => c.extensions.remove(ListExtension))
153
- // hides the list options here without any flag in EditorSettings.
154
- const hasList = useOptionalExtensionDependency(ListExtension) !== undefined
155
- const hasCheckList = useOptionalExtensionDependency(CheckListExtension) !== undefined
198
+ }): React.JSX.Element | null {
199
+ // Entries follow what the resolved editor can actually produce, so
200
+ // removing an extension removes its control along with its node
201
+ // registration no flag in EditorSettings is involved.
156
202
  const [composerEditor] = useLexicalComposerContext()
157
- const hasCodeHighlight = composerEditor.hasNodes([CodeNode])
203
+ const hasCheckListBehaviour = useOptionalExtensionDependency(CheckListExtension) !== undefined
204
+ const formats = availableBlockFormats(composerEditor, hasCheckListBehaviour)
158
205
 
159
206
  const formatParagraph = (): void => {
160
207
  editor.update(() => {
@@ -233,6 +280,10 @@ function BlockFormatDropDown({
233
280
  }
234
281
  }
235
282
 
283
+ // Paragraph alone is not a choice — with every structural format
284
+ // removed the control would offer a single no-op entry.
285
+ if (formats.length === 1) return null
286
+
236
287
  return (
237
288
  <DropDown
238
289
  disabled={disabled}
@@ -248,43 +299,47 @@ function BlockFormatDropDown({
248
299
  <i className="icon paragraph" />
249
300
  <span className="text">Normal</span>
250
301
  </DropDownItem>
251
- <DropDownItem
252
- className={`item ${dropDownActiveClass(blockType === 'h1')}`}
253
- onClick={() => {
254
- formatHeading('h1')
255
- }}
256
- >
257
- <i className="icon h1" />
258
- <span className="text">Heading 1</span>
259
- </DropDownItem>
260
- <DropDownItem
261
- className={`item ${dropDownActiveClass(blockType === 'h2')}`}
262
- onClick={() => {
263
- formatHeading('h2')
264
- }}
265
- >
266
- <i className="icon h2" />
267
- <span className="text">Heading 2</span>
268
- </DropDownItem>
269
- <DropDownItem
270
- className={`item ${dropDownActiveClass(blockType === 'h3')}`}
271
- onClick={() => {
272
- formatHeading('h3')
273
- }}
274
- >
275
- <i className="icon h3" />
276
- <span className="text">Heading 3</span>
277
- </DropDownItem>
278
- <DropDownItem
279
- className={`item ${dropDownActiveClass(blockType === 'h4')}`}
280
- onClick={() => {
281
- formatHeading('h4')
282
- }}
283
- >
284
- <i className="icon h4" />
285
- <span className="text">Heading 4</span>
286
- </DropDownItem>
287
- {hasList && (
302
+ {formats.includes('h1') && (
303
+ <>
304
+ <DropDownItem
305
+ className={`item ${dropDownActiveClass(blockType === 'h1')}`}
306
+ onClick={() => {
307
+ formatHeading('h1')
308
+ }}
309
+ >
310
+ <i className="icon h1" />
311
+ <span className="text">Heading 1</span>
312
+ </DropDownItem>
313
+ <DropDownItem
314
+ className={`item ${dropDownActiveClass(blockType === 'h2')}`}
315
+ onClick={() => {
316
+ formatHeading('h2')
317
+ }}
318
+ >
319
+ <i className="icon h2" />
320
+ <span className="text">Heading 2</span>
321
+ </DropDownItem>
322
+ <DropDownItem
323
+ className={`item ${dropDownActiveClass(blockType === 'h3')}`}
324
+ onClick={() => {
325
+ formatHeading('h3')
326
+ }}
327
+ >
328
+ <i className="icon h3" />
329
+ <span className="text">Heading 3</span>
330
+ </DropDownItem>
331
+ <DropDownItem
332
+ className={`item ${dropDownActiveClass(blockType === 'h4')}`}
333
+ onClick={() => {
334
+ formatHeading('h4')
335
+ }}
336
+ >
337
+ <i className="icon h4" />
338
+ <span className="text">Heading 4</span>
339
+ </DropDownItem>
340
+ </>
341
+ )}
342
+ {formats.includes('bullet') && (
288
343
  <>
289
344
  <DropDownItem
290
345
  className={`item ${dropDownActiveClass(blockType === 'bullet')}`}
@@ -303,7 +358,7 @@ function BlockFormatDropDown({
303
358
  </>
304
359
  )}
305
360
 
306
- {hasCheckList && (
361
+ {formats.includes('check') && (
307
362
  <DropDownItem
308
363
  className={`item ${dropDownActiveClass(blockType === 'check')}`}
309
364
  onClick={formatCheckList}
@@ -313,14 +368,16 @@ function BlockFormatDropDown({
313
368
  </DropDownItem>
314
369
  )}
315
370
 
316
- <DropDownItem
317
- className={`item ${dropDownActiveClass(blockType === 'quote')}`}
318
- onClick={formatQuote}
319
- >
320
- <i className="icon quote" />
321
- <span className="text">Quote</span>
322
- </DropDownItem>
323
- {hasCodeHighlight && (
371
+ {formats.includes('quote') && (
372
+ <DropDownItem
373
+ className={`item ${dropDownActiveClass(blockType === 'quote')}`}
374
+ onClick={formatQuote}
375
+ >
376
+ <i className="icon quote" />
377
+ <span className="text">Quote</span>
378
+ </DropDownItem>
379
+ )}
380
+ {formats.includes('code') && (
324
381
  <DropDownItem
325
382
  className={`item ${dropDownActiveClass(blockType === 'code')}`}
326
383
  onClick={formatCode}
@@ -373,10 +430,16 @@ export function ToolbarPlugin(): React.JSX.Element {
373
430
  const {
374
431
  uuid,
375
432
  config: {
376
- options: { textAlignment, undoRedo, textStyle, inlineCode, markdownToggle },
433
+ controls: { textAlignment, undoRedo, blockFormat, inlineCode, markdownToggle },
377
434
  },
378
435
  } = useEditorConfig()
379
436
  const { isMarkdown, toggleMarkdown } = useMarkdownToggle()
437
+ // Resolved after mount so the first client render matches the server;
438
+ // reading the platform during render mismatches on hydration.
439
+ const isApple = useIsApplePlatform()
440
+ // Source mode needs a CodeNode to hold the Markdown text, so the
441
+ // control follows that capability as well as the preference.
442
+ const canToggleMarkdown = canUseMarkdownSourceMode(editor)
380
443
  // const { openModal } = usePayloadModal()
381
444
  // const editDepth = useEditDepth()
382
445
 
@@ -600,7 +663,7 @@ export function ToolbarPlugin(): React.JSX.Element {
600
663
  onClick={() => {
601
664
  activeEditor.dispatchCommand(UNDO_COMMAND, undefined)
602
665
  }}
603
- title={IS_APPLE ? 'Undo (⌘Z)' : 'Undo (Ctrl+Z)'}
666
+ title={isApple ? 'Undo (⌘Z)' : 'Undo (Ctrl+Z)'}
604
667
  type="button"
605
668
  className="toolbar-item spaced"
606
669
  aria-label="Undo"
@@ -612,7 +675,7 @@ export function ToolbarPlugin(): React.JSX.Element {
612
675
  onClick={() => {
613
676
  activeEditor.dispatchCommand(REDO_COMMAND, undefined)
614
677
  }}
615
- title={IS_APPLE ? 'Redo (⌘Y)' : 'Redo (Ctrl+Y)'}
678
+ title={isApple ? 'Redo (⌘Y)' : 'Redo (Ctrl+Y)'}
616
679
  type="button"
617
680
  className="toolbar-item"
618
681
  aria-label="Redo"
@@ -625,7 +688,7 @@ export function ToolbarPlugin(): React.JSX.Element {
625
688
 
626
689
  {blockType in blockTypeToBlockName && activeEditor === editor && (
627
690
  <>
628
- {textStyle && (
691
+ {blockFormat && (
629
692
  <>
630
693
  <BlockFormatDropDown
631
694
  disabled={!isEditable}
@@ -736,9 +799,9 @@ export function ToolbarPlugin(): React.JSX.Element {
736
799
  activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
737
800
  }}
738
801
  className={`toolbar-item spaced ${isBold ? 'active' : ''}`}
739
- title={IS_APPLE ? 'Bold (⌘B)' : 'Bold (Ctrl+B)'}
802
+ title={isApple ? 'Bold (⌘B)' : 'Bold (Ctrl+B)'}
740
803
  type="button"
741
- aria-label={`Format text as bold. Shortcut: ${IS_APPLE ? '⌘B' : 'Ctrl+B'}`}
804
+ aria-label={`Format text as bold. Shortcut: ${isApple ? '⌘B' : 'Ctrl+B'}`}
742
805
  >
743
806
  <i className="format bold" />
744
807
  </button>
@@ -748,9 +811,9 @@ export function ToolbarPlugin(): React.JSX.Element {
748
811
  activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
749
812
  }}
750
813
  className={`toolbar-item spaced ${isItalic ? 'active' : ''}`}
751
- title={IS_APPLE ? 'Italic (⌘I)' : 'Italic (Ctrl+I)'}
814
+ title={isApple ? 'Italic (⌘I)' : 'Italic (Ctrl+I)'}
752
815
  type="button"
753
- aria-label={`Format text as italics. Shortcut: ${IS_APPLE ? '⌘I' : 'Ctrl+I'}`}
816
+ aria-label={`Format text as italics. Shortcut: ${isApple ? '⌘I' : 'Ctrl+I'}`}
754
817
  >
755
818
  <i className="format italic" />
756
819
  </button>
@@ -760,9 +823,9 @@ export function ToolbarPlugin(): React.JSX.Element {
760
823
  activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
761
824
  }}
762
825
  className={`toolbar-item spaced ${isUnderline ? 'active' : ''}`}
763
- title={IS_APPLE ? 'Underline (⌘U)' : 'Underline (Ctrl+U)'}
826
+ title={isApple ? 'Underline (⌘U)' : 'Underline (Ctrl+U)'}
764
827
  type="button"
765
- aria-label={`Format text to underlined. Shortcut: ${IS_APPLE ? '⌘U' : 'Ctrl+U'}`}
828
+ aria-label={`Format text to underlined. Shortcut: ${isApple ? '⌘U' : 'Ctrl+U'}`}
766
829
  >
767
830
  <i className="format underline" />
768
831
  </button>
@@ -872,7 +935,7 @@ export function ToolbarPlugin(): React.JSX.Element {
872
935
  ))}
873
936
  </ToolbarActiveEditorProvider>
874
937
 
875
- {markdownToggle && (
938
+ {markdownToggle && canToggleMarkdown && (
876
939
  <>
877
940
  <Divider />
878
941
  <button
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { buildFullEditor, buildRestrictedEditor, parseHtmlToTypes } from './build-test-editor'
4
+
5
+ describe('test harness', () => {
6
+ it('builds the full default extension set', () => {
7
+ const editor = buildFullEditor()
8
+ expect(parseHtmlToTypes(editor, '<p>hello</p>').types).toEqual(['paragraph'])
9
+ editor.dispose()
10
+ })
11
+
12
+ it('builds a restricted editor', () => {
13
+ const editor = buildRestrictedEditor()
14
+ expect(parseHtmlToTypes(editor, '<p>hello</p>').types).toEqual(['paragraph'])
15
+ editor.dispose()
16
+ })
17
+ })
@@ -0,0 +1,99 @@
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
+ * Shared editor builders for jsdom tests.
11
+ *
12
+ * These compose a root extension directly rather than rendering
13
+ * `EditorContext`, which keeps capability tests fast and free of React.
14
+ * That also means they do NOT exercise the shipped component: the
15
+ * production root extension lives in `editor-context.tsx`, and a
16
+ * regression there is caught by `editor-context.regression.test.tsx`,
17
+ * not here.
18
+ */
19
+
20
+ import { buildEditorFromExtensions } from '@lexical/extension'
21
+ import { $generateNodesFromDOM } from '@lexical/html'
22
+ import { ReactPluginHostExtension } from '@lexical/react/ReactPluginHostExtension'
23
+ import { defineExtension, type LexicalEditor } from 'lexical'
24
+
25
+ import { builtInExtensions } from '../config/built-in-extension-names'
26
+ import { defaultExtensionsList } from '../config/default-extensions'
27
+ import { rootDependencies } from '../config/root-dependencies'
28
+ import type { ExtensionsList } from '../config/extensions-list'
29
+
30
+ const NAMESPACE = 'LexicalRichText'
31
+
32
+ /**
33
+ * `ReactPluginHostExtension` is required, not optional: several default
34
+ * extensions host React decorators and `buildEditorFromExtensions`
35
+ * throws "No ReactProviderExtension detected" without it.
36
+ */
37
+ function build(extensions: ExtensionsList) {
38
+ return buildEditorFromExtensions(
39
+ defineExtension({
40
+ name: '[test-root]',
41
+ namespace: NAMESPACE,
42
+ // Through the same seam the live editor uses, so these builders
43
+ // mirror production rather than flattering it.
44
+ // biome-ignore lint/suspicious/noExplicitAny: extension arguments are heterogeneous by design
45
+ dependencies: [ReactPluginHostExtension, ...rootDependencies(extensions)] as any,
46
+ })
47
+ )
48
+ }
49
+
50
+ /** Every default extension, as a production editor would have them. */
51
+ export function buildFullEditor() {
52
+ return build(defaultExtensionsList())
53
+ }
54
+
55
+ /**
56
+ * The defaults minus the named extensions. Defaults to removing every
57
+ * Byline built-in plus the upstream list extensions — the "minimal
58
+ * editor" a downstream site builds when it strips a field back.
59
+ */
60
+ export function buildRestrictedEditor(removals?: string[]) {
61
+ const names = removals ?? [
62
+ ...Object.values(builtInExtensions),
63
+ '@lexical/list/List',
64
+ '@lexical/list/CheckList',
65
+ ]
66
+ const list = defaultExtensionsList()
67
+ for (const name of names) list.remove(name)
68
+ return build(list)
69
+ }
70
+
71
+ /**
72
+ * Parse `html` through the legacy `$generateNodesFromDOM` path — the one
73
+ * a clipboard paste actually uses — and report what the editor made of
74
+ * it. Assertions are on node types and text, never on markup.
75
+ *
76
+ * Scope: this inspects the imported nodes BEFORE insertion, so it
77
+ * evidences HTML conversion only. Real clipboard insertion — the
78
+ * `application/x-lexical-editor` branch, its fallthrough, and what
79
+ * survives `$insertGeneratedNodes` — is covered separately by the
80
+ * clipboard-equivalence tests.
81
+ */
82
+ export function parseHtmlToTypes(
83
+ editor: LexicalEditor,
84
+ html: string
85
+ ): { types: string[]; text: string } {
86
+ const dom = new DOMParser().parseFromString(html, 'text/html')
87
+ let result = { types: [] as string[], text: '' }
88
+ editor.update(
89
+ () => {
90
+ const nodes = $generateNodesFromDOM(editor, dom)
91
+ result = {
92
+ types: nodes.map((node) => node.getType()),
93
+ text: nodes.map((node) => node.getTextContent()).join('|'),
94
+ }
95
+ },
96
+ { discrete: true }
97
+ )
98
+ return result
99
+ }
@@ -0,0 +1,20 @@
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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
10
+ import type { LexicalEditor } from 'lexical'
11
+
12
+ /**
13
+ * Hands the composer's editor to a test. Must be rendered as a child of
14
+ * `EditorContext` so it sits inside the composer's React tree.
15
+ */
16
+ export function CaptureEditor({ onEditor }: { onEditor: (editor: LexicalEditor) => void }): null {
17
+ const [editor] = useLexicalComposerContext()
18
+ onEditor(editor)
19
+ return null
20
+ }
@@ -0,0 +1,91 @@
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
+ * Deterministic control over the frames and idle callbacks the editor
11
+ * defers work through.
12
+ *
13
+ * Two pieces of editor behaviour are scheduled rather than immediate,
14
+ * and a test that waits a fixed number of milliseconds for either is
15
+ * guessing:
16
+ *
17
+ * - `ApplyValuePlugin` captures its normalization baseline after a
18
+ * microtask and **two** animation frames.
19
+ * - `editor-component` defers change emission through
20
+ * `requestIdleCallback`, which jsdom does not implement at all — so
21
+ * without a shim it takes a synchronous branch and an entire class of
22
+ * ordering bug becomes invisible.
23
+ *
24
+ * Installing controllable versions of both makes the ordering explicit:
25
+ * a test says when a frame happens, rather than sleeping and hoping.
26
+ */
27
+
28
+ interface FrameControl {
29
+ /** Run pending animation-frame callbacks, `generations` deep. */
30
+ flushFrames: (generations?: number) => Promise<void>
31
+ /** Run every pending idle callback. */
32
+ flushIdle: () => void
33
+ /** How many idle callbacks are scheduled but not yet run. */
34
+ pendingIdle: () => number
35
+ /** Settle the normalization baseline: a microtask, then two frames. */
36
+ settleBaseline: () => Promise<void>
37
+ /** Restore the original globals. */
38
+ restore: () => void
39
+ }
40
+
41
+ export function installFrameControl(): FrameControl {
42
+ const frameCallbacks: FrameRequestCallback[] = []
43
+ const idleCallbacks: Array<() => void> = []
44
+
45
+ const originalRaf = globalThis.requestAnimationFrame
46
+ const originalCaf = globalThis.cancelAnimationFrame
47
+ // biome-ignore lint/suspicious/noExplicitAny: jsdom does not implement these
48
+ const originalRic = (globalThis as any).requestIdleCallback
49
+ // biome-ignore lint/suspicious/noExplicitAny: jsdom does not implement these
50
+ const originalCic = (globalThis as any).cancelIdleCallback
51
+
52
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
53
+ frameCallbacks.push(callback)
54
+ return frameCallbacks.length
55
+ }) as typeof requestAnimationFrame
56
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
57
+ // biome-ignore lint/suspicious/noExplicitAny: shimming a missing jsdom API
58
+ ;(globalThis as any).requestIdleCallback = (callback: () => void) => {
59
+ idleCallbacks.push(callback)
60
+ return idleCallbacks.length
61
+ }
62
+ // biome-ignore lint/suspicious/noExplicitAny: shimming a missing jsdom API
63
+ ;(globalThis as any).cancelIdleCallback = () => {}
64
+
65
+ const flushFrames = async (generations = 1): Promise<void> => {
66
+ for (let generation = 0; generation < generations; generation++) {
67
+ // Drain microtasks first: the plugin schedules its first frame
68
+ // from inside `queueMicrotask`.
69
+ await Promise.resolve()
70
+ for (const callback of frameCallbacks.splice(0)) callback(performance.now())
71
+ }
72
+ await Promise.resolve()
73
+ }
74
+
75
+ return {
76
+ flushFrames,
77
+ flushIdle: () => {
78
+ for (const callback of idleCallbacks.splice(0)) callback()
79
+ },
80
+ pendingIdle: () => idleCallbacks.length,
81
+ settleBaseline: () => flushFrames(2),
82
+ restore: () => {
83
+ globalThis.requestAnimationFrame = originalRaf
84
+ globalThis.cancelAnimationFrame = originalCaf
85
+ // biome-ignore lint/suspicious/noExplicitAny: restoring a shimmed API
86
+ ;(globalThis as any).requestIdleCallback = originalRic
87
+ // biome-ignore lint/suspicious/noExplicitAny: restoring a shimmed API
88
+ ;(globalThis as any).cancelIdleCallback = originalCic
89
+ },
90
+ }
91
+ }
@@ -0,0 +1,41 @@
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
+ * jsdom gaps that the editor's React tree depends on.
11
+ *
12
+ * Mounting the real `EditorContext` pulls in components that query
13
+ * media features and observe element size; jsdom implements neither, so
14
+ * they are stubbed here rather than mocked per-test.
15
+ */
16
+
17
+ if (typeof window !== 'undefined') {
18
+ if (typeof window.matchMedia !== 'function') {
19
+ Object.defineProperty(window, 'matchMedia', {
20
+ writable: true,
21
+ value: (query: string) => ({
22
+ matches: false,
23
+ media: query,
24
+ onchange: null,
25
+ addListener: () => {},
26
+ removeListener: () => {},
27
+ addEventListener: () => {},
28
+ removeEventListener: () => {},
29
+ dispatchEvent: () => false,
30
+ }),
31
+ })
32
+ }
33
+
34
+ if (typeof globalThis.ResizeObserver === 'undefined') {
35
+ globalThis.ResizeObserver = class {
36
+ observe(): void {}
37
+ unobserve(): void {}
38
+ disconnect(): void {}
39
+ } as unknown as typeof ResizeObserver
40
+ }
41
+ }
package/src/index.ts CHANGED
@@ -42,7 +42,9 @@ export {
42
42
  selectToolbarItems,
43
43
  } from './field/extensions/byline-toolbar'
44
44
  export { CodeHighlightExtension } from './field/extensions/code-highlight/code-highlight-extension'
45
+ export { CoreNodesExtension } from './field/extensions/core-nodes/core-nodes-extension'
45
46
  export { FloatingTextFormatExtension } from './field/extensions/floating-text-format/floating-text-format-extension'
47
+ export { HeadingExtension } from './field/extensions/heading/heading-extension'
46
48
  export { HorizontalRuleExtension } from './field/extensions/horizontal-rule/horizontal-rule-extension'
47
49
  export {
48
50
  type InlineImageConfig,
@@ -50,10 +52,13 @@ export {
50
52
  } from './field/extensions/inline-image/inline-image-extension'
51
53
  export { LayoutExtension } from './field/extensions/layout/layout-extension'
52
54
  export { AutoLinkExtension, LinkExtension } from './field/extensions/link'
55
+ export { QuoteExtension } from './field/extensions/quote/quote-extension'
53
56
  export { TableExtension } from './field/extensions/table/table-extension'
54
57
  export { VimeoExtension } from './field/extensions/vimeo/vimeo-extension'
55
58
  export { YouTubeExtension } from './field/extensions/youtube/youtube-extension'
56
- export { Nodes } from './field/nodes'
59
+ // Not a registration list: the vocabulary of node classes Byline can
60
+ // read, used by normalization and the capability manifest.
61
+ export { READABLE_NODES } from './field/nodes'
57
62
  // Hook for extensions that contribute toolbar items via
58
63
  // BylineToolbarExtension and need the active editor for command dispatch.
59
64
  export {
@@ -62,6 +67,11 @@ export {
62
67
  } from './field/plugins/toolbar-plugin/toolbar-active-editor'
63
68
  export { lexicalEditor } from './lexical-editor'
64
69
  export { RichTextField } from './richtext-field'
70
+ // Pre-upgrade capability scan. `capabilitiesFor` / `capabilitiesFromEditor`
71
+ // need a DOM and run in a browser or jsdom; `scanDocument` is pure and runs
72
+ // anywhere, which is why the manifest travels between them as a file.
73
+ export { buildManifest, capabilitiesFor, capabilitiesFromEditor } from './scan/capability-manifest'
74
+ export { scanDocument, summarise } from './scan/scan-documents'
65
75
  export { createEmptyEditorState } from './validate/createEmptyEditorState'
66
76
  export { hasText } from './validate/hasText'
67
77
  export type { EditorConfig, EditorSettings, EditorSettingsOverride } from './field/config/types'
@@ -71,3 +81,4 @@ export type {
71
81
  SerializedInlineImageNode,
72
82
  } from './field/extensions/inline-image/node-types'
73
83
  export type { DocumentRelation } from './field/nodes/document-relation'
84
+ export type { CapabilityManifest, FieldCapabilities, ScanFinding } from './scan/types'