@beyondwork/docx-react-component 1.0.19 → 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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +850 -1315
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1422 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +51 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  46. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  48. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  49. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  51. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  52. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  53. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  54. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  55. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  56. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  57. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  58. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  59. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +174 -48
  60. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  61. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  62. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  63. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  64. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  65. package/src/ui-tailwind/theme/editor-theme.css +4 -0
  66. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  67. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  68. package/src/validation/compatibility-engine.ts +27 -4
  69. package/src/validation/compatibility-report.ts +1 -0
  70. package/src/validation/docx-comment-proof.ts +220 -0
@@ -6,20 +6,32 @@ import * as Toggle from "@radix-ui/react-toggle";
6
6
  import * as ToggleGroup from "@radix-ui/react-toggle-group";
7
7
  import * as Tooltip from "@radix-ui/react-tooltip";
8
8
  import {
9
+ AlignCenter,
10
+ AlignJustify,
11
+ AlignLeft,
12
+ AlignRight,
13
+ Baseline,
9
14
  Bold,
10
15
  ChevronDown,
11
16
  Download,
12
17
  Eye,
13
18
  EyeOff,
14
19
  FileText,
20
+ Highlighter,
21
+ ImagePlus,
15
22
  Indent,
16
23
  Italic,
17
24
  MessageSquare,
18
25
  Minus,
19
26
  Monitor,
27
+ MoreHorizontal,
20
28
  Outdent,
21
29
  Plus,
22
30
  Redo2,
31
+ Rows3,
32
+ Strikethrough,
33
+ Subscript,
34
+ Superscript,
23
35
  ShieldAlert,
24
36
  ShieldCheck,
25
37
  Underline,
@@ -31,7 +43,11 @@ import type {
31
43
  EditorStoryTarget,
32
44
  EditorWarning,
33
45
  FormattingStateSnapshot,
46
+ FormattingAlignment,
47
+ InsertImageOptions,
48
+ SectionBreakType,
34
49
  StyleCatalogSnapshot,
50
+ WorkflowBlockedCommandReason,
35
51
  WorkspaceMode,
36
52
  ZoomLevel,
37
53
  } from "../../api/public-types";
@@ -45,6 +61,7 @@ export interface TwToolbarProps {
45
61
  capabilities?: SessionCapabilities;
46
62
  compatibility?: CompatibilityPanelSnapshot;
47
63
  warnings?: EditorWarning[];
64
+ blockedReasons?: WorkflowBlockedCommandReason[];
48
65
  workspaceMode: WorkspaceMode;
49
66
  zoomLevel?: ZoomLevel;
50
67
  formattingState?: FormattingStateSnapshot;
@@ -61,9 +78,21 @@ export interface TwToolbarProps {
61
78
  onToggleBold?: () => void;
62
79
  onToggleItalic?: () => void;
63
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;
64
89
  onOutdent?: () => void;
65
90
  onIndent?: () => void;
66
91
  onAddComment: () => void;
92
+ onInsertPageBreak?: () => void;
93
+ onInsertTable?: () => void;
94
+ onInsertSectionBreak?: (type: SectionBreakType) => void;
95
+ onInsertImage?: (options: InsertImageOptions) => void;
67
96
  onExport: () => void;
68
97
  onWorkspaceModeChange: (value: WorkspaceMode) => void;
69
98
  onZoomChange?: (level: ZoomLevel) => void;
@@ -77,12 +106,24 @@ export function getSupportedZoomPresets(): ReadonlyArray<number> {
77
106
  const focusRingClass =
78
107
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
79
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
+
80
120
  export function TwToolbar(props: TwToolbarProps) {
81
121
  const caps = props.capabilities;
82
122
  const workspaceMode = props.workspaceMode;
83
123
  const isPageMode = workspaceMode === "page";
84
124
  const paragraphStyles = props.styleCatalog?.paragraphs ?? [];
85
125
  const zoomLevel = props.zoomLevel ?? 100;
126
+ const canEdit = caps ? caps.canEdit : false;
86
127
  const zoomLabel =
87
128
  typeof zoomLevel === "number"
88
129
  ? `${zoomLevel}%`
@@ -109,12 +150,23 @@ export function TwToolbar(props: TwToolbarProps) {
109
150
  <div className="mx-1 h-4 w-px bg-border" />
110
151
 
111
152
  <ToolbarParagraphStyleSelect
112
- disabled={!caps?.canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
153
+ disabled={!canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
113
154
  styles={paragraphStyles}
114
155
  value={props.formattingState?.paragraphStyleId}
115
156
  onValueChange={props.onSetParagraphStyle}
116
157
  />
117
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
+
118
170
  <div className="mx-1 h-4 w-px bg-border" />
119
171
 
120
172
  <TwToolbarIconButton
@@ -135,24 +187,63 @@ export function TwToolbar(props: TwToolbarProps) {
135
187
  icon={Underline}
136
188
  label="Underline"
137
189
  active={props.formattingState?.underline ?? false}
138
- disabled={caps ? !caps.canEdit : true}
190
+ disabled={!canEdit}
139
191
  onClick={props.onToggleUnderline}
140
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
+ />
141
225
 
142
226
  <div className="mx-1 h-4 w-px bg-border" />
143
227
 
144
228
  <TwToolbarIconButton
145
229
  icon={Outdent}
146
230
  label="Outdent"
147
- disabled={caps ? !caps.canEdit : true}
231
+ disabled={!canEdit}
148
232
  onClick={props.onOutdent}
149
233
  />
150
234
  <TwToolbarIconButton
151
235
  icon={Indent}
152
236
  label="Indent"
153
- disabled={caps ? !caps.canEdit : true}
237
+ disabled={!canEdit}
154
238
  onClick={props.onIndent}
155
239
  />
240
+ <ToolbarInsertMenu
241
+ disabled={!canEdit}
242
+ onInsertImage={props.onInsertImage}
243
+ onInsertPageBreak={props.onInsertPageBreak}
244
+ onInsertSectionBreak={props.onInsertSectionBreak}
245
+ onInsertTable={props.onInsertTable}
246
+ />
156
247
 
157
248
  {/* Story focus breadcrumb — visible when editing a secondary story */}
158
249
  {props.activeStory && props.activeStory.kind !== "main" ? (
@@ -400,6 +491,7 @@ export function TwToolbar(props: TwToolbarProps) {
400
491
  align="end"
401
492
  >
402
493
  <TwHealthPanel
494
+ blockedReasons={props.blockedReasons}
403
495
  compatibility={props.compatibility}
404
496
  warnings={props.warnings}
405
497
  />
@@ -452,7 +544,7 @@ function ToolbarParagraphStyleSelect(props: {
452
544
  const resolvedValue =
453
545
  props.value && props.styles.some((style) => style.styleId === props.value)
454
546
  ? props.value
455
- : undefined;
547
+ : "";
456
548
 
457
549
  return (
458
550
  <Select.Root
@@ -494,6 +586,452 @@ function ToolbarParagraphStyleSelect(props: {
494
586
  );
495
587
  }
496
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
+
497
1035
  function storyLabel(target: EditorStoryTarget): string {
498
1036
  switch (target.kind) {
499
1037
  case "header":