@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,6 +17,7 @@ import type {
17
17
  ActiveListContext,
18
18
  CommentSidebarThreadSnapshot,
19
19
  DocumentNavigationSnapshot,
20
+ EditorStoryTarget,
20
21
  EditorViewStateSnapshot,
21
22
  FormattingStateSnapshot,
22
23
  FormattingAlignment,
@@ -157,6 +158,16 @@ export interface TwReviewWorkspaceProps {
157
158
  interactionGuardSnapshot?: InteractionGuardSnapshot;
158
159
  chromePreset?: WordReviewEditorChromePreset;
159
160
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
161
+ /** P9g — live collab session for the `"collab"` chrome preset's top nav. */
162
+ collabSession?: import("../runtime/collab-session.ts").CollabSession;
163
+ collabTransportStatus?: import("../api/awareness-identity-types.ts").TransportStatus;
164
+ collabActorId?: string;
165
+ collabSendBaseline?: {
166
+ originDocumentId: string;
167
+ originPayloadId: string;
168
+ originContentHash: string;
169
+ payloadXml: string;
170
+ };
160
171
  reviewQueue?: ReviewQueueSnapshot;
161
172
  documentContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
162
173
  selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
@@ -278,7 +289,18 @@ export interface TwReviewWorkspaceProps {
278
289
  onAcceptAllChanges?: () => void;
279
290
  onRejectAllChanges?: () => void;
280
291
  onCloseStory?: () => void;
292
+ /**
293
+ * @deprecated P8.11 — the workspace no longer renders a workspace-level
294
+ * header band with an "Edit header" button; per-page header bands route
295
+ * clicks via `onOpenStory` / `runtime.openStory` directly. The prop
296
+ * remains optional for one release so existing hosts continue to
297
+ * compile; supplying it emits a `console.warn` on mount.
298
+ */
281
299
  onOpenHeaderStory?: () => void;
300
+ /**
301
+ * @deprecated P8.11 — see `onOpenHeaderStory`. Footer variant of the
302
+ * same deprecation.
303
+ */
282
304
  onOpenFooterStory?: () => void;
283
305
  /**
284
306
  * Open a header/footer story for a specific page. Called when the user
@@ -288,6 +310,13 @@ export interface TwReviewWorkspaceProps {
288
310
  */
289
311
  onOpenHeaderStoryForPage?: (pageIndex: number) => void;
290
312
  onOpenFooterStoryForPage?: (pageIndex: number) => void;
313
+ /**
314
+ * P8.11 — fired when a per-page chrome band (header / footer) is
315
+ * clicked to promote it into the active editing surface. Wire to
316
+ * `runtime.openStory(target)`; the chrome layer's portal mechanism
317
+ * then reparents the PM surface into the matching band's active slot.
318
+ */
319
+ onOpenStory?: (target: EditorStoryTarget) => void;
291
320
  onSetParagraphIndentation?: (indentation: {
292
321
  left?: number;
293
322
  right?: number;
@@ -384,6 +413,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
384
413
  } as TwReviewWorkspaceProps & EditorCommandBag;
385
414
  const { snapshot, viewState } = props;
386
415
  const selectionToolbarRootRef = useRef<HTMLDivElement>(null);
416
+ // P8.11 — body slot wrapping `{props.document}` (the PM surface) + scroll
417
+ // root ref. The chrome layer's `TwPageStackChromeLayer` needs both to
418
+ // measure per-page rects and to reparent PM's DOM node across band
419
+ // portals when `activeStory` changes. See comment near the body slot
420
+ // in the render tree below.
421
+ const bodySlotRef = useRef<HTMLDivElement | null>(null);
422
+ const scrollRootRef = useRef<HTMLDivElement | null>(null);
423
+ const [pmSurfaceElement, setPmSurfaceElement] =
424
+ useState<HTMLElement | null>(null);
425
+ const [pageStackScrollRoot, setPageStackScrollRoot] =
426
+ useState<HTMLElement | null>(null);
387
427
  const caps = props.capabilities;
388
428
  const isPageWorkspace = props.workspaceMode === "page";
389
429
  const markupDisplay = props.markupDisplay;
@@ -640,8 +680,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
640
680
  };
641
681
  // eslint-disable-next-line react-hooks/exhaustive-deps
642
682
  }, [props.layoutFacet, selectionPosition, snapshot.activeStory, renderFrameRevision]);
643
- const headerBandLabel = resolvePageBandLabel("header", snapshot.activeStory);
644
- const footerBandLabel = resolvePageBandLabel("footer", snapshot.activeStory);
683
+ // P8.11 — `headerBandLabel` / `footerBandLabel` retired along with the
684
+ // workspace-level bands. Per-page bands in `TwPageStackChromeLayer`
685
+ // render the actual header / footer story blocks via
686
+ // `TwRegionBlockRenderer`, so a label row is no longer meaningful here.
645
687
  const hidePageBorderForActiveEditing =
646
688
  isPageWorkspace &&
647
689
  snapshot.activeStory.kind === "main" &&
@@ -708,6 +750,88 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
708
750
  }
