@beyondwork/docx-react-component 1.0.48 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +84 -12
  4. package/src/core/commands/index.ts +9 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-main-document.ts +9 -0
  12. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  13. package/src/io/export/serialize-run-formatting.ts +10 -1
  14. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  15. package/src/io/ooxml/chart/color-palette.ts +101 -0
  16. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  17. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  18. package/src/io/ooxml/chart/parse-series.ts +76 -11
  19. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  20. package/src/io/ooxml/chart/types.ts +30 -11
  21. package/src/io/ooxml/parse-complex-content.ts +6 -3
  22. package/src/io/ooxml/parse-main-document.ts +41 -0
  23. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  24. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  25. package/src/io/ooxml/property-grab-bag.ts +211 -0
  26. package/src/model/canonical-document.ts +69 -3
  27. package/src/runtime/collab/index.ts +7 -0
  28. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  29. package/src/runtime/collab/workflow-shared.ts +247 -0
  30. package/src/runtime/document-locations.ts +1 -9
  31. package/src/runtime/document-outline.ts +1 -9
  32. package/src/runtime/document-runtime.ts +74 -49
  33. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  34. package/src/runtime/surface-projection.ts +94 -36
  35. package/src/runtime/theme-color-resolver.ts +188 -0
  36. package/src/runtime/workflow-markup.ts +7 -18
  37. package/src/ui/WordReviewEditor.tsx +18 -2
  38. package/src/ui/editor-runtime-boundary.ts +36 -0
  39. package/src/ui/headless/selection-helpers.ts +10 -23
  40. package/src/ui/unsupported-previews-policy.ts +23 -0
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  42. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  45. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -1,31 +1,22 @@
1
1
  import type { SelectionSnapshot } from "../../api/public-types";
2
+ import {
3
+ createPublicNodeAnchor,
4
+ createPublicRangeAnchor,
5
+ } from "../../core/selection/anchor-conversion.ts";
2
6
 
3
7
  /**
4
8
  * Headless-UI-side `createSelectionSnapshot` that produces the **public**
5
- * `EditorAnchorProjection` shape (top-level `from`/`to`). The runtime-facing
6
- * twin at `src/core/state/editor-state.ts` produces the internal
7
- * `RangeAnchor` shape (`range: { from, to }`). The two are *not*
8
- * interchangeablethey serve different type contracts. See the
9
- * `EditorAnchorProjection` definitions in `src/api/public-types.ts` vs
10
- * `src/core/selection/mapping.ts`. Do not merge without first unifying
11
- * those two definitions.
9
+ * `EditorAnchorProjection` shape via the canonical
10
+ * `createPublicRangeAnchor` constructor. The runtime-facing twin at
11
+ * `src/core/state/editor-state.ts` produces the internal `RangeAnchor`
12
+ * shape (`range: { from, to }`) the two are not interchangeable.
12
13
  */
13
14
  export function createSelectionSnapshot(anchor: number, head = anchor): SelectionSnapshot {
14
- const from = Math.min(anchor, head);
15
- const to = Math.max(anchor, head);
16
15
  return {
17
16
  anchor,
18
17
  head,
19
18
  isCollapsed: anchor === head,
20
- activeRange: {
21
- kind: "range",
22
- from,
23
- to,
24
- assoc: {
25
- start: -1,
26
- end: 1,
27
- },
28
- },
19
+ activeRange: createPublicRangeAnchor(anchor, head),
29
20
  };
30
21
  }
31
22
 
@@ -34,11 +25,7 @@ export function createNodeSelectionSnapshot(at: number, assoc: -1 | 1 = 1): Sele
34
25
  anchor: at,
35
26
  head: at,
36
27
  isCollapsed: true,
37
- activeRange: {
38
- kind: "node",
39
- at,
40
- assoc,
41
- },
28
+ activeRange: createPublicNodeAnchor(at, assoc),
42
29
  };
43
30
  }
