@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.
Files changed (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. 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,