@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -1,50 +1,135 @@
1
1
  import React from "react";
2
2
 
3
3
  import * as Popover from "@radix-ui/react-popover";
4
+ import * as Select from "@radix-ui/react-select";
4
5
  import * as Toggle from "@radix-ui/react-toggle";
5
6
  import * as ToggleGroup from "@radix-ui/react-toggle-group";
6
7
  import * as Tooltip from "@radix-ui/react-tooltip";
7
8
  import {
9
+ AlignCenter,
10
+ AlignJustify,
11
+ AlignLeft,
12
+ AlignRight,
13
+ Baseline,
14
+ Bold,
15
+ ChevronDown,
8
16
  Download,
9
17
  Eye,
10
18
  EyeOff,
11
19
  FileText,
20
+ Highlighter,
21
+ ImagePlus,
22
+ Indent,
23
+ Italic,
12
24
  MessageSquare,
25
+ Minus,
13
26
  Monitor,
27
+ MoreHorizontal,
28
+ Outdent,
29
+ Plus,
14
30
  Redo2,
31
+ Rows3,
32
+ Strikethrough,
33
+ Subscript,
34
+ Superscript,
15
35
  ShieldAlert,
16
36
  ShieldCheck,
37
+ Underline,
17
38
  Undo2,
18
39
  } from "lucide-react";
19
40
 
20
- import type { CompatibilityPanelSnapshot, EditorWarning } from "../../api/public-types";
41
+ import type {
42
+ CompatibilityPanelSnapshot,
43
+ EditorStoryTarget,
44
+ EditorWarning,
45
+ FormattingStateSnapshot,
46
+ FormattingAlignment,
47
+ InsertImageOptions,
48
+ SectionBreakType,
49
+ StyleCatalogSnapshot,
50
+ WorkflowBlockedCommandReason,
51
+ WorkspaceMode,
52
+ ZoomLevel,
53
+ } from "../../api/public-types";
21
54
  import type { SessionCapabilities } from "../../runtime/session-capabilities";
55
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
22
56
  import { TwHealthPanel } from "../review/tw-health-panel";
23
57
  import { TwToolbarIconButton } from "./tw-toolbar-icon-button";
24
58
 
25
- export type ViewMode = "canvas" | "document";
26
-
27
59
  export interface TwToolbarProps {
28
60
  sourceLabel?: string;
29
61
  capabilities?: SessionCapabilities;
30
62
  compatibility?: CompatibilityPanelSnapshot;
31
63
  warnings?: EditorWarning[];
32
- viewMode: ViewMode;
64
+ blockedReasons?: WorkflowBlockedCommandReason[];
65
+ workspaceMode: WorkspaceMode;
66
+ zoomLevel?: ZoomLevel;
67
+ formattingState?: FormattingStateSnapshot;
68
+ styleCatalog?: StyleCatalogSnapshot;
33
69
  /** Display toggle for tracked change decorations (not a runtime mutation toggle). */
34
70
  showTrackedChanges: boolean;
71
+ /** Active story target — shows a breadcrumb when editing a secondary story. */
72
+ activeStory?: EditorStoryTarget;
73
+ /** Called when the user clicks the story breadcrumb to return to main body. */
74
+ onCloseStory?: () => void;
35
75
  onUndo: () => void;
36
76
  onRedo: () => void;
77
+ onSetParagraphStyle?: (styleId: string) => void;
78
+ onToggleBold?: () => void;
79
+ onToggleItalic?: () => void;
80
+ onToggleUnderline?: () => void;
81
+ onToggleStrikethrough?: () => void;
82
+ onToggleSuperscript?: () => void;
83
+ onToggleSubscript?: () => void;
84
+ onSetFontFamily?: (fontFamily: string) => void;
85
+ onSetFontSize?: (fontSize: number) => void;
86
+ onSetTextColor?: (color: string) => void;
87
+ onSetHighlightColor?: (color: string | null) => void;
88
+ onSetAlignment?: (alignment: FormattingAlignment) => void;
89
+ onOutdent?: () => void;
90
+ onIndent?: () => void;
37
91
  onAddComment: () => void;
92
+ onInsertPageBreak?: () => void;
93
+ onInsertTable?: () => void;
94
+ onInsertSectionBreak?: (type: SectionBreakType) => void;
95
+ onInsertImage?: (options: InsertImageOptions) => void;
38
96
  onExport: () => void;
39
- onViewModeChange: (value: ViewMode) => void;
97
+ onWorkspaceModeChange: (value: WorkspaceMode) => void;
98
+ onZoomChange?: (level: ZoomLevel) => void;
40
99
  onShowTrackedChangesChange: (show: boolean) => void;
41
100
  }
42
101
 
102
+ export function getSupportedZoomPresets(): ReadonlyArray<number> {
103
+ return [75, 100, 125, 150];
104
+ }
105
+
43
106
  const focusRingClass =
44
107
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
45
108
 
