@beyondwork/docx-react-component 1.0.43 → 1.0.45

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 (48) hide show
  1. package/README.md +17 -0
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +139 -3
  4. package/src/core/commands/formatting-commands.ts +7 -1
  5. package/src/core/commands/index.ts +27 -2
  6. package/src/core/commands/text-commands.ts +59 -0
  7. package/src/core/selection/review-anchors.ts +131 -21
  8. package/src/index.ts +16 -1
  9. package/src/io/chart-preview-resolver.ts +281 -0
  10. package/src/io/docx-session.ts +21 -1
  11. package/src/io/export/build-app-properties-xml.ts +1 -1
  12. package/src/io/export/serialize-comments.ts +38 -9
  13. package/src/io/export/twip.ts +1 -1
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-comments.ts +0 -33
  16. package/src/io/ooxml/parse-complex-content.ts +14 -0
  17. package/src/io/ooxml/parse-main-document.ts +4 -0
  18. package/src/preservation/opaque-region.ts +5 -0
  19. package/src/review/store/comment-remapping.ts +2 -2
  20. package/src/runtime/document-runtime.ts +316 -25
  21. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  22. package/src/runtime/edit-dispatch/index.ts +2 -0
  23. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  24. package/src/runtime/editor-surface/capabilities.ts +411 -0
  25. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  26. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  27. package/src/runtime/layout/layout-engine-version.ts +41 -0
  28. package/src/runtime/layout/public-facet.ts +30 -0
  29. package/src/runtime/prerender/cache-envelope.ts +29 -0
  30. package/src/runtime/prerender/cache-key.ts +66 -0
  31. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  32. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  33. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  34. package/src/runtime/prerender/prerender-document.ts +145 -0
  35. package/src/runtime/render/block-fragment-projection.ts +2 -0
  36. package/src/runtime/selection/post-edit-validator.ts +77 -0
  37. package/src/runtime/surface-projection.ts +35 -2
  38. package/src/ui/WordReviewEditor.tsx +75 -192
  39. package/src/ui/editor-runtime-boundary.ts +5 -1
  40. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  41. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  42. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  43. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  44. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  45. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  46. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  47. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  48. package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
@@ -77,6 +77,18 @@
77
77
  --color-page-border: rgba(0, 0, 0, 0.04);
78
78
  --color-page-bg: #ffffff;
79
79
  --color-page-ruler: color-mix(in srgb, var(--color-border) 65%, transparent);
80
+ /*
81
+ * Phase A (L8 page-native layout): the workspace canvas is the gray
82
+ * surface BEHIND the paper frame(s) — LibreOffice Print Layout idiom.
83
+ * The outer `.wre-page-chrome` wrapper paints this; the inner paper
84
+ * frame paints the white `--color-page-bg` on top.
85
+ *
86
+ * L8 polish (2026-04-19): darkened from #e7e5e4 → #d4d1cc so the canvas
87
+ * reads as a distinctly different surface from the white paper (the
88
+ * earlier tone was close enough that the paper edges blurred against
89
+ * the canvas on high-brightness screens).
90
+ */
91
+ --color-workspace-canvas: #d4d1cc;
80
92
 
81
93
  /*
82
94
  * ─── Radius tokens (balanced 10 / 8 / 4 / 2) ───
@@ -188,6 +200,7 @@
188
200
  --color-page-border: rgba(255, 255, 255, 0.06);
189
201
  --color-page-bg: #1B2620;
190
202
  --color-page-ruler: color-mix(in srgb, var(--color-border) 70%, transparent);
203
+ --color-workspace-canvas: #111827;
191
204
 
192
205
  --shadow-soft: 0 6px 18px -10px rgba(0, 0, 0, 0.55);
193
206
  --shadow-float: 0 18px 40px -22px rgba(0, 0, 0, 0.7);
@@ -253,26 +266,39 @@
253
266
  widows: 2;
254
267
  }
255
268
 
256
- /* Page chrome — shadow, border, and background for the page-mode document panel */
269
+ /*
270
+ * Phase A (L8 page-native layout): the outer `.wre-page-chrome` wrapper is
271
+ * now the **workspace canvas** — the gray surface behind the paper frame(s),
272
+ * mirroring LibreOffice Writer's Print Layout. Paper styling (white
273
+ * background, rounded corners, drop shadow, border) lives solely on the
274
+ * inner `[data-paper-frame]` wrapper via `pageShellMetrics.pageFrameStyle`,
275
+ * eliminating the pre-Phase-A double-chrome artefact.
276
+ *
277
+ * Historical note: before 2026-04-19 this selector painted paper chrome
278
+ * (background/border/radius/shadow) AND the outer wrapper was sized to
279
+ * `frameWidthPx`. The inner wrapper painted the SAME paper chrome, producing
280
+ * two nested rounded rectangles visible in every page-mode screenshot.
281
+ */
257
282
  .wre-page-chrome {
258
- background: var(--color-page-bg);
259
- border: 1px solid var(--color-page-border);
260
- border-radius: var(--radius-page);
261
- box-shadow: 0 8px 24px -20px var(--color-page-shadow);
283
+ background: var(--color-workspace-canvas);
284
+ padding: 2rem 0;
262
285
  }
