@beyondwork/docx-react-component 1.0.41 → 1.0.42
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 +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
PublicMeasurementFidelity,
|
|
5
|
+
RuntimeContextAnalyticsSnapshot,
|
|
6
|
+
} from "../../api/public-types";
|
|
4
7
|
|
|
5
8
|
export interface TwStatusBarProps {
|
|
6
9
|
isDirty: boolean;
|
|
@@ -10,6 +13,26 @@ export interface TwStatusBarProps {
|
|
|
10
13
|
changeCount: number;
|
|
11
14
|
sessionId: string;
|
|
12
15
|
contextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
|
|
16
|
+
/**
|
|
17
|
+
* P5b: **displayed** page number for the selection head. When the
|
|
18
|
+
* active section restarts page numbering (roman front matter → arabic
|
|
19
|
+
* body), this reflects what Word would print on the page — not the
|
|
20
|
+
* raw 0-based document-order index. Pass `null` when the facet has
|
|
21
|
+
* no resolved page (e.g., during initial load).
|
|
22
|
+
*/
|
|
23
|
+
displayPageNumber?: number | null;
|
|
24
|
+
/**
|
|
25
|
+
* P5b: total **content** page count (excludes evenPage / oddPage
|
|
26
|
+
* blank fillers). Matches `facet.getPageCount()` — same quantity a
|
|
27
|
+
* `NUMPAGES` field resolves to on every page.
|
|
28
|
+
*/
|
|
29
|
+
pageCount?: number | null;
|
|
30
|
+
/**
|
|
31
|
+
* P5b: measurement fidelity the layout engine is computing against.
|
|
32
|
+
* Surfaced as a compact badge so harness operators can see when the
|
|
33
|
+
* canvas backend is live vs. the empirical fallback. Absent = skip.
|
|
34
|
+
*/
|
|
35
|
+
measurementFidelity?: PublicMeasurementFidelity;
|
|
13
36
|
}
|
|
14
37
|
|
|
15
38
|
export function TwStatusBar(props: TwStatusBarProps) {
|
|
@@ -56,10 +79,38 @@ export function TwStatusBar(props: TwStatusBarProps) {
|
|
|
56
79
|
{props.commentCount} comment{props.commentCount !== 1 ? "s" : ""} ·{" "}
|
|
57
80
|
{props.changeCount} change{props.changeCount !== 1 ? "s" : ""}
|
|
58
81
|
</span>
|
|
82
|
+
{props.displayPageNumber != null && props.pageCount != null ? (
|
|
83
|
+
<span data-testid="status-bar-page-count">
|
|
84
|
+
Page {props.displayPageNumber} of {props.pageCount}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
59
87
|
<span className="flex-1" />
|
|
88
|
+
{props.measurementFidelity ? (
|
|
89
|
+
<span
|
|
90
|
+
data-testid="status-bar-measurement-fidelity"
|
|
91
|
+
data-fidelity={props.measurementFidelity}
|
|
92
|
+
className="uppercase tracking-[0.12em] text-tertiary/70"
|
|
93
|
+
title={`Measurement fidelity: ${props.measurementFidelity}`}
|
|
94
|
+
>
|
|
95
|
+
{formatFidelityBadge(props.measurementFidelity)}
|
|
96
|
+
</span>
|
|
97
|
+
) : null}
|
|
60
98
|
<span className="truncate text-[10px] uppercase tracking-[0.12em] text-tertiary/80">
|
|
61
99
|
Session active
|
|
62
100
|
</span>
|
|
63
101
|
</footer>
|
|
64
102
|
);
|
|
65
103
|
}
|
|
104
|
+
|
|
105
|
+
function formatFidelityBadge(fidelity: PublicMeasurementFidelity): string {
|
|
106
|
+
switch (fidelity) {
|
|
107
|
+
case "empirical":
|
|
108
|
+
return "E";
|
|
109
|
+
case "canvas":
|
|
110
|
+
return "C";
|
|
111
|
+
case "canvas-with-font-loading":
|
|
112
|
+
return "C+F";
|
|
113
|
+
default:
|
|
114
|
+
return String(fidelity);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -488,6 +488,31 @@
|
|
|
488
488
|
outline-offset: -1px;
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
+
/*
|
|
492
|
+
* ─── Agent-pending shimmer (K2 / scope-card-overlay P2) ───
|
|
493
|
+
*
|
|
494
|
+
* Painted on every scope tint that overlaps a WorkflowCandidateRange
|
|
495
|
+
* with `source: "ai"`. A soft 1.8s pulse signals the agent is
|
|
496
|
+
* thinking without competing with the active outline. Reduced-
|
|
497
|
+
* motion disables the animation and holds a static 60% opacity
|
|
498
|
+
* border so the posture is still readable.
|
|
499
|
+
*/
|
|
500
|
+
@keyframes wre-agent-pulse {
|
|
501
|
+
0%, 100% { opacity: 0.4; }
|
|
502
|
+
50% { opacity: 0.85; }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.wre-scope-rail-tint-agent-pending {
|
|
506
|
+
outline: 1px solid color-mix(in srgb, var(--color-workflow) 70%, transparent);
|
|
507
|
+
outline-offset: -1px;
|
|
508
|
+
animation: wre-agent-pulse 1.8s ease-in-out infinite;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
[data-reduced-motion="true"] .wre-scope-rail-tint-agent-pending {
|
|
512
|
+
animation: none;
|
|
513
|
+
opacity: 0.6;
|
|
514
|
+
}
|
|
515
|
+
|
|
491
516
|
/*
|
|
492
517
|
* ─── Scope rail stripe ───
|
|
493
518
|
*
|
|
@@ -88,6 +88,10 @@ import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
|
|
|
88
88
|
import { TwStatusBar } from "./status/tw-status-bar";
|
|
89
89
|
import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
|
|
90
90
|
import { TwChromeOverlay } from "./chrome-overlay";
|
|
91
|
+
import {
|
|
92
|
+
cycleScopeIndex,
|
|
93
|
+
shouldHandleScopeNavKey,
|
|
94
|
+
} from "./chrome-overlay/scope-keyboard-cycle";
|
|
91
95
|
|
|
92
96
|
export type ReviewWorkspaceChromeVisibility = WordReviewEditorChromeVisibility;
|
|
93
97
|
|
|
@@ -343,6 +347,34 @@ export interface TwReviewWorkspaceProps {
|
|
|
343
347
|
issueId: string;
|
|
344
348
|
action: import("../api/public-types.ts").ScopeIssueAction;
|
|
345
349
|
}) => void;
|
|
350
|
+
/**
|
|
351
|
+
* R3 — scope card suggestion-group accept button fired. WordReview-
|
|
352
|
+
* Editor relays to `ref.acceptSuggestionGroup(groupId)` which fans
|
|
353
|
+
* out to individual `acceptChange` calls across the group members.
|
|
354
|
+
*/
|
|
355
|
+
onScopeAcceptSuggestionGroup?: (payload: {
|
|
356
|
+
scopeId: string;
|
|
357
|
+
groupId: string;
|
|
358
|
+
}) => void;
|
|
359
|
+
/** R3 — scope card suggestion-group reject. */
|
|
360
|
+
onScopeRejectSuggestionGroup?: (payload: {
|
|
361
|
+
scopeId: string;
|
|
362
|
+
groupId: string;
|
|
363
|
+
}) => void;
|
|
364
|
+
/**
|
|
365
|
+
* K2 — scope card "Ask review agent" fired. WordReviewEditor emits
|
|
366
|
+
* `agent-on-selection-requested` via WordReviewEditorEvent.
|
|
367
|
+
*/
|
|
368
|
+
onScopeAskAgent?: (payload: {
|
|
369
|
+
scopeId: string;
|
|
370
|
+
}) => void;
|
|
371
|
+
/**
|
|
372
|
+
* P3 — optional scope-tag editor slot rendered inside the scope
|
|
373
|
+
* card when `editorRole === "workflow"`. Hosts pass a chip picker,
|
|
374
|
+
* free-text input, or whatever authoring surface they want. Unset
|
|
375
|
+
* in editor/review roles.
|
|
376
|
+
*/
|
|
377
|
+
scopeCardScopeTagEditor?: ReactNode;
|
|
346
378
|
}
|
|
347
379
|
|
|
348
380
|
export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
@@ -373,6 +405,37 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
373
405
|
const handleScopeCardClose = useCallback(() => {
|
|
374
406
|
setActiveScopeId(null);
|
|
375
407
|
}, []);
|
|
408
|
+
|
|
409
|
+
// P3d: keyboard scope navigation. J / K cycle the active scope in
|
|
410
|
+
// document order; Enter opens the first scope when none is active.
|
|
411
|
+
// `shouldHandleScopeNavKey` + `cycleScopeIndex` are extracted pure
|
|
412
|
+
// helpers so the logic is unit-testable without a workspace mount.
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
const layoutFacet = props.layoutFacet;
|
|
415
|
+
if (!layoutFacet || typeof layoutFacet.getAllScopeCardModels !== "function") {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const onKey = (event: KeyboardEvent) => {
|
|
419
|
+
if (!shouldHandleScopeNavKey(event)) return;
|
|
420
|
+
const models = layoutFacet.getAllScopeCardModels();
|
|
421
|
+
if (models.length === 0) return;
|
|
422
|
+
const ids = models.map((model) => model.scopeId);
|
|
423
|
+
const key = event.key.toLowerCase();
|
|
424
|
+
if (key === "enter") {
|
|
425
|
+
if (!activeScopeId) {
|
|
426
|
+
setActiveScopeId(ids[0] ?? null);
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const direction: 1 | -1 = key === "j" ? 1 : -1;
|
|
432
|
+
const next = cycleScopeIndex(activeScopeId, ids, direction);
|
|
433
|
+
setActiveScopeId(next);
|
|
434
|
+
event.preventDefault();
|
|
435
|
+
};
|
|
436
|
+
window.addEventListener("keydown", onKey);
|
|
437
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
438
|
+
}, [props.layoutFacet, activeScopeId]);
|
|
376
439
|
const onScopeModeChangeRequested = props.onScopeModeChangeRequested;
|
|
377
440
|
const handleScopeCardModeChange = useCallback(
|
|
378
441
|
(scopeId: string, mode: import("../api/public-types.ts").WorkflowScopeMode) => {
|
|
@@ -391,10 +454,32 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
391
454
|
},
|
|
392
455
|
[onScopeIssueActionRequested],
|
|
393
456
|
);
|
|
457
|
+
const onScopeAcceptSuggestionGroup = props.onScopeAcceptSuggestionGroup;
|
|
458
|
+
const handleScopeCardAcceptSuggestionGroup = useCallback(
|
|
459
|
+
(scopeId: string, groupId: string) => {
|
|
460
|
+
onScopeAcceptSuggestionGroup?.({ scopeId, groupId });
|
|
461
|
+
},
|
|
462
|
+
[onScopeAcceptSuggestionGroup],
|
|
463
|
+
);
|
|
464
|
+
const onScopeRejectSuggestionGroup = props.onScopeRejectSuggestionGroup;
|
|
465
|
+
const handleScopeCardRejectSuggestionGroup = useCallback(
|
|
466
|
+
(scopeId: string, groupId: string) => {
|
|
467
|
+
onScopeRejectSuggestionGroup?.({ scopeId, groupId });
|
|
468
|
+
},
|
|
469
|
+
[onScopeRejectSuggestionGroup],
|
|
470
|
+
);
|
|
471
|
+
const onScopeAskAgent = props.onScopeAskAgent;
|
|
472
|
+
const handleScopeCardAskAgent = useCallback(
|
|
473
|
+
(scopeId: string) => {
|
|
474
|
+
onScopeAskAgent?.({ scopeId });
|
|
475
|
+
},
|
|
476
|
+
[onScopeAskAgent],
|
|
477
|
+
);
|
|
394
478
|
const zoomLevel = props.zoomLevel ?? 100;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
479
|
+
// Numeric zooms resolve immediately; "pageWidth" / "onePage" need the
|
|
480
|
+
// page-frame dimensions to fit against — they're resolved below once
|
|
481
|
+
// `pageShellMetrics` has been computed (P2.c).
|
|
482
|
+
const numericZoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
|
|
398
483
|
const chromePreset = resolveChromePreset(props.chromePreset, props.reviewMode);
|
|
399
484
|
const chromeOptions = resolveChromePresetOptions(chromePreset, props.chromeOptions);
|
|
400
485
|
const preserveOnlyCount = caps?.preserveOnlyCount ??
|
|
@@ -412,6 +497,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
412
497
|
});
|
|
413
498
|
const reviewRailAvailable = chromeVisibility.reviewRail && (caps?.reviewRailVisible ?? true);
|
|
414
499
|
const [viewportWidth, setViewportWidth] = useState<number | undefined>(() => readViewportWidth());
|
|
500
|
+
const [viewportHeight, setViewportHeight] = useState<number | undefined>(() => readViewportHeight());
|
|
415
501
|
const [reviewRailOpen, setReviewRailOpen] = useState(() =>
|
|
416
502
|
getInitialReviewRailOpen({
|
|
417
503
|
viewportWidth: readViewportWidth(),
|
|
@@ -461,6 +547,33 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
461
547
|
}
|
|
462
548
|
return props.activeSelectionTool;
|
|
463
549
|
}, [props.activeSelectionTool, chromeVisibility.contextToolbars]);
|
|
550
|
+
const pageShellMetrics = useMemo(
|
|
551
|
+
() => buildPageShellMetrics(snapshot.pageLayout),
|
|
552
|
+
[snapshot.pageLayout],
|
|
553
|
+
);
|
|
554
|
+
// P2.c — resolve "pageWidth" / "onePage" against the active section's
|
|
555
|
+
// real paper dimensions. Numeric zooms pass through. Falls back to
|
|
556
|
+
// `numericZoomScale` (1.0 for symbolic zooms when paper dims are
|
|
557
|
+
// unavailable, e.g., during initial load).
|
|
558
|
+
const zoomScale = useMemo(() => {
|
|
559
|
+
if (typeof zoomLevel === "number") return numericZoomScale;
|
|
560
|
+
return resolveZoomMultiplier(
|
|
561
|
+
zoomLevel,
|
|
562
|
+
pageShellMetrics.frameWidthPx ?? 0,
|
|
563
|
+
pageShellMetrics.frameHeightPx ?? 0,
|
|
564
|
+
viewportWidth,
|
|
565
|
+
viewportHeight,
|
|
566
|
+
);
|
|
567
|
+
}, [
|
|
568
|
+
zoomLevel,
|
|
569
|
+
numericZoomScale,
|
|
570
|
+
pageShellMetrics.frameWidthPx,
|
|
571
|
+
pageShellMetrics.frameHeightPx,
|
|
572
|
+
viewportWidth,
|
|
573
|
+
viewportHeight,
|
|
574
|
+
]);
|
|
575
|
+
const pageZoomBucket =
|
|
576
|
+
!isPageWorkspace ? undefined : zoomScale < 1 ? "low" : zoomScale > 1 ? "high" : "base";
|
|
464
577
|
const selectionToolbarPlacement = useMemo(() => {
|
|
465
578
|
// Prefer render-frame anchors when the layout facet is available — this
|
|
466
579
|
// keeps the tool glued to kernel coordinates across zoom, scroll, and
|
|
@@ -496,10 +609,37 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
496
609
|
renderFrameRevision,
|
|
497
610
|
]);
|
|
498
611
|
const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
612
|
+
// P5b — status-bar facts derived from the layout facet so the
|
|
613
|
+
// Page-N-of-M display + measurement-fidelity badge ("E" / "C" / "C+F")
|
|
614
|
+
// refresh on every layout-affecting facet event. The subscription
|
|
615
|
+
// above bumps `renderFrameRevision` on the same kinds; including it
|
|
616
|
+
// in the dependency list re-runs this memo without a separate
|
|
617
|
+
// subscription.
|
|
618
|
+
const statusBarPageFacts = useMemo(() => {
|
|
619
|
+
const facet = props.layoutFacet;
|
|
620
|
+
if (!facet) {
|
|
621
|
+
return {
|
|
622
|
+
displayPageNumber: null as number | null,
|
|
623
|
+
pageCount: null as number | null,
|
|
624
|
+
measurementFidelity: undefined as
|
|
625
|
+
| import("../api/public-types.ts").PublicMeasurementFidelity
|
|
626
|
+
| undefined,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const head = selectionPosition;
|
|
630
|
+
const pageRef = facet.getPageForOffset(head, snapshot.activeStory);
|
|
631
|
+
const displayPageNumber =
|
|
632
|
+
pageRef !== null && typeof pageRef.pageIndex === "number"
|
|
633
|
+
? facet.getDisplayPageNumber(pageRef.pageIndex) ?? pageRef.pageIndex + 1
|
|
634
|
+
: null;
|
|
635
|
+
const pageCount = facet.getPageCount();
|
|
636
|
+
return {
|
|
637
|
+
displayPageNumber,
|
|
638
|
+
pageCount,
|
|
639
|
+
measurementFidelity: facet.getMeasurementFidelity(),
|
|
640
|
+
};
|
|
641
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
642
|
+
}, [props.layoutFacet, selectionPosition, snapshot.activeStory, renderFrameRevision]);
|
|
503
643
|
const headerBandLabel = resolvePageBandLabel("header", snapshot.activeStory);
|
|
504
644
|
const footerBandLabel = resolvePageBandLabel("footer", snapshot.activeStory);
|
|
505
645
|
const hidePageBorderForActiveEditing =
|
|
@@ -573,24 +713,44 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
573
713
|
return;
|
|
574
714
|
}
|
|
575
715
|
|
|
576
|
-
const
|
|
716
|
+
const updateViewport = () => {
|
|
577
717
|
setViewportWidth(readViewportWidth());
|
|
718
|
+
setViewportHeight(readViewportHeight());
|
|
578
719
|
};
|
|
579
720
|
|
|
580
|
-
|
|
581
|
-
window.addEventListener("resize",
|
|
721
|
+
updateViewport();
|
|
722
|
+
window.addEventListener("resize", updateViewport);
|
|
582
723
|
return () => {
|
|
583
|
-
window.removeEventListener("resize",
|
|
724
|
+
window.removeEventListener("resize", updateViewport);
|
|
584
725
|
};
|
|
585
726
|
}, []);
|
|
586
727
|
|
|
587
|
-
// Subscribe to layout facet events so chrome re-projects
|
|
588
|
-
//
|
|
728
|
+
// Subscribe to layout facet events so chrome re-projects whenever the
|
|
729
|
+
// engine produces new pagination state, fields dirty, or measurement
|
|
730
|
+
// fidelity changes. P5b broadened this beyond the original P3.b set
|
|
731
|
+
// ("zoom_changed" / "render_frame_ready") so the status-bar Page-N-of-M
|
|
732
|
+
// and fidelity badge transition in real time; the hardening commit
|
|
733
|
+
// added "measurement_backend_ready" so the canvas backend swap also
|
|
734
|
+
// refreshes the badge. P14.b adds "layout_committed" — a single
|
|
735
|
+
// coalesced event per applyPatch — so consumers that only care
|
|
736
|
+
// about "the engine just finished a build" can react once instead
|
|
737
|
+
// of N times.
|
|
589
738
|
useEffect(() => {
|
|
590
739
|
if (!props.layoutFacet) return;
|
|
591
740
|
const unsub = props.layoutFacet.subscribe((event) => {
|
|
592
|
-
|
|
593
|
-
|
|
741
|
+
switch (event.kind) {
|
|
742
|
+
case "zoom_changed":
|
|
743
|
+
case "render_frame_ready":
|
|
744
|
+
case "layout_recomputed":
|
|
745
|
+
case "incremental_relayout":
|
|
746
|
+
case "page_count_changed":
|
|
747
|
+
case "page_field_dirtied":
|
|
748
|
+
case "measurement_backend_ready":
|
|
749
|
+
case "layout_committed":
|
|
750
|
+
setRenderFrameRevision((n) => n + 1);
|
|
751
|
+
break;
|
|
752
|
+
default:
|
|
753
|
+
break;
|
|
594
754
|
}
|
|
595
755
|
});
|
|
596
756
|
return unsub;
|
|
@@ -923,11 +1083,32 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
923
1083
|
ref={selectionToolbarRootRef}
|
|
924
1084
|
className={`mx-auto min-h-full w-full ${
|
|
925
1085
|
isPageWorkspace
|
|
926
|
-
? "wre-page-chrome wre-page-surface relative
|
|
1086
|
+
? "wre-page-chrome wre-page-surface relative my-8 overflow-hidden"
|
|
927
1087
|
: "wre-canvas-surface relative my-8 overflow-hidden"
|
|
928
1088
|
}`}
|
|
929
1089
|
data-zoom-bucket={pageZoomBucket}
|
|
930
|
-
|
|
1090
|
+
data-zoom-scale={isPageWorkspace ? zoomScale : undefined}
|
|
1091
|
+
style={
|
|
1092
|
+
isPageWorkspace
|
|
1093
|
+
? {
|
|
1094
|
+
// P2.a — real-dim page frame: width/height from
|
|
1095
|
+
// `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`
|
|
1096
|
+
// so every paper size renders at its
|
|
1097
|
+
// Word-matching CSS px. `max-w-[840px]` retired.
|
|
1098
|
+
...(pageShellMetrics.frameWidthPx
|
|
1099
|
+
? { width: `${pageShellMetrics.frameWidthPx}px` }
|
|
1100
|
+
: {}),
|
|
1101
|
+
...(pageShellMetrics.frameHeightPx
|
|
1102
|
+
? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
|
|
1103
|
+
: {}),
|
|
1104
|
+
// P2.b — browser-native CSS `zoom` rescales layout
|
|
1105
|
+
// so `getBoundingClientRect()` and hit-test offsets
|
|
1106
|
+
// stay truthful at any zoom — no inverse-projection
|
|
1107
|
+
// math needed downstream.
|
|
1108
|
+
...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
|
|
1109
|
+
}
|
|
1110
|
+
: undefined
|
|
1111
|
+
}
|
|
931
1112
|
>
|
|
932
1113
|
{isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout ? (
|
|
933
1114
|
<div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
|
|
@@ -1208,10 +1389,27 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
1208
1389
|
onSetColumnWidth={props.onSetColumnWidth}
|
|
1209
1390
|
onSetRowHeight={props.onSetRowHeight}
|
|
1210
1391
|
activeScopeId={activeScopeId}
|
|
1392
|
+
editorRole={viewState.editorRole}
|
|
1393
|
+
scopeCardScopeTagEditor={props.scopeCardScopeTagEditor}
|
|
1211
1394
|
onScopeStripeClick={handleScopeStripeClick}
|
|
1212
1395
|
onScopeCardClose={handleScopeCardClose}
|
|
1213
1396
|
onScopeCardModeChange={handleScopeCardModeChange}
|
|
1214
1397
|
onScopeCardIssueAction={handleScopeCardIssueAction}
|
|
1398
|
+
onScopeCardAcceptSuggestionGroup={
|
|
1399
|
+
onScopeAcceptSuggestionGroup
|
|
1400
|
+
? handleScopeCardAcceptSuggestionGroup
|
|
1401
|
+
: undefined
|
|
1402
|
+
}
|
|
1403
|
+
onScopeCardRejectSuggestionGroup={
|
|
1404
|
+
onScopeRejectSuggestionGroup
|
|
1405
|
+
? handleScopeCardRejectSuggestionGroup
|
|
1406
|
+
: undefined
|
|
1407
|
+
}
|
|
1408
|
+
onScopeCardAskAgent={
|
|
1409
|
+
onScopeAskAgent
|
|
1410
|
+
? handleScopeCardAskAgent
|
|
1411
|
+
: undefined
|
|
1412
|
+
}
|
|
1215
1413
|
/>
|
|
1216
1414
|
) : null}
|
|
1217
1415
|
</div>
|
|
@@ -1257,6 +1455,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
1257
1455
|
? props.documentContextAnalytics
|
|
1258
1456
|
: null
|
|
1259
1457
|
}
|
|
1458
|
+
displayPageNumber={statusBarPageFacts.displayPageNumber}
|
|
1459
|
+
pageCount={statusBarPageFacts.pageCount}
|
|
1460
|
+
measurementFidelity={statusBarPageFacts.measurementFidelity}
|
|
1260
1461
|
/>
|
|
1261
1462
|
) : null}
|
|
1262
1463
|
</div>
|
|
@@ -1364,6 +1565,10 @@ function readViewportWidth(): number | undefined {
|
|
|
1364
1565
|
return typeof window === "undefined" ? undefined : window.innerWidth;
|
|
1365
1566
|
}
|
|
1366
1567
|
|
|
1568
|
+
function readViewportHeight(): number | undefined {
|
|
1569
|
+
return typeof window === "undefined" ? undefined : window.innerHeight;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1367
1572
|
function shouldHidePageBorderForSelection(
|
|
1368
1573
|
selection: EditorViewStateSnapshot["selection"],
|
|
1369
1574
|
): boolean {
|
|
@@ -1496,7 +1701,20 @@ const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
|
|
|
1496
1701
|
|
|
1497
1702
|
const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
|
|
1498
1703
|
|
|
1499
|
-
|
|
1704
|
+
// P2.a — real-dimension page frame. Page frame width/height are
|
|
1705
|
+
// `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI` so
|
|
1706
|
+
// every paper size renders at its Word-matching CSS px (Letter
|
|
1707
|
+
// 816×1056, A4 794×1123, Legal 816×1344, …). Constants are exported
|
|
1708
|
+
// so tests + harness panels can derive the same values.
|
|
1709
|
+
export const FRAME_PX_PER_TWIP_AT_96DPI = 96 / 1440;
|
|
1710
|
+
/** Floor on header/footer band heights so empty bands stay clickable. */
|
|
1711
|
+
export const MIN_BAND_HEIGHT_PX = 20;
|
|
1712
|
+
|
|
1713
|
+
export interface PageShellMetrics {
|
|
1714
|
+
/** P2.a — page frame CSS px width = `pageWidth × FRAME_PX_PER_TWIP_AT_96DPI`. */
|
|
1715
|
+
frameWidthPx?: number;
|
|
1716
|
+
/** P2.a — page frame CSS px height = `pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`. */
|
|
1717
|
+
frameHeightPx?: number;
|
|
1500
1718
|
contentInsetStyle: CSSProperties;
|
|
1501
1719
|
pageFrameStyle: CSSProperties;
|
|
1502
1720
|
headerBandStyle: CSSProperties;
|
|
@@ -1539,7 +1757,7 @@ function buildPageChromeModel(
|
|
|
1539
1757
|
};
|
|
1540
1758
|
}
|
|
1541
1759
|
|
|
1542
|
-
function buildPageShellMetrics(
|
|
1760
|
+
export function buildPageShellMetrics(
|
|
1543
1761
|
pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
|
|
1544
1762
|
): PageShellMetrics {
|
|
1545
1763
|
if (!pageLayout) {
|
|
@@ -1548,32 +1766,35 @@ function buildPageShellMetrics(
|
|
|
1548
1766
|
pageFrameStyle: {},
|
|
1549
1767
|
headerBandStyle: {},
|
|
1550
1768
|
footerBandStyle: {},
|
|
1769
|
+
frameWidthPx: 0,
|
|
1770
|
+
frameHeightPx: 0,
|
|
1551
1771
|
};
|
|
1552
1772
|
}
|
|
1553
1773
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
);
|
|
1774
|
+
// P2.a — frame dimensions follow the section's real twip width/height
|
|
1775
|
+
// so every paper size in the catalog renders at its Word-matching CSS
|
|
1776
|
+
// px (Letter 816×1056, A4 794×1123, Legal 816×1344, …).
|
|
1777
|
+
const pxPerTwip = FRAME_PX_PER_TWIP_AT_96DPI;
|
|
1778
|
+
const frameWidthPx = Math.round(pageLayout.pageWidth * pxPerTwip);
|
|
1779
|
+
const frameHeightPx = Math.round(pageLayout.pageHeight * pxPerTwip);
|
|
1780
|
+
const horizontalInsetPx = Math.round(pageLayout.marginLeft * pxPerTwip);
|
|
1781
|
+
const horizontalInsetRightPx = Math.round(pageLayout.marginRight * pxPerTwip);
|
|
1782
|
+
// Header BAND height = margin space NOT consumed by header content.
|
|
1783
|
+
// When marginTop == headerMargin, the band has no headroom — floor to
|
|
1784
|
+
// MIN_BAND_HEIGHT_PX so empty bands stay clickable.
|
|
1562
1785
|
const headerBandHeightPx = Math.max(
|
|
1563
|
-
|
|
1564
|
-
Math.
|
|
1786
|
+
MIN_BAND_HEIGHT_PX,
|
|
1787
|
+
Math.round(Math.max(0, pageLayout.marginTop - pageLayout.headerMargin) * pxPerTwip),
|
|
1565
1788
|
);
|
|
1566
1789
|
const footerBandHeightPx = Math.max(
|
|
1567
|
-
|
|
1568
|
-
Math.
|
|
1790
|
+
MIN_BAND_HEIGHT_PX,
|
|
1791
|
+
Math.round(Math.max(0, pageLayout.marginBottom - pageLayout.footerMargin) * pxPerTwip),
|
|
1569
1792
|
);
|
|
1570
1793
|
|
|
1571
1794
|
return {
|
|
1572
1795
|
contentInsetStyle: {
|
|
1573
1796
|
paddingLeft: `${horizontalInsetPx}px`,
|
|
1574
|
-
paddingRight: `${
|
|
1575
|
-
paddingTop: `${Math.max(20, verticalInsetPx - 12)}px`,
|
|
1576
|
-
paddingBottom: `${Math.max(20, Math.round(pageLayout.marginBottom * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP) - 12)}px`,
|
|
1797
|
+
paddingRight: `${horizontalInsetRightPx}px`,
|
|
1577
1798
|
},
|
|
1578
1799
|
pageFrameStyle: {
|
|
1579
1800
|
backgroundColor: "var(--color-page-bg)",
|
|
@@ -1587,9 +1808,47 @@ function buildPageShellMetrics(
|
|
|
1587
1808
|
footerBandStyle: {
|
|
1588
1809
|
minHeight: `${footerBandHeightPx}px`,
|
|
1589
1810
|
},
|
|
1811
|
+
frameWidthPx,
|
|
1812
|
+
frameHeightPx,
|
|
1590
1813
|
};
|
|
1591
1814
|
}
|
|
1592
1815
|
|
|
1816
|
+
// P2.c — fit-to-width / fit-to-page resolves against the active section's
|
|
1817
|
+
// real paper size (not a global constant), so Letter and A4 produce the
|
|
1818
|
+
// expected 1.029:1 fit-width ratio at the same viewport. Clamped so
|
|
1819
|
+
// extreme viewports don't pin the editor at unreadable zooms.
|
|
1820
|
+
const FIT_WIDTH_CHROME_RESERVATION_PX = 96;
|
|
1821
|
+
const FIT_HEIGHT_CHROME_RESERVATION_PX = 180;
|
|
1822
|
+
const MIN_FIT_ZOOM = 0.5;
|
|
1823
|
+
const MAX_FIT_ZOOM = 2.0;
|
|
1824
|
+
|
|
1825
|
+
export function resolveZoomMultiplier(
|
|
1826
|
+
zoomLevel: number | "pageWidth" | "onePage",
|
|
1827
|
+
frameWidthPx: number,
|
|
1828
|
+
frameHeightPx: number,
|
|
1829
|
+
viewportWidth: number | undefined,
|
|
1830
|
+
viewportHeight: number | undefined,
|
|
1831
|
+
): number {
|
|
1832
|
+
if (typeof zoomLevel === "number") {
|
|
1833
|
+
return zoomLevel / 100;
|
|
1834
|
+
}
|
|
1835
|
+
if (!viewportWidth || frameWidthPx <= 0) return 1;
|
|
1836
|
+
const widthFit =
|
|
1837
|
+
(viewportWidth - FIT_WIDTH_CHROME_RESERVATION_PX) / frameWidthPx;
|
|
1838
|
+
if (zoomLevel === "pageWidth") {
|
|
1839
|
+
return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
|
|
1840
|
+
}
|
|
1841
|
+
if (!viewportHeight || frameHeightPx <= 0) {
|
|
1842
|
+
return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
|
|
1843
|
+
}
|
|
1844
|
+
const heightFit =
|
|
1845
|
+
(viewportHeight - FIT_HEIGHT_CHROME_RESERVATION_PX) / frameHeightPx;
|
|
1846
|
+
return Math.max(
|
|
1847
|
+
MIN_FIT_ZOOM,
|
|
1848
|
+
Math.min(MAX_FIT_ZOOM, Math.min(widthFit, heightFit)),
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1593
1852
|
function resolvePageBandLabel(
|
|
1594
1853
|
region: "header" | "footer",
|
|
1595
1854
|
activeStory: RuntimeRenderSnapshot["activeStory"],
|