@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.
- package/CHANGELOG.md +275 -0
- package/LICENSE +22 -0
- package/README.md +88 -2
- package/astryx.integration.mjs +11 -0
- package/blocks/RichTextEditorShowcase.template.mjs +13 -0
- package/blocks/RichTextEditorShowcase.tsx +19 -0
- package/dist/RichTextEditor.d.ts +282 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +657 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
- package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
- package/dist/RichTextEditorToolbar.d.ts +186 -0
- package/dist/RichTextEditorToolbar.d.ts.map +1 -0
- package/dist/RichTextEditorToolbar.js +688 -0
- package/dist/RichTextView.d.ts +92 -0
- package/dist/RichTextView.d.ts.map +1 -0
- package/dist/RichTextView.js +267 -0
- package/dist/codeLanguages.d.ts +14 -0
- package/dist/codeLanguages.d.ts.map +1 -0
- package/dist/codeLanguages.js +34 -0
- package/dist/codeLineTokens.d.ts +53 -0
- package/dist/codeLineTokens.d.ts.map +1 -0
- package/dist/codeLineTokens.js +188 -0
- package/dist/editorNodes.d.ts +8 -0
- package/dist/editorNodes.d.ts.map +1 -0
- package/dist/editorNodes.js +30 -0
- package/dist/editorTheme.d.ts +7 -0
- package/dist/editorTheme.d.ts.map +1 -0
- package/dist/editorTheme.js +328 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
- package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
- package/dist/internal/CodeBackspacePlugin.js +70 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
- package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
- package/dist/internal/CodePastePlugin.d.ts +5 -0
- package/dist/internal/CodePastePlugin.d.ts.map +1 -0
- package/dist/internal/CodePastePlugin.js +104 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
- package/dist/internal/CodeSyntaxPlugin.js +397 -0
- package/dist/internal/CodeTabPlugin.d.ts +2 -0
- package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
- package/dist/internal/CodeTabPlugin.js +256 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
- package/dist/internal/ImageSourceCaretPlugin.js +105 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
- package/dist/internal/ListItemShortcutsPlugin.js +314 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
- package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
- package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
- package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
- package/dist/internal/SelectAllScopePlugin.js +67 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
- package/dist/internal/TableColumnFloorPlugin.js +84 -0
- package/dist/internal/TablePastePlugin.d.ts +9 -0
- package/dist/internal/TablePastePlugin.d.ts.map +1 -0
- package/dist/internal/TablePastePlugin.js +183 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
- package/dist/internal/TableScrollRegionPlugin.js +95 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
- package/dist/internal/TaskCheckboxPlugin.js +193 -0
- package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
- package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
- package/dist/internal/ToolbarLinkPopover.js +292 -0
- package/dist/linkEditing.d.ts +6 -0
- package/dist/linkEditing.d.ts.map +1 -0
- package/dist/linkEditing.js +20 -0
- package/dist/linkForm.d.ts +28 -0
- package/dist/linkForm.d.ts.map +1 -0
- package/dist/linkForm.js +137 -0
- package/dist/linkUtils.d.ts +27 -0
- package/dist/linkUtils.d.ts.map +1 -0
- package/dist/linkUtils.js +101 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +17 -0
- package/dist/markdownCharacterReferences.d.ts +135 -0
- package/dist/markdownCharacterReferences.d.ts.map +1 -0
- package/dist/markdownCharacterReferences.js +1127 -0
- package/dist/markdownCodeFence.d.ts +4 -0
- package/dist/markdownCodeFence.d.ts.map +1 -0
- package/dist/markdownCodeFence.js +167 -0
- package/dist/markdownExtensionNode.d.ts +88 -0
- package/dist/markdownExtensionNode.d.ts.map +1 -0
- package/dist/markdownExtensionNode.js +248 -0
- package/dist/markdownExtensions.d.ts +115 -0
- package/dist/markdownExtensions.d.ts.map +1 -0
- package/dist/markdownExtensions.js +392 -0
- package/dist/markdownHardLineBreak.d.ts +25 -0
- package/dist/markdownHardLineBreak.d.ts.map +1 -0
- package/dist/markdownHardLineBreak.js +59 -0
- package/dist/markdownHeading.d.ts +20 -0
- package/dist/markdownHeading.d.ts.map +1 -0
- package/dist/markdownHeading.js +30 -0
- package/dist/markdownLink.d.ts +5 -0
- package/dist/markdownLink.d.ts.map +1 -0
- package/dist/markdownLink.js +80 -0
- package/dist/markdownListExport.d.ts +3 -0
- package/dist/markdownListExport.d.ts.map +1 -0
- package/dist/markdownListExport.js +68 -0
- package/dist/markdownListIndentation.d.ts +8 -0
- package/dist/markdownListIndentation.d.ts.map +1 -0
- package/dist/markdownListIndentation.js +101 -0
- package/dist/markdownOrderedList.d.ts +3 -0
- package/dist/markdownOrderedList.d.ts.map +1 -0
- package/dist/markdownOrderedList.js +51 -0
- package/dist/markdownQuote.d.ts +22 -0
- package/dist/markdownQuote.d.ts.map +1 -0
- package/dist/markdownQuote.js +32 -0
- package/dist/markdownSerializers.d.ts +49 -0
- package/dist/markdownSerializers.d.ts.map +1 -0
- package/dist/markdownSerializers.js +83 -0
- package/dist/markdownSource.d.ts +71 -0
- package/dist/markdownSource.d.ts.map +1 -0
- package/dist/markdownSource.js +804 -0
- package/dist/markdownSpaceRuns.d.ts +24 -0
- package/dist/markdownSpaceRuns.d.ts.map +1 -0
- package/dist/markdownSpaceRuns.js +147 -0
- package/dist/markdownTable.d.ts +35 -0
- package/dist/markdownTable.d.ts.map +1 -0
- package/dist/markdownTable.js +227 -0
- package/dist/markdownTaskList.d.ts +48 -0
- package/dist/markdownTaskList.d.ts.map +1 -0
- package/dist/markdownTaskList.js +166 -0
- package/dist/markdownThematicBreak.d.ts +3 -0
- package/dist/markdownThematicBreak.d.ts.map +1 -0
- package/dist/markdownThematicBreak.js +55 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/dist/richtext.css +213 -0
- package/dist/textSemantics.d.ts +3 -0
- package/dist/textSemantics.d.ts.map +1 -0
- package/dist/textSemantics.js +87 -0
- package/package.json +158 -3
- package/src/RichTextEditor.doc.mjs +308 -0
- package/src/RichTextEditor.tsx +1109 -0
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
- package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
- package/src/RichTextEditorToolbar.doc.mjs +92 -0
- package/src/RichTextEditorToolbar.tsx +871 -0
- package/src/RichTextView.doc.mjs +101 -0
- package/src/RichTextView.tsx +351 -0
- package/src/codeLanguages.ts +42 -0
- package/src/codeLineTokens.ts +241 -0
- package/src/doc-mjs.d.ts +23 -0
- package/src/editorNodes.ts +44 -0
- package/src/editorTheme.ts +550 -0
- package/src/index.ts +59 -0
- package/src/internal/CodeBackspacePlugin.tsx +96 -0
- package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
- package/src/internal/CodePastePlugin.tsx +144 -0
- package/src/internal/CodeSyntaxPlugin.tsx +493 -0
- package/src/internal/CodeTabPlugin.tsx +298 -0
- package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
- package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
- package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
- package/src/internal/SelectAllScopePlugin.tsx +86 -0
- package/src/internal/TableColumnFloorPlugin.tsx +99 -0
- package/src/internal/TablePastePlugin.tsx +223 -0
- package/src/internal/TableScrollRegionPlugin.tsx +107 -0
- package/src/internal/TaskCheckboxPlugin.tsx +246 -0
- package/src/internal/ToolbarLinkPopover.tsx +392 -0
- package/src/linkEditing.ts +20 -0
- package/src/linkForm.tsx +156 -0
- package/src/linkUtils.ts +106 -0
- package/src/markdown.ts +25 -0
- package/src/markdownCharacterReferences.ts +1137 -0
- package/src/markdownCodeFence.ts +216 -0
- package/src/markdownExtensionNode.ts +339 -0
- package/src/markdownExtensions.ts +496 -0
- package/src/markdownHardLineBreak.ts +73 -0
- package/src/markdownHeading.ts +33 -0
- package/src/markdownLink.ts +86 -0
- package/src/markdownListExport.ts +85 -0
- package/src/markdownListIndentation.ts +105 -0
- package/src/markdownOrderedList.ts +60 -0
- package/src/markdownQuote.ts +35 -0
- package/src/markdownSerializers.ts +108 -0
- package/src/markdownSource.ts +1045 -0
- package/src/markdownSpaceRuns.ts +164 -0
- package/src/markdownTable.ts +309 -0
- package/src/markdownTaskList.ts +222 -0
- package/src/markdownThematicBreak.ts +66 -0
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
- 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
|
+
}
|