@beyondwork/docx-react-component 1.0.18 → 1.0.19

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 (74) 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 +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -68,6 +68,11 @@
68
68
  --font-legal-serif: var(--font-legal-serif, "Source Serif 4", "Georgia", "Times New Roman", serif);
69
69
  --font-legal-sans: var(--font-legal-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
70
70
  --font-legal-mono: "JetBrains Mono", "SF Mono", "Fira Code", "Consolas", monospace;
71
+
72
+ /* Page-mode document surface */
73
+ --color-page-shadow: rgba(16, 24, 40, 0.06);
74
+ --color-page-border: rgba(0, 0, 0, 0.04);
75
+ --color-page-bg: #ffffff;
71
76
  }
72
77
 
73
78
  /* ─── Dark mode overrides ─── */
@@ -108,6 +113,96 @@
108
113
 
109
114
  --color-shadow: rgba(0, 0, 0, 0.32);
110
115
  --color-shadow-strong: rgba(0, 0, 0, 0.48);
116
+
117
+ --color-page-shadow: rgba(0, 0, 0, 0.18);
118
+ --color-page-border: rgba(255, 255, 255, 0.06);
119
+ --color-page-bg: #1e1e1e;
120
+ }
121
+
122
+ /*
123
+ * ─── Font substitution map ───
124
+ *
125
+ * Common Word font families mapped to redistributable metric-compatible or
126
+ * open substitutes. These CSS custom properties are consumed by the
127
+ * ProseMirror surface font_family mark and by the page-mode typography
128
+ * layer. Hosts can override individual properties to supply their own
129
+ * licensed fonts.
130
+ *
131
+ * Strategy:
132
+ * 1. Use metric-compatible open substitutes where available
133
+ * (e.g. Liberation Serif for Times New Roman, Carlito for Calibri).
134
+ * 2. Fall back through system-installed metric-compatible fonts
135
+ * (e.g. Cambria → system serif, Calibri → system sans).
136
+ * 3. Final fallback to the editor's legal-serif or legal-sans role font.
137
+ *
138
+ * This pipeline does NOT ship proprietary Microsoft fonts. It uses only
139
+ * redistributable or system-installed families.
140
+ */
141
+ :root {
142
+ /* Serif substitutions */
143
+ --wre-font-times-new-roman: "Liberation Serif", "Times New Roman", "Source Serif 4", "Georgia", serif;
144
+ --wre-font-cambria: "Caladea", "Cambria", "Source Serif 4", "Georgia", serif;
145
+ --wre-font-garamond: "EB Garamond", "Garamond", "Source Serif 4", serif;
146
+ --wre-font-book-antiqua: "Palatino Linotype", "Book Antiqua", "Source Serif 4", serif;
147
+ --wre-font-georgia: "Georgia", "Source Serif 4", serif;
148
+
149
+ /* Sans-serif substitutions */
150
+ --wre-font-calibri: "Carlito", "Calibri", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
151
+ --wre-font-arial: "Liberation Sans", "Arial", "Inter", sans-serif;
152
+ --wre-font-helvetica: "Liberation Sans", "Helvetica Neue", "Helvetica", "Inter", sans-serif;
153
+ --wre-font-verdana: "Verdana", "Inter", sans-serif;
154
+ --wre-font-tahoma: "Tahoma", "Inter", sans-serif;
155
+ --wre-font-segoe-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
156
+ --wre-font-century-gothic: "URW Gothic", "Century Gothic", "Inter", sans-serif;
157
+
158
+ /* Monospace substitutions */
159
+ --wre-font-courier-new: "Liberation Mono", "Courier New", "JetBrains Mono", monospace;
160
+ --wre-font-consolas: "JetBrains Mono", "Consolas", "SF Mono", monospace;
161
+ }
162
+
163
+ /* ─── Page-mode typography ─── */
164
+ .wre-page-surface {
165
+ font-family: var(--font-legal-serif);
166
+ font-size: 15px;
167
+ line-height: 1.6;
168
+ color: var(--color-primary);
169
+ -webkit-font-smoothing: antialiased;
170
+ -moz-osx-font-smoothing: grayscale;
171
+ text-rendering: optimizeLegibility;
172
+ font-kerning: normal;
173
+ font-variant-ligatures: common-ligatures;
174
+ }
175
+
176
+ .wre-page-surface p {
177
+ margin: 0 0 0.5em 0;
178
+ orphans: 2;
179
+ widows: 2;
180
+ }
181
+
182
+ /* Page chrome — shadow, border, and background for the page-mode document panel */
183
+ .wre-page-chrome {
184
+ background: var(--color-page-bg);
185
+ border: 1px solid var(--color-page-border);
186
+ border-radius: 2px;
187
+ box-shadow: 0 1px 4px var(--color-page-shadow);
188
+ }
189
+
190
+ /* Canvas-mode typography — lighter, review-first baseline */
191
+ .wre-canvas-surface {
192
+ font-family: var(--font-legal-serif);
193
+ font-size: 15px;
194
+ line-height: 1.6;
195
+ color: var(--color-primary);
196
+ -webkit-font-smoothing: antialiased;
197
+ -moz-osx-font-smoothing: grayscale;
198
+ text-rendering: optimizeLegibility;
199
+ font-kerning: normal;
200
+ }
201
+
202
+ /* Wide-table readability in page mode */
203
+ .wre-page-surface table {
204
+ font-size: 14px;
205
+ line-height: 1.45;
111
206
  }