44
31
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * I4 — Effective-visibility rule for preserve-only previews.
3
+ *
4
+ * Combines the harness-only opaque-token flag (from
5
+ * `__harnessDebugPorts`, read via `readHarnessDebugPortsFlag(..., "unsupportedObjectPreviews")`)
6
+ * with the public sibling `unsupportedPreviewsPolicy` prop (default
7
+ * `"never"`). The harness token remains unreachable from consumer
8
+ * apps (see `test/ui/unsupported-previews-invariant.test.ts`).
9
+ */
10
+ export interface EffectiveShowUnsupportedPreviewsInput {
11
+ harnessShowUnsupportedPreviews: boolean;
12
+ unsupportedPreviewsPolicy: "never" | "review-only" | "always";
13
+ reviewMode: "editing" | "review";
14
+ }
15
+
16
+ export function computeEffectiveShowUnsupportedPreviews(
17
+ input: EffectiveShowUnsupportedPreviewsInput,
18
+ ): boolean {
19
+ if (input.harnessShowUnsupportedPreviews === true) return true;
20
+ if (input.unsupportedPreviewsPolicy === "always") return true;
21
+ if (input.unsupportedPreviewsPolicy === "review-only" && input.reviewMode === "review") return true;
22
+ return false;
23
+ }
@@ -152,6 +152,14 @@ export interface TwChromeOverlayProps {
152
152
  * handle leaves focus-restore as a no-op — DOM reparent still runs.
153
153
  */
154
154
  pmView?: PmPortalView | null;
155
+ /**
156
+ * L7 Phase 2.8 — viewport cull for `TwPageStackChromeLayer`. Sequential
157
+ * page-index range (plus overscan) that should render full chrome
158
+ * bands; pages outside the range render empty frame wrappers only.
159
+ * When omitted, every page's chrome mounts (pre-Phase-2.8 behavior).
160
+ * See `useVisiblePageIndexRange` in `src/ui-tailwind/page-stack/use-visible-block-range.ts`.
161
+ */
162
+ visiblePageIndexRange?: { start: number; end: number } | null;
155
163
  }
156
164
 
157
165
  /**
@@ -188,6 +196,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
188
196
  onOpenStory,
189
197
  pmSurfaceElement,
190
198
  pmView,
199
+ visiblePageIndexRange,
191
200
  }) => {
192
201
  return (
193
202
  <div
@@ -211,6 +220,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
211
220
  onOpenStory={onOpenStory}
212
221
  pmSurfaceElement={pmSurfaceElement}
213
222
  pmView={pmView}
223
+ visiblePageIndexRange={visiblePageIndexRange ?? null}
214
224
  />
215
225
  ) : null}
216
226
  <TwScopeRailLayer
@@ -5,6 +5,7 @@ export type PerfProbeKind =
5
5
  | "typing.divergence"
6
6
  | "selection"
7
7
  | "runtime.create"
8
+ | "loadSession.laycacheProbe"
8
9
  | "snapshot.surface"
9
10
  | "snapshot.compatibility"
10
11
  | "snapshot.navigation"
@@ -128,6 +128,23 @@ export interface TwPageStackChromeLayerProps {
128
128
  * DOM element; only the refocus step is skipped.
129
129
  */
130
130
  pmView?: PmPortalView | null;
131
+ /**
132
+ * L7 Phase 2.8 — optional viewport cull. When supplied, the chrome
133
+ * layer only mounts per-page header / footer / footnote bands for
134
+ * pages whose sequential index falls inside this range (with overscan
135
+ * already applied by the caller). Pages outside the range render a
136
+ * lightweight wrapper `div` carrying the `data-page-chrome-frame` +
137
+ * `data-page-index` attributes — every position read (portal-slot
138
+ * query, per-page measurement) continues to resolve deterministically,
139
+ * but the four child React subtrees (Header/Footer/Footnote bands)
140
+ * never mount, eliminating the measured 412 ms Layout + 229 ms
141
+ * Pre-paint cost on 138-pp extra-large CCEP per the rev5 Chrome trace.
142
+ *
143
+ * Omit the prop (or pass `null`) to render every page — current
144
+ * behavior, used on first mount before the IntersectionObserver has
145
+ * reported.
146
+ */
147
+ visiblePageIndexRange?: { start: number; end: number } | null;
131
148
  /** Optional test id applied to the layer root. */
132
149
  "data-testid"?: string;
