@blue-patterns/richtext 0.0.0-stage → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/LICENSE +22 -0
  3. package/README.md +88 -2
  4. package/astryx.integration.mjs +11 -0
  5. package/blocks/RichTextEditorShowcase.template.mjs +13 -0
  6. package/blocks/RichTextEditorShowcase.tsx +19 -0
  7. package/dist/RichTextEditor.d.ts +282 -0
  8. package/dist/RichTextEditor.d.ts.map +1 -0
  9. package/dist/RichTextEditor.js +657 -0
  10. package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
  11. package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
  12. package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
  13. package/dist/RichTextEditorToolbar.d.ts +186 -0
  14. package/dist/RichTextEditorToolbar.d.ts.map +1 -0
  15. package/dist/RichTextEditorToolbar.js +688 -0
  16. package/dist/RichTextView.d.ts +92 -0
  17. package/dist/RichTextView.d.ts.map +1 -0
  18. package/dist/RichTextView.js +267 -0
  19. package/dist/codeLanguages.d.ts +14 -0
  20. package/dist/codeLanguages.d.ts.map +1 -0
  21. package/dist/codeLanguages.js +34 -0
  22. package/dist/codeLineTokens.d.ts +53 -0
  23. package/dist/codeLineTokens.d.ts.map +1 -0
  24. package/dist/codeLineTokens.js +188 -0
  25. package/dist/editorNodes.d.ts +8 -0
  26. package/dist/editorNodes.d.ts.map +1 -0
  27. package/dist/editorNodes.js +30 -0
  28. package/dist/editorTheme.d.ts +7 -0
  29. package/dist/editorTheme.d.ts.map +1 -0
  30. package/dist/editorTheme.js +328 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +28 -0
  34. package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
  35. package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
  36. package/dist/internal/CodeBackspacePlugin.js +70 -0
  37. package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
  38. package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
  39. package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
  40. package/dist/internal/CodePastePlugin.d.ts +5 -0
  41. package/dist/internal/CodePastePlugin.d.ts.map +1 -0
  42. package/dist/internal/CodePastePlugin.js +104 -0
  43. package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
  44. package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
  45. package/dist/internal/CodeSyntaxPlugin.js +397 -0
  46. package/dist/internal/CodeTabPlugin.d.ts +2 -0
  47. package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
  48. package/dist/internal/CodeTabPlugin.js +256 -0
  49. package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
  50. package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
  51. package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
  52. package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
  53. package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
  54. package/dist/internal/ImageSourceCaretPlugin.js +105 -0
  55. package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
  56. package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
  57. package/dist/internal/ListItemShortcutsPlugin.js +314 -0
  58. package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
  59. package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
  60. package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
  61. package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
  62. package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
  63. package/dist/internal/SelectAllScopePlugin.js +67 -0
  64. package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
  65. package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
  66. package/dist/internal/TableColumnFloorPlugin.js +84 -0
  67. package/dist/internal/TablePastePlugin.d.ts +9 -0
  68. package/dist/internal/TablePastePlugin.d.ts.map +1 -0
  69. package/dist/internal/TablePastePlugin.js +183 -0
  70. package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
  71. package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
  72. package/dist/internal/TableScrollRegionPlugin.js +95 -0
  73. package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
  74. package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
  75. package/dist/internal/TaskCheckboxPlugin.js +193 -0
  76. package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
  77. package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
  78. package/dist/internal/ToolbarLinkPopover.js +292 -0
  79. package/dist/linkEditing.d.ts +6 -0
  80. package/dist/linkEditing.d.ts.map +1 -0
  81. package/dist/linkEditing.js +20 -0
  82. package/dist/linkForm.d.ts +28 -0
  83. package/dist/linkForm.d.ts.map +1 -0
  84. package/dist/linkForm.js +137 -0
  85. package/dist/linkUtils.d.ts +27 -0
  86. package/dist/linkUtils.d.ts.map +1 -0
  87. package/dist/linkUtils.js +101 -0
  88. package/dist/markdown.d.ts +17 -0
  89. package/dist/markdown.d.ts.map +1 -0
  90. package/dist/markdown.js +17 -0
  91. package/dist/markdownCharacterReferences.d.ts +135 -0
  92. package/dist/markdownCharacterReferences.d.ts.map +1 -0
  93. package/dist/markdownCharacterReferences.js +1127 -0
  94. package/dist/markdownCodeFence.d.ts +4 -0
  95. package/dist/markdownCodeFence.d.ts.map +1 -0
  96. package/dist/markdownCodeFence.js +167 -0
  97. package/dist/markdownExtensionNode.d.ts +88 -0
  98. package/dist/markdownExtensionNode.d.ts.map +1 -0
  99. package/dist/markdownExtensionNode.js +248 -0
  100. package/dist/markdownExtensions.d.ts +115 -0
  101. package/dist/markdownExtensions.d.ts.map +1 -0
  102. package/dist/markdownExtensions.js +392 -0
  103. package/dist/markdownHardLineBreak.d.ts +25 -0
  104. package/dist/markdownHardLineBreak.d.ts.map +1 -0
  105. package/dist/markdownHardLineBreak.js +59 -0
  106. package/dist/markdownHeading.d.ts +20 -0
  107. package/dist/markdownHeading.d.ts.map +1 -0
  108. package/dist/markdownHeading.js +30 -0
  109. package/dist/markdownLink.d.ts +5 -0
  110. package/dist/markdownLink.d.ts.map +1 -0
  111. package/dist/markdownLink.js +80 -0
  112. package/dist/markdownListExport.d.ts +3 -0
  113. package/dist/markdownListExport.d.ts.map +1 -0
  114. package/dist/markdownListExport.js +68 -0
  115. package/dist/markdownListIndentation.d.ts +8 -0
  116. package/dist/markdownListIndentation.d.ts.map +1 -0
  117. package/dist/markdownListIndentation.js +101 -0
  118. package/dist/markdownOrderedList.d.ts +3 -0
  119. package/dist/markdownOrderedList.d.ts.map +1 -0
  120. package/dist/markdownOrderedList.js +51 -0
  121. package/dist/markdownQuote.d.ts +22 -0
  122. package/dist/markdownQuote.d.ts.map +1 -0
  123. package/dist/markdownQuote.js +32 -0
  124. package/dist/markdownSerializers.d.ts +49 -0
  125. package/dist/markdownSerializers.d.ts.map +1 -0
  126. package/dist/markdownSerializers.js +83 -0
  127. package/dist/markdownSource.d.ts +71 -0
  128. package/dist/markdownSource.d.ts.map +1 -0
  129. package/dist/markdownSource.js +804 -0
  130. package/dist/markdownSpaceRuns.d.ts +24 -0
  131. package/dist/markdownSpaceRuns.d.ts.map +1 -0
  132. package/dist/markdownSpaceRuns.js +147 -0
  133. package/dist/markdownTable.d.ts +35 -0
  134. package/dist/markdownTable.d.ts.map +1 -0
  135. package/dist/markdownTable.js +227 -0
  136. package/dist/markdownTaskList.d.ts +48 -0
  137. package/dist/markdownTaskList.d.ts.map +1 -0
  138. package/dist/markdownTaskList.js +166 -0
  139. package/dist/markdownThematicBreak.d.ts +3 -0
  140. package/dist/markdownThematicBreak.d.ts.map +1 -0
  141. package/dist/markdownThematicBreak.js +55 -0
  142. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
  143. package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
  144. package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
  145. package/dist/richtext.css +213 -0
  146. package/dist/textSemantics.d.ts +3 -0
  147. package/dist/textSemantics.d.ts.map +1 -0
  148. package/dist/textSemantics.js +87 -0
  149. package/package.json +158 -3
  150. package/src/RichTextEditor.doc.mjs +308 -0
  151. package/src/RichTextEditor.tsx +1109 -0
  152. package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
  153. package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
  154. package/src/RichTextEditorToolbar.doc.mjs +92 -0
  155. package/src/RichTextEditorToolbar.tsx +871 -0
  156. package/src/RichTextView.doc.mjs +101 -0
  157. package/src/RichTextView.tsx +351 -0
  158. package/src/codeLanguages.ts +42 -0
  159. package/src/codeLineTokens.ts +241 -0
  160. package/src/doc-mjs.d.ts +23 -0
  161. package/src/editorNodes.ts +44 -0
  162. package/src/editorTheme.ts +550 -0
  163. package/src/index.ts +59 -0
  164. package/src/internal/CodeBackspacePlugin.tsx +96 -0
  165. package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
  166. package/src/internal/CodePastePlugin.tsx +144 -0
  167. package/src/internal/CodeSyntaxPlugin.tsx +493 -0
  168. package/src/internal/CodeTabPlugin.tsx +298 -0
  169. package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
  170. package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
  171. package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
  172. package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
  173. package/src/internal/SelectAllScopePlugin.tsx +86 -0
  174. package/src/internal/TableColumnFloorPlugin.tsx +99 -0
  175. package/src/internal/TablePastePlugin.tsx +223 -0
  176. package/src/internal/TableScrollRegionPlugin.tsx +107 -0
  177. package/src/internal/TaskCheckboxPlugin.tsx +246 -0
  178. package/src/internal/ToolbarLinkPopover.tsx +392 -0
  179. package/src/linkEditing.ts +20 -0
  180. package/src/linkForm.tsx +156 -0
  181. package/src/linkUtils.ts +106 -0
  182. package/src/markdown.ts +25 -0
  183. package/src/markdownCharacterReferences.ts +1137 -0
  184. package/src/markdownCodeFence.ts +216 -0
  185. package/src/markdownExtensionNode.ts +339 -0
  186. package/src/markdownExtensions.ts +496 -0
  187. package/src/markdownHardLineBreak.ts +73 -0
  188. package/src/markdownHeading.ts +33 -0
  189. package/src/markdownLink.ts +86 -0
  190. package/src/markdownListExport.ts +85 -0
  191. package/src/markdownListIndentation.ts +105 -0
  192. package/src/markdownOrderedList.ts +60 -0
  193. package/src/markdownQuote.ts +35 -0
  194. package/src/markdownSerializers.ts +108 -0
  195. package/src/markdownSource.ts +1045 -0
  196. package/src/markdownSpaceRuns.ts +164 -0
  197. package/src/markdownTable.ts +309 -0
  198. package/src/markdownTaskList.ts +222 -0
  199. package/src/markdownThematicBreak.ts +66 -0
  200. package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
  201. package/src/textSemantics.ts +104 -0
@@ -0,0 +1,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';