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