133
150
  }
@@ -140,6 +157,7 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
140
157
  onOpenStory,
141
158
  pmSurfaceElement,
142
159
  pmView,
160
+ visiblePageIndexRange,
143
161
  "data-testid": testId,
144
162
  }) => {
145
163
  const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
@@ -352,6 +370,35 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
352
370
  const page = facet.getPage(pageIndex);
353
371
  if (!page) return null;
354
372
 
373
+ // L7 Phase 2.8 — viewport cull. Pages outside the visible range
374
+ // (plus overscan) render an empty frame wrapper: the
375
+ // `data-page-chrome-frame` + `data-page-index` attributes stay
376
+ // put so downstream DOM queries (portal-slot, test hooks) keep
377
+ // working, but the four heavy React subtrees below do not mount.
378
+ const frameHeightPxForCull = rect.bottomPx - rect.topPx;
379
+ if (
380
+ visiblePageIndexRange &&
381
+ (pageIndex < visiblePageIndexRange.start ||
382
+ pageIndex >= visiblePageIndexRange.end)
383
+ ) {
384
+ return (
385
+ <div
386
+ key={`page-chrome-${rect.pageId}`}
387
+ data-page-chrome-frame=""
388
+ data-page-index={pageIndex}
389
+ data-page-chrome-culled=""
390
+ style={{
391
+ position: "absolute",
392
+ top: `${rect.topPx}px`,
393
+ left: 0,
394
+ width: "100%",
395
+ height: `${frameHeightPxForCull}px`,
396
+ pointerEvents: "none",
397
+ }}
398
+ />
399
+ );
400
+ }
401
+
355
402
  const layout = page.layout;
356
403
  const headerStory = page.stories.header;
357
404
  const footerStory = page.stories.footer;
@@ -37,6 +37,19 @@ export interface BlockRange {
37
37
  end: number; // exclusive
38
38
  }
39
39
 
40
+ /**
41
+ * L7 Phase 2.8 — parallel result the chrome-overlay layers use to cull
42
+ * per-page React mounts. Expressed as sequential 0-based page indices
43
+ * (matching `facet.getPage(pageIndex)` + `pageMarkers.indexOf(marker)`),
44
+ * NOT the block indices stored on `data-page-frame`. `null` signals "no
45
+ * visibility signal yet — render every page" (initial-load fallback).
46
+ * Overscan is already applied to both sides.
47
+ */
48
+ export interface VisiblePageIndexRange {
49
+ start: number; // inclusive
50
+ end: number; // exclusive
51
+ }
52
+
40
53
  function readBlockIndex(el: HTMLElement, attr: string): number | null {
41
54
  const v = el.getAttribute(attr);
42
55
  if (v === null) return null;
@@ -155,3 +168,78 @@ export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange
155
168
  };
156
169
  }, [visiblePages, selectionBlockIndex, pageMarkers, overscanPages, totalBlockCount]);
157
170
  }