112
207
 
113
208
  /* ─── Base resets ─── */
@@ -188,3 +283,31 @@
188
283
  .prosemirror-surface .ProseMirror ::selection {
189
284
  background: var(--color-accent-soft);
190
285
  }
286
+
287
+ .prosemirror-surface .ProseMirror .ProseMirror-selectednode {
288
+ outline: none;
289
+ background-color: var(--color-accent-soft);
290
+ border-radius: 4px;
291
+ }
292
+
293
+ /*
294
+ * ─── Preservation noise suppression ───
295
+ *
296
+ * Quiet-marker opaque inlines (w:proofErr, w:lastRenderedPageBreak, w:permStart,
297
+ * w:permEnd) are preserved in the canonical document for export safety but
298
+ * contribute zero visual weight to the reading/editing surface. They remain in
299
+ * the DOM as zero-dimension spans so round-trip export is unaffected.
300
+ */
301
+ .prosemirror-surface .ProseMirror [data-inline-presentation="quiet-marker"] {
302
+ display: inline-block;
303
+ width: 0;
304
+ height: 0;
305
+ overflow: hidden;
306
+ vertical-align: baseline;
307
+ pointer-events: none;
308
+ }
309
+
310
+ /* ─── Page workspace zoom scaling ─── */
311
+ .wre-page-chrome[style*="scale"] {
312
+ will-change: transform;
313
+ }
@@ -1,6 +1,8 @@
1
1
  import React from "react";
2
2
  import * as Tooltip from "@radix-ui/react-tooltip";
3
3
 
