@beyondwork/docx-react-component 1.0.42 → 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 (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -17,7 +17,10 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
17
17
  import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
18
18
  import type { EditorStoryTarget } from "../../api/public-types";
19
19
  import type {
20
+ PublicBlockFragment,
20
21
  PublicPageNode,
22
+ PublicPageRegion,
23
+ PublicRegionBlock,
21
24
  WordReviewEditorLayoutFacet,
22
25
  } from "../layout/public-facet.ts";
23
26
  import type {
@@ -305,6 +308,7 @@ function buildPage(
305
308
  zoom,
306
309
  "header",
307
310
  page.stories.header,
311
+ facet,
308
312
  );
309
313
  }
310
314
  if (page.stories.footer) {
@@ -314,9 +318,18 @@ function buildPage(
314
318
  zoom,
315
319
  "footer",
316
320
  page.stories.footer,
321
+ facet,
317
322
  );
318
323
  }
319
324
 
325
+ const footnoteRegions = buildFootnoteRegions(page, topPx, zoom, facet);
326
+ if (footnoteRegions.length > 0) {
327
+ regions.footnotes = footnoteRegions;
328
+ }
329
+ // Endnotes intentionally skipped — per-page endnote projection is not
330
+ // populated; endnotes use document-end placement via
331
+ // `facet.getDocumentEndnoteBlocks()`.
332
+
320
333
  const chromeReservations: PageChromeReservations = {
321
334
  ...defaultChromeReservations(layout, zoom),
322
335
  };
@@ -440,51 +453,181 @@ function buildHeaderFooterRegion(
440
453
  zoom: RenderZoom,
441
454
  kind: "header" | "footer",
442
455
  storyTarget: EditorStoryTarget,
456
+ facet: WordReviewEditorLayoutFacet,
443
457
  ): RenderStoryRegion {
444
458
  const layout = page.layout;
445
- const widthTwips =
459
+ const fallbackWidthTwips =
446
460
  layout.pageWidth - layout.marginLeft - layout.marginRight;
447
- let topTwips = 0;
448
- let heightTwips = 0;
449
- if (kind === "header") {
450
- topTwips = layout.headerMargin ?? 720;
451
- heightTwips = Math.max(0, layout.marginTop - topTwips);
452
- } else {
453
- topTwips = layout.pageHeight - layout.marginBottom;
454
- heightTwips = Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
455
- }
461
+ const fallbackTopTwips =
462
+ kind === "header"
463
+ ? (layout.headerMargin ?? 720)
464
+ : layout.pageHeight - layout.marginBottom;
465
+ const fallbackHeightTwips =
466
+ kind === "header"
467
+ ? Math.max(0, layout.marginTop - (layout.headerMargin ?? 720))
468
+ : Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
469
+
470
+ const region: PublicPageRegion =
471
+ (kind === "header" ? page.regions.header : page.regions.footer) ?? {
472
+ kind,
473
+ originTwips: fallbackTopTwips,
474
+ widthTwips: fallbackWidthTwips,
475
+ heightTwips: fallbackHeightTwips,
476
+ fragmentCount: 0,
477
+ };
456
478
 
457
479
  const frame: RenderFrameRect = {
458
480
  leftPx: layout.marginLeft * zoom.pxPerTwip,
459
- topPx: pageTopPx + topTwips * zoom.pxPerTwip,
460
- widthPx: widthTwips * zoom.pxPerTwip,
461
- heightPx: heightTwips * zoom.pxPerTwip,
481
+ topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
482
+ widthPx: region.widthTwips * zoom.pxPerTwip,
483
+ heightPx: region.heightTwips * zoom.pxPerTwip,
462
484
  };
463
485
 
464
- const region = kind === "header" ? page.regions.header : page.regions.footer;
465
- if (!region) {
466
- return {
467
- storyTarget,
468
- region: {
469
- kind,
470
- originTwips: topTwips,
471
- widthTwips,
472
- heightTwips,
473
- fragmentCount: 0,
474
- },
475
- frame,
476
- blocks: [],
477
- };
478
- }
486
+ const regionBlocks = facet.getStoryBlocksForRegion(page.pageIndex, kind);
487
+ const blocks = projectRegionBlocks(
488
+ regionBlocks,
489
+ frame,
490
+ zoom.pxPerTwip,
491
+ page.pageId,
492
+ page.pageIndex,
493
+ kind,
494
+ );
479
495
 
480
496
  return {
481
497
  storyTarget,
482
498
  region,
483
499
  frame,
484
- blocks: [],
500
+ blocks,
485
501
  };
486
502
  }
487
503
 
504
+ /**
505
+ * P8.3 — Build one `RenderStoryRegion` per `page.regions.footnotes` entry.
506
+ * The page graph reserves footnote regions at the bottom of the page
507
+ * (above the footer band) when `noteAllocations` produced fragments.
508
+ * Blocks come from `facet.getStoryBlocksForRegion(pageIndex, "footnote-area")`
509
+ * — one entry per allocated note body, stacked vertically.
510
+ *
511
+ * Currently the page graph emits a single footnote region per page
512
+ * covering every allocation, so the returned array has length 0 or 1. The
513
+ * shape allows for future allocation-splitting without changing the
514
+ * render-kernel contract.
515
+ */
516
+ function buildFootnoteRegions(
517
+ page: PublicPageNode,
518
+ pageTopPx: number,
519
+ zoom: RenderZoom,
520
+ facet: WordReviewEditorLayoutFacet,
521
+ ): RenderStoryRegion[] {
522
+ const footnoteRegions = page.regions.footnotes;
523
+ if (!footnoteRegions || footnoteRegions.length === 0) return [];
524
+
525
+ const regionBlocks = facet.getStoryBlocksForRegion(
526
+ page.pageIndex,
527
+ "footnote-area",
528
+ );
529
+ if (regionBlocks.length === 0) return [];
530
+
531
+ const results: RenderStoryRegion[] = [];
532
+ // Today the runtime emits a single footnote-area region per page; if that
533
+ // ever changes we will split `regionBlocks` across each region entry's
534
+ // `fragmentCount`. Preserve the shape so consumers can iterate safely.
535
+ let cursor = 0;
536
+ for (const region of footnoteRegions) {
537
+ const frame: RenderFrameRect = {
538
+ leftPx: page.layout.marginLeft * zoom.pxPerTwip,
539
+ topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
540
+ widthPx: region.widthTwips * zoom.pxPerTwip,
541
+ heightPx: region.heightTwips * zoom.pxPerTwip,
542
+ };
543
+
544
+ const blocksForThisRegion =
545
+ footnoteRegions.length === 1
546
+ ? regionBlocks
547
+ : regionBlocks.slice(cursor, cursor + region.fragmentCount);
548
+ cursor += region.fragmentCount;
549
+
550
+ const blocks = projectRegionBlocks(
551
+ blocksForThisRegion,
552
+ frame,
553
+ zoom.pxPerTwip,
554
+ page.pageId,
555
+ page.pageIndex,
556
+ "footnote-area",
557
+ );
558
+
559
+ // Footnote regions don't have a single canonical storyTarget (each
560
+ // block belongs to a different footnote body). Pick the first block's
561
+ // note as the region's primary story when available so chrome surfaces
562
+ // keyed on `storyTarget.kind === "footnote"` can dispatch on the band.
563
+ const firstNote = page.noteAllocations.find(
564
+ (alloc) => alloc.noteKind === "footnote",
565
+ );
566
+ const storyTarget: EditorStoryTarget = firstNote
567
+ ? { kind: "footnote", noteId: firstNote.noteId }
568
+ : MAIN_STORY_TARGET;
569
+
570
+ results.push({
571
+ storyTarget,
572
+ region,
573
+ frame,
574
+ blocks,
575
+ });
576
+ }
577
+
578
+ return results;
579
+ }
580
+
581
+ /**
582
+ * P8.3 — Stack a `PublicRegionBlock[]` into `RenderBlock[]` inside the
583
+ * given region frame. The cursor starts at `regionFrame.topPx` and
584
+ * advances by each block's `heightTwips × pxPerTwip`. Synthesizes a
585
+ * `PublicBlockFragment` shape for each block so chrome surfaces reading
586
+ * `RenderBlock.fragment` see a consistent shape across body / header /
587
+ * footer / footnote-area regions.
588
+ */
589
+ function projectRegionBlocks(
590
+ regionBlocks: readonly PublicRegionBlock[],
591
+ regionFrame: RenderFrameRect,
592
+ pxPerTwip: number,
593
+ pageId: string,
594
+ pageIndex: number,
595
+ regionKind: PublicPageRegion["kind"],
596
+ ): RenderBlock[] {
597
+ const blocks: RenderBlock[] = [];
598
+ let y = regionFrame.topPx;
599
+ for (let i = 0; i < regionBlocks.length; i += 1) {
600
+ const regionBlock = regionBlocks[i]!;
601
+ const blockHeightPx = Math.max(0, regionBlock.heightTwips) * pxPerTwip;
602
+ const blockFrame: RenderFrameRect = {
603
+ leftPx: regionFrame.leftPx,
604
+ topPx: y,
605
+ widthPx: regionFrame.widthPx,
606
+ heightPx: blockHeightPx,
607
+ };
608
+ const fragment: PublicBlockFragment = {
609
+ fragmentId: regionBlock.fragmentId,
610
+ blockId: regionBlock.blockId,
611
+ pageId,
612
+ pageIndex,
613
+ regionKind,
614
+ from: regionBlock.runtimeFromOffset,
615
+ to: regionBlock.runtimeToOffset,
616
+ heightTwips: regionBlock.heightTwips,
617
+ orderInRegion: i,
618
+ };
619
+ blocks.push({
620
+ fragment,
621
+ frame: blockFrame,
622
+ kind: classifyBlockKindFromId(regionBlock.blockId),
623
+ lines: [],
624
+ blockDecorations: [],
625
+ });
626
+ y += blockHeightPx;
627
+ }
628
+ return blocks;
629
+ }
630
+
488
631
  // classifyBlockKind moved to `./block-fragment-projection.ts` (P4).
489
632
 
490
633
  function buildAnchorIndex(
@@ -107,7 +107,7 @@ export function createEditorSurfaceSnapshot(
107
107
  }
108
108
  }
109
109
 
110
- const secondaryStories = createSecondaryStorySurfaces(document);
110
+ const secondaryStories = createSecondaryStorySurfaces(document, numberingPrefixResolver);
111
111
 
112
112
  return {
113
113
  storySize: cursor,
@@ -886,9 +886,13 @@ function appendInlineSegments(
886
886
  return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
887
887
  }
888
888
  case "chart_preview":
889
- return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node));
889
+ return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
890
+ previewMediaId: node.previewMediaId,
891
+ });
890
892
  case "smartart_preview":