709
751
  }, [isPageWorkspace, snapshot.activeStory.kind]);
710
752
 
753
+ // P8.11 — capture the scroll-root DOM element on mount so the chrome
754
+ // overlay's `TwPageStackChromeLayer` can measure per-page rects and
755
+ // observe DOM mutations. `scrollRootRef` is attached to the existing
756
+ // `[data-wre-scroll-root]` container; rely on a mount effect rather
757
+ // than a ref callback so render-time state stays cheap.
758
+ useEffect(() => {
759
+ if (scrollRootRef.current !== pageStackScrollRoot) {
760
+ setPageStackScrollRoot(scrollRootRef.current);
761
+ }
762
+ // A `useEffect` re-runs after every render; the comparison guard
763
+ // keeps `setPageStackScrollRoot` from firing every commit. The
764
+ // scroll-root identity only changes when the component re-mounts.
765
+ });
766
+
767
+ // P8.11 — capture the PM surface DOM element. The ProseMirror surface
768
+ // mounts inside `bodySlotRef` on its own schedule (the PM constructor
769
+ // runs inside the `TwProseMirrorSurface` child component). A
770
+ // `MutationObserver` scoped to the body slot's `childList` picks up
771
+ // the PM root on first commit; once captured, the chrome layer owns
772
+ // reparent state (including portal-slot promotion), so we skip
773
+ // further updates unless PM is actually disconnected from the
774
+ // document (e.g. session/document swap tearing PM down).
775
+ useEffect(() => {
776
+ const slot = bodySlotRef.current;
777
+ if (!slot) return undefined;
778
+
779
+ // If we already hold a live reference, the chrome layer may have
780
+ // portaled PM into a per-page band — PM has left `bodySlotRef` but
781
+ // is still connected to the document. We keep the reference until
782
+ // the node is fully disconnected.
783
+ if (pmSurfaceElement && pmSurfaceElement.isConnected) {
784
+ return undefined;
785
+ }
786
+
787
+ const readPm = (): HTMLElement | null =>
788
+ slot.querySelector<HTMLElement>(".ProseMirror");
789
+
790
+ const current = readPm();
791
+ if (current !== pmSurfaceElement) {
792
+ setPmSurfaceElement(current);
793
+ }
794
+ const runtime = slot.ownerDocument?.defaultView as
795
+ | (Window & { MutationObserver?: typeof MutationObserver })
796
+ | null;
797
+ if (!runtime?.MutationObserver) return undefined;
798
+ const observer = new runtime.MutationObserver(() => {
799
+ const next = readPm();
800
+ if (next !== null && next !== pmSurfaceElement) {
801
+ setPmSurfaceElement(next);
802
+ }
803
+ });
804
+ // `childList: true, subtree: false` — we only care when children of
805
+ // the body slot change (e.g. PM is added for the first time).
806
+ // Subtree mutations (PM's own edits) are not our concern and would
807
+ // fire on every keystroke.
808
+ observer.observe(slot, { childList: true, subtree: false });
809
+ return () => observer.disconnect();
810
+ }, [pmSurfaceElement]);
811
+
812
+ // P8.11 — deprecation shim for the legacy `onOpenHeaderStory` /
813
+ // `onOpenFooterStory` props. Per-page chrome bands route clicks via
814
+ // `onOpenStory` + `runtime.openStory` directly; the workspace-level
815
+ // bands that consumed these callbacks are gone. Kept optional for one
816
+ // release so existing hosts compile; a mount-time `console.warn` nudges
817
+ // them toward `onOpenStory`.
818
+ useEffect(() => {
819
+ if (props.onOpenHeaderStory) {
820
+ // eslint-disable-next-line no-console
821
+ console.warn(
822
+ "[docx-react-component] `onOpenHeaderStory` is deprecated. Per-page header bands route clicks via runtime.openStory directly. (P8)",
823
+ );
824
+ }
825
+ if (props.onOpenFooterStory) {
826
+ // eslint-disable-next-line no-console
827
+ console.warn(
828
+ "[docx-react-component] `onOpenFooterStory` is deprecated. Per-page footer bands route clicks via runtime.openStory directly. (P8)",
829
+ );
830
+ }
831
+ // Mount-once: we only want to nudge hosts at startup, not per render.
832
+ // eslint-disable-next-line react-hooks/exhaustive-deps
833
+ }, []);
834
+
711
835
  useEffect(() => {
712
836
  if (typeof window === "undefined") {
713
837
  return;
@@ -818,6 +942,20 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
818
942
  <div className="px-3 pt-3">
819
943
  <ChromePresetToolbar
820
944
  chromePreset={chromePreset}
945
+ {...(props.collabSession ? { collabSession: props.collabSession } : {})}
946
+ {...(props.collabTransportStatus
947
+ ? { collabTransportStatus: props.collabTransportStatus }
948
+ : {})}
949
+ {...(props.activeCommentId !== undefined
950
+ ? { activeCommentId: props.activeCommentId }
951
+ : {})}
952
+ {...(props.collabActorId !== undefined
953
+ ? { collabActorId: props.collabActorId }
954
+ : {})}
955
+ {...(props.collabSendBaseline
956
+ ? { collabSendBaseline: props.collabSendBaseline }
957
+ : {})}
958
+ chromeOptionsResolved={chromeOptions}
821
959
  capabilities={caps}
822
960
  compatibility={snapshot.compatibility}
823
961
  warnings={snapshot.warnings}
@@ -1076,6 +1214,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1076
1214
  {/* Document column */}
1077
1215
  <div className="flex flex-1 flex-col min-w-0">
1078
1216
  <div
1217
+ ref={scrollRootRef}
1079
1218
  className="flex-1 overflow-y-auto bg-surface"
1080
1219
  data-wre-scroll-root="true"
1081
1220
  >
@@ -1345,25 +1484,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1345
1484
  }
1346
1485
  : pageChromeModel.documentGridStyle}
1347
1486
  >
1348
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1349
- <div
1350
- data-testid="page-header-band"
1351
- className="relative z-10 flex items-center justify-between border-b border-border/50 bg-surface/45 px-4 text-[11px] text-secondary backdrop-blur-[1px]"
1352
- style={pageShellMetrics.headerBandStyle}
1353
- >
1354
- <span className="uppercase tracking-[0.12em] text-tertiary">{headerBandLabel}</span>
1355
- {snapshot.pageLayout?.headerVariants[0] ? (
1356
- <button
1357
- type="button"
1358
- aria-label="Open header story"
1359
- onClick={props.onOpenHeaderStory}
1360
- className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1361
- >
1362
- Edit header
1363
- </button>
1364
- ) : null}
1365
- </div>
1366
- ) : null}
1367
1487
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.showPageBorder && !hidePageBorderForActiveEditing ? (
1368
1488
  <div
1369
1489
  aria-hidden="true"
@@ -1373,15 +1493,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1373
1493
  />
1374
1494
  ) : null}
