@beyondwork/docx-react-component 1.0.39 → 1.0.41
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 +122 -0
- package/src/index.ts +9 -0
- package/src/runtime/document-runtime.ts +7 -0
- package/src/runtime/layout/docx-font-loader.ts +30 -11
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/public-facet.ts +41 -0
- package/src/runtime/workflow-rail-segments.ts +149 -1
- package/src/ui/WordReviewEditor.tsx +17 -0
- package/src/ui/editor-shell-view.tsx +18 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +89 -20
- package/src/ui-tailwind/chrome-overlay/index.ts +2 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +55 -1
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +26 -1
- package/src/ui-tailwind/index.ts +5 -0
- package/src/ui-tailwind/theme/editor-theme.css +108 -15
- package/src/ui-tailwind/tw-review-workspace.tsx +75 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwScopeCard — inline floating card shown above a scoped region when
|
|
3
|
+
* the user activates the rail stripe. Displays scope label + mode
|
|
4
|
+
* selector + (when an `IssueMetadataValue` is attached via
|
|
5
|
+
* `ScopeCardModel.issue`) the R2 issue severity, owner, title, and
|
|
6
|
+
* resolve/waive/escalate actions.
|
|
7
|
+
*
|
|
8
|
+
* Per docs/plans/scope-card-overlay.md P1, the card never mutates
|
|
9
|
+
* runtime state directly — it fires `onModeChange` / `onIssueAction`
|
|
10
|
+
* callbacks that bubble up to `scope-mode-change-requested` /
|
|
11
|
+
* `scope-issue-action-requested` events on WordReviewEditorEvent.
|
|
12
|
+
*
|
|
13
|
+
* A11y contract:
|
|
14
|
+
* - role="dialog", aria-modal="false" (not a hard-focus capture; the
|
|
15
|
+
* editor surface remains interactive while the card is open)
|
|
16
|
+
* - aria-labelledby points at the header id
|
|
17
|
+
* - Escape closes; focus-trap wraps Tab / Shift-Tab
|
|
18
|
+
* - An aria-live="polite" region announces the attached issue's
|
|
19
|
+
* severity when the card opens with an issue
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import type {
|
|
24
|
+
IssueMetadataValue,
|
|
25
|
+
IssueOwner,
|
|
26
|
+
IssueSeverity,
|
|
27
|
+
ScopeCardModel,
|
|
28
|
+
ScopeIssueAction,
|
|
29
|
+
WorkflowScopeMode,
|
|
30
|
+
} from "../../api/public-types";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Types
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface TwScopeCardProps {
|
|
37
|
+
model: ScopeCardModel;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
onModeChange: (mode: WorkflowScopeMode) => void;
|
|
40
|
+
onIssueAction: (action: ScopeIssueAction) => void;
|
|
41
|
+
/** Test id applied to the root node. */
|
|
42
|
+
"data-testid"?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MODE_OPTIONS: ReadonlyArray<{ mode: WorkflowScopeMode; label: string }> = [
|
|
46
|
+
{ mode: "edit", label: "Edit" },
|
|
47
|
+
{ mode: "suggest", label: "Suggest" },
|
|
48
|
+
{ mode: "comment", label: "Comment" },
|
|
49
|
+
{ mode: "view", label: "View" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const SEVERITY_COLOR: Record<IssueSeverity, string> = {
|
|
53
|
+
low: "var(--color-secondary)",
|
|
54
|
+
medium: "var(--color-warning)",
|
|
55
|
+
high: "var(--color-warning)",
|
|
56
|
+
blocker: "var(--color-danger)",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const SEVERITY_LABEL: Record<IssueSeverity, string> = {
|
|
60
|
+
low: "Low",
|
|
61
|
+
medium: "Medium",
|
|
62
|
+
high: "High",
|
|
63
|
+
blocker: "Blocker",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const OWNER_LABEL: Record<IssueOwner, string> = {
|
|
67
|
+
procurement: "Procurement",
|
|
68
|
+
legal: "Legal",
|
|
69
|
+
risk: "Risk",
|
|
70
|
+
finance: "Finance",
|
|
71
|
+
sustainability: "Sustainability",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Component
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
79
|
+
model,
|
|
80
|
+
onClose,
|
|
81
|
+
onModeChange,
|
|
82
|
+
onIssueAction,
|
|
83
|
+
"data-testid": testId,
|
|
84
|
+
}) => {
|
|
85
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
86
|
+
const headerId = React.useId();
|
|
87
|
+
const liveRegionId = React.useId();
|
|
88
|
+
|
|
89
|
+
// --- Focus management ----------------------------------------------------
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
const root = rootRef.current;
|
|
92
|
+
if (!root) return undefined;
|
|
93
|
+
const first = getFocusable(root)[0];
|
|
94
|
+
first?.focus();
|
|
95
|
+
return undefined;
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// --- Escape + click-outside ---------------------------------------------
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
const onKey = (event: KeyboardEvent) => {
|
|
101
|
+
if (event.key === "Escape") {
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
onClose();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
107
|
+
const root = rootRef.current;
|
|
108
|
+
if (!root) return;
|
|
109
|
+
if (event.target instanceof Node && root.contains(event.target)) return;
|
|
110
|
+
onClose();
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener("keydown", onKey, true);
|
|
113
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
114
|
+
return () => {
|
|
115
|
+
window.removeEventListener("keydown", onKey, true);
|
|
116
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
117
|
+
};
|
|
118
|
+
}, [onClose]);
|
|
119
|
+
|
|
120
|
+
// --- Focus trap ----------------------------------------------------------
|
|
121
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
122
|
+
if (event.key !== "Tab") return;
|
|
123
|
+
const root = rootRef.current;
|
|
124
|
+
if (!root) return;
|
|
125
|
+
const focusables = getFocusable(root);
|
|
126
|
+
if (focusables.length === 0) return;
|
|
127
|
+
const first = focusables[0];
|
|
128
|
+
const last = focusables[focusables.length - 1];
|
|
129
|
+
const active = document.activeElement;
|
|
130
|
+
if (event.shiftKey) {
|
|
131
|
+
if (active === first || !root.contains(active)) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
last.focus();
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
if (active === last) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
first.focus();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const issue = model.issue;
|
|
144
|
+
const postureLabel = posturePresentationLabel(model.posture);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
ref={rootRef}
|
|
149
|
+
className="wre-scope-card pointer-events-auto absolute flex w-80 max-w-[22rem] flex-col gap-2 rounded-lg border border-border bg-canvas p-3 text-sm shadow-[var(--shadow-float)]"
|
|
150
|
+
role="dialog"
|
|
151
|
+
aria-modal="false"
|
|
152
|
+
aria-labelledby={headerId}
|
|
153
|
+
data-testid={testId ?? "scope-card"}
|
|
154
|
+
data-scope-id={model.scopeId}
|
|
155
|
+
data-posture={model.posture}
|
|
156
|
+
onKeyDown={onKeyDown}
|
|
157
|
+
>
|
|
158
|
+
{/* Header --------------------------------------------------------- */}
|
|
159
|
+
<div className="flex items-center justify-between gap-2">
|
|
160
|
+
<div
|
|
161
|
+
id={headerId}
|
|
162
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-primary"
|
|
163
|
+
>
|
|
164
|
+
<span
|
|
165
|
+
className={`wre-scope-rail-icon wre-scope-rail-icon-${posturePresentationIcon(model.posture)}`}
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
style={{ color: postureTokenColor(model.posture) }}
|
|
168
|
+
/>
|
|
169
|
+
<span className="truncate uppercase tracking-[0.06em]">
|
|
170
|
+
{postureLabel}
|
|
171
|
+
</span>
|
|
172
|
+
{model.label ? (
|
|
173
|
+
<span className="truncate text-tertiary">· {model.label}</span>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
aria-label="Close scope card"
|
|
179
|
+
className="flex h-6 w-6 items-center justify-center rounded-sm text-tertiary transition-colors hover:bg-surface hover:text-primary"
|
|
180
|
+
onClick={onClose}
|
|
181
|
+
data-testid="scope-card-close"
|
|
182
|
+
>
|
|
183
|
+
×
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Mode row ------------------------------------------------------- */}
|
|
188
|
+
<div
|
|
189
|
+
role="group"
|
|
190
|
+
aria-label="Scope mode"
|
|
191
|
+
className="flex gap-1 rounded-md border border-border bg-surface p-0.5"
|
|
192
|
+
>
|
|
193
|
+
{MODE_OPTIONS.map(({ mode, label }) => {
|
|
194
|
+
const active = model.posture === mode;
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
key={mode}
|
|
198
|
+
type="button"
|
|
199
|
+
aria-pressed={active ? "true" : "false"}
|
|
200
|
+
className={`flex-1 rounded-sm px-2 py-1 text-xs font-medium transition-colors ${
|
|
201
|
+
active
|
|
202
|
+
? "bg-canvas text-primary shadow-sm"
|
|
203
|
+
: "text-secondary hover:bg-canvas hover:text-primary"
|
|
204
|
+
}`}
|
|
205
|
+
onClick={() => onModeChange(mode)}
|
|
206
|
+
data-testid={`scope-card-mode-${mode}`}
|
|
207
|
+
>
|
|
208
|
+
{label}
|
|
209
|
+
</button>
|
|
210
|
+
);
|
|
211
|
+
})}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Issue row (R2) ------------------------------------------------- */}
|
|
215
|
+
{issue ? <IssueRow issue={issue} onAction={onIssueAction} /> : null}
|
|
216
|
+
|
|
217
|
+
{/* A11y live region ---------------------------------------------- */}
|
|
218
|
+
<span
|
|
219
|
+
id={liveRegionId}
|
|
220
|
+
role="status"
|
|
221
|
+
aria-live="polite"
|
|
222
|
+
className="sr-only"
|
|
223
|
+
>
|
|
224
|
+
{issue
|
|
225
|
+
? `${postureLabel} scope, ${SEVERITY_LABEL[issue.severity]} severity issue attached: ${issue.title}`
|
|
226
|
+
: `${postureLabel} scope opened`}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Issue row
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
const IssueRow: React.FC<{
|
|
237
|
+
issue: IssueMetadataValue;
|
|
238
|
+
onAction: (action: ScopeIssueAction) => void;
|
|
239
|
+
}> = ({ issue, onAction }) => {
|
|
240
|
+
const canResolve = issue.checklistState === "open" || issue.checklistState === "acknowledged";
|
|
241
|
+
const canWaive = issue.checklistState !== "waived";
|
|
242
|
+
const canEscalate = issue.escalationState !== "requested";
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
className="flex flex-col gap-1.5 rounded-md bg-surface p-2"
|
|
247
|
+
data-testid="scope-card-issue"
|
|
248
|
+
>
|
|
249
|
+
<div className="flex items-center gap-1.5 text-[11px]">
|
|
250
|
+
<span
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
className="inline-block h-2 w-2 rounded-full"
|
|
253
|
+
style={{ background: SEVERITY_COLOR[issue.severity] }}
|
|
254
|
+
/>
|
|
255
|
+
<span className="font-semibold text-primary">
|
|
256
|
+
{SEVERITY_LABEL[issue.severity]}
|
|
257
|
+
</span>
|
|
258
|
+
{issue.owner ? (
|
|
259
|
+
<span
|
|
260
|
+
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] text-secondary"
|
|
261
|
+
data-testid="scope-card-issue-owner"
|
|
262
|
+
>
|
|
263
|
+
{OWNER_LABEL[issue.owner]}
|
|
264
|
+
</span>
|
|
265
|
+
) : null}
|
|
266
|
+
</div>
|
|
267
|
+
<div
|
|
268
|
+
className="text-xs font-medium leading-snug text-primary"
|
|
269
|
+
data-testid="scope-card-issue-title"
|
|
270
|
+
>
|
|
271
|
+
{issue.title}
|
|
272
|
+
</div>
|
|
273
|
+
{issue.summary ? (
|
|
274
|
+
<div className="text-[11px] leading-snug text-secondary">{issue.summary}</div>
|
|
275
|
+
) : null}
|
|
276
|
+
<div className="flex gap-1">
|
|
277
|
+
<IssueActionButton
|
|
278
|
+
label="Resolve"
|
|
279
|
+
testId="scope-card-issue-resolve"
|
|
280
|
+
disabled={!canResolve}
|
|
281
|
+
onClick={() => onAction("resolve")}
|
|
282
|
+
/>
|
|
283
|
+
<IssueActionButton
|
|
284
|
+
label="Waive"
|
|
285
|
+
testId="scope-card-issue-waive"
|
|
286
|
+
disabled={!canWaive}
|
|
287
|
+
onClick={() => onAction("waive")}
|
|
288
|
+
/>
|
|
289
|
+
<IssueActionButton
|
|
290
|
+
label="Escalate"
|
|
291
|
+
testId="scope-card-issue-escalate"
|
|
292
|
+
disabled={!canEscalate}
|
|
293
|
+
onClick={() => onAction("escalate")}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const IssueActionButton: React.FC<{
|
|
301
|
+
label: string;
|
|
302
|
+
testId: string;
|
|
303
|
+
disabled: boolean;
|
|
304
|
+
onClick: () => void;
|
|
305
|
+
}> = ({ label, testId, disabled, onClick }) => (
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
className="flex-1 rounded-sm border border-border bg-canvas px-1.5 py-1 text-[11px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
310
|
+
onClick={onClick}
|
|
311
|
+
data-testid={testId}
|
|
312
|
+
>
|
|
313
|
+
{label}
|
|
314
|
+
</button>
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Helpers
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
function getFocusable(root: HTMLElement): HTMLElement[] {
|
|
322
|
+
const selector =
|
|
323
|
+
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
|
|
324
|
+
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
|
|
325
|
+
(el) => !el.hasAttribute("inert") && el.offsetParent !== null,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function posturePresentationLabel(posture: ScopeCardModel["posture"]): string {
|
|
330
|
+
switch (posture) {
|
|
331
|
+
case "edit":
|
|
332
|
+
return "Edit";
|
|
333
|
+
case "suggest":
|
|
334
|
+
return "Suggest";
|
|
335
|
+
case "comment":
|
|
336
|
+
return "Comment";
|
|
337
|
+
case "view":
|
|
338
|
+
return "View";
|
|
339
|
+
case "candidate":
|
|
340
|
+
return "Proposed";
|
|
341
|
+
case "preserve-only":
|
|
342
|
+
case "blocked-import":
|
|
343
|
+
return "Blocked";
|
|
344
|
+
default:
|
|
345
|
+
return "Scope";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function posturePresentationIcon(posture: ScopeCardModel["posture"]): string {
|
|
350
|
+
switch (posture) {
|
|
351
|
+
case "edit":
|
|
352
|
+
return "pencil";
|
|
353
|
+
case "suggest":
|
|
354
|
+
return "sparkles";
|
|
355
|
+
case "comment":
|
|
356
|
+
return "message";
|
|
357
|
+
case "view":
|
|
358
|
+
return "eye";
|
|
359
|
+
case "candidate":
|
|
360
|
+
return "flag";
|
|
361
|
+
case "preserve-only":
|
|
362
|
+
case "blocked-import":
|
|
363
|
+
return "lock";
|
|
364
|
+
default:
|
|
365
|
+
return "eye";
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function postureTokenColor(posture: ScopeCardModel["posture"]): string {
|
|
370
|
+
switch (posture) {
|
|
371
|
+
case "edit":
|
|
372
|
+
return "var(--color-accent)";
|
|
373
|
+
case "suggest":
|
|
374
|
+
case "candidate":
|
|
375
|
+
return "var(--color-warning)";
|
|
376
|
+
case "comment":
|
|
377
|
+
return "var(--color-insert)";
|
|
378
|
+
case "preserve-only":
|
|
379
|
+
case "blocked-import":
|
|
380
|
+
return "var(--color-danger)";
|
|
381
|
+
default:
|
|
382
|
+
return "var(--color-secondary)";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export default TwScopeCard;
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scope rail layer — renders workflow scopes as a
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Scope rail layer — renders workflow scopes as a thin color stripe in
|
|
3
|
+
* the reserved left-gutter lane plus a per-line flat tint behind the
|
|
4
|
+
* scoped text runs.
|
|
5
5
|
*
|
|
6
|
-
* Per runtime-rendering-and-chrome-phase.md §5
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Per runtime-rendering-and-chrome-phase.md §5 and
|
|
7
|
+
* docs/plans/scope-card-overlay.md P0, the rail is a projection over
|
|
8
|
+
* canonical workflow scopes; it never lives inside the PM NodeView
|
|
9
|
+
* tree. Positions come from the render kernel's per-line block data
|
|
10
|
+
* (walked directly from `RenderFrame.pages[].regions.body.blocks[]`)
|
|
11
|
+
* so multi-line scopes produce one tight tint per line rather than a
|
|
12
|
+
* fat bounding-box union.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import * as React from "react";
|
|
14
16
|
import {
|
|
15
|
-
inflateRect,
|
|
16
17
|
projectRectToOverlay,
|
|
17
|
-
unionRect,
|
|
18
18
|
type OverlayCoordinateSpace,
|
|
19
19
|
} from "./chrome-overlay-projector";
|
|
20
|
-
import type { RenderFrameRect } from "../../runtime/render";
|
|
20
|
+
import type { RenderFrame, RenderFrameRect } from "../../runtime/render";
|
|
21
21
|
import type { ScopeRailSegment, ScopeRailPosture } from "../../runtime/layout";
|
|
22
22
|
import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
|
|
23
23
|
|
|
@@ -26,16 +26,25 @@ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
export interface TwScopeRailLayerProps {
|
|
29
|
-
/** Layout facet that provides segments +
|
|
29
|
+
/** Layout facet that provides segments + render frame. */
|
|
30
30
|
facet: WordReviewEditorLayoutFacet;
|
|
31
31
|
/** Overlay's coordinate space. Defaults to the overlay's own origin. */
|
|
32
32
|
space?: OverlayCoordinateSpace;
|
|
33
|
-
/** Horizontal
|
|
33
|
+
/** Horizontal pad (px) the rail gutter occupies to the left of body. */
|
|
34
34
|
railLaneWidthPx?: number;
|
|
35
|
-
/** Optional click handler for a segment label (open-scope drawer, etc). */
|
|
36
|
-
onSegmentClick?: (segment: ScopeRailSegment) => void;
|
|
37
35
|
/** Scope id that should render with the `active` emphasis. */
|
|
38
36
|
activeScopeId?: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Fires when the user clicks the rail stripe — opens the scope card.
|
|
39
|
+
* P0 wires this directly; P1 replaces with card-layer-aware routing.
|
|
40
|
+
*/
|
|
41
|
+
onStripeClick?: (segment: ScopeRailSegment) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Legacy click handler kept for existing consumers. Called alongside
|
|
44
|
+
* `onStripeClick` so host apps that subscribed to the pre-stripe API
|
|
45
|
+
* continue to receive clicks.
|
|
46
|
+
*/
|
|
47
|
+
onSegmentClick?: (segment: ScopeRailSegment) => void;
|
|
39
48
|
/** Test id applied to the layer root. */
|
|
40
49
|
"data-testid"?: string;
|
|
41
50
|
}
|
|
@@ -46,7 +55,7 @@ export interface TwScopeRailLayerProps {
|
|
|
46
55
|
|
|
47
56
|
interface PostureStyle {
|
|
48
57
|
labelText: string;
|
|
49
|
-
icon: string;
|
|
58
|
+
icon: string;
|
|
50
59
|
railToken: string;
|
|
51
60
|
tintToken: string;
|
|
52
61
|
}
|
|
@@ -65,20 +74,19 @@ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
|
|
|
65
74
|
// Component
|
|
66
75
|
// ---------------------------------------------------------------------------
|
|
67
76
|
|
|
68
|
-
const DEFAULT_RAIL_LANE_PX =
|
|
69
|
-
const
|
|
77
|
+
const DEFAULT_RAIL_LANE_PX = 44;
|
|
78
|
+
const STRIPE_WIDTH_PX = 4;
|
|
79
|
+
const LABEL_WIDTH_PX = 40;
|
|
70
80
|
|
|
71
81
|
export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
72
82
|
facet,
|
|
73
83
|
space,
|
|
74
84
|
railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
|
|
75
|
-
onSegmentClick,
|
|
76
85
|
activeScopeId,
|
|
86
|
+
onStripeClick,
|
|
87
|
+
onSegmentClick,
|
|
77
88
|
"data-testid": testId,
|
|
78
89
|
}) => {
|
|
79
|
-
// Read the render frame once per paint cycle. The facet.subscribe path
|
|
80
|
-
// already invalidates the caller's React state on layout changes, so we
|
|
81
|
-
// just read on render.
|
|
82
90
|
const frame = typeof facet.getRenderFrame === "function"
|
|
83
91
|
? facet.getRenderFrame() ?? null
|
|
84
92
|
: null;
|
|
@@ -88,16 +96,6 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
88
96
|
return null;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
// Group segments by scopeId so multi-page scopes render one contiguous
|
|
92
|
-
// tint per page range. (Per-page render happens below because each
|
|
93
|
-
// scope may span pages.)
|
|
94
|
-
const items = segments.map((segment) => {
|
|
95
|
-
const rect = resolveSegmentRect(facet, frame, segment);
|
|
96
|
-
if (!rect) return null;
|
|
97
|
-
const style = POSTURE_STYLES[segment.posture];
|
|
98
|
-
return { segment, rect, style };
|
|
99
|
-
}).filter((item): item is NonNullable<typeof item> => item !== null);
|
|
100
|
-
|
|
101
99
|
const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
|
|
102
100
|
|
|
103
101
|
return (
|
|
@@ -108,43 +106,86 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
108
106
|
role="group"
|
|
109
107
|
aria-label="Workflow scope rail"
|
|
110
108
|
>
|
|
111
|
-
{
|
|
112
|
-
const
|
|
113
|
-
const
|
|
109
|
+
{segments.map((segment) => {
|
|
110
|
+
const style = POSTURE_STYLES[segment.posture];
|
|
111
|
+
const lineRects = collectLineRectsForSegment(frame, segment);
|
|
112
|
+
if (lineRects.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
const isActive =
|
|
115
|
+
activeScopeId === segment.scopeId || segment.isActiveWorkItem;
|
|
116
|
+
|
|
117
|
+
// Stripe + label span the vertical range of the scope's lines;
|
|
118
|
+
// they live in the gutter lane to the left of the first line.
|
|
119
|
+
const firstLine = lineRects[0];
|
|
120
|
+
const lastLine = lineRects[lineRects.length - 1];
|
|
121
|
+
const stripeTopPx = firstLine.topPx;
|
|
122
|
+
const stripeHeightPx =
|
|
123
|
+
lastLine.topPx + lastLine.heightPx - firstLine.topPx;
|
|
124
|
+
const stripeRect: RenderFrameRect = {
|
|
125
|
+
leftPx: firstLine.leftPx - railLaneWidthPx + (railLaneWidthPx - STRIPE_WIDTH_PX) / 2,
|
|
126
|
+
topPx: stripeTopPx,
|
|
127
|
+
widthPx: STRIPE_WIDTH_PX,
|
|
128
|
+
heightPx: Math.max(stripeHeightPx, 14),
|
|
129
|
+
};
|
|
114
130
|
const labelRect: RenderFrameRect = {
|
|
115
|
-
leftPx:
|
|
116
|
-
topPx:
|
|
131
|
+
leftPx: firstLine.leftPx - railLaneWidthPx,
|
|
132
|
+
topPx: stripeTopPx,
|
|
117
133
|
widthPx: LABEL_WIDTH_PX,
|
|
118
|
-
heightPx:
|
|
134
|
+
heightPx: 20,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleActivate = () => {
|
|
138
|
+
onStripeClick?.(segment);
|
|
139
|
+
onSegmentClick?.(segment);
|
|
140
|
+
};
|
|
141
|
+
const handleStripeKey = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
142
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
handleActivate();
|
|
145
|
+
}
|
|
119
146
|
};
|
|
120
147
|
|
|
121
148
|
return (
|
|
122
149
|
<React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
|
|
123
|
-
{/*
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
{/* Per-line tint behind the scoped text runs. */}
|
|
151
|
+
{lineRects.map((lineRect, index) => (
|
|
152
|
+
<div
|
|
153
|
+
key={`tint:${index}`}
|
|
154
|
+
className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} ${
|
|
155
|
+
isActive ? "wre-scope-rail-tint-active" : ""
|
|
156
|
+
}`}
|
|
157
|
+
data-scope-id={segment.scopeId}
|
|
158
|
+
data-posture={segment.posture}
|
|
159
|
+
data-line-index={index}
|
|
160
|
+
style={projectRectToOverlay(lineRect, projectorSpace)}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
{/* Rail stripe in the gutter. */}
|
|
133
164
|
<button
|
|
134
165
|
type="button"
|
|
135
|
-
className={`wre-scope-rail-
|
|
136
|
-
isActive ? "wre-scope-rail-
|
|
166
|
+
className={`wre-scope-rail-stripe wre-scope-rail-label-${style.railToken} ${
|
|
167
|
+
isActive ? "wre-scope-rail-stripe-active" : ""
|
|
137
168
|
}`}
|
|
138
169
|
data-scope-id={segment.scopeId}
|
|
139
170
|
data-posture={segment.posture}
|
|
140
|
-
data-
|
|
171
|
+
data-testid="scope-rail-stripe"
|
|
141
172
|
aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
|
|
142
|
-
|
|
173
|
+
aria-expanded={isActive ? "true" : "false"}
|
|
174
|
+
onClick={handleActivate}
|
|
175
|
+
onKeyDown={handleStripeKey}
|
|
176
|
+
style={projectRectToOverlay(stripeRect, projectorSpace)}
|
|
177
|
+
/>
|
|
178
|
+
{/* Label pill — revealed on stripe hover via CSS. */}
|
|
179
|
+
<div
|
|
180
|
+
className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken}`}
|
|
181
|
+
data-scope-id={segment.scopeId}
|
|
182
|
+
data-posture={segment.posture}
|
|
183
|
+
aria-hidden="true"
|
|
143
184
|
style={projectRectToOverlay(labelRect, projectorSpace)}
|
|
144
185
|
>
|
|
145
186
|
<span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
|
|
146
187
|
<span className="wre-scope-rail-label-text">{style.labelText}</span>
|
|
147
|
-
</
|
|
188
|
+
</div>
|
|
148
189
|
</React.Fragment>
|
|
149
190
|
);
|
|
150
191
|
})}
|
|
@@ -156,23 +197,53 @@ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
|
|
|
156
197
|
// Internals
|
|
157
198
|
// ---------------------------------------------------------------------------
|
|
158
199
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Walk the render frame and collect one rect per line whose owning
|
|
202
|
+
* fragment overlaps the segment's offset range. Line frames come from
|
|
203
|
+
* the kernel's per-line projection, which clamps widthPx to the line's
|
|
204
|
+
* text-actual width via `Math.min(regionFrame.widthPx, box.widthTwips
|
|
205
|
+
* * pxPerTwip)`. Multi-line scopes therefore produce one tight tint
|
|
206
|
+
* per line instead of a single bounding-box union.
|
|
207
|
+
*
|
|
208
|
+
* Exported for unit testing — not part of the public API.
|
|
209
|
+
*/
|
|
210
|
+
export function collectLineRectsForSegment(
|
|
211
|
+
frame: RenderFrame,
|
|
162
212
|
segment: ScopeRailSegment,
|
|
163
|
-
): RenderFrameRect
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
213
|
+
): RenderFrameRect[] {
|
|
214
|
+
const rects: RenderFrameRect[] = [];
|
|
215
|
+
const lo = segment.fromOffset;
|
|
216
|
+
const hi = segment.toOffset;
|
|
217
|
+
if (lo >= hi) return rects;
|
|
218
|
+
|
|
219
|
+
const page = frame.pages[segment.pageIndex];
|
|
220
|
+
if (!page) {
|
|
221
|
+
// Fall back to scanning every page — protects against pageIndex
|
|
222
|
+
// drift during an in-flight layout update.
|
|
223
|
+
for (const p of frame.pages) {
|
|
224
|
+
pushRectsFromPage(rects, p, lo, hi);
|
|
225
|
+
}
|
|
226
|
+
return rects;
|
|
227
|
+
}
|
|
228
|
+
pushRectsFromPage(rects, page, lo, hi);
|
|
229
|
+
return rects;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pushRectsFromPage(
|
|
233
|
+
sink: RenderFrameRect[],
|
|
234
|
+
page: RenderFrame["pages"][number],
|
|
235
|
+
lo: number,
|
|
236
|
+
hi: number,
|
|
237
|
+
): void {
|
|
238
|
+
for (const block of page.regions.body.blocks) {
|
|
239
|
+
const from = block.fragment.from;
|
|
240
|
+
const to = block.fragment.to;
|
|
241
|
+
if (to <= lo || from >= hi) continue;
|
|
242
|
+
// Block overlaps the segment — emit one tint per line.
|
|
243
|
+
for (const line of block.lines) {
|
|
244
|
+
sink.push(line.frame);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
176
247
|
}
|
|
177
248
|
|
|
178
249
|
export default TwScopeRailLayer;
|