@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.
Files changed (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -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
+ }
@@ -0,0 +1,273 @@
1
+ import type * as Y from "yjs";
2
+ import type { Awareness } from "y-protocols/awareness";
3
+
4
+ import type {
5
+ AwarenessIdentity,
6
+ CollabPosture,
7
+ PresenceSnapshot,
8
+ TransportStatus,
9
+ } from "../api/awareness-identity-types.ts";
10
+ import type {
11
+ ExternalCustodyResolver,
12
+ } from "../api/external-custody-types.ts";
13
+ import type {
14
+ PayloadSignature,
15
+ PayloadSigner,
16
+ PayloadVerifier,
17
+ } from "../io/ooxml/payload-signature.ts";
18
+ import {
19
+ clearLocalIdentity,
20
+ getCollabPosture,
21
+ getPresenceSnapshot,
22
+ setLocalIdentity,
23
+ } from "./awareness-identity.ts";
24
+ import {
25
+ createCollabSessionBridge,
26
+ type CollabSessionBridge,
27
+ type CollabSessionEvent,
28
+ } from "./collab-session-bridge.ts";
29
+ import type { CommentNegotiationAction } from "../api/comment-negotiation-types.ts";
30
+ import type { Participant } from "../api/participants-types.ts";
31
+ import type {
32
+ RuntimeSendToExternalArgs,
33
+ RuntimeSendToExternalResult,
34
+ } from "./external-send-runtime.ts";
35
+ import { runtimeSendToExternal } from "./external-send-runtime.ts";
36
+ import {
37
+ createTamperGate,
38
+ type MetadataIntegrity,
39
+ type TamperGate,
40
+ type TamperGateEvent,
41
+ } from "./tamper-gate.ts";
42
+
43
+ /**
44
+ * Event union emitted by the session. Merges the bridge's event
45
+ * stream with the tamper-gate stream into a single typed channel so
46
+ * hosts subscribe once.
47
+ */
48
+ export type CollabSessionEventOrIntegrity =
49
+ | CollabSessionEvent
50
+ | TamperGateEvent;
51
+
52
+ export interface CollabSessionOptions {
53
+ ydoc?: Y.Doc;
54
+ awareness?: Awareness;
55
+ identity?: AwarenessIdentity;
56
+ signer: PayloadSigner;
57
+ verifier?: PayloadVerifier;
58
+ }
59
+
60
+ export interface AttachPayloadArgs {
61
+ payloadXml: string;
62
+ signature: PayloadSignature | undefined;
63
+ }
64
+
65
+ export type SendToExternalCallArgs = Omit<
66
+ RuntimeSendToExternalArgs,
67
+ "bridge" | "tamperGate" | "signer" | "role" | "resolver"
68
+ > & {
69
+ role?: "author" | "reviewer" | "observer";
70
+ };
71
+
72
+ /**
73
+ * Unified collab session — composes the P8a–P8f slices so hosts call
74
+ * `createCollabSession(...)` instead of wiring the bridge, tamper
75
+ * gate, resolver and identity channel by hand.
76
+ */
77
+ export interface CollabSession {
78
+ readonly bridge: CollabSessionBridge;
79
+ readonly tamperGate: TamperGate;
80
+
81
+ isAttached(): boolean;
82
+ getMetadataIntegrity(): MetadataIntegrity;
83
+
84
+ attach(args: {
85
+ ydoc?: Y.Doc;
86
+ awareness?: Awareness;
87
+ identity?: AwarenessIdentity;
88
+ payload?: AttachPayloadArgs;
89
+ }): Promise<void>;
90
+ detach(): void;
91
+ destroy(): void;
92
+
93
+ // Negotiation + presentation + roster
94
+ dispatchCommentNegotiation: CollabSessionBridge["dispatchCommentNegotiation"];
95
+ dispatchCommentPresentation: CollabSessionBridge["dispatchCommentPresentation"];
96
+ upsertParticipant(entry: Participant): Participant | undefined;
97
+
98
+ getCommentNegotiationSnapshot: CollabSessionBridge["getCommentNegotiationSnapshot"];
99
+ getCommentPresentationSnapshot: CollabSessionBridge["getCommentPresentationSnapshot"];
100
+ getParticipantRoster: CollabSessionBridge["getParticipantRoster"];
101
+
102
+ // Presence + posture
103
+ setLocalIdentity(identity: AwarenessIdentity): void;
104
+ clearLocalIdentity(): void;
105
+ getPresenceSnapshot(opts?: {
106
+ transportStatus?: TransportStatus;
107
+ queuedLocalEvents?: number;
108
+ activeStoryFilter?: string;
109
+ }): PresenceSnapshot;
110
+ getCollabPosture(opts?: { transportStatus?: TransportStatus }): CollabPosture;
111
+
112
+ // Tamper gate
113
+ acknowledgeMetadataTampering(): void;
114
+
115
+ // External-send
116
+ registerExternalCustodyResolver(resolver: ExternalCustodyResolver | undefined): void;
117
+ sendToExternal(args: SendToExternalCallArgs): Promise<RuntimeSendToExternalResult>;
118
+
119
+ subscribe(listener: (event: CollabSessionEventOrIntegrity) => void): () => void;
120
+ }
121
+
122
+ type NegotiationAction = CommentNegotiationAction;
123
+
124
+ export function createCollabSession(
125
+ options: CollabSessionOptions,
126
+ ): CollabSession {
127
+ const bridge = createCollabSessionBridge(
128
+ options.ydoc ? { ydoc: options.ydoc } : {},
129
+ );
130
+ const tamperGate = createTamperGate(
131
+ options.verifier ? { verifier: options.verifier } : {},
132
+ );
133
+
134
+ let awareness: Awareness | undefined = options.awareness;
135
+ let resolver: ExternalCustodyResolver | undefined;
136
+ let destroyed = false;
137
+
138
+ const listeners = new Set<(event: CollabSessionEventOrIntegrity) => void>();
139
+ const emit = (event: CollabSessionEventOrIntegrity): void => {
140
+ for (const fn of [...listeners]) fn(event);
141
+ };
142
+ bridge.subscribe(emit);
143
+ tamperGate.subscribe(emit);
144
+
145
+ if (options.identity && awareness) {
146
+ setLocalIdentity(awareness, options.identity);
147
+ }
148
+
149
+ return {
150
+ bridge,
151
+ tamperGate,
152
+
153
+ isAttached: () => bridge.isAttached(),
154
+ getMetadataIntegrity: () => tamperGate.state,
155
+
156
+ async attach(args) {
157
+ if (destroyed) return;
158
+ if (args.ydoc) bridge.attach(args.ydoc);
159
+ if (args.awareness) awareness = args.awareness;
160
+ if (args.identity && awareness) {
161
+ setLocalIdentity(awareness, args.identity);
162
+ }
163
+ if (args.payload) {
164
+ await tamperGate.attach(args.payload);
165
+ }
166
+ },
167
+
168
+ detach() {
169
+ bridge.detach();
170
+ if (awareness) clearLocalIdentity(awareness);
171
+ tamperGate.detach();
172
+ },
173
+
174
+ destroy() {
175
+ destroyed = true;
176
+ bridge.destroy();
177
+ tamperGate.destroy();
178
+ listeners.clear();
179
+ },
180
+
181
+ dispatchCommentNegotiation(action: NegotiationAction, ctx) {
182
+ return bridge.dispatchCommentNegotiation(action, ctx);
183
+ },
184
+
185
+ async dispatchCommentPresentation(action) {
186
+ return bridge.dispatchCommentPresentation(action);
187
+ },
188
+
189
+ upsertParticipant(entry) {
190
+ return bridge.upsertParticipant(entry);
191
+ },
192
+
193
+ getCommentNegotiationSnapshot: () => bridge.getCommentNegotiationSnapshot(),
194
+ getCommentPresentationSnapshot: () => bridge.getCommentPresentationSnapshot(),
195
+ getParticipantRoster: () => bridge.getParticipantRoster(),
196
+
197
+ setLocalIdentity(identity) {
198
+ if (!awareness) {
199
+ throw new Error("collab session: no awareness attached");
200
+ }
201
+ setLocalIdentity(awareness, identity);
202
+ },
203
+
204
+ clearLocalIdentity() {
205
+ if (!awareness) return;
206
+ clearLocalIdentity(awareness);
207
+ },
208
+
209
+ getPresenceSnapshot(opts = {}) {
210
+ const args: Parameters<typeof getPresenceSnapshot>[0] = {};
211
+ if (awareness) args.awareness = awareness;
212
+ if (opts.transportStatus) args.transportStatus = opts.transportStatus;
213
+ if (opts.queuedLocalEvents !== undefined) {
214
+ args.queuedLocalEvents = opts.queuedLocalEvents;
215
+ }
216
+ if (opts.activeStoryFilter !== undefined) {
217
+ args.activeStoryFilter = opts.activeStoryFilter;
218
+ }
219
+ return getPresenceSnapshot(args);
220
+ },
221
+
222
+ getCollabPosture(opts = {}) {
223
+ const args: Parameters<typeof getCollabPosture>[0] = {};
224
+ if (awareness) args.awareness = awareness;
225
+ if (opts.transportStatus) args.transportStatus = opts.transportStatus;
226
+ return getCollabPosture(args);
227
+ },
228
+
229
+ acknowledgeMetadataTampering() {
230
+ tamperGate.acknowledge();
231
+ },
232
+
233
+ registerExternalCustodyResolver(next) {
234
+ resolver = next;
235
+ },
236
+
237
+ async sendToExternal(args) {
238
+ if (!resolver) {
239
+ throw new Error(
240
+ "collab session: registerExternalCustodyResolver(...) not called",
241
+ );
242
+ }
243
+ const resolvedRole =
244
+ args.role ??
245
+ (awareness
246
+ ? getCollabPosture({ awareness }).role
247
+ : "author");
248
+ return runtimeSendToExternal({
249
+ bridge,
250
+ tamperGate,
251
+ signer: options.signer,
252
+ payloadXml: args.payloadXml,
253
+ role: resolvedRole,
254
+ originDocumentId: args.originDocumentId,
255
+ originPayloadId: args.originPayloadId,
256
+ originContentHash: args.originContentHash,
257
+ resolver,
258
+ recipient: args.recipient,
259
+ sentBy: args.sentBy,
260
+ archiveRef: args.archiveRef,
261
+ ...(args.custodyId !== undefined ? { custodyId: args.custodyId } : {}),
262
+ ...(args.now !== undefined ? { now: args.now } : {}),
263
+ });
264
+ },
265
+
266
+ subscribe(listener) {
267
+ listeners.add(listener);
268
+ return () => {
269
+ listeners.delete(listener);
270
+ };
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,91 @@
1
+ import * as Y from "yjs";
2
+
3
+ import type {
4
+ CommentNegotiationEntry,
5
+ CommentNegotiationSnapshot,
6
+ } from "../api/comment-negotiation-types.ts";
7
+
8
+ export interface NegotiationSyncHandle {
9
+ readEntry(commentId: string): CommentNegotiationEntry | undefined;
10
+ writeEntry(entry: CommentNegotiationEntry): void;
11
+ snapshot(): CommentNegotiationSnapshot;
12
+ subscribe(fn: (changedIds: string[]) => void): () => void;
13
+ destroy(): void;
14
+ }
15
+
16
+ const MAP_KEY = "commentNegotiation";
17
+
18
+ export function createNegotiationSync(ydoc: Y.Doc): NegotiationSyncHandle {
19
+ const yMap = ydoc.getMap<CommentNegotiationEntry>(MAP_KEY);
20
+ const listeners = new Set<(ids: string[]) => void>();
21
+
22
+ const onChange = (event: Y.YMapEvent<CommentNegotiationEntry>): void => {
23
+ if (listeners.size === 0) return;
24
+ const ids = Array.from(event.keysChanged);
25
+ for (const fn of listeners) fn(ids);
26
+ };
27
+ yMap.observe(onChange);
28
+
29
+ return {
30
+ readEntry(commentId) {
31
+ const entry = yMap.get(commentId);
32
+ return entry ? cloneEntry(entry) : undefined;
33
+ },
34
+ writeEntry(entry) {
35
+ yMap.set(entry.commentId, cloneEntry(entry));
36
+ },
37
+ snapshot() {
38
+ return {
39
+ schemaVersion: 1,
40
+ entries: Array.from(yMap.values()).map(cloneEntry),
41
+ };
42
+ },
43
+ subscribe(fn) {
44
+ listeners.add(fn);
45
+ return () => {
46
+ listeners.delete(fn);
47
+ };
48
+ },
49
+ destroy() {
50
+ yMap.unobserve(onChange);
51
+ listeners.clear();
52
+ },
53
+ };
54
+ }
55
+
56
+ function cloneEntry(entry: CommentNegotiationEntry): CommentNegotiationEntry {
57
+ const clone: CommentNegotiationEntry = {
58
+ commentId: entry.commentId,
59
+ state: entry.state,
60
+ requiredApprovers: [...entry.requiredApprovers],
61
+ votes: entry.votes.map((v) => ({ ...v })),
62
+ counterProposals: entry.counterProposals.map((p) => {
63
+ const copy: CommentNegotiationEntry["counterProposals"][number] = {
64
+ id: p.id,
65
+ authorId: p.authorId,
66
+ createdAt: p.createdAt,
67
+ body: p.body,
68
+ };
69
+ if (p.proposedRangeEdit) copy.proposedRangeEdit = { ...p.proposedRangeEdit };
70
+ if (p.supersededBy !== undefined) copy.supersededBy = p.supersededBy;
71
+ return copy;
72
+ }),
73
+ history: entry.history.map((h) => {
74
+ const row: CommentNegotiationEntry["history"][number] = {
75
+ from: h.from,
76
+ to: h.to,
77
+ actorId: h.actorId,
78
+ at: h.at,
79
+ action: h.action,
80
+ };
81
+ if (h.reasonCode !== undefined) row.reasonCode = h.reasonCode;
82
+ return row;
83
+ }),
84
+ };
85
+ if (entry.acceptedProposalId !== undefined) {
86
+ clone.acceptedProposalId = entry.acceptedProposalId;
87
+ }
88
+ if (entry.lockedAt !== undefined) clone.lockedAt = entry.lockedAt;
89
+ if (entry.lockedBy !== undefined) clone.lockedBy = entry.lockedBy;
90
+ return clone;
91
+ }