@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.
- package/README.md +8 -2
- package/package.json +24 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +710 -4
- package/src/api/session-state.ts +60 -0
- package/src/core/commands/formatting-commands.ts +2 -1
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +19 -3
- package/src/core/commands/list-commands.ts +231 -36
- package/src/core/commands/paragraph-layout-commands.ts +339 -0
- package/src/core/commands/section-layout-commands.ts +680 -0
- package/src/core/commands/style-commands.ts +262 -0
- package/src/core/search/search-text.ts +357 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +4 -1
- package/src/index.ts +51 -0
- package/src/io/docx-session.ts +623 -56
- package/src/io/export/serialize-comments.ts +104 -34
- package/src/io/export/serialize-footnotes.ts +198 -1
- package/src/io/export/serialize-headers-footers.ts +203 -10
- package/src/io/export/serialize-main-document.ts +285 -8
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/export/split-review-boundaries.ts +181 -19
- package/src/io/normalize/normalize-text.ts +144 -32
- package/src/io/ooxml/highlight-colors.ts +39 -0
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-comments.ts +85 -19
- package/src/io/ooxml/parse-fields.ts +396 -0
- package/src/io/ooxml/parse-footnotes.ts +452 -22
- package/src/io/ooxml/parse-headers-footers.ts +657 -29
- package/src/io/ooxml/parse-inline-media.ts +30 -0
- package/src/io/ooxml/parse-main-document.ts +807 -20
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-revisions.ts +317 -38
- package/src/io/ooxml/parse-settings.ts +184 -0
- package/src/io/ooxml/parse-shapes.ts +25 -0
- package/src/io/ooxml/parse-styles.ts +463 -0
- package/src/io/ooxml/parse-theme.ts +32 -0
- package/src/legal/bookmarks.ts +44 -0
- package/src/legal/cross-references.ts +59 -1
- package/src/model/canonical-document.ts +250 -4
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +87 -2
- package/src/review/store/revision-store.ts +6 -0
- package/src/review/store/revision-types.ts +1 -0
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +603 -0
- package/src/runtime/document-runtime.ts +1754 -78
- package/src/runtime/document-search.ts +145 -0
- package/src/runtime/numbering-prefix.ts +47 -26
- package/src/runtime/page-layout-estimation.ts +212 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
- package/src/runtime/session-capabilities.ts +35 -3
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +324 -36
- package/src/runtime/table-schema.ts +89 -7
- package/src/runtime/view-state.ts +477 -0
- package/src/runtime/workflow-markup.ts +349 -0
- package/src/ui/WordReviewEditor.tsx +2469 -1344
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/editor-command-bag.ts +120 -0
- package/src/ui/editor-runtime-boundary.ts +1422 -0
- package/src/ui/editor-shell-view.tsx +134 -0
- package/src/ui/editor-surface-controller.tsx +51 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/revision-decoration-model.ts +4 -4
- package/src/ui/headless/selection-helpers.ts +20 -0
- package/src/ui/headless/selection-toolbar-model.ts +22 -0
- package/src/ui/headless/use-editor-keyboard.ts +6 -1
- package/src/ui/runtime-snapshot-selectors.ts +197 -0
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
- package/src/ui-tailwind/index.ts +2 -1
- package/src/ui-tailwind/page-chrome-model.ts +27 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
- package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
- package/src/ui-tailwind/theme/editor-theme.css +127 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
- package/src/validation/compatibility-engine.ts +119 -24
- package/src/validation/compatibility-report.ts +1 -0
- package/src/validation/diagnostics.ts +1 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">←</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) /
|
|
307
|
+
{/* View mode toggle group: Canvas (clean, flowing) / Page (layout-sensitive) */}
|
|
111
308
|
<ToggleGroup.Root
|
|
112
309
|
type="single"
|
|
113
|
-
value={
|
|
310
|
+
value={workspaceMode}
|
|
114
311
|
onValueChange={(v: string) => {
|
|
115
|
-
if (v) props.
|
|
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="
|
|
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
|
-
|
|
346
|
+
Page — layout-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
|
+
}
|