@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.
Files changed (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -0,0 +1,149 @@
1
+ import * as React from "react";
2
+
3
+ export interface CollabSendToSupplierSubmitArgs {
4
+ recipient: string;
5
+ archiveRef: string;
6
+ }
7
+
8
+ export interface CollabSendToSupplierModalProps {
9
+ open: boolean;
10
+ shareableCount: number;
11
+ internalCount: number;
12
+ onClose: () => void;
13
+ /**
14
+ * Invoked when the user confirms. Caller routes to
15
+ * `session.sendToExternal({ payloadXml, recipient, archiveRef, ... })`
16
+ * and owns the resulting docx-zip rewrite.
17
+ */
18
+ onSubmit: (args: CollabSendToSupplierSubmitArgs) => void | Promise<void>;
19
+ /** Seed value; primarily useful for hosts that remember the last supplier. */
20
+ initialRecipient?: string;
21
+ /** Seed value for the archive reference field. */
22
+ initialArchiveRef?: string;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * Confirmation modal for send-to-supplier (P9f). Collects the
28
+ * `recipient` + `archiveRef` from the user and hands them to the
29
+ * caller. Kept controlled (open/close managed externally) so the
30
+ * button in the top nav owns modal state.
31
+ *
32
+ * Deliberately minimal DOM for unit testing — the full visual design
33
+ * (icons, animations, role-scoped styling) ships later as host CSS;
34
+ * this component owns semantics + accessibility only.
35
+ */
36
+ export function CollabSendToSupplierModal({
37
+ open,
38
+ shareableCount,
39
+ internalCount,
40
+ onClose,
41
+ onSubmit,
42
+ initialRecipient = "",
43
+ initialArchiveRef = "",
44
+ className,
45
+ }: CollabSendToSupplierModalProps): React.ReactElement | null {
46
+ const [recipient, setRecipient] = React.useState(initialRecipient);
47
+ const [archiveRef, setArchiveRef] = React.useState(initialArchiveRef);
48
+ const [submitting, setSubmitting] = React.useState(false);
49
+
50
+ // Reset form fields (to the seeds) when the modal is reopened.
51
+ React.useEffect(() => {
52
+ if (open) {
53
+ setRecipient(initialRecipient);
54
+ setArchiveRef(initialArchiveRef);
55
+ setSubmitting(false);
56
+ }
57
+ }, [open, initialRecipient, initialArchiveRef]);
58
+
59
+ if (!open) return null;
60
+
61
+ const canSubmit =
62
+ recipient.trim().length > 0 && archiveRef.trim().length > 0 && !submitting;
63
+
64
+ const rootClass = [
65
+ "tw-collab-send-to-supplier-modal",
66
+ className ?? null,
67
+ ]
68
+ .filter((v): v is string => v !== null)
69
+ .join(" ");
70
+
71
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
72
+ event.preventDefault();
73
+ if (!canSubmit) return;
74
+ setSubmitting(true);
75
+ try {
76
+ await onSubmit({
77
+ recipient: recipient.trim(),
78
+ archiveRef: archiveRef.trim(),
79
+ });
80
+ } finally {
81
+ setSubmitting(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <div
87
+ className={rootClass}
88
+ data-testid="collab-send-to-supplier-modal"
89
+ role="dialog"
90
+ aria-modal="true"
91
+ aria-labelledby="collab-send-to-supplier-title"
92
+ >
93
+ <div className="tw-collab-send-to-supplier-modal__backdrop" onClick={onClose} />
94
+ <form
95
+ className="tw-collab-send-to-supplier-modal__panel"
96
+ onSubmit={handleSubmit}
97
+ >
98
+ <h2 id="collab-send-to-supplier-title">Send to supplier</h2>
99
+ <p className="tw-collab-send-to-supplier-modal__summary">
100
+ {shareableCount} comment{shareableCount === 1 ? "" : "s"} will ship
101
+ to the supplier; {internalCount} internal comment
102
+ {internalCount === 1 ? "" : "s"} will stay in your archive.
103
+ </p>
104
+ <label>
105
+ <span>Recipient</span>
106
+ <input
107
+ data-testid="collab-send-to-supplier-modal-recipient"
108
+ type="text"
109
+ value={recipient}
110
+ onChange={(e) => setRecipient(e.target.value)}
111
+ placeholder="supplier-org-42"
112
+ required
113
+ />
114
+ </label>
115
+ <label>
116
+ <span>Archive reference</span>
117
+ <input
118
+ data-testid="collab-send-to-supplier-modal-archive-ref"
119
+ type="text"
120
+ value={archiveRef}
121
+ onChange={(e) => setArchiveRef(e.target.value)}
122
+ placeholder="clm://archive/abc"
123
+ required
124
+ />
125
+ </label>
126
+ <div className="tw-collab-send-to-supplier-modal__actions">
127
+ <button
128
+ type="button"
129
+ className="tw-collab-send-to-supplier-modal__cancel"
130
+ data-testid="collab-send-to-supplier-modal-cancel"
131
+ onClick={onClose}
132
+ disabled={submitting}
133
+ >
134
+ Cancel
135
+ </button>
136
+ <button
137
+ type="submit"
138
+ className="tw-collab-send-to-supplier-modal__submit"
139
+ data-testid="collab-send-to-supplier-modal-submit"
140
+ disabled={!canSubmit}
141
+ aria-disabled={!canSubmit ? "true" : undefined}
142
+ >
143
+ {submitting ? "Sending…" : "Send"}
144
+ </button>
145
+ </div>
146
+ </form>
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,68 @@
1
+ import * as React from "react";
2
+
3
+ import type { MetadataIntegrity } from "../../runtime/tamper-gate.ts";
4
+
5
+ export interface CollabTamperBannerProps {
6
+ /** Current `metadataIntegrity` state from `session.getMetadataIntegrity()`. */
7
+ integrity: MetadataIntegrity;
8
+ /** Called when the user acknowledges. Caller routes to `session.acknowledgeMetadataTampering()`. */
9
+ onAcknowledge: () => void;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * Banner surfaced in the `"collab"` chrome preset (P9d) whenever the
15
+ * tamper gate reports `"tampered"`. Renders nothing in `"verified"` /
16
+ * `"unsigned"` states so hosts can mount it unconditionally inside the
17
+ * top nav and let it self-gate on the integrity signal.
18
+ *
19
+ * Contract:
20
+ * - Renders nothing when `integrity !== "tampered"`.
21
+ * - When tampered, shows a prominent banner with a single
22
+ * "Acknowledge & continue" action.
23
+ * - The acknowledge button calls `onAcknowledge()`. The runtime's
24
+ * tamper gate treats the first `acknowledge()` as the gate-unlock;
25
+ * subsequent calls while `verified` are no-ops.
26
+ * - Subscribing to `metadata_integrity_violation` is the host's job;
27
+ * this component is pure and idempotent.
28
+ */
29
+ export function CollabTamperBanner({
30
+ integrity,
31
+ onAcknowledge,
32
+ className,
33
+ }: CollabTamperBannerProps): React.ReactElement | null {
34
+ if (integrity !== "tampered") return null;
35
+
36
+ const rootClass = [
37
+ "tw-collab-tamper-banner",
38
+ className ?? null,
39
+ ]
40
+ .filter((v): v is string => v !== null)
41
+ .join(" ");
42
+
43
+ return (
44
+ <div
45
+ className={rootClass}
46
+ data-testid="collab-tamper-banner"
47
+ data-integrity={integrity}
48
+ role="alert"
49
+ aria-live="assertive"
50
+ >
51
+ <span className="tw-collab-tamper-banner__icon" aria-hidden="true">
52
+
53
+ </span>
54
+ <span className="tw-collab-tamper-banner__message">
55
+ Metadata integrity check failed. The workflow payload was modified
56
+ outside the editor. Mutations are blocked until you acknowledge.
57
+ </span>
58
+ <button
59
+ type="button"
60
+ className="tw-collab-tamper-banner__ack"
61
+ data-testid="collab-tamper-banner-ack"
62
+ onClick={onAcknowledge}
63
+ >
64
+ Acknowledge &amp; continue
65
+ </button>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Re-dispatch a click that landed on an invisible resize grip to the element
3
+ * beneath it, so PM-rendered cell text still receives mousedown/mouseup/click
4
+ * when the user clicks near a cell edge without dragging.
5
+ *
6
+ * This is the click-trap fix for the table grip layer. It is extracted into
7
+ * its own module with dependency injection so the hardening rules can be
8
+ * unit-tested in isolation:
9
+ *
10
+ * - Never re-dispatch into chrome overlays (`[data-chrome-overlay]`).
11
+ * Forwarding into a scope card / toolbar / picker would trigger that
12
+ * overlay's own handlers and potentially re-enter the grip.
13
+ * - Never re-dispatch when modifier keys are held — Shift/Ctrl/Meta/Alt
14
+ * clicks carry selection semantics that do not survive synthesis.
15
+ * - Never re-dispatch for non-primary mouse buttons.
16
+ * - Always restore `pointerEvents` on the grip, even if layout queries
17
+ * throw. `elementFromPoint` is permitted to throw in some browsers
18
+ * when called during layout invalidation.
19
+ * - Tolerate a detached `gripEl`: a React re-render between mousedown and
20
+ * mouseup can unmount the grip; style writes on an orphaned element
21
+ * are harmless, but we must not throw if the owner document is gone.
22
+ */
23
+
24
+ export interface ForwardNonDragClickOptions {
25
+ /**
26
+ * Injector for `document.elementFromPoint` (used in tests). Defaults
27
+ * to `gripEl.ownerDocument?.elementFromPoint`.
28
+ */
29
+ elementFromPoint?: (x: number, y: number) => Element | null;
30
+ }
31
+
32
+ const CHROME_OVERLAY_SELECTOR = "[data-chrome-overlay]";
33
+
34
+ export function forwardNonDragClick(
35
+ gripEl: HTMLElement,
36
+ event: MouseEvent,
37
+ options: ForwardNonDragClickOptions = {},
38
+ ): void {
39
+ if (event.button !== 0) return;
40
+ if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
41
+
42
+ const ownerDocument = gripEl.ownerDocument;
43
+ if (!ownerDocument) return;
44
+
45
+ const previousPointerEvents = gripEl.style.pointerEvents;
46
+ const canMutateStyle = gripEl.isConnected || previousPointerEvents !== "";
47
+ if (canMutateStyle) {
48
+ try {
49
+ gripEl.style.pointerEvents = "none";
50
+ } catch {
51
+ // style mutation can fail in extremely narrow edge cases; proceed anyway.
52
+ }
53
+ }
54
+
55
+ try {
56
+ const locate =
57
+ options.elementFromPoint ??
58
+ ((x: number, y: number) =>
59
+ ownerDocument.elementFromPoint
60
+ ? ownerDocument.elementFromPoint(x, y)
61
+ : null);
62
+
63
+ let beneath: Element | null;
64
+ try {
65
+ beneath = locate(event.clientX, event.clientY);
66
+ } catch {
67
+ return;
68
+ }
69
+
70
+ if (!beneath || beneath === gripEl) return;
71
+ if (beneath.closest && beneath.closest(CHROME_OVERLAY_SELECTOR)) return;
72
+
73
+ const view = ownerDocument.defaultView ?? null;
74
+ if (!view) return;
75
+
76
+ const init: MouseEventInit = {
77
+ bubbles: true,
78
+ cancelable: true,
79
+ view,
80
+ clientX: event.clientX,
81
+ clientY: event.clientY,
82
+ screenX: event.screenX,
83
+ screenY: event.screenY,
84
+ button: event.button,
85
+ buttons: event.buttons,
86
+ ctrlKey: false,
87
+ metaKey: false,
88
+ shiftKey: false,
89
+ altKey: false,
90
+ };
91
+ const MouseEventCtor = view.MouseEvent;
92
+ beneath.dispatchEvent(new MouseEventCtor("mousedown", init));
93
+ beneath.dispatchEvent(new MouseEventCtor("mouseup", init));
94
+ beneath.dispatchEvent(new MouseEventCtor("click", init));
95
+ } finally {
96
+ if (canMutateStyle) {
97
+ try {
98
+ gripEl.style.pointerEvents = previousPointerEvents;
99
+ } catch {
100
+ // Orphaned / detached elements may reject style writes; ignore.
101
+ }
102
+ }
103
+ }
104
+ }
@@ -37,6 +37,7 @@ export function TwModeDock(props: TwModeDockProps) {
37
37
  aria-label="Mode dock"
38
38
  className={className}
39
39
  data-testid="tw-mode-dock"
40
+ data-chrome-overlay="mode-dock"
40
41
  >
41
42
  <div className="flex items-center gap-1.5 pl-1 pr-1.5">
42
43
  {props.icon ? (
@@ -138,6 +138,7 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
138
138
  <div
139
139
  className="pointer-events-auto absolute"
140
140
  data-placement="detached"
141
+ data-chrome-overlay="selection-tool"
141
142
  style={{
142
143
  left: `calc(50% + ${detachedOffset.x}px)`,
143
144
  top: `${12 + detachedOffset.y}px`,
@@ -156,6 +157,7 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
156
157
  <div
157
158
  className="pointer-events-auto absolute"
158
159
  data-placement={props.placement.placement}
160
+ data-chrome-overlay="selection-tool"
159
161
  style={props.placement.style}
160
162
  >
161
163
  {wrappedContent}
@@ -169,7 +171,11 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
169
171
  className="pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-4 pt-3"
170
172
  data-testid={overlayTestId}
171
173
  >
172
- <div className="pointer-events-auto" data-placement="fallback">
174
+ <div
175
+ className="pointer-events-auto"
176
+ data-placement="fallback"
177
+ data-chrome-overlay="selection-tool"
178
+ >
173
179
  {wrappedContent}
174
180
  </div>
175
181
  </div>
@@ -30,50 +30,13 @@ import type {
30
30
  import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
31
31
  import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
32
32
  import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
33
+ import { forwardNonDragClick } from "./forward-non-drag-click";
33
34
 
34
35
  const GRIP_PX = 6;
35
36
  const DRAG_THRESHOLD_PX = 3;
36
37
  const MIN_COLUMN_TWIPS = 720;
37
38
  const MIN_ROW_TWIPS = 120;
38
39
 
39
- /**
40
- * Re-dispatch a click that landed on an invisible resize grip to the element
41
- * beneath it. Called when a mouseup fires without any drag movement, so the
42
- * user's intended target (typically PM-rendered cell text) still receives
43
- * mousedown/mouseup/click and can place the caret.
44
- */
45
- function forwardNonDragClick(gripEl: HTMLElement, event: MouseEvent): void {
46
- const previous = gripEl.style.pointerEvents;
47
- gripEl.style.pointerEvents = "none";
48
- try {
49
- const beneath = gripEl.ownerDocument?.elementFromPoint(
50
- event.clientX,
51
- event.clientY,
52
- );
53
- if (!beneath || beneath === gripEl) return;
54
- const init: MouseEventInit = {
55
- bubbles: true,
56
- cancelable: true,
57
- view: gripEl.ownerDocument?.defaultView ?? window,
58
- clientX: event.clientX,
59
- clientY: event.clientY,
60
- screenX: event.screenX,
61
- screenY: event.screenY,
62
- button: event.button,
63
- buttons: event.buttons,
64
- ctrlKey: event.ctrlKey,
65
- metaKey: event.metaKey,
66
- shiftKey: event.shiftKey,
67
- altKey: event.altKey,
68
- };
69
- beneath.dispatchEvent(new MouseEvent("mousedown", init));
70
- beneath.dispatchEvent(new MouseEvent("mouseup", init));
71
- beneath.dispatchEvent(new MouseEvent("click", init));
72
- } finally {
73
- gripEl.style.pointerEvents = previous;
74
- }
75
- }
76
-
77
40
  export interface TwTableGripLayerProps {
78
41
  facet: WordReviewEditorLayoutFacet;
79
42
  tableContext: TableStructureContextSnapshot | null;
@@ -7,6 +7,12 @@
7
7
 
8
8
  export { TwChromeOverlay, type TwChromeOverlayProps } from "./tw-chrome-overlay";
9
9
  export { TwScopeRailLayer, type TwScopeRailLayerProps } from "./tw-scope-rail-layer";
10
+ export {
11
+ TwPageStackOverlayLayer,
12
+ resolvePageOverlayRects,
13
+ type PageOverlayRect,
14
+ type TwPageStackOverlayLayerProps,
15
+ } from "./tw-page-stack-overlay-layer";
10
16
  export { TwScopeCard, type TwScopeCardProps } from "./tw-scope-card";
11
17
  export { TwScopeCardLayer, type TwScopeCardLayerProps } from "./tw-scope-card-layer";
12
18
  export {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Scope card role visibility model.
3
+ *
4
+ * Per docs/plans/scope-card-overlay.md P3, the card's content adapts
5
+ * to the active editor role: editor-mode keeps just mode + issue;
6
+ * review-mode shows the full surface (suggestions, timeline, agent);
7
+ * workflow-mode additionally exposes scope-tag editing. Host-supplied
8
+ * `reviewRailSections` never overrides these — the rail and the
9
+ * over-document card are different surfaces.
10
+ */
11
+
12
+ import type { EditorRole } from "../../api/public-types";
13
+
14
+ export interface ScopeCardSectionVisibility {
15
+ /** Always true — header is always rendered. */
16
+ header: boolean;
17
+ /** Mode selector (edit/suggest/comment/view). */
18
+ modeRow: boolean;
19
+ /** Issue severity + owner + Resolve/Waive/Escalate row (R2). */
20
+ issueRow: boolean;
21
+ /** Suggestion-group chips + Accept/Reject (R3). */
22
+ suggestionRows: boolean;
23
+ /** Review-action audit timeline (K1). */
24
+ timeline: boolean;
25
+ /** "Ask review agent" button (K2). */
26
+ agentButton: boolean;
27
+ /**
28
+ * Scope-tag editor row shown under the mode row. Only in workflow
29
+ * role; the host renders the actual editor inside a slot the card
30
+ * reserves.
31
+ */
32
+ scopeTagEditor: boolean;
33
+ }
34
+
35
+ export function resolveScopeCardVisibility(role: EditorRole): ScopeCardSectionVisibility {
36
+ switch (role) {
37
+ case "editor":
38
+ return {
39
+ header: true,
40
+ modeRow: true,
41
+ issueRow: true,
42
+ suggestionRows: false,
43
+ timeline: false,
44
+ agentButton: false,
45
+ scopeTagEditor: false,
46
+ };
47
+ case "review":
48
+ return {
49
+ header: true,
50
+ modeRow: true,
51
+ issueRow: true,
52
+ suggestionRows: true,
53
+ timeline: true,
54
+ agentButton: true,
55
+ scopeTagEditor: false,
56
+ };
57
+ case "workflow":
58
+ return {
59
+ header: true,
60
+ modeRow: true,
61
+ issueRow: true,
62
+ suggestionRows: true,
63
+ timeline: true,
64
+ agentButton: true,
65
+ scopeTagEditor: true,
66
+ };
67
+ default:
68
+ return {
69
+ header: true,
70
+ modeRow: true,
71
+ issueRow: true,
72
+ suggestionRows: true,
73
+ timeline: true,
74
+ agentButton: true,
75
+ scopeTagEditor: false,
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pure scope-cycling helper for P3d keyboard navigation.
3
+ *
4
+ * J advances `currentScopeId` one position in document order; K
5
+ * retreats it. Both wrap around the list. When no scope is
6
+ * currently active, J opens the first, K opens the last.
7
+ *
8
+ * Exported from a dedicated module so unit tests can exercise the
9
+ * logic without mounting the workspace keydown listener.
10
+ */
11
+
12
+ export function cycleScopeIndex(
13
+ currentScopeId: string | null,
14
+ scopeIds: readonly string[],
15
+ direction: 1 | -1,
16
+ ): string | null {
17
+ if (scopeIds.length === 0) return null;
18
+ const currentIndex = currentScopeId ? scopeIds.indexOf(currentScopeId) : -1;
19
+ if (currentIndex < 0) {
20
+ return direction > 0 ? scopeIds[0] ?? null : scopeIds[scopeIds.length - 1] ?? null;
21
+ }
22
+ const nextIndex = (currentIndex + direction + scopeIds.length) % scopeIds.length;
23
+ return scopeIds[nextIndex] ?? null;
24
+ }
25
+
26
+ /**
27
+ * Determine whether a keyboard event should be handled as scope
28
+ * navigation, or left alone so the PM surface / contenteditable /
29
+ * form controls / open scope card can consume it.
30
+ */
31
+ export function shouldHandleScopeNavKey(event: KeyboardEvent): boolean {
32
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
33
+ if (
34
+ event.key !== "j" &&
35
+ event.key !== "k" &&
36
+ event.key !== "J" &&
37
+ event.key !== "K" &&
38
+ event.key !== "Enter"
39
+ ) {
40
+ return false;
41
+ }
42
+ const target = event.target as Element | null;
43
+ if (!target || typeof target.closest !== "function") return true;
44
+ if (target.closest(".ProseMirror")) return false;
45
+ if (target.closest("[contenteditable='true']")) return false;
46
+ if (target.closest("input, textarea, select")) return false;
47
+ if (target.closest("[data-testid='scope-card']")) return false;
48
+ return true;
49
+ }
@@ -16,6 +16,7 @@ import * as React from "react";
16
16
  import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
17
  import type { ScopeRailSegment } from "../../runtime/layout";
18
18
  import type {
19
+ EditorRole,
19
20
  ScopeIssueAction,
20
21
  TableStructureContextSnapshot,
21
22
  WordReviewEditorLayoutFacet,
@@ -23,6 +24,7 @@ import type {
23
24
  } from "../../api/public-types";
24
25
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
25
26
  import { TwScopeCardLayer } from "./tw-scope-card-layer";
27
+ import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
26
28
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
27
29
 
28
30
  export interface TwChromeOverlayProps {
@@ -64,6 +66,26 @@ export interface TwChromeOverlayProps {
64
66
  issueId: string,
65
67
  action: ScopeIssueAction,
66
68
  ) => void;
69
+ /**
70
+ * R3 — scope card suggestion-group accept fired. The owner relays
71
+ * to `ref.acceptSuggestionGroup(groupId)`.
72
+ */
73
+ onScopeCardAcceptSuggestionGroup?: (scopeId: string, groupId: string) => void;
74
+ /** R3 — suggestion-group reject. */
75
+ onScopeCardRejectSuggestionGroup?: (scopeId: string, groupId: string) => void;
76
+ /**
77
+ * K2 — scope card "Ask review agent" button fired. The owner
78
+ * emits `agent-on-selection-requested` via WordReviewEditorEvent.
79
+ * Button is hidden when this handler is not supplied.
80
+ */
81
+ onScopeCardAskAgent?: (scopeId: string) => void;
82
+ /** P3 — active editor role; drives card visibility matrix. */
83
+ editorRole?: EditorRole;
84
+ /**
85
+ * P3 — scope-tag editor slot (workflow role only). Renders between
86
+ * the card's mode row and issue row when present.
87
+ */
88
+ scopeCardScopeTagEditor?: React.ReactNode;
67
89
  /** Test id applied to the overlay root. */
68
90
  "data-testid"?: string;
69
91
  /** Optional extra children (e.g., future comment balloon layer). */
@@ -80,6 +102,23 @@ export interface TwChromeOverlayProps {
80
102
  twips: number,
81
103
  rule: "auto" | "atLeast" | "exact",
82
104
  ) => void;
105
+
106
+ // Page-stack overlay (P3.b) -------------------------------------------
107
+ /**
108
+ * Scroll root that hosts the PM surface + page-break widgets. The page-
109
+ * stack overlay measures widget positions from this element to derive
110
+ * per-page Y-ranges. When omitted, the page-stack overlay is skipped —
111
+ * consumers in canvas workspace mode or tests without a DOM can pass
112
+ * `null` to disable the overlay cleanly.
113
+ */
114
+ pageStackScrollRoot?: HTMLElement | null;
115
+ /**
116
+ * Render-frame revision tick — incremented by the workspace on
117
+ * `zoom_changed` / `render_frame_ready` / `incremental_relayout` events.
118
+ * Drives re-measurement of the page-stack overlay without triggering a
119
+ * full workspace re-render.
120
+ */
121
+ renderFrameRevision?: number;
83
122
  }
84
123
 
85
124
  /**
@@ -100,11 +139,18 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
100
139
  onScopeCardClose,
101
140
  onScopeCardModeChange,
102
141
  onScopeCardIssueAction,
142
+ onScopeCardAcceptSuggestionGroup,
143
+ onScopeCardRejectSuggestionGroup,
144
+ onScopeCardAskAgent,
145
+ editorRole,
146
+ scopeCardScopeTagEditor,
103
147
  "data-testid": testId,
104
148
  children,
105
149
  tableContext,
106
150
  onSetColumnWidth,
107
151
  onSetRowHeight,
152
+ pageStackScrollRoot,
153
+ renderFrameRevision,
108
154
  }) => {
109
155
  return (
110
156
  <div
@@ -112,6 +158,13 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
112
158
  data-testid={testId ?? "chrome-overlay"}
113
159
  role="presentation"
114
160
  >
161
+ {pageStackScrollRoot !== undefined ? (
162
+ <TwPageStackOverlayLayer
163
+ facet={facet}
164
+ scrollRoot={pageStackScrollRoot}
165
+ renderFrameRevision={renderFrameRevision ?? 0}
166
+ />
167
+ ) : null}
115
168
  <TwScopeRailLayer
116
169
  facet={facet}
117
170
  space={space}
@@ -125,6 +178,11 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
125
178
  onClose={onScopeCardClose ?? noop}
126
179
  onModeChange={onScopeCardModeChange ?? noopModeChange}
127
180
  onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
181
+ onAcceptSuggestionGroup={onScopeCardAcceptSuggestionGroup}
182
+ onRejectSuggestionGroup={onScopeCardRejectSuggestionGroup}
183
+ onAskAgent={onScopeCardAskAgent}
184
+ editorRole={editorRole}
185
+ scopeTagEditor={scopeCardScopeTagEditor}
128
186
  space={space}
129
187
  />
130
188
  <TwTableGripLayer