@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
@@ -32,11 +32,14 @@ import type {
32
32
  InteractionGuardSnapshot,
33
33
  InsertImageOptions,
34
34
  InsertTableOptions,
35
+ MetadataPersistenceMode,
35
36
  PageLayoutSnapshot,
36
37
  PersistedEditorSnapshot,
38
+ ResolveMetadataConflictInput,
37
39
  ReviewQueueItem,
38
40
  ReviewQueueSnapshot,
39
41
  RuntimeRenderSnapshot,
42
+ ScopeMetadataPersistence,
40
43
  SectionBreakType,
41
44
  SectionLayoutPatch,
42
45
  SectionPageNumberingPatch,
@@ -71,6 +74,8 @@ import type {
71
74
  WorkspaceMode,
72
75
  ZoomLevel,
73
76
  } from "../api/public-types";
77
+ import { MetadataResolverMissingError } from "../api/public-types";
78
+ import type { ScopeMetadataResolver } from "../api/scope-metadata-resolver-types.ts";
74
79
  import {
75
80
  editorSessionStateFromPersistedSnapshot,
76
81
  persistedSnapshotFromEditorSessionState,
@@ -254,10 +259,219 @@ type SelectionToolbarDismissReason =
254
259
  | "comment-action"
255
260
  | "escape";
256
261
 
262
+ // ---------------------------------------------------------------------------
263
+ // P17 module-level helpers — metadata persistence
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function conflictKey(input: {
267
+ scopeId?: string;
268
+ entryId?: string;
269
+ fieldKey?: string;
270
+ }): string {
271
+ return `${input.scopeId ?? ""}|${input.entryId ?? ""}|${input.fieldKey ?? ""}`;
272
+ }
273
+
274
+ function resolveEffective(input: {
275
+ overlay?: MetadataPersistenceMode;
276
+ scope?: ScopeMetadataPersistence;
277
+ field?: ScopeMetadataPersistence;
278
+ }): "internal" | "external" {
279
+ if (input.field === "internal" || input.field === "external") return input.field;
280
+ if (input.scope === "internal" || input.scope === "external") return input.scope;
281
+ return input.overlay ?? "internal";
282
+ }
283
+
284
+ /**
285
+ * A pending conflict record held in `metadataConflictPendingRef` while
286
+ * the host decides how to resolve. Keyed by `conflictKey(...)`.
287
+ */
288
+ interface PendingConflict {
289
+ scopeId?: string;
290
+ entryId?: string;
291
+ fieldKey?: string;
292
+ embedded: { value?: Record<string, unknown>; version?: number } | null;
293
+ external: { value?: Record<string, unknown>; version?: number } | null;
294
+ defaultPolicy: "prefer-latest";
295
+ }
296
+
297
+ /** Emit a single `metadata_conflict_detected` event and register the pending conflict. */
298
+ function registerAndEmitConflict(args: {
299
+ onEvent: ((event: WordReviewEditorEvent) => void) | undefined;
300
+ documentId: string;
301
+ conflict: PendingConflict;
302
+ pendingConflicts: Map<string, PendingConflict>;
303
+ }): void {
304
+ const key = conflictKey(args.conflict);
305
+ // Guard: do not emit duplicate events for the same key in a single pass.
306
+ if (args.pendingConflicts.has(key)) return;
307
+ args.pendingConflicts.set(key, args.conflict);
308
+ args.onEvent?.({
309
+ type: "metadata_conflict_detected",
310
+ documentId: args.documentId,
311
+ scopeId: args.conflict.scopeId,
312
+ entryId: args.conflict.entryId,
313
+ fieldKey: args.conflict.fieldKey,
314
+ embedded: args.conflict.embedded,
315
+ external: args.conflict.external,
316
+ defaultPolicy: args.conflict.defaultPolicy,
317
+ });
318
+ }
319
+
320
+ async function runConvertScopesToInternal(args: {
321
+ runtime: WordReviewEditorRuntime;
322
+ scopeIds: string[];
323
+ resolver: ScopeMetadataResolver | null;
324
+ /** When provided, version mismatches emit `metadata_conflict_detected`. */
325
+ documentId?: string;
326
+ onEvent?: (event: WordReviewEditorEvent) => void;
327
+ pendingConflicts?: Map<string, PendingConflict>;
328
+ }): Promise<void> {
329
+ if (!args.resolver) throw new MetadataResolverMissingError();
330
+ const snapshot = args.runtime.getWorkflowMetadataSnapshot();
331
+ const overlay = args.runtime.getWorkflowOverlay();
332
+
333
+ const nextEntries = await Promise.all(
334
+ snapshot.entries.map(async (entry) => {
335
+ if (!entry.scopeId || !args.scopeIds.includes(entry.scopeId)) return entry;
336
+ const scope = overlay?.scopes.find((s) => s.scopeId === entry.scopeId);
337
+ const effective = resolveEffective({
338
+ overlay: overlay?.metadataPersistence,
339
+ scope: scope?.metadataPersistence,
340
+ field: entry.metadataPersistence,
341
+ });
342
+ if (effective !== "external" || !entry.storageRef) return entry;
343
+ const resolved = await args.resolver!.resolve(entry.storageRef);
344
+ if (!resolved) return entry;
345
+
346
+ // Conflict detection: compare embedded metadataVersion vs resolver version.
347
+ if (
348
+ args.pendingConflicts &&
349
+ args.documentId !== undefined &&
350
+ entry.metadataVersion !== undefined &&
351
+ resolved.version !== undefined &&
352
+ entry.metadataVersion !== resolved.version
353
+ ) {
354
+ registerAndEmitConflict({
355
+ onEvent: args.onEvent,
356
+ documentId: args.documentId,
357
+ pendingConflicts: args.pendingConflicts,
358
+ conflict: {
359
+ scopeId: entry.scopeId,
360
+ entryId: entry.entryId,
361
+ // External entries have no inline value; embedded side carries version only.
362
+ embedded: { version: entry.metadataVersion },
363
+ external: { value: resolved.value, version: resolved.version },
364
+ defaultPolicy: "prefer-latest",
365
+ },
366
+ });
367
+ }
368
+
369
+ // Default behavior: always apply the resolved value (host can override via resolveMetadataConflict).
370
+ return {
371
+ ...entry,
372
+ value: resolved.value,
373
+ metadataPersistence: "internal" as const,
374
+ storageRef: undefined,
375
+ metadataVersion: resolved.version ?? entry.metadataVersion,
376
+ };
377
+ }),
378
+ );
379
+
380
+ args.runtime.setWorkflowMetadataEntries(nextEntries);
381
+ }
382
+
383
+ async function runConvertScopesToExternal(args: {
384
+ runtime: WordReviewEditorRuntime;
385
+ scopeIds: string[];
386
+ resolver: ScopeMetadataResolver | null;
387
+ /** When provided, version-conflict publish errors emit `metadata_conflict_detected`. */
388
+ documentId?: string;
389
+ onEvent?: (event: WordReviewEditorEvent) => void;
390
+ pendingConflicts?: Map<string, PendingConflict>;
391
+ }): Promise<void> {
392
+ if (!args.resolver) throw new MetadataResolverMissingError();
393
+ const snapshot = args.runtime.getWorkflowMetadataSnapshot();
394
+ const overlay = args.runtime.getWorkflowOverlay();
395
+
396
+ const nextEntries = await Promise.all(
397
+ snapshot.entries.map(async (entry) => {
398
+ if (!entry.scopeId || !args.scopeIds.includes(entry.scopeId)) return entry;
399
+ const scope = overlay?.scopes.find((s) => s.scopeId === entry.scopeId);
400
+ const effective = resolveEffective({
401
+ overlay: overlay?.metadataPersistence,
402
+ scope: scope?.metadataPersistence,
403
+ field: entry.metadataPersistence,
404
+ });
405
+ if (effective === "external") return entry;
406
+
407
+ try {
408
+ const { ref, version } = await args.resolver!.publish({
409
+ scopeId: entry.scopeId,
410
+ metadataId: entry.metadataId,
411
+ entryId: entry.entryId,
412
+ value: entry.value ?? {},
413
+ expectedVersion: entry.metadataVersion,
414
+ });
415
+ return {
416
+ ...entry,
417
+ value: undefined,
418
+ metadataPersistence: "external" as const,
419
+ storageRef: ref,
420
+ metadataVersion: version,
421
+ };
422
+ } catch (err: unknown) {
423
+ // Duck-type version-conflict errors (HarnessVersionConflictError or compatible shapes).
424
+ if (
425
+ args.pendingConflicts &&
426
+ args.documentId !== undefined &&
427
+ err instanceof Error &&
428
+ (err.name === "HarnessVersionConflictError" ||
429
+ ("ref" in err && "expected" in err && "actual" in err))
430
+ ) {
431
+ const conflictErr = err as Error & { ref?: string; expected?: number; actual?: number };
432
+ registerAndEmitConflict({
433
+ onEvent: args.onEvent,
434
+ documentId: args.documentId,
435
+ pendingConflicts: args.pendingConflicts,
436
+ conflict: {
437
+ scopeId: entry.scopeId,
438
+ entryId: entry.entryId,
439
+ // Embedded side: the entry's current inline value and metadataVersion.
440
+ embedded: { value: entry.value, version: entry.metadataVersion },
441
+ // External side: only the rowstore's actual version (no value available from error).
442
+ external: { version: conflictErr.actual },
443
+ defaultPolicy: "prefer-latest",
444
+ },
445
+ });
446
+ // Skip this entry — do not publish; leave it unchanged.
447
+ return entry;
448
+ }
449
+ // Non-conflict errors propagate normally.
450
+ throw err;
451
+ }
452
+ }),
453
+ );
454
+
455
+ args.runtime.setWorkflowMetadataEntries(nextEntries);
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+
257
460
  export function __createWordReviewEditorRefBridge(
258
461
  runtime: WordReviewEditorRuntime,
259
462
  mountedSurface?: TwProseMirrorSurfaceRef | null,
463
+ options?: {
464
+ documentId?: string;
465
+ onEvent?: (event: WordReviewEditorEvent) => void;
466
+ resolverRef?: { current: ScopeMetadataResolver | null };
467
+ conflictResolutionsRef?: {
468
+ current: Map<string, { choice: string; mergedValue?: Record<string, unknown> }>;
469
+ };
470
+ },
260
471
  ): WordReviewEditorRef {
472
+ // Pending-conflict queue for this bridge instance. Keyed by conflictKey(...).
473
+ const pendingConflicts = new Map<string, PendingConflict>();
474
+
261
475
  return {
262
476
  focus: () => runtime.focus(),
263
477
  blur: () => runtime.blur(),
@@ -277,6 +491,10 @@ export function __createWordReviewEditorRefBridge(
277
491
  rejectChange: (changeId) => runtime.rejectChange(changeId),
278
492
  acceptAllChanges: () => runtime.acceptAllChanges(),
279
493
  rejectAllChanges: () => runtime.rejectAllChanges(),
494
+ acceptSuggestionGroup: (groupId) =>
495
+ applySuggestionGroupAction(runtime, groupId, "accept"),
496
+ rejectSuggestionGroup: (groupId) =>
497
+ applySuggestionGroupAction(runtime, groupId, "reject"),
280
498
  exportDocx: (options) => runtime.exportDocx(options),
281
499
  getSessionState: () => runtime.getSessionState(),
282
500
  getSnapshot: () => runtime.getPersistedSnapshot(),
@@ -575,6 +793,167 @@ export function __createWordReviewEditorRefBridge(
575
793
  getWorkflowMetadataSnapshot: () => {
576
794
  return clonePublicValue(runtime.getWorkflowMetadataSnapshot());
577
795
  },
796
+ // P17 — metadata persistence toggle + convert methods.
797
+ setMetadataPersistenceMode: (mode) => {
798
+ if (mode === "external" && !(options?.resolverRef?.current ?? null)) {
799
+ throw new MetadataResolverMissingError();
800
+ }
801
+ const overlay = runtime.getWorkflowOverlay();
802
+ if (!overlay) return;
803
+ runtime.setWorkflowOverlay({ ...overlay, metadataPersistence: mode });
804
+ options?.onEvent?.({
805
+ type: "metadata_persistence_mode_changed",
806
+ documentId: options?.documentId ?? "",
807
+ mode,
808
+ });
809
+ },
810
+ getMetadataPersistenceMode: () => {
811
+ return runtime.getWorkflowOverlay()?.metadataPersistence ?? "internal";
812
+ },
813
+ setScopeMetadataPersistence: (scopeId, persistence) => {
814
+ if (persistence === "external" && !(options?.resolverRef?.current ?? null)) {
815
+ throw new MetadataResolverMissingError();
816
+ }
817
+ const overlay = runtime.getWorkflowOverlay();
818
+ if (!overlay) return;
819
+ const nextOverlay = {
820
+ ...overlay,
821
+ scopes: overlay.scopes.map((s) => {
822
+ if (s.scopeId !== scopeId) return s;
823
+ if (persistence === "inherit") {
824
+ const { metadataPersistence: _, ...rest } = s;
825
+ return rest as typeof s;
826
+ }
827
+ return { ...s, metadataPersistence: persistence };
828
+ }),
829
+ };
830
+ runtime.setWorkflowOverlay(nextOverlay);
831
+ options?.onEvent?.({
832
+ type: "scope_metadata_persistence_changed",
833
+ documentId: options?.documentId ?? "",
834
+ scopeId,
835
+ persistence,
836
+ });
837
+ },
838
+ getScopeMetadataPersistence: (scopeId) => {
839
+ const overlay = runtime.getWorkflowOverlay();
840
+ return overlay?.scopes.find((s) => s.scopeId === scopeId)?.metadataPersistence ?? "inherit";
841
+ },
842
+ setAllScopesMetadataPersistence: (persistence) => {
843
+ if (persistence === "external" && !(options?.resolverRef?.current ?? null)) {
844
+ throw new MetadataResolverMissingError();
845
+ }
846
+ const overlay = runtime.getWorkflowOverlay();
847
+ if (!overlay) return;
848
+ const nextOverlay = {
849
+ ...overlay,
850
+ scopes: overlay.scopes.map((s) => {
851
+ if (persistence === "inherit") {
852
+ const { metadataPersistence: _, ...rest } = s;
853
+ return rest as typeof s;
854
+ }
855
+ return { ...s, metadataPersistence: persistence };
856
+ }),
857
+ };
858
+ runtime.setWorkflowOverlay(nextOverlay);
859
+ options?.onEvent?.({
860
+ type: "scope_metadata_persistence_changed",
861
+ documentId: options?.documentId ?? "",
862
+ scopeId: "*",
863
+ persistence,
864
+ });
865
+ },
866
+ setScopeMetadataResolver: (resolver) => {
867
+ if (options?.resolverRef) {
868
+ options.resolverRef.current = resolver;
869
+ }
870
+ },
871
+ resolveMetadataConflict: (input: ResolveMetadataConflictInput) => {
872
+ // Legacy: keep conflictResolutionsRef updated so Task 8 tests pass.
873
+ if (options?.conflictResolutionsRef) {
874
+ options.conflictResolutionsRef.current.set(conflictKey(input), {
875
+ choice: input.choice,
876
+ mergedValue: input.mergedValue,
877
+ });
878
+ }
879
+
880
+ // New: apply the chosen value to the metadata snapshot.
881
+ const pending = pendingConflicts.get(conflictKey(input));
882
+ if (!pending) return; // No pending conflict — idempotent no-op.
883
+
884
+ let finalValue: Record<string, unknown> | undefined;
885
+ if (input.choice === "embedded") {
886
+ finalValue = pending.embedded?.value;
887
+ } else if (input.choice === "external") {
888
+ finalValue = pending.external?.value;
889
+ } else if (input.choice === "merge") {
890
+ finalValue = input.mergedValue;
891
+ }
892
+
893
+ if (finalValue === undefined) {
894
+ // Nothing to write (e.g., embedded side has no inline value).
895
+ pendingConflicts.delete(conflictKey(input));
896
+ return;
897
+ }
898
+
899
+ const winnerVersion =
900
+ input.choice === "external"
901
+ ? pending.external?.version ?? undefined
902
+ : input.choice === "embedded"
903
+ ? pending.embedded?.version ?? undefined
904
+ : undefined;
905
+
906
+ const snapshot = runtime.getWorkflowMetadataSnapshot();
907
+ const nextEntries = snapshot.entries.map((entry) => {
908
+ if (input.entryId && entry.entryId !== input.entryId) return entry;
909
+ if (input.scopeId && entry.scopeId !== input.scopeId) return entry;
910
+ return {
911
+ ...entry,
912
+ value: finalValue,
913
+ metadataPersistence: "internal" as const,
914
+ storageRef: undefined,
915
+ metadataVersion: winnerVersion ?? entry.metadataVersion,
916
+ };
917
+ });
918
+ runtime.setWorkflowMetadataEntries(nextEntries);
919
+ pendingConflicts.delete(conflictKey(input));
920
+ },
921
+ convertScopesToInternal: async (scopeIds) => {
922
+ await runConvertScopesToInternal({
923
+ runtime,
924
+ scopeIds,
925
+ resolver: options?.resolverRef?.current ?? null,
926
+ documentId: options?.documentId,
927
+ onEvent: options?.onEvent,
928
+ pendingConflicts,
929
+ });
930
+ },
931
+ convertScopesToExternal: async (scopeIds) => {
932
+ await runConvertScopesToExternal({
933
+ runtime,
934
+ scopeIds,
935
+ resolver: options?.resolverRef?.current ?? null,
936
+ documentId: options?.documentId,
937
+ onEvent: options?.onEvent,
938
+ pendingConflicts,
939
+ });
940
+ },
941
+ // Schema 1.2 — EditorStateChannel delegation.
942
+ configureEditorStatePolicy: (policy) => {
943
+ runtime.configureEditorStatePolicy(policy);
944
+ },
945
+ registerEditorStateResolver: (resolver) => {
946
+ runtime.registerEditorStateResolver(resolver);
947
+ },
948
+ registerEditorStatePersister: (persister) => {
949
+ runtime.registerEditorStatePersister(persister);
950
+ },
951
+ getEditorStateKey: (namespace) => {
952
+ return runtime.getEditorStateKey(namespace);
953
+ },
954
+ retryPendingPersist: async (namespace) => {
955
+ await runtime.retryPendingPersist(namespace);
956
+ },
578
957
  setHostAnnotationOverlay: (overlay) => {
579
958
  runtime.setHostAnnotationOverlay(clonePublicValue(overlay));
580
959
  },
@@ -687,6 +1066,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
687
1066
  const shellRef = useRef<HTMLDivElement | null>(null);
688
1067
  const lastSelectionToolbarKeyRef = useRef<string | null>(null);
689
1068
  const lastAnnouncedErrorIdRef = useRef<string | null>(null);
1069
+ const scopeMetadataResolverRef = useRef<ScopeMetadataResolver | null>(null);
1070
+ const metadataConflictResolutionsRef = useRef(
1071
+ new Map<string, { choice: string; mergedValue?: Record<string, unknown> }>(),
1072
+ );
1073
+ const metadataConflictPendingRef = useRef(new Map<string, PendingConflict>());
690
1074
  const {
691
1075
  runtime,
692
1076
  loadError,
@@ -1094,6 +1478,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1094
1478
  rejectChange: (changeId) => activeRuntime.rejectChange(changeId),
1095
1479
  acceptAllChanges: () => activeRuntime.acceptAllChanges(),
1096
1480
  rejectAllChanges: () => activeRuntime.rejectAllChanges(),
1481
+ acceptSuggestionGroup: (groupId) =>
1482
+ applySuggestionGroupAction(activeRuntime, groupId, "accept"),
1483
+ rejectSuggestionGroup: (groupId) =>
1484
+ applySuggestionGroupAction(activeRuntime, groupId, "reject"),
1097
1485
  exportDocx: (options) =>
1098
1486
  runtime
1099
1487
  ? persistAndExportFromBoundary({
@@ -1446,6 +1834,165 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1446
1834
  getWorkflowMetadataSnapshot: () => {
1447
1835
  return clonePublicValue(activeRuntime.getWorkflowMetadataSnapshot());
1448
1836
  },
1837
+ // P17 — metadata persistence toggle + convert methods.
1838
+ setMetadataPersistenceMode: (mode) => {
1839
+ if (mode === "external" && scopeMetadataResolverRef.current === null) {
1840
+ throw new MetadataResolverMissingError();
1841
+ }
1842
+ const overlay = activeRuntime.getWorkflowOverlay();
1843
+ if (!overlay) return;
1844
+ activeRuntime.setWorkflowOverlay({ ...overlay, metadataPersistence: mode });
1845
+ onEventRef.current?.({
1846
+ type: "metadata_persistence_mode_changed",
1847
+ documentId,
1848
+ mode,
1849
+ });
1850
+ },
1851
+ getMetadataPersistenceMode: () => {
1852
+ return activeRuntime.getWorkflowOverlay()?.metadataPersistence ?? "internal";
1853
+ },
1854
+ setScopeMetadataPersistence: (scopeId, persistence) => {
1855
+ if (persistence === "external" && scopeMetadataResolverRef.current === null) {
1856
+ throw new MetadataResolverMissingError();
1857
+ }
1858
+ const overlay = activeRuntime.getWorkflowOverlay();
1859
+ if (!overlay) return;
1860
+ const nextOverlay = {
1861
+ ...overlay,
1862
+ scopes: overlay.scopes.map((s) => {
1863
+ if (s.scopeId !== scopeId) return s;
1864
+ if (persistence === "inherit") {
1865
+ const { metadataPersistence: _, ...rest } = s;
1866
+ return rest as typeof s;
1867
+ }
1868
+ return { ...s, metadataPersistence: persistence };
1869
+ }),
1870
+ };
1871
+ activeRuntime.setWorkflowOverlay(nextOverlay);
1872
+ onEventRef.current?.({
1873
+ type: "scope_metadata_persistence_changed",
1874
+ documentId,
1875
+ scopeId,
1876
+ persistence,
1877
+ });
1878
+ },
1879
+ getScopeMetadataPersistence: (scopeId) => {
1880
+ const overlay = activeRuntime.getWorkflowOverlay();
1881
+ return (
1882
+ overlay?.scopes.find((s) => s.scopeId === scopeId)?.metadataPersistence ?? "inherit"
1883
+ );
1884
+ },
1885
+ setAllScopesMetadataPersistence: (persistence) => {
1886
+ if (persistence === "external" && scopeMetadataResolverRef.current === null) {
1887
+ throw new MetadataResolverMissingError();
1888
+ }
1889
+ const overlay = activeRuntime.getWorkflowOverlay();
1890
+ if (!overlay) return;
1891
+ const nextOverlay = {
1892
+ ...overlay,
1893
+ scopes: overlay.scopes.map((s) => {
1894
+ if (persistence === "inherit") {
1895
+ const { metadataPersistence: _, ...rest } = s;
1896
+ return rest as typeof s;
1897
+ }
1898
+ return { ...s, metadataPersistence: persistence };
1899
+ }),
1900
+ };
1901
+ activeRuntime.setWorkflowOverlay(nextOverlay);
1902
+ onEventRef.current?.({
1903
+ type: "scope_metadata_persistence_changed",
1904
+ documentId,
1905
+ scopeId: "*",
1906
+ persistence,
1907
+ });
1908
+ },
1909
+ setScopeMetadataResolver: (resolver) => {
1910
+ scopeMetadataResolverRef.current = resolver;
1911
+ },
1912
+ resolveMetadataConflict: (input) => {
1913
+ // Legacy: keep metadataConflictResolutionsRef updated so Task 8 tests pass.
1914
+ metadataConflictResolutionsRef.current.set(conflictKey(input), {
1915
+ choice: input.choice,
1916
+ mergedValue: input.mergedValue,
1917
+ });
1918
+
1919
+ // New: apply the chosen value to the metadata snapshot.
1920
+ const pending = metadataConflictPendingRef.current.get(conflictKey(input));
1921
+ if (!pending) return; // No pending conflict — idempotent no-op.
1922
+
1923
+ let finalValue: Record<string, unknown> | undefined;
1924
+ if (input.choice === "embedded") {
1925
+ finalValue = pending.embedded?.value;
1926
+ } else if (input.choice === "external") {
1927
+ finalValue = pending.external?.value;
1928
+ } else if (input.choice === "merge") {
1929
+ finalValue = input.mergedValue;
1930
+ }
1931
+
1932
+ if (finalValue === undefined) {
1933
+ // Nothing to write (e.g., embedded side has no inline value).
1934
+ metadataConflictPendingRef.current.delete(conflictKey(input));
1935
+ return;
1936
+ }
1937
+
1938
+ const winnerVersion =
1939
+ input.choice === "external"
1940
+ ? pending.external?.version ?? undefined
1941
+ : input.choice === "embedded"
1942
+ ? pending.embedded?.version ?? undefined
1943
+ : undefined;
1944
+
1945
+ const snapshot = activeRuntime.getWorkflowMetadataSnapshot();
1946
+ const nextEntries = snapshot.entries.map((entry) => {
1947
+ if (input.entryId && entry.entryId !== input.entryId) return entry;
1948
+ if (input.scopeId && entry.scopeId !== input.scopeId) return entry;
1949
+ return {
1950
+ ...entry,
1951
+ value: finalValue,
1952
+ metadataPersistence: "internal" as const,
1953
+ storageRef: undefined,
1954
+ metadataVersion: winnerVersion ?? entry.metadataVersion,
1955
+ };
1956
+ });
1957
+ activeRuntime.setWorkflowMetadataEntries(nextEntries);
1958
+ metadataConflictPendingRef.current.delete(conflictKey(input));
1959
+ },
1960
+ convertScopesToInternal: async (scopeIds) => {
1961
+ await runConvertScopesToInternal({
1962
+ runtime: activeRuntime,
1963
+ scopeIds,
1964
+ resolver: scopeMetadataResolverRef.current,
1965
+ documentId,
1966
+ onEvent: onEventRef.current,
1967
+ pendingConflicts: metadataConflictPendingRef.current,
1968
+ });
1969
+ },
1970
+ convertScopesToExternal: async (scopeIds) => {
1971
+ await runConvertScopesToExternal({
1972
+ runtime: activeRuntime,
1973
+ scopeIds,
1974
+ resolver: scopeMetadataResolverRef.current,
1975
+ documentId,
1976
+ onEvent: onEventRef.current,
1977
+ pendingConflicts: metadataConflictPendingRef.current,
1978
+ });
1979
+ },
1980
+ // Schema 1.2 — EditorStateChannel delegation.
1981
+ configureEditorStatePolicy: (policy) => {
1982
+ activeRuntime.configureEditorStatePolicy(policy);
1983
+ },
1984
+ registerEditorStateResolver: (resolver) => {
1985
+ activeRuntime.registerEditorStateResolver(resolver);
1986
+ },
1987
+ registerEditorStatePersister: (persister) => {
1988
+ activeRuntime.registerEditorStatePersister(persister);
1989
+ },
1990
+ getEditorStateKey: (namespace) => {
1991
+ return activeRuntime.getEditorStateKey(namespace);
1992
+ },
1993
+ retryPendingPersist: async (namespace) => {
1994
+ await activeRuntime.retryPendingPersist(namespace);
1995
+ },
1449
1996
  setHostAnnotationOverlay: (overlay) => {
1450
1997
  setHostAnnotationOverlayState(clonePublicValue(overlay));
1451
1998
  },
@@ -2103,6 +2650,19 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2103
2650
  code: "unsupported_surface",
2104
2651
  message,
2105
2652
  }]),
2653
+ onPasteApplied: (meta: {
2654
+ segmentCount: number;
2655
+ charCount: number;
2656
+ source: "paste" | "drop";
2657
+ }) => {
2658
+ onEventRef.current?.({
2659
+ type: "paste_applied",
2660
+ documentId: props.documentId,
2661
+ segmentCount: meta.segmentCount,
2662
+ charCount: meta.charCount,
2663
+ source: meta.source,
2664
+ });
2665
+ },
2106
2666
  };
2107
2667
 
2108
2668
  const reviewCallbacks = {
@@ -2248,14 +2808,21 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2248
2808
  applyRuntimeImageResize(activeRuntime, mediaId, dimensions),
2249
2809
  onSetImageFrame: (mediaId, offsets) =>
2250
2810
  applyRuntimeImageReposition(activeRuntime, mediaId, offsets),
2251
- onOpenHeaderStory: () =>
2252
- openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "header"),
2253
- onOpenFooterStory: () =>
2254
- openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "footer"),
2811
+ // P8.11 — `onOpenHeaderStory` / `onOpenFooterStory` retired from the
2812
+ // WordReviewEditor wiring. Per-page header / footer bands rendered
2813
+ // by `TwPageStackChromeLayer` call `onOpenStory(target)` with the
2814
+ // exact `EditorStoryTarget` they represent, so the variant /
2815
+ // relationship resolution happens inside the layout facet instead
2816
+ // of the UI. The deprecated props remain in the workspace type
2817
+ // with a mount-time `console.warn`; hosts that still pass them can
2818
+ // migrate to `onOpenStory` at their leisure.
2255
2819
  onOpenHeaderStoryForPage: (pageIndex: number) =>
2256
2820
  openStoryForPage(activeRuntime, pageIndex, "header"),
2257
2821
  onOpenFooterStoryForPage: (pageIndex: number) =>
2258
2822
  openStoryForPage(activeRuntime, pageIndex, "footer"),
2823
+ onOpenStory: (target) => {
2824
+ activeRuntime.openStory(target);
2825
+ },
2259
2826
  onDeleteSectionBreak: (sectionIndex) =>
2260
2827
  applyRuntimeDeleteSectionBreak(activeRuntime, sectionIndex),
2261
2828
  onUpdateSectionLayout: (sectionIndex, patch) =>
@@ -2386,6 +2953,17 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2386
2953
  interactionGuardSnapshot={interactionGuardSnapshot}
2387
2954
  chromePreset={effectiveChromePreset}
2388
2955
  chromeOptions={chromeOptions}
2956
+ {...(props.collabSession ? { collabSession: props.collabSession } : {})}
2957
+ {...(props.collabTransportStatus
2958
+ ? { collabTransportStatus: props.collabTransportStatus }
2959
+ : {})}
2960
+ {...(props.activeCommentId !== undefined
2961
+ ? { activeCommentId: props.activeCommentId }
2962
+ : {})}
2963
+ collabActorId={currentUser.userId}
2964
+ {...(props.collabSendBaseline
2965
+ ? { collabSendBaseline: props.collabSendBaseline }
2966
+ : {})}
2389
2967
  reviewQueue={reviewQueueSnapshot}
2390
2968
  documentContextAnalytics={documentContextAnalytics}
2391
2969
  selectionContextAnalytics={selectionContextAnalytics}
@@ -2450,11 +3028,83 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2450
3028
  action: payload.action,
2451
3029
  });
2452
3030
  }}
3031
+ onScopeAcceptSuggestionGroup={(payload) => {
3032
+ applySuggestionGroupAction(activeRuntime, payload.groupId, "accept");
3033
+ }}
3034
+ onScopeRejectSuggestionGroup={(payload) => {
3035
+ applySuggestionGroupAction(activeRuntime, payload.groupId, "reject");
3036
+ }}
3037
+ onScopeAskAgent={(payload) => {
3038
+ // Resolve the scope's anchor + story from the facet's card
3039
+ // model so the agent request carries the canonical range.
3040
+ const facet = activeRuntime.layout;
3041
+ const models =
3042
+ facet && typeof facet.getAllScopeCardModels === "function"
3043
+ ? facet.getAllScopeCardModels()
3044
+ : [];
3045
+ const model = models.find((entry) => entry.scopeId === payload.scopeId);
3046
+ if (!model) return;
3047
+ const scopeSnapshot = activeRuntime.getWorkflowScopeSnapshot();
3048
+ const scopes = scopeSnapshot?.scopes ?? [];
3049
+ const scope = scopes.find((entry) => entry.scopeId === payload.scopeId);
3050
+ if (!scope) return;
3051
+ const anchor = scope.anchor;
3052
+ if (!anchor) return;
3053
+ const requestId =
3054
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
3055
+ ? `req-${crypto.randomUUID()}`
3056
+ : `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
3057
+ const eventPayload: Extract<
3058
+ WordReviewEditorEvent,
3059
+ { type: "agent-on-selection-requested" }
3060
+ > = {
3061
+ type: "agent-on-selection-requested",
3062
+ documentId,
3063
+ requestId,
3064
+ scopeId: payload.scopeId,
3065
+ anchor,
3066
+ selectionText: model.label ?? "",
3067
+ ...(scope.storyTarget ? { storyTarget: scope.storyTarget } : {}),
3068
+ };
3069
+ onEventRef.current?.(eventPayload);
3070
+ }}
2453
3071
  />
2454
3072
  );
2455
3073
  },
2456
3074
  );
2457
3075
 
3076
+ /**
3077
+ * R3 — best-effort suggestion-group accept/reject fan-out. Resolves
3078
+ * the group's suggestions from the current snapshot, then fans out
3079
+ * `acceptChange` / `rejectChange` across every changeId in each
3080
+ * group member. P2 batches these in rapid succession; the runtime
3081
+ * commit boundary collapses them into a single logical transaction.
3082
+ * A future phase adds true atomicity at the runtime level.
3083
+ */
3084
+ function applySuggestionGroupAction(
3085
+ runtime: WordReviewEditorRuntime,
3086
+ groupId: string,
3087
+ action: "accept" | "reject",
3088
+ ): void {
3089
+ const snapshot = runtime.getSuggestionsSnapshot();
3090
+ const group = snapshot.groups?.find((entry) => entry.groupId === groupId);
3091
+ if (!group) return;
3092
+ const byId = new Map(
3093
+ snapshot.suggestions.map((entry) => [entry.suggestionId, entry]),
3094
+ );
3095
+ for (const suggestionId of group.suggestionIds) {
3096
+ const suggestion = byId.get(suggestionId);
3097
+ if (!suggestion) continue;
3098
+ for (const changeId of suggestion.changeIds) {
3099
+ if (action === "accept") {
3100
+ runtime.acceptChange(changeId);
3101
+ } else {
3102
+ runtime.rejectChange(changeId);
3103
+ }
3104
+ }
3105
+ }
3106
+ }
3107
+
2458
3108
  function applyRuntimeFormattingOperation(
2459
3109
  runtime: WordReviewEditorRuntime,
2460
3110
  operation:
@@ -4279,47 +4929,6 @@ function openStoryForPage(
4279
4929
  runtime.openStory(target);
4280
4930
  }
4281
4931
 
4282
- function openDefaultStoryVariant(
4283
- runtime: WordReviewEditorRuntime,
4284
- pageLayout: PageLayoutSnapshot | undefined,
4285
- navigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]> | undefined,
4286
- kind: "header" | "footer",
4287
- ): void {
4288
- const variants =
4289
- kind === "header"
4290
- ? pageLayout?.headerVariants
4291
- : pageLayout?.footerVariants;
4292
- const activePage = navigation?.pages[navigation.activePageIndex];
4293
- const isFirstPageInSection =
4294
- activePage !== undefined &&
4295
- activePage.sectionIndex === pageLayout?.sectionIndex &&
4296
- activePage.pageInSection === 0;
4297
- const isEvenDocumentPage = activePage !== undefined && (activePage.pageIndex + 1) % 2 === 0;
4298
-
4299
- let variant =
4300
- pageLayout?.differentFirstPage && isFirstPageInSection
4301
- ? variants?.find((entry) => entry.variant === "first")
4302
- : undefined;
4303
-
4304
- if (!variant && pageLayout?.differentOddEvenPages && isEvenDocumentPage) {
4305
- variant = variants?.find((entry) => entry.variant === "even");
4306
- }
4307
-
4308
- if (!variant) {
4309
- variant = variants?.find((entry) => entry.variant === "default") ?? variants?.[0];
4310
- }
4311
-
4312
- if (!variant) {
4313
- return;
4314
- }
4315
- runtime.openStory({
4316
- kind,
4317
- relationshipId: variant.relationshipId,
4318
- variant: variant.variant,
4319
- sectionIndex: pageLayout?.sectionIndex,
4320
- });
4321
- }
4322
-
4323
4932
  function searchRuntimeDocument(
4324
4933
  runtime: WordReviewEditorRuntime,
4325
4934
  mountedSurface: TwProseMirrorSurfaceRef | null,