@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,871 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file RichTextEditorToolbar.tsx
|
|
7
|
+
* @input Uses React, @lexical/react (composer context), @lexical/rich-text,
|
|
8
|
+
* @lexical/selection, @lexical/list, @lexical/utils, and the lexical core
|
|
9
|
+
* command constants, plus Astryx Toolbar / IconButton / Selector /
|
|
10
|
+
* ToggleButton / Divider / Layout primitives, and the link popover in
|
|
11
|
+
* ToolbarLinkPopover.
|
|
12
|
+
* @output Exports RichTextEditorToolbar (a compact formatting toolbar with a
|
|
13
|
+
* RichText-owned 4px control gutter, a horizontally scrollable action row,
|
|
14
|
+
* and a link popover anchored to its Link button) and
|
|
15
|
+
* RichTextEditorToolbarProps, plus format and glyph helpers RichText shares
|
|
16
|
+
* internally.
|
|
17
|
+
* @position @blue-patterns/richtext. Drop into RichTextEditor's `toolbar` slot to
|
|
18
|
+
* add a flush top formatting toolbar. Themed via Astryx Toolbar, Selector,
|
|
19
|
+
* IconButton, and ToggleButton, so it inherits the active theme. Base props
|
|
20
|
+
* pass through to the Astryx Toolbar exactly as Toolbar places them
|
|
21
|
+
* (spec:AST-061/DEC-8).
|
|
22
|
+
*
|
|
23
|
+
* SYNC: When modified, update:
|
|
24
|
+
* - /packages/richtext/src/index.ts (exports)
|
|
25
|
+
* - /packages/richtext/src/RichTextEditor.doc.mjs (usage notes)
|
|
26
|
+
* - /packages/richtext/src/RichTextEditor.test.tsx (tests)
|
|
27
|
+
* - /apps/storybook/stories/RichTextEditor.stories.tsx (WithToolbar story)
|
|
28
|
+
*
|
|
29
|
+
* NOTE: `lexical` and `@lexical/*` are OPTIONAL peer dependencies — install them
|
|
30
|
+
* to use this.
|
|
31
|
+
* Behavior mirrors the Lexical playground toolbar (selection sync + format
|
|
32
|
+
* commands); the UI is built from Astryx primitives so it matches the theme.
|
|
33
|
+
*
|
|
34
|
+
* ICONS: Each control resolves its glyph through the core icon registry under a
|
|
35
|
+
* stable `richtext:*` key (see {@link RICHTEXT_ICON_KEYS}) for the active
|
|
36
|
+
* theme, falling back to the bundled inline SVGs below — the library seam
|
|
37
|
+
* `getExtendedIcon` documents. A theme restyles any glyph by naming its key:
|
|
38
|
+
* defineTheme({icons: {'richtext:bold': <MyBoldIcon />}});
|
|
39
|
+
* Undo and redo point backward and forward in time, so they mirror under RTL
|
|
40
|
+
* whichever glyph draws them.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
useCallback,
|
|
45
|
+
useEffect,
|
|
46
|
+
useRef,
|
|
47
|
+
useState,
|
|
48
|
+
type ReactNode,
|
|
49
|
+
type Ref,
|
|
50
|
+
} from 'react';
|
|
51
|
+
import * as stylex from '@stylexjs/stylex';
|
|
52
|
+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
53
|
+
import {$setBlocksType} from '@lexical/selection';
|
|
54
|
+
import {
|
|
55
|
+
$createHeadingNode,
|
|
56
|
+
$createQuoteNode,
|
|
57
|
+
$isHeadingNode,
|
|
58
|
+
$isQuoteNode,
|
|
59
|
+
type HeadingTagType,
|
|
60
|
+
} from '@lexical/rich-text';
|
|
61
|
+
import {
|
|
62
|
+
$isListNode,
|
|
63
|
+
ListNode,
|
|
64
|
+
INSERT_ORDERED_LIST_COMMAND,
|
|
65
|
+
INSERT_UNORDERED_LIST_COMMAND,
|
|
66
|
+
} from '@lexical/list';
|
|
67
|
+
import {$getNearestNodeOfType, mergeRegister} from '@lexical/utils';
|
|
68
|
+
import {$isLinkNode} from '@lexical/link';
|
|
69
|
+
import type {BaseProps} from '@blue-patterns/core';
|
|
70
|
+
import {Toolbar} from '@blue-patterns/core/Toolbar';
|
|
71
|
+
import {IconButton} from '@blue-patterns/core/IconButton';
|
|
72
|
+
import {Selector, type SelectorOptionType} from '@blue-patterns/core/Selector';
|
|
73
|
+
import {ToggleButton} from '@blue-patterns/core/ToggleButton';
|
|
74
|
+
import {Divider} from '@blue-patterns/core/Divider';
|
|
75
|
+
import {HStack} from '@blue-patterns/core/Layout';
|
|
76
|
+
import {getExtendedIcon} from '@blue-patterns/core/Icon';
|
|
77
|
+
import {useThemeName} from '@blue-patterns/core/theme';
|
|
78
|
+
import {isRenderable, rtlStyles} from '@blue-patterns/core/utils';
|
|
79
|
+
import {
|
|
80
|
+
fontWeightVars,
|
|
81
|
+
spacingVars,
|
|
82
|
+
typeScaleVars,
|
|
83
|
+
} from '@blue-patterns/core/theme/tokens.stylex';
|
|
84
|
+
import {useTranslator, type TranslatorFn} from '@blue-patterns/core/i18n';
|
|
85
|
+
import {
|
|
86
|
+
FORMAT_TEXT_COMMAND,
|
|
87
|
+
UNDO_COMMAND,
|
|
88
|
+
REDO_COMMAND,
|
|
89
|
+
CAN_UNDO_COMMAND,
|
|
90
|
+
CAN_REDO_COMMAND,
|
|
91
|
+
SELECTION_CHANGE_COMMAND,
|
|
92
|
+
KEY_DOWN_COMMAND,
|
|
93
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
94
|
+
COMMAND_PRIORITY_NORMAL,
|
|
95
|
+
$getSelection,
|
|
96
|
+
$isNodeSelection,
|
|
97
|
+
type LexicalEditor,
|
|
98
|
+
type NodeSelection,
|
|
99
|
+
type RangeSelection,
|
|
100
|
+
$isRangeSelection,
|
|
101
|
+
$createParagraphNode,
|
|
102
|
+
} from 'lexical';
|
|
103
|
+
import {
|
|
104
|
+
ToolbarLinkPopover,
|
|
105
|
+
isInsertLink,
|
|
106
|
+
useToolbarLink,
|
|
107
|
+
} from './internal/ToolbarLinkPopover';
|
|
108
|
+
import {$selectedExtensionNodesOnly} from './markdownExtensionNode';
|
|
109
|
+
|
|
110
|
+
/** Block types exposed by the toolbar's format selector. */
|
|
111
|
+
type BlockType =
|
|
112
|
+
'paragraph' | 'h1' | 'h2' | 'h3' | 'quote' | 'bullet' | 'number';
|
|
113
|
+
|
|
114
|
+
// The link popover's anchor: a box around the Link button that takes no
|
|
115
|
+
// space of its own.
|
|
116
|
+
const linkAnchorStyles = stylex.create({
|
|
117
|
+
wrapper: {
|
|
118
|
+
display: 'inline-flex',
|
|
119
|
+
flexShrink: 0,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A vertical Divider's default 100% height needs a definite parent height.
|
|
124
|
+
// Toolbar slots are sized by their controls instead, so let flexbox stretch
|
|
125
|
+
// these group separators across the slot's resolved height.
|
|
126
|
+
const toolbarDividerStyles = stylex.create({
|
|
127
|
+
vertical: {
|
|
128
|
+
alignSelf: 'stretch',
|
|
129
|
+
height: 'auto',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const glyphStyles = stylex.create({
|
|
134
|
+
// A transform needs a box: the wrapper is inline-flex so the mirror applies.
|
|
135
|
+
directional: {
|
|
136
|
+
display: 'inline-flex',
|
|
137
|
+
},
|
|
138
|
+
text: {
|
|
139
|
+
fontSize: typeScaleVars['--text-supporting-size'],
|
|
140
|
+
fontWeight: fontWeightVars['--font-weight-bold'],
|
|
141
|
+
fontVariantNumeric: 'tabular-nums',
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const toolbarScrollStyles = stylex.create({
|
|
146
|
+
// Core Toolbar owns an 8px block inset. This transparent RichText-only
|
|
147
|
+
// surface overlaps 4px of that inset at each block edge, so its controls and
|
|
148
|
+
// following editor body retain a 4px visual gutter.
|
|
149
|
+
compactToolbar: {
|
|
150
|
+
'--astryx-section-padding-inline-start': spacingVars['--spacing-1'],
|
|
151
|
+
'--astryx-section-padding-inline-end': spacingVars['--spacing-1'],
|
|
152
|
+
marginBlockStart: `calc(-1 * ${spacingVars['--spacing-1']})`,
|
|
153
|
+
marginBlockEnd: `calc(-1 * ${spacingVars['--spacing-1']})`,
|
|
154
|
+
},
|
|
155
|
+
// The row scrolls when it runs out of room, so a control keeps its own
|
|
156
|
+
// width. A Selector's field may shrink, which squeezes its label to a
|
|
157
|
+
// sliver, so it sits in a slot that may not.
|
|
158
|
+
fixedSlot: {
|
|
159
|
+
display: 'flex',
|
|
160
|
+
flexShrink: 0,
|
|
161
|
+
},
|
|
162
|
+
actions: {
|
|
163
|
+
flex: '1 1 0%',
|
|
164
|
+
width: 0,
|
|
165
|
+
maxWidth: '100%',
|
|
166
|
+
minWidth: 0,
|
|
167
|
+
overflowX: 'auto',
|
|
168
|
+
overflowY: 'hidden',
|
|
169
|
+
overscrollBehaviorX: 'contain',
|
|
170
|
+
scrollbarWidth: 'thin',
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stable icon-registry keys for the toolbar's controls. A theme overrides any
|
|
176
|
+
* of these by key: `defineTheme({icons: {'richtext:bold': <MyIcon />}})`.
|
|
177
|
+
* Keys are namespaced (`richtext:*`) to avoid collisions with the core
|
|
178
|
+
* semantic icon set.
|
|
179
|
+
*/
|
|
180
|
+
export const RICHTEXT_ICON_KEYS = {
|
|
181
|
+
bold: 'richtext:bold',
|
|
182
|
+
italic: 'richtext:italic',
|
|
183
|
+
underline: 'richtext:underline',
|
|
184
|
+
strikethrough: 'richtext:strikethrough',
|
|
185
|
+
code: 'richtext:code',
|
|
186
|
+
link: 'richtext:link',
|
|
187
|
+
paragraph: 'richtext:paragraph',
|
|
188
|
+
h1: 'richtext:h1',
|
|
189
|
+
h2: 'richtext:h2',
|
|
190
|
+
h3: 'richtext:h3',
|
|
191
|
+
quote: 'richtext:quote',
|
|
192
|
+
bullet: 'richtext:bullet',
|
|
193
|
+
number: 'richtext:number',
|
|
194
|
+
undo: 'richtext:undo',
|
|
195
|
+
redo: 'richtext:redo',
|
|
196
|
+
} as const;
|
|
197
|
+
|
|
198
|
+
/** The inline formats, in the order a toolbar offers them. */
|
|
199
|
+
export const INLINE_FORMAT_ACTIONS = [
|
|
200
|
+
{format: 'bold', icon: 'bold'},
|
|
201
|
+
{format: 'italic', icon: 'italic'},
|
|
202
|
+
{format: 'underline', icon: 'underline'},
|
|
203
|
+
{format: 'strikethrough', icon: 'strikethrough'},
|
|
204
|
+
{format: 'code', icon: 'code'},
|
|
205
|
+
] as const;
|
|
206
|
+
|
|
207
|
+
export type InlineFormat = (typeof INLINE_FORMAT_ACTIONS)[number]['format'];
|
|
208
|
+
|
|
209
|
+
/** The inline formats' button labels. */
|
|
210
|
+
export function inlineFormatLabels(
|
|
211
|
+
t: TranslatorFn,
|
|
212
|
+
): Record<InlineFormat, string> {
|
|
213
|
+
// Literal keys, so the catalog gate can verify every one.
|
|
214
|
+
return {
|
|
215
|
+
bold: t('@astryx.richTextEditor.toolbar.bold'),
|
|
216
|
+
italic: t('@astryx.richTextEditor.toolbar.italic'),
|
|
217
|
+
underline: t('@astryx.richTextEditor.toolbar.underline'),
|
|
218
|
+
strikethrough: t('@astryx.richTextEditor.toolbar.strikethrough'),
|
|
219
|
+
code: t('@astryx.richTextEditor.toolbar.inlineCode'),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The inline formats a range or node selection carries, which their buttons
|
|
225
|
+
* show pressed. A selection of Markdown plugin nodes alone (a range around
|
|
226
|
+
* them, or a node selection from a click) shows their formats. Any other
|
|
227
|
+
* node selection, such as a rule, has none.
|
|
228
|
+
*/
|
|
229
|
+
export function $inlineFormatsOf(
|
|
230
|
+
selection: RangeSelection | NodeSelection,
|
|
231
|
+
): Set<InlineFormat> {
|
|
232
|
+
const formats = new Set<InlineFormat>();
|
|
233
|
+
const nodesOnly = $selectedExtensionNodesOnly(selection);
|
|
234
|
+
for (const {format} of INLINE_FORMAT_ACTIONS) {
|
|
235
|
+
if (
|
|
236
|
+
nodesOnly != null
|
|
237
|
+
? nodesOnly.every(node => node.hasFormat(format))
|
|
238
|
+
: $isRangeSelection(selection) && selection.hasFormat(format)
|
|
239
|
+
) {
|
|
240
|
+
formats.add(format);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return formats;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Toggles an inline format over the editor's selection. */
|
|
247
|
+
export function formatSelection(
|
|
248
|
+
editor: LexicalEditor,
|
|
249
|
+
format: InlineFormat,
|
|
250
|
+
): void {
|
|
251
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Bundled 16px inline default icons — no external icon dependency. These are
|
|
256
|
+
* the fallbacks used when a theme hasn't registered an override for the
|
|
257
|
+
* corresponding {@link RICHTEXT_ICON_KEYS} entry.
|
|
258
|
+
*/
|
|
259
|
+
const defaultToolbarIcons: Record<string, ReactNode> = {
|
|
260
|
+
bold: (
|
|
261
|
+
<svg
|
|
262
|
+
width="16"
|
|
263
|
+
height="16"
|
|
264
|
+
viewBox="0 0 24 24"
|
|
265
|
+
fill="none"
|
|
266
|
+
aria-hidden="true">
|
|
267
|
+
<path
|
|
268
|
+
d="M6 4h7a4 4 0 0 1 0 8H6zM6 12h8a4 4 0 0 1 0 8H6z"
|
|
269
|
+
stroke="currentColor"
|
|
270
|
+
strokeWidth="2"
|
|
271
|
+
strokeLinejoin="round"
|
|
272
|
+
/>
|
|
273
|
+
</svg>
|
|
274
|
+
),
|
|
275
|
+
italic: (
|
|
276
|
+
<svg
|
|
277
|
+
width="16"
|
|
278
|
+
height="16"
|
|
279
|
+
viewBox="0 0 24 24"
|
|
280
|
+
fill="none"
|
|
281
|
+
aria-hidden="true">
|
|
282
|
+
<path
|
|
283
|
+
d="M10 4h6M8 20h6M14 4l-4 16"
|
|
284
|
+
stroke="currentColor"
|
|
285
|
+
strokeWidth="2"
|
|
286
|
+
strokeLinecap="round"
|
|
287
|
+
/>
|
|
288
|
+
</svg>
|
|
289
|
+
),
|
|
290
|
+
underline: (
|
|
291
|
+
<svg
|
|
292
|
+
width="16"
|
|
293
|
+
height="16"
|
|
294
|
+
viewBox="0 0 24 24"
|
|
295
|
+
fill="none"
|
|
296
|
+
aria-hidden="true">
|
|
297
|
+
<path
|
|
298
|
+
d="M6 4v6a6 6 0 0 0 12 0V4M5 21h14"
|
|
299
|
+
stroke="currentColor"
|
|
300
|
+
strokeWidth="2"
|
|
301
|
+
strokeLinecap="round"
|
|
302
|
+
/>
|
|
303
|
+
</svg>
|
|
304
|
+
),
|
|
305
|
+
strikethrough: (
|
|
306
|
+
<svg
|
|
307
|
+
width="16"
|
|
308
|
+
height="16"
|
|
309
|
+
viewBox="0 0 24 24"
|
|
310
|
+
fill="none"
|
|
311
|
+
aria-hidden="true">
|
|
312
|
+
<path
|
|
313
|
+
d="M4 12h16M8 6a4 3 0 0 1 8 0M8 16a4 3 0 0 0 8 0"
|
|
314
|
+
stroke="currentColor"
|
|
315
|
+
strokeWidth="2"
|
|
316
|
+
strokeLinecap="round"
|
|
317
|
+
/>
|
|
318
|
+
</svg>
|
|
319
|
+
),
|
|
320
|
+
code: (
|
|
321
|
+
<svg
|
|
322
|
+
width="16"
|
|
323
|
+
height="16"
|
|
324
|
+
viewBox="0 0 24 24"
|
|
325
|
+
fill="none"
|
|
326
|
+
aria-hidden="true">
|
|
327
|
+
<path
|
|
328
|
+
d="M9 8l-4 4 4 4M15 8l4 4-4 4"
|
|
329
|
+
stroke="currentColor"
|
|
330
|
+
strokeWidth="2"
|
|
331
|
+
strokeLinecap="round"
|
|
332
|
+
strokeLinejoin="round"
|
|
333
|
+
/>
|
|
334
|
+
</svg>
|
|
335
|
+
),
|
|
336
|
+
link: (
|
|
337
|
+
<svg
|
|
338
|
+
width="16"
|
|
339
|
+
height="16"
|
|
340
|
+
viewBox="0 0 24 24"
|
|
341
|
+
fill="none"
|
|
342
|
+
aria-hidden="true">
|
|
343
|
+
<path
|
|
344
|
+
d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"
|
|
345
|
+
stroke="currentColor"
|
|
346
|
+
strokeWidth="2"
|
|
347
|
+
strokeLinecap="round"
|
|
348
|
+
strokeLinejoin="round"
|
|
349
|
+
/>
|
|
350
|
+
</svg>
|
|
351
|
+
),
|
|
352
|
+
paragraph: <TextGlyph label="¶" />,
|
|
353
|
+
h1: <TextGlyph label="H1" />,
|
|
354
|
+
h2: <TextGlyph label="H2" />,
|
|
355
|
+
h3: <TextGlyph label="H3" />,
|
|
356
|
+
quote: (
|
|
357
|
+
<svg
|
|
358
|
+
width="16"
|
|
359
|
+
height="16"
|
|
360
|
+
viewBox="0 0 24 24"
|
|
361
|
+
fill="none"
|
|
362
|
+
aria-hidden="true">
|
|
363
|
+
<path
|
|
364
|
+
d="M6 17h3l2-4V7H5v6h3zM15 17h3l2-4V7h-6v6h3z"
|
|
365
|
+
stroke="currentColor"
|
|
366
|
+
strokeWidth="2"
|
|
367
|
+
strokeLinejoin="round"
|
|
368
|
+
/>
|
|
369
|
+
</svg>
|
|
370
|
+
),
|
|
371
|
+
bullet: (
|
|
372
|
+
<svg
|
|
373
|
+
width="16"
|
|
374
|
+
height="16"
|
|
375
|
+
viewBox="0 0 24 24"
|
|
376
|
+
fill="none"
|
|
377
|
+
aria-hidden="true">
|
|
378
|
+
<path
|
|
379
|
+
d="M9 6h11M9 12h11M9 18h11"
|
|
380
|
+
stroke="currentColor"
|
|
381
|
+
strokeWidth="2"
|
|
382
|
+
strokeLinecap="round"
|
|
383
|
+
/>
|
|
384
|
+
<circle cx="4.5" cy="6" r="1.5" fill="currentColor" />
|
|
385
|
+
<circle cx="4.5" cy="12" r="1.5" fill="currentColor" />
|
|
386
|
+
<circle cx="4.5" cy="18" r="1.5" fill="currentColor" />
|
|
387
|
+
</svg>
|
|
388
|
+
),
|
|
389
|
+
number: (
|
|
390
|
+
<svg
|
|
391
|
+
width="16"
|
|
392
|
+
height="16"
|
|
393
|
+
viewBox="0 0 24 24"
|
|
394
|
+
fill="none"
|
|
395
|
+
aria-hidden="true">
|
|
396
|
+
<path
|
|
397
|
+
d="M10 6h10M10 12h10M10 18h10"
|
|
398
|
+
stroke="currentColor"
|
|
399
|
+
strokeWidth="2"
|
|
400
|
+
strokeLinecap="round"
|
|
401
|
+
/>
|
|
402
|
+
<text x="2" y="8" fontSize="6" fill="currentColor">
|
|
403
|
+
1
|
|
404
|
+
</text>
|
|
405
|
+
<text x="2" y="14" fontSize="6" fill="currentColor">
|
|
406
|
+
2
|
|
407
|
+
</text>
|
|
408
|
+
<text x="2" y="20" fontSize="6" fill="currentColor">
|
|
409
|
+
3
|
|
410
|
+
</text>
|
|
411
|
+
</svg>
|
|
412
|
+
),
|
|
413
|
+
undo: (
|
|
414
|
+
<svg
|
|
415
|
+
width="16"
|
|
416
|
+
height="16"
|
|
417
|
+
viewBox="0 0 24 24"
|
|
418
|
+
fill="none"
|
|
419
|
+
aria-hidden="true">
|
|
420
|
+
<path
|
|
421
|
+
d="M9 7L4 12l5 5M4 12h11a5 5 0 0 1 0 10"
|
|
422
|
+
stroke="currentColor"
|
|
423
|
+
strokeWidth="2"
|
|
424
|
+
strokeLinecap="round"
|
|
425
|
+
strokeLinejoin="round"
|
|
426
|
+
/>
|
|
427
|
+
</svg>
|
|
428
|
+
),
|
|
429
|
+
redo: (
|
|
430
|
+
<svg
|
|
431
|
+
width="16"
|
|
432
|
+
height="16"
|
|
433
|
+
viewBox="0 0 24 24"
|
|
434
|
+
fill="none"
|
|
435
|
+
aria-hidden="true">
|
|
436
|
+
<path
|
|
437
|
+
d="M15 7l5 5-5 5M20 12H9a5 5 0 0 0 0 10"
|
|
438
|
+
stroke="currentColor"
|
|
439
|
+
strokeWidth="2"
|
|
440
|
+
strokeLinecap="round"
|
|
441
|
+
strokeLinejoin="round"
|
|
442
|
+
/>
|
|
443
|
+
</svg>
|
|
444
|
+
),
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/** Renders a short text label as a toolbar glyph (for heading buttons). */
|
|
448
|
+
function TextGlyph({label}: {label: string}) {
|
|
449
|
+
return (
|
|
450
|
+
<span aria-hidden="true" {...stylex.props(glyphStyles.text)}>
|
|
451
|
+
{label}
|
|
452
|
+
</span>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export type ToolbarIconName = keyof typeof RICHTEXT_ICON_KEYS;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Resolve a toolbar glyph for the active theme: the theme's icon for the
|
|
460
|
+
* stable `richtext:*` key, then a globally registered one, then the bundled
|
|
461
|
+
* inline default.
|
|
462
|
+
*/
|
|
463
|
+
function resolveIcon(
|
|
464
|
+
name: ToolbarIconName,
|
|
465
|
+
themeName: string | null | undefined,
|
|
466
|
+
): ReactNode {
|
|
467
|
+
return getExtendedIcon(
|
|
468
|
+
RICHTEXT_ICON_KEYS[name],
|
|
469
|
+
defaultToolbarIcons[name],
|
|
470
|
+
themeName,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The glyph resolver for the active theme, as the toolbars draw glyphs. */
|
|
475
|
+
export function useToolbarIcon(): (name: ToolbarIconName) => ReactNode {
|
|
476
|
+
const themeName = useThemeName();
|
|
477
|
+
return (name: ToolbarIconName) => resolveIcon(name, themeName);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A glyph that points along the reading direction, mirrored under RTL. */
|
|
481
|
+
function DirectionalGlyph({children}: {children: ReactNode}) {
|
|
482
|
+
return (
|
|
483
|
+
<span
|
|
484
|
+
aria-hidden="true"
|
|
485
|
+
{...stylex.props(glyphStyles.directional, rtlStyles.mirror)}>
|
|
486
|
+
{children}
|
|
487
|
+
</span>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export interface RichTextEditorToolbarProps extends BaseProps<HTMLDivElement> {
|
|
492
|
+
/**
|
|
493
|
+
* Ref to the toolbar's root element. Base props pass through to the
|
|
494
|
+
* underlying Astryx `Toolbar`: the ref, `className`, `style`, and `xstyle`
|
|
495
|
+
* reach its root, and `data-*`, other `aria-*` attributes, and DOM
|
|
496
|
+
* handlers reach the `role="toolbar"` element inside it. The toolbar names
|
|
497
|
+
* itself, so pass `label` rather than `aria-label`.
|
|
498
|
+
*/
|
|
499
|
+
ref?: Ref<HTMLDivElement>;
|
|
500
|
+
/**
|
|
501
|
+
* Accessible label for the toolbar element.
|
|
502
|
+
* @default 'Text formatting', translated for the active locale
|
|
503
|
+
*/
|
|
504
|
+
label?: string;
|
|
505
|
+
/**
|
|
506
|
+
* Which heading levels to expose in the block-format selector, in order.
|
|
507
|
+
* @default ['h1', 'h2', 'h3']
|
|
508
|
+
*/
|
|
509
|
+
headingLevels?: ReadonlyArray<'h1' | 'h2' | 'h3'>;
|
|
510
|
+
/**
|
|
511
|
+
* Toolbar size. Cascades to formatting controls and coordinates the
|
|
512
|
+
* Toolbar's internal padding.
|
|
513
|
+
* @default 'sm'
|
|
514
|
+
*/
|
|
515
|
+
size?: 'sm' | 'md' | 'lg';
|
|
516
|
+
/**
|
|
517
|
+
* Whether to show the insert/edit-link button. By default, pressing it (or
|
|
518
|
+
* Cmd/Ctrl+K in the editor) opens an Astryx Popover anchored to the button:
|
|
519
|
+
* one URL field, applied with Enter, with Remove when the selection is in a
|
|
520
|
+
* link. The popover is not modal. Escape or Cancel closes it and returns
|
|
521
|
+
* focus and the selection to the editor; a URL that cannot be linked shows
|
|
522
|
+
* an error under the field, which keeps focus. With a collapsed selection,
|
|
523
|
+
* applying inserts the entered URL as link text at the caret; with a
|
|
524
|
+
* non-empty selection it links the selected text.
|
|
525
|
+
*
|
|
526
|
+
* Requires `LinkNode` to be registered (it is, by default) — no extra setup.
|
|
527
|
+
* The entered URL is sanitized (see `sanitizeUrl`) so only
|
|
528
|
+
* http/https/mailto/tel links are written.
|
|
529
|
+
* @default true
|
|
530
|
+
*/
|
|
531
|
+
hasLink?: boolean;
|
|
532
|
+
/**
|
|
533
|
+
* Called to obtain a URL when the link button is pressed on a non-link
|
|
534
|
+
* selection. Return the URL string to link, or `null`/`''` to cancel.
|
|
535
|
+
* Receives the currently selected text as a hint. When omitted, the built-in
|
|
536
|
+
* link popover is used. This synchronous override is retained
|
|
537
|
+
* for consumers that already provide their own URL UI.
|
|
538
|
+
*/
|
|
539
|
+
promptForUrl?: (selectedText: string) => string | null | undefined;
|
|
540
|
+
/**
|
|
541
|
+
* Whether links created via the toolbar open in a new tab. When `true`
|
|
542
|
+
* (default), the created link carries `target="_blank"` and
|
|
543
|
+
* `rel="noopener noreferrer"` — written into the link node's data (so it
|
|
544
|
+
* serializes and round-trips), not patched onto the DOM. Set `false` to
|
|
545
|
+
* create same-tab links.
|
|
546
|
+
* @default true
|
|
547
|
+
*/
|
|
548
|
+
hasNewTabLinks?: boolean;
|
|
549
|
+
/**
|
|
550
|
+
* Extra items rendered at the end of the toolbar (after a divider). Use this
|
|
551
|
+
* to compose product-specific controls (e.g. mentions, AI) alongside the
|
|
552
|
+
* default formatting buttons.
|
|
553
|
+
*/
|
|
554
|
+
endContent?: ReactNode;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* A composable formatting toolbar for {@link RichTextEditor}, built from Astryx
|
|
559
|
+
* `Toolbar` / `ToggleButton` primitives so it matches the active theme. Reads
|
|
560
|
+
* the current selection to keep the active states in sync and dispatches the
|
|
561
|
+
* standard Lexical formatting commands.
|
|
562
|
+
*
|
|
563
|
+
* Render it inside the editor's `toolbar` slot — it uses
|
|
564
|
+
* `useLexicalComposerContext()` to reach the editor, so it must live within the
|
|
565
|
+
* `LexicalComposer` the editor sets up.
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* ```
|
|
569
|
+
* import {RichTextEditor, RichTextEditorToolbar} from '@blue-patterns/richtext';
|
|
570
|
+
*
|
|
571
|
+
* <RichTextEditor
|
|
572
|
+
* label="Notes"
|
|
573
|
+
* toolbar={<RichTextEditorToolbar />}
|
|
574
|
+
* />
|
|
575
|
+
* ```
|
|
576
|
+
*/
|
|
577
|
+
export function RichTextEditorToolbar({
|
|
578
|
+
label: labelFromProps,
|
|
579
|
+
headingLevels = ['h1', 'h2', 'h3'],
|
|
580
|
+
size = 'sm',
|
|
581
|
+
hasLink = true,
|
|
582
|
+
promptForUrl,
|
|
583
|
+
hasNewTabLinks = true,
|
|
584
|
+
endContent,
|
|
585
|
+
xstyle,
|
|
586
|
+
ref,
|
|
587
|
+
...rest
|
|
588
|
+
}: RichTextEditorToolbarProps) {
|
|
589
|
+
const [editor] = useLexicalComposerContext();
|
|
590
|
+
const icon = useToolbarIcon();
|
|
591
|
+
const t = useTranslator();
|
|
592
|
+
const label = labelFromProps ?? t('@astryx.richTextEditor.toolbar.label');
|
|
593
|
+
// Literal keys, so the catalog gate can verify every one.
|
|
594
|
+
const headingLabels = {
|
|
595
|
+
h1: t('@astryx.richTextEditor.toolbar.heading1'),
|
|
596
|
+
h2: t('@astryx.richTextEditor.toolbar.heading2'),
|
|
597
|
+
h3: t('@astryx.richTextEditor.toolbar.heading3'),
|
|
598
|
+
};
|
|
599
|
+
const formatLabels = inlineFormatLabels(t);
|
|
600
|
+
const [activeFormats, setActiveFormats] = useState<Set<InlineFormat>>(
|
|
601
|
+
() => new Set(),
|
|
602
|
+
);
|
|
603
|
+
const [blockType, setBlockType] = useState<BlockType>('paragraph');
|
|
604
|
+
const [isLink, setIsLink] = useState(false);
|
|
605
|
+
const [canUndo, setCanUndo] = useState(false);
|
|
606
|
+
const [canRedo, setCanRedo] = useState(false);
|
|
607
|
+
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
|
|
608
|
+
const linkButtonRef = useRef<HTMLButtonElement>(null);
|
|
609
|
+
const linkAnchorRef = useRef<HTMLSpanElement>(null);
|
|
610
|
+
const link = useToolbarLink(editor, {
|
|
611
|
+
buttonRef: linkButtonRef,
|
|
612
|
+
hasNewTabLinks,
|
|
613
|
+
promptForUrl,
|
|
614
|
+
});
|
|
615
|
+
const {toggle: toggleLink} = link;
|
|
616
|
+
|
|
617
|
+
const $syncToolbar = useCallback(() => {
|
|
618
|
+
const selection = $getSelection();
|
|
619
|
+
if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
setActiveFormats($inlineFormatsOf(selection));
|
|
623
|
+
if (!$isRangeSelection(selection)) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Link active state — a link is "active" when the caret/selection anchor
|
|
628
|
+
// sits inside a LinkNode (or its immediate parent is one):
|
|
629
|
+
// `$isLinkNode(parent) || $isLinkNode(node)`.
|
|
630
|
+
const node = selection.anchor.getNode();
|
|
631
|
+
setIsLink($isLinkNode(node) || $isLinkNode(node.getParent()));
|
|
632
|
+
const anchorNode = selection.anchor.getNode();
|
|
633
|
+
const element =
|
|
634
|
+
anchorNode.getKey() === 'root'
|
|
635
|
+
? anchorNode
|
|
636
|
+
: anchorNode.getTopLevelElementOrThrow();
|
|
637
|
+
if ($isListNode(element)) {
|
|
638
|
+
const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode);
|
|
639
|
+
const type = parentList
|
|
640
|
+
? parentList.getListType()
|
|
641
|
+
: element.getListType();
|
|
642
|
+
setBlockType(type === 'number' ? 'number' : 'bullet');
|
|
643
|
+
} else if ($isHeadingNode(element)) {
|
|
644
|
+
setBlockType(element.getTag() as BlockType);
|
|
645
|
+
} else if ($isQuoteNode(element)) {
|
|
646
|
+
setBlockType('quote');
|
|
647
|
+
} else {
|
|
648
|
+
setBlockType('paragraph');
|
|
649
|
+
}
|
|
650
|
+
}, []);
|
|
651
|
+
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
return mergeRegister(
|
|
654
|
+
editor.registerUpdateListener(({editorState}) => {
|
|
655
|
+
editorState.read($syncToolbar);
|
|
656
|
+
}),
|
|
657
|
+
editor.registerCommand(
|
|
658
|
+
SELECTION_CHANGE_COMMAND,
|
|
659
|
+
() => {
|
|
660
|
+
$syncToolbar();
|
|
661
|
+
return false;
|
|
662
|
+
},
|
|
663
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
664
|
+
),
|
|
665
|
+
editor.registerCommand(
|
|
666
|
+
CAN_UNDO_COMMAND,
|
|
667
|
+
payload => {
|
|
668
|
+
setCanUndo(payload);
|
|
669
|
+
return false;
|
|
670
|
+
},
|
|
671
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
672
|
+
),
|
|
673
|
+
editor.registerCommand(
|
|
674
|
+
CAN_REDO_COMMAND,
|
|
675
|
+
payload => {
|
|
676
|
+
setCanRedo(payload);
|
|
677
|
+
return false;
|
|
678
|
+
},
|
|
679
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
680
|
+
),
|
|
681
|
+
editor.registerEditableListener(editable => {
|
|
682
|
+
setIsEditable(editable);
|
|
683
|
+
}),
|
|
684
|
+
// Cmd/Ctrl+K opens link insertion. Detection mirrors the Lexical
|
|
685
|
+
// playground's ShortcutsPlugin (isInsertLink → isExactShortcutMatch with
|
|
686
|
+
// CONTROL_OR_META), so it fires only on the exact primary-modifier combo
|
|
687
|
+
// and ignores Cmd+Shift+K etc. Only registered when the link button is
|
|
688
|
+
// enabled. Returns true to consume the event so the browser's own
|
|
689
|
+
// shortcut doesn't also fire.
|
|
690
|
+
hasLink
|
|
691
|
+
? editor.registerCommand(
|
|
692
|
+
KEY_DOWN_COMMAND,
|
|
693
|
+
(event: KeyboardEvent) => {
|
|
694
|
+
if (isInsertLink(event)) {
|
|
695
|
+
event.preventDefault();
|
|
696
|
+
toggleLink();
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
return false;
|
|
700
|
+
},
|
|
701
|
+
COMMAND_PRIORITY_NORMAL,
|
|
702
|
+
)
|
|
703
|
+
: () => {},
|
|
704
|
+
);
|
|
705
|
+
}, [editor, $syncToolbar, hasLink, toggleLink]);
|
|
706
|
+
|
|
707
|
+
const setBlock = (next: BlockType) => {
|
|
708
|
+
// Selector choices are idempotent: choosing the active block format keeps
|
|
709
|
+
// it active instead of toggling it back to paragraph.
|
|
710
|
+
if (!isEditable || blockType === next) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (next === 'bullet') {
|
|
714
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (next === 'number') {
|
|
718
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
editor.update(() => {
|
|
722
|
+
const selection = $getSelection();
|
|
723
|
+
if (!$isRangeSelection(selection)) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
$setBlocksType(selection, () => {
|
|
727
|
+
if (next === 'quote') {
|
|
728
|
+
return $createQuoteNode();
|
|
729
|
+
}
|
|
730
|
+
if (next === 'h1' || next === 'h2' || next === 'h3') {
|
|
731
|
+
return $createHeadingNode(next as HeadingTagType);
|
|
732
|
+
}
|
|
733
|
+
return $createParagraphNode();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const blockOptions: SelectorOptionType[] = [
|
|
739
|
+
{
|
|
740
|
+
value: 'paragraph',
|
|
741
|
+
label: t('@astryx.richTextEditor.toolbar.paragraph'),
|
|
742
|
+
icon: icon('paragraph'),
|
|
743
|
+
},
|
|
744
|
+
...headingLevels.map(level => ({
|
|
745
|
+
value: level,
|
|
746
|
+
label: headingLabels[level],
|
|
747
|
+
icon: icon(level),
|
|
748
|
+
})),
|
|
749
|
+
{
|
|
750
|
+
value: 'bullet',
|
|
751
|
+
label: t('@astryx.richTextEditor.toolbar.bulletedList'),
|
|
752
|
+
icon: icon('bullet'),
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
value: 'number',
|
|
756
|
+
label: t('@astryx.richTextEditor.toolbar.numberedList'),
|
|
757
|
+
icon: icon('number'),
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
value: 'quote',
|
|
761
|
+
label: t('@astryx.richTextEditor.toolbar.blockQuote'),
|
|
762
|
+
icon: icon('quote'),
|
|
763
|
+
},
|
|
764
|
+
];
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<>
|
|
768
|
+
<Toolbar
|
|
769
|
+
{...rest}
|
|
770
|
+
ref={ref}
|
|
771
|
+
label={label}
|
|
772
|
+
size={size}
|
|
773
|
+
xstyle={[toolbarScrollStyles.compactToolbar, xstyle]}
|
|
774
|
+
startContent={
|
|
775
|
+
<HStack
|
|
776
|
+
gap={1}
|
|
777
|
+
role="group"
|
|
778
|
+
aria-label={t('@astryx.richTextEditor.toolbar.actions')}
|
|
779
|
+
xstyle={toolbarScrollStyles.actions}>
|
|
780
|
+
<IconButton
|
|
781
|
+
label={t('@astryx.richTextEditor.toolbar.undo')}
|
|
782
|
+
icon={<DirectionalGlyph>{icon('undo')}</DirectionalGlyph>}
|
|
783
|
+
variant="ghost"
|
|
784
|
+
tooltip={t('@astryx.richTextEditor.toolbar.undo')}
|
|
785
|
+
isDisabled={!isEditable || !canUndo}
|
|
786
|
+
onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
|
|
787
|
+
/>
|
|
788
|
+
<IconButton
|
|
789
|
+
label={t('@astryx.richTextEditor.toolbar.redo')}
|
|
790
|
+
icon={<DirectionalGlyph>{icon('redo')}</DirectionalGlyph>}
|
|
791
|
+
variant="ghost"
|
|
792
|
+
tooltip={t('@astryx.richTextEditor.toolbar.redo')}
|
|
793
|
+
isDisabled={!isEditable || !canRedo}
|
|
794
|
+
onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
|
|
795
|
+
/>
|
|
796
|
+
<Divider
|
|
797
|
+
orientation="vertical"
|
|
798
|
+
aria-label={t('@astryx.richTextEditor.toolbar.historyDivider')}
|
|
799
|
+
xstyle={toolbarDividerStyles.vertical}
|
|
800
|
+
/>
|
|
801
|
+
<div {...stylex.props(toolbarScrollStyles.fixedSlot)}>
|
|
802
|
+
<Selector
|
|
803
|
+
label={t('@astryx.richTextEditor.toolbar.blockFormat')}
|
|
804
|
+
isLabelHidden
|
|
805
|
+
variant="ghost"
|
|
806
|
+
size={size}
|
|
807
|
+
value={blockType}
|
|
808
|
+
options={blockOptions}
|
|
809
|
+
startIcon={icon(blockType)}
|
|
810
|
+
isDisabled={!isEditable}
|
|
811
|
+
onChange={value => setBlock(value as BlockType)}
|
|
812
|
+
/>
|
|
813
|
+
</div>
|
|
814
|
+
<Divider
|
|
815
|
+
orientation="vertical"
|
|
816
|
+
aria-label={t('@astryx.richTextEditor.toolbar.formatDivider')}
|
|
817
|
+
xstyle={toolbarDividerStyles.vertical}
|
|
818
|
+
/>
|
|
819
|
+
{INLINE_FORMAT_ACTIONS.map(action => (
|
|
820
|
+
<ToggleButton
|
|
821
|
+
key={action.format}
|
|
822
|
+
label={formatLabels[action.format]}
|
|
823
|
+
icon={icon(action.icon)}
|
|
824
|
+
size={size}
|
|
825
|
+
isIconOnly
|
|
826
|
+
isPressed={activeFormats.has(action.format)}
|
|
827
|
+
isDisabled={!isEditable}
|
|
828
|
+
onPressedChange={() => formatSelection(editor, action.format)}
|
|
829
|
+
/>
|
|
830
|
+
))}
|
|
831
|
+
{hasLink && (
|
|
832
|
+
// The popover anchors to this wrapper, which a press never
|
|
833
|
+
// scales, and finds the button inside it for its handlers.
|
|
834
|
+
<span
|
|
835
|
+
key="link"
|
|
836
|
+
ref={linkAnchorRef}
|
|
837
|
+
{...stylex.props(linkAnchorStyles.wrapper)}>
|
|
838
|
+
<ToggleButton
|
|
839
|
+
ref={linkButtonRef}
|
|
840
|
+
label={t('@astryx.richTextEditor.toolbar.link')}
|
|
841
|
+
icon={icon('link')}
|
|
842
|
+
size={size}
|
|
843
|
+
isIconOnly
|
|
844
|
+
isPressed={isLink || link.isOpen}
|
|
845
|
+
isDisabled={!isEditable}
|
|
846
|
+
onMouseDown={event => event.preventDefault()}
|
|
847
|
+
// The link popover owns a press on the button, and with it
|
|
848
|
+
// the button's popup state; `promptForUrl` replaces both.
|
|
849
|
+
onPressedChange={promptForUrl ? toggleLink : undefined}
|
|
850
|
+
/>
|
|
851
|
+
</span>
|
|
852
|
+
)}
|
|
853
|
+
{isRenderable(endContent) && (
|
|
854
|
+
<>
|
|
855
|
+
<Divider orientation="vertical" />
|
|
856
|
+
{endContent}
|
|
857
|
+
</>
|
|
858
|
+
)}
|
|
859
|
+
</HStack>
|
|
860
|
+
}
|
|
861
|
+
/>
|
|
862
|
+
{/* A sibling of the toolbar, not inside it: the toolbar is one roving
|
|
863
|
+
tab stop, which would leave the form's controls out of Tab. */}
|
|
864
|
+
{hasLink && promptForUrl == null && (
|
|
865
|
+
<ToolbarLinkPopover link={link} anchorRef={linkAnchorRef} size={size} />
|
|
866
|
+
)}
|
|
867
|
+
</>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
RichTextEditorToolbar.displayName = 'RichTextEditorToolbar';
|