@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,124 @@
1
+ import { act } from 'react'
2
+
3
+ import { defineAdminConfig } from '@byline/core'
4
+ import { MarkNode } from '@lexical/mark'
5
+ import { OverflowNode } from '@lexical/overflow'
6
+ import { HeadingNode } from '@lexical/rich-text'
7
+ import type { LexicalEditor } from 'lexical'
8
+ import { createRoot, type Root } from 'react-dom/client'
9
+ import { afterEach, describe, expect, it } from 'vitest'
10
+
11
+ import { builtInExtensions } from './config/built-in-extension-names'
12
+ import { defaultClientEditorConfig } from './config/default-extensions'
13
+ import { EditorContext } from './editor-context'
14
+ import { CoreNodesExtension } from './extensions/core-nodes/core-nodes-extension'
15
+ import { READABLE_NODES } from './nodes'
16
+ import { CaptureEditor } from './test-support/capture-editor'
17
+
18
+ // biome-ignore lint/suspicious/noExplicitAny: React act environment flag
19
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
20
+
21
+ // The editor's toolbar resolves the admin config unconditionally, so with
22
+ // none registered the mount throws "Byline has not been configured yet".
23
+ // Register a real minimal one rather than mocking `@byline/core` — the
24
+ // same approach `@byline/admin`'s form tests take. It lives on a global,
25
+ // so module scope runs it once.
26
+ defineAdminConfig({
27
+ i18n: {
28
+ admin: { defaultLocale: 'en', locales: ['en'] },
29
+ content: { defaultLocale: 'en', locales: ['en'] },
30
+ },
31
+ collections: [
32
+ {
33
+ path: 'pages',
34
+ labels: { singular: 'Page', plural: 'Pages' },
35
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
36
+ },
37
+ ],
38
+ })
39
+
40
+ /**
41
+ * Mounts the REAL EditorContext. The shared jsdom builders in
42
+ * `test-support/build-test-editor.ts` compose their own root extension
43
+ * and so cannot see a blanket node list living in the shipped
44
+ * component — this test can, which is why the defect is pinned here.
45
+ */
46
+ /** Every root mounted by this file, unmounted after each test. */
47
+ const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
48
+
49
+ afterEach(async () => {
50
+ // Without this each test leaves a live editor and its listeners
51
+ // attached to the document, which leaks between tests.
52
+ for (const { root, container } of mounted.splice(0)) {
53
+ await act(async () => {
54
+ root.unmount()
55
+ })
56
+ container.remove()
57
+ }
58
+ })
59
+
60
+ async function mountEditor(removals: string[]): Promise<LexicalEditor> {
61
+ const extensions = defaultClientEditorConfig.extensions?.clone()
62
+ for (const name of removals) extensions.remove(name)
63
+
64
+ let editor: LexicalEditor | undefined
65
+ const container = document.createElement('div')
66
+ document.body.appendChild(container)
67
+ const root = createRoot(container)
68
+ mounted.push({ root, container })
69
+
70
+ // Async act: Lexical flushes listeners in a microtask and ToolbarPlugin
71
+ // sets state from them, so a synchronous act() leaves those updates
72
+ // outside its scope and React warns.
73
+ await act(async () => {
74
+ root.render(
75
+ <EditorContext
76
+ composerKey="regression"
77
+ editorConfig={{ ...defaultClientEditorConfig, extensions }}
78
+ onChange={() => {}}
79
+ readOnly={false}
80
+ >
81
+ <CaptureEditor
82
+ onEditor={(instance) => {
83
+ editor = instance
84
+ }}
85
+ />
86
+ </EditorContext>
87
+ )
88
+ })
89
+
90
+ if (editor == null) throw new Error('editor was never captured')
91
+ return editor
92
+ }
93
+
94
+ describe('EditorContext registers only what its extensions own', () => {
95
+ it('does not register headings when the heading extension is removed', async () => {
96
+ const editor = await mountEditor([builtInExtensions.Heading])
97
+ expect(editor.hasNode(HeadingNode)).toBe(false)
98
+ })
99
+
100
+ it('still registers headings with the default configuration', async () => {
101
+ const editor = await mountEditor([])
102
+ expect(editor.hasNode(HeadingNode)).toBe(true)
103
+ })
104
+
105
+ it('registers every readable node class with the default configuration', async () => {
106
+ const editor = await mountEditor([])
107
+ // The former blanket list, now the read vocabulary. Every class in it
108
+ // must still be reachable through an extension, or the default
109
+ // configuration has silently lost a feature.
110
+ const missing = READABLE_NODES.filter((klass) => !editor.hasNode(klass)).map((klass) =>
111
+ klass.getType()
112
+ )
113
+ expect(missing).toEqual([])
114
+ })
115
+
116
+ it('keeps the core nodes even when site code tries to remove them', async () => {
117
+ // `remove()` matches by name and accepts the extension object, so
118
+ // this is a removal a site could really attempt. The root injects
119
+ // CoreNodesExtension outside the configurable list, so it survives.
120
+ const editor = await mountEditor([CoreNodesExtension.name])
121
+ expect(editor.hasNode(MarkNode)).toBe(true)
122
+ expect(editor.hasNode(OverflowNode)).toBe(true)
123
+ })
124
+ })
@@ -22,12 +22,17 @@ import {
22
22
 
23
23
  import { defaultExtensionsList } from './config/default-extensions'
24
24
  import { EditorConfigContext } from './config/editor-config-context'
25
+ import { rootDependencies } from './config/root-dependencies'
25
26
  import { MarkdownModeProvider } from './context/markdown-mode-context'
26
27
  import { SharedHistoryContext } from './context/shared-history-context'
27
28
  import { SharedOnChangeContext } from './context/shared-on-change-context'
28
29
  import { Editor } from './editor'
29
30
  import { InlineImageExtension } from './extensions/inline-image/inline-image-extension'
30
- import { Nodes } from './nodes'
31
+ import { AdaptedNotice, UnsupportedContentNotice } from './normalize/adapted-notice'
32
+ import {
33
+ NormalizationStatusProvider,
34
+ useNormalizationStatus,
35
+ } from './normalize/normalization-status'
31
36
  import type { EditorConfig } from './config/types'
32
37
 
33
38
  // Catch any errors that occur during Lexical updates and log them
@@ -92,15 +97,30 @@ export function EditorContext(props: {
92
97
  collection: editorConfig.settings.inlineImageUploadCollection,
93
98
  })
94
99
  }
