@blue-patterns/richtext 0.0.0-stage → 0.7.18

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 (201) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/LICENSE +22 -0
  3. package/README.md +88 -2
  4. package/astryx.integration.mjs +11 -0
  5. package/blocks/RichTextEditorShowcase.template.mjs +13 -0
  6. package/blocks/RichTextEditorShowcase.tsx +19 -0
  7. package/dist/RichTextEditor.d.ts +282 -0
  8. package/dist/RichTextEditor.d.ts.map +1 -0
  9. package/dist/RichTextEditor.js +657 -0
  10. package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
  11. package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
  12. package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
  13. package/dist/RichTextEditorToolbar.d.ts +186 -0
  14. package/dist/RichTextEditorToolbar.d.ts.map +1 -0
  15. package/dist/RichTextEditorToolbar.js +688 -0
  16. package/dist/RichTextView.d.ts +92 -0
  17. package/dist/RichTextView.d.ts.map +1 -0
  18. package/dist/RichTextView.js +267 -0
  19. package/dist/codeLanguages.d.ts +14 -0
  20. package/dist/codeLanguages.d.ts.map +1 -0
  21. package/dist/codeLanguages.js +34 -0
  22. package/dist/codeLineTokens.d.ts +53 -0
  23. package/dist/codeLineTokens.d.ts.map +1 -0
  24. package/dist/codeLineTokens.js +188 -0
  25. package/dist/editorNodes.d.ts +8 -0
  26. package/dist/editorNodes.d.ts.map +1 -0
  27. package/dist/editorNodes.js +30 -0
  28. package/dist/editorTheme.d.ts +7 -0
  29. package/dist/editorTheme.d.ts.map +1 -0
  30. package/dist/editorTheme.js +328 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +28 -0
  34. package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
  35. package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
  36. package/dist/internal/CodeBackspacePlugin.js +70 -0
  37. package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
  38. package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
  39. package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
  40. package/dist/internal/CodePastePlugin.d.ts +5 -0
  41. package/dist/internal/CodePastePlugin.d.ts.map +1 -0
  42. package/dist/internal/CodePastePlugin.js +104 -0
  43. package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
  44. package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
  45. package/dist/internal/CodeSyntaxPlugin.js +397 -0
  46. package/dist/internal/CodeTabPlugin.d.ts +2 -0
  47. package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
  48. package/dist/internal/CodeTabPlugin.js +256 -0
  49. package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
  50. package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
  51. package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
  52. package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
  53. package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
  54. package/dist/internal/ImageSourceCaretPlugin.js +105 -0
  55. package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
  56. package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
  57. package/dist/internal/ListItemShortcutsPlugin.js +314 -0
  58. package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
  59. package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
  60. package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
  61. package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
  62. package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
  63. package/dist/internal/SelectAllScopePlugin.js +67 -0
  64. package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
  65. package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
  66. package/dist/internal/TableColumnFloorPlugin.js +84 -0
  67. package/dist/internal/TablePastePlugin.d.ts +9 -0
  68. package/dist/internal/TablePastePlugin.d.ts.map +1 -0
  69. package/dist/internal/TablePastePlugin.js +183 -0
  70. package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
  71. package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
  72. package/dist/internal/TableScrollRegionPlugin.js +95 -0
  73. package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
  74. package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
  75. package/dist/internal/TaskCheckboxPlugin.js +193 -0
  76. package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
  77. package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
  78. package/dist/internal/ToolbarLinkPopover.js +292 -0
  79. package/dist/linkEditing.d.ts +6 -0
  80. package/dist/linkEditing.d.ts.map +1 -0
  81. package/dist/linkEditing.js +20 -0
  82. package/dist/linkForm.d.ts +28 -0
  83. package/dist/linkForm.d.ts.map +1 -0
  84. package/dist/linkForm.js +137 -0
  85. package/dist/linkUtils.d.ts +27 -0
  86. package/dist/linkUtils.d.ts.map +1 -0
  87. package/dist/linkUtils.js +101 -0
  88. package/dist/markdown.d.ts +17 -0
  89. package/dist/markdown.d.ts.map +1 -0
  90. package/dist/markdown.js +17 -0
  91. package/dist/markdownCharacterReferences.d.ts +135 -0
  92. package/dist/markdownCharacterReferences.d.ts.map +1 -0
  93. package/dist/markdownCharacterReferences.js +1127 -0
  94. package/dist/markdownCodeFence.d.ts +4 -0
  95. package/dist/markdownCodeFence.d.ts.map +1 -0
  96. package/dist/markdownCodeFence.js +167 -0
  97. package/dist/markdownExtensionNode.d.ts +88 -0
  98. package/dist/markdownExtensionNode.d.ts.map +1 -0
  99. package/dist/markdownExtensionNode.js +248 -0
  100. package/dist/markdownExtensions.d.ts +115 -0
  101. package/dist/markdownExtensions.d.ts.map +1 -0
  102. package/dist/markdownExtensions.js +392 -0
  103. package/dist/markdownHardLineBreak.d.ts +25 -0
  104. package/dist/markdownHardLineBreak.d.ts.map +1 -0
  105. package/dist/markdownHardLineBreak.js +59 -0
  106. package/dist/markdownHeading.d.ts +20 -0
  107. package/dist/markdownHeading.d.ts.map +1 -0
  108. package/dist/markdownHeading.js +30 -0
  109. package/dist/markdownLink.d.ts +5 -0
  110. package/dist/markdownLink.d.ts.map +1 -0
  111. package/dist/markdownLink.js +80 -0
  112. package/dist/markdownListExport.d.ts +3 -0
  113. package/dist/markdownListExport.d.ts.map +1 -0
  114. package/dist/markdownListExport.js +68 -0
  115. package/dist/markdownListIndentation.d.ts +8 -0
  116. package/dist/markdownListIndentation.d.ts.map +1 -0
  117. package/dist/markdownListIndentation.js +101 -0
  118. package/dist/markdownOrderedList.d.ts +3 -0
  119. package/dist/markdownOrderedList.d.ts.map +1 -0
  120. package/dist/markdownOrderedList.js +51 -0
  121. package/dist/markdownQuote.d.ts +22 -0
  122. package/dist/markdownQuote.d.ts.map +1 -0
  123. package/dist/markdownQuote.js +32 -0
  124. package/dist/markdownSerializers.d.ts +49 -0
  125. package/dist/markdownSerializers.d.ts.map +1 -0
  126. package/dist/markdownSerializers.js +83 -0
  127. package/dist/markdownSource.d.ts +71 -0
  128. package/dist/markdownSource.d.ts.map +1 -0
  129. package/dist/markdownSource.js +804 -0
  130. package/dist/markdownSpaceRuns.d.ts +24 -0
  131. package/dist/markdownSpaceRuns.d.ts.map +1 -0
  132. package/dist/markdownSpaceRuns.js +147 -0
  133. package/dist/markdownTable.d.ts +35 -0
  134. package/dist/markdownTable.d.ts.map +1 -0
  135. package/dist/markdownTable.js +227 -0
  136. package/dist/markdownTaskList.d.ts +48 -0
  137. package/dist/markdownTaskList.d.ts.map +1 -0
  138. package/dist/markdownTaskList.js +166 -0
  139. package/dist/markdownThematicBreak.d.ts +3 -0
  140. package/dist/markdownThematicBreak.d.ts.map +1 -0
  141. package/dist/markdownThematicBreak.js +55 -0
  142. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
  143. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
  144. package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
  145. package/dist/richtext.css +213 -0
  146. package/dist/textSemantics.d.ts +3 -0
  147. package/dist/textSemantics.d.ts.map +1 -0
  148. package/dist/textSemantics.js +87 -0
  149. package/package.json +158 -3
  150. package/src/RichTextEditor.doc.mjs +308 -0
  151. package/src/RichTextEditor.tsx +1109 -0
  152. package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
  153. package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
  154. package/src/RichTextEditorToolbar.doc.mjs +92 -0
  155. package/src/RichTextEditorToolbar.tsx +871 -0
  156. package/src/RichTextView.doc.mjs +101 -0
  157. package/src/RichTextView.tsx +351 -0
  158. package/src/codeLanguages.ts +42 -0
  159. package/src/codeLineTokens.ts +241 -0
  160. package/src/doc-mjs.d.ts +23 -0
  161. package/src/editorNodes.ts +44 -0
  162. package/src/editorTheme.ts +550 -0
  163. package/src/index.ts +59 -0
  164. package/src/internal/CodeBackspacePlugin.tsx +96 -0
  165. package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
  166. package/src/internal/CodePastePlugin.tsx +144 -0
  167. package/src/internal/CodeSyntaxPlugin.tsx +493 -0
  168. package/src/internal/CodeTabPlugin.tsx +298 -0
  169. package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
  170. package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
  171. package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
  172. package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
  173. package/src/internal/SelectAllScopePlugin.tsx +86 -0
  174. package/src/internal/TableColumnFloorPlugin.tsx +99 -0
  175. package/src/internal/TablePastePlugin.tsx +223 -0
  176. package/src/internal/TableScrollRegionPlugin.tsx +107 -0
  177. package/src/internal/TaskCheckboxPlugin.tsx +246 -0
  178. package/src/internal/ToolbarLinkPopover.tsx +392 -0
  179. package/src/linkEditing.ts +20 -0
  180. package/src/linkForm.tsx +156 -0
  181. package/src/linkUtils.ts +106 -0
  182. package/src/markdown.ts +25 -0
  183. package/src/markdownCharacterReferences.ts +1137 -0
  184. package/src/markdownCodeFence.ts +216 -0
  185. package/src/markdownExtensionNode.ts +339 -0
  186. package/src/markdownExtensions.ts +496 -0
  187. package/src/markdownHardLineBreak.ts +73 -0
  188. package/src/markdownHeading.ts +33 -0
  189. package/src/markdownLink.ts +86 -0
  190. package/src/markdownListExport.ts +85 -0
  191. package/src/markdownListIndentation.ts +105 -0
  192. package/src/markdownOrderedList.ts +60 -0
  193. package/src/markdownQuote.ts +35 -0
  194. package/src/markdownSerializers.ts +108 -0
  195. package/src/markdownSource.ts +1045 -0
  196. package/src/markdownSpaceRuns.ts +164 -0
  197. package/src/markdownTable.ts +309 -0
  198. package/src/markdownTaskList.ts +222 -0
  199. package/src/markdownThematicBreak.ts +66 -0
  200. package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
  201. package/src/textSemantics.ts +104 -0