263
286
 
264
- /* Canvas-mode typography — lighter, review-first baseline */
287
+ /*
288
+ * Canvas-mode typography — lighter, review-first baseline.
289
+ *
290
+ * L8 Phase B (2026-04-19): canvas mode is **continuous flow, no paper
291
+ * card**. Paper-card declarations (width cap, min-height, background,
292
+ * border, rounded corners, drop shadow) were removed so the review
293
+ * surface reads as a single scrollable column. The mode-contract gate
294
+ * (page-mode paper card vs canvas-mode continuous flow) is pinned by
295
+ * `test/ui/pm-page-break-decorations-posture.test.ts`.
296
+ */
265
297
  .wre-canvas-surface {
266
298
  font-family: var(--font-legal-serif);
267
299
  font-size: 15px;
268
300
  line-height: 1.6;
269
301
  color: var(--color-primary);
270
- width: min(100%, 920px);
271
- min-height: 100%;
272
- background: var(--color-page-bg);
273
- border: 1px solid var(--color-page-border);
274
- border-radius: var(--radius-page);
275
- box-shadow: 0 6px 18px -14px var(--color-page-shadow);
276
302
  -webkit-font-smoothing: antialiased;
277
303
  -moz-osx-font-smoothing: grayscale;
278
304
  text-rendering: optimizeLegibility;
@@ -734,8 +760,15 @@
734
760
  pointer-events: none;
735
761
  }
736
762
 
737
- /* ─── Page workspace zoom scaling ─── */
738
- .wre-page-chrome[style*="scale"] {
763
+ /*
764
+ * ─── Paper-frame zoom scaling ───
765
+ *
766
+ * Phase A (L8 page-native layout): browser-native CSS `zoom` lives on the
767
+ * inner paper frame, not the workspace canvas, so layout measurement stays
768
+ * truthful inside the card. `will-change: transform` primes the compositor
769
+ * when a zoom is active.
770
+ */
771
+ [data-paper-frame][style*="zoom"] {
739
772
  will-change: transform;
740
773
  }
741
774
 
@@ -52,6 +52,7 @@ import {
52
52
  incrementInvalidationCounter,
53
53
  recordPerfSample,
54
54
  } from "./editor-surface/perf-probe.ts";
55
+ import { useVisibleBlockRange } from "./page-stack/use-visible-block-range.ts";
55
56
  import { sliceBlocksForPage } from "./editor-surface/page-slice-util.ts";
56
57
  import { TwPageBlockView } from "./editor-surface/tw-page-block-view.tsx";
57
58
  import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
@@ -895,6 +896,101 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
895
896
  );
896
897
  }, [reviewRailAvailable, viewportWidth]);
897
898
 