109
+ const FONT_FAMILIES = ["Arial", "Times New Roman", "Calibri", "Cambria", "Georgia", "Verdana"];
110
+ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36];
111
+ const TEXT_COLORS = ["#000000", "#434343", "#1660a8", "#1a7f37", "#cf222e", "#7a4f00"];
112
+ const HIGHLIGHT_COLORS = [
113
+ { value: "#ffff00", label: "Yellow" },
114
+ { value: "#00ff00", label: "Green" },
115
+ { value: "#00ffff", label: "Cyan" },
116
+ { value: "#ff69b4", label: "Pink" },
117
+ { value: null, label: "None" },
118
+ ] as const;
119
+
46
120
  export function TwToolbar(props: TwToolbarProps) {
47
121
  const caps = props.capabilities;
122
+ const workspaceMode = props.workspaceMode;
123
+ const isPageMode = workspaceMode === "page";
124
+ const paragraphStyles = props.styleCatalog?.paragraphs ?? [];
125
+ const zoomLevel = props.zoomLevel ?? 100;
126
+ const canEdit = caps ? caps.canEdit : false;
127
+ const zoomLabel =
128
+ typeof zoomLevel === "number"
129
+ ? `${zoomLevel}%`
130
+ : zoomLevel === "pageWidth"
131
+ ? "Fit width"
132
+ : "Fit page";
48
133
 
49
134
  return (
50
135
  <header className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
@@ -64,7 +149,118 @@ export function TwToolbar(props: TwToolbarProps) {
64
149
  />
65
150
  <div className="mx-1 h-4 w-px bg-border" />
66
151
 
67
- {/* Paragraph style selector and B/I/U removed — not yet supported */}
152
+ <ToolbarParagraphStyleSelect
153
+ disabled={!canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
154
+ styles={paragraphStyles}
155
+ value={props.formattingState?.paragraphStyleId}
156
+ onValueChange={props.onSetParagraphStyle}
157
+ />
158
+
159
+ <ToolbarFontFamilySelect
160
+ disabled={!canEdit || !props.onSetFontFamily}
161
+ value={props.formattingState?.fontFamily}
162
+ onValueChange={props.onSetFontFamily}
163
+ />
164
+ <ToolbarFontSizeSelect
165
+ disabled={!canEdit || !props.onSetFontSize}
166
+ value={props.formattingState?.fontSize}
167
+ onValueChange={props.onSetFontSize}
168
+ />
169
+
170
+ <div className="mx-1 h-4 w-px bg-border" />
171
+
172
+ <TwToolbarIconButton
173
+ icon={Bold}
174
+ label="Bold"
175
+ active={props.formattingState?.bold ?? false}
176
+ disabled={caps ? !caps.canEdit : true}
177
+ onClick={props.onToggleBold}
178
+ />
179
+ <TwToolbarIconButton
180
+ icon={Italic}
181
+ label="Italic"
182
+ active={props.formattingState?.italic ?? false}
183
+ disabled={caps ? !caps.canEdit : true}
184
+ onClick={props.onToggleItalic}
185
+ />
186
+ <TwToolbarIconButton
187
+ icon={Underline}
188
+ label="Underline"
189
+ active={props.formattingState?.underline ?? false}
190
+ disabled={!canEdit}
191
+ onClick={props.onToggleUnderline}
192
+ />
193
+ <ToolbarFormattingOverflow
194
+ disabled={!canEdit}
195
+ formattingState={props.formattingState}
196
+ onToggleStrikethrough={props.onToggleStrikethrough}
197
+ onToggleSuperscript={props.onToggleSuperscript}
198
+ onToggleSubscript={props.onToggleSubscript}
199
+ />
200
+ <ToolbarColorPopover
201
+ ariaLabel="Text color"
202
+ colors={TEXT_COLORS.map((value) => ({ value, label: value }))}
203
+ disabled={!canEdit || !props.onSetTextColor}
204
+ icon={<Baseline className="h-3.5 w-3.5" />}
205
+ onSelect={(value) => {
206
+ if (value) {
207
+ props.onSetTextColor?.(value);
208
+ }
209
+ }}
210
+ title="Text color"
211
+ />
212
+ <ToolbarColorPopover
213
+ ariaLabel="Highlight color"
214
+ colors={HIGHLIGHT_COLORS.map((entry) => ({ value: entry.value, label: entry.label }))}
215
+ disabled={!canEdit || !props.onSetHighlightColor}
216
+ icon={<Highlighter className="h-3.5 w-3.5" />}
217
+ onSelect={(value) => props.onSetHighlightColor?.(value)}
218
+ title="Highlight color"
219
+ />
220
+ <ToolbarAlignmentPopover
221
+ activeAlignment={props.formattingState?.alignment}
222
+ disabled={!canEdit || !props.onSetAlignment}
223
+ onSelect={(alignment) => props.onSetAlignment?.(alignment)}
224
+ />
225
+
226
+ <div className="mx-1 h-4 w-px bg-border" />
227
+
228
+ <TwToolbarIconButton
229
+ icon={Outdent}
230
+ label="Outdent"
231
+ disabled={!canEdit}
232
+ onClick={props.onOutdent}
233
+ />
234
+ <TwToolbarIconButton
235
+ icon={Indent}
236
+ label="Indent"
237
+ disabled={!canEdit}
238
+ onClick={props.onIndent}
239
+ />
240
+ <ToolbarInsertMenu
241
+ disabled={!canEdit}
242
+ onInsertImage={props.onInsertImage}
243
+ onInsertPageBreak={props.onInsertPageBreak}
244
+ onInsertSectionBreak={props.onInsertSectionBreak}
245
+ onInsertTable={props.onInsertTable}
246
+ />
247
+
248
+ {/* Story focus breadcrumb — visible when editing a secondary story */}
249
+ {props.activeStory && props.activeStory.kind !== "main" ? (
250
+ <>
251
+ <div className="mx-1 h-4 w-px bg-border" />
252
+ <button
253
+ type="button"
254
+ onClick={props.onCloseStory}
255
+ onMouseDown={preserveEditorSelectionMouseDown}
256
+ className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium text-accent hover:bg-accent-soft transition-colors outline-none ${focusRingClass}`}
257
+ aria-label={`Editing ${storyLabel(props.activeStory)} — click to return to main body`}
258
+ >
259
+ <span className="text-secondary">&larr;</span>
260
+ {storyLabel(props.activeStory)}
261
+ </button>
262
+ </>
263
+ ) : null}
68
264
  </div>
69
265
 
70
266
  {/* Center: document title */}
@@ -90,6 +286,7 @@ export function TwToolbar(props: TwToolbarProps) {
90
286
  pressed={props.showTrackedChanges}
91
287
  onPressedChange={props.onShowTrackedChangesChange}
92
288
  disabled={caps ? !caps.trackChangesSupported : false}
289
+ onMouseDown={preserveEditorSelectionMouseDown}
93
290
  className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-accent-soft data-[state=on]:text-accent outline-none disabled:opacity-40 ${focusRingClass}`}
94
291
  >
95
292
  {props.showTrackedChanges ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
@@ -107,12 +304,12 @@ export function TwToolbar(props: TwToolbarProps) {
107
304
 
108
305
  <div className="mx-1 h-4 w-px bg-border" />
109
306
 
110
- {/* View mode toggle group: Canvas (clean, flowing) / Document (paged, markup) */}
307
+ {/* View mode toggle group: Canvas (clean, flowing) / Page (layout-sensitive) */}
111
308
  <ToggleGroup.Root
112
309
  type="single"
113
- value={props.viewMode}
310
+ value={workspaceMode}
114
311
  onValueChange={(v: string) => {
115
- if (v) props.onViewModeChange(v as ViewMode);
312
+ if (v) props.onWorkspaceModeChange(v as WorkspaceMode);
116
313
  }}
117
314
  className="flex items-center gap-0.5"
118
315
  >
@@ -120,6 +317,8 @@ export function TwToolbar(props: TwToolbarProps) {
120
317
  <Tooltip.Trigger asChild>
121
318
  <ToggleGroup.Item
122
319
  value="canvas"
320
+ aria-label="Canvas workspace"
321
+ onMouseDown={preserveEditorSelectionMouseDown}
123
322
  className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-accent-soft data-[state=on]:text-accent outline-none ${focusRingClass}`}
124
323
  >
125
324
  <Monitor className="h-3.5 w-3.5" />
@@ -134,7 +333,9 @@ export function TwToolbar(props: TwToolbarProps) {
134
333
  <Tooltip.Root>
135
334
  <Tooltip.Trigger asChild>
136
335
  <ToggleGroup.Item
137
- value="document"
336
+ value="page"
337
+ aria-label="Page workspace"
338
+ onMouseDown={preserveEditorSelectionMouseDown}
138
339
  className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-accent-soft data-[state=on]:text-accent outline-none ${focusRingClass}`}
139
340
  >
140
341
  <FileText className="h-3.5 w-3.5" />
@@ -142,12 +343,113 @@ export function TwToolbar(props: TwToolbarProps) {
142
343
  </Tooltip.Trigger>
143
344
  <Tooltip.Portal>
144
345
  <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
145
- Documentpaged with markup
346
+ Pagelayout-sensitive view
146
347
  </Tooltip.Content>
147
348
  </Tooltip.Portal>
148
349
  </Tooltip.Root>
149
350
  </ToggleGroup.Root>
150
351
 
352
+ {/* Zoom controls — visible in page mode */}
353
+ {isPageMode && props.onZoomChange ? (
354
+ <>
355
+ <div className="mx-1 h-4 w-px bg-border" />
356
+ <div className="flex items-center gap-0.5">
357
+ <Tooltip.Root>
358
+ <Tooltip.Trigger asChild>
359
+ <button
360
+ type="button"
361
+ aria-label="Zoom out"
362
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
363
+ disabled={typeof zoomLevel === "number" && zoomLevel <= 50}
364
+ onMouseDown={preserveEditorSelectionMouseDown}
365
+ onClick={() => {
366
+ const current = typeof zoomLevel === "number" ? zoomLevel : 100;
367
+ props.onZoomChange!(Math.max(50, current - 10));
368
+ }}
369
+ >
370
+ <Minus className="h-3 w-3" />
371
+ </button>
372
+ </Tooltip.Trigger>
373
+ <Tooltip.Portal>
374
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
375
+ Zoom out
376
+ </Tooltip.Content>
377
+ </Tooltip.Portal>
378
+ </Tooltip.Root>
379
+
380
+ <Popover.Root>
381
+ <Tooltip.Root>
382
+ <Tooltip.Trigger asChild>
383
+ <Popover.Trigger asChild>
384
+ <button
385
+ type="button"
386
+ aria-label={`Zoom: ${zoomLabel}`}
387
+ onMouseDown={preserveEditorSelectionMouseDown}
388
+ className={`inline-flex h-7 items-center justify-center rounded-md px-1.5 text-[11px] font-medium text-secondary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
389
+ >
390
+ {zoomLabel}
391
+ </button>
392
+ </Popover.Trigger>
393
+ </Tooltip.Trigger>
394
+ <Tooltip.Portal>
395
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
396
+ Zoom level
397
+ </Tooltip.Content>
398
+ </Tooltip.Portal>
399
+ </Tooltip.Root>
400
+ <Popover.Portal>
401
+ <Popover.Content
402
+ className="w-[140px] rounded-lg bg-canvas shadow-lg ring-1 ring-border p-1 z-50"
403
+ sideOffset={8}
404
+ align="center"
405
+ >
406
+ {getSupportedZoomPresets().map((preset) => {
407
+ const label = `${preset}%`;
408
+ return (
409
+ <Popover.Close key={preset} asChild>
410
+ <button
411
+ type="button"
412
+ onMouseDown={preserveEditorSelectionMouseDown}
413
+ className={`w-full rounded-md px-3 py-1.5 text-left text-xs transition-colors hover:bg-surface ${
414
+ zoomLevel === preset ? "font-semibold text-accent" : "text-primary"
415
+ }`}
416
+ onClick={() => props.onZoomChange!(preset)}
417
+ >
418
+ {label}
419
+ </button>
420
+ </Popover.Close>
421
+ );
422
+ })}
423
+ </Popover.Content>
424
+ </Popover.Portal>
425
+ </Popover.Root>
426
+
427
+ <Tooltip.Root>
428
+ <Tooltip.Trigger asChild>
429
+ <button
430
+ type="button"
431
+ aria-label="Zoom in"
432
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
433
+ disabled={typeof zoomLevel === "number" && zoomLevel >= 200}
434
+ onMouseDown={preserveEditorSelectionMouseDown}
435
+ onClick={() => {
436
+ const current = typeof zoomLevel === "number" ? zoomLevel : 100;
437
+ props.onZoomChange!(Math.min(200, current + 10));
438
+ }}
439
+ >
440
+ <Plus className="h-3 w-3" />
441
+ </button>
442
+ </Tooltip.Trigger>
443
+ <Tooltip.Portal>
444
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
445
+ Zoom in
446
+ </Tooltip.Content>
447
+ </Tooltip.Portal>
448
+ </Tooltip.Root>
449
+ </div>
450
+ </>
451
+ ) : null}
452
+
151
453
  {/* Health indicator */}
152
454
  {props.compatibility && props.warnings ? (
153
455
  <Popover.Root>
@@ -156,6 +458,7 @@ export function TwToolbar(props: TwToolbarProps) {
156
458
  <Popover.Trigger asChild>
157
459
  <button
158
460
  type="button"
461
+ onMouseDown={preserveEditorSelectionMouseDown}
159
462
  className={`relative inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-surface hover:text-primary outline-none ${focusRingClass} ${
160
463
  (caps?.healthIssueCount ?? 0) > 0 ? "text-secondary" : "text-secondary"
161
464
  }`}
@@ -188,6 +491,7 @@ export function TwToolbar(props: TwToolbarProps) {
188
491
  align="end"
189
492
  >
190
493
  <TwHealthPanel
494
+ blockedReasons={props.blockedReasons}
191
495
  compatibility={props.compatibility}
192
496
  warnings={props.warnings}
193
497
  />
@@ -204,6 +508,7 @@ export function TwToolbar(props: TwToolbarProps) {
204
508
  <button
205
509
  type="button"
206
510
  disabled={caps ? !caps.canExport : true}
511
+ onMouseDown={preserveEditorSelectionMouseDown}
207
512
  className={[
208
513
  "inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors outline-none",
209
514
  focusRingClass,
@@ -214,7 +519,7 @@ export function TwToolbar(props: TwToolbarProps) {
214
519
  onClick={props.onExport}
215
520
  >
216
521
  <Download className="h-3.5 w-3.5" />
217
- Export
522
+ Export .docx
218
523
  </button>
219
524
  </Tooltip.Trigger>
220
525
  <Tooltip.Portal>
@@ -229,3 +534,515 @@ export function TwToolbar(props: TwToolbarProps) {
229
534
  </header>
230
535
  );
231
536
  }
537
+
538
+ function ToolbarParagraphStyleSelect(props: {
539
+ styles: StyleCatalogSnapshot["paragraphs"];
540
+ value?: string;
541
+ disabled: boolean;
542
+ onValueChange?: (styleId: string) => void;
543
+ }) {
544
+ const resolvedValue =
545
+ props.value && props.styles.some((style) => style.styleId === props.value)
546
+ ? props.value
547
+ : "";
548
+
549
+ return (
550
+ <Select.Root
551
+ disabled={props.disabled}
552
+ onValueChange={(value) => props.onValueChange?.(value)}
553
+ value={resolvedValue}
554
+ >
555
+ <Select.Trigger
556
+ aria-label="Paragraph style"
557
+ onMouseDown={preserveEditorSelectionMouseDown}
558
+ className={`inline-flex h-7 min-w-[8.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2.5 text-xs font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
559
+ >
560
+ <Select.Value placeholder="Style" />
561
+ <Select.Icon>
562
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
563
+ </Select.Icon>
564
+ </Select.Trigger>
565
+ <Select.Portal>
566
+ <Select.Content
567
+ align="start"
568
+ className="z-50 overflow-hidden rounded-lg bg-canvas shadow-lg ring-1 ring-border"
569
+ position="popper"
570
+ sideOffset={8}
571
+ >
572
+ <Select.Viewport className="p-1">
573
+ {props.styles.map((style) => (
574
+ <Select.Item
575
+ className={`flex cursor-pointer items-center rounded-md px-2.5 py-1.5 text-xs text-primary outline-none data-[highlighted]:bg-surface data-[state=checked]:bg-accent-soft data-[state=checked]:text-accent ${focusRingClass}`}
576
+ key={style.styleId}
577
+ value={style.styleId}
578
+ >
579
+ <Select.ItemText>{style.displayName}</Select.ItemText>
580
+ </Select.Item>
581
+ ))}
582
+ </Select.Viewport>
583
+ </Select.Content>
584
+ </Select.Portal>
585
+ </Select.Root>
586
+ );
587
+ }
588
+
589
+ function ToolbarFontFamilySelect(props: {
590
+ value?: string;
591
+ disabled: boolean;
592
+ onValueChange?: (fontFamily: string) => void;
593
+ }) {
594
+ const resolvedValue = props.value && FONT_FAMILIES.includes(props.value) ? props.value : "";
595
+
596
+ return (
597
+ <Select.Root
598
+ disabled={props.disabled}
599
+ onValueChange={(value) => props.onValueChange?.(value)}
600
+ value={resolvedValue}
601
+ >
602
+ <Select.Trigger
603
+ aria-label="Font family"
604
+ onMouseDown={preserveEditorSelectionMouseDown}
605
+ className={`inline-flex h-7 min-w-[7rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-xs font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
606
+ >
607
+ <Select.Value placeholder="Font" />
608
+ <Select.Icon>
609
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
610
+ </Select.Icon>
611
+ </Select.Trigger>
612
+ <Select.Portal>
613
+ <Select.Content
614
+ align="start"
615
+ className="z-50 overflow-hidden rounded-lg bg-canvas shadow-lg ring-1 ring-border"
616
+ position="popper"
617
+ sideOffset={8}
618
+ >
619
+ <Select.Viewport className="p-1">
620
+ {FONT_FAMILIES.map((font) => (
621
+ <Select.Item
622
+ className={`flex cursor-pointer items-center rounded-md px-2.5 py-1.5 text-xs text-primary outline-none data-[highlighted]:bg-surface data-[state=checked]:bg-accent-soft data-[state=checked]:text-accent ${focusRingClass}`}
623
+ key={font}
624
+ value={font}
625
+ >
626
+ <Select.ItemText>{font}</Select.ItemText>
627
+ </Select.Item>
628
+ ))}
629
+ </Select.Viewport>
630
+ </Select.Content>
631
+ </Select.Portal>
632
+ </Select.Root>
633
+ );
634
+ }
635
+
636
+ function ToolbarFontSizeSelect(props: {
637
+ value?: number;
638
+ disabled: boolean;
639
+ onValueChange?: (fontSize: number) => void;
640
+ }) {
641
+ const resolvedValue =
642
+ typeof props.value === "number" && FONT_SIZES.includes(props.value) ? String(props.value) : "";
643
+
644
+ return (
645
+ <Select.Root
646
+ disabled={props.disabled}
647
+ onValueChange={(value) => props.onValueChange?.(Number(value))}
648
+ value={resolvedValue}
649
+ >
650
+ <Select.Trigger
651
+ aria-label="Font size"
652
+ onMouseDown={preserveEditorSelectionMouseDown}
653
+ className={`inline-flex h-7 min-w-[4rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-xs font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
654
+ >
655
+ <Select.Value placeholder="Size" />
656
+ <Select.Icon>
657
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
658
+ </Select.Icon>
659
+ </Select.Trigger>
660
+ <Select.Portal>
661
+ <Select.Content
662
+ align="start"
663
+ className="z-50 overflow-hidden rounded-lg bg-canvas shadow-lg ring-1 ring-border"
664
+ position="popper"
665
+ sideOffset={8}
666
+ >
667
+ <Select.Viewport className="p-1">
668
+ {FONT_SIZES.map((size) => (
669
+ <Select.Item
670
+ className={`flex cursor-pointer items-center rounded-md px-2.5 py-1.5 text-xs text-primary outline-none data-[highlighted]:bg-surface data-[state=checked]:bg-accent-soft data-[state=checked]:text-accent ${focusRingClass}`}
671
+ key={size}
672
+ value={String(size)}
673
+ >
674
+ <Select.ItemText>{size}</Select.ItemText>
675
+ </Select.Item>
676
+ ))}
677
+ </Select.Viewport>
678
+ </Select.Content>
679
+ </Select.Portal>
680
+ </Select.Root>
681
+ );
682
+ }
683
+
684
+ function ToolbarFormattingOverflow(props: {
685
+ disabled: boolean;
686
+ formattingState?: FormattingStateSnapshot;
687
+ onToggleStrikethrough?: () => void;
688
+ onToggleSuperscript?: () => void;
689
+ onToggleSubscript?: () => void;
690
+ }) {
691
+ const [open, setOpen] = React.useState(false);
692
+
693
+ return (
694
+ <div className="relative">
695
+ <Tooltip.Root>
696
+ <Tooltip.Trigger asChild>
697
+ <button
698
+ type="button"
699
+ aria-label="More text formatting"
700
+ aria-expanded={open}
701
+ disabled={props.disabled}
702
+ onMouseDown={preserveEditorSelectionMouseDown}
703
+ onClick={() => setOpen((value) => !value)}
704
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
705
+ >
706
+ <MoreHorizontal className="h-3.5 w-3.5" />
707
+ </button>
708
+ </Tooltip.Trigger>
709
+ <Tooltip.Portal>
710
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
711
+ More text formatting
712
+ </Tooltip.Content>
713
+ </Tooltip.Portal>
714
+ </Tooltip.Root>
715
+ {open ? (
716
+ <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
717
+ <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
718
+ Text styling
719
+ </div>
720
+ <div className="grid grid-cols-3 gap-1">
721
+ <ToolbarPopoverActionButton
722
+ active={props.formattingState?.strikethrough ?? false}
723
+ ariaLabel="Strikethrough"
724
+ disabled={props.disabled}
725
+ icon={<Strikethrough className="h-3.5 w-3.5" />}
726
+ onClick={() => {
727
+ props.onToggleStrikethrough?.();
728
+ setOpen(false);
729
+ }}
730
+ />
731
+ <ToolbarPopoverActionButton
732
+ active={props.formattingState?.superscript ?? false}
733
+ ariaLabel="Superscript"
734
+ disabled={props.disabled}
735
+ icon={<Superscript className="h-3.5 w-3.5" />}
736
+ onClick={() => {
737
+ props.onToggleSuperscript?.();
738
+ setOpen(false);
739
+ }}
740
+ />
741
+ <ToolbarPopoverActionButton
742
+ active={props.formattingState?.subscript ?? false}
743
+ ariaLabel="Subscript"
744
+ disabled={props.disabled}
745
+ icon={<Subscript className="h-3.5 w-3.5" />}
746
+ onClick={() => {
747
+ props.onToggleSubscript?.();
748
+ setOpen(false);
749
+ }}
750
+ />
751
+ </div>
752
+ </div>
753
+ ) : null}
754
+ </div>
755
+ );
756
+ }
757
+
758
+ function ToolbarColorPopover(props: {
759
+ ariaLabel: string;
760
+ colors: ReadonlyArray<{ value: string | null; label: string }>;
761
+ disabled: boolean;
762
+ icon: React.ReactNode;
763
+ title: string;
764
+ onSelect: (value: string | null) => void;
765
+ }) {
766
+ const [open, setOpen] = React.useState(false);
767
+
768
+ return (
769
+ <div className="relative">
770
+ <Tooltip.Root>
771
+ <Tooltip.Trigger asChild>
772
+ <button
773
+ type="button"
774
+ aria-label={props.ariaLabel}
775
+ aria-expanded={open}
776
+ disabled={props.disabled}
777
+ onMouseDown={preserveEditorSelectionMouseDown}
778
+ onClick={() => setOpen((value) => !value)}
779
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
780
+ >
781
+ {props.icon}
782
+ </button>
783
+ </Tooltip.Trigger>
784
+ <Tooltip.Portal>
785
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
786
+ {props.title}
787
+ </Tooltip.Content>
788
+ </Tooltip.Portal>
789
+ </Tooltip.Root>
790
+ {open ? (
791
+ <div className="absolute left-0 top-9 z-50 w-[180px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
792
+ <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
793
+ {props.title}
794
+ </div>
795
+ <div className="grid grid-cols-3 gap-1">
796
+ {props.colors.map((color) => (
797
+ <button
798
+ key={`${props.ariaLabel}-${color.label}`}
799
+ type="button"
800
+ aria-label={`${props.title} ${color.label}`}
801
+ disabled={props.disabled}
802
+ onMouseDown={preserveEditorSelectionMouseDown}
803
+ onClick={() => {
804
+ props.onSelect(color.value);
805
+ setOpen(false);
806
+ }}
807
+ className={`inline-flex h-8 items-center justify-center rounded-md border border-border text-[10px] font-medium text-primary transition-transform hover:scale-[1.04] disabled:cursor-not-allowed disabled:opacity-40 ${
808
+ color.value ? "" : "bg-surface"
809
+ } ${focusRingClass}`}
810
+ style={color.value ? { backgroundColor: color.value } : undefined}
811
+ >
812
+ {color.value ? <span className="sr-only">{color.label}</span> : "None"}
813
+ </button>
814
+ ))}
815
+ </div>
816
+ </div>
817
+ ) : null}
818
+ </div>
819
+ );
820
+ }
821
+
822
+ function ToolbarAlignmentPopover(props: {
823
+ activeAlignment?: FormattingAlignment;
824
+ disabled: boolean;
825
+ onSelect: (alignment: FormattingAlignment) => void;
826
+ }) {
827
+ const [open, setOpen] = React.useState(false);
828
+ const alignments = [
829
+ { value: "left" as const, label: "Align left", icon: <AlignLeft className="h-3.5 w-3.5" /> },
830
+ { value: "center" as const, label: "Align center", icon: <AlignCenter className="h-3.5 w-3.5" /> },
831
+ { value: "right" as const, label: "Align right", icon: <AlignRight className="h-3.5 w-3.5" /> },
832
+ { value: "justify" as const, label: "Align justify", icon: <AlignJustify className="h-3.5 w-3.5" /> },
833
+ ];
834
+
835
+ return (
836
+ <div className="relative">
837
+ <Tooltip.Root>
838
+ <Tooltip.Trigger asChild>
839
+ <button
840
+ type="button"
841
+ aria-label="Paragraph alignment"
842
+ aria-expanded={open}
843
+ disabled={props.disabled}
844
+ onMouseDown={preserveEditorSelectionMouseDown}
845
+ onClick={() => setOpen((value) => !value)}
846
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
847
+ >
848
+ {(alignments.find((entry) => entry.value === props.activeAlignment) ?? alignments[0])?.icon}
849
+ </button>
850
+ </Tooltip.Trigger>
851
+ <Tooltip.Portal>
852
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
853
+ Paragraph alignment
854
+ </Tooltip.Content>
855
+ </Tooltip.Portal>
856
+ </Tooltip.Root>
857
+ {open ? (
858
+ <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
859
+ <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
860
+ Paragraph alignment
861
+ </div>
862
+ <div className="grid grid-cols-2 gap-1">
863
+ {alignments.map((entry) => (
864
+ <ToolbarPopoverActionButton
865
+ key={entry.value}
866
+ active={props.activeAlignment === entry.value}
867
+ ariaLabel={entry.label}
868
+ disabled={props.disabled}
869
+ icon={entry.icon}
870
+ onClick={() => {
871
+ props.onSelect(entry.value);
872
+ setOpen(false);
873
+ }}
874
+ />
875
+ ))}
876
+ </div>
877
+ </div>
878
+ ) : null}
879
+ </div>
880
+ );
881
+ }
882
+
883
+ function ToolbarInsertMenu(props: {
884
+ disabled: boolean;
885
+ onInsertPageBreak?: () => void;
886
+ onInsertTable?: () => void;
887
+ onInsertSectionBreak?: (type: SectionBreakType) => void;
888
+ onInsertImage?: (options: InsertImageOptions) => void;
889
+ }) {
890
+ const [open, setOpen] = React.useState(false);
891
+
892
+ async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
893
+ const file = event.target.files?.[0];
894
+ if (!file || props.disabled || !props.onInsertImage) {
895
+ event.target.value = "";
896
+ return;
897
+ }
898
+ const data = new Uint8Array(await file.arrayBuffer());
899
+ props.onInsertImage({
900
+ data,
901
+ mimeType: file.type || "image/png",
902
+ altText: file.name,
903
+ });
904
+ setOpen(false);
905
+ event.target.value = "";
906
+ }
907
+
908
+ return (
909
+ <div className="relative">
910
+ <Tooltip.Root>
911
+ <Tooltip.Trigger asChild>
912
+ <button
913
+ type="button"
914
+ aria-label="Insert"
915
+ aria-expanded={open}
916
+ disabled={props.disabled}
917
+ onMouseDown={preserveEditorSelectionMouseDown}
918
+ onClick={() => setOpen((value) => !value)}
919
+ className={`inline-flex h-7 items-center gap-1 rounded-md border border-border bg-canvas px-2 text-xs font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
920
+ >
921
+ Insert
922
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
923
+ </button>
924
+ </Tooltip.Trigger>
925
+ <Tooltip.Portal>
926
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
927
+ Insert
928
+ </Tooltip.Content>
929
+ </Tooltip.Portal>
930
+ </Tooltip.Root>
931
+ {open ? (
932
+ <div className="absolute left-0 top-9 z-50 w-[220px] rounded-lg bg-canvas p-2 shadow-lg ring-1 ring-border">
933
+ <div className="space-y-1">
934
+ <ToolbarMenuButton
935
+ ariaLabel="Insert page break"
936
+ disabled={props.disabled || !props.onInsertPageBreak}
937
+ icon={<Minus className="h-3.5 w-3.5" />}
938
+ label="Page break"
939
+ onClick={() => {
940
+ props.onInsertPageBreak?.();
941
+ setOpen(false);
942
+ }}
943
+ />
944
+ <ToolbarMenuButton
945
+ ariaLabel="Insert table"
946
+ disabled={props.disabled || !props.onInsertTable}
947
+ icon={<Rows3 className="h-3.5 w-3.5" />}
948
+ label="Table"
949
+ onClick={() => {
950
+ props.onInsertTable?.();
951
+ setOpen(false);
952
+ }}
953
+ />
954
+ <label
955
+ className={`flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-xs font-medium text-primary transition-colors hover:bg-surface ${
956
+ props.disabled || !props.onInsertImage ? "pointer-events-none opacity-40" : ""
957
+ }`}
958
+ >
959
+ <ImagePlus className="h-3.5 w-3.5 text-secondary" />
960
+ <span>Image</span>
961
+ <input
962
+ accept="image/png,image/jpeg,image/gif"
963
+ aria-label="Insert image"
964
+ className="sr-only"
965
+ disabled={props.disabled || !props.onInsertImage}
966
+ type="file"
967
+ onChange={(event) => {
968
+ void handleImageChange(event);
969
+ }}
970
+ />
971
+ </label>
972
+ <ToolbarMenuButton
973
+ ariaLabel="Insert next-page section break"
974
+ disabled={props.disabled || !props.onInsertSectionBreak}
975
+ icon={<FileText className="h-3.5 w-3.5" />}
976
+ label="Next-page section break"
977
+ onClick={() => {
978
+ props.onInsertSectionBreak?.("nextPage");
979
+ setOpen(false);
980
+ }}
981
+ />
982
+ </div>
983
+ </div>
984
+ ) : null}
985
+ </div>
986
+ );
987
+ }
988
+
989
+ function ToolbarPopoverActionButton(props: {
990
+ active: boolean;
991
+ ariaLabel: string;
992
+ disabled: boolean;
993
+ icon: React.ReactNode;
994
+ onClick?: () => void;
995
+ }) {
996
+ return (
997
+ <button
998
+ type="button"
999
+ aria-label={props.ariaLabel}
1000
+ aria-pressed={props.active}
1001
+ disabled={props.disabled}
1002
+ onMouseDown={preserveEditorSelectionMouseDown}
1003
+ onClick={props.onClick}
1004
+ className={`inline-flex h-8 items-center justify-center rounded-md border border-border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
1005
+ props.active ? "bg-accent-soft text-accent" : "bg-canvas text-secondary hover:bg-surface"
1006
+ } ${focusRingClass}`}
1007
+ >
1008
+ {props.icon}
1009
+ </button>
1010
+ );
1011
+ }
1012
+
1013
+ function ToolbarMenuButton(props: {
1014
+ ariaLabel: string;
1015
+ disabled: boolean;
1016
+ icon: React.ReactNode;
1017
+ label: string;
1018
+ onClick?: () => void;
1019
+ }) {
1020
+ return (
1021
+ <button
1022
+ type="button"
1023
+ aria-label={props.ariaLabel}
1024
+ disabled={props.disabled}
1025
+ onMouseDown={preserveEditorSelectionMouseDown}
1026
+ onClick={props.onClick}
1027
+ className={`flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
1028
+ >
1029
+ <span className="text-secondary">{props.icon}</span>
1030
+ <span>{props.label}</span>
1031
+ </button>
1032
+ );
1033
+ }
1034
+
1035
+ function storyLabel(target: EditorStoryTarget): string {
1036
+ switch (target.kind) {
1037
+ case "header":
1038
+ return `Header (${target.variant})`;
1039
+ case "footer":
1040
+ return `Footer (${target.variant})`;
1041
+ case "footnote":
1042
+ return "Footnote";
1043
+ case "endnote":
1044
+ return "Endnote";
1045
+ default:
1046
+ return "Document";
1047
+ }
1048
+ }