@beyondwork/docx-react-component 1.0.52 → 1.0.54
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 +31 -40
- package/src/api/public-types.ts +67 -7
- package/src/io/chart-preview-resolver.ts +41 -0
- package/src/io/docx-session.ts +217 -23
- 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 +88 -8
- package/src/runtime/document-runtime.ts +182 -9
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +97 -2
- 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 +70 -1
- package/src/runtime/prerender/cache-envelope.ts +30 -0
- package/src/runtime/prerender/customxml-cache.ts +17 -3
- package/src/runtime/prerender/prerender-document.ts +17 -1
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/render/render-kernel.ts +67 -19
- package/src/runtime/surface-projection.ts +28 -0
- package/src/runtime/table-schema.ts +27 -0
- package/src/runtime/table-style-resolver.ts +51 -0
- package/src/ui/WordReviewEditor.tsx +6 -3
- package/src/ui/editor-runtime-boundary.ts +39 -2
- 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/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
- 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/chart-palette-adapter.ts +57 -0
- package/src/ui-tailwind/theme/editor-theme.css +275 -46
- package/src/ui-tailwind/theme/tokens.css +345 -0
- package/src/ui-tailwind/theme/tokens.ts +313 -0
- package/src/ui-tailwind/theme/use-density.ts +60 -0
- 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
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* TwCommandPalette — Ctrl+K / Cmd+K overflow-relief valve for every
|
|
12
|
+
* editor command (designsystem §6.25, Lane 6b §6b.N1).
|
|
13
|
+
*
|
|
14
|
+
* Mounted once in the shell zone. Host controls visibility via `open` +
|
|
15
|
+
* `onOpenChange` and supplies the command graph. The component itself
|
|
16
|
+
* handles:
|
|
17
|
+
*
|
|
18
|
+
* - Fuzzy substring / token / subsequence matching
|
|
19
|
+
* - Keyboard navigation (ArrowUp / ArrowDown / Enter / Escape)
|
|
20
|
+
* - Grouped result rendering with Lane 6a tokens
|
|
21
|
+
* - Click-off-backdrop close
|
|
22
|
+
* - auto-focus on the search input when opened
|
|
23
|
+
*
|
|
24
|
+
* Shortcut wiring (Ctrl+K) lives with the host — we do NOT subscribe to
|
|
25
|
+
* global keydown here to keep the component pure and testable. See the
|
|
26
|
+
* exported `createCommandPaletteKeyBinding` helper for a ready-to-use
|
|
27
|
+
* listener that hosts can drop into their runtime-shortcut-dispatch path.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface CommandPaletteItem {
|
|
31
|
+
/** Stable id used as React key and for onInvoke dispatch. */
|
|
32
|
+
id: string;
|
|
33
|
+
/** User-visible label. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Optional leading icon (Lucide or equivalent). */
|
|
36
|
+
icon?: ReactNode;
|
|
37
|
+
/** Optional keyboard shortcut hint rendered on the row. */
|
|
38
|
+
shortcut?: string;
|
|
39
|
+
/** Optional search synonyms — boosts match ranking. */
|
|
40
|
+
synonyms?: readonly string[];
|
|
41
|
+
/** Optional description rendered below the label. */
|
|
42
|
+
description?: string;
|
|
43
|
+
/** Disabled items still render but are dimmed and non-invokable. */
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** Invoked on Enter or click. Caller is responsible for closing the palette. */
|
|
46
|
+
onInvoke: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CommandPaletteGroup {
|
|
50
|
+
/** Stable id. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** Section heading. */
|
|
53
|
+
label: string;
|
|
54
|
+
/** Items in this group, displayed in array order when no query. */
|
|
55
|
+
commands: readonly CommandPaletteItem[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TwCommandPaletteProps {
|
|
59
|
+
open: boolean;
|
|
60
|
+
onOpenChange: (open: boolean) => void;
|
|
61
|
+
groups: readonly CommandPaletteGroup[];
|
|
62
|
+
/** Placeholder text for the search input. */
|
|
63
|
+
placeholder?: string;
|
|
64
|
+
/** Empty-state message when no command matches the query. */
|
|
65
|
+
emptyMessage?: string;
|
|
66
|
+
/** className passthrough for the palette card. */
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FlatItem extends CommandPaletteItem {
|
|
71
|
+
groupId: string;
|
|
72
|
+
groupLabel: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fuzzy matcher — returns `true` if the needle matches the haystack as
|
|
77
|
+
* substring OR as a character subsequence. Cheap, deterministic, and
|
|
78
|
+
* good enough for the ~100-item palette sizes we expect.
|
|
79
|
+
*/
|
|
80
|
+
function fuzzyMatch(needle: string, haystack: string): boolean {
|
|
81
|
+
if (needle.length === 0) return true;
|
|
82
|
+
const lowerNeedle = needle.toLowerCase();
|
|
83
|
+
const lowerHay = haystack.toLowerCase();
|
|
84
|
+
if (lowerHay.includes(lowerNeedle)) return true;
|
|
85
|
+
// Subsequence: each char of needle must appear in haystack in order.
|
|
86
|
+
let hi = 0;
|
|
87
|
+
for (const ch of lowerNeedle) {
|
|
88
|
+
const next = lowerHay.indexOf(ch, hi);
|
|
89
|
+
if (next === -1) return false;
|
|
90
|
+
hi = next + 1;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function itemMatches(item: CommandPaletteItem, query: string): boolean {
|
|
96
|
+
if (!query) return true;
|
|
97
|
+
if (fuzzyMatch(query, item.label)) return true;
|
|
98
|
+
for (const syn of item.synonyms ?? []) {
|
|
99
|
+
if (fuzzyMatch(query, syn)) return true;
|
|
100
|
+
}
|
|
101
|
+
if (item.description && fuzzyMatch(query, item.description)) return true;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function TwCommandPalette(
|
|
106
|
+
props: TwCommandPaletteProps,
|
|
107
|
+
): React.ReactElement | null {
|
|
108
|
+
const { open, onOpenChange, groups } = props;
|
|
109
|
+
const placeholder = props.placeholder ?? "Type a command or search…";
|
|
110
|
+
const emptyMessage =
|
|
111
|
+
props.emptyMessage ?? "No commands match this query.";
|
|
112
|
+
|
|
113
|
+
const [query, setQuery] = useState("");
|
|
114
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
115
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
116
|
+
|
|
117
|
+
const filtered = useMemo(() => {
|
|
118
|
+
const trimmed = query.trim();
|
|
119
|
+
const out: CommandPaletteGroup[] = [];
|
|
120
|
+
for (const group of groups) {
|
|
121
|
+
const matched = group.commands.filter((cmd) => itemMatches(cmd, trimmed));
|
|
122
|
+
if (matched.length > 0) out.push({ ...group, commands: matched });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}, [groups, query]);
|
|
126
|
+
|
|
127
|
+
const flat: FlatItem[] = useMemo(() => {
|
|
128
|
+
const out: FlatItem[] = [];
|
|
129
|
+
for (const group of filtered) {
|
|
130
|
+
for (const cmd of group.commands) {
|
|
131
|
+
out.push({ ...cmd, groupId: group.id, groupLabel: group.label });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}, [filtered]);
|
|
136
|
+
|
|
137
|
+
// Reset query + focus when the palette opens / closes.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open) return;
|
|
140
|
+
setQuery("");
|
|
141
|
+
setActiveIndex(0);
|
|
142
|
+
const handle = setTimeout(() => inputRef.current?.focus(), 0);
|
|
143
|
+
return () => clearTimeout(handle);
|
|
144
|
+
}, [open]);
|
|
145
|
+
|
|
146
|
+
// Keep activeIndex inside the filtered result range.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (activeIndex >= flat.length) setActiveIndex(flat.length > 0 ? 0 : 0);
|
|
149
|
+
}, [activeIndex, flat.length]);
|
|
150
|
+
|
|
151
|
+
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
|
|
152
|
+
|
|
153
|
+
const invokeAt = useCallback(
|
|
154
|
+
(index: number) => {
|
|
155
|
+
const item = flat[index];
|
|
156
|
+
if (!item || item.disabled) return;
|
|
157
|
+
item.onInvoke();
|
|
158
|
+
close();
|
|
159
|
+
},
|
|
160
|
+
[flat, close],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const handleKeyDown = useCallback(
|
|
164
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
165
|
+
if (event.key === "Escape") {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
close();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (event.key === "ArrowDown") {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
setActiveIndex((i) => (flat.length === 0 ? 0 : (i + 1) % flat.length));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (event.key === "ArrowUp") {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
setActiveIndex((i) =>
|
|
178
|
+
flat.length === 0 ? 0 : (i - 1 + flat.length) % flat.length,
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (event.key === "Enter") {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
invokeAt(activeIndex);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
[activeIndex, close, flat.length, invokeAt],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!open) return null;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]"
|
|
196
|
+
role="dialog"
|
|
197
|
+
aria-modal="true"
|
|
198
|
+
aria-label="Command palette"
|
|
199
|
+
data-testid="tw-command-palette"
|
|
200
|
+
onKeyDown={handleKeyDown}
|
|
201
|
+
>
|
|
202
|
+
{/* Backdrop */}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label="Close command palette"
|
|
206
|
+
onClick={close}
|
|
207
|
+
data-testid="tw-command-palette__backdrop"
|
|
208
|
+
className={[
|
|
209
|
+
"absolute inset-0 cursor-default",
|
|
210
|
+
"bg-[var(--color-bg-overlay)] backdrop-blur-sm",
|
|
211
|
+
"transition-opacity duration-[var(--motion-fast)]",
|
|
212
|
+
].join(" ")}
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
{/* Palette card */}
|
|
216
|
+
<div
|
|
217
|
+
className={[
|
|
218
|
+
"relative z-10 w-[min(720px,90vw)] max-h-[70vh]",
|
|
219
|
+
"flex flex-col overflow-hidden",
|
|
220
|
+
"rounded-[var(--radius-md)]",
|
|
221
|
+
"bg-[var(--color-bg-elevated)]",
|
|
222
|
+
"shadow-[var(--shadow-float)]",
|
|
223
|
+
"border border-[var(--color-border-subtle)]",
|
|
224
|
+
props.className,
|
|
225
|
+
]
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.join(" ")}
|
|
228
|
+
data-testid="tw-command-palette__card"
|
|
229
|
+
>
|
|
230
|
+
{/* Search input */}
|
|
231
|
+
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
|
|
232
|
+
<input
|
|
233
|
+
ref={inputRef}
|
|
234
|
+
type="text"
|
|
235
|
+
value={query}
|
|
236
|
+
onChange={(e) => {
|
|
237
|
+
setQuery(e.target.value);
|
|
238
|
+
setActiveIndex(0);
|
|
239
|
+
}}
|
|
240
|
+
placeholder={placeholder}
|
|
241
|
+
aria-label="Search commands"
|
|
242
|
+
data-testid="tw-command-palette__input"
|
|
243
|
+
className={[
|
|
244
|
+
"w-full bg-transparent outline-none",
|
|
245
|
+
"text-sm text-[var(--color-text-primary)]",
|
|
246
|
+
"placeholder:text-[var(--color-text-tertiary)]",
|
|
247
|
+
].join(" ")}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{/* Results */}
|
|
252
|
+
<div
|
|
253
|
+
className="flex-1 overflow-y-auto py-1"
|
|
254
|
+
role="listbox"
|
|
255
|
+
aria-label="Commands"
|
|
256
|
+
data-testid="tw-command-palette__list"
|
|
257
|
+
>
|
|
258
|
+
{flat.length === 0 ? (
|
|
259
|
+
<div
|
|
260
|
+
className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]"
|
|
261
|
+
data-testid="tw-command-palette__empty"
|
|
262
|
+
>
|
|
263
|
+
{emptyMessage}
|
|
264
|
+
</div>
|
|
265
|
+
) : (
|
|
266
|
+
filtered.map((group) => (
|
|
267
|
+
<div key={group.id}>
|
|
268
|
+
<div
|
|
269
|
+
className={[
|
|
270
|
+
"px-4 pt-2 pb-1",
|
|
271
|
+
"text-[10px] font-semibold uppercase tracking-[0.12em]",
|
|
272
|
+
"text-[var(--color-text-tertiary)]",
|
|
273
|
+
].join(" ")}
|
|
274
|
+
data-testid={`tw-command-palette__group-${group.id}`}
|
|
275
|
+
>
|
|
276
|
+
{group.label}
|
|
277
|
+
</div>
|
|
278
|
+
{group.commands.map((cmd) => {
|
|
279
|
+
const idxInFlat = flat.findIndex(
|
|
280
|
+
(fi) => fi.groupId === group.id && fi.id === cmd.id,
|
|
281
|
+
);
|
|
282
|
+
const isActive = idxInFlat === activeIndex;
|
|
283
|
+
return (
|
|
284
|
+
<button
|
|
285
|
+
key={cmd.id}
|
|
286
|
+
type="button"
|
|
287
|
+
role="option"
|
|
288
|
+
aria-selected={isActive}
|
|
289
|
+
aria-disabled={cmd.disabled ? "true" : undefined}
|
|
290
|
+
disabled={cmd.disabled}
|
|
291
|
+
onMouseEnter={() => setActiveIndex(idxInFlat)}
|
|
292
|
+
onClick={() => invokeAt(idxInFlat)}
|
|
293
|
+
data-testid={`tw-command-palette__item-${cmd.id}`}
|
|
294
|
+
data-active={isActive ? "true" : undefined}
|
|
295
|
+
className={[
|
|
296
|
+
"flex w-full items-center gap-3 px-4 py-2 text-left",
|
|
297
|
+
"text-sm",
|
|
298
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
299
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
300
|
+
isActive
|
|
301
|
+
? "bg-[var(--color-accent-soft)] text-[var(--color-accent-primary)]"
|
|
302
|
+
: "text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]",
|
|
303
|
+
].join(" ")}
|
|
304
|
+
>
|
|
305
|
+
{cmd.icon ? (
|
|
306
|
+
<span
|
|
307
|
+
aria-hidden="true"
|
|
308
|
+
className="inline-flex h-4 w-4 items-center justify-center shrink-0"
|
|
309
|
+
>
|
|
310
|
+
{cmd.icon}
|
|
311
|
+
</span>
|
|
312
|
+
) : (
|
|
313
|
+
<span className="inline-flex h-4 w-4 shrink-0" />
|
|
314
|
+
)}
|
|
315
|
+
<span className="min-w-0 flex-1">
|
|
316
|
+
<span className="block truncate">{cmd.label}</span>
|
|
317
|
+
{cmd.description ? (
|
|
318
|
+
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
|
|
319
|
+
{cmd.description}
|
|
320
|
+
</span>
|
|
321
|
+
) : null}
|
|
322
|
+
</span>
|
|
323
|
+
{cmd.shortcut ? (
|
|
324
|
+
<kbd
|
|
325
|
+
className={[
|
|
326
|
+
"inline-flex items-center shrink-0",
|
|
327
|
+
"rounded-[var(--radius-sm)] px-1.5 py-0.5 font-sans text-[10px] font-medium",
|
|
328
|
+
"border border-[var(--color-border-subtle)]",
|
|
329
|
+
"bg-[var(--color-bg-muted)] text-[var(--color-text-tertiary)]",
|
|
330
|
+
].join(" ")}
|
|
331
|
+
>
|
|
332
|
+
{cmd.shortcut}
|
|
333
|
+
</kbd>
|
|
334
|
+
) : null}
|
|
335
|
+
</button>
|
|
336
|
+
);
|
|
337
|
+
})}
|
|
338
|
+
</div>
|
|
339
|
+
))
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Convenience: checks whether a DOM keyboard event matches the Ctrl+K /
|
|
349
|
+
* Cmd+K palette open combo. Hosts compose this into their existing
|
|
350
|
+
* runtime-shortcut-dispatch path without having to duplicate the
|
|
351
|
+
* platform-key logic.
|
|
352
|
+
*/
|
|
353
|
+
export function isCommandPaletteOpenShortcut(event: KeyboardEvent): boolean {
|
|
354
|
+
const isK = event.key === "k" || event.key === "K";
|
|
355
|
+
if (!isK) return false;
|
|
356
|
+
// Cmd on macOS, Ctrl everywhere else — accept either.
|
|
357
|
+
return event.metaKey || event.ctrlKey;
|
|
358
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export interface TwCommentPreviewProps {
|
|
4
|
+
author: {
|
|
5
|
+
name: string;
|
|
6
|
+
initials?: string;
|
|
7
|
+
avatarUrl?: string;
|
|
8
|
+
};
|
|
9
|
+
/** ISO timestamp or a pre-formatted relative time string like "5m ago". */
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
/** 1–2 line excerpt of the comment body. */
|
|
12
|
+
excerpt: string;
|
|
13
|
+
onOpenThread?: () => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const focusRingClass =
|
|
18
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
|
|
19
|
+
|
|
20
|
+
export function TwCommentPreview(props: TwCommentPreviewProps): React.JSX.Element {
|
|
21
|
+
const initials =
|
|
22
|
+
props.author.initials ?? props.author.name.slice(0, 2).toUpperCase();
|
|
23
|
+
|
|
24
|
+
const containerClass = [
|
|
25
|
+
"flex flex-col gap-1.5",
|
|
26
|
+
"max-w-[min(20rem,calc(100vw-1.5rem))]",
|
|
27
|
+
"rounded-[var(--radius-lg)]",
|
|
28
|
+
"border border-[var(--color-border-subtle)]",
|
|
29
|
+
"bg-[var(--color-bg-canvas)]",
|
|
30
|
+
"shadow-[var(--shadow-float)]",
|
|
31
|
+
"px-3 py-3",
|
|
32
|
+
props.className,
|
|
33
|
+
]
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(" ");
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
role="region"
|
|
40
|
+
aria-label="Comment"
|
|
41
|
+
className={containerClass}
|
|
42
|
+
data-testid="tw-comment-preview"
|
|
43
|
+
>
|
|
44
|
+
<header className="flex items-center gap-2">
|
|
45
|
+
{/* avatar: 24×24 */}
|
|
46
|
+
<span
|
|
47
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-[var(--color-bg-muted)] text-[10px] font-semibold text-[var(--color-text-primary)]"
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
data-testid="tw-comment-preview__avatar"
|
|
50
|
+
>
|
|
51
|
+
{props.author.avatarUrl ? (
|
|
52
|
+
<img
|
|
53
|
+
src={props.author.avatarUrl}
|
|
54
|
+
alt=""
|
|
55
|
+
className="h-6 w-6 rounded-full object-cover"
|
|
56
|
+
/>
|
|
57
|
+
) : (
|
|
58
|
+
initials
|
|
59
|
+
)}
|
|
60
|
+
</span>
|
|
61
|
+
<span
|
|
62
|
+
className="text-[12px] font-semibold text-[var(--color-text-primary)]"
|
|
63
|
+
data-testid="tw-comment-preview__author"
|
|
64
|
+
>
|
|
65
|
+
{props.author.name}
|
|
66
|
+
</span>
|
|
67
|
+
{props.timestamp !== undefined && (
|
|
68
|
+
<span
|
|
69
|
+
className="text-[11px] text-[var(--color-text-tertiary)]"
|
|
70
|
+
data-testid="tw-comment-preview__timestamp"
|
|
71
|
+
>
|
|
72
|
+
{props.timestamp}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</header>
|
|
76
|
+
<p
|
|
77
|
+
className="text-[13px] text-[var(--color-text-primary)]"
|
|
78
|
+
style={{
|
|
79
|
+
overflow: "hidden",
|
|
80
|
+
display: "-webkit-box",
|
|
81
|
+
WebkitLineClamp: 2,
|
|
82
|
+
WebkitBoxOrient: "vertical",
|
|
83
|
+
}}
|
|
84
|
+
data-testid="tw-comment-preview__excerpt"
|
|
85
|
+
>
|
|
86
|
+
{props.excerpt}
|
|
87
|
+
</p>
|
|
88
|
+
{props.onOpenThread !== undefined && (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={props.onOpenThread}
|
|
92
|
+
className={[
|
|
93
|
+
"mt-1 inline-flex h-7 items-center justify-center",
|
|
94
|
+
"rounded-[var(--radius-sm)]",
|
|
95
|
+
"border border-[var(--color-border-default)]",
|
|
96
|
+
"bg-[var(--color-bg-canvas)]",
|
|
97
|
+
"px-3 text-[12px] font-medium text-[var(--color-text-primary)]",
|
|
98
|
+
"hover:bg-[var(--color-bg-hover)]",
|
|
99
|
+
focusRingClass,
|
|
100
|
+
].join(" ")}
|
|
101
|
+
data-testid="tw-comment-preview__open-thread"
|
|
102
|
+
>
|
|
103
|
+
Open thread
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwContextMenu — compact right-click context menu for the Word editor.
|
|
3
|
+
*
|
|
4
|
+
* Design system §6.24: Context menus must not duplicate floating local chrome
|
|
5
|
+
* (selection toolbar, suggestion card, table toolbar). Rows that overlap
|
|
6
|
+
* visible floating chrome are filtered out.
|
|
7
|
+
*
|
|
8
|
+
* Design system §6.25: Shortcut keys are right-aligned via TwShortcutHint.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import { TwShortcutHint, type ShortcutKey } from "./tw-shortcut-hint";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Dedupe context
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Describes which pieces of floating chrome are currently visible.
|
|
21
|
+
* Used to suppress context-menu rows that would duplicate those surfaces.
|
|
22
|
+
*/
|
|
23
|
+
export interface ContextMenuContext {
|
|
24
|
+
selectionToolbarVisible: boolean;
|
|
25
|
+
suggestionCardVisible: boolean;
|
|
26
|
+
commentCardVisible: boolean;
|
|
27
|
+
tableToolbarVisible: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Menu item model
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export type ContextMenuGroupId =
|
|
35
|
+
| "formatting"
|
|
36
|
+
| "suggestion"
|
|
37
|
+
| "table"
|
|
38
|
+
| "clipboard"
|
|
39
|
+
| "comment"
|
|
40
|
+
| "misc";
|
|
41
|
+
|
|
42
|
+
export interface ContextMenuItem {
|
|
43
|
+
kind: "item";
|
|
44
|
+
/** Unique identifier for the row. */
|
|
45
|
+
id: string;
|
|
46
|
+
/** Display label. */
|
|
47
|
+
label: string;
|
|
48
|
+
/** Optional icon element rendered left of the label. */
|
|
49
|
+
icon?: React.ReactNode;
|
|
50
|
+
/** Shortcut key sequence shown right-aligned via TwShortcutHint. */
|
|
51
|
+
shortcut?: ShortcutKey[];
|
|
52
|
+
/**
|
|
53
|
+
* Group membership — determines which rows are suppressed when the
|
|
54
|
+
* corresponding floating chrome is visible (§6.24).
|
|
55
|
+
*/
|
|
56
|
+
group: ContextMenuGroupId;
|
|
57
|
+
/** Whether the row is disabled (rendered but non-interactive). */
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
onSelect?: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ContextMenuSeparator {
|
|
63
|
+
kind: "separator";
|
|
64
|
+
id: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Dedupe helper (pure — no React)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns only the rows that should be visible given the current floating
|
|
75
|
+
* chrome context. §6.24: "Do not show a richer command set than the local
|
|
76
|
+
* floating chrome already exposes."
|
|
77
|
+
*/
|
|
78
|
+
export function filterContextMenuEntries(
|
|
79
|
+
entries: ContextMenuEntry[],
|
|
80
|
+
ctx: ContextMenuContext,
|
|
81
|
+
): ContextMenuEntry[] {
|
|
82
|
+
const suppressedGroups = new Set<ContextMenuGroupId>();
|
|
83
|
+
|
|
84
|
+
if (ctx.selectionToolbarVisible) {
|
|
85
|
+
suppressedGroups.add("formatting");
|
|
86
|
+
}
|
|
87
|
+
if (ctx.suggestionCardVisible) {
|
|
88
|
+
suppressedGroups.add("suggestion");
|
|
89
|
+
}
|
|
90
|
+
if (ctx.tableToolbarVisible) {
|
|
91
|
+
suppressedGroups.add("table");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const filtered = entries.filter((e) => {
|
|
95
|
+
if (e.kind === "separator") return true;
|
|
96
|
+
return !suppressedGroups.has(e.group);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Collapse leading/trailing/consecutive separators
|
|
100
|
+
return collapseSeparators(filtered);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collapseSeparators(entries: ContextMenuEntry[]): ContextMenuEntry[] {
|
|
104
|
+
const out: ContextMenuEntry[] = [];
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (entry.kind === "separator") {
|
|
107
|
+
// Skip if nothing has been emitted yet or if last emitted was also a separator
|
|
108
|
+
if (out.length === 0) continue;
|
|
109
|
+
const last = out[out.length - 1];
|
|
110
|
+
if (last.kind === "separator") continue;
|
|
111
|
+
out.push(entry);
|
|
112
|
+
} else {
|
|
113
|
+
out.push(entry);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Drop trailing separator
|
|
117
|
+
if (out.length > 0 && out[out.length - 1].kind === "separator") {
|
|
118
|
+
out.pop();
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Component
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
export interface TwContextMenuProps {
|
|
128
|
+
entries: ContextMenuEntry[];
|
|
129
|
+
context?: Partial<ContextMenuContext>;
|
|
130
|
+
platform?: "mac" | "win";
|
|
131
|
+
"data-testid"?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const FULL_CONTEXT: ContextMenuContext = {
|
|
135
|
+
selectionToolbarVisible: false,
|
|
136
|
+
suggestionCardVisible: false,
|
|
137
|
+
commentCardVisible: false,
|
|
138
|
+
tableToolbarVisible: false,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function TwContextMenu(props: TwContextMenuProps): React.JSX.Element {
|
|
142
|
+
const {
|
|
143
|
+
entries,
|
|
144
|
+
context,
|
|
145
|
+
platform,
|
|
146
|
+
"data-testid": testId = "tw-context-menu",
|
|
147
|
+
} = props;
|
|
148
|
+
|
|
149
|
+
const ctx: ContextMenuContext = { ...FULL_CONTEXT, ...context };
|
|
150
|
+
const visible = filterContextMenuEntries(entries, ctx);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
data-testid={testId}
|
|
155
|
+
role="menu"
|
|
156
|
+
className={[
|
|
157
|
+
"flex flex-col",
|
|
158
|
+
"rounded-[var(--radius-lg)]",
|
|
159
|
+
"bg-[var(--color-bg-elevated)]",
|
|
160
|
+
"shadow-[var(--shadow-float)]",
|
|
161
|
+
"border border-[var(--color-border-subtle)]",
|
|
162
|
+
"py-1.5",
|
|
163
|
+
"min-w-[160px]",
|
|
164
|
+
].join(" ")}
|
|
165
|
+
>
|
|
166
|
+
{visible.map((entry) => {
|
|
167
|
+
if (entry.kind === "separator") {
|
|
168
|
+
return (
|
|
169
|
+
<hr
|
|
170
|
+
key={entry.id}
|
|
171
|
+
role="separator"
|
|
172
|
+
className="my-1 border-[var(--color-border-subtle)]"
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return (
|
|
177
|
+
<ContextMenuRow
|
|
178
|
+
key={entry.id}
|
|
179
|
+
item={entry}
|
|
180
|
+
platform={platform}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Row
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
interface ContextMenuRowProps {
|
|
193
|
+
item: ContextMenuItem;
|
|
194
|
+
platform?: "mac" | "win";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function ContextMenuRow({ item, platform }: ContextMenuRowProps): React.JSX.Element {
|
|
198
|
+
const { label, icon, shortcut, disabled, onSelect } = item;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<button
|
|
202
|
+
role="menuitem"
|
|
203
|
+
data-testid={`context-menu-item-${item.id}`}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
onClick={disabled ? undefined : onSelect}
|
|
206
|
+
className={[
|
|
207
|
+
"flex items-center justify-between h-[30px] w-full px-3",
|
|
208
|
+
"text-[13px] text-[var(--color-text-primary)] text-left",
|
|
209
|
+
"hover:bg-[var(--color-bg-hover)]",
|
|
210
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
211
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-accent)]",
|
|
212
|
+
].join(" ")}
|
|
213
|
+
>
|
|
214
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
215
|
+
{icon && (
|
|
216
|
+
<span className="shrink-0 w-4 h-4 flex items-center justify-center text-[var(--color-text-secondary)]">
|
|
217
|
+
{icon}
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
<span className="truncate">{label}</span>
|
|
221
|
+
</span>
|
|
222
|
+
{shortcut && shortcut.length > 0 && (
|
|
223
|
+
<TwShortcutHint keys={shortcut} platform={platform} />
|
|
224
|
+
)}
|
|
225
|
+
</button>
|
|
226
|
+
);
|
|
227
|
+
}
|