4
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
5
+
4
6
  export interface TwToolbarIconButtonProps {
5
7
  icon: React.ComponentType<{ className?: string }>;
6
8
  label: string;
@@ -19,7 +21,9 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
19
21
  <Tooltip.Trigger asChild>
20
22
  <button
21
23
  type="button"
24
+ aria-label={props.label}
22
25
  disabled={props.disabled}
26
+ onMouseDown={preserveEditorSelectionMouseDown}
23
27
  onClick={props.onClick}
24
28
  className={[
25
29
  "inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors outline-none",
@@ -1,50 +1,94 @@
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
+ Bold,
10
+ ChevronDown,
8
11
  Download,
9
12
  Eye,
10
13
  EyeOff,
11
14
  FileText,
15
+ Indent,
16
+ Italic,
12
17
  MessageSquare,
18
+ Minus,
13
19
  Monitor,
20
+ Outdent,
21
+ Plus,
14
22
  Redo2,
15
23
  ShieldAlert,
16
24
  ShieldCheck,
25
+ Underline,
17
26
  Undo2,
18
27
  } from "lucide-react";
19
28
 
20
- import type { CompatibilityPanelSnapshot, EditorWarning } from "../../api/public-types";
29
+ import type {
30
+ CompatibilityPanelSnapshot,
31
+ EditorStoryTarget,
32
+ EditorWarning,
33
+ FormattingStateSnapshot,
34
+ StyleCatalogSnapshot,
35
+ WorkspaceMode,
36
+ ZoomLevel,
37
+ } from "../../api/public-types";
21
38
  import type { SessionCapabilities } from "../../runtime/session-capabilities";
39
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
22
40
  import { TwHealthPanel } from "../review/tw-health-panel";
23
41
  import { TwToolbarIconButton } from "./tw-toolbar-icon-button";
24
42
 
25
- export type ViewMode = "canvas" | "document";
26
-
27
43
  export interface TwToolbarProps {
28
44
  sourceLabel?: string;
29
45
  capabilities?: SessionCapabilities;
30
46
  compatibility?: CompatibilityPanelSnapshot;
31
47
  warnings?: EditorWarning[];
32
- viewMode: ViewMode;
48
+ workspaceMode: WorkspaceMode;
49
+ zoomLevel?: ZoomLevel;
50
+ formattingState?: FormattingStateSnapshot;
51
+ styleCatalog?: StyleCatalogSnapshot;
33
52
  /** Display toggle for tracked change decorations (not a runtime mutation toggle). */
34
53
  showTrackedChanges: boolean;
54
+ /** Active story target — shows a breadcrumb when editing a secondary story. */
55
+ activeStory?: EditorStoryTarget;
56
+ /** Called when the user clicks the story breadcrumb to return to main body. */
57
+ onCloseStory?: () => void;
35
58
  onUndo: () => void;
36
59
  onRedo: () => void;
60
+ onSetParagraphStyle?: (styleId: string) => void;
61
+ onToggleBold?: () => void;
62
+ onToggleItalic?: () => void;
63
+ onToggleUnderline?: () => void;
64
+ onOutdent?: () => void;
65
+ onIndent?: () => void;
37
66
  onAddComment: () => void;
38
67
  onExport: () => void;
39
- onViewModeChange: (value: ViewMode) => void;
68
+ onWorkspaceModeChange: (value: WorkspaceMode) => void;
69
+ onZoomChange?: (level: ZoomLevel) => void;
40
70
  onShowTrackedChangesChange: (show: boolean) => void;
41
71
  }
42
72
 
73
+ export function getSupportedZoomPresets(): ReadonlyArray<number> {
74
+ return [75, 100, 125, 150];
75
+ }
76
+
43
77
  const focusRingClass =
44
78
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
45
79
 
46
80
  export function TwToolbar(props: TwToolbarProps) {
47
81
  const caps = props.capabilities;
82
+ const workspaceMode = props.workspaceMode;
83
+ const isPageMode = workspaceMode === "page";
84
+ const paragraphStyles = props.styleCatalog?.paragraphs ?? [];
85
+ const zoomLevel = props.zoomLevel ?? 100;
86
+ const zoomLabel =
87
+ typeof zoomLevel === "number"
88
+ ? `${zoomLevel}%`
89
+ : zoomLevel === "pageWidth"
90
+ ? "Fit width"
91
+ : "Fit page";
48
92
 
49
93
  return (
50
94
  <header className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
@@ -64,7 +108,68 @@ export function TwToolbar(props: TwToolbarProps) {
64
108
  />
65
109
  <div className="mx-1 h-4 w-px bg-border" />
66
110
 
67
- {/* Paragraph style selector and B/I/U removed — not yet supported */}
111
+ <ToolbarParagraphStyleSelect
112
+ disabled={!caps?.canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
113
+ styles={paragraphStyles}
114
+ value={props.formattingState?.paragraphStyleId}
115
+ onValueChange={props.onSetParagraphStyle}
116
+ />
117
+
118
+ <div className="mx-1 h-4 w-px bg-border" />
119
+
120
+ <TwToolbarIconButton
121
+ icon={Bold}
122
+ label="Bold"
123
+ active={props.formattingState?.bold ?? false}
124
+ disabled={caps ? !caps.canEdit : true}
125
+ onClick={props.onToggleBold}
126
+ />
127
+ <TwToolbarIconButton
128
+ icon={Italic}
129
+ label="Italic"
130
+ active={props.formattingState?.italic ?? false}
131
+ disabled={caps ? !caps.canEdit : true}
132
+ onClick={props.onToggleItalic}
133
+ />
134
+ <TwToolbarIconButton
135
+ icon={Underline}
136
+ label="Underline"
137
+ active={props.formattingState?.underline ?? false}
138
+ disabled={caps ? !caps.canEdit : true}
139
+ onClick={props.onToggleUnderline}
140
+ />
141
+
142
+ <div className="mx-1 h-4 w-px bg-border" />
143
+
144
+ <TwToolbarIconButton
145
+ icon={Outdent}
146
+ label="Outdent"
147
+ disabled={caps ? !caps.canEdit : true}
148
+ onClick={props.onOutdent}
149
+ />
150
+ <TwToolbarIconButton
151
+ icon={Indent}
152
+ label="Indent"
153
+ disabled={caps ? !caps.canEdit : true}
154
+ onClick={props.onIndent}
155
+ />
156
+
157
+ {/* Story focus breadcrumb — visible when editing a secondary story */}
158
+ {props.activeStory && props.activeStory.kind !== "main" ? (
159
+ <>
160
+ <div className="mx-1 h-4 w-px bg-border" />
161
+ <button
162
+ type="button"
163
+ onClick={props.onCloseStory}
164
+ onMouseDown={preserveEditorSelectionMouseDown}
165
+ 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}`}
166
+ aria-label={`Editing ${storyLabel(props.activeStory)} — click to return to main body`}
167
+ >
168
+ <span className="text-secondary">&larr;</span>
169
+ {storyLabel(props.activeStory)}
170
+ </button>
171
+ </>
172
+ ) : null}
68
173
  </div>
69
174
 
70
175
  {/* Center: document title */}
@@ -90,6 +195,7 @@ export function TwToolbar(props: TwToolbarProps) {
90
195
  pressed={props.showTrackedChanges}
91
196
  onPressedChange={props.onShowTrackedChangesChange}
92
197
  disabled={caps ? !caps.trackChangesSupported : false}
198
+ onMouseDown={preserveEditorSelectionMouseDown}
93
199
  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
200
  >
95
201
  {props.showTrackedChanges ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
@@ -107,12 +213,12 @@ export function TwToolbar(props: TwToolbarProps) {
107
213
 
108
214
  <div className="mx-1 h-4 w-px bg-border" />
109
215
 
110
- {/* View mode toggle group: Canvas (clean, flowing) / Document (paged, markup) */}
216
+ {/* View mode toggle group: Canvas (clean, flowing) / Page (layout-sensitive) */}
111
217
  <ToggleGroup.Root
112
218
  type="single"
113
- value={props.viewMode}
219
+ value={workspaceMode}
114
220
  onValueChange={(v: string) => {
115
- if (v) props.onViewModeChange(v as ViewMode);
221
+ if (v) props.onWorkspaceModeChange(v as WorkspaceMode);
116
222
  }}
117
223
  className="flex items-center gap-0.5"
118
224
  >
@@ -120,6 +226,8 @@ export function TwToolbar(props: TwToolbarProps) {
120
226
  <Tooltip.Trigger asChild>
121
227
  <ToggleGroup.Item
122
228
  value="canvas"
229
+ aria-label="Canvas workspace"
230
+ onMouseDown={preserveEditorSelectionMouseDown}
123
231
  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
232
  >
125
233
  <Monitor className="h-3.5 w-3.5" />
@@ -134,7 +242,9 @@ export function TwToolbar(props: TwToolbarProps) {
134
242
  <Tooltip.Root>
135
243
  <Tooltip.Trigger asChild>
136
244
  <ToggleGroup.Item
137
- value="document"
245
+ value="page"
246
+ aria-label="Page workspace"
247
+ onMouseDown={preserveEditorSelectionMouseDown}
138
248
  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
249
  >
140
250
  <FileText className="h-3.5 w-3.5" />
@@ -142,12 +252,113 @@ export function TwToolbar(props: TwToolbarProps) {
142
252
  </Tooltip.Trigger>
143
253
  <Tooltip.Portal>
144
254
  <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
255
+ Pagelayout-sensitive view
146
256
  </Tooltip.Content>
147
257
  </Tooltip.Portal>
148
258
  </Tooltip.Root>
149
259
  </ToggleGroup.Root>
150
260
 
261
+ {/* Zoom controls — visible in page mode */}
262
+ {isPageMode && props.onZoomChange ? (
263
+ <>
264
+ <div className="mx-1 h-4 w-px bg-border" />
265
+ <div className="flex items-center gap-0.5">
266
+ <Tooltip.Root>
267
+ <Tooltip.Trigger asChild>
268
+ <button
269
+ type="button"
270
+ aria-label="Zoom out"
271
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
272
+ disabled={typeof zoomLevel === "number" && zoomLevel <= 50}
273
+ onMouseDown={preserveEditorSelectionMouseDown}
274
+ onClick={() => {
275
+ const current = typeof zoomLevel === "number" ? zoomLevel : 100;
276
+ props.onZoomChange!(Math.max(50, current - 10));
277
+ }}
278
+ >
279
+ <Minus className="h-3 w-3" />
280
+ </button>
281
+ </Tooltip.Trigger>
282
+ <Tooltip.Portal>
283
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
284
+ Zoom out
285
+ </Tooltip.Content>
286
+ </Tooltip.Portal>
287
+ </Tooltip.Root>
288
+
289
+ <Popover.Root>
290
+ <Tooltip.Root>
291
+ <Tooltip.Trigger asChild>
292
+ <Popover.Trigger asChild>
293
+ <button
294
+ type="button"
295
+ aria-label={`Zoom: ${zoomLabel}`}
296
+ onMouseDown={preserveEditorSelectionMouseDown}
297
+ 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}`}
298
+ >
299
+ {zoomLabel}
300
+ </button>
301
+ </Popover.Trigger>
302
+ </Tooltip.Trigger>
303
+ <Tooltip.Portal>
304
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
305
+ Zoom level
306
+ </Tooltip.Content>
307
+ </Tooltip.Portal>
308
+ </Tooltip.Root>
309
+ <Popover.Portal>
310
+ <Popover.Content
311
+ className="w-[140px] rounded-lg bg-canvas shadow-lg ring-1 ring-border p-1 z-50"
312
+ sideOffset={8}
313
+ align="center"
314
+ >
315
+ {getSupportedZoomPresets().map((preset) => {
316
+ const label = `${preset}%`;
317
+ return (
318
+ <Popover.Close key={preset} asChild>
319
+ <button
320
+ type="button"
321
+ onMouseDown={preserveEditorSelectionMouseDown}
322
+ className={`w-full rounded-md px-3 py-1.5 text-left text-xs transition-colors hover:bg-surface ${
323
+ zoomLevel === preset ? "font-semibold text-accent" : "text-primary"
324
+ }`}
325
+ onClick={() => props.onZoomChange!(preset)}
326
+ >
327
+ {label}
328
+ </button>
329
+ </Popover.Close>
330
+ );
331
+ })}
332
+ </Popover.Content>
333
+ </Popover.Portal>
334
+ </Popover.Root>
335
+
336
+ <Tooltip.Root>
337
+ <Tooltip.Trigger asChild>
338
+ <button
339
+ type="button"
340
+ aria-label="Zoom in"
341
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface outline-none ${focusRingClass}`}
342
+ disabled={typeof zoomLevel === "number" && zoomLevel >= 200}
343
+ onMouseDown={preserveEditorSelectionMouseDown}
344
+ onClick={() => {
345
+ const current = typeof zoomLevel === "number" ? zoomLevel : 100;
346
+ props.onZoomChange!(Math.min(200, current + 10));
347
+ }}
348
+ >
349
+ <Plus className="h-3 w-3" />
350
+ </button>
351
+ </Tooltip.Trigger>
352
+ <Tooltip.Portal>
353
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
354
+ Zoom in
355
+ </Tooltip.Content>
356
+ </Tooltip.Portal>
357
+ </Tooltip.Root>
358
+ </div>
359
+ </>
360
+ ) : null}
361
+
151
362
  {/* Health indicator */}
152
363
  {props.compatibility && props.warnings ? (
153
364
  <Popover.Root>
@@ -156,6 +367,7 @@ export function TwToolbar(props: TwToolbarProps) {
156
367
  <Popover.Trigger asChild>
157
368
  <button
158
369
  type="button"
370
+ onMouseDown={preserveEditorSelectionMouseDown}
159
371
  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
372
  (caps?.healthIssueCount ?? 0) > 0 ? "text-secondary" : "text-secondary"
161
373
  }`}
@@ -204,6 +416,7 @@ export function TwToolbar(props: TwToolbarProps) {
204
416
  <button
205
417
  type="button"
206
418
  disabled={caps ? !caps.canExport : true}
419
+ onMouseDown={preserveEditorSelectionMouseDown}
207
420
  className={[
208
421
  "inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors outline-none",
209
422
  focusRingClass,
@@ -214,7 +427,7 @@ export function TwToolbar(props: TwToolbarProps) {
214
427
  onClick={props.onExport}
215
428
  >
216
429
  <Download className="h-3.5 w-3.5" />
217
- Export
430
+ Export .docx
218
431
  </button>
219
432
  </Tooltip.Trigger>
220
433
  <Tooltip.Portal>
@@ -229,3 +442,69 @@ export function TwToolbar(props: TwToolbarProps) {
229
442
  </header>
230
443
  );
231
444
  }
445
+
446
+ function ToolbarParagraphStyleSelect(props: {
447
+ styles: StyleCatalogSnapshot["paragraphs"];
448
+ value?: string;
449
+ disabled: boolean;
450
+ onValueChange?: (styleId: string) => void;
451
+ }) {
452
+ const resolvedValue =
453
+ props.value && props.styles.some((style) => style.styleId === props.value)
454
+ ? props.value
455
+ : undefined;
456
+
457
+ return (
458
+ <Select.Root
459
+ disabled={props.disabled}
460
+ onValueChange={(value) => props.onValueChange?.(value)}
461
+ value={resolvedValue}
462
+ >
463
+ <Select.Trigger
464
+ aria-label="Paragraph style"
465
+ onMouseDown={preserveEditorSelectionMouseDown}
466
+ 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}`}
467
+ >
468
+ <Select.Value placeholder="Style" />
469
+ <Select.Icon>
470
+ <ChevronDown className="h-3.5 w-3.5 text-tertiary" />
471
+ </Select.Icon>
472
+ </Select.Trigger>
473
+ <Select.Portal>
474
+ <Select.Content
475
+ align="start"
476
+ className="z-50 overflow-hidden rounded-lg bg-canvas shadow-lg ring-1 ring-border"
477
+ position="popper"
478
+ sideOffset={8}
479
+ >
480
+ <Select.Viewport className="p-1">
481
+ {props.styles.map((style) => (
482
+ <Select.Item
483
+ 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}`}
484
+ key={style.styleId}
485
+ value={style.styleId}
486
+ >
487
+ <Select.ItemText>{style.displayName}</Select.ItemText>
488
+ </Select.Item>
489
+ ))}
490
+ </Select.Viewport>
491
+ </Select.Content>
492
+ </Select.Portal>
493
+ </Select.Root>
494
+ );
495
+ }
496
+
497
+ function storyLabel(target: EditorStoryTarget): string {
498
+ switch (target.kind) {
499
+ case "header":
500
+ return `Header (${target.variant})`;
501
+ case "footer":
502
+ return `Footer (${target.variant})`;
503
+ case "footnote":
504
+ return "Footnote";
505
+ case "endnote":
506
+ return "Endnote";
507
+ default:
508
+ return "Document";
509
+ }
510
+ }