@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,1109 @@
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
+
27
+ import {
28
+ useEffect,
29
+ useId,
30
+ useImperativeHandle,
31
+ useMemo,
32
+ useRef,
33
+ useState,
34
+ type ReactNode,
35
+ type Ref,
36
+ } from 'react';
37
+ import * as stylex from '@stylexjs/stylex';
38
+ import {
39
+ colorVars,
40
+ spacingVars,
41
+ typographyVars,
42
+ typeScaleVars,
43
+ } from '@blue-patterns/core/theme/tokens.stylex';
44
+ import {sharedEditorTheme} from './editorTheme';
45
+ import {
46
+ Field,
47
+ inputWrapperStyles,
48
+ inputStatusBorderStyles,
49
+ inputStatusHoverShadowStyles,
50
+ inputStatusFocusWithinStyles,
51
+ type FieldStatusVariant,
52
+ } from '@blue-patterns/core/Field';
53
+ import type {BaseProps} from '@blue-patterns/core';
54
+ import {useInputStatusIcon} from '@blue-patterns/core/hooks';
55
+ import {VisuallyHidden} from '@blue-patterns/core/VisuallyHidden';
56
+ import {useTranslator} from '@blue-patterns/core/i18n';
57
+ import {
58
+ isRenderable,
59
+ mergeProps,
60
+ themeProps,
61
+ type SizeValue,
62
+ } from '@blue-patterns/core/utils';
63
+ import {useSize} from '@blue-patterns/core/SizeContext';
64
+
65
+ import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer';
66
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
67
+ import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
68
+ import {ContentEditable} from '@lexical/react/LexicalContentEditable';
69
+ import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
70
+ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
71
+ import {ListPlugin} from '@lexical/react/LexicalListPlugin';
72
+ import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
73
+ import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin';
74
+ import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin';
75
+ import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
76
+ import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
77
+ import {HorizontalRuleExtension} from '@lexical/extension';
78
+ import {TextSemanticsExtension} from './textSemantics';
79
+ import {TableColumnFloorPlugin} from './internal/TableColumnFloorPlugin';
80
+ import {TablePastePlugin} from './internal/TablePastePlugin';
81
+ import {CodeBackspacePlugin} from './internal/CodeBackspacePlugin';
82
+ import {CodeBlockHeaderPlugin} from './internal/CodeBlockHeaderPlugin';
83
+ import {CodePastePlugin} from './internal/CodePastePlugin';
84
+ import {CodeSyntaxPlugin} from './internal/CodeSyntaxPlugin';
85
+ import {CodeTabPlugin} from './internal/CodeTabPlugin';
86
+ import {MarkdownExtensionsPlugin} from './internal/MarkdownExtensionsPlugin';
87
+ import type {RichTextMarkdownExtension} from './markdownExtensions';
88
+ import type {CodeLanguage} from '@blue-patterns/core/CodeBlock';
89
+ import {TaskCheckboxPlugin} from './internal/TaskCheckboxPlugin';
90
+ import {ListItemShortcutsPlugin} from './internal/ListItemShortcutsPlugin';
91
+ import {DisabledLinkGuardPlugin} from './internal/DisabledLinkGuardPlugin';
92
+ import {SelectAllScopePlugin} from './internal/SelectAllScopePlugin';
93
+ import {ImageSourceCaretPlugin} from './internal/ImageSourceCaretPlugin';
94
+ import {type Transformer} from '@lexical/markdown';
95
+ import {$generateHtmlFromNodes} from '@lexical/html';
96
+ import {DEFAULT_NODES} from './editorNodes';
97
+ import {$exportMarkdownKeepingSource} from './markdownSource';
98
+ import {DEFAULT_TRANSFORMERS} from './markdownTable';
99
+ import {
100
+ BLUR_COMMAND,
101
+ COMMAND_PRIORITY_LOW,
102
+ defineExtension,
103
+ KEY_DOWN_COMMAND,
104
+ KEY_ESCAPE_COMMAND,
105
+ KEY_TAB_COMMAND,
106
+ mergeRegister,
107
+ type AnyLexicalExtension,
108
+ type EditorState,
109
+ type Klass,
110
+ type LexicalEditor,
111
+ type LexicalNode,
112
+ type EditorThemeClasses,
113
+ } from 'lexical';
114
+ import {rendererManagedRootProps} from './privateAttributes/layerPortalBoundary.stylex';
115
+
116
+ /**
117
+ * Serialized state for an empty editor: a root containing a single empty
118
+ * paragraph. Used by the imperative `clear()` handle.
119
+ *
120
+ * We deliberately reset the editor via `editor.setEditorState(...)` rather than
121
+ * an `editor.update(() => { $getRoot()... })` callback, because `$getRoot` /
122
+ * `$createParagraphNode` are *runtime value* imports from the top-level
123
+ * `lexical` package. In the sandbox's Next build a top-level `lexical` value
124
+ * import forces Babel to transpile lexical's raw `src/*.ts` (which uses
125
+ * `declare` class fields) and fails the build. `parseEditorState` /
126
+ * `setEditorState` are methods on the editor instance, so no top-level
127
+ * `lexical` value import is needed. Setting a fresh state still notifies update
128
+ * listeners, so `onChange` fires.
129
+ */
130
+ const EMPTY_EDITOR_STATE_JSON = JSON.stringify({
131
+ root: {
132
+ children: [
133
+ {
134
+ children: [],
135
+ direction: null,
136
+ format: '',
137
+ indent: 0,
138
+ type: 'paragraph',
139
+ version: 1,
140
+ },
141
+ ],
142
+ direction: null,
143
+ format: '',
144
+ indent: 0,
145
+ type: 'root',
146
+ version: 1,
147
+ },
148
+ });
149
+
150
+ const styles = stylex.create({
151
+ wrapper: {
152
+ // Establish a new container boundary so Toolbar's Section does not escape
153
+ // padding inherited from an ancestor Section outside the editor field.
154
+ '--container-padding-inline-start': '0px',
155
+ '--container-padding-inline-end': '0px',
156
+ '--container-padding-block-start': '0px',
157
+ '--container-padding-block-end': '0px',
158
+ flexDirection: 'column',
159
+ alignItems: 'stretch',
160
+ gap: spacingVars['--spacing-0'],
161
+ paddingBlock: spacingVars['--spacing-0'],
162
+ paddingInline: spacingVars['--spacing-0'],
163
+ minHeight: 'auto',
164
+ },
165
+ editorBody: {
166
+ position: 'relative',
167
+ boxSizing: 'border-box',
168
+ width: '100%',
169
+ paddingBlock: spacingVars['--spacing-1'],
170
+ paddingInline: spacingVars['--spacing-2'],
171
+ },
172
+ editorBodyWithToolbar: {
173
+ borderTopWidth: 1,
174
+ borderTopStyle: 'solid',
175
+ borderTopColor: colorVars['--color-border'],
176
+ },
177
+ editorBodyWithStatus: {
178
+ // Reserve the same trailing space as TextArea: the normal text inset plus
179
+ // room for the 20px status icon and its gap.
180
+ paddingInlineEnd: `calc(${spacingVars['--spacing-2']} + ${spacingVars['--spacing-6']})`,
181
+ },
182
+ contentEditable: {
183
+ display: 'block',
184
+ width: '100%',
185
+ borderWidth: 0,
186
+ borderStyle: 'none',
187
+ padding: 0,
188
+ outline: 'none',
189
+ color: colorVars['--color-text-primary'],
190
+ },
191
+ placeholder: {
192
+ position: 'absolute',
193
+ top: spacingVars['--spacing-0'],
194
+ insetInlineStart: 0,
195
+ pointerEvents: 'none',
196
+ userSelect: 'none',
197
+ color: colorVars['--color-text-secondary'],
198
+ },
199
+ editorRoot: {
200
+ position: 'relative',
201
+ width: '100%',
202
+ // ContentEditable and Lexical's sibling placeholder inherit one shared
203
+ // text style. This keeps their leading and coarse-pointer sizing identical
204
+ // to TextInput/TextArea without duplicating placeholder typography. The
205
+ // 16px floor is iOS-only: iOS Safari zooms the page when a focused
206
+ // control sits under 16px, and only iOS WebKit implements
207
+ // -webkit-touch-callout to key the coarse-pointer floor to it.
208
+ fontFamily: typographyVars['--font-family-body'],
209
+ fontSize: {
210
+ default: typeScaleVars['--text-body-size'],
211
+ '@media (pointer: coarse)': {
212
+ '@supports (-webkit-touch-callout: none)': `max(1rem, ${typeScaleVars['--text-body-size']})`,
213
+ },
214
+ },
215
+ lineHeight: typeScaleVars['--text-body-leading'],
216
+ },
217
+ statusIcon: {
218
+ position: 'absolute',
219
+ top: spacingVars['--spacing-2'],
220
+ insetInlineEnd: spacingVars['--spacing-2'],
221
+ zIndex: 1,
222
+ display: 'flex',
223
+ alignItems: 'center',
224
+ },
225
+ disabled: {
226
+ // Per #5335: `default`, not `not-allowed`. A disabled control already
227
+ // carries its own visual treatment, and `not-allowed` is unpaintable on
228
+ // the part of the library sealed behind `pointer-events: none`, so the
229
+ // library cannot promise it consistently.
230
+ cursor: {
231
+ default: 'default',
232
+ ':is(:disabled,[aria-disabled="true"])': 'default',
233
+ },
234
+ },
235
+ counter: {
236
+ display: 'flex',
237
+ justifyContent: 'flex-end',
238
+ marginTop: spacingVars['--spacing-1'],
239
+ fontFamily: typographyVars['--font-family-body'],
240
+ fontSize: typeScaleVars['--text-supporting-size'],
241
+ color: colorVars['--color-text-secondary'],
242
+ },
243
+ counterError: {
244
+ color: colorVars['--color-text-error'],
245
+ },
246
+ });
247
+
248
+ const dynamicStyles = stylex.create({
249
+ contentEditableMinHeight: (minHeight: SizeValue) => ({minHeight}),
250
+ });
251
+
252
+ const editorBodySizeStyles = stylex.create({
253
+ // Only `lg` adds block padding; `sm` and `md` take the base body spacing.
254
+ // An empty entry each would read as deliberate size-specific styling.
255
+ lg: {
256
+ paddingBlock: spacingVars['--spacing-2'],
257
+ },
258
+ });
259
+
260
+ /**
261
+ * Fraction of `maxLength` at which the character counter begins announcing
262
+ * remaining/over-limit characters to screen readers. Matches TextArea.
263
+ */
264
+ const COUNTER_WARNING_THRESHOLD = 0.8;
265
+
266
+ export type RichTextEditorStatusType = 'warning' | 'error' | 'success';
267
+
268
+ export type RichTextEditorSize = 'sm' | 'md' | 'lg';
269
+
270
+ export interface RichTextEditorStatus {
271
+ /** The type of status to display. */
272
+ type: RichTextEditorStatusType;
273
+ /** Optional message to display below the editor. */
274
+ message?: string;
275
+ }
276
+
277
+ /**
278
+ * Imperative handle exposed via `ref`. Lets callers focus, clear, and read the
279
+ * editor without wiring a custom plugin. Available after mount.
280
+ */
281
+ export interface RichTextEditorRef {
282
+ /**
283
+ * Move focus into the editor's editable surface. No-op when the editor is
284
+ * read-only or disabled.
285
+ */
286
+ focus: () => void;
287
+ /**
288
+ * Remove all content, resetting the editor to a single empty paragraph.
289
+ * No-op when the editor is read-only or disabled.
290
+ */
291
+ clear: () => void;
292
+ /** Read the current `EditorState`. Serialize with `.toJSON()` to persist. */
293
+ getEditorState: () => EditorState;
294
+ /**
295
+ * Serialize the current content to a Markdown string, using the same
296
+ * `transformers` the editor is configured with (so custom transformers
297
+ * layered in via the `transformers` prop are honored). Content imported
298
+ * with `markdownToEditorStateJSON` comes back as written: blocks nobody
299
+ * changed byte for byte, and only edited or added blocks in canonical
300
+ * Markdown.
301
+ */
302
+ getMarkdown: () => string;
303
+ /**
304
+ * Serialize the current content to an HTML string via
305
+ * `$generateHtmlFromNodes`. Requires a DOM (available in the browser and in
306
+ * jsdom-based tests). Useful for copy/paste, email, or non-Lexical consumers.
307
+ */
308
+ getHTML: () => string;
309
+ /**
310
+ * Access the underlying `LexicalEditor` instance for advanced use cases
311
+ * (custom commands, listeners, node transforms).
312
+ */
313
+ getEditor: () => LexicalEditor;
314
+ }
315
+
316
+ export interface RichTextEditorProps extends Omit<
317
+ BaseProps,
318
+ 'onChange' | 'defaultValue'
319
+ > {
320
+ /**
321
+ * Imperative handle for callers that manage the editor from outside. In
322
+ * React 19 `ref` is an ordinary prop, so it is declared here rather than
323
+ * supplied by a `forwardRef` wrapper.
324
+ */
325
+ ref?: Ref<RichTextEditorRef>;
326
+ /** Label text for the editor (always rendered for accessibility). */
327
+ label: string;
328
+ /**
329
+ * Whether to visually hide the label (still accessible to screen readers).
330
+ * @default false
331
+ */
332
+ isLabelHidden?: boolean;
333
+ /** Description text displayed between the label and editor. */
334
+ description?: string;
335
+ /**
336
+ * Whether the field is optional. Mutually exclusive with isRequired.
337
+ * @default false
338
+ */
339
+ isOptional?: boolean;
340
+ /**
341
+ * Whether the field is required. Mutually exclusive with isOptional.
342
+ * @default false
343
+ */
344
+ isRequired?: boolean;
345
+ /**
346
+ * Initial serialized editor state (a JSON string produced by
347
+ * `JSON.stringify(editorState.toJSON())`), used to seed the editor on mount.
348
+ * The editor is uncontrolled: this is read once.
349
+ */
350
+ defaultValue?: string;
351
+ /**
352
+ * Callback fired when the editor content changes. Receives the current
353
+ * `EditorState` and the `LexicalEditor` instance. Serialize with
354
+ * `editorState.toJSON()` for persistence.
355
+ */
356
+ onChange?: (editorState: EditorState, editor: LexicalEditor) => void;
357
+ /** Placeholder text shown when the editor is empty. */
358
+ placeholder?: string;
359
+ /**
360
+ * Whether the editor is read-only (non-editable). The content stays at full
361
+ * opacity, in the tab order, and announced as read-only, so people can still
362
+ * reach, read, and copy it. Takes effect when changed after mount.
363
+ * @default false
364
+ */
365
+ isReadOnly?: boolean;
366
+ /**
367
+ * Whether the editor is disabled: non-editable, dimmed, out of the tab
368
+ * order, and announced as disabled. Its links do not navigate and are not
369
+ * tab stops. Wins when `isReadOnly` is also set.
370
+ * Takes effect when changed after mount.
371
+ * @default false
372
+ */
373
+ isDisabled?: boolean;
374
+ /**
375
+ * Status indicator. When set, displays a colored border. If message is
376
+ * provided, displays a message box below the editor.
377
+ */
378
+ status?: RichTextEditorStatus;
379
+ /**
380
+ * How the status message is placed relative to the editor.
381
+ * - 'attached': message overlaps directly below the editor and the status
382
+ * icon remains inside the editing surface
383
+ * - 'detached': message floats below with its own leading status icon
384
+ * - 'tooltip': no message box; the in-editor status icon becomes a focusable
385
+ * info-tip button that reveals the message
386
+ * @default 'attached'
387
+ */
388
+ statusVariant?: FieldStatusVariant | 'tooltip';
389
+ /**
390
+ * Width of the field. Numbers are treated as pixels, strings are used as-is
391
+ * (e.g. `'100%'`).
392
+ */
393
+ width?: SizeValue;
394
+ /**
395
+ * Minimum height of the editable content surface. Numbers are treated as
396
+ * pixels, strings are used as CSS lengths.
397
+ * @default '4.5rem'
398
+ */
399
+ minHeight?: SizeValue;
400
+ /** Tooltip text to display in an info icon at the end of the label. */
401
+ labelTooltip?: string;
402
+ /**
403
+ * The size of the editor, affecting internal padding.
404
+ * @default 'md'
405
+ */
406
+ size?: RichTextEditorSize;
407
+ /**
408
+ * Additional Lexical nodes to register beyond the default OSS set
409
+ * (Heading, Quote, List, Link, Code). Use this extension point to plug in
410
+ * custom nodes (e.g. mentions, images) without forking the editor.
411
+ */
412
+ nodes?: ReadonlyArray<Klass<LexicalNode>>;
413
+ /**
414
+ * Toolbar content rendered edge-to-edge at the top of the field, before the
415
+ * padded editing surface. Pass `<RichTextEditorToolbar />` here so the
416
+ * toolbar stays inside the Lexical composer while retaining correct visual
417
+ * and keyboard order.
418
+ */
419
+ toolbar?: ReactNode;
420
+ /**
421
+ * Additional Lexical plugins to render inside the composer. Use this to
422
+ * compose extra behaviour (mentions, autolink, etc.) on top of the base
423
+ * editor. Plugins receive the editor via `useLexicalComposerContext()`.
424
+ */
425
+ plugins?: ReactNode;
426
+ /**
427
+ * Whether to enable Markdown shortcut typing (e.g. `# ` for a heading,
428
+ * `- ` for a list). Uses the `transformers` prop (defaults to the standard
429
+ * `@lexical/markdown` transformers plus GFM tables). At the start of a list
430
+ * item, `- `, `1. `, or `[ ] ` switches its list between bullets, numbers,
431
+ * and tasks, and `# ` or `> ` takes a top-level item out of its list as a
432
+ * heading or a quote.
433
+ * @default true
434
+ */
435
+ hasMarkdownShortcuts?: boolean;
436
+ /**
437
+ * Markdown transformers — the single source of truth for markdown behaviour.
438
+ * Defaults to the standard `@lexical/markdown` `TRANSFORMERS` plus GFM
439
+ * tables. A custom array replaces the default, tables included.
440
+ *
441
+ * The same array drives all three markdown operations in Lexical (see the
442
+ * lexical-playground reference, where one `PLAYGROUND_TRANSFORMERS` array
443
+ * feeds each):
444
+ * - shortcut typing — `registerMarkdownShortcuts` (wired here today)
445
+ * - markdown -> state — `$convertFromMarkdownString` (future import API)
446
+ * - state -> markdown — `$convertToMarkdownString` (future `getMarkdown`)
447
+ *
448
+ * Pass a custom array to support additional node types (e.g. custom
449
+ * transformers layered in via the `nodes` extension point) consistently
450
+ * across all three. Shortcut typing is only applied when
451
+ * `hasMarkdownShortcuts` is true; the array is still the intended input for
452
+ * the serialization APIs added in later phases.
453
+ */
454
+ transformers?: ReadonlyArray<Transformer>;
455
+ /**
456
+ * Markdown plugins whose nodes this surface draws, each adopted with
457
+ * `createRichTextExtension` (spec:AST-064). A plugin node renders exactly as
458
+ * core `Markdown` renders it, and one whose plugin is not given here shows
459
+ * its source. Pass the extensions the content was converted with. Create
460
+ * them in a client module: they hold the plugins' functions, so they are not
461
+ * serializable props.
462
+ */
463
+ markdownExtensions?: ReadonlyArray<RichTextMarkdownExtension>;
464
+ /**
465
+ * CodeBlock language values a fence may name, each imported on its own,
466
+ * such as `graphql` from `@blue-patterns/core/CodeBlock/languages/graphql`,
467
+ * or made with `createCodeLanguage`. Pass the same list as `Markdown`'s
468
+ * `codeLanguages`, so reading and editing paint and label the same
469
+ * languages (spec:AST-061).
470
+ */
471
+ codeLanguages?: ReadonlyArray<CodeLanguage>;
472
+ /** Whether to automatically focus the editor on mount. @default false */
473
+ hasAutoFocus?: boolean;
474
+ /**
475
+ * Screen-reader hint describing how to move focus out of the editor, since
476
+ * Tab is bound to indentation (press Escape, then Tab). Rendered visually
477
+ * hidden and referenced from the editor's `aria-describedby`. Override it
478
+ * to change the text, or pass an empty string to omit the hint entirely
479
+ * (e.g. when the host app provides its own instructions).
480
+ * @default 'Press Escape then Tab to move focus out of the editor.', translated for the active locale
481
+ */
482
+ tabEscapeHint?: string;
483
+ /**
484
+ * Maximum number of characters. When set, a character counter
485
+ * (current/max) is displayed below the editor. Like TextArea, this does
486
+ * NOT enforce the limit natively — the counter shows error styling when the
487
+ * plain-text length exceeds the limit. Count is the editor's plain-text
488
+ * content length.
489
+ */
490
+ maxLength?: number;
491
+ /**
492
+ * The Lexical composer namespace, used for editor identity.
493
+ * @default 'astryx-editor'
494
+ */
495
+ namespace?: string;
496
+ }
497
+
498
+ /**
499
+ * A WYSIWYG rich-text editor built on Lexical, styled with Astryx design
500
+ * tokens. Ships from `@blue-patterns/richtext`. `lexical` and `@lexical/*` are
501
+ * optional peer dependencies — install them to use this component.
502
+ *
503
+ * The editor is intentionally minimal and extensible: pass `toolbar`, `nodes`,
504
+ * and `plugins` to layer richer behaviour (formatting, mentions, hover cards)
505
+ * on top without forking.
506
+ *
507
+ * The forwarded `RichTextEditorRef` exposes imperative `focus()` and `clear()`
508
+ * methods for callers that manage the editor from outside.
509
+ *
510
+ * @example
511
+ * ```
512
+ * import {RichTextEditor, type RichTextEditorRef} from '@blue-patterns/richtext';
513
+ * const ref = useRef<RichTextEditorRef>(null);
514
+ * <RichTextEditor
515
+ * ref={ref}
516
+ * label="Notes"
517
+ * placeholder="Write something..."
518
+ * onChange={state => save(JSON.stringify(state.toJSON()))}
519
+ * />
520
+ * ```
521
+ */
522
+ export const RichTextEditor = function RichTextEditor({
523
+ label,
524
+ isLabelHidden = false,
525
+ description,
526
+ isOptional = false,
527
+ isRequired = false,
528
+ defaultValue,
529
+ onChange,
530
+ placeholder,
531
+ isReadOnly = false,
532
+ isDisabled = false,
533
+ status,
534
+ statusVariant = 'attached',
535
+ width,
536
+ minHeight = '4.5rem',
537
+ labelTooltip,
538
+ size: sizeProp,
539
+ nodes,
540
+ toolbar,
541
+ plugins,
542
+ hasMarkdownShortcuts = true,
543
+ transformers = DEFAULT_TRANSFORMERS,
544
+ markdownExtensions,
545
+ codeLanguages,
546
+ hasAutoFocus = false,
547
+ tabEscapeHint: tabEscapeHintFromProps,
548
+ maxLength,
549
+ namespace = 'astryx-editor',
550
+ xstyle,
551
+ className,
552
+ style,
553
+ ref,
554
+ ...rest
555
+ }: RichTextEditorProps) {
556
+ const size = useSize(sizeProp, 'md');
557
+ const t = useTranslator();
558
+ const tabEscapeHint =
559
+ tabEscapeHintFromProps ?? t('@astryx.richTextEditor.tabEscapeHint');
560
+ const inputID = useId();
561
+ const labelID = useId();
562
+ const descriptionID = useId();
563
+ const statusMessageID = useId();
564
+ const placeholderID = useId();
565
+ const counterID = useId();
566
+ const tabEscapeHintID = useId();
567
+
568
+ // Plain-text character count, tracked from inside the composer via
569
+ // CharCountPlugin. Only used when maxLength is set.
570
+ const [charCount, setCharCount] = useState(0);
571
+
572
+ // Theme is stable per render; build once.
573
+ const themeRef = useRef<EditorThemeClasses | null>(null);
574
+ if (themeRef.current === null) {
575
+ themeRef.current = sharedEditorTheme();
576
+ }
577
+
578
+ const editable = !isReadOnly && !isDisabled;
579
+
580
+ // Stabilize the transformers array so MarkdownShortcutPlugin doesn't
581
+ // re-register on every render. `[...transformers]` would allocate a new
582
+ // array each time; memoize on the prop identity instead. (React Compiler
583
+ // isn't running the transform in this repo, so this isn't auto-memoized.)
584
+ const markdownTransformers = useMemo(() => [...transformers], [transformers]);
585
+
586
+ // The extension replaces LexicalComposer's `initialConfig`: it carries the
587
+ // same editor configuration (namespace, theme, nodes, editability, initial
588
+ // state) in the shape LexicalBuilder consumes.
589
+ //
590
+ // LexicalExtensionComposer re-creates the editor whenever the extension's
591
+ // identity changes, whereas LexicalComposer read `initialConfig` exactly once
592
+ // on mount. Build it on first render and keep it, so the editor's lifetime —
593
+ // and the content it holds — is unaffected by later renders (a consumer
594
+ // passing an inline `nodes={[...]}` array would otherwise blow away the
595
+ // editor's content on every render).
596
+ const extensionRef = useRef<AnyLexicalExtension | null>(null);
597
+ if (extensionRef.current === null) {
598
+ extensionRef.current = defineExtension({
599
+ name: '@blue-patterns/richtext/RichTextEditor',
600
+ namespace,
601
+ theme: themeRef.current,
602
+ editable,
603
+ nodes: nodes ? [...DEFAULT_NODES, ...nodes] : [...DEFAULT_NODES],
604
+ // Horizontal rules select on click and show their selection; struck
605
+ // text is a deletion.
606
+ dependencies: [HorizontalRuleExtension, TextSemanticsExtension],
607
+ // `undefined` (not `null`) leaves Lexical's default initializer in place,
608
+ // which seeds the empty document with one paragraph — what
609
+ // LexicalComposer did when no `editorState` was given. `null` would mean
610
+ // "start from a genuinely empty root".
611
+ $initialEditorState: defaultValue ?? undefined,
612
+ onError(error: Error) {
613
+ // Surface errors to the host app rather than swallowing them.
614
+ throw error;
615
+ },
616
+ });
617
+ }
618
+
619
+ const hasTabEscapeHint = editable && tabEscapeHint !== '';
620
+ const hasToolbar = isRenderable(toolbar);
621
+
622
+ const {statusIcon, describedBy: statusTooltipDescribedBy} =
623
+ useInputStatusIcon({
624
+ status,
625
+ statusVariant,
626
+ });
627
+
628
+ const ariaDescribedBy =
629
+ [
630
+ description ? descriptionID : null,
631
+ statusVariant !== 'tooltip' && status?.message ? statusMessageID : null,
632
+ // Tooltip status renders no FieldStatus message box. Reference the
633
+ // tooltip layer so assistive technology still receives the message.
634
+ statusTooltipDescribedBy,
635
+ placeholder ? placeholderID : null,
636
+ maxLength != null ? counterID : null,
637
+ hasTabEscapeHint ? tabEscapeHintID : null,
638
+ ]
639
+ .filter(Boolean)
640
+ .join(' ') || undefined;
641
+
642
+ return (
643
+ <Field
644
+ label={label}
645
+ isLabelHidden={isLabelHidden}
646
+ description={description}
647
+ inputID={inputID}
648
+ labelID={labelID}
649
+ descriptionID={description ? descriptionID : undefined}
650
+ isOptional={isOptional}
651
+ isRequired={isRequired}
652
+ isDisabled={isDisabled}
653
+ status={
654
+ status
655
+ ? {
656
+ type: status.type,
657
+ message: status.message,
658
+ messageID: status.message ? statusMessageID : undefined,
659
+ }
660
+ : undefined
661
+ }
662
+ statusVariant={statusVariant}
663
+ labelTooltip={labelTooltip}
664
+ width={width}>
665
+ <div
666
+ {...mergeProps(
667
+ themeProps('rich-text-editor', {
668
+ size,
669
+ status: status?.type ?? null,
670
+ }),
671
+ stylex.props(
672
+ inputWrapperStyles.base,
673
+ inputWrapperStyles.textField,
674
+ styles.wrapper,
675
+ // Only disabled is dimmed, matching TextArea: a read-only editor
676
+ // keeps full-opacity text and stays keyboard-reachable.
677
+ isDisabled && inputWrapperStyles.disabled,
678
+ isDisabled && styles.disabled,
679
+ status && inputStatusBorderStyles[status.type],
680
+ status &&
681
+ !isDisabled &&
682
+ !isReadOnly &&
683
+ inputStatusHoverShadowStyles[status.type],
684
+ status && inputStatusFocusWithinStyles[status.type],
685
+ xstyle,
686
+ ),
687
+ className,
688
+ style,
689
+ )}>
690
+ <LexicalExtensionComposer
691
+ extension={extensionRef.current}
692
+ contentEditable={null}>
693
+ {hasToolbar ? toolbar : null}
694
+ <div
695
+ {...stylex.props(
696
+ styles.editorBody,
697
+ hasToolbar && styles.editorBodyWithToolbar,
698
+ !!statusIcon && styles.editorBodyWithStatus,
699
+ size === 'lg' && editorBodySizeStyles.lg,
700
+ )}>
701
+ <div {...stylex.props(styles.editorRoot)} dir={rest.dir}>
702
+ <RichTextPlugin
703
+ contentEditable={
704
+ <EditorContentEditable
705
+ id={inputID}
706
+ ariaLabel={isLabelHidden ? label : undefined}
707
+ ariaLabelledBy={isLabelHidden ? undefined : labelID}
708
+ ariaDescribedBy={ariaDescribedBy}
709
+ ariaRequired={isRequired && !isOptional}
710
+ ariaInvalid={status?.type === 'error'}
711
+ isReadOnly={isReadOnly}
712
+ isDisabled={isDisabled}
713
+ placeholderText={placeholder}
714
+ placeholderID={placeholderID}
715
+ minHeight={minHeight}
716
+ rest={rest}
717
+ />
718
+ }
719
+ placeholder={null}
720
+ ErrorBoundary={LexicalErrorBoundary}
721
+ />
722
+ <HistoryPlugin />
723
+ <ListPlugin />
724
+ <TaskCheckboxPlugin isReadOnly={isReadOnly || isDisabled} />
725
+ <LinkPlugin />
726
+ <DisabledLinkGuardPlugin isDisabled={isDisabled} />
727
+ {/* Tab keeps its editor meaning inside a table (indent, and
728
+ Escape then Tab to leave the editor); arrow keys move
729
+ between cells. GFM tables have no merged cells or cell
730
+ colors, and a wide table scrolls inside its own wrapper. */}
731
+ <TablePlugin
732
+ hasCellMerge={false}
733
+ hasCellBackgroundColor={false}
734
+ hasTabHandler={false}
735
+ hasHorizontalScroll
736
+ />
737
+ <TableColumnFloorPlugin />
738
+ <TablePastePlugin />
739
+ <CodeBlockHeaderPlugin codeLanguages={codeLanguages} />
740
+ <CodeSyntaxPlugin codeLanguages={codeLanguages} />
741
+ {/* Backspace at the very start of a code block makes it a
742
+ paragraph, instead of merging it into the block above. */}
743
+ <CodeBackspacePlugin />
744
+ <CodePastePlugin />
745
+ {markdownExtensions != null && markdownExtensions.length > 0 ? (
746
+ <MarkdownExtensionsPlugin
747
+ extensions={markdownExtensions}
748
+ transformers={markdownTransformers}
749
+ />
750
+ ) : null}
751
+ {/* In a code block, Tab indents the lines the selection
752
+ touches instead of inserting a tab character. */}
753
+ <CodeTabPlugin />
754
+ <TabIndentationPlugin />
755
+ <TabFocusEscapePlugin />
756
+ <SelectAllScopePlugin />
757
+ <ImageSourceCaretPlugin />
758
+ {hasMarkdownShortcuts && (
759
+ <MarkdownShortcutPlugin transformers={markdownTransformers} />
760
+ )}
761
+ {hasMarkdownShortcuts && (
762
+ <ListItemShortcutsPlugin transformers={markdownTransformers} />
763
+ )}
764
+ {hasAutoFocus && <AutoFocusOnMount />}
765
+ {onChange && (
766
+ <OnChangePlugin
767
+ onChange={onChange}
768
+ ignoreHistoryMergeTagChange
769
+ ignoreSelectionChange
770
+ />
771
+ )}
772
+ {plugins}
773
+ <EditorRefBridge
774
+ editorRef={ref}
775
+ editable={editable}
776
+ transformers={markdownTransformers}
777
+ />
778
+ {maxLength != null && (
779
+ <CharCountPlugin onCountChange={setCharCount} />
780
+ )}
781
+ </div>
782
+ {statusIcon && (
783
+ <div {...stylex.props(styles.statusIcon)}>{statusIcon}</div>
784
+ )}
785
+ </div>
786
+ </LexicalExtensionComposer>
787
+ {hasTabEscapeHint && (
788
+ <VisuallyHidden id={tabEscapeHintID}>{tabEscapeHint}</VisuallyHidden>
789
+ )}
790
+ </div>
791
+ {/* eslint-disable-next-line @astryx/no-nullish-jsx-guard -- `maxLength` is a number; 0 still shows its counter */}
792
+ {maxLength != null && (
793
+ <div
794
+ id={counterID}
795
+ {...stylex.props(
796
+ styles.counter,
797
+ charCount > maxLength && styles.counterError,
798
+ )}>
799
+ {charCount}/{maxLength}
800
+ <VisuallyHidden aria-live="polite">
801
+ {charCount >= maxLength * COUNTER_WARNING_THRESHOLD
802
+ ? charCount > maxLength
803
+ ? t('@astryx.textArea.charactersOverLimit', {
804
+ count: charCount - maxLength,
805
+ })
806
+ : t('@astryx.textArea.charactersRemaining', {
807
+ count: maxLength - charCount,
808
+ })
809
+ : ''}
810
+ </VisuallyHidden>
811
+ </div>
812
+ )}
813
+ </Field>
814
+ );
815
+ };
816
+
817
+ // Keeps the editor inside `@astryx/require-baseprops-passthrough`, which only
818
+ // checks components that assign a top-level `displayName`. Without it, a
819
+ // dropped `className`, `style` or `xstyle` on the root stops warning.
820
+ RichTextEditor.displayName = 'RichTextEditor';
821
+
822
+ /**
823
+ * Keys that must not cancel an armed Tab escape: Escape (arming again),
824
+ * Tab (the escape itself) and bare modifier presses — the Shift keydown that
825
+ * precedes Shift+Tab must not disarm, or Escape → Shift+Tab could never
826
+ * escape backwards.
827
+ */
828
+ const ESCAPE_REARM_EXEMPT_KEYS = new Set([
829
+ 'Escape',
830
+ 'Tab',
831
+ 'Shift',
832
+ 'Control',
833
+ 'Alt',
834
+ 'Meta',
835
+ ]);
836
+
837
+ /**
838
+ * WCAG 2.1.2 (No Keyboard Trap) escape for TabIndentationPlugin.
839
+ *
840
+ * TabIndentationPlugin rebinds Tab to indent/outdent, which would otherwise
841
+ * trap keyboard focus inside the editor. This plugin restores an exit: after
842
+ * Escape is pressed, the next Tab (or Shift+Tab) performs native focus
843
+ * movement instead of indenting; pressing any other non-modifier key — or
844
+ * leaving the editor — re-arms indentation.
845
+ *
846
+ * Mechanics: TabIndentationPlugin handles KEY_TAB_COMMAND at
847
+ * COMMAND_PRIORITY_EDITOR (0), the lowest priority, and Lexical runs command
848
+ * listeners from the highest priority down, stopping at the first one that
849
+ * returns true. Registering at COMMAND_PRIORITY_LOW (1) therefore runs first;
850
+ * when the escape is armed we return true WITHOUT calling
851
+ * `event.preventDefault()`, so the indentation handlers (TabIndentationPlugin,
852
+ * and CodeTabPlugin at the front of the same priority) never see the event
853
+ * and the browser performs its default Tab focus navigation.
854
+ */
855
+ function TabFocusEscapePlugin(): null {
856
+ const [editor] = useLexicalComposerContext();
857
+ useEffect(() => {
858
+ let escapeArmed = false;
859
+ return mergeRegister(
860
+ editor.registerCommand<KeyboardEvent>(
861
+ KEY_ESCAPE_COMMAND,
862
+ () => {
863
+ escapeArmed = true;
864
+ // Consumed: this supersedes @lexical/rich-text's default Escape
865
+ // handler (COMMAND_PRIORITY_EDITOR), which blurs the editor and
866
+ // drops focus on the document body — disorienting, and it would
867
+ // immediately disarm via BLUR_COMMAND below. Consumer plugins that
868
+ // handle Escape (e.g. to close a popover) register at a higher
869
+ // priority and still run first.
870
+ return true;
871
+ },
872
+ COMMAND_PRIORITY_LOW,
873
+ ),
874
+ editor.registerCommand<KeyboardEvent>(
875
+ KEY_TAB_COMMAND,
876
+ () => {
877
+ if (!escapeArmed) {
878
+ return false;
879
+ }
880
+ escapeArmed = false;
881
+ // Consume the command (blocks indentation) but leave the event's
882
+ // default alone so focus moves natively.
883
+ return true;
884
+ },
885
+ COMMAND_PRIORITY_LOW,
886
+ ),
887
+ editor.registerCommand<KeyboardEvent>(
888
+ KEY_DOWN_COMMAND,
889
+ event => {
890
+ if (escapeArmed && !ESCAPE_REARM_EXEMPT_KEYS.has(event.key)) {
891
+ escapeArmed = false;
892
+ }
893
+ return false;
894
+ },
895
+ COMMAND_PRIORITY_LOW,
896
+ ),
897
+ editor.registerCommand(
898
+ BLUR_COMMAND,
899
+ () => {
900
+ escapeArmed = false;
901
+ return false;
902
+ },
903
+ COMMAND_PRIORITY_LOW,
904
+ ),
905
+ );
906
+ }, [editor]);
907
+ return null;
908
+ }
909
+
910
+ /**
911
+ * Focuses the editor on mount. Split into its own plugin so it runs inside the
912
+ * composer context.
913
+ */
914
+ function AutoFocusOnMount(): null {
915
+ const [editor] = useLexicalComposerContext();
916
+ useEffect(() => {
917
+ editor.focus();
918
+ }, [editor]);
919
+ return null;
920
+ }
921
+
922
+ /**
923
+ * Wires the imperative `RichTextEditorRef` handle. Split into its own plugin so
924
+ * it runs inside the composer context and can reach the `LexicalEditor` via
925
+ * `useLexicalComposerContext()`. Renders nothing.
926
+ *
927
+ * `focus()` and `clear()` are gated on `editable` so a read-only or disabled
928
+ * editor cannot be mutated or focused through the imperative handle — matching
929
+ * the behaviour of the editable surface itself.
930
+ */
931
+ function EditorRefBridge({
932
+ editorRef,
933
+ editable,
934
+ transformers,
935
+ }: {
936
+ // Optional, matching the public `ref` prop: with `forwardRef` gone, React
937
+ // no longer supplies `null` when the caller passes nothing.
938
+ editorRef: Ref<RichTextEditorRef> | undefined;
939
+ editable: boolean;
940
+ transformers: Array<Transformer>;
941
+ }): null {
942
+ const [editor] = useLexicalComposerContext();
943
+
944
+ // The extension applies `editable` once, when the composer builds the
945
+ // editor, so a later isReadOnly/isDisabled change would leave
946
+ // contenteditable at its mount value while the wrapper styling and ARIA
947
+ // follow the props. Sync the editor on a prop change only: the mount value
948
+ // is already applied, and this bridge renders after `plugins`, so
949
+ // re-asserting it on mount would undo a plugin that set editability during
950
+ // its own mount.
951
+ const syncedEditableRef = useRef(editable);
952
+ useEffect(() => {
953
+ if (syncedEditableRef.current === editable) {
954
+ return;
955
+ }
956
+ syncedEditableRef.current = editable;
957
+ editor.setEditable(editable);
958
+ }, [editor, editable]);
959
+
960
+ useImperativeHandle(
961
+ editorRef,
962
+ () => ({
963
+ focus: () => {
964
+ if (!editable) {
965
+ return;
966
+ }
967
+ editor.focus();
968
+ },
969
+ clear: () => {
970
+ if (!editable) {
971
+ return;
972
+ }
973
+ // Reset to a single empty paragraph via a fresh EditorState. Uses the
974
+ // editor instance's own parse/set methods so we avoid a top-level
975
+ // `lexical` value import (see EMPTY_EDITOR_STATE_JSON). This still
976
+ // notifies update listeners, so `onChange` fires.
977
+ editor.setEditorState(editor.parseEditorState(EMPTY_EDITOR_STATE_JSON));
978
+ },
979
+ getEditorState: () => editor.getEditorState(),
980
+ getMarkdown: () =>
981
+ // Untouched blocks export exactly as imported and changed blocks in
982
+ // canonical form (spec:AST-062). Honors the same transformers the
983
+ // editor uses for shortcuts, so custom transformers round-trip. Reads
984
+ // the state without changing it.
985
+ editor
986
+ .getEditorState()
987
+ .read(() => $exportMarkdownKeepingSource(transformers)),
988
+ getHTML: () =>
989
+ // $generateHtmlFromNodes serializes the whole document (null selection)
990
+ // to HTML; must run in a read context and requires a DOM.
991
+ // `@lexical/html` is a subpackage (built dist) — safe.
992
+ editor
993
+ .getEditorState()
994
+ .read(() => $generateHtmlFromNodes(editor, null)),
995
+ getEditor: () => editor,
996
+ }),
997
+ [editor, editable, transformers],
998
+ );
999
+ return null;
1000
+ }
1001
+
1002
+ /**
1003
+ * Tracks the editor's plain-text length and reports it to the host component.
1004
+ * Split into its own plugin so it runs inside the composer context and can
1005
+ * read the editor state via `useLexicalComposerContext()`. Renders nothing.
1006
+ */
1007
+ function CharCountPlugin({
1008
+ onCountChange,
1009
+ }: {
1010
+ onCountChange: (count: number) => void;
1011
+ }): null {
1012
+ const [editor] = useLexicalComposerContext();
1013
+ useEffect(() => {
1014
+ // Report the initial count (e.g. seeded via defaultValue) from the mounted
1015
+ // root element's text, then track changes via registerTextContentListener,
1016
+ // which hands us the plain-text content directly.
1017
+ //
1018
+ // We deliberately avoid importing `$getRoot` from the top-level `lexical`
1019
+ // package: that is a *runtime value* import, and in the sandbox's Next
1020
+ // build it forces Babel to transpile lexical's raw `src/*.ts` (which uses
1021
+ // `declare` class fields) and fails. Both APIs used here are methods on the
1022
+ // editor instance, so no top-level `lexical` value import is needed.
1023
+ onCountChange(editor.getRootElement()?.textContent?.length ?? 0);
1024
+ return editor.registerTextContentListener(textContent => {
1025
+ onCountChange(textContent.length);
1026
+ });
1027
+ }, [editor, onCountChange]);
1028
+ return null;
1029
+ }
1030
+
1031
+ /**
1032
+ * Renders the Lexical ContentEditable. Split out so the placeholder
1033
+ * discriminated union (`placeholder` + `aria-placeholder` present together, or
1034
+ * neither) is satisfied by two concrete branches rather than a spread.
1035
+ */
1036
+ function EditorContentEditable({
1037
+ id,
1038
+ ariaLabel,
1039
+ ariaLabelledBy,
1040
+ ariaDescribedBy,
1041
+ ariaRequired,
1042
+ ariaInvalid,
1043
+ isReadOnly,
1044
+ isDisabled,
1045
+ placeholderText,
1046
+ placeholderID,
1047
+ minHeight,
1048
+ rest,
1049
+ }: {
1050
+ id: string;
1051
+ ariaLabel?: string;
1052
+ ariaLabelledBy?: string;
1053
+ ariaDescribedBy?: string;
1054
+ ariaRequired: boolean;
1055
+ ariaInvalid: boolean;
1056
+ isReadOnly: boolean;
1057
+ isDisabled: boolean;
1058
+ placeholderText?: string;
1059
+ placeholderID: string;
1060
+ minHeight: SizeValue;
1061
+ rest: Record<string, unknown>;
1062
+ }) {
1063
+ const shared = {
1064
+ id,
1065
+ role: 'textbox' as const,
1066
+ 'aria-multiline': 'true' as const,
1067
+ 'aria-label': ariaLabel,
1068
+ 'aria-labelledby': ariaLabelledBy,
1069
+ 'aria-describedby': ariaDescribedBy,
1070
+ 'aria-required': ariaRequired ? ('true' as const) : undefined,
1071
+ 'aria-invalid': ariaInvalid ? ('true' as const) : undefined,
1072
+ // Lexical announces every non-editable surface as aria-readonly and
1073
+ // leaves it out of the tab order. Split the two states: read-only stays
1074
+ // reachable and is announced read-only; disabled is announced disabled
1075
+ // instead. These keys land after Lexical's computed attributes, so the
1076
+ // disabled branch's explicit `undefined` removes the wrong read-only
1077
+ // announcement. With neither prop set no keys are passed, so a plugin
1078
+ // that drives editor.setEditable keeps Lexical's own aria-readonly.
1079
+ ...(isDisabled
1080
+ ? {'aria-disabled': 'true' as const, 'aria-readonly': undefined}
1081
+ : isReadOnly
1082
+ ? {tabIndex: 0, 'aria-readonly': 'true' as const}
1083
+ : null),
1084
+ ...stylex.props(
1085
+ styles.contentEditable,
1086
+ dynamicStyles.contentEditableMinHeight(minHeight),
1087
+ ),
1088
+ ...rest,
1089
+ // Lexical reconciles this DOM, so Layer keeps corrective portals out.
1090
+ ...rendererManagedRootProps,
1091
+ };
1092
+ if (placeholderText) {
1093
+ return (
1094
+ <ContentEditable
1095
+ {...shared}
1096
+ aria-placeholder={placeholderText}
1097
+ placeholder={() => (
1098
+ <div
1099
+ id={placeholderID}
1100
+ aria-hidden="true"
1101
+ {...stylex.props(styles.placeholder)}>
1102
+ {placeholderText}
1103
+ </div>
1104
+ )}
1105
+ />
1106
+ );
1107
+ }
1108
+ return <ContentEditable {...shared} />;
1109
+ }