@beyondwork/docx-react-component 1.0.40 → 1.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
|
@@ -51,6 +51,24 @@ export function resolveChromePresetOptions(
|
|
|
51
51
|
showSectionTagAction: false,
|
|
52
52
|
showReviewRail: true,
|
|
53
53
|
},
|
|
54
|
+
collab: {
|
|
55
|
+
// Collab preset composes on top of "review" — same review rail,
|
|
56
|
+
// plus the collab top nav (presence, role + audience chips,
|
|
57
|
+
// tamper banner, negotiation action bar, send-to-supplier).
|
|
58
|
+
// Each sub-surface has its own flag so hosts can selectively
|
|
59
|
+
// disable parts (e.g. single-user demo, or a presenter-only view)
|
|
60
|
+
// without forking the preset.
|
|
61
|
+
showReviewQueueBar: false,
|
|
62
|
+
showSectionTagAction: false,
|
|
63
|
+
showReviewRail: true,
|
|
64
|
+
showCollabTopNav: true,
|
|
65
|
+
showCollabPresenceStrip: true,
|
|
66
|
+
showCollabRoleChip: true,
|
|
67
|
+
showCollabAudienceChip: true,
|
|
68
|
+
showCollabTamperBanner: true,
|
|
69
|
+
showCollabNegotiationActionBar: true,
|
|
70
|
+
showCollabSendToSupplier: true,
|
|
71
|
+
},
|
|
54
72
|
};
|
|
55
73
|
|
|
56
74
|
return {
|
|
@@ -116,6 +134,16 @@ export function resolveChromeVisibilityForPreset(input: {
|
|
|
116
134
|
statusBar: true,
|
|
117
135
|
reviewRail: options.showReviewRail,
|
|
118
136
|
},
|
|
137
|
+
collab: {
|
|
138
|
+
toolbar: true,
|
|
139
|
+
alerts: true,
|
|
140
|
+
selectionOverlay: true,
|
|
141
|
+
contextToolbars: true,
|
|
142
|
+
contextAnalytics: true,
|
|
143
|
+
pageChrome: true,
|
|
144
|
+
statusBar: true,
|
|
145
|
+
reviewRail: options.showReviewRail,
|
|
146
|
+
},
|
|
119
147
|
};
|
|
120
148
|
|
|
121
149
|
return {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import type { CommentAudience } from "../../api/comment-presentation-types.ts";
|
|
4
|
+
|
|
5
|
+
export interface CollabAudienceChipProps {
|
|
6
|
+
/** `undefined` when there is no active comment. */
|
|
7
|
+
audience: CommentAudience | undefined;
|
|
8
|
+
/** `true` when the local user is allowed to change audience. */
|
|
9
|
+
canEdit: boolean;
|
|
10
|
+
/** Invoked when the user cycles the chip. Caller dispatches set-audience. */
|
|
11
|
+
onCycle?: (next: CommentAudience) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CYCLE: readonly CommentAudience[] = ["internal", "external", "shared"];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Audience chip for the active comment in the collab top nav (P9c).
|
|
19
|
+
*
|
|
20
|
+
* Renders the current `audience` of the active comment. Clicking
|
|
21
|
+
* cycles `internal → external → shared → internal` and invokes
|
|
22
|
+
* `onCycle(next)` — the caller is responsible for dispatching
|
|
23
|
+
* `set-audience` through `session.dispatchCommentPresentation`.
|
|
24
|
+
*
|
|
25
|
+
* Fail-safe rendering:
|
|
26
|
+
* - Renders a disabled "no comment" placeholder when `audience` is
|
|
27
|
+
* undefined (no active comment selected).
|
|
28
|
+
* - Respects `canEdit`: when false, renders a disabled chip with the
|
|
29
|
+
* current audience but no click handler — useful for observer /
|
|
30
|
+
* reviewer who cannot change the audience.
|
|
31
|
+
*/
|
|
32
|
+
export function CollabAudienceChip({
|
|
33
|
+
audience,
|
|
34
|
+
canEdit,
|
|
35
|
+
onCycle,
|
|
36
|
+
className,
|
|
37
|
+
}: CollabAudienceChipProps): React.ReactElement {
|
|
38
|
+
const empty = audience === undefined;
|
|
39
|
+
const disabled = empty || !canEdit;
|
|
40
|
+
const rootClass = [
|
|
41
|
+
"tw-collab-audience-chip",
|
|
42
|
+
audience ? `tw-collab-audience-chip--${audience}` : "tw-collab-audience-chip--empty",
|
|
43
|
+
disabled ? "tw-collab-audience-chip--disabled" : null,
|
|
44
|
+
className ?? null,
|
|
45
|
+
]
|
|
46
|
+
.filter((v): v is string => v !== null)
|
|
47
|
+
.join(" ");
|
|
48
|
+
|
|
49
|
+
const label = empty ? "no comment" : audience;
|
|
50
|
+
|
|
51
|
+
const handleClick = (): void => {
|
|
52
|
+
if (disabled || !audience) return;
|
|
53
|
+
const idx = CYCLE.indexOf(audience);
|
|
54
|
+
const next = CYCLE[(idx + 1) % CYCLE.length]!;
|
|
55
|
+
onCycle?.(next);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className={rootClass}
|
|
62
|
+
data-testid="collab-audience-chip"
|
|
63
|
+
data-audience={audience ?? "none"}
|
|
64
|
+
data-can-edit={canEdit ? "true" : "false"}
|
|
65
|
+
aria-label={empty ? "No active comment" : `Audience: ${audience}`}
|
|
66
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
onClick={handleClick}
|
|
69
|
+
>
|
|
70
|
+
{label}
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|