899
+ // L7 Phase 2 Task 2.2.4a — viewport-scroll wiring.
900
+ // Collect DOM elements with `[data-page-frame]` from the PM surface so the
901
+ // IntersectionObserver inside `useVisibleBlockRange` can determine which
902
+ // pages are currently visible. The MutationObserver refreshes the set when
903
+ // the PM surface re-renders (e.g. a document load changes the page count).
904
+ const [pageMarkers, setPageMarkers] = useState<readonly HTMLElement[]>([]);
905
+
906
+ useEffect(() => {
907
+ const root = pageStackScrollRoot;
908
+ if (!root) {
909
+ setPageMarkers([]);
910
+ return undefined;
911
+ }
912
+ const refresh = () => {
913
+ const found = Array.from(root.querySelectorAll<HTMLElement>("[data-page-frame]"));
914
+ // The boundary widgets between pages N and N+1 carry `data-page-frame`
915
+ // for the *next* page (pages 1 … N). Page 0 has no widget before it,
916
+ // so we synthesize an in-memory element that carries page-0's attributes.
917
+ // This element is NOT in the DOM — the IntersectionObserver won't fire
918
+ // on it — but the hook's useMemo uses it to look up block indices when
919
+ // the overscan expansion includes page 0 (which happens whenever any of
920
+ // pages 1-2 are visible with overscan ≥ 1).
921
+ const hasPage0 = found.some(
922
+ (el) => el.getAttribute("data-page-frame") === "0",
923
+ );
924
+ if (!hasPage0 && found.length > 0) {
925
+ // Derive page-0 block range from the snapshot surface: page 0 starts
926
+ // at block 0 and ends just before the first block that belongs to page 1.
927
+ // The boundary widget's `data-page-first-block-index` for page 1 tells
928
+ // us where page 0 ends.
929
+ const page1Marker = found.find(
930
+ (el) => el.getAttribute("data-page-frame") === "1",
931
+ );
932
+ const page1First = page1Marker
933
+ ? Number(page1Marker.getAttribute("data-page-first-block-index") ?? "")
934
+ : NaN;
935
+ const page0Last = Number.isFinite(page1First) && page1First > 0
936
+ ? page1First - 1
937
+ : -1; // page 0 empty or unknown; synthetic marker contributes nothing.
938
+ const ownerDoc = found[0]!.ownerDocument;
939
+ const synth = ownerDoc.createElement("span");
940
+ synth.setAttribute("data-page-frame", "0");
941
+ synth.setAttribute("data-page-first-block-index", "0");
942
+ synth.setAttribute("data-page-last-block-index", String(Math.max(0, page0Last)));
943
+ setPageMarkers([synth, ...found]);
944
+ } else {
945
+ setPageMarkers(found);
946
+ }
947
+ };
948
+ refresh();
949
+ // Observe PM surface mutations for page-count changes (new doc load, etc).
950
+ const view = root.ownerDocument?.defaultView;
951
+ if (!view?.MutationObserver) return undefined;
952
+ const mo = new view.MutationObserver(refresh);
953
+ mo.observe(root, { childList: true, subtree: true });
954
+ return () => mo.disconnect();
955
+ // Re-run when the scroll root changes OR when a new snapshot lands
956
+ // (which may have a different page count and new widgets in the PM DOM).
957
+ // NOTE: snapshot.surface is intentionally excluded — its reference changes on
958
+ // every requestViewportRefresh(), which would add two extra render passes per
959
+ // scroll event. The page-0 fallback uses -1 when page1First is unknown,
960
+ // which is correct (no page-0 blocks → synthetic marker contributes nothing).
961
+ }, [pageStackScrollRoot, snapshot.revisionToken]);
962
+
963
+ // Derive the surface block index for the current selection head so the
964
+ // hook can extend the visible range to always include the selection.
965
+ const selectionBlockIndex = useMemo(() => {
966
+ const sel = snapshot.selection;
967
+ const blocks = snapshot.surface?.blocks;
968
+ if (!sel || !blocks) return null;
969
+ for (let i = 0; i < blocks.length; i++) {
970
+ const block = blocks[i]!; // from/to are required on all SurfaceBlockSnapshot variants
971
+ const blockFrom = block.from;
972
+ const blockTo = block.to;
973
+ if (sel.head >= blockFrom && sel.head <= blockTo) return i;
974
+ }
975
+ return null;
976
+ }, [snapshot.selection, snapshot.surface]);
977
+
978
+ const visibleBlockRange = useVisibleBlockRange({
979
+ pageMarkers,
980
+ overscanPages: 2,
981
+ selectionBlockIndex,
982
+ totalBlockCount: snapshot.surface?.blocks.length ?? 0,
983
+ });
984
+
985
+ // Push the visible range into the layout facet (which delegates to the
986
+ // runtime's viewport-culling machinery). Depend on `[start, end]` values
987
+ // (not the range object) so identity-preserving updates are a no-op.
988
+ useEffect(() => {
989
+ if (!props.layoutFacet) return;
990
+ props.layoutFacet.setVisibleBlockRange(visibleBlockRange);
991
+ props.layoutFacet.requestViewportRefresh();
992
+ }, [props.layoutFacet, visibleBlockRange.start, visibleBlockRange.end]);
993
+
898
994
  const dismissSelectionToolbar = useCallback(() => {
899
995
  props.onDismissSelectionToolbar?.();
900
996
  }, [props.onDismissSelectionToolbar]);