95
- const dependencies = configured.toArray()
100
+ // Optional features plus mandatory infrastructure. See
101
+ // `rootDependencies` for why the core nodes are injected here rather
102
+ // than carried in the configurable list.
103
+ const dependencies = rootDependencies(configured)
96
104
 
97
105
  return defineExtension({
98
106
  name: '[root]',
99
107
  namespace: editorConfig.lexical.namespace,
100
- nodes: [...Nodes],
108
+ // No `nodes` here on purpose. Every node class is owned by the
109
+ // extension that provides its feature, so removing an extension
110
+ // removes its controls, its behaviour AND its node registration.
111
+ // A blanket list here would let a field accept structures it does
112
+ // not offer — a field configured without headings would still
113
+ // store a heading pasted into it.
101
114
  theme: editorConfig.lexical.theme,
102
115
  editable,
103
- $initialEditorState: value != null ? JSON.stringify(value) : undefined,
116
+ // No `$initialEditorState`. It is evaluated while the editor is
117
+ // being built, before the registered node types exist, so an
118
+ // unnormalized value would throw inside the builder with no chance
119
+ // to adapt it — and its function form runs inside an update, where
120
+ // `setEditorState` is unsafe (see apply-value-plugin.tsx). The
121
+ // value arrives through ApplyValuePlugin instead, which normalizes
122
+ // against the built editor's real capabilities and applies in a
123
+ // layout effect so nothing paints empty first.
104
124
  onError: (error: Error) => {
105
125
  throw error
106
126
  },
@@ -123,24 +143,72 @@ export function EditorContext(props: {
123
143
  // back inside their React tree.
124
144
  return (
125
145
  <EditorConfigContext config={editorConfig.settings}>
126
- <SharedOnChangeContext onChange={onChange}>
127
- <SharedHistoryContext>
128
- <MarkdownModeProvider>
129
- <LexicalExtensionComposer
130
- extension={rootExtension}
131
- contentEditable={null}
132
- key={composerKey + editable}
133
- >
134
- <div className="editor-shell">
135
- {beforeEditor}
136
- <Editor minHeight={props.minHeight} maxHeight={props.maxHeight} />
137
- {afterEditor}
138
- {children}
139
- </div>
140
- </LexicalExtensionComposer>
141
- </MarkdownModeProvider>
142
- </SharedHistoryContext>
143
- </SharedOnChangeContext>
146
+ <NormalizationStatusProvider>
147
+ <SharedOnChangeContext onChange={onChange}>
148
+ <SharedHistoryContext>
149
+ <MarkdownModeProvider>
150
+ <LexicalExtensionComposer
151
+ extension={rootExtension}
152
+ contentEditable={null}
153
+ key={composerKey + editable}
154
+ >
155
+ <EditorShell
156
+ beforeEditor={beforeEditor}
157
+ afterEditor={afterEditor}
158
+ minHeight={props.minHeight}
159
+ maxHeight={props.maxHeight}
160
+ >
161
+ {children}
162
+ </EditorShell>
163
+ </LexicalExtensionComposer>
164
+ </MarkdownModeProvider>
165
+ </SharedHistoryContext>
166
+ </SharedOnChangeContext>
167
+ </NormalizationStatusProvider>
144
168
  </EditorConfigContext>
145
169
  )
146
170
  }
171
+
172
+ /**
173
+ * The editor surface, or the read-only notice in its place.
174
+ *
175
+ * Content with no safe conversion is not adapted and not discarded: the
176
+ * field declines to edit it and says why. Sits inside the composer so it
177
+ * can read the status the apply plugin publishes.
178
+ */
179
+ function EditorShell({
180
+ beforeEditor,
181
+ afterEditor,
182
+ minHeight,
183
+ maxHeight,
184
+ children,
185
+ }: {
186
+ beforeEditor?: React.ReactNode[]
187
+ afterEditor?: React.ReactNode[]
188
+ minHeight?: number | string
189
+ maxHeight?: number | string
190
+ children?: React.ReactNode
191
+ }): React.JSX.Element {
192
+ const { status } = useNormalizationStatus()
193
+
194
+ // `children` carries ApplyValuePlugin, so it stays mounted in BOTH
195
+ // branches. Unmounting it on refusal would strand the field: the plugin
196
+ // that applies values would be gone, so a later valid value could
197
+ // neither load nor clear the notice, and the only escape would be a
198
+ // full remount.
199
+ return (
200
+ <div className="editor-shell">
201
+ {status.kind === 'refused' ? (
202
+ <UnsupportedContentNotice unsupportedTypes={status.unsupportedTypes} />
203
+ ) : (
204
+ <>
205
+ {status.kind === 'adapted' && <AdaptedNotice />}
206
+ {beforeEditor}
207
+ <Editor minHeight={minHeight} maxHeight={maxHeight} />
208
+ {afterEditor}
209
+ </>
210
+ )}
211
+ {children}
212
+ </div>
213
+ )
214
+ }
@@ -11,18 +11,18 @@
11
11
  import type * as React from 'react'
12
12
  import { memo, useCallback, useEffect, useMemo, useState } from 'react'
13
13
 
14
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
14
15
  import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
15
16
  import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
16
17
  import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
17
18
  import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
18
19
  import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'
19
20
  import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
20
- import {
21
- useExtensionDependency,
22
- useOptionalExtensionDependency,
23
- } from '@lexical/react/useExtensionComponent'
21
+ import { useExtensionDependency } from '@lexical/react/useExtensionComponent'
24
22
  import type { EditorState, LexicalEditor } from 'lexical'
25
23
 
24
+ import { transformersFor } from './capabilities/filter-transformers'
25
+ import { supportsNodeType } from './capabilities/registered-node-types'
26
26
  import { useEditorConfig } from './config/editor-config-context'
27
27
  import { ContentEditable } from './content-editable'
28
28
  import { useMarkdownMode } from './context/markdown-mode-context'
@@ -33,7 +33,6 @@ import {
33
33
  BylineFloatingUIExtension,
34
34
  selectFloatingUIItems,
35
35
  } from './extensions/byline-floating-ui/byline-floating-ui-extension'
36
- import { TableExtension as BylineTableExtension } from './extensions/table/table-extension'
37
36
  import { BYLINE_TRANSFORMERS } from './markdown/transformers'
38
37
  // import { AiPlugin } from './plugins/ai-plugin'
39
38
  // import { DragDropPaste } from './plugins/drag-drop-paste-plugin'
@@ -67,11 +66,24 @@ export const Editor = memo(function Editor({
67
66
  const { markdownModeRef } = useMarkdownMode()
68
67
  const {
69
68
  config: {
70
- options: { debug, richText, showTreeView, markdownShortcutPlugin },
69
+ mode,
70
+ markdownShortcuts,
71
+ controls: { treeView },
72
+ debug,
71
73
  placeholderText,
72
74
  },
73
75
  } = useEditorConfig()
74
- const hasTableExtension = useOptionalExtensionDependency(BylineTableExtension) !== undefined
76
+ const richText = mode === 'richText'
77
+ const [editor] = useLexicalComposerContext()
78
+ // Table availability is structural, so it follows the registered node
79
+ // rather than extension-list membership: TableNode arrives through
80
+ // `@lexical/table` as a dependency of the Byline wrapper, and a
81
+ // membership test would miss any other route to the same node.
82
+ const hasTableNode = supportsNodeType(editor, 'table')
83
+ // Markdown shortcuts create nodes, so the transformer list must follow
84
+ // what this editor registered — an unregistered dependency throws for
85
+ // the whole pipeline, not just its own transformer.
86
+ const activeTransformers = useMemo(() => transformersFor(editor, BYLINE_TRANSFORMERS), [editor])
75
87
  // Merged floating-UI contributions from every extension in the graph.
76
88
  // Removing a contributing extension (e.g. `c.extensions.remove(LinkExtension)`)
77
89
  // automatically suppresses its floating UI — no separate boolean toggle.
@@ -132,10 +144,10 @@ export const Editor = memo(function Editor({
132
144
 
133
145
  const content = (
134
146
  <>
135
- {hasTableExtension && <TablePlugin />}
147
+ {hasTableNode && <TablePlugin />}
136
148
  {richText && <ToolbarPlugin />}
137
149
  <div
138
- className={`editor-container ${showTreeView ? 'tree-view' : ''} ${
150
+ className={`editor-container ${treeView ? 'tree-view' : ''} ${
139
151
  !richText ? 'plain-text' : ''
140
152
  }`}
141
153
  >
@@ -166,9 +178,7 @@ export const Editor = memo(function Editor({
166
178
  ErrorBoundary={LexicalErrorBoundary}
167
179
  />
168
180
  <HistoryPlugin externalHistoryState={historyState} />
169
- {markdownShortcutPlugin && (
170
- <MarkdownShortcutPlugin transformers={BYLINE_TRANSFORMERS} />
171
- )}
181
+ {markdownShortcuts && <MarkdownShortcutPlugin transformers={activeTransformers} />}
172
182
  {floatingAnchorElem != null &&
173
183
  !isSmallWidthViewport &&
174
184
  floatingUIItems.map(({ id, Component }) => (
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { supportsNodeType } from '../../capabilities/registered-node-types'
4
+ import { buildRestrictedEditor } from '../../test-support/build-test-editor'
5
+
6
+ describe('CoreNodesExtension', () => {
7
+ it('keeps mark and overflow registered even in a fully stripped editor', () => {
8
+ const editor = buildRestrictedEditor()
9
+ expect(supportsNodeType(editor, 'mark')).toBe(true)
10
+ expect(supportsNodeType(editor, 'overflow')).toBe(true)
11
+ editor.dispose()
12
+ })
13
+ })
@@ -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 { MarkNode } from '@lexical/mark'
10
+ import { OverflowNode } from '@lexical/overflow'
11
+ import { defineExtension } from 'lexical'
12
+
13
+ /**
14
+ * Owns the node classes that belong to no switchable feature.
15
+ *
16
+ * `MarkNode` and `OverflowNode` ship without an upstream extension, and
17
+ * neither corresponds to something a field can turn off. Without an
18
+ * owner both would lose their only registration once the editor root
19
+ * stopped registering a blanket node list.
20
+ *
21
+ * `EditorContext` injects this extension directly into the root's
22
+ * dependencies, outside `editorConfig.extensions`. Leaving it out of
23
+ * `builtInExtensions` would NOT have made it non-removable: this module
24
+ * is publicly exported and `ExtensionsList.remove()` accepts an
25
+ * extension object as readily as a name string, so site code could have
26
+ * removed it from the configurable list. Injection at the root is what
27
+ * enforces its presence.
28
+ */
29
+ export const CoreNodesExtension = defineExtension({
30
+ name: '@byline/richtext-lexical/CoreNodes',
31
+ nodes: () => [MarkNode, OverflowNode],
32
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { builtInExtensions } from '../../config/built-in-extension-names'
4
+ import {
5
+ buildFullEditor,
6
+ buildRestrictedEditor,
7
+ parseHtmlToTypes,
8
+ } from '../../test-support/build-test-editor'
9
+
10
+ describe('heading and quote ownership', () => {
11
+ it('imports headings and quotes when both extensions are present', () => {
12
+ const editor = buildFullEditor()
13
+ expect(parseHtmlToTypes(editor, '<h1>Title</h1>').types).toEqual(['heading'])
14
+ expect(parseHtmlToTypes(editor, '<blockquote>Quoted</blockquote>').types).toEqual(['quote'])
15
+ editor.dispose()
16
+ })
17
+
18
+ it('degrades a pasted heading to a paragraph and keeps the text', () => {
19
+ const editor = buildRestrictedEditor([builtInExtensions.Heading])
20
+ const result = parseHtmlToTypes(editor, '<h1>Pasted title</h1>')
21
+ expect(result.types).toEqual(['paragraph'])
22
+ expect(result.text).toBe('Pasted title')
23
+ editor.dispose()
24
+ })
25
+
26
+ it('keeps headings while quotes are removed', () => {
27
+ const editor = buildRestrictedEditor([builtInExtensions.Quote])
28
+ expect(parseHtmlToTypes(editor, '<h1>Title</h1>').types).toEqual(['heading'])
29
+ expect(parseHtmlToTypes(editor, '<blockquote>Quoted</blockquote>').types).toEqual(['paragraph'])
30
+ editor.dispose()
31
+ })
32
+ })
@@ -0,0 +1,27 @@
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 { HeadingNode } from '@lexical/rich-text'
10
+ import { defineExtension } from 'lexical'
11
+
12
+ /**
13
+ * Owns `HeadingNode`.
14
+ *
15
+ * Byline deliberately does not adopt `@lexical/rich-text`'s
16
+ * `RichTextExtension` for this. That extension binds heading and quote
17
+ * registration to general rich-text behaviour, so adopting it would
18
+ * register headings in every rich-text field — and a field configured
19
+ * without headings would still accept one from a paste, which is the
20
+ * defect this package had. Owning the node here keeps headings
21
+ * removable on their own, independently of quotes and of rich-text
22
+ * behaviour itself.
23
+ */
24
+ export const HeadingExtension = defineExtension({
25
+ name: '@byline/richtext-lexical/Heading',
26
+ nodes: () => [HeadingNode],
27
+ })
@@ -0,0 +1,23 @@
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 { QuoteNode } from '@lexical/rich-text'
10
+ import { defineExtension } from 'lexical'
11
+
12
+ /**
13
+ * Owns `QuoteNode`.
14
+ *
15
+ * Separate from {@link HeadingExtension} on purpose: a field may want
16
+ * headings without block quotes, or neither. See the heading extension
17
+ * for why Byline owns these nodes rather than adopting
18
+ * `RichTextExtension`.
19
+ */
20
+ export const QuoteExtension = defineExtension({
21
+ name: '@byline/richtext-lexical/Quote',
22
+ nodes: () => [QuoteNode],
23
+ })