@beyondwork/docx-react-component 1.0.53 → 1.0.55
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/package.json +1 -1
- package/src/api/public-types.ts +125 -7
- package/src/index.ts +5 -0
- package/src/io/docx-session.ts +27 -3
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/parse-field-switches.ts +134 -0
- package/src/io/ooxml/parse-fields.ts +28 -2
- package/src/model/canonical-document.ts +13 -2
- package/src/runtime/chart/chart-model-store.ts +88 -0
- package/src/runtime/chart/chart-snapshot.ts +239 -0
- package/src/runtime/collab/checkpoint-store.ts +1 -1
- package/src/runtime/collab/event-types.ts +4 -0
- package/src/runtime/collab/runtime-collab-sync.ts +1 -2
- package/src/runtime/document-runtime.ts +115 -13
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +58 -1
- package/src/runtime/layout/layout-invalidation.ts +150 -30
- package/src/runtime/layout/page-graph.ts +19 -0
- package/src/runtime/layout/paginated-layout-engine.ts +128 -19
- package/src/runtime/layout/project-block-fragments.ts +27 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/page-number-format.ts +207 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/surface-projection.ts +32 -3
- package/src/ui/WordReviewEditor.tsx +57 -3
- package/src/ui/headless/comment-decoration-model.ts +60 -5
- package/src/ui/headless/revision-decoration-model.ts +94 -6
- package/src/ui/shared/revision-filters.ts +16 -6
- package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
- package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
- package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
- package/src/ui-tailwind/chart/render/area.tsx +277 -0
- package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
- package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
- package/src/ui-tailwind/chart/render/combo.tsx +85 -0
- package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
- package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
- package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
- package/src/ui-tailwind/chart/render/line.tsx +363 -0
- package/src/ui-tailwind/chart/render/number-format.ts +120 -16
- package/src/ui-tailwind/chart/render/pie.tsx +275 -0
- package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
- package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
- package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
- package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
- package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
- package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
- package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
- package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
- package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
- package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
- package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
- package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
- package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
- package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
- package/src/ui-tailwind/theme/editor-theme.css +249 -22
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
|
@@ -4,15 +4,20 @@ import * as Tabs from "@radix-ui/react-tabs";
|
|
|
4
4
|
/**
|
|
5
5
|
* TwShellHeader — the top "app chrome" bar above the document canvas.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Designsystem §6.1 — three subregions on a single 48-px row:
|
|
8
8
|
* ┌─────────────────────────────────────────────────────────────────────┐
|
|
9
|
-
* │
|
|
9
|
+
* │ LEFT │ CENTER │ RIGHT │
|
|
10
|
+
* │ brand + slot │ mode tabs (always-on) │ icon actions + CTA │
|
|
10
11
|
* └─────────────────────────────────────────────────────────────────────┘
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Layout is CSS-grid `grid-cols-[1fr_auto_1fr]` so the center zone is
|
|
14
|
+
* always visually centred regardless of left / right slot width. The
|
|
15
|
+
* 4-mode switcher (edit / review / workflow / more) renders
|
|
16
|
+
* unconditionally — Lane 6b §6b.S1 flips it from opt-in to always-on.
|
|
17
|
+
*
|
|
18
|
+
* All colors, shadows, radius, and motion bind to the Lane 6a token
|
|
19
|
+
* substrate (`var(--color-*)` / `var(--shadow-*)` / `var(--radius-*)` /
|
|
20
|
+
* `var(--motion-*)`) — no hex literals or legacy Tailwind palette names.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
export type ShellHeaderMode = "edit" | "review" | "workflow" | "more";
|
|
@@ -40,6 +45,10 @@ export interface ShellHeaderIconAction {
|
|
|
40
45
|
|
|
41
46
|
export interface TwShellHeaderProps {
|
|
42
47
|
brand?: ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Mode tab options. When omitted, a default 4-mode set is rendered so
|
|
50
|
+
* the center subregion always has tabs per designsystem §6.1.
|
|
51
|
+
*/
|
|
43
52
|
modes?: readonly ShellHeaderModeOption[];
|
|
44
53
|
activeMode?: ShellHeaderMode;
|
|
45
54
|
onModeChange?: (mode: ShellHeaderMode) => void;
|
|
@@ -50,23 +59,33 @@ export interface TwShellHeaderProps {
|
|
|
50
59
|
className?: string;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Default 4-mode set — designsystem §6.1. Exported so hosts can extend
|
|
64
|
+
* / relabel without reconstructing the whole list.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_SHELL_HEADER_MODES: readonly ShellHeaderModeOption[] = [
|
|
67
|
+
{ id: "edit", label: "Edit" },
|
|
68
|
+
{ id: "review", label: "Review" },
|
|
69
|
+
{ id: "workflow", label: "Workflow" },
|
|
70
|
+
{ id: "more", label: "More" },
|
|
71
|
+
];
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
props.brand ||
|
|
59
|
-
(props.modes && props.modes.length > 0) ||
|
|
60
|
-
(props.iconActions && props.iconActions.length > 0) ||
|
|
61
|
-
props.primaryAction;
|
|
73
|
+
const focusRingClass =
|
|
74
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]";
|
|
62
75
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
export function TwShellHeader(props: TwShellHeaderProps): React.ReactElement {
|
|
77
|
+
const modes =
|
|
78
|
+
props.modes && props.modes.length > 0
|
|
79
|
+
? props.modes
|
|
80
|
+
: DEFAULT_SHELL_HEADER_MODES;
|
|
81
|
+
const activeMode: ShellHeaderMode = props.activeMode ?? modes[0]!.id;
|
|
66
82
|
|
|
67
83
|
const className = [
|
|
68
|
-
"
|
|
69
|
-
|
|
84
|
+
"grid h-12 shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-2 px-4",
|
|
85
|
+
"bg-[var(--color-bg-chrome)]/92 backdrop-blur-sm",
|
|
86
|
+
props.isScrolled
|
|
87
|
+
? "border-b border-[var(--color-border-subtle)]"
|
|
88
|
+
: "border-b border-transparent",
|
|
70
89
|
"transition-colors duration-[var(--motion-fast)]",
|
|
71
90
|
props.className,
|
|
72
91
|
]
|
|
@@ -75,10 +94,15 @@ export function TwShellHeader(props: TwShellHeaderProps) {
|
|
|
75
94
|
|
|
76
95
|
return (
|
|
77
96
|
<header className={className} data-testid="tw-shell-header">
|
|
78
|
-
|
|
97
|
+
{/* LEFT: brand + host-supplied slot */}
|
|
98
|
+
<div
|
|
99
|
+
className="flex min-w-0 items-center gap-3"
|
|
100
|
+
data-region="left"
|
|
101
|
+
data-testid="tw-shell-header__region-left"
|
|
102
|
+
>
|
|
79
103
|
{props.brand ? (
|
|
80
104
|
<div
|
|
81
|
-
className="font-[family-name:var(--font-legal-serif)] text-[15px] font-semibold text-primary
|
|
105
|
+
className="truncate font-[family-name:var(--font-legal-serif)] text-[15px] font-semibold text-[var(--color-text-primary)]"
|
|
82
106
|
data-testid="tw-shell-header__brand"
|
|
83
107
|
>
|
|
84
108
|
{props.brand}
|
|
@@ -86,9 +110,14 @@ export function TwShellHeader(props: TwShellHeaderProps) {
|
|
|
86
110
|
) : null}
|
|
87
111
|
</div>
|
|
88
112
|
|
|
89
|
-
{
|
|
113
|
+
{/* CENTER: mode tabs (always on) */}
|
|
114
|
+
<div
|
|
115
|
+
className="flex items-center justify-center"
|
|
116
|
+
data-region="center"
|
|
117
|
+
data-testid="tw-shell-header__region-center"
|
|
118
|
+
>
|
|
90
119
|
<Tabs.Root
|
|
91
|
-
value={
|
|
120
|
+
value={activeMode}
|
|
92
121
|
onValueChange={(v: string) =>
|
|
93
122
|
props.onModeChange?.(v as ShellHeaderMode)
|
|
94
123
|
}
|
|
@@ -97,7 +126,7 @@ export function TwShellHeader(props: TwShellHeaderProps) {
|
|
|
97
126
|
aria-label="Workspace modes"
|
|
98
127
|
className="flex items-center gap-1"
|
|
99
128
|
>
|
|
100
|
-
{
|
|
129
|
+
{modes.map((mode) => (
|
|
101
130
|
<Tabs.Trigger
|
|
102
131
|
key={mode.id}
|
|
103
132
|
value={mode.id}
|
|
@@ -110,17 +139,26 @@ export function TwShellHeader(props: TwShellHeaderProps) {
|
|
|
110
139
|
))}
|
|
111
140
|
</Tabs.List>
|
|
112
141
|
</Tabs.Root>
|
|
113
|
-
|
|
114
|
-
<div aria-hidden="true" />
|
|
115
|
-
)}
|
|
142
|
+
</div>
|
|
116
143
|
|
|
117
|
-
|
|
144
|
+
{/* RIGHT: icon actions + primary CTA */}
|
|
145
|
+
<div
|
|
146
|
+
className="flex items-center justify-end gap-1"
|
|
147
|
+
data-region="right"
|
|
148
|
+
data-testid="tw-shell-header__region-right"
|
|
149
|
+
>
|
|
118
150
|
{props.iconActions?.map((action) => {
|
|
119
151
|
const commonProps = {
|
|
120
152
|
key: action.id,
|
|
121
153
|
"aria-label": action.label,
|
|
122
154
|
title: action.label,
|
|
123
|
-
className:
|
|
155
|
+
className: [
|
|
156
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-sm)]",
|
|
157
|
+
"text-[var(--color-text-secondary)]",
|
|
158
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
159
|
+
"hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]",
|
|
160
|
+
focusRingClass,
|
|
161
|
+
].join(" "),
|
|
124
162
|
} as const;
|
|
125
163
|
|
|
126
164
|
if (action.href) {
|
|
@@ -145,10 +183,13 @@ export function TwShellHeader(props: TwShellHeaderProps) {
|
|
|
145
183
|
onClick={props.primaryAction.onClick}
|
|
146
184
|
data-tone={props.primaryAction.tone ?? "accent"}
|
|
147
185
|
className={[
|
|
148
|
-
"ml-2 inline-flex h-8 items-center rounded-sm px-3
|
|
186
|
+
"ml-2 inline-flex h-8 items-center rounded-[var(--radius-sm)] px-3",
|
|
187
|
+
"text-xs font-semibold",
|
|
188
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
189
|
+
"disabled:opacity-40",
|
|
149
190
|
props.primaryAction.tone === "neutral"
|
|
150
|
-
? "bg-
|
|
151
|
-
: "bg-accent text-
|
|
191
|
+
? "bg-[var(--color-bg-muted)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]"
|
|
192
|
+
: "bg-[var(--color-accent-primary)] text-[var(--color-text-on-accent)] hover:bg-[var(--color-accent-primary-hover)]",
|
|
152
193
|
focusRingClass,
|
|
153
194
|
].join(" ")}
|
|
154
195
|
data-testid="tw-shell-header__primary-action"
|
|
@@ -7,13 +7,31 @@ export interface TwToolbarIconButtonProps {
|
|
|
7
7
|
icon: React.ComponentType<{ className?: string }>;
|
|
8
8
|
label: string;
|
|
9
9
|
disabled?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Active / pressed state. Designsystem §6.2 + Lane 6b §6b.S2: active
|
|
12
|
+
* buttons paint an accent-soft tint with an accent border ring — NOT a
|
|
13
|
+
* filled accent CTA. The filled-CTA grammar is reserved for the shell
|
|
14
|
+
* header primary action only.
|
|
15
|
+
*/
|
|
10
16
|
active?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Emphasis variant — used for "stand-out" toolbar items (e.g. a pinned
|
|
19
|
+
* AI action). Paints the accent glyph color at rest; still tints on
|
|
20
|
+
* hover and on active.
|
|
21
|
+
*/
|
|
11
22
|
emphasis?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Lane 6b §6b.U6 — optional keyboard-shortcut hint rendered as a small
|
|
25
|
+
* `<kbd>` chip to the right of the label inside the tooltip. Use
|
|
26
|
+
* platform-agnostic symbols (⌘, ⇧, ⌥, ⌃) — callers format for macOS
|
|
27
|
+
* vs. Windows however they like.
|
|
28
|
+
*/
|
|
29
|
+
shortcut?: string;
|
|
12
30
|
onClick?: () => void;
|
|
13
31
|
}
|
|
14
32
|
|
|
15
33
|
const focusRingClass =
|
|
16
|
-
"focus-visible:outline-none focus-visible:
|
|
34
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]";
|
|
17
35
|
|
|
18
36
|
export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
|
|
19
37
|
return (
|
|
@@ -22,17 +40,22 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
|
|
|
22
40
|
<button
|
|
23
41
|
type="button"
|
|
24
42
|
aria-label={props.label}
|
|
43
|
+
aria-pressed={props.active ?? undefined}
|
|
44
|
+
data-active={props.active ? "true" : undefined}
|
|
25
45
|
disabled={props.disabled}
|
|
26
46
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
27
47
|
onClick={props.onClick}
|
|
28
48
|
className={[
|
|
29
|
-
"inline-flex h-6 w-6 items-center justify-center rounded-
|
|
49
|
+
"inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-sm)]",
|
|
50
|
+
"border border-transparent outline-none",
|
|
51
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
30
52
|
"disabled:opacity-30 disabled:cursor-not-allowed",
|
|
31
|
-
props.
|
|
32
|
-
?
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
props.active
|
|
54
|
+
? // Active = underline-tint grammar: accent-soft fill, accent-primary glyph, accent border ring.
|
|
55
|
+
"bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)] border-[var(--color-border-accent)]"
|
|
56
|
+
: props.emphasis
|
|
57
|
+
? "text-[var(--color-accent-primary)] hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-border-subtle)]"
|
|
58
|
+
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border-subtle)]",
|
|
36
59
|
focusRingClass,
|
|
37
60
|
].join(" ")}
|
|
38
61
|
>
|
|
@@ -41,10 +64,27 @@ export function TwToolbarIconButton(props: TwToolbarIconButtonProps) {
|
|
|
41
64
|
</Tooltip.Trigger>
|
|
42
65
|
<Tooltip.Portal>
|
|
43
66
|
<Tooltip.Content
|
|
44
|
-
className=
|
|
67
|
+
className={[
|
|
68
|
+
"inline-flex items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1 text-xs z-50",
|
|
69
|
+
"bg-[var(--color-text-primary)] text-[var(--color-text-inverse)]",
|
|
70
|
+
"shadow-[var(--shadow-soft)]",
|
|
71
|
+
].join(" ")}
|
|
45
72
|
sideOffset={6}
|
|
46
73
|
>
|
|
47
|
-
{props.label}
|
|
74
|
+
<span>{props.label}</span>
|
|
75
|
+
{props.shortcut ? (
|
|
76
|
+
<kbd
|
|
77
|
+
className={[
|
|
78
|
+
"inline-flex items-center rounded-[var(--radius-sm)]",
|
|
79
|
+
"px-1 py-0.5 font-sans text-[10px] font-medium",
|
|
80
|
+
"border border-[var(--color-border-subtle)]/40",
|
|
81
|
+
"bg-[var(--color-bg-overlay)] text-[var(--color-text-inverse)]/80",
|
|
82
|
+
].join(" ")}
|
|
83
|
+
data-testid="tw-toolbar-icon-button__shortcut"
|
|
84
|
+
>
|
|
85
|
+
{props.shortcut}
|
|
86
|
+
</kbd>
|
|
87
|
+
) : null}
|
|
48
88
|
</Tooltip.Content>
|
|
49
89
|
</Tooltip.Portal>
|
|
50
90
|
</Tooltip.Root>
|
|
@@ -174,6 +174,40 @@ export interface TwToolbarProps {
|
|
|
174
174
|
chromePins?: ChromePinsState;
|
|
175
175
|
/** Called when the user detaches or re-attaches the topnav. */
|
|
176
176
|
onChromePinChange?: (surface: ChromePinSurface, pin: PinState | null) => void;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Lane 6b §6b.U1 — unread count badges on the sidebar toggle when the
|
|
180
|
+
* review rail is closed. Hosts pass summed open-comment / open-change
|
|
181
|
+
* counts from the runtime snapshot; the badge only shows when
|
|
182
|
+
* `isSidebarOpen === false` and `(openCommentCount + openChangeCount) > 0`.
|
|
183
|
+
*/
|
|
184
|
+
openCommentCount?: number;
|
|
185
|
+
openChangeCount?: number;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Lane 6b §6b.U2 — mixed-value grammar for the three style / font
|
|
189
|
+
* dropdowns. When `true`, the trigger renders italic "Mixed" instead
|
|
190
|
+
* of the current value (selection spans different values). Hosts
|
|
191
|
+
* compute this from whatever mixed-selection signal their runtime
|
|
192
|
+
* exposes; undefined keeps today's single-value behaviour.
|
|
193
|
+
*/
|
|
194
|
+
hasMixedParagraphStyle?: boolean;
|
|
195
|
+
hasMixedFontFamily?: boolean;
|
|
196
|
+
hasMixedFontSize?: boolean;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Lane 6b §6b.U3 — per-item disabled-explanation reasons for the
|
|
200
|
+
* Insert menu. When the item is disabled (either because its handler
|
|
201
|
+
* is undefined or because `canInsertStructural` is false), hovering
|
|
202
|
+
* it surfaces `title="Not available: {reason}"` so the user understands
|
|
203
|
+
* WHY the capability is missing in the current context.
|
|
204
|
+
*/
|
|
205
|
+
insertDisabledReasons?: {
|
|
206
|
+
pageBreak?: string;
|
|
207
|
+
table?: string;
|
|
208
|
+
image?: string;
|
|
209
|
+
sectionBreak?: string;
|
|
210
|
+
};
|
|
177
211
|
}
|
|
178
212
|
|
|
179
213
|
export interface ToolbarInteractionPolicy {
|
|
@@ -275,11 +309,26 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
275
309
|
|
|
276
310
|
return (
|
|
277
311
|
<header
|
|
312
|
+
data-testid="tw-toolbar"
|
|
313
|
+
style={
|
|
314
|
+
isCompact
|
|
315
|
+
? undefined
|
|
316
|
+
: {
|
|
317
|
+
// Lane 6b §6b.U5 — density opt-in. Scales the 40 px base
|
|
318
|
+
// by `--space-density-multiplier` so `data-density="compact"`
|
|
319
|
+
// shrinks the toolbar to ~34 px and `comfortable` expands
|
|
320
|
+
// it to ~46 px. Wrap mode keeps flex-wrap semantics in
|
|
321
|
+
// compact viewports.
|
|
322
|
+
height:
|
|
323
|
+
"calc(40px * var(--space-density-multiplier, 1))",
|
|
324
|
+
}
|
|
325
|
+
}
|
|
278
326
|
className={[
|
|
279
|
-
"shrink-0 rounded-
|
|
327
|
+
"shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border-subtle)]",
|
|
328
|
+
"bg-[var(--color-bg-chrome)]/92 px-2.5 shadow-[var(--shadow-soft)] backdrop-blur-sm",
|
|
280
329
|
isCompact
|
|
281
330
|
? "flex min-h-10 flex-wrap items-center gap-1.5 py-1.5"
|
|
282
|
-
: "flex
|
|
331
|
+
: "flex items-center gap-1",
|
|
283
332
|
].join(" ")}
|
|
284
333
|
>
|
|
285
334
|
{/* Left cluster: undo/redo + formatting */}
|
|
@@ -287,12 +336,14 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
287
336
|
<TwToolbarIconButton
|
|
288
337
|
icon={Undo2}
|
|
289
338
|
label="Undo"
|
|
339
|
+
shortcut="⌘Z"
|
|
290
340
|
disabled={caps ? !caps.canUndo : true}
|
|
291
341
|
onClick={props.onUndo}
|
|
292
342
|
/>
|
|
293
343
|
<TwToolbarIconButton
|
|
294
344
|
icon={Redo2}
|
|
295
345
|
label="Redo"
|
|
346
|
+
shortcut="⌘⇧Z"
|
|
296
347
|
disabled={caps ? !caps.canRedo : true}
|
|
297
348
|
onClick={props.onRedo}
|
|
298
349
|
/>
|
|
@@ -304,17 +355,20 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
304
355
|
disabled={!canEdit || paragraphStyles.length === 0 || !props.onSetParagraphStyle}
|
|
305
356
|
styles={paragraphStyles}
|
|
306
357
|
value={props.formattingState?.paragraphStyleId}
|
|
358
|
+
hasMixedValue={props.hasMixedParagraphStyle ?? false}
|
|
307
359
|
onValueChange={props.onSetParagraphStyle}
|
|
308
360
|
/>
|
|
309
361
|
|
|
310
362
|
<ToolbarFontFamilySelect
|
|
311
363
|
disabled={!canEdit || !props.onSetFontFamily}
|
|
312
364
|
value={props.formattingState?.fontFamily}
|
|
365
|
+
hasMixedValue={props.hasMixedFontFamily ?? false}
|
|
313
366
|
onValueChange={props.onSetFontFamily}
|
|
314
367
|
/>
|
|
315
368
|
<ToolbarFontSizeSelect
|
|
316
369
|
disabled={!canEdit || !props.onSetFontSize}
|
|
317
370
|
value={props.formattingState?.fontSize}
|
|
371
|
+
hasMixedValue={props.hasMixedFontSize ?? false}
|
|
318
372
|
onValueChange={props.onSetFontSize}
|
|
319
373
|
/>
|
|
320
374
|
|
|
@@ -327,6 +381,7 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
327
381
|
<TwToolbarIconButton
|
|
328
382
|
icon={Bold}
|
|
329
383
|
label="Bold"
|
|
384
|
+
shortcut="⌘B"
|
|
330
385
|
active={props.formattingState?.bold ?? false}
|
|
331
386
|
disabled={!canEdit}
|
|
332
387
|
onClick={props.onToggleBold}
|
|
@@ -334,6 +389,7 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
334
389
|
<TwToolbarIconButton
|
|
335
390
|
icon={Italic}
|
|
336
391
|
label="Italic"
|
|
392
|
+
shortcut="⌘I"
|
|
337
393
|
active={props.formattingState?.italic ?? false}
|
|
338
394
|
disabled={!canEdit}
|
|
339
395
|
onClick={props.onToggleItalic}
|
|
@@ -341,6 +397,7 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
341
397
|
<TwToolbarIconButton
|
|
342
398
|
icon={Underline}
|
|
343
399
|
label="Underline"
|
|
400
|
+
shortcut="⌘U"
|
|
344
401
|
active={props.formattingState?.underline ?? false}
|
|
345
402
|
disabled={!canEdit}
|
|
346
403
|
onClick={props.onToggleUnderline}
|
|
@@ -455,6 +512,7 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
455
512
|
{showInsertMenu && showInsertActionsInRow ? (
|
|
456
513
|
<ToolbarInsertMenu
|
|
457
514
|
disabled={!canInsertStructural}
|
|
515
|
+
disabledReasons={props.insertDisabledReasons}
|
|
458
516
|
onInsertImage={props.onInsertImage}
|
|
459
517
|
onInsertPageBreak={props.onInsertPageBreak}
|
|
460
518
|
onInsertSectionBreak={props.onInsertSectionBreak}
|
|
@@ -581,12 +639,43 @@ export function TwToolbar(props: TwToolbarProps) {
|
|
|
581
639
|
) : null}
|
|
582
640
|
{showSidebarToggle ? (
|
|
583
641
|
<>
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
642
|
+
{/*
|
|
643
|
+
Lane 6b §6b.U1 — unread badge on the sidebar toggle when the
|
|
644
|
+
rail is closed. Host passes summed open-comment / open-
|
|
645
|
+
change counts; we only paint the badge when the rail is
|
|
646
|
+
closed AND there is something unread.
|
|
647
|
+
*/}
|
|
648
|
+
<span className="relative inline-flex">
|
|
649
|
+
<TwToolbarIconButton
|
|
650
|
+
icon={PanelRight}
|
|
651
|
+
label="Toggle sidebar"
|
|
652
|
+
active={props.isSidebarOpen ?? false}
|
|
653
|
+
onClick={props.onToggleSidebar}
|
|
654
|
+
/>
|
|
655
|
+
{(() => {
|
|
656
|
+
const isOpen = props.isSidebarOpen ?? false;
|
|
657
|
+
const count =
|
|
658
|
+
(props.openCommentCount ?? 0) +
|
|
659
|
+
(props.openChangeCount ?? 0);
|
|
660
|
+
if (isOpen || count <= 0) return null;
|
|
661
|
+
const display = count > 99 ? "99+" : String(count);
|
|
662
|
+
return (
|
|
663
|
+
<span
|
|
664
|
+
className={[
|
|
665
|
+
"pointer-events-none absolute -top-1 -right-1",
|
|
666
|
+
"inline-flex h-4 min-w-4 items-center justify-center",
|
|
667
|
+
"rounded-[var(--radius-pill)] px-1",
|
|
668
|
+
"bg-[var(--color-accent-primary)] text-[var(--color-text-on-accent)]",
|
|
669
|
+
"text-[9px] font-semibold leading-none",
|
|
670
|
+
].join(" ")}
|
|
671
|
+
data-testid="toolbar-sidebar-toggle-badge"
|
|
672
|
+
aria-label={`${count} unread review items`}
|
|
673
|
+
>
|
|
674
|
+
{display}
|
|
675
|
+
</span>
|
|
676
|
+
);
|
|
677
|
+
})()}
|
|
678
|
+
</span>
|
|
590
679
|
<div className="mx-1 h-4 w-px bg-border" />
|
|
591
680
|
</>
|
|
592
681
|
) : null}
|
|
@@ -851,10 +940,14 @@ function ToolbarParagraphStyleSelect(props: {
|
|
|
851
940
|
styles: StyleCatalogSnapshot["paragraphs"];
|
|
852
941
|
value?: string;
|
|
853
942
|
disabled: boolean;
|
|
943
|
+
hasMixedValue?: boolean;
|
|
854
944
|
onValueChange?: (styleId: string) => void;
|
|
855
945
|
}) {
|
|
946
|
+
const isMixed = props.hasMixedValue === true;
|
|
856
947
|
const resolvedValue =
|
|
857
|
-
|
|
948
|
+
!isMixed &&
|
|
949
|
+
props.value &&
|
|
950
|
+
props.styles.some((style) => style.styleId === props.value)
|
|
858
951
|
? props.value
|
|
859
952
|
: "";
|
|
860
953
|
|
|
@@ -868,10 +961,20 @@ function ToolbarParagraphStyleSelect(props: {
|
|
|
868
961
|
aria-label="Paragraph style"
|
|
869
962
|
aria-disabled={props.disabled || undefined}
|
|
870
963
|
data-disabled={props.disabled ? "" : undefined}
|
|
964
|
+
data-mixed={isMixed ? "true" : undefined}
|
|
871
965
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
872
966
|
className={`inline-flex h-6 min-w-[7.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
|
|
873
967
|
>
|
|
874
|
-
|
|
968
|
+
{isMixed ? (
|
|
969
|
+
<span
|
|
970
|
+
className="italic text-[var(--color-text-tertiary)]"
|
|
971
|
+
data-testid="toolbar-paragraph-style-mixed"
|
|
972
|
+
>
|
|
973
|
+
Mixed
|
|
974
|
+
</span>
|
|
975
|
+
) : (
|
|
976
|
+
<Select.Value placeholder="Style" />
|
|
977
|
+
)}
|
|
875
978
|
<Select.Icon>
|
|
876
979
|
<ChevronDown className="h-3.5 w-3.5 text-tertiary" />
|
|
877
980
|
</Select.Icon>
|
|
@@ -903,9 +1006,14 @@ function ToolbarParagraphStyleSelect(props: {
|
|
|
903
1006
|
function ToolbarFontFamilySelect(props: {
|
|
904
1007
|
value?: string;
|
|
905
1008
|
disabled: boolean;
|
|
1009
|
+
hasMixedValue?: boolean;
|
|
906
1010
|
onValueChange?: (fontFamily: string) => void;
|
|
907
1011
|
}) {
|
|
908
|
-
const
|
|
1012
|
+
const isMixed = props.hasMixedValue === true;
|
|
1013
|
+
const resolvedValue =
|
|
1014
|
+
!isMixed && props.value && FONT_FAMILIES.includes(props.value)
|
|
1015
|
+
? props.value
|
|
1016
|
+
: "";
|
|
909
1017
|
|
|
910
1018
|
return (
|
|
911
1019
|
<Select.Root
|
|
@@ -917,10 +1025,20 @@ function ToolbarFontFamilySelect(props: {
|
|
|
917
1025
|
aria-label="Font family"
|
|
918
1026
|
aria-disabled={props.disabled || undefined}
|
|
919
1027
|
data-disabled={props.disabled ? "" : undefined}
|
|
1028
|
+
data-mixed={isMixed ? "true" : undefined}
|
|
920
1029
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
921
1030
|
className={`inline-flex h-6 min-w-[6.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
|
|
922
1031
|
>
|
|
923
|
-
|
|
1032
|
+
{isMixed ? (
|
|
1033
|
+
<span
|
|
1034
|
+
className="italic text-[var(--color-text-tertiary)]"
|
|
1035
|
+
data-testid="toolbar-font-family-mixed"
|
|
1036
|
+
>
|
|
1037
|
+
Mixed
|
|
1038
|
+
</span>
|
|
1039
|
+
) : (
|
|
1040
|
+
<Select.Value placeholder="Font" />
|
|
1041
|
+
)}
|
|
924
1042
|
<Select.Icon>
|
|
925
1043
|
<ChevronDown className="h-3.5 w-3.5 text-tertiary" />
|
|
926
1044
|
</Select.Icon>
|
|
@@ -952,10 +1070,16 @@ function ToolbarFontFamilySelect(props: {
|
|
|
952
1070
|
function ToolbarFontSizeSelect(props: {
|
|
953
1071
|
value?: number;
|
|
954
1072
|
disabled: boolean;
|
|
1073
|
+
hasMixedValue?: boolean;
|
|
955
1074
|
onValueChange?: (fontSize: number) => void;
|
|
956
1075
|
}) {
|
|
1076
|
+
const isMixed = props.hasMixedValue === true;
|
|
957
1077
|
const resolvedValue =
|
|
958
|
-
|
|
1078
|
+
!isMixed &&
|
|
1079
|
+
typeof props.value === "number" &&
|
|
1080
|
+
FONT_SIZES.includes(props.value)
|
|
1081
|
+
? String(props.value)
|
|
1082
|
+
: "";
|
|
959
1083
|
|
|
960
1084
|
return (
|
|
961
1085
|
<Select.Root
|
|
@@ -967,10 +1091,20 @@ function ToolbarFontSizeSelect(props: {
|
|
|
967
1091
|
aria-label="Font size"
|
|
968
1092
|
aria-disabled={props.disabled || undefined}
|
|
969
1093
|
data-disabled={props.disabled ? "" : undefined}
|
|
1094
|
+
data-mixed={isMixed ? "true" : undefined}
|
|
970
1095
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
971
1096
|
className={`inline-flex h-6 min-w-[3.5rem] items-center justify-between gap-2 rounded-md border border-border bg-canvas px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface outline-none disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
|
|
972
1097
|
>
|
|
973
|
-
|
|
1098
|
+
{isMixed ? (
|
|
1099
|
+
<span
|
|
1100
|
+
className="italic text-[var(--color-text-tertiary)]"
|
|
1101
|
+
data-testid="toolbar-font-size-mixed"
|
|
1102
|
+
>
|
|
1103
|
+
Mixed
|
|
1104
|
+
</span>
|
|
1105
|
+
) : (
|
|
1106
|
+
<Select.Value placeholder="Size" />
|
|
1107
|
+
)}
|
|
974
1108
|
<Select.Icon>
|
|
975
1109
|
<ChevronDown className="h-3.5 w-3.5 text-tertiary" />
|
|
976
1110
|
</Select.Icon>
|
|
@@ -1483,6 +1617,17 @@ function ToolbarAlignmentPopover(props: {
|
|
|
1483
1617
|
|
|
1484
1618
|
function ToolbarInsertMenu(props: {
|
|
1485
1619
|
disabled: boolean;
|
|
1620
|
+
/**
|
|
1621
|
+
* Lane 6b §6b.U3 — optional per-item disabled-explanation reasons.
|
|
1622
|
+
* When the item is disabled (no handler or policy-gated), hovering it
|
|
1623
|
+
* surfaces a `title=` tooltip "Not available: {reason}".
|
|
1624
|
+
*/
|
|
1625
|
+
disabledReasons?: {
|
|
1626
|
+
pageBreak?: string;
|
|
1627
|
+
table?: string;
|
|
1628
|
+
image?: string;
|
|
1629
|
+
sectionBreak?: string;
|
|
1630
|
+
};
|
|
1486
1631
|
onInsertPageBreak?: () => void;
|
|
1487
1632
|
onInsertTable?: () => void;
|
|
1488
1633
|
onInsertSectionBreak?: (type: SectionBreakType) => void;
|
|
@@ -1535,6 +1680,7 @@ function ToolbarInsertMenu(props: {
|
|
|
1535
1680
|
<ToolbarMenuButton
|
|
1536
1681
|
ariaLabel="Insert page break"
|
|
1537
1682
|
disabled={props.disabled || !props.onInsertPageBreak}
|
|
1683
|
+
disabledReason={props.disabledReasons?.pageBreak}
|
|
1538
1684
|
icon={<Minus className="h-3.5 w-3.5" />}
|
|
1539
1685
|
label="Page break"
|
|
1540
1686
|
onClick={() => {
|
|
@@ -1545,6 +1691,7 @@ function ToolbarInsertMenu(props: {
|
|
|
1545
1691
|
<ToolbarMenuButton
|
|
1546
1692
|
ariaLabel="Insert table"
|
|
1547
1693
|
disabled={props.disabled || !props.onInsertTable}
|
|
1694
|
+
disabledReason={props.disabledReasons?.table}
|
|
1548
1695
|
icon={<Rows3 className="h-3.5 w-3.5" />}
|
|
1549
1696
|
label="Table"
|
|
1550
1697
|
onClick={() => {
|
|
@@ -1573,6 +1720,7 @@ function ToolbarInsertMenu(props: {
|
|
|
1573
1720
|
<ToolbarMenuButton
|
|
1574
1721
|
ariaLabel="Insert next-page section break"
|
|
1575
1722
|
disabled={props.disabled || !props.onInsertSectionBreak}
|
|
1723
|
+
disabledReason={props.disabledReasons?.sectionBreak}
|
|
1576
1724
|
icon={<FileText className="h-3.5 w-3.5" />}
|
|
1577
1725
|
label="Next-page section break"
|
|
1578
1726
|
onClick={() => {
|
|
@@ -1616,13 +1764,29 @@ function ToolbarMenuButton(props: {
|
|
|
1616
1764
|
disabled: boolean;
|
|
1617
1765
|
icon: React.ReactNode;
|
|
1618
1766
|
label: string;
|
|
1767
|
+
/**
|
|
1768
|
+
* Lane 6b §6b.U3 — optional explanation surfaced as `title=` when the
|
|
1769
|
+
* item is disabled. Hosts pass the capability-policy reason so users
|
|
1770
|
+
* hover the entry and understand why it is unavailable in the current
|
|
1771
|
+
* context, rather than just seeing a faded click target.
|
|
1772
|
+
*/
|
|
1773
|
+
disabledReason?: string;
|
|
1619
1774
|
onClick?: () => void;
|
|
1620
1775
|
}) {
|
|
1776
|
+
const titleAttr =
|
|
1777
|
+
props.disabled && props.disabledReason
|
|
1778
|
+
? `Not available: ${props.disabledReason}`
|
|
1779
|
+
: undefined;
|
|
1621
1780
|
return (
|
|
1622
1781
|
<button
|
|
1623
1782
|
type="button"
|
|
1624
1783
|
aria-label={props.ariaLabel}
|
|
1784
|
+
aria-disabled={props.disabled ? "true" : undefined}
|
|
1625
1785
|
disabled={props.disabled}
|
|
1786
|
+
title={titleAttr}
|
|
1787
|
+
data-disabled-reason={
|
|
1788
|
+
props.disabled && props.disabledReason ? props.disabledReason : undefined
|
|
1789
|
+
}
|
|
1626
1790
|
onMouseDown={preserveEditorSelectionMouseDown}
|
|
1627
1791
|
onClick={props.onClick}
|
|
1628
1792
|
className={`flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 ${focusRingClass}`}
|