@@ -0,0 +1,101 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@blue-patterns/cli/authoring').ComponentDoc} */
4
+
5
+ export const docs = {
6
+ name: 'RichTextView',
7
+ displayName: 'Rich Text View',
8
+ category: 'Content',
9
+ keywords: [
10
+ 'richtext',
11
+ 'rich text',
12
+ 'viewer',
13
+ 'read-only',
14
+ 'lexical',
15
+ 'render',
16
+ 'serialized',
17
+ ],
18
+ props: [
19
+ {
20
+ name: 'value',
21
+ type: 'string',
22
+ description:
23
+ 'Serialized editor state to render: the JSON string from JSON.stringify(editorState.toJSON()). The view updates when value changes.',
24
+ required: true,
25
+ },
26
+ {
27
+ name: 'nodes',
28
+ type: 'ReadonlyArray<Klass<LexicalNode>>',
29
+ description:
30
+ 'Lexical nodes to register beyond the default set. Pass the same nodes the content was authored with, so custom node types deserialize.',
31
+ },
32
+ {
33
+ name: 'markdownExtensions',
34
+ type: 'ReadonlyArray<RichTextMarkdownExtension>',
35
+ description:
36
+ 'Markdown plugins whose nodes this view draws, each adopted with createRichTextExtension. A plugin node renders as core Markdown renders it; a node whose plugin is not passed shows its source. Pass the extensions the content was converted with, created in a client module.',
37
+ },
38
+ {
39
+ name: 'codeLanguages',
40
+ type: 'ReadonlyArray<CodeLanguage>',
41
+ description:
42
+ "CodeBlock languages a fence may name, such as graphql from @blue-patterns/core/CodeBlock/languages/graphql. Pass the same list as Markdown's codeLanguages, so reading and editing label and paint the same languages.",
43
+ },
44
+ {
45
+ name: 'plugins',
46
+ type: 'ReactNode',
47
+ description:
48
+ 'Read-only Lexical plugins to render inside the view, such as hover cards or decorators.',
49
+ },
50
+ {
51
+ name: 'namespace',
52
+ type: 'string',
53
+ description: 'The Lexical composer namespace.',
54
+ default: "'astryx-view'",
55
+ },
56
+ {
57
+ name: 'onParseError',
58
+ type: '(error: Error) => void',
59
+ description:
60
+ 'Called when value cannot be parsed or rendered, for example malformed JSON or a node type that is not registered through nodes. Use it to log or report the failure.',
61
+ },
62
+ {
63
+ name: 'errorFallback',
64
+ type: 'ReactNode',
65
+ description:
66
+ 'What to render when value cannot be parsed or rendered. The view renders it instead of throwing.',
67
+ default: 'null',
68
+ },
69
+ {
70
+ name: 'xstyle',
71
+ type: 'StyleXStyles',
72
+ description: 'StyleX styles for the view root.',
73
+ },
74
+ ],
75
+ usage: {
76
+ description:
77
+ 'Renders serialized RichTextEditor content read-only, with the same nodes, tables, code blocks and Markdown plugin nodes the editor draws. Ships from @blue-patterns/richtext, with lexical and @lexical/* as optional peer dependencies.',
78
+ bestPractices: [
79
+ {
80
+ guidance: true,
81
+ description:
82
+ 'Store editorState.toJSON() from RichTextEditor onChange, and pass the stringified JSON as value.',
83
+ },
84
+ {
85
+ guidance: true,
86
+ description:
87
+ 'Register the same custom nodes and markdownExtensions on the editor and the view, so stored content round-trips.',
88
+ },
89
+ {
90
+ guidance: true,
91
+ description:
92
+ 'Pass onParseError and an errorFallback when the stored content can come from an older or a foreign schema. By default a parse failure renders nothing instead of throwing.',
93
+ },
94
+ {
95
+ guidance: false,
96
+ description:
97
+ 'Use RichTextView to render Markdown source; use core Markdown for that.',
98
+ },
99
+ ],
100
+ },
101
+ };
@@ -0,0 +1,351 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file RichTextView.tsx
7
+ * @input Uses React, Lexical (lexical + @lexical/react, composed through
8
+ * LexicalExtensionComposer), mergeProps from core utils, design tokens,
9
+ * TableScrollRegionPlugin
10
+ * @output Exports RichTextView component and RichTextViewProps
11
+ * @position Read-only renderer for serialized Lexical editor state, exposed to
12
+ * assistive technology as document content rather than a form field;
13
+ * exported from @blue-patterns/richtext
14
+ *
15
+ * SYNC: When modified, update these files to stay in sync:
16
+ * - /packages/richtext/src/RichTextEditor.test.tsx
17
+ * - /packages/richtext/src/index.ts
18
+ * - /apps/storybook/stories/RichTextEditor.stories.tsx
19
+ */
20
+
21
+ import {useEffect, useRef, useState, type ReactNode} from 'react';
22
+ import * as stylex from '@stylexjs/stylex';
23
+ import {sharedEditorTheme} from './editorTheme';
24
+ import type {BaseProps} from '@blue-patterns/core';
25
+ import {mergeProps} from '@blue-patterns/core/utils';
26
+ import {
27
+ colorVars,
28
+ typeScaleVars,
29
+ typographyVars,
30
+ } from '@blue-patterns/core/theme/tokens.stylex';
31
+
32
+ import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer';
33
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
34
+ import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
35
+ import {ContentEditable} from '@lexical/react/LexicalContentEditable';
36
+ import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
37
+ import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
38
+ import {TableScrollRegionPlugin} from './internal/TableScrollRegionPlugin';
39
+ import {TableColumnFloorPlugin} from './internal/TableColumnFloorPlugin';
40
+ import {CodeBlockHeaderPlugin} from './internal/CodeBlockHeaderPlugin';
41
+ import {CodeSyntaxPlugin} from './internal/CodeSyntaxPlugin';
42
+ import {MarkdownExtensionsPlugin} from './internal/MarkdownExtensionsPlugin';
43
+ import type {RichTextMarkdownExtension} from './markdownExtensions';
44
+ import type {CodeLanguage} from '@blue-patterns/core/CodeBlock';
45
+ import {TaskCheckboxPlugin} from './internal/TaskCheckboxPlugin';
46
+ import type {
47
+ AnyLexicalExtension,
48
+ Klass,
49
+ LexicalNode,
50
+ EditorThemeClasses,
51
+ } from 'lexical';
52
+ import {defineExtension} from 'lexical';
53
+ import {TextSemanticsExtension} from './textSemantics';
54
+ // The same node set as the editor, so anything it writes renders here.
55
+ import {DEFAULT_NODES} from './editorNodes';
56
+
57
+ const styles = stylex.create({
58
+ root: {
59
+ width: '100%',
60
+ // Holds the code block headers and task checkboxes drawn over the
61
+ // content.
62
+ position: 'relative',
63
+ // The document's body text, as core Markdown and the editor set it
64
+ // (spec:AST-061 FR2). Without it, blocks the theme leaves unsized —
65
+ // list items, quotes, table cells — take the host page's font.
66
+ fontFamily: typographyVars['--font-family-body'],
67
+ fontSize: typeScaleVars['--text-body-size'],
68
+ lineHeight: typeScaleVars['--text-body-leading'],
69
+ color: colorVars['--color-text-primary'],
70
+ },
71
+ });
72
+
73
+ /**
74
+ * Lexical's `ContentEditable` renders `role="textbox"` and widget-only ARIA
75
+ * (`aria-autocomplete`, `aria-readonly`) even when the editor is not editable.
76
+ * A view renders document content, not a form field, so all three are cleared:
77
+ *
78
+ * - A textbox is a widget, so an unnamed one fails axe
79
+ * `aria-input-field-name` — and a view has no name to give, because it is
80
+ * content rather than a labelled input.
81
+ * - The widget role exposes the rendered document to assistive technology
82
+ * as a form field rather than as the content it is.
83
+ * - Widget-only ARIA is invalid without a widget role, so clearing only the
84
+ * role would trade `aria-input-field-name` for `aria-allowed-attr`.
85
+ *
86
+ * `role` must be `null`, not `undefined`: Lexical substitutes its `textbox`
87
+ * default for `undefined`. The ARIA keys reach the element through Lexical's
88
+ * trailing prop spread, which is why `undefined` clears them. A proposed
89
+ * upstream fix (facebook/lexical#9270) would make these overrides no-ops.
90
+ *
91
+ * A caller who wants read-only form-field semantics instead wants
92
+ * `<RichTextEditor isReadOnly />`, which is a labelled field by construction.
93
+ */
94
+ const VIEW_CONTENT_EDITABLE_PROPS = {
95
+ 'aria-autocomplete': undefined,
96
+ 'aria-readonly': undefined,
97
+ role: null as unknown as undefined,
98
+ } as const;
99
+
100
+ /** The view imports and exports no Markdown, so it uses no transformers. */
101
+ const NO_TRANSFORMERS: ReadonlyArray<never> = [];
102
+
103
+ export interface RichTextViewProps extends BaseProps {
104
+ /**
105
+ * Serialized editor state to render (a JSON string produced by
106
+ * `JSON.stringify(editorState.toJSON())`).
107
+ */
108
+ value: string;
109
+ /**
110
+ * Additional Lexical nodes to register beyond the default OSS set. Must match
111
+ * the nodes used to author `value` so custom node types deserialize.
112
+ */
113
+ nodes?: ReadonlyArray<Klass<LexicalNode>>;
114
+ /**
115
+ * Markdown plugins whose nodes this surface draws, each adopted with
116
+ * `createRichTextExtension` (spec:AST-064). A plugin node renders exactly as
117
+ * core `Markdown` renders it, and one whose plugin is not given here shows
118
+ * its source. Pass the extensions the content was converted with. Create
119
+ * them in a client module: they hold the plugins' functions, so they are not
120
+ * serializable props.
121
+ */
122
+ markdownExtensions?: ReadonlyArray<RichTextMarkdownExtension>;
123
+ /**
124
+ * CodeBlock language values a fence may name, each imported on its own,
125
+ * such as `graphql` from `@blue-patterns/core/CodeBlock/languages/graphql`,
126
+ * or made with `createCodeLanguage`. Pass the same list as `Markdown`'s
127
+ * `codeLanguages`, so reading and editing paint and label the same
128
+ * languages (spec:AST-061).
129
+ */
130
+ codeLanguages?: ReadonlyArray<CodeLanguage>;
131
+ /**
132
+ * Additional read-only plugins to render inside the composer (e.g. hover
133
+ * cards, decorators).
134
+ */
135
+ plugins?: ReactNode;
136
+ /** The Lexical composer namespace. @default 'astryx-view' */
137
+ namespace?: string;
138
+ /**
139
+ * Called when `value` cannot be parsed/rendered (e.g. malformed JSON, or
140
+ * state authored with node types not registered via `nodes`). A read-only
141
+ * view renders *persisted* content — exactly where stale or foreign-schema
142
+ * state shows up — so by default a parse failure renders `errorFallback`
143
+ * instead of throwing and taking down the host. Provide `onParseError` to log or
144
+ * report it.
145
+ */
146
+ onParseError?: (error: Error) => void;
147
+ /**
148
+ * What to render when `value` fails to parse/render. Defaults to `null`
149
+ * (renders nothing). Pass a node to show a placeholder/empty state.
150
+ * @default null
151
+ */
152
+ errorFallback?: ReactNode;
153
+ }
154
+
155
+ /**
156
+ * Keeps the rendered content in sync with the `value` prop after mount.
157
+ *
158
+ * The editor's initial state is seeded once, when the composer mounts, so a
159
+ * plain `<RichTextView value={changingValue} />` would freeze at its first
160
+ * value — the content would never update when `value` changed. This plugin runs
161
+ * inside the composer context and re-applies `value` whenever it changes, so the
162
+ * read-only view stays reactive (e.g. previewing content edited elsewhere).
163
+ *
164
+ * The initial `value` is already applied via the extension's
165
+ * `$initialEditorState`, so we skip the first run to avoid a redundant re-parse
166
+ * on mount.
167
+ *
168
+ * This mirrors the canonical Lexical pattern for applying externally-sourced
169
+ * serialized state after mount: the Lexical Playground's ActionsPlugin does the
170
+ * same `editor.setEditorState(editor.parseEditorState(...))` from inside a
171
+ * plugin (see facebook/lexical
172
+ * packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx). It is
173
+ * necessary because the composer builds the editor once and reads the initial
174
+ * state exactly once at that point, so a changed prop cannot re-seed the editor
175
+ * on its own. A read-only view has no history, so we skip the Playground's
176
+ * accompanying CLEAR_HISTORY_COMMAND.
177
+ *
178
+ * `parseEditorState` / `setEditorState` are methods on the editor instance, so
179
+ * this plugin needs no module-level Lexical imports of its own.
180
+ */
181
+ function SyncValuePlugin({value}: {value: string}): null {
182
+ const [editor] = useLexicalComposerContext();
183
+ const isFirstRun = useRef(true);
184
+ useEffect(() => {
185
+ if (isFirstRun.current) {
186
+ isFirstRun.current = false;
187
+ return;
188
+ }
189
+ editor.setEditorState(editor.parseEditorState(value));
190
+ }, [editor, value]);
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * A read-only renderer for serialized Lexical content. Renders the same styled
196
+ * output as {@link RichTextEditor} without any editing affordances.
197
+ *
198
+ * The output is document content, not a form field: headings, lists and links
199
+ * keep their own roles for assistive technology, and the view takes no label.
200
+ * For a labelled, read-only field, use `<RichTextEditor isReadOnly />`. A
201
+ * table wider than the view scrolls inside a region named "Table" that takes a
202
+ * tab stop while it overflows, as core `Table` does.
203
+ *
204
+ * @example
205
+ * ```
206
+ * import {RichTextView} from '@blue-patterns/richtext';
207
+ * <RichTextView value={storedEditorStateJSON} />
208
+ * ```
209
+ */
210
+ export function RichTextView({
211
+ value,
212
+ nodes,
213
+ markdownExtensions,
214
+ codeLanguages,
215
+ plugins,
216
+ namespace = 'astryx-view',
217
+ onParseError,
218
+ errorFallback = null,
219
+ xstyle,
220
+ className,
221
+ style,
222
+ ...rest
223
+ }: RichTextViewProps) {
224
+ const themeRef = useRef<EditorThemeClasses | null>(null);
225
+ if (themeRef.current === null) {
226
+ themeRef.current = sharedEditorTheme();
227
+ }
228
+
229
+ // Built on first render (and again after an error, below) rather than per
230
+ // render: LexicalExtensionComposer re-creates the editor whenever the
231
+ // extension's identity changes, and `value` updates are applied in place by
232
+ // SyncValuePlugin instead.
233
+ const extensionRef = useRef<AnyLexicalExtension | null>(null);
234
+
235
+ const [hasError, setHasError] = useState(false);
236
+
237
+ // Reset the error state when the value changes so a corrected value recovers.
238
+ const lastValueRef = useRef(value);
239
+ if (lastValueRef.current !== value && hasError) {
240
+ lastValueRef.current = value;
241
+ setHasError(false);
242
+ } else {
243
+ lastValueRef.current = value;
244
+ }
245
+
246
+ // A parse failure is recorded during render (below, or from Lexical's
247
+ // `onError` while the composer builds the editor) but reported to the
248
+ // consumer only after commit. Updating this component's own state during
249
+ // render is allowed; running a consumer's callback is not — a callback that
250
+ // updates its own component would do so during another component's render,
251
+ // and StrictMode or a discarded concurrent render would repeat it or run it
252
+ // for output that never commits. This Effect synchronizes the consumer's
253
+ // `onParseError`, reporting each distinct failure exactly once.
254
+ const pendingErrorRef = useRef<Error | null>(null);
255
+ const reportedErrorRef = useRef<Error | null>(null);
256
+ useEffect(() => {
257
+ const error = pendingErrorRef.current;
258
+ if (error !== null && error !== reportedErrorRef.current) {
259
+ reportedErrorRef.current = error;
260
+ onParseError?.(error);
261
+ }
262
+ });
263
+
264
+ const handleError = (error: Error) => {
265
+ pendingErrorRef.current = error;
266
+ setHasError(true);
267
+ };
268
+
269
+ // Validate `value` parses as JSON before handing it to Lexical. Malformed
270
+ // JSON would otherwise throw synchronously while the composer builds the
271
+ // editor and escape any error boundary, taking down the host on the render
272
+ // path.
273
+ if (!hasError) {
274
+ try {
275
+ JSON.parse(value);
276
+ } catch (err) {
277
+ handleError(err instanceof Error ? err : new Error(String(err)));
278
+ }
279
+ }
280
+
281
+ if (hasError) {
282
+ // The composer subtree is unmounted while the fallback renders. Drop the
283
+ // extension so that recovering from the error builds a fresh one, seeded
284
+ // with the corrected `value`.
285
+ extensionRef.current = null;
286
+ return (
287
+ <div
288
+ {...mergeProps(stylex.props(styles.root, xstyle), className, style)}
289
+ {...rest}>
290
+ {errorFallback}
291
+ </div>
292
+ );
293
+ }
294
+
295
+ if (extensionRef.current === null) {
296
+ extensionRef.current = defineExtension({
297
+ name: '@blue-patterns/richtext/RichTextView',
298
+ namespace,
299
+ theme: themeRef.current,
300
+ editable: false,
301
+ nodes: nodes ? [...DEFAULT_NODES, ...nodes] : [...DEFAULT_NODES],
302
+ // Struck text is a deletion.
303
+ dependencies: [TextSemanticsExtension],
304
+ $initialEditorState: value,
305
+ // A read-only view renders persisted content; a bad node/schema should not
306
+ // crash the host. Surface it via onParseError + fallback instead of re-throwing.
307
+ onError: handleError,
308
+ });
309
+ }
310
+
311
+ return (
312
+ <div
313
+ {...mergeProps(stylex.props(styles.root, xstyle), className, style)}
314
+ {...rest}>
315
+ <LexicalExtensionComposer
316
+ extension={extensionRef.current}
317
+ contentEditable={null}>
318
+ <SyncValuePlugin value={value} />
319
+ {/* Same table configuration as the editor, so a wide table scrolls
320
+ inside its own wrapper instead of widening the page. */}
321
+ <TablePlugin
322
+ hasCellMerge={false}
323
+ hasCellBackgroundColor={false}
324
+ hasTabHandler={false}
325
+ hasHorizontalScroll
326
+ />
327
+ <TableScrollRegionPlugin />
328
+ <TableColumnFloorPlugin />
329
+ <RichTextPlugin
330
+ contentEditable={<ContentEditable {...VIEW_CONTENT_EDITABLE_PROPS} />}
331
+ placeholder={null}
332
+ ErrorBoundary={LexicalErrorBoundary}
333
+ />
334
+ {/* After the content, so the copy buttons and checkboxes follow it
335
+ in tab order. */}
336
+ <CodeBlockHeaderPlugin codeLanguages={codeLanguages} />
337
+ <CodeSyntaxPlugin codeLanguages={codeLanguages} />
338
+ {markdownExtensions != null && markdownExtensions.length > 0 ? (
339
+ <MarkdownExtensionsPlugin
340
+ extensions={markdownExtensions}
341
+ transformers={NO_TRANSFORMERS}
342
+ />
343
+ ) : null}
344
+ <TaskCheckboxPlugin isReadOnly />
345
+ {plugins}
346
+ </LexicalExtensionComposer>
347
+ </div>
348
+ );
349
+ }
350
+
351
+ RichTextView.displayName = 'RichTextView';
@@ -0,0 +1,42 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file codeLanguages.ts
5
+ * @input CodeLanguage values from @blue-patterns/core/CodeBlock
6
+ * @output listedLanguage, which finds the listed value a fence word names, and
7
+ * sameLanguages, which compares two lists entry by entry
8
+ * @position Shared by CodeSyntaxPlugin and CodeBlockHeaderPlugin, so RichText
9
+ * resolves a fence as Markdown does (component:Markdown FR30)
10
+ */
11
+
12
+ import type {CodeLanguage} from '@blue-patterns/core/CodeBlock';
13
+
14
+ /** The listed value whose identifiers include `word`; a later entry wins. */
15
+ export function listedLanguage(
16
+ languages: ReadonlyArray<CodeLanguage> | undefined,
17
+ word: string,
18
+ ): CodeLanguage | undefined {
19
+ if (languages == null) {
20
+ return undefined;
21
+ }
22
+ for (let i = languages.length - 1; i >= 0; i--) {
23
+ if (languages[i].identifiers.includes(word)) {
24
+ return languages[i];
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ /** Whether two lists name the same values in the same order. */
31
+ export function sameLanguages(
32
+ a: ReadonlyArray<CodeLanguage> | undefined,
33
+ b: ReadonlyArray<CodeLanguage> | undefined,
34
+ ): boolean {
35
+ if (a === b) {
36
+ return true;
37
+ }
38
+ if (a == null || b == null || a.length !== b.length) {
39
+ return false;
40
+ }
41
+ return a.every((language, i) => language === b[i]);
42
+ }