@beyondwork/docx-react-component 1.0.36 → 1.0.38
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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +402 -1
- package/src/core/commands/index.ts +18 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +815 -55
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +328 -50
- package/src/io/export/serialize-numbering.ts +114 -24
- package/src/io/export/serialize-tables.ts +87 -11
- package/src/io/export/table-properties-xml.ts +174 -20
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +20 -0
- package/src/io/ooxml/parse-footnotes.ts +62 -1
- package/src/io/ooxml/parse-headers-footers.ts +62 -1
- package/src/io/ooxml/parse-main-document.ts +158 -1
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +45 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +2 -306
- package/src/runtime/document-runtime.ts +287 -11
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +233 -0
- package/src/runtime/layout/inert-layout-facet.ts +59 -0
- package/src/runtime/layout/layout-engine-instance.ts +628 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +452 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +921 -0
- package/src/runtime/layout/project-block-fragments.ts +91 -0
- package/src/runtime/layout/public-facet.ts +1398 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/layout/table-render-plan.ts +229 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +755 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +99 -15
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-shell-view.tsx +6 -0
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +501 -0
- package/src/ui/headless/scoped-chrome-policy.ts +183 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
- package/src/ui-tailwind/index.ts +33 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +505 -144
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render kernel (Phase R1 MVP).
|
|
3
|
+
*
|
|
4
|
+
* Projects the runtime-owned layout engine's page graph into a
|
|
5
|
+
* `RenderFrame` that every chrome surface reads. Keeps workflow scope
|
|
6
|
+
* and decoration projection minimal so consumers can land on a stable
|
|
7
|
+
* shape while the richer projectors (line-box per run, decoration
|
|
8
|
+
* resolver, frame diff, table render plan) are built out in R1 and R4.
|
|
9
|
+
*
|
|
10
|
+
* Ownership:
|
|
11
|
+
* - the kernel is a pure projection over the layout facet. It never
|
|
12
|
+
* mutates runtime or layout state.
|
|
13
|
+
* - it never consults the DOM. Zoom/viewport come in as input.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
|
|
17
|
+
import type { EditorStoryTarget } from "../../api/public-types";
|
|
18
|
+
import type {
|
|
19
|
+
PublicPageNode,
|
|
20
|
+
WordReviewEditorLayoutFacet,
|
|
21
|
+
} from "../layout/public-facet.ts";
|
|
22
|
+
import type {
|
|
23
|
+
CommentDecorationModel,
|
|
24
|
+
} from "../../ui/headless/comment-decoration-model.ts";
|
|
25
|
+
import type {
|
|
26
|
+
RevisionDecorationModel,
|
|
27
|
+
} from "../../ui/headless/revision-decoration-model.ts";
|
|
28
|
+
import type { ScopeRailSegment } from "../workflow-rail-segments.ts";
|
|
29
|
+
import {
|
|
30
|
+
resolveDecorationIndex,
|
|
31
|
+
type LockedRangeInput,
|
|
32
|
+
type SearchMatchRange,
|
|
33
|
+
} from "./decoration-resolver.ts";
|
|
34
|
+
import { classifyBlockKind as classifyBlockKindFromId } from "./block-fragment-projection.ts";
|
|
35
|
+
import {
|
|
36
|
+
EMPTY_DECORATION_INDEX,
|
|
37
|
+
defaultChromeReservations,
|
|
38
|
+
resolveDefaultZoom,
|
|
39
|
+
type DecorationIndex,
|
|
40
|
+
type PageChromeReservations,
|
|
41
|
+
type RenderAnchorIndex,
|
|
42
|
+
type RenderBlock,
|
|
43
|
+
type RenderFrame,
|
|
44
|
+
type RenderFrameQueryOptions,
|
|
45
|
+
type RenderFrameRect,
|
|
46
|
+
type RenderKernelEvent,
|
|
47
|
+
type RenderLine,
|
|
48
|
+
type RenderLineAnchor,
|
|
49
|
+
type RenderPage,
|
|
50
|
+
type RenderPageRegions,
|
|
51
|
+
type RenderStoryRegion,
|
|
52
|
+
type RenderZoom,
|
|
53
|
+
} from "./render-frame-types.ts";
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Kernel interface
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export interface RenderKernel {
|
|
60
|
+
/** Build (or return from cache) the RenderFrame for a query. */
|
|
61
|
+
getRenderFrame(options?: RenderFrameQueryOptions): RenderFrame;
|
|
62
|
+
/** Active zoom the kernel uses when no zoom is supplied. */
|
|
63
|
+
getZoom(): RenderZoom;
|
|
64
|
+
/** Swap the zoom the kernel composes frames at. */
|
|
65
|
+
setZoom(zoom: RenderZoom): void;
|
|
66
|
+
/** Subscribe to kernel-scoped events. */
|
|
67
|
+
subscribe(listener: (event: RenderKernelEvent) => void): () => void;
|
|
68
|
+
/** Invalidate the cache so the next read rebuilds. */
|
|
69
|
+
invalidate(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CreateRenderKernelInput {
|
|
73
|
+
facet: WordReviewEditorLayoutFacet;
|
|
74
|
+
getActiveStory?: () => EditorStoryTarget;
|
|
75
|
+
/** Initial zoom; see `resolveDefaultZoom`. */
|
|
76
|
+
initialZoom?: RenderZoom;
|
|
77
|
+
/**
|
|
78
|
+
* Optional accessor for pending predicted-op deltas. When supplied, the
|
|
79
|
+
* kernel shifts anchor resolution through these deltas so chrome
|
|
80
|
+
* overlays stay aligned with the visible (predicted) text during the
|
|
81
|
+
* predicted-dispatch window. Returns `+N` for insert, `-N` for delete.
|
|
82
|
+
*/
|
|
83
|
+
getPendingOpDeltas?: () => readonly PendingOpDelta[];
|
|
84
|
+
/**
|
|
85
|
+
* Optional decoration sources. When any of these is supplied, the
|
|
86
|
+
* kernel invokes `resolveDecorationIndex` to populate
|
|
87
|
+
* `RenderFrame.decorationIndex` with per-decoration anchor rects so
|
|
88
|
+
* chrome surfaces can read them directly instead of re-projecting. When
|
|
89
|
+
* all are omitted, the kernel falls back to the MVP walk over
|
|
90
|
+
* `block.blockDecorations` (typically empty today).
|
|
91
|
+
*/
|
|
92
|
+
getDecorationSources?: () => DecorationSources;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Canonical decoration sources the kernel aggregates into
|
|
97
|
+
* `DecorationIndex`. Every field is optional; unspecified lanes stay
|
|
98
|
+
* empty.
|
|
99
|
+
*/
|
|
100
|
+
export interface DecorationSources {
|
|
101
|
+
workflowSegments?: readonly ScopeRailSegment[];
|
|
102
|
+
comments?: CommentDecorationModel | null | undefined;
|
|
103
|
+
revisions?: RevisionDecorationModel | null | undefined;
|
|
104
|
+
searchMatches?: readonly SearchMatchRange[];
|
|
105
|
+
lockedRanges?: readonly LockedRangeInput[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PendingOpDelta {
|
|
109
|
+
/** Runtime offset at which the op was applied. */
|
|
110
|
+
fromRuntime: number;
|
|
111
|
+
/** Signed character delta applied at that offset. */
|
|
112
|
+
delta: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sum the pending-op deltas that apply at or before a runtime offset. Used
|
|
117
|
+
* by the kernel's anchor index to shift anchor resolution during the
|
|
118
|
+
* predicted-dispatch window.
|
|
119
|
+
*/
|
|
120
|
+
export function sumDeltasBefore(
|
|
121
|
+
deltas: readonly PendingOpDelta[],
|
|
122
|
+
runtimeOffset: number,
|
|
123
|
+
): number {
|
|
124
|
+
let total = 0;
|
|
125
|
+
for (const d of deltas) {
|
|
126
|
+
if (d.fromRuntime <= runtimeOffset) total += d.delta;
|
|
127
|
+
}
|
|
128
|
+
return total;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Factory
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel {
|
|
136
|
+
const { facet } = input;
|
|
137
|
+
const getActiveStory = input.getActiveStory ?? (() => MAIN_STORY_TARGET);
|
|
138
|
+
let zoom: RenderZoom = input.initialZoom ?? resolveDefaultZoom();
|
|
139
|
+
let cache: { revision: number; frame: RenderFrame } | null = null;
|
|
140
|
+
|
|
141
|
+
const listeners = new Set<(event: RenderKernelEvent) => void>();
|
|
142
|
+
const unsubscribeFacet = facet.subscribe((event) => {
|
|
143
|
+
// Any layout-changing event invalidates the cached frame. We rely on
|
|
144
|
+
// the frame cache check below rather than rebuilding eagerly so single
|
|
145
|
+
// layout events don't produce multiple kernel builds.
|
|
146
|
+
if (
|
|
147
|
+
event.kind === "layout_recomputed" ||
|
|
148
|
+
event.kind === "incremental_relayout" ||
|
|
149
|
+
event.kind === "measurement_backend_ready" ||
|
|
150
|
+
event.kind === "zoom_changed"
|
|
151
|
+
) {
|
|
152
|
+
cache = null;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
void unsubscribeFacet;
|
|
156
|
+
|
|
157
|
+
function emit(event: RenderKernelEvent): void {
|
|
158
|
+
for (const listener of listeners) {
|
|
159
|
+
try {
|
|
160
|
+
listener(event);
|
|
161
|
+
} catch {
|
|
162
|
+
// never let listener errors break the kernel
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildFrame(options?: RenderFrameQueryOptions): RenderFrame {
|
|
168
|
+
const activeStory = options?.story ?? getActiveStory();
|
|
169
|
+
const measurementFidelity = facet.getMeasurementFidelity();
|
|
170
|
+
const rawPages = facet.getPages();
|
|
171
|
+
const pageRange = options?.pageRange;
|
|
172
|
+
const filteredPages = pageRange
|
|
173
|
+
? rawPages.filter(
|
|
174
|
+
(p) =>
|
|
175
|
+
p.pageIndex >= pageRange.fromPageIndex &&
|
|
176
|
+
p.pageIndex <= pageRange.toPageIndex,
|
|
177
|
+
)
|
|
178
|
+
: rawPages;
|
|
179
|
+
|
|
180
|
+
// Compose pages sequentially with a running y-cursor in pixels.
|
|
181
|
+
let y = 0;
|
|
182
|
+
const renderPages: RenderPage[] = [];
|
|
183
|
+
for (const page of filteredPages) {
|
|
184
|
+
const renderPage = buildPage(page, y, zoom, activeStory, facet);
|
|
185
|
+
renderPages.push(renderPage);
|
|
186
|
+
y += renderPage.frame.heightPx + PAGE_GAP_PX;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pendingDeltas = input.getPendingOpDeltas?.() ?? [];
|
|
190
|
+
const anchorIndex = buildAnchorIndex(renderPages, pendingDeltas, zoom.pxPerTwip);
|
|
191
|
+
const includeDecorations = options?.includeDecorations ?? true;
|
|
192
|
+
const sources = input.getDecorationSources?.();
|
|
193
|
+
const hasSources =
|
|
194
|
+
sources !== undefined &&
|
|
195
|
+
((sources.workflowSegments && sources.workflowSegments.length > 0) ||
|
|
196
|
+
(sources.comments?.threads?.length ?? 0) > 0 ||
|
|
197
|
+
(sources.revisions?.revisions?.length ?? 0) > 0 ||
|
|
198
|
+
(sources.searchMatches && sources.searchMatches.length > 0) ||
|
|
199
|
+
(sources.lockedRanges && sources.lockedRanges.length > 0));
|
|
200
|
+
const decorationIndex: DecorationIndex = !includeDecorations
|
|
201
|
+
? EMPTY_DECORATION_INDEX
|
|
202
|
+
: hasSources
|
|
203
|
+
? resolveDecorationIndex({ anchorIndex, ...sources })
|
|
204
|
+
: buildDecorationIndex(renderPages);
|
|
205
|
+
|
|
206
|
+
// Revision: keyed off the engine's current page graph so repeated reads
|
|
207
|
+
// at the same revision return the same cached frame. We derive it
|
|
208
|
+
// from the first page since the engine stamps every page with the
|
|
209
|
+
// graph's revision indirectly via pageId; fall back to 0 if empty.
|
|
210
|
+
const revision = filteredPages[0]
|
|
211
|
+
? Number(extractRevisionFromPageId(filteredPages[0].pageId))
|
|
212
|
+
: 0;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
revision: Number.isFinite(revision) ? revision : 0,
|
|
216
|
+
measurementFidelity,
|
|
217
|
+
activeStory,
|
|
218
|
+
zoom: { ...zoom },
|
|
219
|
+
pages: renderPages,
|
|
220
|
+
decorationIndex,
|
|
221
|
+
anchorIndex,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
getRenderFrame(options) {
|
|
227
|
+
const rebuild = options !== undefined || cache === null;
|
|
228
|
+
if (!rebuild && cache) {
|
|
229
|
+
return cache.frame;
|
|
230
|
+
}
|
|
231
|
+
const frame = buildFrame(options);
|
|
232
|
+
if (options === undefined) {
|
|
233
|
+
cache = { revision: frame.revision, frame };
|
|
234
|
+
emit({ kind: "frame_built", revision: frame.revision, reason: "full" });
|
|
235
|
+
}
|
|
236
|
+
return frame;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
getZoom() {
|
|
240
|
+
return { ...zoom };
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
setZoom(next) {
|
|
244
|
+
zoom = { ...next };
|
|
245
|
+
cache = null;
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
subscribe(listener) {
|
|
249
|
+
listeners.add(listener);
|
|
250
|
+
return () => {
|
|
251
|
+
listeners.delete(listener);
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
invalidate() {
|
|
256
|
+
cache = null;
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Internals
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
const PAGE_GAP_PX = 16;
|
|
266
|
+
|
|
267
|
+
function buildPage(
|
|
268
|
+
page: PublicPageNode,
|
|
269
|
+
topPx: number,
|
|
270
|
+
zoom: RenderZoom,
|
|
271
|
+
activeStory: EditorStoryTarget,
|
|
272
|
+
facet: WordReviewEditorLayoutFacet,
|
|
273
|
+
): RenderPage {
|
|
274
|
+
const layout = page.layout;
|
|
275
|
+
const widthPx = layout.pageWidth * zoom.pxPerTwip;
|
|
276
|
+
const heightPx = layout.pageHeight * zoom.pxPerTwip;
|
|
277
|
+
|
|
278
|
+
const frame: RenderFrameRect = {
|
|
279
|
+
leftPx: 0,
|
|
280
|
+
topPx,
|
|
281
|
+
widthPx,
|
|
282
|
+
heightPx,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const bodyRegion: RenderStoryRegion = buildBodyRegion(
|
|
286
|
+
page,
|
|
287
|
+
topPx,
|
|
288
|
+
zoom,
|
|
289
|
+
activeStory,
|
|
290
|
+
facet,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const regions: RenderPageRegions = {
|
|
294
|
+
body: bodyRegion,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (page.stories.header) {
|
|
298
|
+
regions.header = buildHeaderFooterRegion(
|
|
299
|
+
page,
|
|
300
|
+
topPx,
|
|
301
|
+
zoom,
|
|
302
|
+
"header",
|
|
303
|
+
page.stories.header,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (page.stories.footer) {
|
|
307
|
+
regions.footer = buildHeaderFooterRegion(
|
|
308
|
+
page,
|
|
309
|
+
topPx,
|
|
310
|
+
zoom,
|
|
311
|
+
"footer",
|
|
312
|
+
page.stories.footer,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const chromeReservations: PageChromeReservations = {
|
|
317
|
+
...defaultChromeReservations(layout, zoom),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
page,
|
|
322
|
+
frame,
|
|
323
|
+
regions,
|
|
324
|
+
chromeReservations,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function buildBodyRegion(
|
|
329
|
+
page: PublicPageNode,
|
|
330
|
+
pageTopPx: number,
|
|
331
|
+
zoom: RenderZoom,
|
|
332
|
+
activeStory: EditorStoryTarget,
|
|
333
|
+
facet: WordReviewEditorLayoutFacet,
|
|
334
|
+
): RenderStoryRegion {
|
|
335
|
+
const layout = page.layout;
|
|
336
|
+
const bodyLeftTwips = layout.marginLeft;
|
|
337
|
+
const bodyTopTwips = layout.marginTop;
|
|
338
|
+
const bodyWidthTwips = page.regions.body.widthTwips;
|
|
339
|
+
const bodyHeightTwips = page.regions.body.heightTwips;
|
|
340
|
+
|
|
341
|
+
const regionFrame: RenderFrameRect = {
|
|
342
|
+
leftPx: bodyLeftTwips * zoom.pxPerTwip,
|
|
343
|
+
topPx: pageTopPx + bodyTopTwips * zoom.pxPerTwip,
|
|
344
|
+
widthPx: bodyWidthTwips * zoom.pxPerTwip,
|
|
345
|
+
heightPx: bodyHeightTwips * zoom.pxPerTwip,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const fragments = facet.getFragmentsForPage(page.pageIndex);
|
|
349
|
+
const bodyLineBoxes = facet.getLineBoxes(page.pageIndex, { region: "body" });
|
|
350
|
+
|
|
351
|
+
// Current layout engine does not track per-fragment line boxes with
|
|
352
|
+
// baselines that have been laid out against a cursor. The kernel
|
|
353
|
+
// distributes height across fragments proportional to their
|
|
354
|
+
// `heightTwips`, giving chrome a stable anchor per block; richer
|
|
355
|
+
// per-line projection lands with the full kernel in later phases.
|
|
356
|
+
const blocks: RenderBlock[] = [];
|
|
357
|
+
let blockY = regionFrame.topPx;
|
|
358
|
+
const totalFragmentHeight = fragments.reduce(
|
|
359
|
+
(acc, fragment) => acc + Math.max(0, fragment.heightTwips),
|
|
360
|
+
0,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
for (const fragment of fragments) {
|
|
364
|
+
if (fragment.regionKind !== "body") continue;
|
|
365
|
+
const fragmentHeightTwips = fragment.heightTwips > 0
|
|
366
|
+
? fragment.heightTwips
|
|
367
|
+
: totalFragmentHeight === 0
|
|
368
|
+
? bodyHeightTwips / Math.max(1, fragments.length)
|
|
369
|
+
: 0;
|
|
370
|
+
const blockHeightPx = fragmentHeightTwips * zoom.pxPerTwip;
|
|
371
|
+
const blockFrame: RenderFrameRect = {
|
|
372
|
+
leftPx: regionFrame.leftPx,
|
|
373
|
+
topPx: blockY,
|
|
374
|
+
widthPx: regionFrame.widthPx,
|
|
375
|
+
heightPx: blockHeightPx,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const blockLines = bodyLineBoxes
|
|
379
|
+
.filter((box) => box.fragmentId === fragment.fragmentId)
|
|
380
|
+
.map<RenderLine>((box) => {
|
|
381
|
+
const lineTopPx =
|
|
382
|
+
pageTopPx + (bodyTopTwips + box.baselineTwips) * zoom.pxPerTwip;
|
|
383
|
+
const lineFrame: RenderFrameRect = {
|
|
384
|
+
leftPx: regionFrame.leftPx,
|
|
385
|
+
topPx: lineTopPx,
|
|
386
|
+
widthPx: Math.min(
|
|
387
|
+
regionFrame.widthPx,
|
|
388
|
+
box.widthTwips * zoom.pxPerTwip,
|
|
389
|
+
),
|
|
390
|
+
heightPx: box.heightTwips * zoom.pxPerTwip,
|
|
391
|
+
};
|
|
392
|
+
const anchors: RenderLineAnchor[] = [
|
|
393
|
+
{
|
|
394
|
+
runtimeOffset: fragment.from,
|
|
395
|
+
frame: lineFrame,
|
|
396
|
+
fragmentId: fragment.fragmentId,
|
|
397
|
+
blockId: fragment.blockId,
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
return {
|
|
401
|
+
line: box,
|
|
402
|
+
frame: lineFrame,
|
|
403
|
+
anchors,
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const kind = classifyBlockKindFromId(fragment.blockId);
|
|
408
|
+
const tablePlan =
|
|
409
|
+
kind === "table"
|
|
410
|
+
? facet.getTableRenderPlan(fragment.blockId, page.pageIndex)
|
|
411
|
+
: undefined;
|
|
412
|
+
|
|
413
|
+
blocks.push({
|
|
414
|
+
fragment,
|
|
415
|
+
frame: blockFrame,
|
|
416
|
+
kind,
|
|
417
|
+
lines: blockLines,
|
|
418
|
+
blockDecorations: [],
|
|
419
|
+
...(tablePlan !== undefined ? { tablePlan } : {}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
blockY += blockHeightPx;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
storyTarget: activeStory,
|
|
427
|
+
region: page.regions.body,
|
|
428
|
+
frame: regionFrame,
|
|
429
|
+
blocks,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function buildHeaderFooterRegion(
|
|
434
|
+
page: PublicPageNode,
|
|
435
|
+
pageTopPx: number,
|
|
436
|
+
zoom: RenderZoom,
|
|
437
|
+
kind: "header" | "footer",
|
|
438
|
+
storyTarget: EditorStoryTarget,
|
|
439
|
+
): RenderStoryRegion {
|
|
440
|
+
const layout = page.layout;
|
|
441
|
+
const widthTwips =
|
|
442
|
+
layout.pageWidth - layout.marginLeft - layout.marginRight;
|
|
443
|
+
let topTwips = 0;
|
|
444
|
+
let heightTwips = 0;
|
|
445
|
+
if (kind === "header") {
|
|
446
|
+
topTwips = layout.headerMargin ?? 720;
|
|
447
|
+
heightTwips = Math.max(0, layout.marginTop - topTwips);
|
|
448
|
+
} else {
|
|
449
|
+
topTwips = layout.pageHeight - layout.marginBottom;
|
|
450
|
+
heightTwips = Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const frame: RenderFrameRect = {
|
|
454
|
+
leftPx: layout.marginLeft * zoom.pxPerTwip,
|
|
455
|
+
topPx: pageTopPx + topTwips * zoom.pxPerTwip,
|
|
456
|
+
widthPx: widthTwips * zoom.pxPerTwip,
|
|
457
|
+
heightPx: heightTwips * zoom.pxPerTwip,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const region = kind === "header" ? page.regions.header : page.regions.footer;
|
|
461
|
+
if (!region) {
|
|
462
|
+
return {
|
|
463
|
+
storyTarget,
|
|
464
|
+
region: {
|
|
465
|
+
kind,
|
|
466
|
+
originTwips: topTwips,
|
|
467
|
+
widthTwips,
|
|
468
|
+
heightTwips,
|
|
469
|
+
fragmentCount: 0,
|
|
470
|
+
},
|
|
471
|
+
frame,
|
|
472
|
+
blocks: [],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
storyTarget,
|
|
478
|
+
region,
|
|
479
|
+
frame,
|
|
480
|
+
blocks: [],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// classifyBlockKind moved to `./block-fragment-projection.ts` (P4).
|
|
485
|
+
|
|
486
|
+
function buildAnchorIndex(
|
|
487
|
+
pages: readonly RenderPage[],
|
|
488
|
+
pendingDeltas: readonly PendingOpDelta[] = [],
|
|
489
|
+
pxPerTwip = 1,
|
|
490
|
+
): RenderAnchorIndex {
|
|
491
|
+
const byRuntimeOffset = new Map<number, RenderFrameRect>();
|
|
492
|
+
const byFragmentId = new Map<string, RenderFrameRect>();
|
|
493
|
+
const byBlockId = new Map<string, RenderFrameRect>();
|
|
494
|
+
const byPageIndex = new Map<number, RenderFrameRect>();
|
|
495
|
+
// Per-table geometry: cell rects (key = `${blockId}:${row}:${col}`),
|
|
496
|
+
// column edges (key = `${blockId}:${columnIndex}`), row edges
|
|
497
|
+
// (key = `${blockId}:${rowIndex}`).
|
|
498
|
+
const tableCellRects = new Map<string, RenderFrameRect>();
|
|
499
|
+
const tableColumnEdges = new Map<string, RenderFrameRect>();
|
|
500
|
+
const tableRowEdges = new Map<string, RenderFrameRect>();
|
|
501
|
+
|
|
502
|
+
for (const page of pages) {
|
|
503
|
+
byPageIndex.set(page.page.pageIndex, page.frame);
|
|
504
|
+
for (const block of page.regions.body.blocks) {
|
|
505
|
+
byFragmentId.set(block.fragment.fragmentId, block.frame);
|
|
506
|
+
byBlockId.set(block.fragment.blockId, block.frame);
|
|
507
|
+
byRuntimeOffset.set(block.fragment.from, block.frame);
|
|
508
|
+
for (const line of block.lines) {
|
|
509
|
+
for (const anchor of line.anchors) {
|
|
510
|
+
byRuntimeOffset.set(anchor.runtimeOffset, anchor.frame);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// P4d: derive per-table cell/edge rects from the attached plan.
|
|
514
|
+
if (block.kind === "table" && block.tablePlan) {
|
|
515
|
+
recordTableAnchors(
|
|
516
|
+
block.fragment.blockId,
|
|
517
|
+
block.frame,
|
|
518
|
+
block.tablePlan,
|
|
519
|
+
pxPerTwip,
|
|
520
|
+
tableCellRects,
|
|
521
|
+
tableColumnEdges,
|
|
522
|
+
tableRowEdges,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Pending-op deltas are read here as a seam for a future decoration-
|
|
529
|
+
// resolver-driven rect shift; today the kernel does not mutate rects
|
|
530
|
+
// during the predicted-dispatch window because the correct reconciliation
|
|
531
|
+
// belongs to R4's decoration resolver (it needs per-line width info the
|
|
532
|
+
// MVP doesn't surface yet). The deltas are still read so consumers can
|
|
533
|
+
// rely on the accessor shape landing now.
|
|
534
|
+
void pendingDeltas;
|
|
535
|
+
|
|
536
|
+
const resolveByRuntimeOffset = (
|
|
537
|
+
offset: number,
|
|
538
|
+
_story?: EditorStoryTarget,
|
|
539
|
+
): RenderFrameRect | null => {
|
|
540
|
+
void _story;
|
|
541
|
+
const exact = byRuntimeOffset.get(offset);
|
|
542
|
+
if (exact) return exact;
|
|
543
|
+
let best: RenderFrameRect | null = null;
|
|
544
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
545
|
+
for (const [key, rect] of byRuntimeOffset) {
|
|
546
|
+
const distance = Math.abs(key - offset);
|
|
547
|
+
if (distance < bestDistance) {
|
|
548
|
+
best = rect;
|
|
549
|
+
bestDistance = distance;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return best;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
byRuntimeOffset(offset, story) {
|
|
557
|
+
return resolveByRuntimeOffset(offset, story);
|
|
558
|
+
},
|
|
559
|
+
byBlockId(blockId) {
|
|
560
|
+
return byBlockId.get(blockId) ?? null;
|
|
561
|
+
},
|
|
562
|
+
byFragmentId(fragmentId) {
|
|
563
|
+
return byFragmentId.get(fragmentId) ?? null;
|
|
564
|
+
},
|
|
565
|
+
byPageIndex(pageIndex) {
|
|
566
|
+
return byPageIndex.get(pageIndex) ?? null;
|
|
567
|
+
},
|
|
568
|
+
bySelection(fromOffset, toOffset, story) {
|
|
569
|
+
const lo = Math.min(fromOffset, toOffset);
|
|
570
|
+
const hi = Math.max(fromOffset, toOffset);
|
|
571
|
+
if (lo === hi) {
|
|
572
|
+
return resolveByRuntimeOffset(lo, story);
|
|
573
|
+
}
|
|
574
|
+
let union: RenderFrameRect | null = null;
|
|
575
|
+
for (const [key, rect] of byRuntimeOffset) {
|
|
576
|
+
if (key < lo || key >= hi) continue;
|
|
577
|
+
union = unionRects(union, rect);
|
|
578
|
+
}
|
|
579
|
+
if (union) return union;
|
|
580
|
+
// Fallback: union the endpoint resolutions so callers always get a
|
|
581
|
+
// rect when at least one endpoint is in the document.
|
|
582
|
+
const fromRect = resolveByRuntimeOffset(lo, story);
|
|
583
|
+
const toRect = resolveByRuntimeOffset(hi - 1, story);
|
|
584
|
+
return unionRects(fromRect, toRect);
|
|
585
|
+
},
|
|
586
|
+
byTableCell(tableBlockId, rowIndex, columnIndex) {
|
|
587
|
+
return tableCellRects.get(`${tableBlockId}:${rowIndex}:${columnIndex}`) ?? null;
|
|
588
|
+
},
|
|
589
|
+
byTableColumnEdge(tableBlockId, columnIndex) {
|
|
590
|
+
return tableColumnEdges.get(`${tableBlockId}:${columnIndex}`) ?? null;
|
|
591
|
+
},
|
|
592
|
+
byTableRowEdge(tableBlockId, rowIndex) {
|
|
593
|
+
return tableRowEdges.get(`${tableBlockId}:${rowIndex}`) ?? null;
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Emit cell, column-edge, and row-edge anchor rects for a single table
|
|
600
|
+
* block from its attached render plan. Uses the block's frame (pixel
|
|
601
|
+
* coordinates at the frame's zoom) as the origin and lays out cell rects
|
|
602
|
+
* by summing `columnsTwips` horizontally and letting rows inherit the
|
|
603
|
+
* block's vertical height (row-level splits will refine this in a
|
|
604
|
+
* follow-up).
|
|
605
|
+
*/
|
|
606
|
+
function recordTableAnchors(
|
|
607
|
+
tableBlockId: string,
|
|
608
|
+
blockFrame: RenderFrameRect,
|
|
609
|
+
plan: import("./render-frame-types.ts").RenderBlock["tablePlan"],
|
|
610
|
+
pxPerTwip: number,
|
|
611
|
+
cellRects: Map<string, RenderFrameRect>,
|
|
612
|
+
columnEdges: Map<string, RenderFrameRect>,
|
|
613
|
+
rowEdges: Map<string, RenderFrameRect>,
|
|
614
|
+
): void {
|
|
615
|
+
if (!plan) return;
|
|
616
|
+
const columnCount = plan.columnsTwips.length;
|
|
617
|
+
if (columnCount === 0) return;
|
|
618
|
+
|
|
619
|
+
// Column x-positions in px, starting from the block's leftPx.
|
|
620
|
+
const columnLeftsPx: number[] = [blockFrame.leftPx];
|
|
621
|
+
for (let i = 0; i < columnCount; i += 1) {
|
|
622
|
+
columnLeftsPx.push(
|
|
623
|
+
columnLeftsPx[i]! + (plan.columnsTwips[i] ?? 0) * pxPerTwip,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Row y-positions: even split of blockFrame height across rows (row-
|
|
628
|
+
// level heights require per-row pagination in a future phase).
|
|
629
|
+
const rowCount = Math.max(
|
|
630
|
+
1,
|
|
631
|
+
plan.bandClasses.rows.length ||
|
|
632
|
+
plan.bandClasses.cells.reduce(
|
|
633
|
+
(max, c) => Math.max(max, c.rowIndex + 1),
|
|
634
|
+
0,
|
|
635
|
+
),
|
|
636
|
+
);
|
|
637
|
+
const rowHeightPx = rowCount > 0 ? blockFrame.heightPx / rowCount : blockFrame.heightPx;
|
|
638
|
+
const rowTopsPx: number[] = [];
|
|
639
|
+
for (let r = 0; r <= rowCount; r += 1) {
|
|
640
|
+
rowTopsPx.push(blockFrame.topPx + r * rowHeightPx);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Cell rects from bandClasses.cells (single entry per logical origin).
|
|
644
|
+
for (const cell of plan.bandClasses.cells) {
|
|
645
|
+
// Determine the columnSpan for this origin by checking whether the
|
|
646
|
+
// next cell in the same row has a columnIndex > this + 1 (sparse
|
|
647
|
+
// representation: only origins are emitted).
|
|
648
|
+
const sameRow = plan.bandClasses.cells.filter((c) => c.rowIndex === cell.rowIndex);
|
|
649
|
+
const sorted = [...sameRow].sort((a, b) => a.columnIndex - b.columnIndex);
|
|
650
|
+
const idx = sorted.findIndex((c) => c === cell);
|
|
651
|
+
const next = sorted[idx + 1];
|
|
652
|
+
const columnSpan = (next?.columnIndex ?? columnCount) - cell.columnIndex;
|
|
653
|
+
|
|
654
|
+
const left = columnLeftsPx[cell.columnIndex] ?? blockFrame.leftPx;
|
|
655
|
+
const right =
|
|
656
|
+
columnLeftsPx[cell.columnIndex + columnSpan] ??
|
|
657
|
+
blockFrame.leftPx + blockFrame.widthPx;
|
|
658
|
+
const top = rowTopsPx[cell.rowIndex] ?? blockFrame.topPx;
|
|
659
|
+
const bottom =
|
|
660
|
+
rowTopsPx[cell.rowIndex + 1] ?? blockFrame.topPx + blockFrame.heightPx;
|
|
661
|
+
|
|
662
|
+
cellRects.set(`${tableBlockId}:${cell.rowIndex}:${cell.columnIndex}`, {
|
|
663
|
+
leftPx: left,
|
|
664
|
+
topPx: top,
|
|
665
|
+
widthPx: Math.max(0, right - left),
|
|
666
|
+
heightPx: Math.max(0, bottom - top),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Column edges — zero-width rect at each internal column boundary.
|
|
671
|
+
for (const handle of plan.columnResizeHandles) {
|
|
672
|
+
const x = blockFrame.leftPx + handle.originTwips * pxPerTwip;
|
|
673
|
+
columnEdges.set(`${tableBlockId}:${handle.columnIndex}`, {
|
|
674
|
+
leftPx: x,
|
|
675
|
+
topPx: blockFrame.topPx,
|
|
676
|
+
widthPx: 0,
|
|
677
|
+
heightPx: handle.heightTwips * pxPerTwip,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Row edges — zero-height rect at each internal row boundary.
|
|
682
|
+
for (let r = 0; r < rowCount - 1; r += 1) {
|
|
683
|
+
const y = rowTopsPx[r + 1] ?? blockFrame.topPx + blockFrame.heightPx;
|
|
684
|
+
rowEdges.set(`${tableBlockId}:${r}`, {
|
|
685
|
+
leftPx: blockFrame.leftPx,
|
|
686
|
+
topPx: y,
|
|
687
|
+
widthPx: blockFrame.widthPx,
|
|
688
|
+
heightPx: 0,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function unionRects(
|
|
694
|
+
a: RenderFrameRect | null,
|
|
695
|
+
b: RenderFrameRect | null,
|
|
696
|
+
): RenderFrameRect | null {
|
|
697
|
+
if (!a) return b ?? null;
|
|
698
|
+
if (!b) return a;
|
|
699
|
+
const left = Math.min(a.leftPx, b.leftPx);
|
|
700
|
+
const top = Math.min(a.topPx, b.topPx);
|
|
701
|
+
const right = Math.max(a.leftPx + a.widthPx, b.leftPx + b.widthPx);
|
|
702
|
+
const bottom = Math.max(a.topPx + a.heightPx, b.topPx + b.heightPx);
|
|
703
|
+
return {
|
|
704
|
+
leftPx: left,
|
|
705
|
+
topPx: top,
|
|
706
|
+
widthPx: right - left,
|
|
707
|
+
heightPx: bottom - top,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function buildDecorationIndex(pages: readonly RenderPage[]): DecorationIndex {
|
|
712
|
+
// Minimum-viable decoration projection: collect block decorations directly
|
|
713
|
+
// off the rendered blocks. The full kernel phase adds a dedicated
|
|
714
|
+
// decoration-resolver that unions workflow scopes, comments, revisions,
|
|
715
|
+
// search matches, and locked zones into a single pass.
|
|
716
|
+
const workflow: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
|
|
717
|
+
const comments: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
|
|
718
|
+
const revisions: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
|
|
719
|
+
const search: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
|
|
720
|
+
const locked: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
|
|
721
|
+
|
|
722
|
+
for (const page of pages) {
|
|
723
|
+
for (const block of page.regions.body.blocks) {
|
|
724
|
+
for (const decoration of block.blockDecorations) {
|
|
725
|
+
switch (decoration.kind) {
|
|
726
|
+
case "workflow":
|
|
727
|
+
workflow.push(decoration);
|
|
728
|
+
break;
|
|
729
|
+
case "comment":
|
|
730
|
+
comments.push(decoration);
|
|
731
|
+
break;
|
|
732
|
+
case "revision":
|
|
733
|
+
revisions.push(decoration);
|
|
734
|
+
break;
|
|
735
|
+
case "search":
|
|
736
|
+
search.push(decoration);
|
|
737
|
+
break;
|
|
738
|
+
case "locked":
|
|
739
|
+
locked.push(decoration);
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return { workflow, comments, revisions, search, locked };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function extractRevisionFromPageId(pageId: string): number | string {
|
|
750
|
+
// pageIds look like `page-<revision>-<index>`; extract the revision so the
|
|
751
|
+
// kernel's frame-level cache keys track graph revisions.
|
|
752
|
+
const match = /^page-(\d+)-/.exec(pageId);
|
|
753
|
+
if (!match) return pageId;
|
|
754
|
+
return Number(match[1]);
|
|
755
|
+
}
|