@beyondwork/docx-react-component 1.0.41 → 1.0.43
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 +38 -37
- 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/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- 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 +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- 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/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -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 +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -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 +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- 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/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- 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/collab-top-nav-container.tsx +281 -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 +106 -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/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- 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 +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- 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 +455 -118
|
@@ -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 & continue
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type { CollabSession } from "../../runtime/collab-session.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CommentAudience,
|
|
6
|
+
} from "../../api/comment-presentation-types.ts";
|
|
7
|
+
import type { WordReviewEditorChromeOptions } from "../../api/public-types.ts";
|
|
8
|
+
import type { TransportStatus } from "../../api/awareness-identity-types.ts";
|
|
9
|
+
import { CollabPresenceStrip } from "./collab-presence-strip.tsx";
|
|
10
|
+
import { CollabRoleChip } from "./collab-role-chip.tsx";
|
|
11
|
+
import { CollabAudienceChip } from "./collab-audience-chip.tsx";
|
|
12
|
+
import { CollabTamperBanner } from "./collab-tamper-banner.tsx";
|
|
13
|
+
import { CollabNegotiationActionBar } from "./collab-negotiation-action-bar.tsx";
|
|
14
|
+
import { CollabSendToSupplierButton } from "./collab-send-to-supplier-button.tsx";
|
|
15
|
+
import {
|
|
16
|
+
CollabSendToSupplierModal,
|
|
17
|
+
type CollabSendToSupplierSubmitArgs,
|
|
18
|
+
} from "./collab-send-to-supplier-modal.tsx";
|
|
19
|
+
|
|
20
|
+
export type CollabSubSurfaceVisibility = Pick<
|
|
21
|
+
WordReviewEditorChromeOptions,
|
|
22
|
+
| "showCollabTopNav"
|
|
23
|
+
| "showCollabPresenceStrip"
|
|
24
|
+
| "showCollabRoleChip"
|
|
25
|
+
| "showCollabAudienceChip"
|
|
26
|
+
| "showCollabTamperBanner"
|
|
27
|
+
| "showCollabNegotiationActionBar"
|
|
28
|
+
| "showCollabSendToSupplier"
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export interface CollabTopNavContainerProps {
|
|
32
|
+
/** Wired collab session. When `undefined` / detached, the container renders nothing. */
|
|
33
|
+
session: CollabSession | undefined;
|
|
34
|
+
/** The currently-focused comment id (if any). Host supplies this from its own selection signal. */
|
|
35
|
+
activeCommentId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Fresh identity metadata about the local user. The session falls
|
|
38
|
+
* back to `author` if no awareness is wired, which matches the
|
|
39
|
+
* `CollabPosture` contract. This prop is passed through so tests /
|
|
40
|
+
* hosts can override the actor id used by the action bar without
|
|
41
|
+
* also mutating awareness.
|
|
42
|
+
*/
|
|
43
|
+
actorId: string;
|
|
44
|
+
/** Current transport status signal (from the host's provider). */
|
|
45
|
+
transportStatus?: TransportStatus;
|
|
46
|
+
/** Visibility toggles from the chrome preset options. */
|
|
47
|
+
visibility?: CollabSubSurfaceVisibility;
|
|
48
|
+
/**
|
|
49
|
+
* Minimum send-to-supplier args a host wants to commit to before the
|
|
50
|
+
* confirmation modal even appears. The modal collects the rest.
|
|
51
|
+
*/
|
|
52
|
+
sendBaseline?: {
|
|
53
|
+
originDocumentId: string;
|
|
54
|
+
originPayloadId: string;
|
|
55
|
+
originContentHash: string;
|
|
56
|
+
payloadXml: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Optional override for the activeStory filter passed into
|
|
60
|
+
* `getPresenceSnapshot({ activeStoryFilter })`.
|
|
61
|
+
*/
|
|
62
|
+
activeStoryFilter?: string;
|
|
63
|
+
className?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DEFAULT_VISIBILITY: Required<CollabSubSurfaceVisibility> = {
|
|
67
|
+
showCollabTopNav: true,
|
|
68
|
+
showCollabPresenceStrip: true,
|
|
69
|
+
showCollabRoleChip: true,
|
|
70
|
+
showCollabAudienceChip: true,
|
|
71
|
+
showCollabTamperBanner: true,
|
|
72
|
+
showCollabNegotiationActionBar: true,
|
|
73
|
+
showCollabSendToSupplier: true,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* P9g — chrome-toolbar container. Subscribes to the `CollabSession`
|
|
78
|
+
* event stream and renders the six P9a–f components from live
|
|
79
|
+
* snapshots. Pure React, no Yjs / awareness coupling — the session
|
|
80
|
+
* abstracts those away.
|
|
81
|
+
*
|
|
82
|
+
* The container intentionally renders `null` when no session is wired
|
|
83
|
+
* so the chrome toolbar can mount it unconditionally while the host
|
|
84
|
+
* decides whether to supply a session.
|
|
85
|
+
*
|
|
86
|
+
* Individual sub-surfaces can be opted out via `visibility`; the
|
|
87
|
+
* defaults mirror the `"collab"` chrome preset so hosts that
|
|
88
|
+
* instantiate directly get the full top nav.
|
|
89
|
+
*/
|
|
90
|
+
export function CollabTopNavContainer({
|
|
91
|
+
session,
|
|
92
|
+
activeCommentId,
|
|
93
|
+
actorId,
|
|
94
|
+
transportStatus,
|
|
95
|
+
visibility,
|
|
96
|
+
sendBaseline,
|
|
97
|
+
activeStoryFilter,
|
|
98
|
+
className,
|
|
99
|
+
}: CollabTopNavContainerProps): React.ReactElement | null {
|
|
100
|
+
const effective: Required<CollabSubSurfaceVisibility> = {
|
|
101
|
+
...DEFAULT_VISIBILITY,
|
|
102
|
+
...normalizeVisibility(visibility),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const [tick, setTick] = React.useState(0);
|
|
106
|
+
const [sendModalOpen, setSendModalOpen] = React.useState(false);
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (!session) return undefined;
|
|
110
|
+
const off = session.subscribe((event) => {
|
|
111
|
+
// Any event from the bridge OR the tamper gate triggers a
|
|
112
|
+
// re-read. The container is cheap to re-render; the underlying
|
|
113
|
+
// snapshots are already deep-cloned by the stores.
|
|
114
|
+
//
|
|
115
|
+
// We deliberately ignore the event type — the container is
|
|
116
|
+
// declarative about what it renders, so a single bump is enough.
|
|
117
|
+
void event;
|
|
118
|
+
setTick((n) => n + 1);
|
|
119
|
+
});
|
|
120
|
+
return off;
|
|
121
|
+
}, [session]);
|
|
122
|
+
|
|
123
|
+
if (!session || !effective.showCollabTopNav) return null;
|
|
124
|
+
|
|
125
|
+
const presence = session.getPresenceSnapshot({
|
|
126
|
+
...(transportStatus ? { transportStatus } : {}),
|
|
127
|
+
...(activeStoryFilter !== undefined ? { activeStoryFilter } : {}),
|
|
128
|
+
});
|
|
129
|
+
const posture = session.getCollabPosture({
|
|
130
|
+
...(transportStatus ? { transportStatus } : {}),
|
|
131
|
+
});
|
|
132
|
+
const integrity = session.getMetadataIntegrity();
|
|
133
|
+
const presentation = session.getCommentPresentationSnapshot();
|
|
134
|
+
const negotiation = session.getCommentNegotiationSnapshot();
|
|
135
|
+
|
|
136
|
+
const activePresentation = activeCommentId
|
|
137
|
+
? presentation.entries.find((e) => e.commentId === activeCommentId)
|
|
138
|
+
: undefined;
|
|
139
|
+
const activeNegotiation = activeCommentId
|
|
140
|
+
? negotiation.entries.find((e) => e.commentId === activeCommentId)
|
|
141
|
+
: undefined;
|
|
142
|
+
|
|
143
|
+
const internalCount = presentation.entries.filter(
|
|
144
|
+
(e) => e.audience === "internal",
|
|
145
|
+
).length;
|
|
146
|
+
const shareableCount = presentation.entries.filter(
|
|
147
|
+
(e) => e.audience !== "internal",
|
|
148
|
+
).length;
|
|
149
|
+
|
|
150
|
+
const canEditAudience = posture.role !== "observer" && activePresentation !== undefined;
|
|
151
|
+
const tampered = integrity === "tampered";
|
|
152
|
+
|
|
153
|
+
const rootClass = [
|
|
154
|
+
"tw-collab-top-nav",
|
|
155
|
+
`tw-collab-top-nav--${posture.role}`,
|
|
156
|
+
tampered ? "tw-collab-top-nav--tampered" : null,
|
|
157
|
+
className ?? null,
|
|
158
|
+
]
|
|
159
|
+
.filter((v): v is string => v !== null)
|
|
160
|
+
.join(" ");
|
|
161
|
+
|
|
162
|
+
const handleAudienceCycle = (next: CommentAudience): void => {
|
|
163
|
+
if (!activeCommentId) return;
|
|
164
|
+
void session.dispatchCommentPresentation({
|
|
165
|
+
type: "set-audience",
|
|
166
|
+
commentId: activeCommentId,
|
|
167
|
+
audience: next,
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleNegotiationDispatch = (
|
|
172
|
+
action: Parameters<CollabSession["dispatchCommentNegotiation"]>[0],
|
|
173
|
+
): void => {
|
|
174
|
+
session.dispatchCommentNegotiation(action, {
|
|
175
|
+
role: posture.role,
|
|
176
|
+
now: new Date().toISOString(),
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handleSendSubmit = async (
|
|
181
|
+
args: CollabSendToSupplierSubmitArgs,
|
|
182
|
+
): Promise<void> => {
|
|
183
|
+
if (!sendBaseline) {
|
|
184
|
+
setSendModalOpen(false);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await session.sendToExternal({
|
|
188
|
+
payloadXml: sendBaseline.payloadXml,
|
|
189
|
+
originDocumentId: sendBaseline.originDocumentId,
|
|
190
|
+
originPayloadId: sendBaseline.originPayloadId,
|
|
191
|
+
originContentHash: sendBaseline.originContentHash,
|
|
192
|
+
recipient: args.recipient,
|
|
193
|
+
sentBy: actorId,
|
|
194
|
+
archiveRef: args.archiveRef,
|
|
195
|
+
});
|
|
196
|
+
setSendModalOpen(false);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div
|
|
201
|
+
className={rootClass}
|
|
202
|
+
data-testid="collab-top-nav-container"
|
|
203
|
+
data-tick={tick.toString()}
|
|
204
|
+
data-role={posture.role}
|
|
205
|
+
data-integrity={integrity}
|
|
206
|
+
>
|
|
207
|
+
{effective.showCollabTamperBanner ? (
|
|
208
|
+
<CollabTamperBanner
|
|
209
|
+
integrity={integrity}
|
|
210
|
+
onAcknowledge={() => session.acknowledgeMetadataTampering()}
|
|
211
|
+
/>
|
|
212
|
+
) : null}
|
|
213
|
+
|
|
214
|
+
<div className="tw-collab-top-nav__row">
|
|
215
|
+
{effective.showCollabPresenceStrip ? (
|
|
216
|
+
<CollabPresenceStrip
|
|
217
|
+
presence={presence}
|
|
218
|
+
localRole={posture.role}
|
|
219
|
+
/>
|
|
220
|
+
) : null}
|
|
221
|
+
|
|
222
|
+
{effective.showCollabRoleChip ? (
|
|
223
|
+
<CollabRoleChip
|
|
224
|
+
posture={posture}
|
|
225
|
+
{...(transportStatus ? { transportStatus } : {})}
|
|
226
|
+
/>
|
|
227
|
+
) : null}
|
|
228
|
+
|
|
229
|
+
{effective.showCollabAudienceChip ? (
|
|
230
|
+
<CollabAudienceChip
|
|
231
|
+
audience={activePresentation?.audience}
|
|
232
|
+
canEdit={canEditAudience}
|
|
233
|
+
onCycle={handleAudienceCycle}
|
|
234
|
+
/>
|
|
235
|
+
) : null}
|
|
236
|
+
|
|
237
|
+
{effective.showCollabNegotiationActionBar ? (
|
|
238
|
+
<CollabNegotiationActionBar
|
|
239
|
+
entry={activeNegotiation}
|
|
240
|
+
actorId={actorId}
|
|
241
|
+
role={posture.role}
|
|
242
|
+
onDispatch={handleNegotiationDispatch}
|
|
243
|
+
disabled={tampered}
|
|
244
|
+
/>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
{effective.showCollabSendToSupplier ? (
|
|
248
|
+
<CollabSendToSupplierButton
|
|
249
|
+
role={posture.role}
|
|
250
|
+
integrity={integrity}
|
|
251
|
+
shareableCount={shareableCount}
|
|
252
|
+
internalCount={internalCount}
|
|
253
|
+
onOpenModal={() => setSendModalOpen(true)}
|
|
254
|
+
/>
|
|
255
|
+
) : null}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{effective.showCollabSendToSupplier ? (
|
|
259
|
+
<CollabSendToSupplierModal
|
|
260
|
+
open={sendModalOpen}
|
|
261
|
+
shareableCount={shareableCount}
|
|
262
|
+
internalCount={internalCount}
|
|
263
|
+
onClose={() => setSendModalOpen(false)}
|
|
264
|
+
onSubmit={handleSendSubmit}
|
|
265
|
+
/>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizeVisibility(
|
|
272
|
+
visibility: CollabSubSurfaceVisibility | undefined,
|
|
273
|
+
): Partial<Required<CollabSubSurfaceVisibility>> {
|
|
274
|
+
if (!visibility) return {};
|
|
275
|
+
const out: Partial<Required<CollabSubSurfaceVisibility>> = {};
|
|
276
|
+
for (const key of Object.keys(DEFAULT_VISIBILITY) as (keyof CollabSubSurfaceVisibility)[]) {
|
|
277
|
+
const v = visibility[key];
|
|
278
|
+
if (typeof v === "boolean") out[key] = v;
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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
|
+
}
|