@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.
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,29 @@
1
+ import { HeadingNode } from '@lexical/rich-text'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { buildFullEditor, buildRestrictedEditor } from '../test-support/build-test-editor'
5
+ import { registeredNodeTypes, supportsNodeType } from './registered-node-types'
6
+
7
+ describe('registeredNodeTypes', () => {
8
+ it('always reports the core types', () => {
9
+ const editor = buildRestrictedEditor()
10
+ const types = registeredNodeTypes(editor)
11
+ expect(types.has('root')).toBe(true)
12
+ expect(types.has('paragraph')).toBe(true)
13
+ expect(types.has('text')).toBe(true)
14
+ editor.dispose()
15
+ })
16
+
17
+ it('agrees with hasNode for a class the editor registers', () => {
18
+ const editor = buildFullEditor()
19
+ expect(editor.hasNode(HeadingNode)).toBe(supportsNodeType(editor, 'heading'))
20
+ editor.dispose()
21
+ })
22
+
23
+ it('reports a type as unsupported once its extension is removed', () => {
24
+ const editor = buildRestrictedEditor()
25
+ expect(supportsNodeType(editor, 'table')).toBe(false)
26
+ expect(supportsNodeType(editor, 'link')).toBe(false)
27
+ editor.dispose()
28
+ })
29
+ })
@@ -0,0 +1,36 @@
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 type { LexicalEditor } from 'lexical'
10
+
11
+ /**
12
+ * The set of node type strings this editor accepts.
13
+ *
14
+ * This is the ONLY place in Byline that reads `editor._nodes`. Lexical
15
+ * declares the property on the public `LexicalEditor` type but exposes
16
+ * no way to enumerate registered type strings — `hasNode` answers only
17
+ * for a class you already hold, while the normalizer works from
18
+ * serialized JSON where all it has is the type string. Keeping the
19
+ * access here gives a future Lexical change one site to fix.
20
+ */
21
+ export function registeredNodeTypes(editor: LexicalEditor): ReadonlySet<string> {
22
+ return new Set(editor._nodes.keys())
23
+ }
24
+
25
+ /**
26
+ * Whether this editor accepts `type`.
27
+ *
28
+ * Node registration is what decides whether a structure can exist in the
29
+ * field at all — it governs the legacy paste path, `parseEditorState`
30
+ * and every command that creates a node — so this is the honest answer
31
+ * to "does this field support X", not whether some extension appears in
32
+ * a configuration list.
33
+ */
34
+ export function supportsNodeType(editor: LexicalEditor, type: string): boolean {
35
+ return editor._nodes.has(type)
36
+ }
@@ -8,7 +8,11 @@
8
8
 
9
9
  import { describe, expect, it } from 'vitest'
10
10
 
11
- import { builtInExtensions } from './built-in-extension-names'
11
+ import {
12
+ builtInExtensions,
13
+ bylineExtensionNames,
14
+ upstreamExtensionNames,
15
+ } from './built-in-extension-names'
12
16
 
13
17
  /**
14
18
  * Node-safe self-consistency checks for the `builtInExtensions` name map.
@@ -16,29 +20,49 @@ import { builtInExtensions } from './built-in-extension-names'
16
20
  * The map deliberately carries strings only (no extension imports), so
17
21
  * this test cannot diff against the live `defineExtension({ name })`
18
22
  * values without dragging the React-heavy editor graph into a node test.
19
- * Instead it guards the map's own integrity — the `@byline/richtext-lexical/*`
20
- * convention, uniqueness, and that each key matches the suffix of its
21
- * value. Each value must still mirror the corresponding extension's
22
- * `name`; that mirror is asserted by the editor's jsdom/integration
23
- * coverage, not here.
23
+ * Instead it guards the map's own integrity — the naming convention,
24
+ * uniqueness, and that each key matches the suffix of its value. Each
25
+ * value must still mirror the corresponding extension's `name`; that
26
+ * mirror is asserted by the editor's jsdom coverage, not here.
27
+ *
28
+ * The convention applies to the names Byline owns. `upstreamExtensionNames`
29
+ * is a documented exception: those extensions ship from `@lexical/list`,
30
+ * so Byline cannot namespace them and only guarantees that they are
31
+ * well-formed and unique.
24
32
  */