891
- return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node));
893
+ return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node), {
894
+ previewMediaId: node.previewMediaId,
895
+ });
892
896
  case "shape":
893
897
  if (promoteSecondaryStoryTextBoxes && node.isTextBox && node.text) {
894
898
  return appendTextBoxSegment(
@@ -1023,6 +1027,7 @@ function appendComplexPreviewSegment(
1023
1027
  start: number,
1024
1028
  label: string,
1025
1029
  detail: string,
1030
+ extras: { previewMediaId?: string } = {},
1026
1031
  ): { nextCursor: number; lockedFragmentIds: string[] } {
1027
1032
  paragraph.segments.push({
1028
1033
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -1033,6 +1038,7 @@ function appendComplexPreviewSegment(
1033
1038
  warningId: `warning:complex-preview:${start}`,
1034
1039
  label,
1035
1040
  detail,
1041
+ ...(extras.previewMediaId ? { previewMediaId: extras.previewMediaId } : {}),
1036
1042
  state: "locked-preserve-only",
1037
1043
  });
1038
1044
  return { nextCursor: start + 1, lockedFragmentIds: [] };
@@ -1174,6 +1180,7 @@ function createPlainText(
1174
1180
 
1175
1181
  function createSecondaryStorySurfaces(
1176
1182
  document: CanonicalDocumentEnvelope,
1183
+ numberingPrefixResolver: NumberingPrefixResolver,
1177
1184
  ): SecondaryStorySurface[] {
1178
1185
  const surfaces: SecondaryStorySurface[] = [];
1179
1186
  const subParts = document.subParts;
@@ -1181,8 +1188,6 @@ function createSecondaryStorySurfaces(
1181
1188
  return surfaces;
1182
1189
  }
1183
1190
 
1184
- const numberingPrefixResolver = createNumberingPrefixResolver(document.numbering);
1185
-
1186
1191
  for (const section of collectSectionContexts(document)) {
1187
1192
  const headerVariants = resolveSectionVariants(
1188
1193
  "header",
@@ -22,28 +22,32 @@ import type {
22
22
  WorkflowCommentMarkup,
23
23
  } from "../api/public-types";
24
24
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
25
- import { searchSurfaceBlocks } from "../core/search/search-text.ts";
25
+ import {
26
+ projectSurfaceText,
27
+ searchProjectedSurfaceText,
28
+ } from "../core/search/search-text.ts";
26
29
  import { describeOpaqueFragment, isBlockedImportFeatureKey } from "../preservation/store.ts";
27
30
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
28
31
 
29
- export function collectWorkflowMarkupSnapshot(input: {
30
- renderSnapshot: RuntimeRenderSnapshot;
31
- fieldSnapshot: FieldSnapshot;
32
- protectionSnapshot: ProtectionSnapshot;
33
- preservation: CanonicalDocumentEnvelope["preservation"];
34
- workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
35
- }): WorkflowMarkupSnapshot {
32
+ /**
33
+ * Surface-derived markup (highlights + block-level opaque fragments).
34
+ *
35
+ * Pure function of `(surface, preservation)` — extracted from
36
+ * `collectWorkflowMarkupSnapshot` so callers can cache the expensive walk
37
+ * separately from the cheap reference-equal inputs (metadata, comments,
38
+ * revisions, protected ranges).
39
+ */
40
+ export function collectWorkflowSurfaceMarkup(
41
+ surface: RuntimeRenderSnapshot["surface"],
42
+ preservation: CanonicalDocumentEnvelope["preservation"],
43
+ ): { highlights: WorkflowHighlightMarkup[]; opaqueFragments: WorkflowOpaqueFragmentMarkup[] } {
36
44
  const highlights: WorkflowHighlightMarkup[] = [];
37
- const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
38
- const fields: WorkflowFieldMarkup[] = [];
39
45
  const opaqueFragments: WorkflowOpaqueFragmentMarkup[] = [];
40
- const surface = input.renderSnapshot.surface;
41
-
42
46
  if (surface) {
43
47
  collectSurfaceMarkup(
44
48
  surface.blocks,
45
49
  MAIN_STORY_TARGET,
46
- input.preservation,
50
+ preservation,
47
51
  highlights,
48
52
  opaqueFragments,
49
53
  );
@@ -51,16 +55,55 @@ export function collectWorkflowMarkupSnapshot(input: {
51
55
  collectSurfaceMarkup(
52
56
  story.blocks,
53
57
  story.target,
54
- input.preservation,
58
+ preservation,
55
59
  highlights,
56
60
  opaqueFragments,
57
61
  );
58
62
  }
63
+ }
64
+ return { highlights, opaqueFragments };
65
+ }
59
66
 
67
+ export function collectWorkflowMarkupSnapshot(input: {
68
+ renderSnapshot: RuntimeRenderSnapshot;
69
+ fieldSnapshot: FieldSnapshot;
70
+ protectionSnapshot: ProtectionSnapshot;
71
+ preservation: CanonicalDocumentEnvelope["preservation"];
72
+ workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
73
+ surfaceMarkupCache?: {
74
+ highlights: WorkflowHighlightMarkup[];
75
+ opaqueFragments: WorkflowOpaqueFragmentMarkup[];
76
+ };
77
+ perfStage?: (name: string, durationMs: number) => void;
78
+ }): WorkflowMarkupSnapshot {
79
+ const perf = input.perfStage;
80
+ const stageStart = perf ? () => performance.now() : () => 0;
81
+ const stageEnd = perf ? (name: string, t0: number) => perf(name, performance.now() - t0) : () => {};
82
+
83
+ const tMeta = stageStart();
84
+ const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
85
+ stageEnd("metadata", tMeta);
86
+
87
+ const fields: WorkflowFieldMarkup[] = [];
88
+ const surface = input.renderSnapshot.surface;
89
+
90
+ const tSurface = stageStart();
91
+ const surfaceMarkup = input.surfaceMarkupCache ??
92
+ collectWorkflowSurfaceMarkup(surface, input.preservation);
93
+ const highlights = surfaceMarkup.highlights.slice();
94
+ const opaqueFragments = surfaceMarkup.opaqueFragments.slice();
95
+ stageEnd("surface", tSurface);
96
+
97
+ if (surface) {
98
+ const tFields = stageStart();
60
99
  fields.push(...collectFieldMarkup(surface, input.fieldSnapshot));
100
+ stageEnd("fields", tFields);
61
101
  }
102
+ const tOpaqueRest = stageStart();
62
103
  opaqueFragments.push(...collectOpaqueFragmentMarkup(input.preservation, opaqueFragments));
104
+ stageEnd("opaqueRest", tOpaqueRest);
63
105
 
106
+ const tCommentsEtc = stageStart();
64
107
  const comments = input.renderSnapshot.comments.threads.map((thread): WorkflowCommentMarkup => ({
65
108
  markupId: `comment:${thread.commentId}`,
66
109
  kind: "comment",
@@ -113,6 +156,8 @@ export function collectWorkflowMarkupSnapshot(input: {
113
156
  }),
114
157
  );
115
158
 
159
+ stageEnd("commentsEtc", tCommentsEtc);
160
+
116
161
  const items: WorkflowMarkupItem[] = [
117
162
  ...highlights,
118
163
  ...metadata,
@@ -303,11 +348,21 @@ function collectFieldMarkup(
303
348
  return [];
304
349
  }
305
350
 
351
+ // L7 Phase 1.5: project each story's text once up-front. The prior code
352
+ // called searchSurfaceBlocks per field, which re-projected the entire
353
+ // surface on every invocation. For the CCEP large-tables fixture this
354
+ // was ~220 ms per commit. Hoisting the projection out of the per-field
355
+ // loop collapses that to a single projection per story.
306
356
  const stories = [
307
- { blocks: surface.blocks, storyTarget: MAIN_STORY_TARGET },
357
+ {
358
+ blocks: surface.blocks,
359
+ storyTarget: MAIN_STORY_TARGET,
360
+ projection: projectSurfaceText(surface.blocks),
361
+ },
308
362
  ...surface.secondaryStories.map((story) => ({
309
363
  blocks: story.blocks,
310
364
  storyTarget: story.target,
365
+ projection: projectSurfaceText(story.blocks),
311
366
  })),
312
367
  ];
313
368
 
@@ -318,7 +373,7 @@ function collectFieldMarkup(
318
373
  }
319
374
 
320
375
  for (const story of stories) {
321
- const matches = searchSurfaceBlocks(story.blocks, displayText, { limit: 2 });
376
+ const matches = searchProjectedSurfaceText(story.projection, displayText, { limit: 2 });
322
377
  if (matches.length === 1) {
323
378
  const match = matches[0]!;
324
379
  return [
@@ -938,6 +938,22 @@ export function __createWordReviewEditorRefBridge(
938
938
  pendingConflicts,
939
939
  });
940
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
+ },
941
957
  setHostAnnotationOverlay: (overlay) => {
942
958
  runtime.setHostAnnotationOverlay(clonePublicValue(overlay));
943
959
  },
@@ -1961,6 +1977,22 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1961
1977
  pendingConflicts: metadataConflictPendingRef.current,
1962
1978
  });
1963
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
+ },
1964
1996
  setHostAnnotationOverlay: (overlay) => {
1965
1997
  setHostAnnotationOverlayState(clonePublicValue(overlay));
1966
1998
  },
@@ -2618,6 +2650,19 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2618
2650
  code: "unsupported_surface",
2619
2651
  message,
2620
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
+ },
2621
2666
  };
2622
2667
 
2623
2668
  const reviewCallbacks = {
@@ -2763,14 +2808,21 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2763
2808
  applyRuntimeImageResize(activeRuntime, mediaId, dimensions),
2764
2809
  onSetImageFrame: (mediaId, offsets) =>
2765
2810
  applyRuntimeImageReposition(activeRuntime, mediaId, offsets),
2766
- onOpenHeaderStory: () =>
2767
- openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "header"),
2768
- onOpenFooterStory: () =>
2769
- 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.
2770
2819
  onOpenHeaderStoryForPage: (pageIndex: number) =>
2771
2820
  openStoryForPage(activeRuntime, pageIndex, "header"),
2772
2821
  onOpenFooterStoryForPage: (pageIndex: number) =>
2773
2822
  openStoryForPage(activeRuntime, pageIndex, "footer"),
2823
+ onOpenStory: (target) => {
2824
+ activeRuntime.openStory(target);
2825
+ },
2774
2826
  onDeleteSectionBreak: (sectionIndex) =>
2775
2827
  applyRuntimeDeleteSectionBreak(activeRuntime, sectionIndex),
2776
2828
  onUpdateSectionLayout: (sectionIndex, patch) =>
@@ -2901,6 +2953,17 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2901
2953
  interactionGuardSnapshot={interactionGuardSnapshot}
2902
2954
  chromePreset={effectiveChromePreset}
2903
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
+ : {})}
2904
2967
  reviewQueue={reviewQueueSnapshot}
2905
2968
  documentContextAnalytics={documentContextAnalytics}
2906
2969
  selectionContextAnalytics={selectionContextAnalytics}
@@ -4866,47 +4929,6 @@ function openStoryForPage(
4866
4929
  runtime.openStory(target);
4867
4930
  }
4868
4931
 
4869
- function openDefaultStoryVariant(
4870
- runtime: WordReviewEditorRuntime,
4871
- pageLayout: PageLayoutSnapshot | undefined,
4872
- navigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]> | undefined,
4873
- kind: "header" | "footer",
4874
- ): void {
4875
- const variants =
4876
- kind === "header"
4877
- ? pageLayout?.headerVariants
4878
- : pageLayout?.footerVariants;
4879
- const activePage = navigation?.pages[navigation.activePageIndex];
4880
- const isFirstPageInSection =
4881
- activePage !== undefined &&
4882
- activePage.sectionIndex === pageLayout?.sectionIndex &&
4883
- activePage.pageInSection === 0;
4884
- const isEvenDocumentPage = activePage !== undefined && (activePage.pageIndex + 1) % 2 === 0;
4885
-
4886
- let variant =
4887
- pageLayout?.differentFirstPage && isFirstPageInSection
4888
- ? variants?.find((entry) => entry.variant === "first")
4889
- : undefined;
4890
-
4891
- if (!variant && pageLayout?.differentOddEvenPages && isEvenDocumentPage) {
4892
- variant = variants?.find((entry) => entry.variant === "even");
4893
- }
4894
-
4895
- if (!variant) {
4896
- variant = variants?.find((entry) => entry.variant === "default") ?? variants?.[0];
4897
- }
4898
-
4899
- if (!variant) {
4900
- return;
4901
- }
4902
- runtime.openStory({
4903
- kind,
4904
- relationshipId: variant.relationshipId,
4905
- variant: variant.variant,
4906
- sectionIndex: pageLayout?.sectionIndex,
4907
- });
4908
- }
4909
-
4910
4932
  function searchRuntimeDocument(
4911
4933
  runtime: WordReviewEditorRuntime,
4912
4934
  mountedSurface: TwProseMirrorSurfaceRef | null,
@@ -2,6 +2,7 @@ import { useMemo, useRef } from "react";
2
2
 
3
3
  import type {
4
4
  CommentSidebarThreadSnapshot,
5
+ EditorStoryTarget,
5
6
  FormattingAlignment,
6
7
  HeaderFooterLinkPatch,
7
8
  InsertImageOptions,
@@ -87,12 +88,25 @@ export interface EditorCommandBag {
87
88
  onAcceptAllChanges(): void;
88
89
  onRejectAllChanges(): void;
89
90
  onCloseStory?(): void;
91
+ /**
92
+ * @deprecated P8.11 — see the matching prop on `TwReviewWorkspaceProps`.
93
+ * Kept optional for back-compat; per-page bands use `onOpenStory`.
94
+ */
90
95
  onOpenHeaderStory?(): void;
96
+ /**
97
+ * @deprecated P8.11 — see `onOpenHeaderStory`.
98
+ */
91
99
  onOpenFooterStory?(): void;
92
100
  /** Open the header story for a specific page (double-click on its band). */
93
101
  onOpenHeaderStoryForPage?(pageIndex: number): void;
94
102
  /** Open the footer story for a specific page (double-click on its band). */
95
103
  onOpenFooterStoryForPage?(pageIndex: number): void;
104
+ /**
105
+ * P8.11 — per-page header/footer band click handler. Receives the
106
+ * exact `EditorStoryTarget` the band represents; the command bag wires
107
+ * this to `runtime.openStory(target)`.
108
+ */
109
+ onOpenStory?(target: EditorStoryTarget): void;
96
110
  onSetParagraphIndentation?(indentation: {
97
111
  left?: number;
98
112
  right?: number;