@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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AwarenessIdentity,
|
|
5
|
+
AwarenessPeer,
|
|
6
|
+
CollabPosture,
|
|
7
|
+
PresenceSnapshot,
|
|
8
|
+
TransportStatus,
|
|
9
|
+
} from "../api/awareness-identity-types.ts";
|
|
10
|
+
|
|
11
|
+
const IDENTITY_FIELD = "identity";
|
|
12
|
+
|
|
13
|
+
const ROLE_VOCAB = new Set<AwarenessIdentity["role"]>([
|
|
14
|
+
"author",
|
|
15
|
+
"reviewer",
|
|
16
|
+
"observer",
|
|
17
|
+
]);
|
|
18
|
+
const KIND_VOCAB = new Set<AwarenessIdentity["authorKind"]>([
|
|
19
|
+
"human",
|
|
20
|
+
"agent",
|
|
21
|
+
"system",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Publishes the local client's `identity` record onto the Awareness
|
|
26
|
+
* channel. Every connected peer sees it through
|
|
27
|
+
* `getPresenceSnapshot`. Safe to call repeatedly — re-writes the
|
|
28
|
+
* field, which Awareness treats as a full replacement.
|
|
29
|
+
*/
|
|
30
|
+
export function setLocalIdentity(
|
|
31
|
+
awareness: Awareness,
|
|
32
|
+
identity: AwarenessIdentity,
|
|
33
|
+
): void {
|
|
34
|
+
awareness.setLocalStateField(IDENTITY_FIELD, validate(identity));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Clears the local `identity` field. Typically called on disconnect or
|
|
39
|
+
* role downgrade so stale presence rows don't linger.
|
|
40
|
+
*/
|
|
41
|
+
export function clearLocalIdentity(awareness: Awareness): void {
|
|
42
|
+
awareness.setLocalStateField(IDENTITY_FIELD, null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PresenceSnapshotArgs {
|
|
46
|
+
awareness?: Awareness;
|
|
47
|
+
transportStatus?: TransportStatus;
|
|
48
|
+
queuedLocalEvents?: number;
|
|
49
|
+
/** Used to filter presence by the active story. When omitted, every peer is returned. */
|
|
50
|
+
activeStoryFilter?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a `PresenceSnapshot` from the current Awareness map + a
|
|
55
|
+
* host-supplied transport + queue signal. Unknown or malformed
|
|
56
|
+
* identity entries are silently dropped (fail-closed — never render a
|
|
57
|
+
* partially-trusted peer).
|
|
58
|
+
*/
|
|
59
|
+
export function getPresenceSnapshot(args: PresenceSnapshotArgs = {}): PresenceSnapshot {
|
|
60
|
+
const peers: AwarenessPeer[] = [];
|
|
61
|
+
if (args.awareness) {
|
|
62
|
+
for (const [clientId, state] of args.awareness.getStates()) {
|
|
63
|
+
const raw = (state as Record<string, unknown>)[IDENTITY_FIELD];
|
|
64
|
+
const identity = coerceIdentity(raw);
|
|
65
|
+
if (!identity) continue;
|
|
66
|
+
if (
|
|
67
|
+
args.activeStoryFilter !== undefined &&
|
|
68
|
+
identity.activeStoryId !== undefined &&
|
|
69
|
+
identity.activeStoryId !== args.activeStoryFilter
|
|
70
|
+
) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
peers.push({ clientId, ...identity });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
peers,
|
|
78
|
+
transportStatus: args.transportStatus ?? "offline",
|
|
79
|
+
queuedLocalEvents: args.queuedLocalEvents ?? 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CollabPostureArgs {
|
|
84
|
+
awareness?: Awareness;
|
|
85
|
+
transportStatus?: TransportStatus;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Derives the local user's posture from the Awareness map. Role comes
|
|
90
|
+
* from the local-state `identity`; transport comes from the
|
|
91
|
+
* host-supplied signal; peer count is the presence size minus self.
|
|
92
|
+
*
|
|
93
|
+
* Returns a default "unattached author" posture when no Awareness is
|
|
94
|
+
* wired — this matches the fail-open behaviour of the facet (host
|
|
95
|
+
* without a Y.Doc gets an author posture so single-user docs work).
|
|
96
|
+
*/
|
|
97
|
+
export function getCollabPosture(args: CollabPostureArgs = {}): CollabPosture {
|
|
98
|
+
if (!args.awareness) {
|
|
99
|
+
return { role: "author", transport: "none", peers: 0 };
|
|
100
|
+
}
|
|
101
|
+
const localClientId = args.awareness.clientID;
|
|
102
|
+
const localState = args.awareness.getLocalState() as
|
|
103
|
+
| Record<string, unknown>
|
|
104
|
+
| null;
|
|
105
|
+
const localIdentity = coerceIdentity(localState?.[IDENTITY_FIELD]);
|
|
106
|
+
const role = localIdentity?.role ?? "author";
|
|
107
|
+
|
|
108
|
+
let peers = 0;
|
|
109
|
+
for (const [clientId, state] of args.awareness.getStates()) {
|
|
110
|
+
if (clientId === localClientId) continue;
|
|
111
|
+
const identity = coerceIdentity(
|
|
112
|
+
(state as Record<string, unknown>)[IDENTITY_FIELD],
|
|
113
|
+
);
|
|
114
|
+
if (identity) peers += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
role,
|
|
119
|
+
transport: args.transportStatus === "offline" ? "attached" : "attached",
|
|
120
|
+
peers,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function coerceIdentity(raw: unknown): AwarenessIdentity | undefined {
|
|
125
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
126
|
+
const candidate = raw as Record<string, unknown>;
|
|
127
|
+
const userId = candidate["userId"];
|
|
128
|
+
const displayName = candidate["displayName"];
|
|
129
|
+
const role = candidate["role"];
|
|
130
|
+
const authorKind = candidate["authorKind"];
|
|
131
|
+
if (
|
|
132
|
+
typeof userId !== "string" ||
|
|
133
|
+
userId === "" ||
|
|
134
|
+
typeof displayName !== "string" ||
|
|
135
|
+
displayName === "" ||
|
|
136
|
+
typeof role !== "string" ||
|
|
137
|
+
!ROLE_VOCAB.has(role as AwarenessIdentity["role"]) ||
|
|
138
|
+
typeof authorKind !== "string" ||
|
|
139
|
+
!KIND_VOCAB.has(authorKind as AwarenessIdentity["authorKind"])
|
|
140
|
+
) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const identity: AwarenessIdentity = {
|
|
144
|
+
userId,
|
|
145
|
+
displayName,
|
|
146
|
+
role: role as AwarenessIdentity["role"],
|
|
147
|
+
authorKind: authorKind as AwarenessIdentity["authorKind"],
|
|
148
|
+
};
|
|
149
|
+
const collabIdentity = candidate["collabIdentity"];
|
|
150
|
+
if (typeof collabIdentity === "string" && collabIdentity !== "") {
|
|
151
|
+
identity.collabIdentity = collabIdentity;
|
|
152
|
+
}
|
|
153
|
+
const activeStoryId = candidate["activeStoryId"];
|
|
154
|
+
if (typeof activeStoryId === "string" && activeStoryId !== "") {
|
|
155
|
+
identity.activeStoryId = activeStoryId;
|
|
156
|
+
}
|
|
157
|
+
return identity;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validate(identity: AwarenessIdentity): AwarenessIdentity {
|
|
161
|
+
if (!identity.userId) throw new Error("awareness identity: userId required");
|
|
162
|
+
if (!identity.displayName)
|
|
163
|
+
throw new Error("awareness identity: displayName required");
|
|
164
|
+
if (!ROLE_VOCAB.has(identity.role)) {
|
|
165
|
+
throw new Error(`awareness identity: unknown role ${identity.role}`);
|
|
166
|
+
}
|
|
167
|
+
if (!KIND_VOCAB.has(identity.authorKind)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`awareness identity: unknown authorKind ${identity.authorKind}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return identity;
|
|
173
|
+
}
|
|
@@ -83,6 +83,33 @@ export const BROADCAST_COMMAND_TYPES: ReadonlySet<EditorCommand["type"]> = new S
|
|
|
83
83
|
"change.reject",
|
|
84
84
|
"change.accept-all",
|
|
85
85
|
"change.reject-all",
|
|
86
|
+
"workflow.set-overlay",
|
|
87
|
+
"workflow.clear-overlay",
|
|
88
|
+
"workflow.set-metadata-definitions",
|
|
89
|
+
"workflow.clear-metadata-definitions",
|
|
90
|
+
"workflow.set-metadata-entries",
|
|
91
|
+
"workflow.clear-metadata-entries",
|
|
92
|
+
"host-annotation.set-overlay",
|
|
93
|
+
"host-annotation.clear-overlay",
|
|
94
|
+
"formatting.apply",
|
|
95
|
+
"style.set-paragraph",
|
|
96
|
+
"style.set-table",
|
|
97
|
+
"list.toggle",
|
|
98
|
+
"list.indent",
|
|
99
|
+
"list.outdent",
|
|
100
|
+
"list.restart-numbering",
|
|
101
|
+
"list.continue-numbering",
|
|
102
|
+
"table.apply-structure",
|
|
103
|
+
"image.insert",
|
|
104
|
+
"image.set-layout",
|
|
105
|
+
"image.set-frame",
|
|
106
|
+
"section.insert-break",
|
|
107
|
+
"section.delete-break",
|
|
108
|
+
"section.update-layout",
|
|
109
|
+
"section.set-page-numbering",
|
|
110
|
+
"section.set-header-footer-link",
|
|
111
|
+
"content.insert-page-break",
|
|
112
|
+
"content.insert-table",
|
|
86
113
|
]);
|
|
87
114
|
|
|
88
115
|
/**
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CommentNegotiationAction,
|
|
5
|
+
CommentNegotiationSnapshot,
|
|
6
|
+
} from "../api/comment-negotiation-types.ts";
|
|
7
|
+
import type {
|
|
8
|
+
CommentPresentationAction,
|
|
9
|
+
CommentPresentationSnapshot,
|
|
10
|
+
} from "../api/comment-presentation-types.ts";
|
|
11
|
+
import type {
|
|
12
|
+
Participant,
|
|
13
|
+
ParticipantRoster,
|
|
14
|
+
} from "../api/participants-types.ts";
|
|
15
|
+
import {
|
|
16
|
+
createCollabSessionFacet,
|
|
17
|
+
type CollabSessionFacet,
|
|
18
|
+
type DispatchContext,
|
|
19
|
+
type DispatchResult,
|
|
20
|
+
} from "./collab-session-facet.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Event stream emitted by the collab session bridge. Hosts (or
|
|
24
|
+
* `DocumentRuntime` in a later slice) subscribe via
|
|
25
|
+
* `bridge.subscribe(fn)` and re-broadcast into their own event bus.
|
|
26
|
+
*
|
|
27
|
+
* Intentionally additive — this does NOT extend `DocumentRuntimeEvent`.
|
|
28
|
+
* That lets the bridge land today without perturbing the already-
|
|
29
|
+
* sealed `WordReviewEditorEvent` union. When the public-types surface
|
|
30
|
+
* opens up for collab extensions (P8d / P8e), these variants flow
|
|
31
|
+
* through by union.
|
|
32
|
+
*/
|
|
33
|
+
export type CollabSessionEvent =
|
|
34
|
+
| { type: "comment_negotiation_changed"; commentIds: string[] }
|
|
35
|
+
| { type: "comment_presentation_changed"; commentIds: string[] }
|
|
36
|
+
| { type: "participants_changed"; userIds: string[] }
|
|
37
|
+
| { type: "collab_attached" }
|
|
38
|
+
| { type: "collab_detached" };
|
|
39
|
+
|
|
40
|
+
export interface CollabSessionBridge {
|
|
41
|
+
isAttached(): boolean;
|
|
42
|
+
attach(ydoc: Y.Doc): void;
|
|
43
|
+
detach(): void;
|
|
44
|
+
destroy(): void;
|
|
45
|
+
|
|
46
|
+
subscribe(listener: (event: CollabSessionEvent) => void): () => void;
|
|
47
|
+
|
|
48
|
+
getCommentNegotiationSnapshot(): CommentNegotiationSnapshot;
|
|
49
|
+
getCommentPresentationSnapshot(): CommentPresentationSnapshot;
|
|
50
|
+
getParticipantRoster(): ParticipantRoster;
|
|
51
|
+
|
|
52
|
+
dispatchCommentNegotiation(
|
|
53
|
+
action: CommentNegotiationAction,
|
|
54
|
+
ctx: DispatchContext,
|
|
55
|
+
): DispatchResult;
|
|
56
|
+
|
|
57
|
+
dispatchCommentPresentation(
|
|
58
|
+
action: CommentPresentationAction,
|
|
59
|
+
): Promise<DispatchResult>;
|
|
60
|
+
|
|
61
|
+
upsertParticipant(entry: Participant): Participant | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CreateCollabSessionBridgeOptions {
|
|
65
|
+
ydoc?: Y.Doc;
|
|
66
|
+
/**
|
|
67
|
+
* Optional pre-built facet. Primarily for tests. When omitted the
|
|
68
|
+
* bridge creates its own via `createCollabSessionFacet`.
|
|
69
|
+
*/
|
|
70
|
+
facet?: CollabSessionFacet;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createCollabSessionBridge(
|
|
74
|
+
options: CreateCollabSessionBridgeOptions = {},
|
|
75
|
+
): CollabSessionBridge {
|
|
76
|
+
const facet = options.facet ?? createCollabSessionFacet(options.ydoc);
|
|
77
|
+
const listeners = new Set<(event: CollabSessionEvent) => void>();
|
|
78
|
+
const facetSubs = new Set<() => void>();
|
|
79
|
+
|
|
80
|
+
const emit = (event: CollabSessionEvent): void => {
|
|
81
|
+
for (const fn of [...listeners]) fn(event);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const wireFacetSubscriptions = (): void => {
|
|
85
|
+
if (!facet.isAttached()) return;
|
|
86
|
+
facetSubs.add(
|
|
87
|
+
facet.subscribe("negotiation", (commentIds) =>
|
|
88
|
+
emit({ type: "comment_negotiation_changed", commentIds }),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
facetSubs.add(
|
|
92
|
+
facet.subscribe("presentation", (commentIds) =>
|
|
93
|
+
emit({ type: "comment_presentation_changed", commentIds }),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
facetSubs.add(
|
|
97
|
+
facet.subscribe("participants", (userIds) =>
|
|
98
|
+
emit({ type: "participants_changed", userIds }),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const unwireFacetSubscriptions = (): void => {
|
|
104
|
+
for (const off of facetSubs) off();
|
|
105
|
+
facetSubs.clear();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (facet.isAttached()) wireFacetSubscriptions();
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
isAttached: () => facet.isAttached(),
|
|
112
|
+
|
|
113
|
+
attach(ydoc) {
|
|
114
|
+
const wasAttached = facet.isAttached();
|
|
115
|
+
unwireFacetSubscriptions();
|
|
116
|
+
facet.attach(ydoc);
|
|
117
|
+
wireFacetSubscriptions();
|
|
118
|
+
if (!wasAttached) emit({ type: "collab_attached" });
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
detach() {
|
|
122
|
+
if (!facet.isAttached()) return;
|
|
123
|
+
unwireFacetSubscriptions();
|
|
124
|
+
facet.detach();
|
|
125
|
+
emit({ type: "collab_detached" });
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
destroy() {
|
|
129
|
+
unwireFacetSubscriptions();
|
|
130
|
+
facet.destroy();
|
|
131
|
+
listeners.clear();
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
subscribe(listener) {
|
|
135
|
+
listeners.add(listener);
|
|
136
|
+
return () => {
|
|
137
|
+
listeners.delete(listener);
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
getCommentNegotiationSnapshot: () => facet.getCommentNegotiationSnapshot(),
|
|
142
|
+
getCommentPresentationSnapshot: () => facet.getCommentPresentationSnapshot(),
|
|
143
|
+
getParticipantRoster: () => facet.getParticipantRoster(),
|
|
144
|
+
|
|
145
|
+
dispatchCommentNegotiation(action, ctx) {
|
|
146
|
+
return facet.dispatchCommentNegotiation(action, ctx);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async dispatchCommentPresentation(action) {
|
|
150
|
+
return facet.dispatchCommentPresentation(action);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
upsertParticipant(entry) {
|
|
154
|
+
return facet.upsertParticipant(entry);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CommentNegotiationAction,
|
|
5
|
+
CommentNegotiationSnapshot,
|
|
6
|
+
} from "../api/comment-negotiation-types.ts";
|
|
7
|
+
import type {
|
|
8
|
+
CommentPresentationAction,
|
|
9
|
+
CommentPresentationSnapshot,
|
|
10
|
+
} from "../api/comment-presentation-types.ts";
|
|
11
|
+
import type {
|
|
12
|
+
Participant,
|
|
13
|
+
ParticipantRoster,
|
|
14
|
+
} from "../api/participants-types.ts";
|
|
15
|
+
import {
|
|
16
|
+
createNegotiationSync,
|
|
17
|
+
type NegotiationSyncHandle,
|
|
18
|
+
} from "./comment-negotiation-sync.ts";
|
|
19
|
+
import {
|
|
20
|
+
createInitialEntry,
|
|
21
|
+
reduceNegotiation,
|
|
22
|
+
} from "./comment-negotiation.ts";
|
|
23
|
+
import type {
|
|
24
|
+
CollabBlockReason,
|
|
25
|
+
NegotiationRole,
|
|
26
|
+
} from "../api/comment-negotiation-types.ts";
|
|
27
|
+
import {
|
|
28
|
+
createPresentationStore,
|
|
29
|
+
type PresentationStoreHandle,
|
|
30
|
+
} from "./comment-presentation.ts";
|
|
31
|
+
import {
|
|
32
|
+
createParticipantRoster,
|
|
33
|
+
type ParticipantRosterStore,
|
|
34
|
+
} from "./participants.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bundle of the three Y.Map-backed collab stores.
|
|
38
|
+
*
|
|
39
|
+
* Lazy: if no `Y.Doc` is attached the facet is "detached" — every
|
|
40
|
+
* snapshot returns an empty shape and every dispatch is a no-op that
|
|
41
|
+
* resolves without mutating anything. This lets `DocumentRuntime`
|
|
42
|
+
* expose the surface unconditionally without forcing hosts that don't
|
|
43
|
+
* want collab to supply a `Y.Doc`.
|
|
44
|
+
*
|
|
45
|
+
* Connect / disconnect is idempotent. Reconnecting with a different
|
|
46
|
+
* `Y.Doc` tears down the existing subscriptions first.
|
|
47
|
+
*/
|
|
48
|
+
export interface CollabSessionFacet {
|
|
49
|
+
isAttached(): boolean;
|
|
50
|
+
|
|
51
|
+
getCommentNegotiationSnapshot(): CommentNegotiationSnapshot;
|
|
52
|
+
getCommentPresentationSnapshot(): CommentPresentationSnapshot;
|
|
53
|
+
getParticipantRoster(): ParticipantRoster;
|
|
54
|
+
|
|
55
|
+
dispatchCommentNegotiation(
|
|
56
|
+
action: CommentNegotiationAction,
|
|
57
|
+
ctx: DispatchContext,
|
|
58
|
+
): DispatchResult;
|
|
59
|
+
|
|
60
|
+
dispatchCommentPresentation(
|
|
61
|
+
action: CommentPresentationAction,
|
|
62
|
+
): Promise<DispatchResult>;
|
|
63
|
+
|
|
64
|
+
upsertParticipant(entry: Participant): Participant | undefined;
|
|
65
|
+
|
|
66
|
+
subscribe(
|
|
67
|
+
kind: "negotiation" | "presentation" | "participants",
|
|
68
|
+
fn: (changedIds: string[]) => void,
|
|
69
|
+
): () => void;
|
|
70
|
+
|
|
71
|
+
attach(ydoc: Y.Doc): void;
|
|
72
|
+
detach(): void;
|
|
73
|
+
destroy(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DispatchContext {
|
|
77
|
+
role: NegotiationRole;
|
|
78
|
+
now: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type DispatchResult =
|
|
82
|
+
| { ok: true }
|
|
83
|
+
| { ok: false; reason: CollabBlockReason };
|
|
84
|
+
|
|
85
|
+
const EMPTY_NEGOTIATION: CommentNegotiationSnapshot = {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
entries: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const EMPTY_PRESENTATION: CommentPresentationSnapshot = {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
entries: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const EMPTY_ROSTER: ParticipantRoster = {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
entries: [],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
interface AttachedStores {
|
|
101
|
+
ydoc: Y.Doc;
|
|
102
|
+
negotiation: NegotiationSyncHandle;
|
|
103
|
+
presentation: PresentationStoreHandle;
|
|
104
|
+
participants: ParticipantRosterStore;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createCollabSessionFacet(
|
|
108
|
+
initialYdoc?: Y.Doc,
|
|
109
|
+
): CollabSessionFacet {
|
|
110
|
+
let stores: AttachedStores | null = null;
|
|
111
|
+
let destroyed = false;
|
|
112
|
+
|
|
113
|
+
const unwire = (): void => {
|
|
114
|
+
if (!stores) return;
|
|
115
|
+
stores.negotiation.destroy();
|
|
116
|
+
stores.presentation.destroy();
|
|
117
|
+
stores.participants.destroy();
|
|
118
|
+
stores = null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const wire = (ydoc: Y.Doc): void => {
|
|
122
|
+
if (destroyed) return;
|
|
123
|
+
unwire();
|
|
124
|
+
stores = {
|
|
125
|
+
ydoc,
|
|
126
|
+
negotiation: createNegotiationSync(ydoc),
|
|
127
|
+
presentation: createPresentationStore(ydoc),
|
|
128
|
+
participants: createParticipantRoster(ydoc),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (initialYdoc) wire(initialYdoc);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
isAttached: () => stores !== null,
|
|
136
|
+
|
|
137
|
+
getCommentNegotiationSnapshot: () =>
|
|
138
|
+
stores ? stores.negotiation.snapshot() : EMPTY_NEGOTIATION,
|
|
139
|
+
|
|
140
|
+
getCommentPresentationSnapshot: () =>
|
|
141
|
+
stores ? stores.presentation.snapshot() : EMPTY_PRESENTATION,
|
|
142
|
+
|
|
143
|
+
getParticipantRoster: () =>
|
|
144
|
+
stores ? stores.participants.snapshot() : EMPTY_ROSTER,
|
|
145
|
+
|
|
146
|
+
dispatchCommentNegotiation(action, ctx) {
|
|
147
|
+
if (!stores) return { ok: false, reason: "collab_not_attached" };
|
|
148
|
+
const prev =
|
|
149
|
+
stores.negotiation.readEntry(action.commentId) ??
|
|
150
|
+
createInitialEntry(action.commentId);
|
|
151
|
+
const result = reduceNegotiation(prev, action, ctx);
|
|
152
|
+
if (!result.ok) return { ok: false, reason: result.reason };
|
|
153
|
+
stores.negotiation.writeEntry(result.entry);
|
|
154
|
+
return { ok: true };
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async dispatchCommentPresentation(action) {
|
|
158
|
+
if (!stores) return { ok: false, reason: "collab_not_attached" };
|
|
159
|
+
await stores.presentation.apply(action);
|
|
160
|
+
return { ok: true };
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
upsertParticipant(entry) {
|
|
164
|
+
if (!stores) return undefined;
|
|
165
|
+
return stores.participants.upsert(entry);
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
subscribe(kind, fn) {
|
|
169
|
+
if (!stores) return () => {};
|
|
170
|
+
switch (kind) {
|
|
171
|
+
case "negotiation":
|
|
172
|
+
return stores.negotiation.subscribe(fn);
|
|
173
|
+
case "presentation":
|
|
174
|
+
return stores.presentation.subscribe(fn);
|
|
175
|
+
case "participants":
|
|
176
|
+
return stores.participants.subscribe(fn);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
attach(ydoc) {
|
|
181
|
+
wire(ydoc);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
detach() {
|
|
185
|
+
unwire();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
destroy() {
|
|
189
|
+
destroyed = true;
|
|
190
|
+
unwire();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|