@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,244 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CommentNegotiationAction,
|
|
5
|
+
CommentNegotiationEntry,
|
|
6
|
+
CommentNegotiationState,
|
|
7
|
+
NegotiationRole,
|
|
8
|
+
} from "../../api/comment-negotiation-types.ts";
|
|
9
|
+
|
|
10
|
+
export interface CollabNegotiationActionBarProps {
|
|
11
|
+
/** Currently-selected comment's negotiation entry. `undefined` when no comment is active. */
|
|
12
|
+
entry: CommentNegotiationEntry | undefined;
|
|
13
|
+
/** Local user identity — used to stamp actor / author ids on actions. */
|
|
14
|
+
actorId: string;
|
|
15
|
+
/** Local user role — drives button visibility. */
|
|
16
|
+
role: NegotiationRole;
|
|
17
|
+
/**
|
|
18
|
+
* Dispatcher. Caller routes to `session.dispatchCommentNegotiation(action, ctx)`.
|
|
19
|
+
* We hand back the fully-populated action; context (role/now) is the caller's job.
|
|
20
|
+
*/
|
|
21
|
+
onDispatch: (action: CommentNegotiationAction) => void;
|
|
22
|
+
/** Blocks the whole bar when true — used by the chrome when the tamper gate is tampered. */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/** Fresh timestamp source (injectable for tests). */
|
|
25
|
+
now?: () => string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Per-state, per-role action bar for the active comment negotiation
|
|
31
|
+
* (P9e). Mirrors the role-gating matrix in `collab-master-plan §7`:
|
|
32
|
+
*
|
|
33
|
+
* state author reviewer observer
|
|
34
|
+
* proposed propose / counter / reject propose / counter (none)
|
|
35
|
+
* negotiating approve / reject (vote) / counter / approve / reject (none)
|
|
36
|
+
* reject-thread / accept / lock (vote) / counter
|
|
37
|
+
* accepted lock — (none)
|
|
38
|
+
* rejected lock — (none)
|
|
39
|
+
* resolved reopen reopen (none)
|
|
40
|
+
*
|
|
41
|
+
* Accept is author-only (needs quorum validated server-side; the
|
|
42
|
+
* reducer will block reviewers with `collab_role_restricted`).
|
|
43
|
+
*/
|
|
44
|
+
export function CollabNegotiationActionBar({
|
|
45
|
+
entry,
|
|
46
|
+
actorId,
|
|
47
|
+
role,
|
|
48
|
+
onDispatch,
|
|
49
|
+
disabled = false,
|
|
50
|
+
now = () => new Date().toISOString(),
|
|
51
|
+
className,
|
|
52
|
+
}: CollabNegotiationActionBarProps): React.ReactElement {
|
|
53
|
+
const empty = entry === undefined;
|
|
54
|
+
const blocked = disabled || role === "observer" || empty;
|
|
55
|
+
const state: CommentNegotiationState | "empty" = entry?.state ?? "empty";
|
|
56
|
+
|
|
57
|
+
const rootClass = [
|
|
58
|
+
"tw-collab-negotiation-action-bar",
|
|
59
|
+
`tw-collab-negotiation-action-bar--${state}`,
|
|
60
|
+
blocked ? "tw-collab-negotiation-action-bar--blocked" : null,
|
|
61
|
+
className ?? null,
|
|
62
|
+
]
|
|
63
|
+
.filter((v): v is string => v !== null)
|
|
64
|
+
.join(" ");
|
|
65
|
+
|
|
66
|
+
const buttons = entry
|
|
67
|
+
? buildButtons(entry, role, actorId, now)
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={rootClass}
|
|
73
|
+
data-testid="collab-negotiation-action-bar"
|
|
74
|
+
data-state={state}
|
|
75
|
+
data-role={role}
|
|
76
|
+
data-blocked={blocked ? "true" : "false"}
|
|
77
|
+
role="toolbar"
|
|
78
|
+
aria-label="Comment negotiation actions"
|
|
79
|
+
>
|
|
80
|
+
{empty ? (
|
|
81
|
+
<span
|
|
82
|
+
className="tw-collab-negotiation-action-bar__empty"
|
|
83
|
+
data-testid="collab-negotiation-action-bar-empty"
|
|
84
|
+
>
|
|
85
|
+
No active comment
|
|
86
|
+
</span>
|
|
87
|
+
) : (
|
|
88
|
+
buttons.map((btn) => (
|
|
89
|
+
<button
|
|
90
|
+
key={btn.id}
|
|
91
|
+
type="button"
|
|
92
|
+
className={`tw-collab-negotiation-action-bar__button tw-collab-negotiation-action-bar__button--${btn.id}`}
|
|
93
|
+
data-testid={`collab-negotiation-action-${btn.id}`}
|
|
94
|
+
data-action-id={btn.id}
|
|
95
|
+
disabled={blocked || btn.disabled}
|
|
96
|
+
aria-disabled={blocked || btn.disabled ? "true" : undefined}
|
|
97
|
+
title={btn.title}
|
|
98
|
+
onClick={() => {
|
|
99
|
+
if (blocked || btn.disabled) return;
|
|
100
|
+
onDispatch(btn.build());
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{btn.label}
|
|
104
|
+
</button>
|
|
105
|
+
))
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
type ButtonId =
|
|
114
|
+
| "propose-change"
|
|
115
|
+
| "counter-propose"
|
|
116
|
+
| "vote-approve"
|
|
117
|
+
| "vote-reject"
|
|
118
|
+
| "accept"
|
|
119
|
+
| "reject"
|
|
120
|
+
| "lock"
|
|
121
|
+
| "reopen";
|
|
122
|
+
|
|
123
|
+
interface ButtonDef {
|
|
124
|
+
id: ButtonId;
|
|
125
|
+
label: string;
|
|
126
|
+
title?: string;
|
|
127
|
+
disabled?: boolean;
|
|
128
|
+
build(): CommentNegotiationAction;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildButtons(
|
|
132
|
+
entry: CommentNegotiationEntry,
|
|
133
|
+
role: NegotiationRole,
|
|
134
|
+
actorId: string,
|
|
135
|
+
now: () => string,
|
|
136
|
+
): ButtonDef[] {
|
|
137
|
+
const state = entry.state;
|
|
138
|
+
const isAuthor = role === "author";
|
|
139
|
+
const isReviewer = role === "reviewer";
|
|
140
|
+
const id = entry.commentId;
|
|
141
|
+
const buttons: ButtonDef[] = [];
|
|
142
|
+
|
|
143
|
+
if (state === "proposed" && (isAuthor || isReviewer)) {
|
|
144
|
+
buttons.push({
|
|
145
|
+
id: "propose-change",
|
|
146
|
+
label: "Propose change",
|
|
147
|
+
build: () => ({ type: "propose-change", commentId: id, actorId }),
|
|
148
|
+
});
|
|
149
|
+
buttons.push({
|
|
150
|
+
id: "counter-propose",
|
|
151
|
+
label: "Counter-propose",
|
|
152
|
+
build: () => ({
|
|
153
|
+
type: "counter-propose",
|
|
154
|
+
commentId: id,
|
|
155
|
+
authorId: actorId,
|
|
156
|
+
proposalId: generateProposalId(),
|
|
157
|
+
body: "",
|
|
158
|
+
createdAt: now(),
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
buttons.push({
|
|
162
|
+
id: "reject",
|
|
163
|
+
label: "Reject",
|
|
164
|
+
build: () => ({ type: "reject", commentId: id, actorId }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (state === "negotiating" && (isAuthor || isReviewer)) {
|
|
169
|
+
buttons.push({
|
|
170
|
+
id: "vote-approve",
|
|
171
|
+
label: "Approve",
|
|
172
|
+
build: () => ({
|
|
173
|
+
type: "vote",
|
|
174
|
+
commentId: id,
|
|
175
|
+
authorId: actorId,
|
|
176
|
+
verdict: "approve",
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
buttons.push({
|
|
180
|
+
id: "vote-reject",
|
|
181
|
+
label: "Reject (vote)",
|
|
182
|
+
build: () => ({
|
|
183
|
+
type: "vote",
|
|
184
|
+
commentId: id,
|
|
185
|
+
authorId: actorId,
|
|
186
|
+
verdict: "reject",
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
buttons.push({
|
|
190
|
+
id: "counter-propose",
|
|
191
|
+
label: "Counter-propose",
|
|
192
|
+
build: () => ({
|
|
193
|
+
type: "counter-propose",
|
|
194
|
+
commentId: id,
|
|
195
|
+
authorId: actorId,
|
|
196
|
+
proposalId: generateProposalId(),
|
|
197
|
+
body: "",
|
|
198
|
+
createdAt: now(),
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
buttons.push({
|
|
202
|
+
id: "reject",
|
|
203
|
+
label: "Reject thread",
|
|
204
|
+
build: () => ({ type: "reject", commentId: id, actorId }),
|
|
205
|
+
});
|
|
206
|
+
if (isAuthor) {
|
|
207
|
+
buttons.push({
|
|
208
|
+
id: "accept",
|
|
209
|
+
label: "Accept",
|
|
210
|
+
title: "Accept requires quorum",
|
|
211
|
+
build: () => ({
|
|
212
|
+
type: "accept",
|
|
213
|
+
commentId: id,
|
|
214
|
+
actorId,
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if ((state === "accepted" || state === "rejected") && isAuthor) {
|
|
221
|
+
buttons.push({
|
|
222
|
+
id: "lock",
|
|
223
|
+
label: "Lock",
|
|
224
|
+
build: () => ({ type: "lock", commentId: id, actorId }),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (state === "resolved" && (isAuthor || isReviewer)) {
|
|
229
|
+
buttons.push({
|
|
230
|
+
id: "reopen",
|
|
231
|
+
label: "Reopen",
|
|
232
|
+
build: () => ({ type: "reopen", commentId: id, actorId }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return buttons;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function generateProposalId(): string {
|
|
240
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
241
|
+
return `prop-${globalThis.crypto.randomUUID()}`;
|
|
242
|
+
}
|
|
243
|
+
return `prop-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
244
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AwarenessPeer,
|
|
5
|
+
PresenceSnapshot,
|
|
6
|
+
TransportStatus,
|
|
7
|
+
} from "../../api/awareness-identity-types.ts";
|
|
8
|
+
|
|
9
|
+
export interface CollabPresenceStripProps {
|
|
10
|
+
presence: PresenceSnapshot;
|
|
11
|
+
/**
|
|
12
|
+
* Optional current-user posture for display styling. When `role ===
|
|
13
|
+
* "observer"` the strip is rendered dimmed to reflect the read-only
|
|
14
|
+
* session posture.
|
|
15
|
+
*/
|
|
16
|
+
localRole?: "author" | "reviewer" | "observer";
|
|
17
|
+
/** Max number of peer tiles to render inline before collapsing the remainder. */
|
|
18
|
+
maxInline?: number;
|
|
19
|
+
/** Classname hook for host styling. */
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Presence strip for the `"collab"` chrome preset (P9b).
|
|
25
|
+
*
|
|
26
|
+
* Reads the snapshot passed in by the host (from
|
|
27
|
+
* `session.getPresenceSnapshot()`). Kept as a pure component so the
|
|
28
|
+
* preset can mount / remount it freely without the chrome owning a
|
|
29
|
+
* session subscription.
|
|
30
|
+
*
|
|
31
|
+
* Visual contract:
|
|
32
|
+
* - Per-peer avatar tile with display-name initials.
|
|
33
|
+
* - `authorKind` shown as a small badge (`agent` / `system`; `human` is
|
|
34
|
+
* the default and unlabelled).
|
|
35
|
+
* - Transport chip at the end of the strip: `connected` / `syncing` /
|
|
36
|
+
* `offline (N queued)`.
|
|
37
|
+
* - When `localRole === "observer"` the whole strip gets the
|
|
38
|
+
* `tw-collab-presence-observer` modifier so the host CSS can dim it.
|
|
39
|
+
* - When there are zero peers the strip still renders the transport
|
|
40
|
+
* chip so hosts see the disconnected state unambiguously.
|
|
41
|
+
*/
|
|
42
|
+
export function CollabPresenceStrip({
|
|
43
|
+
presence,
|
|
44
|
+
localRole,
|
|
45
|
+
maxInline = 6,
|
|
46
|
+
className,
|
|
47
|
+
}: CollabPresenceStripProps): React.ReactElement {
|
|
48
|
+
const observer = localRole === "observer";
|
|
49
|
+
const tiles = presence.peers.slice(0, maxInline);
|
|
50
|
+
const overflow = Math.max(0, presence.peers.length - tiles.length);
|
|
51
|
+
const rootClass = [
|
|
52
|
+
"tw-collab-presence-strip",
|
|
53
|
+
observer ? "tw-collab-presence-observer" : null,
|
|
54
|
+
className ?? null,
|
|
55
|
+
]
|
|
56
|
+
.filter((value): value is string => value !== null)
|
|
57
|
+
.join(" ");
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={rootClass}
|
|
62
|
+
data-testid="collab-presence-strip"
|
|
63
|
+
role="group"
|
|
64
|
+
aria-label="Collaborators"
|
|
65
|
+
data-observer={observer ? "true" : "false"}
|
|
66
|
+
>
|
|
67
|
+
<ul className="tw-collab-presence-strip__peers" aria-live="polite">
|
|
68
|
+
{tiles.map((peer) => (
|
|
69
|
+
<CollabPresencePeerTile key={peer.userId} peer={peer} />
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
{overflow > 0 ? (
|
|
73
|
+
<span
|
|
74
|
+
className="tw-collab-presence-strip__overflow"
|
|
75
|
+
aria-label={`${overflow} additional peers`}
|
|
76
|
+
data-testid="collab-presence-overflow"
|
|
77
|
+
>
|
|
78
|
+
+{overflow}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
<CollabTransportChip
|
|
82
|
+
status={presence.transportStatus}
|
|
83
|
+
queuedLocalEvents={presence.queuedLocalEvents}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function CollabPresencePeerTile({ peer }: { peer: AwarenessPeer }) {
|
|
90
|
+
const initials = computeInitials(peer.displayName);
|
|
91
|
+
return (
|
|
92
|
+
<li
|
|
93
|
+
className="tw-collab-presence-strip__tile"
|
|
94
|
+
data-testid={`collab-presence-peer-${peer.userId}`}
|
|
95
|
+
data-author-kind={peer.authorKind}
|
|
96
|
+
data-active-story={peer.activeStoryId ?? ""}
|
|
97
|
+
title={peer.displayName}
|
|
98
|
+
>
|
|
99
|
+
<span className="tw-collab-presence-strip__avatar" aria-hidden="true">
|
|
100
|
+
{initials}
|
|
101
|
+
</span>
|
|
102
|
+
<span className="tw-collab-presence-strip__name">{peer.displayName}</span>
|
|
103
|
+
{peer.authorKind !== "human" ? (
|
|
104
|
+
<span
|
|
105
|
+
className="tw-collab-presence-strip__badge"
|
|
106
|
+
data-testid={`collab-presence-peer-${peer.userId}-badge`}
|
|
107
|
+
>
|
|
108
|
+
{peer.authorKind}
|
|
109
|
+
</span>
|
|
110
|
+
) : null}
|
|
111
|
+
</li>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function CollabTransportChip({
|
|
116
|
+
status,
|
|
117
|
+
queuedLocalEvents,
|
|
118
|
+
}: {
|
|
119
|
+
status: TransportStatus;
|
|
120
|
+
queuedLocalEvents: number;
|
|
121
|
+
}) {
|
|
122
|
+
const label =
|
|
123
|
+
status === "offline" && queuedLocalEvents > 0
|
|
124
|
+
? `offline (${queuedLocalEvents} queued)`
|
|
125
|
+
: status;
|
|
126
|
+
return (
|
|
127
|
+
<span
|
|
128
|
+
className={`tw-collab-presence-strip__transport tw-collab-presence-strip__transport--${status}`}
|
|
129
|
+
data-testid="collab-presence-transport"
|
|
130
|
+
data-status={status}
|
|
131
|
+
data-queued={queuedLocalEvents.toString()}
|
|
132
|
+
aria-label={`Transport ${label}`}
|
|
133
|
+
>
|
|
134
|
+
{label}
|
|
135
|
+
</span>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function computeInitials(displayName: string): string {
|
|
140
|
+
const trimmed = displayName.trim();
|
|
141
|
+
if (trimmed.length === 0) return "?";
|
|
142
|
+
const words = trimmed.split(/\s+/);
|
|
143
|
+
if (words.length === 1) {
|
|
144
|
+
return words[0]!.slice(0, 2).toUpperCase();
|
|
145
|
+
}
|
|
146
|
+
return (
|
|
147
|
+
(words[0]![0] ?? "").toUpperCase() +
|
|
148
|
+
(words[words.length - 1]![0] ?? "").toUpperCase()
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CollabPosture,
|
|
5
|
+
TransportStatus,
|
|
6
|
+
} from "../../api/awareness-identity-types.ts";
|
|
7
|
+
|
|
8
|
+
export interface CollabRoleChipProps {
|
|
9
|
+
posture: CollabPosture;
|
|
10
|
+
/** Optional transport override (chip dims on offline). */
|
|
11
|
+
transportStatus?: TransportStatus;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Role + peer-count chip for the collab top nav (P9c).
|
|
17
|
+
*
|
|
18
|
+
* Reads `CollabPosture` (role, transport, peers). Pure presentational —
|
|
19
|
+
* the host passes in the snapshot from `session.getCollabPosture()`.
|
|
20
|
+
* Observer role gets a dimmed modifier class so the chrome can visually
|
|
21
|
+
* flag the read-only session posture alongside the role chip.
|
|
22
|
+
*/
|
|
23
|
+
export function CollabRoleChip({
|
|
24
|
+
posture,
|
|
25
|
+
transportStatus,
|
|
26
|
+
className,
|
|
27
|
+
}: CollabRoleChipProps): React.ReactElement {
|
|
28
|
+
const offline =
|
|
29
|
+
transportStatus === "offline" || posture.transport === "none";
|
|
30
|
+
const rootClass = [
|
|
31
|
+
"tw-collab-role-chip",
|
|
32
|
+
`tw-collab-role-chip--${posture.role}`,
|
|
33
|
+
offline ? "tw-collab-role-chip--offline" : null,
|
|
34
|
+
className ?? null,
|
|
35
|
+
]
|
|
36
|
+
.filter((v): v is string => v !== null)
|
|
37
|
+
.join(" ");
|
|
38
|
+
|
|
39
|
+
const peerLabel =
|
|
40
|
+
posture.peers === 0
|
|
41
|
+
? "alone"
|
|
42
|
+
: posture.peers === 1
|
|
43
|
+
? "1 peer"
|
|
44
|
+
: `${posture.peers} peers`;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
className={rootClass}
|
|
49
|
+
data-testid="collab-role-chip"
|
|
50
|
+
data-role={posture.role}
|
|
51
|
+
data-transport={posture.transport}
|
|
52
|
+
data-peers={posture.peers.toString()}
|
|
53
|
+
aria-label={`Role ${posture.role}, ${peerLabel}`}
|
|
54
|
+
title={`${posture.role} · ${peerLabel}`}
|
|
55
|
+
>
|
|
56
|
+
<span className="tw-collab-role-chip__role">{posture.role}</span>
|
|
57
|
+
<span className="tw-collab-role-chip__peers" aria-hidden="true">
|
|
58
|
+
{posture.peers}
|
|
59
|
+
</span>
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type { MetadataIntegrity } from "../../runtime/tamper-gate.ts";
|
|
4
|
+
|
|
5
|
+
export interface CollabSendToSupplierButtonProps {
|
|
6
|
+
role: "author" | "reviewer" | "observer";
|
|
7
|
+
integrity: MetadataIntegrity;
|
|
8
|
+
/** Counts from the presentation snapshot, per audience. */
|
|
9
|
+
shareableCount: number; // external + shared
|
|
10
|
+
internalCount: number;
|
|
11
|
+
onOpenModal: () => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Send-to-supplier button (P9f). Author-only; disabled when the
|
|
17
|
+
* tamper gate reports "tampered" or there are zero shareable comments
|
|
18
|
+
* (external + shared). Clicking opens the confirmation modal; the
|
|
19
|
+
* caller owns modal state.
|
|
20
|
+
*/
|
|
21
|
+
export function CollabSendToSupplierButton({
|
|
22
|
+
role,
|
|
23
|
+
integrity,
|
|
24
|
+
shareableCount,
|
|
25
|
+
internalCount,
|
|
26
|
+
onOpenModal,
|
|
27
|
+
className,
|
|
28
|
+
}: CollabSendToSupplierButtonProps): React.ReactElement | null {
|
|
29
|
+
if (role !== "author") return null;
|
|
30
|
+
|
|
31
|
+
const tampered = integrity === "tampered";
|
|
32
|
+
const emptyShareable = shareableCount === 0;
|
|
33
|
+
const disabled = tampered || emptyShareable;
|
|
34
|
+
const rootClass = [
|
|
35
|
+
"tw-collab-send-to-supplier-button",
|
|
36
|
+
tampered ? "tw-collab-send-to-supplier-button--tampered" : null,
|
|
37
|
+
emptyShareable ? "tw-collab-send-to-supplier-button--empty" : null,
|
|
38
|
+
className ?? null,
|
|
39
|
+
]
|
|
40
|
+
.filter((v): v is string => v !== null)
|
|
41
|
+
.join(" ");
|
|
42
|
+
|
|
43
|
+
const title = tampered
|
|
44
|
+
? "Blocked: metadata integrity check failed. Acknowledge first."
|
|
45
|
+
: emptyShareable
|
|
46
|
+
? "No external or shared comments to send."
|
|
47
|
+
: `Send ${shareableCount} comment${shareableCount === 1 ? "" : "s"} to supplier · ${internalCount} internal kept local`;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className={rootClass}
|
|
53
|
+
data-testid="collab-send-to-supplier-button"
|
|
54
|
+
data-shareable={shareableCount.toString()}
|
|
55
|
+
data-internal={internalCount.toString()}
|
|
56
|
+
data-tampered={tampered ? "true" : "false"}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
59
|
+
title={title}
|
|
60
|
+
onClick={() => {
|
|
61
|
+
if (disabled) return;
|
|
62
|
+
onOpenModal();
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
Send to supplier
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|