@byline/richtext-lexical 3.19.0 → 3.20.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/field/config/resolve-editor-config.d.ts +32 -0
- package/dist/field/config/resolve-editor-config.js +16 -0
- package/dist/lexical-editor.d.ts +5 -3
- package/dist/richtext-field.js +2 -1
- package/package.json +5 -5
- package/src/field/config/resolve-editor-config.test.node.ts +101 -0
- package/src/field/config/resolve-editor-config.ts +53 -0
- package/src/lexical-editor.tsx +5 -3
- package/src/richtext-field.tsx +15 -12
|
@@ -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
|
+
import type { EditorConfig } from './types';
|
|
9
|
+
/**
|
|
10
|
+
* Merge a schema field's `editorConfig` over the registered editor's
|
|
11
|
+
* config (baked at registration via `lexicalEditor()`, or the package
|
|
12
|
+
* default).
|
|
13
|
+
*
|
|
14
|
+
* This is a MERGE, not a pick-one precedence — each layer can only express
|
|
15
|
+
* what its side is allowed to carry, so whole-object precedence would
|
|
16
|
+
* silently discard the rest:
|
|
17
|
+
*
|
|
18
|
+
* - The field layer (schema side) is JSON-safe: `settings` + `lexical`
|
|
19
|
+
* only, never `extensions`. It is the most specific layer, so its
|
|
20
|
+
* values win — but ONLY for the keys it can express. Settings merge
|
|
21
|
+
* per-key (including `settings.options`), so a field config produced
|
|
22
|
+
* by a partial override keeps the registered layer's remaining flags.
|
|
23
|
+
* - The registered layer is the only one that can carry an `extensions`
|
|
24
|
+
* graph. Before this merge existed, a schema-side `editorConfig`
|
|
25
|
+
* replaced the baked config wholesale, throwing away registration-time
|
|
26
|
+
* extension changes (an added AI extension, removed Insert-menu
|
|
27
|
+
* extensions, …) and silently falling back to the built-in list.
|
|
28
|
+
*
|
|
29
|
+
* Returns the registered config as-is when the field carries no
|
|
30
|
+
* `editorConfig`. Never mutates either input.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveEditorConfig(fieldConfig: EditorConfig | undefined, registeredConfig: EditorConfig): EditorConfig;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function resolveEditorConfig(fieldConfig, registeredConfig) {
|
|
2
|
+
if (null == fieldConfig) return registeredConfig;
|
|
3
|
+
return {
|
|
4
|
+
settings: {
|
|
5
|
+
...registeredConfig.settings,
|
|
6
|
+
...fieldConfig.settings,
|
|
7
|
+
options: {
|
|
8
|
+
...registeredConfig.settings.options,
|
|
9
|
+
...fieldConfig.settings?.options
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
lexical: fieldConfig.lexical ?? registeredConfig.lexical,
|
|
13
|
+
extensions: fieldConfig.extensions ?? registeredConfig.extensions
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export { resolveEditorConfig };
|
package/dist/lexical-editor.d.ts
CHANGED
|
@@ -20,9 +20,11 @@ type ConfigureFn = (config: LexicalEditorConfigureInput) => LexicalEditorConfigu
|
|
|
20
20
|
/**
|
|
21
21
|
* Returns a `RichTextEditorComponent` with editor settings baked in. Use
|
|
22
22
|
* this at the registration site in your admin config when you want to
|
|
23
|
-
* customise the editor across the whole installation
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* customise the editor across the whole installation. Per-field overrides
|
|
24
|
+
* via `RichTextField.editorConfig` are MERGED over the baked config at
|
|
25
|
+
* render time (settings win per-key, most specific first) — but the baked
|
|
26
|
+
* `extensions` graph survives, since schema-side configs cannot carry
|
|
27
|
+
* extension references. See `resolveEditorConfig`.
|
|
26
28
|
*
|
|
27
29
|
* The returned component is lazy: the editor module graph (RichTextField
|
|
28
30
|
* + every built-in extension + the Lexical core) is dynamically imported
|
package/dist/richtext-field.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ErrorText, Label } from "@byline/ui/react";
|
|
|
4
4
|
import classnames from "classnames";
|
|
5
5
|
import { defaultEditorConfig } from "./field/config/default.js";
|
|
6
6
|
import { defaultExtensionsList } from "./field/config/default-extensions.js";
|
|
7
|
+
import { resolveEditorConfig } from "./field/config/resolve-editor-config.js";
|
|
7
8
|
import { EditorField } from "./field/editor-field.js";
|
|
8
9
|
import richtext_field_module from "./richtext-field.module.js";
|
|
9
10
|
const RichTextField = ({ field, value, defaultValue, editorConfig, readonly = false, instanceKey, onChange, path, locale, featureBeforeEditor, featureAfterEditor, featureChildren })=>{
|
|
@@ -13,7 +14,7 @@ const RichTextField = ({ field, value, defaultValue, editorConfig, readonly = fa
|
|
|
13
14
|
const incomingValue = value ?? fieldValue;
|
|
14
15
|
const incomingDefault = defaultValue;
|
|
15
16
|
const fieldId = instanceKey ? `${field.name}-${instanceKey}` : field.name;
|
|
16
|
-
const resolved = field.editorConfig
|
|
17
|
+
const resolved = resolveEditorConfig(field.editorConfig, editorConfig ?? defaultEditorConfig);
|
|
17
18
|
const baseEditorConfig = null != resolved.extensions ? resolved : {
|
|
18
19
|
...resolved,
|
|
19
20
|
extensions: defaultExtensionsList()
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.20.1",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -73,10 +73,10 @@
|
|
|
73
73
|
"lexical": "0.46.0",
|
|
74
74
|
"npm-run-all": "^4.1.5",
|
|
75
75
|
"react-error-boundary": "^6.1.2",
|
|
76
|
-
"@byline/admin": "3.
|
|
77
|
-
"@byline/
|
|
78
|
-
"@byline/
|
|
79
|
-
"@byline/
|
|
76
|
+
"@byline/admin": "3.20.1",
|
|
77
|
+
"@byline/ui": "3.20.1",
|
|
78
|
+
"@byline/core": "3.20.1",
|
|
79
|
+
"@byline/client": "3.20.1"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
82
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,101 @@
|
|
|
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 { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { defaultEditorConfig } from './default'
|
|
12
|
+
import { resolveEditorConfig } from './resolve-editor-config'
|
|
13
|
+
import type { EditorConfig } from './types'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal stand-in for an ExtensionsList — resolveEditorConfig only passes
|
|
17
|
+
* the reference through, so identity is all these tests need.
|
|
18
|
+
*/
|
|
19
|
+
const fakeExtensions = { items: ['ext-a', 'ext-b'] } as unknown as NonNullable<
|
|
20
|
+
EditorConfig['extensions']
|
|
21
|
+
>
|
|
22
|
+
|
|
23
|
+
function clone(config: EditorConfig): EditorConfig {
|
|
24
|
+
return structuredClone(config)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('resolveEditorConfig', () => {
|
|
28
|
+
it('returns the registered config as-is when the field carries none', () => {
|
|
29
|
+
const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
|
|
30
|
+
expect(resolveEditorConfig(undefined, registered)).toBe(registered)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('field settings win per-key; unspecified registered settings survive', () => {
|
|
34
|
+
const registered = clone(defaultEditorConfig)
|
|
35
|
+
registered.settings.options.markdownToggle = true
|
|
36
|
+
registered.settings.placeholderText = 'registered placeholder'
|
|
37
|
+
|
|
38
|
+
const fieldConfig = clone(defaultEditorConfig)
|
|
39
|
+
fieldConfig.settings.options.textStyle = false
|
|
40
|
+
fieldConfig.settings.options.undoRedo = false
|
|
41
|
+
|
|
42
|
+
const resolved = resolveEditorConfig(fieldConfig, registered)
|
|
43
|
+
// Field's flags applied…
|
|
44
|
+
expect(resolved.settings.options.textStyle).toBe(false)
|
|
45
|
+
expect(resolved.settings.options.undoRedo).toBe(false)
|
|
46
|
+
// …and since schema-side configs are complete objects, its values win
|
|
47
|
+
// for every key it carries (markdownToggle false from the default seed).
|
|
48
|
+
expect(resolved.settings.options.markdownToggle).toBe(false)
|
|
49
|
+
expect(resolved.settings.placeholderText).toBe(fieldConfig.settings.placeholderText)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('REGRESSION: a schema-side settings-only config must not discard the registered extensions graph', () => {
|
|
53
|
+
// Before the merge existed, `field.editorConfig ?? registered` replaced
|
|
54
|
+
// the whole object: a compact/minimal settings preset baked into the
|
|
55
|
+
// schema silently dropped registration-time extension changes (removed
|
|
56
|
+
// Insert-menu extensions, an added AI extension) and the editor fell
|
|
57
|
+
// back to the built-in list.
|
|
58
|
+
const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
|
|
59
|
+
const fieldConfig = clone(defaultEditorConfig) // settings-only, no extensions
|
|
60
|
+
|
|
61
|
+
const resolved = resolveEditorConfig(fieldConfig, registered)
|
|
62
|
+
expect(resolved.extensions).toBe(fakeExtensions)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('a field-carried extensions graph (advanced/manual) still wins', () => {
|
|
66
|
+
const fieldExtensions = { items: ['field-ext'] } as unknown as NonNullable<
|
|
67
|
+
EditorConfig['extensions']
|
|
68
|
+
>
|
|
69
|
+
const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
|
|
70
|
+
const fieldConfig = { ...clone(defaultEditorConfig), extensions: fieldExtensions }
|
|
71
|
+
|
|
72
|
+
const resolved = resolveEditorConfig(fieldConfig, registered)
|
|
73
|
+
expect(resolved.extensions).toBe(fieldExtensions)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('lexical config: field wins when present, registered otherwise', () => {
|
|
77
|
+
const registered = clone(defaultEditorConfig)
|
|
78
|
+
const fieldConfig = clone(defaultEditorConfig)
|
|
79
|
+
|
|
80
|
+
const withField = resolveEditorConfig(fieldConfig, registered)
|
|
81
|
+
expect(withField.lexical).toBe(fieldConfig.lexical)
|
|
82
|
+
|
|
83
|
+
const withoutFieldLexical = resolveEditorConfig(
|
|
84
|
+
{ settings: fieldConfig.settings } as EditorConfig,
|
|
85
|
+
registered
|
|
86
|
+
)
|
|
87
|
+
expect(withoutFieldLexical.lexical).toBe(registered.lexical)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not mutate either input', () => {
|
|
91
|
+
const registered = { ...clone(defaultEditorConfig), extensions: fakeExtensions }
|
|
92
|
+
const fieldConfig = clone(defaultEditorConfig)
|
|
93
|
+
const registeredSnapshot = JSON.stringify({ ...registered, extensions: undefined })
|
|
94
|
+
const fieldSnapshot = JSON.stringify(fieldConfig)
|
|
95
|
+
|
|
96
|
+
resolveEditorConfig(fieldConfig, registered)
|
|
97
|
+
|
|
98
|
+
expect(JSON.stringify({ ...registered, extensions: undefined })).toBe(registeredSnapshot)
|
|
99
|
+
expect(JSON.stringify(fieldConfig)).toBe(fieldSnapshot)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
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 { EditorConfig } from './types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Merge a schema field's `editorConfig` over the registered editor's
|
|
13
|
+
* config (baked at registration via `lexicalEditor()`, or the package
|
|
14
|
+
* default).
|
|
15
|
+
*
|
|
16
|
+
* This is a MERGE, not a pick-one precedence — each layer can only express
|
|
17
|
+
* what its side is allowed to carry, so whole-object precedence would
|
|
18
|
+
* silently discard the rest:
|
|
19
|
+
*
|
|
20
|
+
* - The field layer (schema side) is JSON-safe: `settings` + `lexical`
|
|
21
|
+
* only, never `extensions`. It is the most specific layer, so its
|
|
22
|
+
* values win — but ONLY for the keys it can express. Settings merge
|
|
23
|
+
* per-key (including `settings.options`), so a field config produced
|
|
24
|
+
* by a partial override keeps the registered layer's remaining flags.
|
|
25
|
+
* - The registered layer is the only one that can carry an `extensions`
|
|
26
|
+
* graph. Before this merge existed, a schema-side `editorConfig`
|
|
27
|
+
* replaced the baked config wholesale, throwing away registration-time
|
|
28
|
+
* extension changes (an added AI extension, removed Insert-menu
|
|
29
|
+
* extensions, …) and silently falling back to the built-in list.
|
|
30
|
+
*
|
|
31
|
+
* Returns the registered config as-is when the field carries no
|
|
32
|
+
* `editorConfig`. Never mutates either input.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveEditorConfig(
|
|
35
|
+
fieldConfig: EditorConfig | undefined,
|
|
36
|
+
registeredConfig: EditorConfig
|
|
37
|
+
): EditorConfig {
|
|
38
|
+
if (fieldConfig == null) return registeredConfig
|
|
39
|
+
return {
|
|
40
|
+
settings: {
|
|
41
|
+
...registeredConfig.settings,
|
|
42
|
+
...fieldConfig.settings,
|
|
43
|
+
options: {
|
|
44
|
+
...registeredConfig.settings.options,
|
|
45
|
+
...fieldConfig.settings?.options,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
lexical: fieldConfig.lexical ?? registeredConfig.lexical,
|
|
49
|
+
// Schema-side configs never carry extensions (not JSON-safe); the
|
|
50
|
+
// registered editor's graph survives a field-level settings override.
|
|
51
|
+
extensions: fieldConfig.extensions ?? registeredConfig.extensions,
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/lexical-editor.tsx
CHANGED
|
@@ -71,9 +71,11 @@ function loadEditorBundle(): Promise<EditorBundle> {
|
|
|
71
71
|
/**
|
|
72
72
|
* Returns a `RichTextEditorComponent` with editor settings baked in. Use
|
|
73
73
|
* this at the registration site in your admin config when you want to
|
|
74
|
-
* customise the editor across the whole installation
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* customise the editor across the whole installation. Per-field overrides
|
|
75
|
+
* via `RichTextField.editorConfig` are MERGED over the baked config at
|
|
76
|
+
* render time (settings win per-key, most specific first) — but the baked
|
|
77
|
+
* `extensions` graph survives, since schema-side configs cannot carry
|
|
78
|
+
* extension references. See `resolveEditorConfig`.
|
|
77
79
|
*
|
|
78
80
|
* The returned component is lazy: the editor module graph (RichTextField
|
|
79
81
|
* + every built-in extension + the Lexical core) is dynamically imported
|
package/src/richtext-field.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import cx from 'classnames'
|
|
|
15
15
|
|
|
16
16
|
import { defaultEditorConfig } from './field/config/default'
|
|
17
17
|
import { defaultExtensionsList } from './field/config/default-extensions'
|
|
18
|
+
import { resolveEditorConfig } from './field/config/resolve-editor-config'
|
|
18
19
|
import { EditorField } from './field/editor-field'
|
|
19
20
|
import styles from './richtext-field.module.css'
|
|
20
21
|
import type { EditorConfig } from './field/config/types'
|
|
@@ -70,18 +71,20 @@ export const RichTextField = ({
|
|
|
70
71
|
|
|
71
72
|
const fieldId = instanceKey ? `${field.name}-${instanceKey}` : field.name
|
|
72
73
|
|
|
73
|
-
// Resolve the editor config
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// The schema-level value is typed as `unknown` at the `@byline/core`
|
|
78
|
-
// so the cast lives here where the Lexical config shape is known.
|
|
79
|
-
const resolved: EditorConfig =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
74
|
+
// Resolve the editor config by MERGING the schema field's `editorConfig`
|
|
75
|
+
// (settings-only, most specific) over the registered editor's config
|
|
76
|
+
// (baked via `lexicalEditor()`, the only layer that can carry an
|
|
77
|
+
// `extensions` graph) — see `resolveEditorConfig` for the full rationale.
|
|
78
|
+
// The schema-level value is typed as `unknown` at the `@byline/core`
|
|
79
|
+
// boundary, so the cast lives here where the Lexical config shape is known.
|
|
80
|
+
const resolved: EditorConfig = resolveEditorConfig(
|
|
81
|
+
field.editorConfig as EditorConfig | undefined,
|
|
82
|
+
editorConfig ?? defaultEditorConfig
|
|
83
|
+
)
|
|
84
|
+
// The server-safe `defaultEditorConfig` carries no `extensions` field —
|
|
85
|
+
// extension references aren't JSON-safe. Materialise the package's
|
|
86
|
+
// client-only default list when one isn't already present so every render
|
|
87
|
+
// has a complete graph to feed `EditorContext`.
|
|
85
88
|
const baseEditorConfig: EditorConfig =
|
|
86
89
|
resolved.extensions != null ? resolved : { ...resolved, extensions: defaultExtensionsList() }
|
|
87
90
|
|