@byline/richtext-lexical 5.2.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +1 -1
- package/dist/field/apply-value-plugin.js +31 -6
- package/dist/field/capabilities/filter-transformers.d.ts +29 -0
- package/dist/field/capabilities/filter-transformers.js +8 -0
- package/dist/field/capabilities/registered-node-types.d.ts +29 -0
- package/dist/field/capabilities/registered-node-types.js +7 -0
- package/dist/field/config/built-in-extension-names.d.ts +42 -0
- package/dist/field/config/built-in-extension-names.js +12 -2
- package/dist/field/config/default-extensions.js +4 -0
- package/dist/field/config/default.js +8 -8
- package/dist/field/config/editor-config-context.d.ts +2 -2
- package/dist/field/config/editor-config-context.js +10 -13
- package/dist/field/config/resolve-editor-config.js +3 -3
- package/dist/field/config/root-dependencies.d.ts +26 -0
- package/dist/field/config/root-dependencies.js +8 -0
- package/dist/field/config/types.d.ts +61 -8
- package/dist/field/editor-context.js +44 -27
- package/dist/field/editor.js +15 -8
- package/dist/field/extensions/core-nodes/core-nodes-extension.d.ts +24 -0
- package/dist/field/extensions/core-nodes/core-nodes-extension.js +11 -0
- package/dist/field/extensions/heading/heading-extension.d.ts +20 -0
- package/dist/field/extensions/heading/heading-extension.js +9 -0
- package/dist/field/extensions/quote/quote-extension.d.ts +16 -0
- package/dist/field/extensions/quote/quote-extension.js +9 -0
- package/dist/field/hooks/use-markdown-toggle.d.ts +15 -0
- package/dist/field/hooks/use-markdown-toggle.js +12 -4
- package/dist/field/hooks/use-platform-modifier.d.ts +16 -0
- package/dist/field/hooks/use-platform-modifier.js +8 -0
- package/dist/field/markdown/transformers.d.ts +1 -0
- package/dist/field/markdown/transformers.js +5 -4
- package/dist/field/nodes/index.d.ts +11 -1
- package/dist/field/nodes/index.js +2 -2
- package/dist/field/normalize/adapted-notice.css +34 -0
- package/dist/field/normalize/adapted-notice.d.ts +29 -0
- package/dist/field/normalize/adapted-notice.js +35 -0
- package/dist/field/normalize/content-labels.d.ts +15 -0
- package/dist/field/normalize/content-labels.js +19 -0
- package/dist/field/normalize/declared-conversions.d.ts +131 -0
- package/dist/field/normalize/declared-conversions.js +93 -0
- package/dist/field/normalize/normalization-status.d.ts +33 -0
- package/dist/field/normalize/normalization-status.js +28 -0
- package/dist/field/normalize/normalize-value.d.ts +38 -0
- package/dist/field/normalize/normalize-value.js +132 -0
- package/dist/field/normalize/use-notice-text.d.ts +11 -0
- package/dist/field/normalize/use-notice-text.js +34 -0
- package/dist/field/plugins/toolbar-plugin/index.d.ts +23 -0
- package/dist/field/plugins/toolbar-plugin/index.js +104 -78
- package/dist/field/test-support/build-test-editor.js +49 -0
- package/dist/field/test-support/capture-editor.js +7 -0
- package/dist/field/test-support/frame-control.js +40 -0
- package/dist/field/test-support/jsdom-setup.js +21 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +6 -1
- package/dist/scan/capability-manifest.d.ts +30 -0
- package/dist/scan/capability-manifest.js +43 -0
- package/dist/scan/scan-documents.d.ts +29 -0
- package/dist/scan/scan-documents.js +39 -0
- package/dist/scan/types.d.ts +63 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan.d.ts +25 -0
- package/dist/scan.js +1 -0
- package/package.json +14 -5
- package/src/config.ts +1 -1
- package/src/field/apply-value-plugin.tsx +71 -5
- package/src/field/capabilities/filter-transformers.test.tsx +71 -0
- package/src/field/capabilities/filter-transformers.ts +42 -0
- package/src/field/capabilities/markdown-behaviour.test.tsx +188 -0
- package/src/field/capabilities/no-blanket-registration.test.tsx +86 -0
- package/src/field/capabilities/registered-node-types.test.tsx +29 -0
- package/src/field/capabilities/registered-node-types.ts +36 -0
- package/src/field/config/built-in-extension-names.test.node.ts +38 -14
- package/src/field/config/built-in-extension-names.ts +29 -1
- package/src/field/config/default-extensions.ts +6 -0
- package/src/field/config/default.ts +8 -8
- package/src/field/config/editor-config-context.tsx +14 -9
- package/src/field/config/list-removal.test.tsx +44 -0
- package/src/field/config/resolve-editor-config.test.node.ts +27 -6
- package/src/field/config/resolve-editor-config.ts +3 -3
- package/src/field/config/root-dependencies.ts +32 -0
- package/src/field/config/types.ts +61 -17
- package/src/field/editor-component.test.tsx +18 -3
- package/src/field/editor-context.regression.test.tsx +124 -0
- package/src/field/editor-context.tsx +90 -22
- package/src/field/editor.tsx +22 -12
- package/src/field/extensions/core-nodes/core-nodes-extension.test.tsx +13 -0
- package/src/field/extensions/core-nodes/core-nodes-extension.ts +32 -0
- package/src/field/extensions/heading/heading-extension.test.tsx +32 -0
- package/src/field/extensions/heading/heading-extension.ts +27 -0
- package/src/field/extensions/quote/quote-extension.ts +23 -0
- package/src/field/hooks/markdown-source-mode.test.tsx +221 -0
- package/src/field/hooks/use-markdown-toggle.ts +39 -4
- package/src/field/hooks/use-platform-modifier.test.tsx +81 -0
- package/src/field/hooks/use-platform-modifier.ts +41 -0
- package/src/field/markdown/transformers.ts +18 -4
- package/src/field/nodes/index.ts +11 -1
- package/src/field/normalize/adapted-notice.css +52 -0
- package/src/field/normalize/adapted-notice.tsx +63 -0
- package/src/field/normalize/clipboard-equivalence.test.tsx +330 -0
- package/src/field/normalize/content-labels.ts +44 -0
- package/src/field/normalize/declared-conversions.test.node.ts +67 -0
- package/src/field/normalize/declared-conversions.ts +160 -0
- package/src/field/normalize/emission-gate.test.tsx +302 -0
- package/src/field/normalize/normalization-status.tsx +48 -0
- package/src/field/normalize/normalize-load.test.tsx +371 -0
- package/src/field/normalize/normalize-value.test.node.ts +530 -0
- package/src/field/normalize/normalize-value.ts +180 -0
- package/src/field/normalize/notice-i18n.test.tsx +128 -0
- package/src/field/normalize/use-notice-text.ts +82 -0
- package/src/field/normalize/wiring.test.tsx +348 -0
- package/src/field/plugins/toolbar-plugin/block-format-capabilities.test.tsx +57 -0
- package/src/field/plugins/toolbar-plugin/index.tsx +131 -68
- package/src/field/test-support/build-test-editor.test.tsx +17 -0
- package/src/field/test-support/build-test-editor.ts +99 -0
- package/src/field/test-support/capture-editor.tsx +20 -0
- package/src/field/test-support/frame-control.ts +91 -0
- package/src/field/test-support/jsdom-setup.ts +41 -0
- package/src/index.ts +12 -1
- package/src/scan/capability-manifest.test.tsx +82 -0
- package/src/scan/capability-manifest.ts +81 -0
- package/src/scan/scan-documents.test.node.ts +125 -0
- package/src/scan/scan-documents.ts +78 -0
- package/src/scan/types.ts +66 -0
- package/src/scan.ts +27 -0
|
@@ -0,0 +1,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 {
|
|
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
|
|
20
|
-
*
|
|
21
|
-
* value
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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('
|
|
35
|
-
const
|
|
36
|
-
|
|
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('
|
|
40
|
-
for (const [
|
|
41
|
-
expect(value).
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(() => ({
|
|
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.
|
|
35
|
+
registered.settings.controls.markdownToggle = true
|
|
36
36
|
registered.settings.placeholderText = 'registered placeholder'
|
|
37
37
|
|
|
38
38
|
const fieldConfig = clone(defaultEditorConfig)
|
|
39
|
-
fieldConfig.settings.
|
|
40
|
-
fieldConfig.settings.
|
|
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.
|
|
45
|
-
expect(resolved.settings.
|
|
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.
|
|
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
|
-
|
|
44
|
-
...registeredConfig.settings.
|
|
45
|
-
...fieldConfig.settings?.
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|