@beyondwork/docx-react-component 1.0.47 → 1.0.49
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/README.md +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +199 -13
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +103 -7
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +59 -1
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +46 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
- package/src/io/ooxml/chart/parse-series.ts +635 -0
- package/src/io/ooxml/chart/resolve-color.ts +261 -0
- package/src/io/ooxml/chart/types.ts +439 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +90 -2
- package/src/io/ooxml/parse-main-document.ts +156 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +160 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +29 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +330 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +288 -65
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +102 -37
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +48 -2
- package/src/ui/editor-runtime-boundary.ts +42 -1
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
|
@@ -17,8 +17,44 @@ import {
|
|
|
17
17
|
createCommandEvent,
|
|
18
18
|
isBroadcastCommand,
|
|
19
19
|
isLocalOnlyCommand,
|
|
20
|
+
COMMAND_EVENT_SCHEMA_VERSION,
|
|
20
21
|
type CommandEvent,
|
|
21
22
|
} from "./event-types.ts";
|
|
23
|
+
import { computeBaseDocFingerprint } from "./base-doc-fingerprint.ts";
|
|
24
|
+
import type { Checkpoint } from "./checkpoint-store.ts";
|
|
25
|
+
import { createWorkflowShared, type WorkflowSharedHandle } from "./workflow-shared.ts";
|
|
26
|
+
|
|
27
|
+
/** Shared Y.Map key — {@link SHARED_META_MAP_KEY}. */
|
|
28
|
+
const SHARED_META_MAP_KEY = "meta";
|
|
29
|
+
const META_BASE_DOC_HASH_KEY = "baseDocHash";
|
|
30
|
+
const META_SCHEMA_VERSION_KEY = "schemaVersion";
|
|
31
|
+
const META_CREATED_AT_KEY = "createdAt";
|
|
32
|
+
const CHECKPOINTS_KEY = "checkpoints";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lifecycle + correctness events surfaced by a
|
|
36
|
+
* {@link RuntimeCollabSyncHandle} subscription. Emitted in response to
|
|
37
|
+
* attach-path checks (base-doc fingerprint) and inbound replay filtering
|
|
38
|
+
* (command-event schema version). Hosts subscribe once and react to the
|
|
39
|
+
* whole stream.
|
|
40
|
+
*
|
|
41
|
+
* - `collab_sync_attached` — fires once after a successful attach with
|
|
42
|
+
* the computed `baseDocFingerprint`. Safe to ignore; surfaced so hosts
|
|
43
|
+
* can log a "session started" event or cache the fingerprint for a
|
|
44
|
+
* subsequent peer-to-peer connect.
|
|
45
|
+
* - `collab_base_doc_mismatch` — fires when the shared `meta.baseDocHash`
|
|
46
|
+
* disagrees with the local runtime's fingerprint. The sync transitions
|
|
47
|
+
* to read-only (inbound replay dropped, outbound broadcast suppressed)
|
|
48
|
+
* until the host detaches and re-attaches with a matching base doc.
|
|
49
|
+
* - `collab_event_schema_mismatch` — fires when an inbound
|
|
50
|
+
* `CommandEvent` carries a `schemaVersion` other than
|
|
51
|
+
* {@link COMMAND_EVENT_SCHEMA_VERSION}. The event is skipped; other
|
|
52
|
+
* events in the log continue to replay.
|
|
53
|
+
*/
|
|
54
|
+
export type RuntimeCollabSyncEvent =
|
|
55
|
+
| { type: "collab_sync_attached"; baseDocFingerprint: string | null }
|
|
56
|
+
| { type: "collab_base_doc_mismatch"; expected: string; actual: string }
|
|
57
|
+
| { type: "collab_event_schema_mismatch"; eventId: string; receivedSchemaVersion: unknown };
|
|
22
58
|
|
|
23
59
|
const PATCHABLE_TOP_LEVEL_KEYS = [
|
|
24
60
|
"updatedAt",
|
|
@@ -153,10 +189,54 @@ export interface RuntimeCollabSyncOptions {
|
|
|
153
189
|
runtime: DocumentRuntime;
|
|
154
190
|
authorId: string;
|
|
155
191
|
commandAppliedBridge: RuntimeCommandAppliedBridge;
|
|
192
|
+
/**
|
|
193
|
+
* Role of the local peer for workflow-shared gating. Defaults to `"author"`
|
|
194
|
+
* for backward compatibility with pre-P13 callers — ⚠ OMITTING THIS FIELD
|
|
195
|
+
* GRANTS AUTHOR-LEVEL WRITE ACCESS to the shared `workflow` Y.Map. Hosts
|
|
196
|
+
* that derive role from Awareness or an external auth layer should pass
|
|
197
|
+
* it explicitly to avoid silently promoting reviewers/observers to authors.
|
|
198
|
+
* - `"author"`: all workflow writes allowed.
|
|
199
|
+
* - `"reviewer"`: only `setAssignedReviewers` allowed; other writes refused.
|
|
200
|
+
* - `"observer"`: all writes refused with `collab_observer_readonly`.
|
|
201
|
+
*/
|
|
202
|
+
role?: "author" | "reviewer" | "observer";
|
|
156
203
|
}
|
|
157
204
|
|
|
158
205
|
export interface RuntimeCollabSyncHandle {
|
|
159
206
|
destroy(): void;
|
|
207
|
+
/**
|
|
208
|
+
* Subscribe to lifecycle + correctness events. Returns an
|
|
209
|
+
* unsubscribe function.
|
|
210
|
+
*/
|
|
211
|
+
subscribe(listener: (event: RuntimeCollabSyncEvent) => void): () => void;
|
|
212
|
+
/**
|
|
213
|
+
* Returns the base-doc fingerprint this sync either seeded (first peer
|
|
214
|
+
* to attach) or verified against (subsequent peers). `null` before the
|
|
215
|
+
* attach-path fingerprint check wires up in commit 2 of the P11
|
|
216
|
+
* ship, or when the fingerprint check has not yet run.
|
|
217
|
+
*/
|
|
218
|
+
getBaseDocFingerprint(): string | null;
|
|
219
|
+
/**
|
|
220
|
+
* `true` when the sync has transitioned to read-only (either a
|
|
221
|
+
* base-doc mismatch or a future shutdown condition). Read-only syncs
|
|
222
|
+
* neither broadcast outbound commands nor replay inbound events.
|
|
223
|
+
*/
|
|
224
|
+
isReadOnly(): boolean;
|
|
225
|
+
/**
|
|
226
|
+
* Current size of the internal `appliedEventIds` dedup set. Exposed
|
|
227
|
+
* for observability + tests — hosts can watch it to spot runaway
|
|
228
|
+
* growth (the checkpoint pruning path shrinks it when a new
|
|
229
|
+
* checkpoint covers previously-seen events).
|
|
230
|
+
*/
|
|
231
|
+
getAppliedEventCount(): number;
|
|
232
|
+
/**
|
|
233
|
+
* Returns the shared workflow handle backing the `workflow` Y.Map.
|
|
234
|
+
* Hosts can call `.setLockedMode()`, `.setRoundDeadline()`,
|
|
235
|
+
* `.setAssignedReviewers()`, and `.setWorkItemId()` to propagate
|
|
236
|
+
* state changes to other peers. Role gating (passed in `options.role`)
|
|
237
|
+
* enforces write permissions per §7 of the lane plan.
|
|
238
|
+
*/
|
|
239
|
+
getWorkflowShared(): WorkflowSharedHandle;
|
|
160
240
|
}
|
|
161
241
|
|
|
162
242
|
export function createRuntimeCommandAppliedBridge(): RuntimeCommandAppliedBridge {
|
|
@@ -184,7 +264,123 @@ export function createRuntimeCollabSync(
|
|
|
184
264
|
const yEvents = ydoc.getArray<CommandEvent>("commandEvents");
|
|
185
265
|
const appliedEventIds = new Set<string>();
|
|
186
266
|
|
|
267
|
+
const listeners = new Set<(event: RuntimeCollabSyncEvent) => void>();
|
|
268
|
+
let readOnly = false;
|
|
269
|
+
let baseDocFingerprint: string | null = null;
|
|
270
|
+
|
|
271
|
+
// Events emitted before any subscriber exists are buffered and flushed
|
|
272
|
+
// to the first subscriber. This lets hosts react to the attach-path
|
|
273
|
+
// base-doc mismatch and schema-version mismatch for pre-existing
|
|
274
|
+
// events — both fire synchronously during `createRuntimeCollabSync`,
|
|
275
|
+
// before the caller has had a chance to call `sync.subscribe(...)`.
|
|
276
|
+
// Buffering is one-shot: subsequent subscribers see only live events.
|
|
277
|
+
const bufferedEvents: RuntimeCollabSyncEvent[] = [];
|
|
278
|
+
let bufferFlushed = false;
|
|
279
|
+
|
|
280
|
+
function emit(event: RuntimeCollabSyncEvent): void {
|
|
281
|
+
if (!bufferFlushed && listeners.size === 0) {
|
|
282
|
+
bufferedEvents.push(event);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Snapshot to avoid re-entrant mutation during dispatch.
|
|
286
|
+
for (const listener of [...listeners]) {
|
|
287
|
+
try {
|
|
288
|
+
listener(event);
|
|
289
|
+
} catch {
|
|
290
|
+
// Listener exceptions are isolated; the sync continues.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Compute the local base-doc fingerprint once at attach time and
|
|
296
|
+
// seed-or-verify the shared `meta` Y.Map. First peer to attach to a
|
|
297
|
+
// fresh Y.Doc seeds `{ baseDocHash, schemaVersion, createdAt }`;
|
|
298
|
+
// subsequent peers verify their own fingerprint matches and transition
|
|
299
|
+
// to read-only on divergence.
|
|
300
|
+
const yMeta = ydoc.getMap<unknown>(SHARED_META_MAP_KEY);
|
|
301
|
+
const yCheckpoints = ydoc.getArray<Checkpoint>(CHECKPOINTS_KEY);
|
|
302
|
+
baseDocFingerprint = computeBaseDocFingerprint(runtime.getSessionState().canonicalDocument);
|
|
303
|
+
|
|
304
|
+
function latestCheckpointFingerprint(): string | null {
|
|
305
|
+
if (yCheckpoints.length === 0) return null;
|
|
306
|
+
const latest = yCheckpoints.get(yCheckpoints.length - 1) as Checkpoint | undefined;
|
|
307
|
+
if (!latest || !latest.documentAtCheckpoint) return null;
|
|
308
|
+
return computeBaseDocFingerprint(latest.documentAtCheckpoint);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function checkFingerprintAgainstMeta(): void {
|
|
312
|
+
if (readOnly) return;
|
|
313
|
+
const stored = yMeta.get(META_BASE_DOC_HASH_KEY);
|
|
314
|
+
if (typeof stored !== "string") return;
|
|
315
|
+
if (baseDocFingerprint === null) return;
|
|
316
|
+
if (stored === baseDocFingerprint) return;
|
|
317
|
+
// Joiner-with-checkpoint path: if the local runtime's fingerprint
|
|
318
|
+
// matches the latest checkpoint's fingerprint, accept the attach —
|
|
319
|
+
// the joiner legitimately booted from a mid-session snapshot.
|
|
320
|
+
// `meta.baseDocHash` still reflects the original base doc, which
|
|
321
|
+
// the joiner does not (and should not) reconstruct.
|
|
322
|
+
const latestCpFingerprint = latestCheckpointFingerprint();
|
|
323
|
+
if (latestCpFingerprint !== null && latestCpFingerprint === baseDocFingerprint) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
readOnly = true;
|
|
327
|
+
emit({
|
|
328
|
+
type: "collab_base_doc_mismatch",
|
|
329
|
+
expected: stored,
|
|
330
|
+
actual: baseDocFingerprint,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
ydoc.transact(() => {
|
|
335
|
+
if (yMeta.size === 0) {
|
|
336
|
+
yMeta.set(META_BASE_DOC_HASH_KEY, baseDocFingerprint);
|
|
337
|
+
yMeta.set(META_SCHEMA_VERSION_KEY, COMMAND_EVENT_SCHEMA_VERSION);
|
|
338
|
+
yMeta.set(META_CREATED_AT_KEY, new Date().toISOString());
|
|
339
|
+
} else {
|
|
340
|
+
checkFingerprintAgainstMeta();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Watch for late-arriving divergent writes — covers the race case
|
|
345
|
+
// where two peers both saw an empty `meta` and both seeded their own
|
|
346
|
+
// fingerprint, plus the case where an out-of-band peer rewrites the
|
|
347
|
+
// hash after attach. Own-writes that match our fingerprint are no-ops
|
|
348
|
+
// (stored === baseDocFingerprint short-circuits).
|
|
349
|
+
yMeta.observe(checkFingerprintAgainstMeta);
|
|
350
|
+
|
|
351
|
+
if (!readOnly) {
|
|
352
|
+
emit({ type: "collab_sync_attached", baseDocFingerprint });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Workflow shared state — P13 Slice C
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Construct a WorkflowSharedHandle over `ydoc.getMap("workflow")`. The
|
|
359
|
+
// handle subscribes to Y.Map changes and propagates them to the runtime
|
|
360
|
+
// via `setSharedWorkflowState`. Role gating is enforced by the handle
|
|
361
|
+
// itself; the `role` option defaults to `"author"` for historical compat.
|
|
362
|
+
const effectiveRole = options.role ?? "author";
|
|
363
|
+
const workflowShared = createWorkflowShared({
|
|
364
|
+
ydoc,
|
|
365
|
+
role: effectiveRole,
|
|
366
|
+
localAuthorId: authorId,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Seed initial state synchronously. For a fresh Y.Doc this is `{}`.
|
|
370
|
+
// For a late joiner it may already be populated — the seed ensures the
|
|
371
|
+
// runtime reflects pre-existing shared state at attach time (i.e. if a
|
|
372
|
+
// peer already set `lockedMode`, the new peer starts out locked).
|
|
373
|
+
runtime.setSharedWorkflowState(workflowShared.get());
|
|
374
|
+
|
|
375
|
+
const workflowUnsub = workflowShared.subscribe((state) => {
|
|
376
|
+
if (readOnly) return; // don't propagate while in read-only (post-mismatch)
|
|
377
|
+
runtime.setSharedWorkflowState(state);
|
|
378
|
+
});
|
|
379
|
+
|
|
187
380
|
const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
|
|
381
|
+
if (readOnly) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
188
384
|
if (isLocalOnlyCommand(command)) {
|
|
189
385
|
return;
|
|
190
386
|
}
|
|
@@ -216,14 +412,125 @@ export function createRuntimeCollabSync(
|
|
|
216
412
|
|
|
217
413
|
yEvents.observe(onYEventsChange);
|
|
218
414
|
|
|
415
|
+
// Checkpoint-aware startup replay: if there's a checkpoint in the
|
|
416
|
+
// shared log, seed `appliedEventIds` with every event up to and
|
|
417
|
+
// including `latestCheckpoint.afterEventId` so the startup loop
|
|
418
|
+
// skips them. The caller is expected to have created `runtime` with
|
|
419
|
+
// `initialCanonicalDocument = latestCheckpoint.documentAtCheckpoint`
|
|
420
|
+
// so the snapshot + tail replay reproduces the author's state.
|
|
421
|
+
//
|
|
422
|
+
// If the `afterEventId` is not found in the current log (e.g. an
|
|
423
|
+
// out-of-order merge or a corrupt checkpoint), no events are
|
|
424
|
+
// seeded — the startup loop falls back to full replay, which is
|
|
425
|
+
// wasteful but safe thanks to the existing dedup.
|
|
426
|
+
if (yCheckpoints.length > 0) {
|
|
427
|
+
const latestCheckpoint = yCheckpoints.get(yCheckpoints.length - 1);
|
|
428
|
+
const targetAfterEventId = latestCheckpoint?.afterEventId;
|
|
429
|
+
if (typeof targetAfterEventId === "string") {
|
|
430
|
+
const events = yEvents.toArray();
|
|
431
|
+
let found = false;
|
|
432
|
+
for (const value of events) {
|
|
433
|
+
const eventId = (value as { eventId?: unknown })?.eventId;
|
|
434
|
+
if (typeof eventId !== "string") continue;
|
|
435
|
+
appliedEventIds.add(eventId);
|
|
436
|
+
if (eventId === targetAfterEventId) {
|
|
437
|
+
found = true;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (!found) {
|
|
442
|
+
// Fallback: clear the seeded ids so startup replay can proceed
|
|
443
|
+
// across the full log. Safe because the runtime was created
|
|
444
|
+
// from some canonical base (either a matching snapshot or the
|
|
445
|
+
// original doc) and the commit path is idempotent via
|
|
446
|
+
// `appliedEventIds` dedup for the live channel anyway.
|
|
447
|
+
appliedEventIds.clear();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
219
452
|
for (const value of yEvents.toArray()) {
|
|
220
453
|
applyStartupEvent(value);
|
|
221
454
|
}
|
|
222
455
|
|
|
456
|
+
// When a new checkpoint lands, prune `appliedEventIds` of every
|
|
457
|
+
// event up to and including the checkpoint's `afterEventId`. Those
|
|
458
|
+
// ids are now permanently captured in the snapshot, so the dedup
|
|
459
|
+
// set no longer needs them — Yjs doesn't re-deliver events to
|
|
460
|
+
// existing observers, so pruning is safe. Stale checkpoints whose
|
|
461
|
+
// `afterEventId` is not found in the current log leave the set
|
|
462
|
+
// intact.
|
|
463
|
+
function pruneAppliedEventIdsForCheckpoint(cursorEventId: string): void {
|
|
464
|
+
const events = yEvents.toArray();
|
|
465
|
+
const idsCovered = new Set<string>();
|
|
466
|
+
let foundCursor = false;
|
|
467
|
+
for (const value of events) {
|
|
468
|
+
const evt = value as { eventId?: unknown } | undefined;
|
|
469
|
+
const eventId = evt?.eventId;
|
|
470
|
+
if (typeof eventId !== "string") continue;
|
|
471
|
+
idsCovered.add(eventId);
|
|
472
|
+
if (eventId === cursorEventId) {
|
|
473
|
+
foundCursor = true;
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!foundCursor) return;
|
|
478
|
+
for (const id of idsCovered) {
|
|
479
|
+
appliedEventIds.delete(id);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function onCheckpointsChange(event: Y.YArrayEvent<Checkpoint>): void {
|
|
484
|
+
for (const delta of event.changes.delta) {
|
|
485
|
+
if (!Array.isArray(delta.insert)) continue;
|
|
486
|
+
for (const value of delta.insert) {
|
|
487
|
+
const cursor = (value as { afterEventId?: unknown } | undefined)?.afterEventId;
|
|
488
|
+
if (typeof cursor !== "string") continue;
|
|
489
|
+
pruneAppliedEventIdsForCheckpoint(cursor);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
yCheckpoints.observe(onCheckpointsChange);
|
|
494
|
+
|
|
223
495
|
return {
|
|
224
496
|
destroy() {
|
|
225
497
|
unsubscribeCommandApplied();
|
|
226
498
|
yEvents.unobserve(onYEventsChange);
|
|
499
|
+
yMeta.unobserve(checkFingerprintAgainstMeta);
|
|
500
|
+
yCheckpoints.unobserve(onCheckpointsChange);
|
|
501
|
+
workflowUnsub();
|
|
502
|
+
workflowShared.destroy();
|
|
503
|
+
runtime.setSharedWorkflowState(null);
|
|
504
|
+
listeners.clear();
|
|
505
|
+
},
|
|
506
|
+
subscribe(listener) {
|
|
507
|
+
listeners.add(listener);
|
|
508
|
+
if (!bufferFlushed) {
|
|
509
|
+
bufferFlushed = true;
|
|
510
|
+
const pending = bufferedEvents.splice(0);
|
|
511
|
+
for (const event of pending) {
|
|
512
|
+
try {
|
|
513
|
+
listener(event);
|
|
514
|
+
} catch {
|
|
515
|
+
// Listener exceptions are isolated.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return () => {
|
|
520
|
+
listeners.delete(listener);
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
getBaseDocFingerprint() {
|
|
524
|
+
return baseDocFingerprint;
|
|
525
|
+
},
|
|
526
|
+
isReadOnly() {
|
|
527
|
+
return readOnly;
|
|
528
|
+
},
|
|
529
|
+
getAppliedEventCount() {
|
|
530
|
+
return appliedEventIds.size;
|
|
531
|
+
},
|
|
532
|
+
getWorkflowShared() {
|
|
533
|
+
return workflowShared;
|
|
227
534
|
},
|
|
228
535
|
};
|
|
229
536
|
|
|
@@ -240,6 +547,9 @@ export function createRuntimeCollabSync(
|
|
|
240
547
|
}
|
|
241
548
|
|
|
242
549
|
function applyStartupEvent(value: unknown): void {
|
|
550
|
+
if (readOnly) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
243
553
|
const event = asCommandEvent(value);
|
|
244
554
|
if (!event) {
|
|
245
555
|
return;
|
|
@@ -257,6 +567,9 @@ export function createRuntimeCollabSync(
|
|
|
257
567
|
}
|
|
258
568
|
|
|
259
569
|
function applyObservedEvent(value: unknown): void {
|
|
570
|
+
if (readOnly) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
260
573
|
const event = asCommandEvent(value);
|
|
261
574
|
if (!event) {
|
|
262
575
|
return;
|
|
@@ -277,6 +590,14 @@ export function createRuntimeCollabSync(
|
|
|
277
590
|
}
|
|
278
591
|
|
|
279
592
|
function isReplayableEvent(event: CommandEvent): boolean {
|
|
593
|
+
if ((event.schemaVersion as number) !== COMMAND_EVENT_SCHEMA_VERSION) {
|
|
594
|
+
emit({
|
|
595
|
+
type: "collab_event_schema_mismatch",
|
|
596
|
+
eventId: event.eventId,
|
|
597
|
+
receivedSchemaVersion: event.schemaVersion,
|
|
598
|
+
});
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
280
601
|
if (isLocalOnlyCommand(event.command)) {
|
|
281
602
|
return false;
|
|
282
603
|
}
|
|
@@ -326,8 +647,17 @@ function asCommandEvent(value: unknown): CommandEvent | null {
|
|
|
326
647
|
return null;
|
|
327
648
|
}
|
|
328
649
|
|
|
650
|
+
// Preserve the received `schemaVersion` verbatim when present so
|
|
651
|
+
// `isReplayableEvent` can report the offending value on mismatch.
|
|
652
|
+
// Missing fields decode to `COMMAND_EVENT_SCHEMA_VERSION` — legacy
|
|
653
|
+
// peers that pre-date the field continue to replay cleanly.
|
|
654
|
+
const schemaVersion = typeof value.schemaVersion === "number"
|
|
655
|
+
? value.schemaVersion
|
|
656
|
+
: COMMAND_EVENT_SCHEMA_VERSION;
|
|
657
|
+
|
|
329
658
|
return {
|
|
330
659
|
eventId: value.eventId,
|
|
660
|
+
schemaVersion: schemaVersion as CommandEvent["schemaVersion"],
|
|
331
661
|
originClientId: value.originClientId,
|
|
332
662
|
authorId: value.authorId,
|
|
333
663
|
timestamp,
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type { CollabBlockReason } from "../../api/comment-negotiation-types.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Public types
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export interface SharedWorkflowState {
|
|
10
|
+
lockedMode?: "editing" | "suggesting" | "commenting" | "viewing";
|
|
11
|
+
roundDeadline?: string; // ISO-8601
|
|
12
|
+
assignedReviewers?: string[]; // userIds
|
|
13
|
+
workItemId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateWorkflowSharedOptions {
|
|
17
|
+
ydoc: Y.Doc;
|
|
18
|
+
role: "author" | "reviewer" | "observer";
|
|
19
|
+
/**
|
|
20
|
+
* Reserved for future audit use — emit `{ actor: localAuthorId }` on
|
|
21
|
+
* writes once the audit path lands. Currently unused at runtime;
|
|
22
|
+
* optional so callers without an author-id scheme don't have to
|
|
23
|
+
* fabricate one.
|
|
24
|
+
*/
|
|
25
|
+
localAuthorId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type WorkflowSharedResult =
|
|
29
|
+
| { ok: true }
|
|
30
|
+
| { ok: false; reason: CollabBlockReason };
|
|
31
|
+
|
|
32
|
+
export interface WorkflowSharedHandle {
|
|
33
|
+
get(): SharedWorkflowState;
|
|
34
|
+
setLockedMode(mode: SharedWorkflowState["lockedMode"]): WorkflowSharedResult;
|
|
35
|
+
setRoundDeadline(deadline: string | undefined): WorkflowSharedResult;
|
|
36
|
+
setAssignedReviewers(reviewers: string[]): WorkflowSharedResult;
|
|
37
|
+
setWorkItemId(id: string | undefined): WorkflowSharedResult;
|
|
38
|
+
subscribe(listener: (state: SharedWorkflowState) => void): () => void;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Y.Map key constants
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const WORKFLOW_MAP_NAME = "workflow";
|
|
47
|
+
const KEY_LOCKED_MODE = "lockedMode";
|
|
48
|
+
const KEY_ROUND_DEADLINE = "roundDeadline";
|
|
49
|
+
const KEY_ASSIGNED_REVIEWERS = "assignedReviewers";
|
|
50
|
+
const KEY_WORK_ITEM_ID = "workItemId";
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Shallow-equality helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function arrayShallowEqual(
|
|
57
|
+
a: string[] | undefined,
|
|
58
|
+
b: string[] | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (a === b) return true;
|
|
61
|
+
if (a == null || b == null) return false;
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
for (let i = 0; i < a.length; i++) {
|
|
64
|
+
if (a[i] !== b[i]) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stateShallowEqual(
|
|
70
|
+
a: SharedWorkflowState,
|
|
71
|
+
b: SharedWorkflowState,
|
|
72
|
+
): boolean {
|
|
73
|
+
return (
|
|
74
|
+
a.lockedMode === b.lockedMode &&
|
|
75
|
+
a.roundDeadline === b.roundDeadline &&
|
|
76
|
+
a.workItemId === b.workItemId &&
|
|
77
|
+
arrayShallowEqual(a.assignedReviewers, b.assignedReviewers)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Factory
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a handle over `ydoc.getMap<unknown>("workflow")` that propagates
|
|
87
|
+
* shared workflow state — `lockedMode`, `roundDeadline`, `assignedReviewers`,
|
|
88
|
+
* and `workItemId` — across collab peers via per-key Yjs LWW conflict
|
|
89
|
+
* resolution.
|
|
90
|
+
*
|
|
91
|
+
* Role-gating (§7 lane plan):
|
|
92
|
+
* - observer: all writes refused with `collab_observer_readonly`.
|
|
93
|
+
* - reviewer: only `setAssignedReviewers` allowed; other writes refused with
|
|
94
|
+
* `collab_role_restricted`.
|
|
95
|
+
* - author: all writes allowed.
|
|
96
|
+
*/
|
|
97
|
+
export function createWorkflowShared(
|
|
98
|
+
options: CreateWorkflowSharedOptions,
|
|
99
|
+
): WorkflowSharedHandle {
|
|
100
|
+
const { ydoc, role } = options;
|
|
101
|
+
// Reserved for future audit path — see CreateWorkflowSharedOptions.localAuthorId.
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
103
|
+
const _localAuthorId = options.localAuthorId;
|
|
104
|
+
|
|
105
|
+
const yMap = ydoc.getMap<unknown>(WORKFLOW_MAP_NAME);
|
|
106
|
+
|
|
107
|
+
const listeners = new Set<(state: SharedWorkflowState) => void>();
|
|
108
|
+
let destroyed = false;
|
|
109
|
+
let lastEmitted: SharedWorkflowState | null = null;
|
|
110
|
+
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
// Read helper
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function readState(): SharedWorkflowState {
|
|
116
|
+
const state: SharedWorkflowState = {};
|
|
117
|
+
const lockedMode = yMap.get(KEY_LOCKED_MODE);
|
|
118
|
+
if (lockedMode !== undefined) {
|
|
119
|
+
state.lockedMode = lockedMode as SharedWorkflowState["lockedMode"];
|
|
120
|
+
}
|
|
121
|
+
const roundDeadline = yMap.get(KEY_ROUND_DEADLINE);
|
|
122
|
+
if (roundDeadline !== undefined) {
|
|
123
|
+
state.roundDeadline = roundDeadline as string;
|
|
124
|
+
}
|
|
125
|
+
const assignedReviewers = yMap.get(KEY_ASSIGNED_REVIEWERS);
|
|
126
|
+
if (assignedReviewers !== undefined) {
|
|
127
|
+
state.assignedReviewers = [...(assignedReviewers as string[])];
|
|
128
|
+
}
|
|
129
|
+
const workItemId = yMap.get(KEY_WORK_ITEM_ID);
|
|
130
|
+
if (workItemId !== undefined) {
|
|
131
|
+
state.workItemId = workItemId as string;
|
|
132
|
+
}
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
// Observer — dedup-fires listeners on any Y.Map change
|
|
138
|
+
// -------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function onMapChange(): void {
|
|
141
|
+
if (destroyed) return;
|
|
142
|
+
if (listeners.size === 0) {
|
|
143
|
+
lastEmitted = null;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const next = readState();
|
|
147
|
+
if (lastEmitted !== null && stateShallowEqual(lastEmitted, next)) {
|
|
148
|
+
return; // deduplicated — same state, skip fire
|
|
149
|
+
}
|
|
150
|
+
lastEmitted = next;
|
|
151
|
+
for (const listener of [...listeners]) {
|
|
152
|
+
try {
|
|
153
|
+
listener(next);
|
|
154
|
+
} catch {
|
|
155
|
+
// Listener exceptions are isolated; the handle continues.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
yMap.observe(onMapChange);
|
|
161
|
+
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
// Role-gating helpers
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function checkObserver(): WorkflowSharedResult | null {
|
|
167
|
+
if (role === "observer") {
|
|
168
|
+
return { ok: false, reason: "collab_observer_readonly" };
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkReviewerRestricted(): WorkflowSharedResult | null {
|
|
174
|
+
if (role === "reviewer") {
|
|
175
|
+
return { ok: false, reason: "collab_role_restricted" };
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
// Handle
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
get(): SharedWorkflowState {
|
|
186
|
+
return readState();
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
setLockedMode(mode): WorkflowSharedResult {
|
|
190
|
+
const denied = checkObserver() ?? checkReviewerRestricted();
|
|
191
|
+
if (denied) return denied;
|
|
192
|
+
if (mode === undefined) {
|
|
193
|
+
yMap.delete(KEY_LOCKED_MODE);
|
|
194
|
+
} else {
|
|
195
|
+
yMap.set(KEY_LOCKED_MODE, mode);
|
|
196
|
+
}
|
|
197
|
+
return { ok: true };
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
setRoundDeadline(deadline): WorkflowSharedResult {
|
|
201
|
+
const denied = checkObserver() ?? checkReviewerRestricted();
|
|
202
|
+
if (denied) return denied;
|
|
203
|
+
if (deadline === undefined) {
|
|
204
|
+
yMap.delete(KEY_ROUND_DEADLINE);
|
|
205
|
+
} else {
|
|
206
|
+
yMap.set(KEY_ROUND_DEADLINE, deadline);
|
|
207
|
+
}
|
|
208
|
+
return { ok: true };
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
setAssignedReviewers(reviewers): WorkflowSharedResult {
|
|
212
|
+
const denied = checkObserver();
|
|
213
|
+
if (denied) return denied;
|
|
214
|
+
// reviewer is allowed here — do NOT call checkReviewerRestricted().
|
|
215
|
+
yMap.set(KEY_ASSIGNED_REVIEWERS, [...reviewers]);
|
|
216
|
+
return { ok: true };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
setWorkItemId(id): WorkflowSharedResult {
|
|
220
|
+
const denied = checkObserver() ?? checkReviewerRestricted();
|
|
221
|
+
if (denied) return denied;
|
|
222
|
+
if (id === undefined) {
|
|
223
|
+
yMap.delete(KEY_WORK_ITEM_ID);
|
|
224
|
+
} else {
|
|
225
|
+
yMap.set(KEY_WORK_ITEM_ID, id);
|
|
226
|
+
}
|
|
227
|
+
return { ok: true };
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
subscribe(listener): () => void {
|
|
231
|
+
listeners.add(listener);
|
|
232
|
+
// Reset dedup baseline so the next change always fires fresh.
|
|
233
|
+
lastEmitted = null;
|
|
234
|
+
return () => {
|
|
235
|
+
listeners.delete(listener);
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
destroy(): void {
|
|
240
|
+
if (destroyed) return;
|
|
241
|
+
destroyed = true;
|
|
242
|
+
yMap.unobserve(onMapChange);
|
|
243
|
+
listeners.clear();
|
|
244
|
+
lastEmitted = null;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
} from "../api/public-types";
|
|
19
19
|
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
20
20
|
import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
|
|
21
|
+
import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
|
|
21
22
|
import {
|
|
22
23
|
createDocumentSectionSnapshots,
|
|
23
24
|
findBookmarkNameForOffset,
|
|
@@ -47,15 +48,6 @@ function createLocationId(anchor: EditorAnchorProjection, storyTarget?: EditorSt
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function createPublicRangeAnchor(from: number, to: number): EditorAnchorProjection {
|
|
51
|
-
return {
|
|
52
|
-
kind: "range",
|
|
53
|
-
from,
|
|
54
|
-
to,
|
|
55
|
-
assoc: { start: -1, end: 1 },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
51
|
function resolveOffsetMetadata(
|
|
60
52
|
navigation: DocumentNavigationSnapshot,
|
|
61
53
|
offset: number,
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
19
19
|
import { createSelectionSnapshot } from "../core/state/editor-state.ts";
|
|
20
20
|
import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
|
|
21
|
+
import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
|
|
21
22
|
import { parseTocLevelRange } from "../io/ooxml/parse-fields.ts";
|
|
22
23
|
import { buildPageLayoutSnapshot, buildResolvedSections } from "./document-layout.ts";
|
|
23
24
|
import { createDocumentNavigationSnapshot } from "./document-navigation.ts";
|
|
@@ -34,15 +35,6 @@ function getAnchorOffset(anchor: EditorAnchorProjection): number | undefined {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function createPublicRangeAnchor(from: number, to: number): EditorAnchorProjection {
|
|
38
|
-
return {
|
|
39
|
-
kind: "range",
|
|
40
|
-
from,
|
|
41
|
-
to,
|
|
42
|
-
assoc: { start: -1, end: 1 },
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
38
|
export function resolveHeadingPath(
|
|
47
39
|
headings: readonly DocumentHeadingSnapshot[],
|
|
48
40
|
offset: number | undefined,
|