@beyondwork/docx-react-component 1.0.53 → 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 +1 -1
- package/src/api/public-types.ts +35 -7
- package/src/io/docx-session.ts +30 -6
- 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 +23 -9
- 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/render/render-frame-diff.ts +38 -2
- package/src/ui/WordReviewEditor.tsx +6 -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/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- 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
|
@@ -97,10 +97,17 @@ const MODE_OPTIONS: ReadonlyArray<{ mode: WorkflowScopeMode; label: string }> =
|
|
|
97
97
|
];
|
|
98
98
|
|
|
99
99
|
const SEVERITY_COLOR: Record<IssueSeverity, string> = {
|
|
100
|
-
low:
|
|
101
|
-
medium:
|
|
102
|
-
high:
|
|
103
|
-
blocker: "var(--color-
|
|
100
|
+
low: "var(--color-semantic-info)",
|
|
101
|
+
medium: "var(--color-semantic-warning)",
|
|
102
|
+
high: "var(--color-semantic-error)",
|
|
103
|
+
blocker: "var(--color-semantic-error)",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const SEVERITY_SOFT: Record<IssueSeverity, string> = {
|
|
107
|
+
low: "var(--color-semantic-info-soft)",
|
|
108
|
+
medium: "var(--color-semantic-warning-soft)",
|
|
109
|
+
high: "var(--color-semantic-error-soft)",
|
|
110
|
+
blocker: "var(--color-semantic-error-soft)",
|
|
104
111
|
};
|
|
105
112
|
|
|
106
113
|
const SEVERITY_LABEL: Record<IssueSeverity, string> = {
|
|
@@ -151,14 +158,18 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
151
158
|
}, []);
|
|
152
159
|
|
|
153
160
|
// --- Escape + click-outside ---------------------------------------------
|
|
161
|
+
// Pinned cards survive Escape and click-outside; only unpinned cards
|
|
162
|
+
// are dismissed by those gestures.
|
|
154
163
|
React.useEffect(() => {
|
|
155
164
|
const onKey = (event: KeyboardEvent) => {
|
|
156
165
|
if (event.key === "Escape") {
|
|
166
|
+
if (pinned) return; // pinned cards are immune to Escape
|
|
157
167
|
event.stopPropagation();
|
|
158
168
|
onClose();
|
|
159
169
|
}
|
|
160
170
|
};
|
|
161
171
|
const onPointerDown = (event: PointerEvent) => {
|
|
172
|
+
if (pinned) return; // pinned cards are immune to click-outside
|
|
162
173
|
const root = rootRef.current;
|
|
163
174
|
if (!root) return;
|
|
164
175
|
if (event.target instanceof Node && root.contains(event.target)) return;
|
|
@@ -170,7 +181,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
170
181
|
window.removeEventListener("keydown", onKey, true);
|
|
171
182
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
172
183
|
};
|
|
173
|
-
}, [onClose]);
|
|
184
|
+
}, [onClose, pinned]);
|
|
174
185
|
|
|
175
186
|
// --- Focus trap ----------------------------------------------------------
|
|
176
187
|
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
@@ -201,7 +212,8 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
201
212
|
return (
|
|
202
213
|
<div
|
|
203
214
|
ref={rootRef}
|
|
204
|
-
className="wre-scope-card pointer-events-auto absolute flex w-
|
|
215
|
+
className="wre-scope-card pointer-events-auto absolute flex min-w-[320px] max-w-[500px] flex-col gap-2 rounded-[var(--radius-xl)] border border-[var(--color-border-default)] bg-[var(--color-bg-canvas)] p-[14px] text-sm shadow-[var(--shadow-float)]"
|
|
216
|
+
style={{ padding: "calc(14px * var(--space-density-multiplier))" }}
|
|
205
217
|
role="dialog"
|
|
206
218
|
aria-modal="false"
|
|
207
219
|
aria-labelledby={headerId}
|
|
@@ -215,7 +227,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
215
227
|
<div className="flex items-center justify-between gap-2">
|
|
216
228
|
<div
|
|
217
229
|
id={headerId}
|
|
218
|
-
className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-primary"
|
|
230
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-xs font-medium text-[var(--color-text-primary)]"
|
|
219
231
|
>
|
|
220
232
|
<span
|
|
221
233
|
className={`wre-scope-rail-icon wre-scope-rail-icon-${posturePresentationIcon(model.posture)}`}
|
|
@@ -226,7 +238,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
226
238
|
{postureLabel}
|
|
227
239
|
</span>
|
|
228
240
|
{model.label ? (
|
|
229
|
-
<span className="truncate text-tertiary">· {model.label}</span>
|
|
241
|
+
<span className="truncate text-[var(--color-text-tertiary)]">· {model.label}</span>
|
|
230
242
|
) : null}
|
|
231
243
|
</div>
|
|
232
244
|
<div className="flex items-center gap-1">
|
|
@@ -237,8 +249,8 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
237
249
|
aria-pressed={pinned ? "true" : "false"}
|
|
238
250
|
className={`flex h-6 w-6 items-center justify-center rounded-sm text-[10px] font-semibold uppercase tracking-[0.06em] transition-colors ${
|
|
239
251
|
pinned
|
|
240
|
-
? "bg-surface text-accent"
|
|
241
|
-
: "text-tertiary hover:bg-surface hover:text-primary"
|
|
252
|
+
? "bg-surface text-[var(--color-accent-primary)]"
|
|
253
|
+
: "text-[var(--color-text-tertiary)] hover:bg-surface hover:text-[var(--color-text-primary)]"
|
|
242
254
|
}`}
|
|
243
255
|
onClick={onTogglePin}
|
|
244
256
|
data-testid="scope-card-pin"
|
|
@@ -264,7 +276,7 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
264
276
|
<div
|
|
265
277
|
role="group"
|
|
266
278
|
aria-label="Scope mode"
|
|
267
|
-
className="flex
|
|
279
|
+
className="flex items-center gap-0 border-b border-[var(--color-border-default)]"
|
|
268
280
|
>
|
|
269
281
|
{MODE_OPTIONS.map(({ mode, label }) => {
|
|
270
282
|
const active = model.posture === mode;
|
|
@@ -273,10 +285,10 @@ export const TwScopeCard: React.FC<TwScopeCardProps> = ({
|
|
|
273
285
|
key={mode}
|
|
274
286
|
type="button"
|
|
275
287
|
aria-pressed={active ? "true" : "false"}
|
|
276
|
-
className={`
|
|
288
|
+
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
|
277
289
|
active
|
|
278
|
-
? "
|
|
279
|
-
: "
|
|
290
|
+
? "border-[var(--color-accent-primary)] text-[var(--color-text-primary)]"
|
|
291
|
+
: "border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
|
280
292
|
}`}
|
|
281
293
|
onClick={() => onModeChange(mode)}
|
|
282
294
|
data-testid={`scope-card-mode-${mode}`}
|
|
@@ -382,10 +394,13 @@ const IssueRow: React.FC<{
|
|
|
382
394
|
<div className="flex items-center gap-1.5 text-[11px]">
|
|
383
395
|
<span
|
|
384
396
|
aria-hidden="true"
|
|
385
|
-
className="inline-
|
|
386
|
-
style={{
|
|
387
|
-
|
|
388
|
-
|
|
397
|
+
className="inline-flex items-center rounded-[var(--radius-sm)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
|
398
|
+
style={{
|
|
399
|
+
color: SEVERITY_COLOR[issue.severity],
|
|
400
|
+
backgroundColor: SEVERITY_SOFT[issue.severity],
|
|
401
|
+
}}
|
|
402
|
+
data-testid="scope-card-severity-badge"
|
|
403
|
+
>
|
|
389
404
|
{SEVERITY_LABEL[issue.severity]}
|
|
390
405
|
</span>
|
|
391
406
|
{issue.owner ? (
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders repeated header rows for a multi-page table on a continuation page.
|
|
3
|
+
*
|
|
4
|
+
* Architecture: one PM view → one `<table>` DOM element per table. NodeView
|
|
5
|
+
* cannot clip rows by page, so header repetition on continuation pages must
|
|
6
|
+
* come from a chrome overlay. This component renders an absolutely-positioned
|
|
7
|
+
* `<table>` (header rows only) at the Y-offset of the table's first row on
|
|
8
|
+
* this page, computed from `facet.getTableBodyYOffsetOnPage`. Visual-only
|
|
9
|
+
* (`aria-hidden`, `pointer-events: none`); no effect on PM doc structure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import type { SurfaceTableRowSnapshot } from "../../api/public-types.ts";
|
|
14
|
+
import type { WordReviewEditorLayoutFacet } from "../../runtime/layout/public-facet.ts";
|
|
15
|
+
|
|
16
|
+
export interface TwTableContinuationHeaderProps {
|
|
17
|
+
blockId: string;
|
|
18
|
+
pageIndex: number;
|
|
19
|
+
facet: WordReviewEditorLayoutFacet;
|
|
20
|
+
/** Pre-resolved header rows (from `SurfaceTableBlock.rows[ref.sourceRowIndex]`). */
|
|
21
|
+
headerRows: readonly SurfaceTableRowSnapshot[];
|
|
22
|
+
/** px-per-twip conversion factor from the page layout. */
|
|
23
|
+
pxPerTwip: number;
|
|
24
|
+
/** Top edge of the page body in px (absolute within the page frame). */
|
|
25
|
+
bodyOriginTopPx: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function TwTableContinuationHeaderInner({
|
|
29
|
+
blockId,
|
|
30
|
+
pageIndex,
|
|
31
|
+
facet,
|
|
32
|
+
headerRows,
|
|
33
|
+
pxPerTwip,
|
|
34
|
+
bodyOriginTopPx,
|
|
35
|
+
}: TwTableContinuationHeaderProps): React.ReactElement | null {
|
|
36
|
+
const yOffsetTwips = facet.getTableBodyYOffsetOnPage(blockId, pageIndex);
|
|
37
|
+
if (yOffsetTwips === null || headerRows.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
const topPx = bodyOriginTopPx + yOffsetTwips * pxPerTwip;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
aria-hidden
|
|
44
|
+
style={{
|
|
45
|
+
position: "absolute",
|
|
46
|
+
top: `${topPx}px`,
|
|
47
|
+
left: 0,
|
|
48
|
+
right: 0,
|
|
49
|
+
pointerEvents: "none",
|
|
50
|
+
overflow: "hidden",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
54
|
+
<tbody>
|
|
55
|
+
{headerRows.map((row, ri) => (
|
|
56
|
+
<tr key={ri}>
|
|
57
|
+
{row.cells.map((cell, ci) => (
|
|
58
|
+
<td
|
|
59
|
+
key={ci}
|
|
60
|
+
colSpan={cell.colspan ?? 1}
|
|
61
|
+
style={{
|
|
62
|
+
backgroundColor: cell.backgroundColor ?? undefined,
|
|
63
|
+
verticalAlign: cell.verticalAlign ?? "top",
|
|
64
|
+
borderTop: cell.borderTop ?? undefined,
|
|
65
|
+
borderRight: cell.borderRight ?? undefined,
|
|
66
|
+
borderBottom: cell.borderBottom ?? undefined,
|
|
67
|
+
borderLeft: cell.borderLeft ?? undefined,
|
|
68
|
+
padding: "2px 4px",
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{cell.content
|
|
72
|
+
.filter((b) => b.kind === "paragraph")
|
|
73
|
+
.map((b, bi) =>
|
|
74
|
+
b.kind === "paragraph" ? (
|
|
75
|
+
<span key={bi}>
|
|
76
|
+
{b.segments
|
|
77
|
+
.map((s) => ("text" in s ? s.text : ""))
|
|
78
|
+
.join("")}
|
|
79
|
+
</span>
|
|
80
|
+
) : null,
|
|
81
|
+
)}
|
|
82
|
+
</td>
|
|
83
|
+
))}
|
|
84
|
+
</tr>
|
|
85
|
+
))}
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const TwTableContinuationHeader = React.memo(
|
|
93
|
+
TwTableContinuationHeaderInner,
|
|
94
|
+
);
|
|
@@ -308,24 +308,37 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
|
|
|
308
308
|
line.style.right = "0";
|
|
309
309
|
line.style.top = `${Math.round(input.interGapPx / 2)}px`;
|
|
310
310
|
line.style.height = "0";
|
|
311
|
-
line.style.borderTop = "1px dotted var(--color-border
|
|
311
|
+
line.style.borderTop = "1px dotted var(--color-border-default)";
|
|
312
312
|
root.appendChild(line);
|
|
313
313
|
|
|
314
|
+
// L6d.U2: badge is now a true pill — `--radius-pill` geometry,
|
|
315
|
+
// hairline `--color-border-default` border, subtle `--shadow-soft`
|
|
316
|
+
// so the callout reads as a card floating over the seam line rather
|
|
317
|
+
// than text painted on top of it. The pill is 18 px tall (10 px
|
|
318
|
+
// font + 8 px vertical padding + hairline border); re-center so the
|
|
319
|
+
// seam line bisects the pill vertically.
|
|
320
|
+
const PILL_HEIGHT_PX = 18;
|
|
314
321
|
const badge = document.createElement("span");
|
|
315
322
|
badge.className = "wre-page-chrome-canvas-badge";
|
|
316
323
|
badge.setAttribute("data-kind", "canvas-seam-badge");
|
|
324
|
+
badge.setAttribute("data-variant", "pill");
|
|
317
325
|
badge.textContent = input.nextPageLabel;
|
|
318
326
|
badge.style.position = "absolute";
|
|
319
|
-
badge.style.top = `${Math.round(input.interGapPx / 2) -
|
|
327
|
+
badge.style.top = `${Math.round(input.interGapPx / 2) - Math.round(PILL_HEIGHT_PX / 2)}px`;
|
|
320
328
|
badge.style.left = "50%";
|
|
321
329
|
badge.style.transform = "translateX(-50%)";
|
|
330
|
+
badge.style.display = "inline-flex";
|
|
331
|
+
badge.style.alignItems = "center";
|
|
332
|
+
badge.style.height = `${PILL_HEIGHT_PX}px`;
|
|
333
|
+
badge.style.padding = "0 10px";
|
|
322
334
|
badge.style.fontSize = "10px";
|
|
323
335
|
badge.style.letterSpacing = "0.12em";
|
|
324
336
|
badge.style.textTransform = "uppercase";
|
|
325
|
-
badge.style.color = "var(--color-text-tertiary
|
|
326
|
-
badge.style.backgroundColor =
|
|
327
|
-
|
|
328
|
-
badge.style.
|
|
337
|
+
badge.style.color = "var(--color-text-tertiary)";
|
|
338
|
+
badge.style.backgroundColor = "var(--color-surface)";
|
|
339
|
+
badge.style.border = "1px solid var(--color-border-default)";
|
|
340
|
+
badge.style.borderRadius = "var(--radius-pill)";
|
|
341
|
+
badge.style.boxShadow = "var(--shadow-soft)";
|
|
329
342
|
root.appendChild(badge);
|
|
330
343
|
return root;
|
|
331
344
|
}
|
|
@@ -360,7 +373,7 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
|
|
|
360
373
|
label.style.fontSize = "10px";
|
|
361
374
|
label.style.letterSpacing = "0.12em";
|
|
362
375
|
label.style.textTransform = "uppercase";
|
|
363
|
-
label.style.color = "var(--color-tertiary
|
|
376
|
+
label.style.color = "var(--color-text-tertiary)";
|
|
364
377
|
// Intentionally no background, no border, no padding — the label floats
|
|
365
378
|
// transparently in the workspace-canvas gap.
|
|
366
379
|
root.appendChild(label);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — Slice U3: workspace-mode scroll anchoring.
|
|
3
|
+
*
|
|
4
|
+
* Capturing a scroll anchor before toggling `workspaceMode` between
|
|
5
|
+
* `canvas` and `page` — then restoring it after the new chrome has
|
|
6
|
+
* painted — keeps the user's viewport over the same paragraph even
|
|
7
|
+
* though the page-break widgets add ~112 px of vertical spacer per
|
|
8
|
+
* boundary in page mode.
|
|
9
|
+
*
|
|
10
|
+
* The anchor is keyed on `data-block-id` rather than pixel offset so
|
|
11
|
+
* it survives arbitrary height changes across the toggle. Each helper
|
|
12
|
+
* does a single DOM read or a single `scrollTop` write — no mutation
|
|
13
|
+
* observers, no rAF loops — so the inverted-PM contract is preserved.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ScrollAnchor {
|
|
17
|
+
/** `data-block-id` of the topmost block straddling or below scrollTop. */
|
|
18
|
+
blockId: string;
|
|
19
|
+
/**
|
|
20
|
+
* Offset of the viewport's top edge within that block's rect, in CSS
|
|
21
|
+
* pixels. `restoreScrollAnchor` adds this back so the block's
|
|
22
|
+
* leading edge lands in the same relative spot after the toggle.
|
|
23
|
+
*/
|
|
24
|
+
offsetWithinBlock: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the scroll anchor for `root` — the scroll container whose
|
|
29
|
+
* `scrollTop` / `getBoundingClientRect` we will read.
|
|
30
|
+
*
|
|
31
|
+
* Returns `null` when the root has no `[data-block-id]` descendants or
|
|
32
|
+
* when the root itself has zero height (e.g. disconnected during the
|
|
33
|
+
* toggle).
|
|
34
|
+
*/
|
|
35
|
+
export function findScrollAnchor(root: HTMLElement | null): ScrollAnchor | null {
|
|
36
|
+
if (!root) return null;
|
|
37
|
+
const blocks = root.querySelectorAll<HTMLElement>("[data-block-id]");
|
|
38
|
+
if (blocks.length === 0) return null;
|
|
39
|
+
const rootRect = root.getBoundingClientRect();
|
|
40
|
+
const rootTop = rootRect.top;
|
|
41
|
+
// Walk blocks in document order; pick the first one whose bottom is
|
|
42
|
+
// >= the scroll root's top edge. That's the topmost block at least
|
|
43
|
+
// partially visible (or the one just below when the viewport sits in
|
|
44
|
+
// a gap between blocks).
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
const rect = block.getBoundingClientRect();
|
|
47
|
+
if (rect.bottom < rootTop) continue;
|
|
48
|
+
const blockId = block.getAttribute("data-block-id");
|
|
49
|
+
if (!blockId) continue;
|
|
50
|
+
return {
|
|
51
|
+
blockId,
|
|
52
|
+
offsetWithinBlock: rootTop - rect.top,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Restore an earlier anchor by finding the same `data-block-id` under
|
|
60
|
+
* `root` and adjusting `scrollTop` so the block's leading edge sits at
|
|
61
|
+
* the same offset relative to the viewport.
|
|
62
|
+
*
|
|
63
|
+
* Graceful no-op when the anchor's block no longer exists (deletion
|
|
64
|
+
* mid-toggle, doc swap) or when the new root is empty.
|
|
65
|
+
*/
|
|
66
|
+
export function restoreScrollAnchor(
|
|
67
|
+
root: HTMLElement | null,
|
|
68
|
+
anchor: ScrollAnchor | null,
|
|
69
|
+
): void {
|
|
70
|
+
if (!root || !anchor) return;
|
|
71
|
+
const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
|
|
72
|
+
const block = root.querySelector<HTMLElement>(selector);
|
|
73
|
+
if (!block) return;
|
|
74
|
+
const rootRect = root.getBoundingClientRect();
|
|
75
|
+
const blockRect = block.getBoundingClientRect();
|
|
76
|
+
// We want, post-restore, `blockRect.top === rootRect.top - offsetWithinBlock`
|
|
77
|
+
// (i.e. the block's leading edge sits `offsetWithinBlock` px above the
|
|
78
|
+
// viewport top because the viewport sat that far INTO the block at
|
|
79
|
+
// capture time). Scrolling by `delta` shifts rects by `-delta`, so
|
|
80
|
+
// solve for delta: delta = blockRect.top - rootRect.top + offsetWithinBlock.
|
|
81
|
+
const delta = blockRect.top - rootRect.top + anchor.offsetWithinBlock;
|
|
82
|
+
root.scrollTop = root.scrollTop + delta;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Minimal CSS.escape shim for attribute selectors — `data-block-id`
|
|
87
|
+
* values include UUIDs or runtime-offset strings, so escape anything
|
|
88
|
+
* that isn't a safe word-char. Avoids depending on DOM `CSS.escape`
|
|
89
|
+
* which may be absent under some JSDOM builds.
|
|
90
|
+
*/
|
|
91
|
+
function cssEscape(value: string): string {
|
|
92
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
|
|
93
|
+
}
|
package/src/ui-tailwind/index.ts
CHANGED
|
@@ -56,6 +56,17 @@ export {
|
|
|
56
56
|
type TwModeDockAction,
|
|
57
57
|
type TwModeDockProps,
|
|
58
58
|
} from "./chrome/tw-mode-dock";
|
|
59
|
+
export {
|
|
60
|
+
TwPasteDropToast,
|
|
61
|
+
type TwPasteDropToastProps,
|
|
62
|
+
} from "./chrome/tw-paste-drop-toast";
|
|
63
|
+
export {
|
|
64
|
+
TwCommandPalette,
|
|
65
|
+
isCommandPaletteOpenShortcut,
|
|
66
|
+
type CommandPaletteGroup,
|
|
67
|
+
type CommandPaletteItem,
|
|
68
|
+
type TwCommandPaletteProps,
|
|
69
|
+
} from "./chrome/tw-command-palette";
|
|
59
70
|
|
|
60
71
|
// Collab chrome (P9) — mount when chromePreset === "collab"; each
|
|
61
72
|
// component is pure presentational and takes snapshots + callbacks.
|
|
@@ -17,9 +17,11 @@ import React from "react";
|
|
|
17
17
|
import type {
|
|
18
18
|
EditorStoryTarget,
|
|
19
19
|
PublicPageNode,
|
|
20
|
+
SurfaceTableRowSnapshot,
|
|
20
21
|
WordReviewEditorLayoutFacet,
|
|
21
22
|
} from "../../api/public-types.ts";
|
|
22
23
|
import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
|
|
24
|
+
import { TwTableContinuationHeader } from "../chrome-overlay/tw-table-continuation-header.tsx";
|
|
23
25
|
import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
|
|
24
26
|
import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
|
|
25
27
|
import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
|
|
@@ -85,6 +87,32 @@ function TwPageChromeEntryInner({
|
|
|
85
87
|
[facet, pageIndex, page, renderFrameRevision],
|
|
86
88
|
);
|
|
87
89
|
|
|
90
|
+
// Continuation table header entries: only non-empty for continuation pages
|
|
91
|
+
// of multi-page tables (isContinuationPage && repeatedHeaderRows.length > 0).
|
|
92
|
+
const continuationTableEntries = React.useMemo((): Array<{
|
|
93
|
+
blockId: string;
|
|
94
|
+
headerRows: readonly SurfaceTableRowSnapshot[];
|
|
95
|
+
}> => {
|
|
96
|
+
const bodyBlocks = facet.getStoryBlocksForRegion(pageIndex, "body");
|
|
97
|
+
const entries: Array<{
|
|
98
|
+
blockId: string;
|
|
99
|
+
headerRows: readonly SurfaceTableRowSnapshot[];
|
|
100
|
+
}> = [];
|
|
101
|
+
for (const { blockSnapshot } of bodyBlocks) {
|
|
102
|
+
if (blockSnapshot.kind !== "table") continue;
|
|
103
|
+
const plan = facet.getTableRenderPlan(blockSnapshot.blockId, pageIndex);
|
|
104
|
+
if (!plan || plan.repeatedHeaderRows.length === 0) continue;
|
|
105
|
+
const headerRows = plan.repeatedHeaderRows
|
|
106
|
+
.map((ref) => blockSnapshot.rows[ref.sourceRowIndex])
|
|
107
|
+
.filter((r): r is SurfaceTableRowSnapshot => r !== undefined);
|
|
108
|
+
if (headerRows.length > 0) {
|
|
109
|
+
entries.push({ blockId: blockSnapshot.blockId, headerRows });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return entries;
|
|
113
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
+
}, [facet, pageIndex, page, renderFrameRevision]);
|
|
115
|
+
|
|
88
116
|
const handleHeaderClick = React.useCallback(
|
|
89
117
|
() => headerStory && onOpenStory?.(headerStory),
|
|
90
118
|
[onOpenStory, headerStory],
|
|
@@ -125,6 +153,15 @@ function TwPageChromeEntryInner({
|
|
|
125
153
|
const bandWidthPx = px(layout.pageWidth - layout.marginLeft - layout.marginRight);
|
|
126
154
|
const bandLeftPx = px(layout.marginLeft);
|
|
127
155
|
|
|
156
|
+
// L6d.U1 — 1-based section label for the active-band ribbon. The
|
|
157
|
+
// `sectionIndex` on the page is 0-based; reviewers expect "Section 1"
|
|
158
|
+
// not "Section 0", matching Word's section-break dialog numbering.
|
|
159
|
+
const sectionNumber = (page.sectionIndex ?? 0) + 1;
|
|
160
|
+
const headerSectionLabel = `Header — Section ${sectionNumber}`;
|
|
161
|
+
const footerSectionLabel = `Footer — Section ${sectionNumber}`;
|
|
162
|
+
const headerActive = headerStory && isActiveStoryMatch(activeStory, headerStory);
|
|
163
|
+
const footerActive = footerStory && isActiveStoryMatch(activeStory, footerStory);
|
|
164
|
+
|
|
128
165
|
return (
|
|
129
166
|
<div
|
|
130
167
|
data-page-chrome-frame=""
|
|
@@ -146,7 +183,8 @@ function TwPageChromeEntryInner({
|
|
|
146
183
|
leftPx={bandLeftPx}
|
|
147
184
|
widthPx={bandWidthPx}
|
|
148
185
|
bandHeightPx={px(headerRegion.heightTwips)}
|
|
149
|
-
isActiveSlot={
|
|
186
|
+
isActiveSlot={Boolean(headerActive)}
|
|
187
|
+
sectionLabel={headerActive ? headerSectionLabel : undefined}
|
|
150
188
|
onClick={handleHeaderClick}
|
|
151
189
|
/>
|
|
152
190
|
) : null}
|
|
@@ -158,7 +196,8 @@ function TwPageChromeEntryInner({
|
|
|
158
196
|
leftPx={bandLeftPx}
|
|
159
197
|
widthPx={bandWidthPx}
|
|
160
198
|
bandHeightPx={px(footerRegion.heightTwips)}
|
|
161
|
-
isActiveSlot={
|
|
199
|
+
isActiveSlot={Boolean(footerActive)}
|
|
200
|
+
sectionLabel={footerActive ? footerSectionLabel : undefined}
|
|
162
201
|
onClick={handleFooterClick}
|
|
163
202
|
/>
|
|
164
203
|
) : null}
|
|
@@ -172,6 +211,17 @@ function TwPageChromeEntryInner({
|
|
|
172
211
|
heightPx={px(footnoteRegion.heightTwips)}
|
|
173
212
|
/>
|
|
174
213
|
) : null}
|
|
214
|
+
{continuationTableEntries.map(({ blockId, headerRows }) => (
|
|
215
|
+
<TwTableContinuationHeader
|
|
216
|
+
key={`table-cont-${blockId}`}
|
|
217
|
+
blockId={blockId}
|
|
218
|
+
pageIndex={pageIndex}
|
|
219
|
+
facet={facet}
|
|
220
|
+
headerRows={headerRows}
|
|
221
|
+
pxPerTwip={FRAME_PX_PER_TWIP_AT_96DPI}
|
|
222
|
+
bodyOriginTopPx={px(layout.marginTop)}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
175
225
|
</div>
|
|
176
226
|
);
|
|
177
227
|
}
|
|
@@ -27,6 +27,11 @@ export interface TwPageFooterBandProps {
|
|
|
27
27
|
widthPx: number;
|
|
28
28
|
/** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
|
|
29
29
|
isActiveSlot: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Lane 6d.U1 — section label for the active-band ribbon (e.g. "Footer — Section 1").
|
|
32
|
+
* Only rendered when `isActiveSlot` is true.
|
|
33
|
+
*/
|
|
34
|
+
sectionLabel?: string;
|
|
30
35
|
onClick: () => void;
|
|
31
36
|
"data-testid"?: string;
|
|
32
37
|
}
|
|
@@ -39,13 +44,16 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
|
39
44
|
leftPx,
|
|
40
45
|
widthPx,
|
|
41
46
|
isActiveSlot,
|
|
47
|
+
sectionLabel,
|
|
42
48
|
onClick,
|
|
43
49
|
"data-testid": testId,
|
|
44
50
|
}) => {
|
|
45
51
|
return (
|
|
46
52
|
<div
|
|
53
|
+
className="wre-page-band"
|
|
47
54
|
data-page-band="footer"
|
|
48
55
|
data-page-index={pageIndex}
|
|
56
|
+
data-active={isActiveSlot ? "true" : undefined}
|
|
49
57
|
data-testid={testId}
|
|
50
58
|
onClick={onClick}
|
|
51
59
|
style={{
|
|
@@ -57,6 +65,11 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
|
57
65
|
cursor: "pointer",
|
|
58
66
|
}}
|
|
59
67
|
>
|
|
68
|
+
{isActiveSlot && sectionLabel ? (
|
|
69
|
+
<span className="wre-page-band__label" data-kind="page-band-label">
|
|
70
|
+
{sectionLabel}
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
60
73
|
{isActiveSlot ? (
|
|
61
74
|
<div
|
|
62
75
|
data-pm-portal-slot
|
|
@@ -28,6 +28,11 @@ export interface TwPageHeaderBandProps {
|
|
|
28
28
|
widthPx: number;
|
|
29
29
|
/** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
|
|
30
30
|
isActiveSlot: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Lane 6d.U1 — section label for the active-band ribbon (e.g. "Header — Section 1").
|
|
33
|
+
* Only rendered when `isActiveSlot` is true.
|
|
34
|
+
*/
|
|
35
|
+
sectionLabel?: string;
|
|
31
36
|
onClick: () => void;
|
|
32
37
|
"data-testid"?: string;
|
|
33
38
|
}
|
|
@@ -40,13 +45,16 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
|
40
45
|
leftPx,
|
|
41
46
|
widthPx,
|
|
42
47
|
isActiveSlot,
|
|
48
|
+
sectionLabel,
|
|
43
49
|
onClick,
|
|
44
50
|
"data-testid": testId,
|
|
45
51
|
}) => {
|
|
46
52
|
return (
|
|
47
53
|
<div
|
|
54
|
+
className="wre-page-band"
|
|
48
55
|
data-page-band="header"
|
|
49
56
|
data-page-index={pageIndex}
|
|
57
|
+
data-active={isActiveSlot ? "true" : undefined}
|
|
50
58
|
data-testid={testId}
|
|
51
59
|
onClick={onClick}
|
|
52
60
|
style={{
|
|
@@ -58,6 +66,11 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
|
58
66
|
cursor: "pointer",
|
|
59
67
|
}}
|
|
60
68
|
>
|
|
69
|
+
{isActiveSlot && sectionLabel ? (
|
|
70
|
+
<span className="wre-page-band__label" data-kind="page-band-label">
|
|
71
|
+
{sectionLabel}
|
|
72
|
+
</span>
|
|
73
|
+
) : null}
|
|
61
74
|
{isActiveSlot ? (
|
|
62
75
|
<div
|
|
63
76
|
data-pm-portal-slot
|
|
@@ -361,6 +361,14 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
361
361
|
ref={overlayRootRef}
|
|
362
362
|
data-page-stack-chrome-layer=""
|
|
363
363
|
data-testid={testId ?? "page-stack-chrome-layer"}
|
|
364
|
+
// L6d.U1 — expose the active-story kind so the CSS can dim the
|
|
365
|
+
// body when an H/F story is being edited and un-dim again when
|
|
366
|
+
// the active story is the main body.
|
|
367
|
+
data-story-active={
|
|
368
|
+
activeStory.kind === "header" || activeStory.kind === "footer"
|
|
369
|
+
? activeStory.kind
|
|
370
|
+
: undefined
|
|
371
|
+
}
|
|
364
372
|
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
|
|
365
373
|
>
|
|
366
374
|
{rects.map((rect, pageIndex) => {
|