1375
1495
  <div className={isPageWorkspace ? "relative z-10" : "relative"}>
1376
- {/* Page chrome (frame borders, header/footer bands,
1377
- page-number labels, inter-page separators) is
1378
- rendered as in-flow widget decorations inside
1379
- the PM surface itself see
1380
- `pm-page-break-decorations.ts`. That keeps the
1381
- chrome perfectly aligned with PM content without
1382
- any absolute-positioned overlay that would drift
1383
- relative to the browser's line layout. */}
1384
- {props.document}
1496
+ {/* P8.11 workspace-level header / footer bands
1497
+ retired. The PM surface now mounts inside
1498
+ `data-pm-body-slot`; per-page header, footer,
1499
+ footnote, and endnote chrome is owned by
1500
+ `TwPageStackChromeLayer` inside `TwChromeOverlay`
1501
+ (see below). When the user clicks a per-page
1502
+ band, the chrome layer reparents PM's DOM node
1503
+ into the active band's `data-pm-portal-slot`;
1504
+ when the user returns to the body, PM slides
1505
+ back into this wrapper. */}
1506
+ <div
1507
+ data-pm-body-slot=""
1508
+ ref={bodySlotRef}
1509
+ style={{ width: "100%" }}
1510
+ >
1511
+ {props.document}
1512
+ </div>
1385
1513
  {props.layoutFacet ? (
1386
1514
  <TwChromeOverlay
1387
1515
  facet={props.layoutFacet}
@@ -1410,28 +1538,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1410
1538
  ? handleScopeCardAskAgent
1411
1539
  : undefined
1412
1540
  }
1541
+ pageStackScrollRoot={
1542
+ isPageWorkspace && chromeVisibility.pageChrome
1543
+ ? pageStackScrollRoot
1544
+ : undefined
1545
+ }
1546
+ renderFrameRevision={renderFrameRevision}
1547
+ activeStory={snapshot.activeStory}
1548
+ onOpenStory={props.onOpenStory}
1549
+ pmSurfaceElement={pmSurfaceElement}
1413
1550
  />
1414
1551
  ) : null}
1415
1552
  </div>