@@ -1222,32 +1318,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1222
1318
  ref={selectionToolbarRootRef}
1223
1319
  className={`mx-auto min-h-full w-full ${
1224
1320
  isPageWorkspace
1225
- ? "wre-page-chrome wre-page-surface relative my-8 overflow-hidden"
1321
+ ? "wre-page-chrome relative overflow-hidden"
1226
1322
  : "wre-canvas-surface relative my-8 overflow-hidden"
1227
1323
  }`}
1228
1324
  data-zoom-bucket={pageZoomBucket}
1229
1325
  data-zoom-scale={isPageWorkspace ? zoomScale : undefined}
1230
- style={
1231
- isPageWorkspace
1232
- ? {
1233
- // P2.a — real-dim page frame: width/height from
1234
- // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`
1235
- // so every paper size renders at its
1236
- // Word-matching CSS px. `max-w-[840px]` retired.
1237
- ...(pageShellMetrics.frameWidthPx
1238
- ? { width: `${pageShellMetrics.frameWidthPx}px` }
1239
- : {}),
1240
- ...(pageShellMetrics.frameHeightPx
1241
- ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1242
- : {}),
1243
- // P2.b — browser-native CSS `zoom` rescales layout
1244
- // so `getBoundingClientRect()` and hit-test offsets
1245
- // stay truthful at any zoom — no inverse-projection
1246
- // math needed downstream.
1247
- ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1248
- }
1249
- : undefined
1250
- }
1326
+ data-workspace-canvas={isPageWorkspace ? "true" : undefined}
1327
+ data-workspace-mode={isPageWorkspace ? "page" : "canvas"}
1251
1328
  >
1252
1329
  {isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout ? (
1253
1330
  <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
@@ -1448,8 +1525,38 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1448
1525
  />
1449
1526
  ) : null}
1450
1527
  <div
1451
- className={isPageWorkspace ? "relative" : undefined}
1528
+ className={
1529
+ isPageWorkspace
1530
+ ? "wre-page-surface relative mx-auto my-8"
1531
+ : "relative"
1532
+ }
1452
1533
  data-line-numbering={pageChromeModel.lineNumberingEnabled ? "enabled" : "disabled"}
1534
+ data-paper-frame={isPageWorkspace ? "true" : undefined}
1535
+ data-debug-page-layout={
1536
+ isPageWorkspace && snapshot.pageLayout
1537
+ ? `${snapshot.pageLayout.pageWidth}:${snapshot.pageLayout.pageHeight}:${snapshot.pageLayout.orientation}:${snapshot.pageLayout.sectionIndex}`
1538
+ : undefined
1539
+ }
1540
+ style={
1541
+ isPageWorkspace
1542
+ ? {
1543
+ // Phase A (L8 page-native layout): the paper frame
1544
+ // owns paper width/height + browser-native CSS
1545
+ // `zoom` so layout measurement stays truthful
1546
+ // inside the card. `pageShellMetrics.pageFrameStyle`
1547
+ // carries the paper chrome (bg + shadow + rounded +
1548
+ // border) — painted exactly once.
1549
+ ...(pageShellMetrics.frameWidthPx
1550
+ ? { width: `${pageShellMetrics.frameWidthPx}px` }
1551
+ : {}),
1552
+ ...(pageShellMetrics.frameHeightPx
1553
+ ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1554
+ : {}),
1555
+ ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1556
+ ...pageShellMetrics.pageFrameStyle,
1557
+ }
1558
+ : undefined
1559
+ }
1453
1560
  >
1454
1561
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.lineNumberingEnabled ? (
1455
1562
  <div
@@ -1477,12 +1584,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1477
1584
  className={isPageWorkspace ? "relative" : undefined}
1478
1585
  data-document-grid={pageChromeModel.documentGridType}
1479
1586
  data-page-border-display={pageChromeModel.pageBorderDisplay}
1480
- style={isPageWorkspace
1481
- ? {
1482
- ...pageChromeModel.documentGridStyle,
1483
- ...pageShellMetrics.pageFrameStyle,
1484
- }
1485
- : pageChromeModel.documentGridStyle}
1587
+ style={pageChromeModel.documentGridStyle}
1486
1588
  >
1487
1589
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.showPageBorder && !hidePageBorderForActiveEditing ? (
1488
1590
  <div