@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.
Files changed (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. 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
+ }