171
+
172
+ /**
173
+ * L7 Phase 2.8 — sibling hook returning the visible sequential page index
174
+ * range for chrome-overlay viewport culling. Reuses the same
175
+ * IntersectionObserver wiring as `useVisibleBlockRange` but translates the
176
+ * visible-set of `data-page-frame` first-block-indices into SEQUENTIAL
177
+ * page indices via the order of `pageMarkers`. Returns `null` while the
178
+ * observer hasn't reported yet (fresh mount) — callers treat that as
179
+ * "render every page" and fall through to the current non-culled path.
180
+ *
181
+ * Overscan is applied on both sides so a single rapid scroll does not
182
+ * unmount chrome that is about to be revisible.
183
+ */
184
+ export function useVisiblePageIndexRange(input: {
185
+ pageMarkers: readonly HTMLElement[];
186
+ overscanPages: number;
187
+ }): VisiblePageIndexRange | null {
188
+ const { pageMarkers, overscanPages } = input;
189
+ const [visiblePageFirstBlockIndices, setVisiblePageFirstBlockIndices] =
190
+ React.useState<Set<number>>(() => new Set());
191
+
192
+ React.useEffect(() => {
193
+ setVisiblePageFirstBlockIndices(new Set());
194
+ if (pageMarkers.length === 0) return;
195
+ const view = pageMarkers[0].ownerDocument?.defaultView;
196
+ if (!view?.IntersectionObserver) return;
197
+
198
+ const observer = new view.IntersectionObserver(
199
+ (entries) => {
200
+ setVisiblePageFirstBlockIndices((prev) => {
201
+ const next = new Set(prev);
202
+ let changed = false;
203
+ for (const entry of entries) {
204
+ const idx = readBlockIndex(entry.target as HTMLElement, "data-page-frame");
205
+ if (idx === null) continue;
206
+ const was = next.has(idx);
207
+ if (entry.isIntersecting && !was) {
208
+ next.add(idx);
209
+ changed = true;
210
+ } else if (!entry.isIntersecting && was) {
211
+ next.delete(idx);
212
+ changed = true;
213
+ }
214
+ }
215
+ return changed ? next : prev;
216
+ });
217
+ },
218
+ { root: null, rootMargin: "0px", threshold: 0 },
219
+ );
220
+
221
+ for (const marker of pageMarkers) observer.observe(marker);
222
+ return () => observer.disconnect();
223
+ }, [pageMarkers]);
224
+
225
+ return React.useMemo(() => {
226
+ if (pageMarkers.length === 0) return null;
227
+ if (visiblePageFirstBlockIndices.size === 0) return null;
228
+
229
+ let minSeq = Infinity;
230
+ let maxSeq = -Infinity;
231
+ pageMarkers.forEach((marker, seqIndex) => {
232
+ const firstBlockIdx = readBlockIndex(marker, "data-page-frame");
233
+ if (firstBlockIdx === null) return;
234
+ if (!visiblePageFirstBlockIndices.has(firstBlockIdx)) return;
235
+ if (seqIndex < minSeq) minSeq = seqIndex;
236
+ if (seqIndex > maxSeq) maxSeq = seqIndex;
237
+ });
238
+ if (minSeq === Infinity) return null;
239
+
240
+ return {
241
+ start: Math.max(0, minSeq - overscanPages),
242
+ end: Math.min(pageMarkers.length, maxSeq + overscanPages + 1),
243
+ };
244
+ }, [visiblePageFirstBlockIndices, pageMarkers, overscanPages]);
245
+ }
@@ -52,7 +52,10 @@ 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
+ import {
56
+ useVisibleBlockRange,
57
+ useVisiblePageIndexRange,
58
+ } from "./page-stack/use-visible-block-range.ts";
56
59
  import { sliceBlocksForPage } from "./editor-surface/page-slice-util.ts";
57
60
  import { TwPageBlockView } from "./editor-surface/tw-page-block-view.tsx";
58
61
  import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
@@ -982,6 +985,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
982
985
  totalBlockCount: snapshot.surface?.blocks.length ?? 0,
983
986
  });
984
987
 
988
+ // L7 Phase 2.8 — viewport cull for `TwPageStackChromeLayer`. Returns
989
+ // `null` while the IntersectionObserver hasn't reported yet; the chrome
990
+ // layer treats null as "render every page" so first paint is
991
+ // unaffected. Once the observer fires, the chrome layer only mounts
992
+ // bands for pages inside `[start, end)` plus overscan, eliminating the
993
+ // measured 412 ms Layout + 229 ms Pre-paint cost on 138-pp extra-large.
994
+ const visiblePageIndexRange = useVisiblePageIndexRange({
995
+ pageMarkers,
996
+ overscanPages: 2,
997
+ });
998
+
985
999
  // Push the visible range into the layout facet (which delegates to the
986
1000
  // runtime's viewport-culling machinery). Depend on `[start, end]` values
987
1001
  // (not the range object) so identity-preserving updates are a no-op.
@@ -1649,6 +1663,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1649
1663
  activeStory={snapshot.activeStory}
1650
1664
  onOpenStory={props.onOpenStory}
1651
1665
  pmSurfaceElement={pmSurfaceElement}
1666
+ visiblePageIndexRange={visiblePageIndexRange}
1652
1667
  />
1653
1668
  ) : null}
1654
1669
  </div>