@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,657 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file RichTextEditor.tsx
7
+ * @input Uses React, useId, a declared DOM boundary, Lexical (lexical + @lexical/react, composed through
8
+ * LexicalExtensionComposer), Field, VisuallyHidden, useInputStatusIcon,
9
+ * mergeProps, design tokens
10
+ * @output Exports an accessibly labelled RichTextEditor component with a flush
11
+ * top toolbar slot, configurable editable-surface minimum height, and distinct
12
+ * read-only and disabled states that follow prop changes, RichTextEditorProps,
13
+ * RichTextEditorStatus, RichTextEditorStatusType, RichTextEditorSize
14
+ * @position @blue-patterns/richtext implementation; consumed by the package index.ts and
15
+ * re-exported from @blue-patterns/richtext. Tested by RichTextEditor.test.tsx.
16
+ *
17
+ * SYNC: When modified, update these files to stay in sync:
18
+ * - /packages/richtext/src/RichTextEditor.doc.mjs (props table, features, implementation notes)
19
+ * - /packages/richtext/src/RichTextEditor.test.tsx (tests for new/changed behavior)
20
+ * - /packages/richtext/src/index.ts (exports if types change)
21
+ * - /apps/storybook/stories/RichTextEditor.stories.tsx (storybook stories)
22
+ *
23
+ * NOTE: `lexical` and `@lexical/*` are OPTIONAL peer dependencies — install them to
24
+ * use this component.
25
+ */
26
+ import { useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react';
27
+ import * as stylex from '@stylexjs/stylex';
28
+ import '@blue-patterns/core/theme/tokens.stylex';
29
+ import { colorVars, spacingVars, typographyVars, typeScaleVars } from '@blue-patterns/core/theme/tokens.stylex';
30
+ import { sharedEditorTheme } from "./editorTheme.js";
31
+ import { Field, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles } from '@blue-patterns/core/Field';
32
+ import { useInputStatusIcon } from '@blue-patterns/core/hooks';
33
+ import { VisuallyHidden } from '@blue-patterns/core/VisuallyHidden';
34
+ import { useTranslator } from '@blue-patterns/core/i18n';
35
+ import { isRenderable, mergeProps, themeProps } from '@blue-patterns/core/utils';
36
+ import { useSize } from '@blue-patterns/core/SizeContext';
37
+ import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer';
38
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
39
+ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
40
+ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
41
+ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
42
+ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
43
+ import { ListPlugin } from '@lexical/react/LexicalListPlugin';
44
+ import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
45
+ import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin';
46
+ import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
47
+ import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
48
+ import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
49
+ import { HorizontalRuleExtension } from '@lexical/extension';
50
+ import { TextSemanticsExtension } from "./textSemantics.js";
51
+ import { TableColumnFloorPlugin } from "./internal/TableColumnFloorPlugin.js";
52
+ import { TablePastePlugin } from "./internal/TablePastePlugin.js";
53
+ import { CodeBackspacePlugin } from "./internal/CodeBackspacePlugin.js";
54
+ import { CodeBlockHeaderPlugin } from "./internal/CodeBlockHeaderPlugin.js";
55
+ import { CodePastePlugin } from "./internal/CodePastePlugin.js";
56
+ import { CodeSyntaxPlugin } from "./internal/CodeSyntaxPlugin.js";
57
+ import { CodeTabPlugin } from "./internal/CodeTabPlugin.js";
58
+ import { MarkdownExtensionsPlugin } from "./internal/MarkdownExtensionsPlugin.js";
59
+ import { TaskCheckboxPlugin } from "./internal/TaskCheckboxPlugin.js";
60
+ import { ListItemShortcutsPlugin } from "./internal/ListItemShortcutsPlugin.js";
61
+ import { DisabledLinkGuardPlugin } from "./internal/DisabledLinkGuardPlugin.js";
62
+ import { SelectAllScopePlugin } from "./internal/SelectAllScopePlugin.js";
63
+ import { ImageSourceCaretPlugin } from "./internal/ImageSourceCaretPlugin.js";
64
+ import { $generateHtmlFromNodes } from '@lexical/html';
65
+ import { DEFAULT_NODES } from "./editorNodes.js";
66
+ import { $exportMarkdownKeepingSource } from "./markdownSource.js";
67
+ import { DEFAULT_TRANSFORMERS } from "./markdownTable.js";
68
+ import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, defineExtension, KEY_DOWN_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, mergeRegister } from 'lexical';
69
+ import { rendererManagedRootProps } from "./privateAttributes/layerPortalBoundary.stylex.js";
70
+
71
+ /**
72
+ * Serialized state for an empty editor: a root containing a single empty
73
+ * paragraph. Used by the imperative `clear()` handle.
74
+ *
75
+ * We deliberately reset the editor via `editor.setEditorState(...)` rather than
76
+ * an `editor.update(() => { $getRoot()... })` callback, because `$getRoot` /
77
+ * `$createParagraphNode` are *runtime value* imports from the top-level
78
+ * `lexical` package. In the sandbox's Next build a top-level `lexical` value
79
+ * import forces Babel to transpile lexical's raw `src/*.ts` (which uses
80
+ * `declare` class fields) and fails the build. `parseEditorState` /
81
+ * `setEditorState` are methods on the editor instance, so no top-level
82
+ * `lexical` value import is needed. Setting a fresh state still notifies update
83
+ * listeners, so `onChange` fires.
84
+ */
85
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
86
+ const EMPTY_EDITOR_STATE_JSON = JSON.stringify({
87
+ root: {
88
+ children: [{
89
+ children: [],
90
+ direction: null,
91
+ format: '',
92
+ indent: 0,
93
+ type: 'paragraph',
94
+ version: 1
95
+ }],
96
+ direction: null,
97
+ format: '',
98
+ indent: 0,
99
+ type: 'root',
100
+ version: 1
101
+ }
102
+ });
103
+ const styles = {
104
+ wrapper: {
105
+ "--container-padding-inline-start": "xrhngw9",
106
+ "--container-padding-inline-end": "xjsfl84",
107
+ "--container-padding-block-start": "x1047aw6",
108
+ "--container-padding-block-end": "xax9j7h",
109
+ kXwgrk: "xdt5ytf",
110
+ kGNEyG: "x1qjc9v5",
111
+ kOIVth: "xsn7fz1",
112
+ k8WAf4: "x1hp7d8q",
113
+ kg3NbH: "x17a0aqu",
114
+ kAzted: "x65nank",
115
+ $$css: true
116
+ },
117
+ contentEditable: {
118
+ k1xSpc: "x1lliihq",
119
+ kzqmXN: "xh8yej3",
120
+ kMzoRj: "xc342km",
121
+ ksu8eU: "xng3xce",
122
+ kmVPX3: "x1717udv",
123
+ kI3sdo: "x1a2a7pz",
124
+ kMwMTN: "x1tgivj0",
125
+ $$css: true
126
+ },
127
+ disabled: {
128
+ kkrTdU: "xt0e3qv x16khyan",
129
+ $$css: true
130
+ }
131
+ };
132
+ const dynamicStyles = {
133
+ contentEditableMinHeight: minHeight => [{
134
+ kAzted: minHeight != null ? "x82snj4" : minHeight,
135
+ $$css: true
136
+ }, {
137
+ "--x-minHeight": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(minHeight)
138
+ }]
139
+ };
140
+ /**
141
+ * Fraction of `maxLength` at which the character counter begins announcing
142
+ * remaining/over-limit characters to screen readers. Matches TextArea.
143
+ */
144
+ const COUNTER_WARNING_THRESHOLD = 0.8;
145
+
146
+ /**
147
+ * Imperative handle exposed via `ref`. Lets callers focus, clear, and read the
148
+ * editor without wiring a custom plugin. Available after mount.
149
+ */
150
+
151
+ /**
152
+ * A WYSIWYG rich-text editor built on Lexical, styled with Astryx design
153
+ * tokens. Ships from `@blue-patterns/richtext`. `lexical` and `@lexical/*` are
154
+ * optional peer dependencies — install them to use this component.
155
+ *
156
+ * The editor is intentionally minimal and extensible: pass `toolbar`, `nodes`,
157
+ * and `plugins` to layer richer behaviour (formatting, mentions, hover cards)
158
+ * on top without forking.
159
+ *
160
+ * The forwarded `RichTextEditorRef` exposes imperative `focus()` and `clear()`
161
+ * methods for callers that manage the editor from outside.
162
+ *
163
+ * @example
164
+ * ```
165
+ * import {RichTextEditor, type RichTextEditorRef} from '@blue-patterns/richtext';
166
+ * const ref = useRef<RichTextEditorRef>(null);
167
+ * <RichTextEditor
168
+ * ref={ref}
169
+ * label="Notes"
170
+ * placeholder="Write something..."
171
+ * onChange={state => save(JSON.stringify(state.toJSON()))}
172
+ * />
173
+ * ```
174
+ */
175
+ export const RichTextEditor = function RichTextEditor({
176
+ label,
177
+ isLabelHidden = false,
178
+ description,
179
+ isOptional = false,
180
+ isRequired = false,
181
+ defaultValue,
182
+ onChange,
183
+ placeholder,
184
+ isReadOnly = false,
185
+ isDisabled = false,
186
+ status,
187
+ statusVariant = 'attached',
188
+ width,
189
+ minHeight = '4.5rem',
190
+ labelTooltip,
191
+ size: sizeProp,
192
+ nodes,
193
+ toolbar,
194
+ plugins,
195
+ hasMarkdownShortcuts = true,
196
+ transformers = DEFAULT_TRANSFORMERS,
197
+ markdownExtensions,
198
+ codeLanguages,
199
+ hasAutoFocus = false,
200
+ tabEscapeHint: tabEscapeHintFromProps,
201
+ maxLength,
202
+ namespace = 'astryx-editor',
203
+ xstyle,
204
+ className,
205
+ style,
206
+ ref,
207
+ ...rest
208
+ }) {
209
+ const size = useSize(sizeProp, 'md');
210
+ const t = useTranslator();
211
+ const tabEscapeHint = tabEscapeHintFromProps ?? t('@astryx.richTextEditor.tabEscapeHint');
212
+ const inputID = useId();
213
+ const labelID = useId();
214
+ const descriptionID = useId();
215
+ const statusMessageID = useId();
216
+ const placeholderID = useId();
217
+ const counterID = useId();
218
+ const tabEscapeHintID = useId();
219
+
220
+ // Plain-text character count, tracked from inside the composer via
221
+ // CharCountPlugin. Only used when maxLength is set.
222
+ const [charCount, setCharCount] = useState(0);
223
+
224
+ // Theme is stable per render; build once.
225
+ const themeRef = useRef(null);
226
+ if (themeRef.current === null) {
227
+ themeRef.current = sharedEditorTheme();
228
+ }
229
+ const editable = !isReadOnly && !isDisabled;
230
+
231
+ // Stabilize the transformers array so MarkdownShortcutPlugin doesn't
232
+ // re-register on every render. `[...transformers]` would allocate a new
233
+ // array each time; memoize on the prop identity instead. (React Compiler
234
+ // isn't running the transform in this repo, so this isn't auto-memoized.)
235
+ const markdownTransformers = useMemo(() => [...transformers], [transformers]);
236
+
237
+ // The extension replaces LexicalComposer's `initialConfig`: it carries the
238
+ // same editor configuration (namespace, theme, nodes, editability, initial
239
+ // state) in the shape LexicalBuilder consumes.
240
+ //
241
+ // LexicalExtensionComposer re-creates the editor whenever the extension's
242
+ // identity changes, whereas LexicalComposer read `initialConfig` exactly once
243
+ // on mount. Build it on first render and keep it, so the editor's lifetime —
244
+ // and the content it holds — is unaffected by later renders (a consumer
245
+ // passing an inline `nodes={[...]}` array would otherwise blow away the
246
+ // editor's content on every render).
247
+ const extensionRef = useRef(null);
248
+ if (extensionRef.current === null) {
249
+ extensionRef.current = defineExtension({
250
+ name: '@blue-patterns/richtext/RichTextEditor',
251
+ namespace,
252
+ theme: themeRef.current,
253
+ editable,
254
+ nodes: nodes ? [...DEFAULT_NODES, ...nodes] : [...DEFAULT_NODES],
255
+ // Horizontal rules select on click and show their selection; struck
256
+ // text is a deletion.
257
+ dependencies: [HorizontalRuleExtension, TextSemanticsExtension],
258
+ // `undefined` (not `null`) leaves Lexical's default initializer in place,
259
+ // which seeds the empty document with one paragraph — what
260
+ // LexicalComposer did when no `editorState` was given. `null` would mean
261
+ // "start from a genuinely empty root".
262
+ $initialEditorState: defaultValue ?? undefined,
263
+ onError(error) {
264
+ // Surface errors to the host app rather than swallowing them.
265
+ throw error;
266
+ }
267
+ });
268
+ }
269
+ const hasTabEscapeHint = editable && tabEscapeHint !== '';
270
+ const hasToolbar = isRenderable(toolbar);
271
+ const {
272
+ statusIcon,
273
+ describedBy: statusTooltipDescribedBy
274
+ } = useInputStatusIcon({
275
+ status,
276
+ statusVariant
277
+ });
278
+ const ariaDescribedBy = [description ? descriptionID : null, statusVariant !== 'tooltip' && status?.message ? statusMessageID : null,
279
+ // Tooltip status renders no FieldStatus message box. Reference the
280
+ // tooltip layer so assistive technology still receives the message.
281
+ statusTooltipDescribedBy, placeholder ? placeholderID : null, maxLength != null ? counterID : null, hasTabEscapeHint ? tabEscapeHintID : null].filter(Boolean).join(' ') || undefined;
282
+ return /*#__PURE__*/_jsxs(Field, {
283
+ label: label,
284
+ isLabelHidden: isLabelHidden,
285
+ description: description,
286
+ inputID: inputID,
287
+ labelID: labelID,
288
+ descriptionID: description ? descriptionID : undefined,
289
+ isOptional: isOptional,
290
+ isRequired: isRequired,
291
+ isDisabled: isDisabled,
292
+ status: status ? {
293
+ type: status.type,
294
+ message: status.message,
295
+ messageID: status.message ? statusMessageID : undefined
296
+ } : undefined,
297
+ statusVariant: statusVariant,
298
+ labelTooltip: labelTooltip,
299
+ width: width,
300
+ children: [/*#__PURE__*/_jsxs("div", {
301
+ ...mergeProps(themeProps('rich-text-editor', {
302
+ size,
303
+ status: status?.type ?? null
304
+ }), stylex.props(inputWrapperStyles.base, inputWrapperStyles.textField, styles.wrapper,
305
+ // Only disabled is dimmed, matching TextArea: a read-only editor
306
+ // keeps full-opacity text and stays keyboard-reachable.
307
+ isDisabled && inputWrapperStyles.disabled, isDisabled && styles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && !isReadOnly && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], xstyle), className, style),
308
+ children: [/*#__PURE__*/_jsxs(LexicalExtensionComposer, {
309
+ extension: extensionRef.current,
310
+ contentEditable: null,
311
+ children: [hasToolbar ? toolbar : null, /*#__PURE__*/_jsxs("div", {
312
+ ...{
313
+ 0: {
314
+ className: "x1n2onr6 x9f619 xh8yej3 xu0wf1k xf314gf"
315
+ },
316
+ 4: {
317
+ className: "x1n2onr6 x9f619 xh8yej3 xu0wf1k xf314gf x178xt8z x13fuv20 x1pc3f07"
318
+ },
319
+ 2: {
320
+ className: "x1n2onr6 x9f619 xh8yej3 xu0wf1k xf314gf xp74n0j"
321
+ },
322
+ 6: {
323
+ className: "x1n2onr6 x9f619 xh8yej3 xu0wf1k xf314gf x178xt8z x13fuv20 x1pc3f07 xp74n0j"
324
+ },
325
+ 1: {
326
+ className: "x1n2onr6 x9f619 xh8yej3 xf314gf xce4md1"
327
+ },
328
+ 5: {
329
+ className: "x1n2onr6 x9f619 xh8yej3 xf314gf x178xt8z x13fuv20 x1pc3f07 xce4md1"
330
+ },
331
+ 3: {
332
+ className: "x1n2onr6 x9f619 xh8yej3 xf314gf xp74n0j xce4md1"
333
+ },
334
+ 7: {
335
+ className: "x1n2onr6 x9f619 xh8yej3 xf314gf x178xt8z x13fuv20 x1pc3f07 xp74n0j xce4md1"
336
+ }
337
+ }[!!hasToolbar << 2 | !!!!statusIcon << 1 | !!(size === 'lg') << 0],
338
+ children: [/*#__PURE__*/_jsxs("div", {
339
+ ...{
340
+ className: "x1n2onr6 xh8yej3 x9ynric xjm74w1 xf9zqsd xw6l6zx"
341
+ },
342
+ dir: rest.dir,
343
+ children: [/*#__PURE__*/_jsx(RichTextPlugin, {
344
+ contentEditable: /*#__PURE__*/_jsx(EditorContentEditable, {
345
+ id: inputID,
346
+ ariaLabel: isLabelHidden ? label : undefined,
347
+ ariaLabelledBy: isLabelHidden ? undefined : labelID,
348
+ ariaDescribedBy: ariaDescribedBy,
349
+ ariaRequired: isRequired && !isOptional,
350
+ ariaInvalid: status?.type === 'error',
351
+ isReadOnly: isReadOnly,
352
+ isDisabled: isDisabled,
353
+ placeholderText: placeholder,
354
+ placeholderID: placeholderID,
355
+ minHeight: minHeight,
356
+ rest: rest
357
+ }),
358
+ placeholder: null,
359
+ ErrorBoundary: LexicalErrorBoundary
360
+ }), /*#__PURE__*/_jsx(HistoryPlugin, {}), /*#__PURE__*/_jsx(ListPlugin, {}), /*#__PURE__*/_jsx(TaskCheckboxPlugin, {
361
+ isReadOnly: isReadOnly || isDisabled
362
+ }), /*#__PURE__*/_jsx(LinkPlugin, {}), /*#__PURE__*/_jsx(DisabledLinkGuardPlugin, {
363
+ isDisabled: isDisabled
364
+ }), /*#__PURE__*/_jsx(TablePlugin, {
365
+ hasCellMerge: false,
366
+ hasCellBackgroundColor: false,
367
+ hasTabHandler: false,
368
+ hasHorizontalScroll: true
369
+ }), /*#__PURE__*/_jsx(TableColumnFloorPlugin, {}), /*#__PURE__*/_jsx(TablePastePlugin, {}), /*#__PURE__*/_jsx(CodeBlockHeaderPlugin, {
370
+ codeLanguages: codeLanguages
371
+ }), /*#__PURE__*/_jsx(CodeSyntaxPlugin, {
372
+ codeLanguages: codeLanguages
373
+ }), /*#__PURE__*/_jsx(CodeBackspacePlugin, {}), /*#__PURE__*/_jsx(CodePastePlugin, {}), markdownExtensions != null && markdownExtensions.length > 0 ? /*#__PURE__*/_jsx(MarkdownExtensionsPlugin, {
374
+ extensions: markdownExtensions,
375
+ transformers: markdownTransformers
376
+ }) : null, /*#__PURE__*/_jsx(CodeTabPlugin, {}), /*#__PURE__*/_jsx(TabIndentationPlugin, {}), /*#__PURE__*/_jsx(TabFocusEscapePlugin, {}), /*#__PURE__*/_jsx(SelectAllScopePlugin, {}), /*#__PURE__*/_jsx(ImageSourceCaretPlugin, {}), hasMarkdownShortcuts && /*#__PURE__*/_jsx(MarkdownShortcutPlugin, {
377
+ transformers: markdownTransformers
378
+ }), hasMarkdownShortcuts && /*#__PURE__*/_jsx(ListItemShortcutsPlugin, {
379
+ transformers: markdownTransformers
380
+ }), hasAutoFocus && /*#__PURE__*/_jsx(AutoFocusOnMount, {}), onChange && /*#__PURE__*/_jsx(OnChangePlugin, {
381
+ onChange: onChange,
382
+ ignoreHistoryMergeTagChange: true,
383
+ ignoreSelectionChange: true
384
+ }), plugins, /*#__PURE__*/_jsx(EditorRefBridge, {
385
+ editorRef: ref,
386
+ editable: editable,
387
+ transformers: markdownTransformers
388
+ }), maxLength != null && /*#__PURE__*/_jsx(CharCountPlugin, {
389
+ onCountChange: setCharCount
390
+ })]
391
+ }), statusIcon && /*#__PURE__*/_jsx("div", {
392
+ ...{
393
+ className: "x10l6tqk xctzyg x72tfeb x1vjfegm x78zum5 x6s0dn4"
394
+ },
395
+ children: statusIcon
396
+ })]
397
+ })]
398
+ }), hasTabEscapeHint && /*#__PURE__*/_jsx(VisuallyHidden, {
399
+ id: tabEscapeHintID,
400
+ children: tabEscapeHint
401
+ })]
402
+ }), maxLength != null && /*#__PURE__*/_jsxs("div", {
403
+ id: counterID,
404
+ ...{
405
+ 0: {
406
+ className: "x78zum5 x13a6bvl xcsaf9d x9ynric x141an7d xv1l7n4"
407
+ },
408
+ 1: {
409
+ className: "x78zum5 x13a6bvl xcsaf9d x9ynric x141an7d x1wj83wy"
410
+ }
411
+ }[!!(charCount > maxLength) << 0],
412
+ children: [charCount, "/", maxLength, /*#__PURE__*/_jsx(VisuallyHidden, {
413
+ "aria-live": "polite",
414
+ children: charCount >= maxLength * COUNTER_WARNING_THRESHOLD ? charCount > maxLength ? t('@astryx.textArea.charactersOverLimit', {
415
+ count: charCount - maxLength
416
+ }) : t('@astryx.textArea.charactersRemaining', {
417
+ count: maxLength - charCount
418
+ }) : ''
419
+ })]
420
+ })]
421
+ });
422
+ };
423
+
424
+ // Keeps the editor inside `@astryx/require-baseprops-passthrough`, which only
425
+ // checks components that assign a top-level `displayName`. Without it, a
426
+ // dropped `className`, `style` or `xstyle` on the root stops warning.
427
+ RichTextEditor.displayName = 'RichTextEditor';
428
+
429
+ /**
430
+ * Keys that must not cancel an armed Tab escape: Escape (arming again),
431
+ * Tab (the escape itself) and bare modifier presses — the Shift keydown that
432
+ * precedes Shift+Tab must not disarm, or Escape → Shift+Tab could never
433
+ * escape backwards.
434
+ */
435
+ const ESCAPE_REARM_EXEMPT_KEYS = new Set(['Escape', 'Tab', 'Shift', 'Control', 'Alt', 'Meta']);
436
+
437
+ /**
438
+ * WCAG 2.1.2 (No Keyboard Trap) escape for TabIndentationPlugin.
439
+ *
440
+ * TabIndentationPlugin rebinds Tab to indent/outdent, which would otherwise
441
+ * trap keyboard focus inside the editor. This plugin restores an exit: after
442
+ * Escape is pressed, the next Tab (or Shift+Tab) performs native focus
443
+ * movement instead of indenting; pressing any other non-modifier key — or
444
+ * leaving the editor — re-arms indentation.
445
+ *
446
+ * Mechanics: TabIndentationPlugin handles KEY_TAB_COMMAND at
447
+ * COMMAND_PRIORITY_EDITOR (0), the lowest priority, and Lexical runs command
448
+ * listeners from the highest priority down, stopping at the first one that
449
+ * returns true. Registering at COMMAND_PRIORITY_LOW (1) therefore runs first;
450
+ * when the escape is armed we return true WITHOUT calling
451
+ * `event.preventDefault()`, so the indentation handlers (TabIndentationPlugin,
452
+ * and CodeTabPlugin at the front of the same priority) never see the event
453
+ * and the browser performs its default Tab focus navigation.
454
+ */
455
+ function TabFocusEscapePlugin() {
456
+ const [editor] = useLexicalComposerContext();
457
+ useEffect(() => {
458
+ let escapeArmed = false;
459
+ return mergeRegister(editor.registerCommand(KEY_ESCAPE_COMMAND, () => {
460
+ escapeArmed = true;
461
+ // Consumed: this supersedes @lexical/rich-text's default Escape
462
+ // handler (COMMAND_PRIORITY_EDITOR), which blurs the editor and
463
+ // drops focus on the document body — disorienting, and it would
464
+ // immediately disarm via BLUR_COMMAND below. Consumer plugins that
465
+ // handle Escape (e.g. to close a popover) register at a higher
466
+ // priority and still run first.
467
+ return true;
468
+ }, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_TAB_COMMAND, () => {
469
+ if (!escapeArmed) {
470
+ return false;
471
+ }
472
+ escapeArmed = false;
473
+ // Consume the command (blocks indentation) but leave the event's
474
+ // default alone so focus moves natively.
475
+ return true;
476
+ }, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_DOWN_COMMAND, event => {
477
+ if (escapeArmed && !ESCAPE_REARM_EXEMPT_KEYS.has(event.key)) {
478
+ escapeArmed = false;
479
+ }
480
+ return false;
481
+ }, COMMAND_PRIORITY_LOW), editor.registerCommand(BLUR_COMMAND, () => {
482
+ escapeArmed = false;
483
+ return false;
484
+ }, COMMAND_PRIORITY_LOW));
485
+ }, [editor]);
486
+ return null;
487
+ }
488
+
489
+ /**
490
+ * Focuses the editor on mount. Split into its own plugin so it runs inside the
491
+ * composer context.
492
+ */
493
+ function AutoFocusOnMount() {
494
+ const [editor] = useLexicalComposerContext();
495
+ useEffect(() => {
496
+ editor.focus();
497
+ }, [editor]);
498
+ return null;
499
+ }
500
+
501
+ /**
502
+ * Wires the imperative `RichTextEditorRef` handle. Split into its own plugin so
503
+ * it runs inside the composer context and can reach the `LexicalEditor` via
504
+ * `useLexicalComposerContext()`. Renders nothing.
505
+ *
506
+ * `focus()` and `clear()` are gated on `editable` so a read-only or disabled
507
+ * editor cannot be mutated or focused through the imperative handle — matching
508
+ * the behaviour of the editable surface itself.
509
+ */
510
+ function EditorRefBridge({
511
+ editorRef,
512
+ editable,
513
+ transformers
514
+ }) {
515
+ const [editor] = useLexicalComposerContext();
516
+
517
+ // The extension applies `editable` once, when the composer builds the
518
+ // editor, so a later isReadOnly/isDisabled change would leave
519
+ // contenteditable at its mount value while the wrapper styling and ARIA
520
+ // follow the props. Sync the editor on a prop change only: the mount value
521
+ // is already applied, and this bridge renders after `plugins`, so
522
+ // re-asserting it on mount would undo a plugin that set editability during
523
+ // its own mount.
524
+ const syncedEditableRef = useRef(editable);
525
+ useEffect(() => {
526
+ if (syncedEditableRef.current === editable) {
527
+ return;
528
+ }
529
+ syncedEditableRef.current = editable;
530
+ editor.setEditable(editable);
531
+ }, [editor, editable]);
532
+ useImperativeHandle(editorRef, () => ({
533
+ focus: () => {
534
+ if (!editable) {
535
+ return;
536
+ }
537
+ editor.focus();
538
+ },
539
+ clear: () => {
540
+ if (!editable) {
541
+ return;
542
+ }
543
+ // Reset to a single empty paragraph via a fresh EditorState. Uses the
544
+ // editor instance's own parse/set methods so we avoid a top-level
545
+ // `lexical` value import (see EMPTY_EDITOR_STATE_JSON). This still
546
+ // notifies update listeners, so `onChange` fires.
547
+ editor.setEditorState(editor.parseEditorState(EMPTY_EDITOR_STATE_JSON));
548
+ },
549
+ getEditorState: () => editor.getEditorState(),
550
+ getMarkdown: () =>
551
+ // Untouched blocks export exactly as imported and changed blocks in
552
+ // canonical form (spec:AST-062). Honors the same transformers the
553
+ // editor uses for shortcuts, so custom transformers round-trip. Reads
554
+ // the state without changing it.
555
+ editor.getEditorState().read(() => $exportMarkdownKeepingSource(transformers)),
556
+ getHTML: () =>
557
+ // $generateHtmlFromNodes serializes the whole document (null selection)
558
+ // to HTML; must run in a read context and requires a DOM.
559
+ // `@lexical/html` is a subpackage (built dist) — safe.
560
+ editor.getEditorState().read(() => $generateHtmlFromNodes(editor, null)),
561
+ getEditor: () => editor
562
+ }), [editor, editable, transformers]);
563
+ return null;
564
+ }
565
+
566
+ /**
567
+ * Tracks the editor's plain-text length and reports it to the host component.
568
+ * Split into its own plugin so it runs inside the composer context and can
569
+ * read the editor state via `useLexicalComposerContext()`. Renders nothing.
570
+ */
571
+ function CharCountPlugin({
572
+ onCountChange
573
+ }) {
574
+ const [editor] = useLexicalComposerContext();
575
+ useEffect(() => {
576
+ // Report the initial count (e.g. seeded via defaultValue) from the mounted
577
+ // root element's text, then track changes via registerTextContentListener,
578
+ // which hands us the plain-text content directly.
579
+ //
580
+ // We deliberately avoid importing `$getRoot` from the top-level `lexical`
581
+ // package: that is a *runtime value* import, and in the sandbox's Next
582
+ // build it forces Babel to transpile lexical's raw `src/*.ts` (which uses
583
+ // `declare` class fields) and fails. Both APIs used here are methods on the
584
+ // editor instance, so no top-level `lexical` value import is needed.
585
+ onCountChange(editor.getRootElement()?.textContent?.length ?? 0);
586
+ return editor.registerTextContentListener(textContent => {
587
+ onCountChange(textContent.length);
588
+ });
589
+ }, [editor, onCountChange]);
590
+ return null;
591
+ }
592
+
593
+ /**
594
+ * Renders the Lexical ContentEditable. Split out so the placeholder
595
+ * discriminated union (`placeholder` + `aria-placeholder` present together, or
596
+ * neither) is satisfied by two concrete branches rather than a spread.
597
+ */
598
+ function EditorContentEditable({
599
+ id,
600
+ ariaLabel,
601
+ ariaLabelledBy,
602
+ ariaDescribedBy,
603
+ ariaRequired,
604
+ ariaInvalid,
605
+ isReadOnly,
606
+ isDisabled,
607
+ placeholderText,
608
+ placeholderID,
609
+ minHeight,
610
+ rest
611
+ }) {
612
+ const shared = {
613
+ id,
614
+ role: 'textbox',
615
+ 'aria-multiline': 'true',
616
+ 'aria-label': ariaLabel,
617
+ 'aria-labelledby': ariaLabelledBy,
618
+ 'aria-describedby': ariaDescribedBy,
619
+ 'aria-required': ariaRequired ? 'true' : undefined,
620
+ 'aria-invalid': ariaInvalid ? 'true' : undefined,
621
+ // Lexical announces every non-editable surface as aria-readonly and
622
+ // leaves it out of the tab order. Split the two states: read-only stays
623
+ // reachable and is announced read-only; disabled is announced disabled
624
+ // instead. These keys land after Lexical's computed attributes, so the
625
+ // disabled branch's explicit `undefined` removes the wrong read-only
626
+ // announcement. With neither prop set no keys are passed, so a plugin
627
+ // that drives editor.setEditable keeps Lexical's own aria-readonly.
628
+ ...(isDisabled ? {
629
+ 'aria-disabled': 'true',
630
+ 'aria-readonly': undefined
631
+ } : isReadOnly ? {
632
+ tabIndex: 0,
633
+ 'aria-readonly': 'true'
634
+ } : null),
635
+ ...stylex.props(styles.contentEditable, dynamicStyles.contentEditableMinHeight(minHeight)),
636
+ ...rest,
637
+ // Lexical reconciles this DOM, so Layer keeps corrective portals out.
638
+ ...rendererManagedRootProps
639
+ };
640
+ if (placeholderText) {
641
+ return /*#__PURE__*/_jsx(ContentEditable, {
642
+ ...shared,
643
+ "aria-placeholder": placeholderText,
644
+ placeholder: () => /*#__PURE__*/_jsx("div", {
645
+ id: placeholderID,
646
+ "aria-hidden": "true",
647
+ ...{
648
+ className: "x10l6tqk x4xegtk x1o0tod x47corl x87ps6o xv1l7n4"
649
+ },
650
+ children: placeholderText
651
+ })
652
+ });
653
+ }
654
+ return /*#__PURE__*/_jsx(ContentEditable, {
655
+ ...shared
656
+ });
657
+ }