1416
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1417
- <div
1418
- data-testid="page-footer-band"
1419
- className="relative z-10 flex items-center justify-between border-t border-border/50 bg-surface/45 px-4 text-[11px] text-secondary backdrop-blur-[1px]"
1420
- style={pageShellMetrics.footerBandStyle}
1421
- >
1422
- <span className="uppercase tracking-[0.12em] text-tertiary">{footerBandLabel}</span>
1423
- {snapshot.pageLayout?.footerVariants[0] ? (
1424
- <button
1425
- type="button"
1426
- aria-label="Open footer story"
1427
- onClick={props.onOpenFooterStory}
1428
- className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1429
- >
1430
- Edit footer
1431
- </button>
1432
- ) : null}
1433
- </div>
1434
- ) : null}
1435
1553
  </div>
1436
1554
  </div>
1437
1555
  </div>
@@ -1717,8 +1835,6 @@ export interface PageShellMetrics {
1717
1835
  frameHeightPx?: number;
1718
1836
  contentInsetStyle: CSSProperties;
1719
1837
  pageFrameStyle: CSSProperties;
1720
- headerBandStyle: CSSProperties;
1721
- footerBandStyle: CSSProperties;
1722
1838
  }
1723
1839
 
1724
1840
  function buildPageChromeModel(
@@ -1764,8 +1880,6 @@ export function buildPageShellMetrics(
1764
1880
  return {
1765
1881
  contentInsetStyle: {},
1766
1882
  pageFrameStyle: {},
1767
- headerBandStyle: {},
1768
- footerBandStyle: {},
1769
1883
  frameWidthPx: 0,
1770
1884
  frameHeightPx: 0,
1771
1885
  };
@@ -1779,17 +1893,11 @@ export function buildPageShellMetrics(
1779
1893
  const frameHeightPx = Math.round(pageLayout.pageHeight * pxPerTwip);
1780
1894
  const horizontalInsetPx = Math.round(pageLayout.marginLeft * pxPerTwip);
1781
1895
  const horizontalInsetRightPx = Math.round(pageLayout.marginRight * pxPerTwip);
1782
- // Header BAND height = margin space NOT consumed by header content.
1783
- // When marginTop == headerMargin, the band has no headroom — floor to
1784
- // MIN_BAND_HEIGHT_PX so empty bands stay clickable.
1785
- const headerBandHeightPx = Math.max(
1786
- MIN_BAND_HEIGHT_PX,
1787
- Math.round(Math.max(0, pageLayout.marginTop - pageLayout.headerMargin) * pxPerTwip),
1788
- );
1789
- const footerBandHeightPx = Math.max(
1790
- MIN_BAND_HEIGHT_PX,
1791
- Math.round(Math.max(0, pageLayout.marginBottom - pageLayout.footerMargin) * pxPerTwip),
1792
- );
1896
+
1897
+ // P8.11 `headerBandStyle` / `footerBandStyle` removed. The
1898
+ // workspace-level band divs that consumed them are gone; per-page
1899
+ // bands (rendered by `TwPageStackChromeLayer`) compute their own
1900
+ // heights from the runtime's `PageRegionsSnapshot`.
1793
1901
 
1794
1902
  return {
1795
1903
  contentInsetStyle: {
@@ -1802,12 +1910,6 @@ export function buildPageShellMetrics(
1802
1910
  boxShadow: "0 24px 48px -32px rgba(15, 23, 42, 0.38), 0 8px 20px -18px rgba(15, 23, 42, 0.22)",
1803
1911
  border: "1px solid rgba(148, 163, 184, 0.2)",
1804
1912
  },
1805
- headerBandStyle: {
1806
- minHeight: `${headerBandHeightPx}px`,
1807
- },
1808
- footerBandStyle: {
1809
- minHeight: `${footerBandHeightPx}px`,
1810
- },
1811
1913
  frameWidthPx,
1812
1914
  frameHeightPx,
1813
1915
  };
@@ -1849,30 +1951,6 @@ export function resolveZoomMultiplier(
1849
1951
  );
1850
1952
  }
1851
1953
 
1852
- function resolvePageBandLabel(
1853
- region: "header" | "footer",
1854
- activeStory: RuntimeRenderSnapshot["activeStory"],
1855
- ): string {
1856
- const regionLabel = region === "header" ? "header" : "footer";
1857
- let label: string;
1858
- if (activeStory.kind !== region) {
1859
- label = region === "header" ? "Header" : "Footer";
1860
- } else {
1861
- switch (activeStory.variant) {
1862
- case "first":
1863
- label = `First page ${regionLabel}`;
1864
- break;
1865
- case "even":
1866
- label = `Even page ${regionLabel}`;
1867
- break;
1868
- default:
1869
- label = `Default ${regionLabel}`;
1870
- break;
1871
- }
1872
- }
1873
- return label;
1874
- }
1875
-
1876
1954
  function buildLineNumberMarkers(
1877
1955
  blocks: readonly SurfaceBlockSnapshot[],
1878
1956
  pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,