@byline/richtext-lexical 5.2.0 → 6.0.0

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,302 @@
1
+ import { act } from 'react'
2
+
3
+ import { defineAdminConfig } from '@byline/core'
4
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
5
+ import {
6
+ $createParagraphNode,
7
+ $createTextNode,
8
+ $getRoot,
9
+ type LexicalEditor,
10
+ type SerializedEditorState,
11
+ } from 'lexical'
12
+ import { createRoot, type Root } from 'react-dom/client'
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14
+
15
+ import { builtInExtensions } from '../config/built-in-extension-names'
16
+ import { defaultClientEditorConfig } from '../config/default-extensions'
17
+ import { EditorComponent } from '../editor-component'
18
+ import { installFrameControl } from '../test-support/frame-control'
19
+
20
+ // biome-ignore lint/suspicious/noExplicitAny: React act environment flag
21
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
22
+
23
+ defineAdminConfig({
24
+ i18n: {
25
+ admin: { defaultLocale: 'en', locales: ['en'] },
26
+ content: { defaultLocale: 'en', locales: ['en'] },
27
+ },
28
+ collections: [
29
+ {
30
+ path: 'pages',
31
+ labels: { singular: 'Page', plural: 'Pages' },
32
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
33
+ },
34
+ ],
35
+ })
36
+
37
+ /**
38
+ * Deterministic frames and idle callbacks.
39
+ *
40
+ * jsdom implements no `requestIdleCallback`, so `editor-component` takes
41
+ * its synchronous branch and an emission scheduled before a value swap
42
+ * can never land after it — hiding an entire class of ordering bug. And
43
+ * the normalization baseline settles after a microtask and two frames,
44
+ * which a fixed delay can only guess at.
45
+ */
46
+ let frames: ReturnType<typeof installFrameControl>
47
+
48
+ beforeEach(() => {
49
+ frames = installFrameControl()
50
+ })
51
+
52
+ afterEach(() => {
53
+ frames.restore()
54
+ })
55
+
56
+ const text = (value: string) => ({
57
+ detail: 0,
58
+ format: 0,
59
+ mode: 'normal',
60
+ style: '',
61
+ text: value,
62
+ type: 'text',
63
+ version: 1,
64
+ })
65
+ // biome-ignore lint/suspicious/noExplicitAny: serialized fixtures are structural
66
+ const doc = (...children: unknown[]): any => ({
67
+ root: { children, direction: null, format: '', indent: 0, type: 'root', version: 1 },
68
+ })
69
+ const headingNode = (value: string) => ({
70
+ children: [text(value)],
71
+ direction: null,
72
+ format: '',
73
+ indent: 0,
74
+ type: 'heading',
75
+ version: 1,
76
+ tag: 'h1',
77
+ })
78
+ const paragraph = (value: string) => ({
79
+ children: [text(value)],
80
+ direction: null,
81
+ format: '',
82
+ indent: 0,
83
+ type: 'paragraph',
84
+ version: 1,
85
+ })
86
+ const imageDoc = () =>
87
+ doc({
88
+ children: [
89
+ {
90
+ type: 'inline-image',
91
+ version: 1,
92
+ targetDocumentId: 'doc-1',
93
+ targetCollectionPath: 'media',
94
+ src: '/cat.png',
95
+ position: 'full',
96
+ altText: 'a cat',
97
+ width: 10,
98
+ height: 10,
99
+ showCaption: false,
100
+ caption: {
101
+ editorState: {
102
+ root: {
103
+ children: [],
104
+ direction: null,
105
+ format: '',
106
+ indent: 0,
107
+ type: 'root',
108
+ version: 1,
109
+ },
110
+ },
111
+ },
112
+ },
113
+ ],
114
+ direction: null,
115
+ format: '',
116
+ indent: 0,
117
+ type: 'paragraph',
118
+ version: 1,
119
+ })
120
+
121
+ const mounted: Array<{ root: Root; container: HTMLDivElement }> = []
122
+
123
+ afterEach(async () => {
124
+ for (const { root, container } of mounted.splice(0)) {
125
+ await act(async () => {
126
+ root.unmount()
127
+ })
128
+ container.remove()
129
+ }
130
+ })
131
+
132
+ /** Mount EditorComponent and report every value it emits. */
133
+ async function mountField(
134
+ removals: string[],
135
+ value: SerializedEditorState,
136
+ onChange: (next: SerializedEditorState) => void
137
+ ): Promise<{ editor: () => LexicalEditor; container: HTMLDivElement }> {
138
+ const extensions = defaultClientEditorConfig.extensions?.clone()
139
+ for (const name of removals) extensions.remove(name)
140
+
141
+ let editor: LexicalEditor | undefined
142
+ function Capture(): null {
143
+ const [instance] = useLexicalComposerContext()
144
+ editor = instance
145
+ return null
146
+ }
147
+
148
+ const container = document.createElement('div')
149
+ document.body.appendChild(container)
150
+ const root = createRoot(container)
151
+ mounted.push({ root, container })
152
+
153
+ await act(async () => {
154
+ root.render(
155
+ <EditorComponent
156
+ id="field"
157
+ name="field"
158
+ editorConfig={{ ...defaultClientEditorConfig, extensions }}
159
+ value={value}
160
+ onChange={onChange}
161
+ featureChildren={[<Capture key="capture" />]}
162
+ />
163
+ )
164
+ })
165
+ return {
166
+ container,
167
+ editor: () => {
168
+ if (editor == null) throw new Error('editor never captured')
169
+ return editor
170
+ },
171
+ }
172
+ }
173
+
174
+ /**
175
+ * These assert on what the editor EMITS, not on what a form ends up
176
+ * holding. From the form's side a suppressed emission and an emission
177
+ * that happens to equal the stored value are indistinguishable — both
178
+ * produce no patch — yet only one is the behaviour the spec asks for.
179
+ */
180
+ describe('adapted content and the change gate', () => {
181
+ it('emits nothing when adapted content is merely loaded', async () => {
182
+ const onChange = vi.fn()
183
+ await mountField([builtInExtensions.Heading], doc(headingNode('Legacy title')), onChange)
184
+ await act(async () => {
185
+ await frames.settleBaseline()
186
+ })
187
+ await act(async () => {
188
+ frames.flushIdle()
189
+ })
190
+ // The adaptation is presentational. Emitting here would make every
191
+ // affected document dirty on open and persist the adapted form on the
192
+ // next unrelated save.
193
+ expect(onChange).not.toHaveBeenCalled()
194
+ })
195
+
196
+ it('emits the adapted content, including the new text, once edited', async () => {
197
+ const onChange = vi.fn()
198
+ const harness = await mountField(
199
+ [builtInExtensions.Heading],
200
+ doc(headingNode('Legacy title')),
201
+ onChange
202
+ )
203
+ await act(async () => {
204
+ await frames.settleBaseline()
205
+ })
206
+ expect(onChange).not.toHaveBeenCalled()
207
+
208
+ await act(async () => {
209
+ harness.editor().update(
210
+ () => {
211
+ $getRoot().selectEnd()
212
+ $getRoot().append($createParagraphNode().append($createTextNode('and more')))
213
+ },
214
+ { discrete: true }
215
+ )
216
+ })
217
+ await act(async () => {
218
+ frames.flushIdle()
219
+ })
220
+
221
+ expect(onChange).toHaveBeenCalled()
222
+ const emitted = JSON.stringify(onChange.mock.calls.at(-1)?.[0])
223
+ // Expected loss: the adaptation persists once the reader edits. What
224
+ // must NOT happen is losing either the original text or the edit.
225
+ expect(emitted).not.toContain('"type":"heading"')
226
+ expect(emitted).toContain('Legacy title')
227
+ expect(emitted).toContain('and more')
228
+ })
229
+ })
230
+
231
+ describe('a refused field must not emit the previous document as its value', () => {
232
+ it('suppresses a deferred emission scheduled before the refusal', async () => {
233
+ const onChange = vi.fn()
234
+ const extensions = defaultClientEditorConfig.extensions?.clone()
235
+ extensions.remove(builtInExtensions.InlineImage)
236
+ const editorConfig = { ...defaultClientEditorConfig, extensions }
237
+
238
+ let editor: LexicalEditor | undefined
239
+ function Capture(): null {
240
+ const [instance] = useLexicalComposerContext()
241
+ editor = instance
242
+ return null
243
+ }
244
+
245
+ const container = document.createElement('div')
246
+ document.body.appendChild(container)
247
+ const root = createRoot(container)
248
+ mounted.push({ root, container })
249
+
250
+ const paint = async (value: SerializedEditorState) => {
251
+ await act(async () => {
252
+ root.render(
253
+ <EditorComponent
254
+ id="gate"
255
+ name="gate"
256
+ editorConfig={editorConfig}
257
+ value={value}
258
+ onChange={onChange}
259
+ featureChildren={[<Capture key="capture" />]}
260
+ />
261
+ )
262
+ })
263
+ }
264
+
265
+ await paint(doc(paragraph('Original')))
266
+ expect(editor).toBeDefined()
267
+
268
+ // The reader edits. handleChange defers the emission.
269
+ await act(async () => {
270
+ editor?.update(
271
+ () => {
272
+ $getRoot()
273
+ .clear()
274
+ .append($createParagraphNode().append($createTextNode('SECRET EDIT')))
275
+ },
276
+ { discrete: true }
277
+ )
278
+ })
279
+ // An emission is now scheduled but has not run — the window this
280
+ // test exists to exercise.
281
+ expect(frames.pendingIdle()).toBeGreaterThan(0)
282
+
283
+ // Before it fires, the value swaps to a document this field cannot
284
+ // accept — a locale switch or a version restore.
285
+ await paint(imageDoc())
286
+ expect(container.querySelector('.byline-richtext-notice--refused')).not.toBeNull()
287
+
288
+ // The deferred callback now fires.
289
+ await act(async () => {
290
+ frames.flushIdle()
291
+ })
292
+
293
+ // It must NOT emit: the editor does not hold the refused document, so
294
+ // anything it emits would overwrite the image document's body with
295
+ // the previous document's content — exactly the loss refusal exists
296
+ // to prevent.
297
+ const emitted = onChange.mock.calls.map((call) => JSON.stringify(call[0])).join('')
298
+ expect(emitted).not.toContain('SECRET EDIT')
299
+ expect(emitted).not.toContain('Original')
300
+ expect(onChange).not.toHaveBeenCalled()
301
+ })
302
+ })
@@ -0,0 +1,48 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import type * as React from 'react'
12
+ import { createContext, useContext, useMemo, useState } from 'react'
13
+
14
+ export type NormalizationStatus =
15
+ | { kind: 'ok' }
16
+ | { kind: 'adapted'; convertedTypes: string[] }
17
+ | { kind: 'refused'; unsupportedTypes: string[] }
18
+
19
+ interface ContextValue {
20
+ status: NormalizationStatus
21
+ setStatus: (status: NormalizationStatus) => void
22
+ }
23
+
24
+ const Context = createContext<ContextValue>({
25
+ status: { kind: 'ok' },
26
+ setStatus: () => {},
27
+ })
28
+
29
+ /**
30
+ * Carries how the stored value fared against this field's capabilities.
31
+ *
32
+ * Provided ABOVE the composer, like the other shared contexts, so the
33
+ * surface that renders the notice sits outside the editor while the
34
+ * plugin that discovers the status sits inside it.
35
+ */
36
+ export function NormalizationStatusProvider({
37
+ children,
38
+ }: {
39
+ children: React.ReactNode
40
+ }): React.JSX.Element {
41
+ const [status, setStatus] = useState<NormalizationStatus>({ kind: 'ok' })
42
+ const value = useMemo(() => ({ status, setStatus }), [status])
43
+ return <Context.Provider value={value}>{children}</Context.Provider>
44
+ }
45
+
46
+ export function useNormalizationStatus(): ContextValue {
47
+ return useContext(Context)
48
+ }