25
33
  describe('builtInExtensions name map', () => {
34
+ const bylineEntries = Object.entries(bylineExtensionNames)
26
35
  const entries = Object.entries(builtInExtensions)
27
36
 
28
- it('namespaces every value under @byline/richtext-lexical/', () => {
29
- for (const [, value] of entries) {
37
+ it('namespaces every Byline-owned value under @byline/richtext-lexical/', () => {
38
+ for (const [, value] of bylineEntries) {
30
39
  expect(value).toMatch(/^@byline\/richtext-lexical\/[A-Za-z]+$/)
31
40
  }
32
41
  })
33
42
 
34
- it('uses each value exactly once', () => {
35
- const values = entries.map(([, value]) => value)
36
- expect(new Set(values).size).toBe(values.length)
43
+ it('names each Byline-owned key after the suffix of its value', () => {
44
+ for (const [key, value] of bylineEntries) {
45
+ expect(value).toBe(`@byline/richtext-lexical/${key}`)
46
+ }
37
47
  })
38
48
 
39
- it('names each key after the suffix of its value', () => {
40
- for (const [key, value] of entries) {
41
- expect(value).toBe(`@byline/richtext-lexical/${key}`)
49
+ it('keeps upstream-owned values well-formed and namespaced to their package', () => {
50
+ for (const [, value] of Object.entries(upstreamExtensionNames)) {
51
+ expect(value).toMatch(/^@lexical\/[a-z-]+\/[A-Za-z]+$/)
42
52
  }
43
53
  })
54
+
55
+ it('exposes both groups through the merged map', () => {
56
+ for (const key of [
57
+ ...Object.keys(bylineExtensionNames),
58
+ ...Object.keys(upstreamExtensionNames),
59
+ ]) {
60
+ expect(builtInExtensions).toHaveProperty(key)
61
+ }
62
+ })
63
+
64
+ it('uses each value exactly once across the merged map', () => {
65
+ const values = entries.map(([, value]) => value)
66
+ expect(new Set(values).size).toBe(values.length)
67
+ })
44
68
  })
@@ -32,22 +32,50 @@
32
32
  * against the live extension `name`s is exercised by the editor's
33
33
  * jsdom/integration coverage.
34
34
  */
35
- export const builtInExtensions = {
35
+ export const bylineExtensionNames = {
36
36
  Admonition: '@byline/richtext-lexical/Admonition',
37
37
  AutoEmbed: '@byline/richtext-lexical/AutoEmbed',
38
38
  AutoLink: '@byline/richtext-lexical/AutoLink',
39
39
  CodeHighlight: '@byline/richtext-lexical/CodeHighlight',
40
40
  FloatingTextFormat: '@byline/richtext-lexical/FloatingTextFormat',
41
41
  FloatingUI: '@byline/richtext-lexical/FloatingUI',
42
+ Heading: '@byline/richtext-lexical/Heading',
42
43
  HorizontalRule: '@byline/richtext-lexical/HorizontalRule',
43
44
  InlineImage: '@byline/richtext-lexical/InlineImage',
44
45
  Layout: '@byline/richtext-lexical/Layout',
45
46
  Link: '@byline/richtext-lexical/Link',
47
+ Quote: '@byline/richtext-lexical/Quote',
46
48
  Table: '@byline/richtext-lexical/Table',
47
49
  Toolbar: '@byline/richtext-lexical/Toolbar',
48
50
  Vimeo: '@byline/richtext-lexical/Vimeo',
49
51
  YouTube: '@byline/richtext-lexical/YouTube',
50
52
  } as const
51
53
 
54
+ /**
55
+ * Names owned by upstream Lexical packages.
56
+ *
57
+ * These deliberately break the `@byline/richtext-lexical/<Key>`
58
+ * convention because Byline does not own the names — the extensions ship
59
+ * from `@lexical/list`. They are carried here so a site can remove lists
60
+ * without importing `@lexical/list` directly and taking a
61
+ * version-pinned dependency on `lexical` of its own.
62
+ */
63
+ export const upstreamExtensionNames = {
64
+ List: '@lexical/list/List',
65
+ CheckList: '@lexical/list/CheckList',
66
+ } as const
67
+
68
+ /**
69
+ * Every extension a site may remove, replace or reorder.
70
+ *
71
+ * Note what is absent: `CoreNodesExtension` is injected by the editor
72
+ * root rather than configured here, because `MarkNode` and
73
+ * `OverflowNode` belong to no switchable feature.
74
+ */
75
+ export const builtInExtensions = {
76
+ ...bylineExtensionNames,
77
+ ...upstreamExtensionNames,
78
+ } as const
79
+
52
80
  /** Union of the built-in extension `name` strings. */
53
81
  export type BuiltInExtensionName = (typeof builtInExtensions)[keyof typeof builtInExtensions]
@@ -22,10 +22,12 @@ import { BylineFloatingUIExtension } from '../extensions/byline-floating-ui/byli
22
22
  import { BylineToolbarExtension } from '../extensions/byline-toolbar/byline-toolbar-extension'
23
23
  import { CodeHighlightExtension } from '../extensions/code-highlight/code-highlight-extension'
24
24
  import { FloatingTextFormatExtension } from '../extensions/floating-text-format/floating-text-format-extension'
25
+ import { HeadingExtension } from '../extensions/heading/heading-extension'
25
26
  import { HorizontalRuleExtension } from '../extensions/horizontal-rule/horizontal-rule-extension'
26
27
  import { InlineImageExtension } from '../extensions/inline-image/inline-image-extension'
27
28
  import { LayoutExtension } from '../extensions/layout/layout-extension'
28
29
  import { LinkExtension } from '../extensions/link/link-extension'
30
+ import { QuoteExtension } from '../extensions/quote/quote-extension'
29
31
  import { TableExtension } from '../extensions/table/table-extension'
30
32
  import { VimeoExtension } from '../extensions/vimeo/vimeo-extension'
31
33
  import { YouTubeExtension } from '../extensions/youtube/youtube-extension'
@@ -61,6 +63,10 @@ export function defaultExtensionsArray(): AnyLexicalExtensionArgument[] {
61
63
  BylineToolbarExtension,
62
64
  BylineFloatingUIExtension,
63
65
 
66
+ // Block-level text structures.
67
+ HeadingExtension,
68
+ QuoteExtension,
69
+
64
70
  // Block- / list-level features.
65
71
  ListExtension,
66
72
  CheckListExtension,
@@ -9,17 +9,17 @@ export const defaultEditorLexicalConfig: LexicalEditorConfig = {
9
9
  }
10
10
 
11
11
  export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
12
- options: {
13
- richText: true,
14
- showTreeView: false,
12
+ mode: 'richText',
13
+ markdownShortcuts: false,
14
+ controls: {
15
+ blockFormat: true,
16
+ inlineCode: true,
17
+ undoRedo: true,
15
18
  textAlignment: true,
16
- markdownShortcutPlugin: false,
17
19
  markdownToggle: false,
18
- undoRedo: true,
19
- textStyle: true,
20
- inlineCode: true,
21
- debug: false,
20
+ treeView: false,
22
21
  },
22
+ debug: false,
23
23
  inlineImageUploadCollection: 'media',
24
24
  placeholderText: 'Enter some rich text...',
25
25
  }
@@ -4,20 +4,20 @@ import type * as React from 'react'
4
4
  import { createContext, useCallback, useContext, useMemo, useState } from 'react'
5
5
 
6
6
  import { DEFAULT_EDITOR_SETTINGS } from './default'
7
- import type { EditorSettings, OptionName } from './types'
7
+ import type { EditorControls, EditorSettings } from './types'
8
8
 
9
9
  // Should always produce a 20 character pseudo-random string
10
10
  function generateQuickGuid(): string {
11
11
  return Math.random().toString(36).substring(2, 12) + Math.random().toString(36).substring(2, 12)
12
12
  }
13
13
  interface ContextType {
14
- setOption: (name: OptionName, value: boolean) => void
14
+ setControl: (name: keyof EditorControls, value: boolean) => void
15
15
  config: EditorSettings
16
16
  uuid: string
17
17
  }
18
18
 
19
19
  const Context: React.Context<ContextType> = createContext({
20
- setOption: (_name: OptionName, _value: boolean) => {},
20
+ setControl: (_name: keyof EditorControls, _value: boolean) => {},
21
21
  config: DEFAULT_EDITOR_SETTINGS,
22
22
  uuid: generateQuickGuid(),
23
23
  })
@@ -33,14 +33,19 @@ export const EditorConfigContext = ({
33
33
  // State to store the UUID
34
34
  const [uuid] = useState(() => generateQuickGuid())
35
35
 
36
- const setOption = useCallback((option: OptionName, value: boolean) => {
37
- setConfig((config) => {
38
- const options = { ...config.options, [option as string]: value }
39
- return { ...config, options }
40
- })
36
+ /**
37
+ * Toggle one interface control at runtime. Controls hide affordances
38
+ * only there is deliberately no equivalent for capabilities, which
39
+ * are fixed when the editor is built.
40
+ */
41
+ const setControl = useCallback((control: keyof EditorControls, value: boolean) => {
42
+ setConfig((config) => ({
43
+ ...config,
44
+ controls: { ...config.controls, [control]: value },
45
+ }))
41
46
  }, [])
42
47
 
43
- const editorContext = useMemo(() => ({ setOption, config, uuid }), [setOption, config, uuid])
48
+ const editorContext = useMemo(() => ({ setControl, config, uuid }), [setControl, config, uuid])
44
49
 
45
50
  return <Context.Provider value={editorContext}>{children}</Context.Provider>
46
51
  }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { supportsNodeType } from '../capabilities/registered-node-types'
4
+ import {
5
+ buildFullEditor,
6
+ buildRestrictedEditor,
7
+ parseHtmlToTypes,
8
+ } from '../test-support/build-test-editor'
9
+ import { builtInExtensions } from './built-in-extension-names'
10
+
11
+ describe('list removal', () => {
12
+ it('exposes the upstream list names so a site need not import @lexical/list', () => {
13
+ expect(builtInExtensions.List).toBe('@lexical/list/List')
14
+ expect(builtInExtensions.CheckList).toBe('@lexical/list/CheckList')
15
+ })
16
+
17
+ it('degrades bullet and check lists to paragraphs', () => {
18
+ const editor = buildRestrictedEditor([builtInExtensions.List, builtInExtensions.CheckList])
19
+ expect(supportsNodeType(editor, 'list')).toBe(false)
20
+ expect(supportsNodeType(editor, 'listitem')).toBe(false)
21
+
22
+ const bullet = parseHtmlToTypes(editor, '<ul><li>item one</li><li>item two</li></ul>')
23
+ expect(bullet.types).toEqual(['paragraph', 'paragraph'])
24
+ expect(bullet.text).toContain('item one')
25
+ expect(bullet.text).toContain('item two')
26
+ editor.dispose()
27
+ })
28
+
29
+ it('keeps bullet lists when only the check-list extension is removed', () => {
30
+ // CheckListExtension contributes behaviour, not nodes — ListExtension
31
+ // owns both node classes, so removing the check list must not take
32
+ // ordinary lists with it.
33
+ const editor = buildRestrictedEditor([builtInExtensions.CheckList])
34
+ expect(supportsNodeType(editor, 'list')).toBe(true)
35
+ expect(parseHtmlToTypes(editor, '<ul><li>one</li></ul>').types).toEqual(['list'])
36
+ editor.dispose()
37
+ })
38
+
39
+ it('still imports lists with the default configuration', () => {
40
+ const editor = buildFullEditor()
41
+ expect(parseHtmlToTypes(editor, '<ul><li>one</li></ul>').types).toEqual(['list'])
42
+ editor.dispose()
43
+ })
44
+ })
@@ -32,23 +32,44 @@ describe('resolveEditorConfig', () => {
32
32
 
33
33
  it('field settings win per-key; unspecified registered settings survive', () => {
34
34
  const registered = clone(defaultEditorConfig)
35
- registered.settings.options.markdownToggle = true
35
+ registered.settings.controls.markdownToggle = true
36
36
  registered.settings.placeholderText = 'registered placeholder'
37
37
 
38
38
  const fieldConfig = clone(defaultEditorConfig)
39
- fieldConfig.settings.options.textStyle = false
40
- fieldConfig.settings.options.undoRedo = false
39
+ fieldConfig.settings.controls.blockFormat = false
40
+ fieldConfig.settings.controls.undoRedo = false
41
41
 
42
42
  const resolved = resolveEditorConfig(fieldConfig, registered)
43
43
  // Field's flags applied…
44
- expect(resolved.settings.options.textStyle).toBe(false)
45
- expect(resolved.settings.options.undoRedo).toBe(false)
44
+ expect(resolved.settings.controls.blockFormat).toBe(false)
45
+ expect(resolved.settings.controls.undoRedo).toBe(false)
46
46
  // …and since schema-side configs are complete objects, its values win
47
47
  // for every key it carries (markdownToggle false from the default seed).
48
- expect(resolved.settings.options.markdownToggle).toBe(false)
48
+ expect(resolved.settings.controls.markdownToggle).toBe(false)
49
49
  expect(resolved.settings.placeholderText).toBe(fieldConfig.settings.placeholderText)
50
50
  })
51
51
 
52
+ it('merges controls per-key so a partial override keeps the rest', () => {
53
+ const registered = clone(defaultEditorConfig)
54
+ const fieldConfig = {
55
+ settings: { controls: { blockFormat: false } },
56
+ } as unknown as typeof registered
57
+
58
+ const resolved = resolveEditorConfig(fieldConfig, registered)
59
+ expect(resolved.settings.controls.blockFormat).toBe(false)
60
+ // Untouched controls keep the registered layer's values rather than
61
+ // being wiped by a partial object.
62
+ expect(resolved.settings.controls.undoRedo).toBe(true)
63
+ expect(resolved.settings.controls.inlineCode).toBe(true)
64
+ expect(resolved.settings.mode).toBe('richText')
65
+ expect(resolved.settings.markdownShortcuts).toBe(false)
66
+ })
67
+
68
+ it('no longer carries an options record', () => {
69
+ const registered = clone(defaultEditorConfig)
70
+ expect('options' in registered.settings).toBe(false)
71
+ })
72
+
52
73
  it('REGRESSION: a schema-side settings-only config must not discard the registered extensions graph', () => {
53
74
  // Before the merge existed, `field.editorConfig ?? registered` replaced
54
75
  // the whole object: a compact/minimal settings preset baked into the
@@ -40,9 +40,9 @@ export function resolveEditorConfig(
40
40
  settings: {
41
41
  ...registeredConfig.settings,
42
42
  ...fieldConfig.settings,
43
- options: {
44
- ...registeredConfig.settings.options,
45
- ...fieldConfig.settings?.options,
43
+ controls: {
44
+ ...registeredConfig.settings.controls,
45
+ ...fieldConfig.settings?.controls,
46
46
  },
47
47
  },
48
48
  lexical: fieldConfig.lexical ?? registeredConfig.lexical,
@@ -0,0 +1,32 @@
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 type { AnyLexicalExtensionArgument } from 'lexical'
10
+
11
+ import { CoreNodesExtension } from '../extensions/core-nodes/core-nodes-extension'
12
+ import type { ExtensionsList } from './extensions-list'
13
+
14
+ /**
15
+ * The complete dependency list for an editor root.
16
+ *
17
+ * `editorConfig.extensions` carries the OPTIONAL features a site
18
+ * configures. What an editor actually registers is that list plus
19
+ * mandatory infrastructure, and `CoreNodesExtension` is injected here
20
+ * rather than living in the configurable list because `MarkNode` and
21
+ * `OverflowNode` belong to no switchable feature — `ExtensionsList.remove()`
22
+ * matches by name and accepts the extension object, so an entry in the
23
+ * list could be removed by site code.
24
+ *
25
+ * Everything that builds an editor goes through this: the live editor,
26
+ * the test builders, and the capability manifest. A manifest that
27
+ * disagreed with the runtime about which nodes exist would be worse than
28
+ * no manifest, and this is the seam that keeps them identical.
29
+ */
30
+ export function rootDependencies(extensions: ExtensionsList): AnyLexicalExtensionArgument[] {
31
+ return [CoreNodesExtension, ...extensions.toArray()]
32
+ }
@@ -3,25 +3,66 @@ import type { EditorConfig as LexicalEditorConfig } from 'lexical'
3
3
  import type { ExtensionsList } from './extensions-list'
4
4
 
5
5
  /**
6
- * Settings that aren't expressible as "is extension X present in the
7
- * extensions list?" — toolbar / UI mode toggles, debug switches, the
8
- * inline-image upload target, etc. Everything else now lives on
9
- * {@link EditorConfig.extensions} and is manipulated via the chainable
10
- * `lexicalEditor((c) => c.extensions.add(...).remove(...))` API.
6
+ * Interface preferences which controls the editing surface shows.
7
+ *
8
+ * Every flag here hides an affordance and NOTHING else. None of them
9
+ * restricts what the field accepts: content still arrives by paste, by
10
+ * Markdown, and from storage regardless. What a field supports is
11
+ * decided by its resolved editor — the extensions it is configured with,
12
+ * everything those pull in transitively, and mandatory core nodes.
13
+ *
14
+ * If you want to stop a structure existing in a field, remove the
15
+ * extension that owns it:
16
+ * `lexicalEditor((c) => c.extensions.remove(builtInExtensions.Heading))`.
11
17
  */
12
- export type OptionName =
13
- | 'richText'
14
- | 'showTreeView'
15
- | 'textAlignment'
16
- | 'markdownShortcutPlugin'
17
- | 'markdownToggle'
18
- | 'undoRedo'
19
- | 'textStyle'
20
- | 'inlineCode'
21
- | 'debug'
18
+ export interface EditorControls {
19
+ /**
20
+ * Show the block-format dropdown. Its entries are further limited to
21
+ * the formats the editor can actually produce, so this hides a control
22
+ * that is already capability-aware.
23
+ */
24
+ blockFormat: boolean
25
+ /**
26
+ * Show the inline-code button.
27
+ *
28
+ * This does NOT prevent inline code. Inline code is a `TextNode`
29
+ * format rather than a node type, so it has no node to unregister and
30
+ * still arrives by paste. Node-level configuration cannot constrain
31
+ * it, and neither can this flag.
32
+ */
33
+ inlineCode: boolean
34
+ /** Show the undo and redo buttons. Keyboard undo keeps working either way. */
35
+ undoRedo: boolean
36
+ /** Show the text-alignment controls. */
37
+ textAlignment: boolean
38
+ /** Show the Markdown source-mode toggle. */
39
+ markdownToggle: boolean
40
+ /** Show the Lexical tree view beneath the editor. */
41
+ treeView: boolean
42
+ }
22
43
 
23
44
  export interface EditorSettings {
24
- options: Record<OptionName, boolean>
45
+ /**
46
+ * Which editing surface to mount: `RichTextPlugin` or
47
+ * `PlainTextPlugin`. Plain-text mode also hides the toolbar.
48
+ *
49
+ * This selects the plugin and its input handling — it does NOT change
50
+ * what the field accepts. Switching to `plainText` unregisters no node
51
+ * class, so structural content already in a stored value still loads,
52
+ * and a paste is still governed by the editor's registered nodes. To
53
+ * stop a structure existing in the field, remove the extension that
54
+ * owns it.
55
+ */
56
+ mode: 'richText' | 'plainText'
57
+ /**
58
+ * Enable Markdown input shortcuts (`# `, `- `, `> ` as you type). The
59
+ * active transformer set follows the editor's registered nodes, so a
60
+ * shortcut for a structure the field does not support never fires.
61
+ */
62
+ markdownShortcuts: boolean
63
+ controls: EditorControls
64
+ /** Render debug output and the tree view. */
65
+ debug: boolean
25
66
  /**
26
67
  * Upload collection passed to the inline-image picker. Forwarded to
27
68
  * `InlineImageExtension`'s config when the extensions list is built;
@@ -33,7 +74,10 @@ export interface EditorSettings {
33
74
  }
34
75
 
35
76
  export interface EditorSettingsOverride {
36
- options?: Partial<Record<OptionName, boolean>>
77
+ mode?: EditorSettings['mode']
78
+ markdownShortcuts?: boolean
79
+ controls?: Partial<EditorControls>
80
+ debug?: boolean
37
81
  inlineImageUploadCollection?: string
38
82
  placeholderText?: string
39
83
  }
@@ -17,17 +17,29 @@ vi.mock('@byline/ui/react', () => ({
17
17
  }))
18
18
 
19
19
  import { ApplyValuePlugin } from './apply-value-plugin'
20
+ import { APPLY_VALUE_TAG } from './constants'
20
21
  import { hashSerializedState } from './utils/hashSerializedState'
21
22
 
22
23
  // Enable React act warnings suppression for this environment
23
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
25
  ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
25
26
 
26
- // Mock the Lexical composer context to avoid spinning up a real editor
27
+ // Mock the Lexical composer context to avoid spinning up a real editor.
28
+ //
29
+ // `_nodes` is required because ApplyValuePlugin normalizes the incoming
30
+ // value against the editor's registered node types before applying it.
31
+ // A stub with only the core types means nothing in these fixtures needs
32
+ // converting, so the plugin's apply behaviour is what is under test here
33
+ // rather than normalization (covered in `normalize/`).
27
34
  const mockEditor = {
28
35
  update: (fn: () => void) => fn(),
29
36
  parseEditorState: vi.fn((val) => val),
30
37
  setEditorState: vi.fn(),
38
+ _nodes: new Map<string, unknown>([
39
+ ['root', {}],
40
+ ['paragraph', {}],
41
+ ['text', {}],
42
+ ]),
31
43
  }
32
44
 
33
45
  vi.mock('@lexical/react/LexicalComposerContext', () => {
@@ -105,7 +117,10 @@ describe('ApplyValuePlugin', () => {
105
117
  })
106
118
 
107
119
  expect(mockEditor.setEditorState).toHaveBeenCalledTimes(1)
108
- expect(mockEditor.setEditorState).toHaveBeenLastCalledWith(stateA)
120
+ // The APPLY_VALUE_TAG argument is not incidental: it is what stops
121
+ // OnChangePlugin treating an applied value as a user edit, so assert
122
+ // it rather than the state alone.
123
+ expect(mockEditor.setEditorState).toHaveBeenLastCalledWith(stateA, { tag: APPLY_VALUE_TAG })
109
124
 
110
125
  // Re-render with same value -> no new apply
111
126
  await act(async () => {
@@ -135,7 +150,7 @@ describe('ApplyValuePlugin', () => {
135
150
  )
136
151
  })
137
152
  expect(mockEditor.setEditorState).toHaveBeenCalledTimes(2)
138
- expect(mockEditor.setEditorState).toHaveBeenLastCalledWith(stateB)
153
+ expect(mockEditor.setEditorState).toHaveBeenLastCalledWith(stateB, { tag: APPLY_VALUE_TAG })
139
154
 
140
155
  await act(async () => {
141
156
  root.unmount()