@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.
@@ -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 };
@@ -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; per-field
24
- * overrides via `RichTextField.editorConfig` continue to take precedence
25
- * at render time.
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
@@ -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 ?? editorConfig ?? defaultEditorConfig;
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.19.0",
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.19.0",
77
- "@byline/core": "3.19.0",
78
- "@byline/client": "3.19.0",
79
- "@byline/ui": "3.19.0"
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
+ }
@@ -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; per-field
75
- * overrides via `RichTextField.editorConfig` continue to take precedence
76
- * at render time.
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
@@ -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 with field-level priority:
74
- // 1. `field.editorConfig` set on the schema field itself (most specific).
75
- // 2. `editorConfig` prop — baked in at registration via `lexicalEditor()`.
76
- // 3. `defaultEditorConfig` — package default.
77
- // The schema-level value is typed as `unknown` at the `@byline/core` boundary,
78
- // so the cast lives here where the Lexical config shape is known.
79
- const resolved: EditorConfig =
80
- (field.editorConfig as EditorConfig | undefined) ?? editorConfig ?? defaultEditorConfig
81
- // Schema-side configs and the server-safe `defaultEditorConfig` carry no
82
- // `extensions` field — extension references aren't JSON-safe. Materialise
83
- // the package's client-only default list when one isn't already present so
84
- // every render has a complete graph to feed `EditorContext`.
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