@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,1398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public Layout Facet — the ergonomic surface exposed on
|
|
3
|
+
* `WordReviewEditorRef.layout`.
|
|
4
|
+
*
|
|
5
|
+
* Per the plan (Phase 7), this provides a single coherent object tree for
|
|
6
|
+
* consumers to walk the layout graph, resolve positions, inspect measurement
|
|
7
|
+
* state, and observe layout events — without needing to deal with the legacy
|
|
8
|
+
* opaque JSON snapshots.
|
|
9
|
+
*
|
|
10
|
+
* Design rules:
|
|
11
|
+
* - Every returned value is a deep-cloned read model. Internal graph
|
|
12
|
+
* identities never escape the facet.
|
|
13
|
+
* - Story-aware. `EditorStoryTarget` is accepted anywhere a position is
|
|
14
|
+
* interpreted so header/footer/note queries work cleanly.
|
|
15
|
+
* - No DOM. No PM. No canonical model leakage.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
EditorStoryTarget,
|
|
20
|
+
PageLayoutSnapshot,
|
|
21
|
+
SelectionSnapshot,
|
|
22
|
+
} from "../../api/public-types";
|
|
23
|
+
import type {
|
|
24
|
+
ResolvedPageStories,
|
|
25
|
+
} from "./page-story-resolver.ts";
|
|
26
|
+
import type {
|
|
27
|
+
RuntimeBlockFragment,
|
|
28
|
+
RuntimeLineBox,
|
|
29
|
+
RuntimeNoteAllocation,
|
|
30
|
+
RuntimePageGraph,
|
|
31
|
+
RuntimePageNode,
|
|
32
|
+
RuntimePageRegion,
|
|
33
|
+
RuntimePageRegions,
|
|
34
|
+
} from "./page-graph.ts";
|
|
35
|
+
import type {
|
|
36
|
+
ResolvedFormattingState,
|
|
37
|
+
ResolvedRunFormatting,
|
|
38
|
+
} from "./resolved-formatting-document.ts";
|
|
39
|
+
import type { ResolvedParagraphFormatting } from "./resolved-formatting-state.ts";
|
|
40
|
+
import type {
|
|
41
|
+
LayoutEngineEvent,
|
|
42
|
+
LayoutEngineInstance,
|
|
43
|
+
LayoutEngineQueryInput,
|
|
44
|
+
} from "./layout-engine-instance.ts";
|
|
45
|
+
import type { PageFragmentMapper } from "./page-fragment-mapper.ts";
|
|
46
|
+
import {
|
|
47
|
+
PAGE_FORMAT_CATALOG,
|
|
48
|
+
matchPageFormat,
|
|
49
|
+
type PageFormatDefinition,
|
|
50
|
+
type ActivePageFormat,
|
|
51
|
+
} from "./page-format-catalog.ts";
|
|
52
|
+
import {
|
|
53
|
+
MARGIN_PRESET_CATALOG,
|
|
54
|
+
matchMarginPreset,
|
|
55
|
+
type MarginPresetDefinition,
|
|
56
|
+
type ActiveMarginPreset,
|
|
57
|
+
} from "./margin-preset-catalog.ts";
|
|
58
|
+
import {
|
|
59
|
+
collectScopeRailSegments,
|
|
60
|
+
type ScopeRailSegment,
|
|
61
|
+
} from "../workflow-rail-segments.ts";
|
|
62
|
+
import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
|
|
63
|
+
import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
|
|
64
|
+
import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
65
|
+
import { resolveTableStyleResolution } from "../table-style-resolver.ts";
|
|
66
|
+
import { buildTableRenderPlan } from "./table-render-plan.ts";
|
|
67
|
+
import type {
|
|
68
|
+
SurfaceBlockSnapshot,
|
|
69
|
+
} from "../../api/public-types";
|
|
70
|
+
|
|
71
|
+
export type {
|
|
72
|
+
PageFormatDefinition,
|
|
73
|
+
ActivePageFormat,
|
|
74
|
+
MarginPresetDefinition,
|
|
75
|
+
ActiveMarginPreset,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type { ScopeRailSegment };
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Public read model types (shape-stable, cloned at the facet boundary)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export interface PublicPageNode {
|
|
85
|
+
pageId: string;
|
|
86
|
+
pageIndex: number;
|
|
87
|
+
sectionIndex: number;
|
|
88
|
+
pageInSection: number;
|
|
89
|
+
startOffset: number;
|
|
90
|
+
endOffset: number;
|
|
91
|
+
/** Whether this page is a blank filler (e.g. from evenPage/oddPage). */
|
|
92
|
+
isBlankFiller: boolean;
|
|
93
|
+
/** Resolved display page number (1-based, honors section restarts). */
|
|
94
|
+
displayPageNumber: number;
|
|
95
|
+
/** Whether this is treated as the first page of its section (title page). */
|
|
96
|
+
isFirstPage: boolean;
|
|
97
|
+
/** Whether the displayed page number is even. */
|
|
98
|
+
isEvenPage: boolean;
|
|
99
|
+
/** Section-derived page layout geometry. */
|
|
100
|
+
layout: PageLayoutSnapshot;
|
|
101
|
+
/** Resolved header/footer/note stories active on this page. */
|
|
102
|
+
stories: PublicResolvedPageStories;
|
|
103
|
+
/** Sub-regions rendered on the page. */
|
|
104
|
+
regions: PublicPageRegions;
|
|
105
|
+
/** Number of line boxes rendered in the body region. */
|
|
106
|
+
lineBoxCount: number;
|
|
107
|
+
/** Footnotes reserved at the bottom of the page, if any. */
|
|
108
|
+
noteAllocations: readonly PublicNoteAllocation[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PublicResolvedPageStories {
|
|
112
|
+
header?: EditorStoryTarget;
|
|
113
|
+
footer?: EditorStoryTarget;
|
|
114
|
+
isFirstPage: boolean;
|
|
115
|
+
isEvenPage: boolean;
|
|
116
|
+
displayPageNumber: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface PublicPageRegions {
|
|
120
|
+
body: PublicPageRegion;
|
|
121
|
+
header?: PublicPageRegion;
|
|
122
|
+
footer?: PublicPageRegion;
|
|
123
|
+
columns?: readonly PublicPageRegion[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface PublicPageRegion {
|
|
127
|
+
kind: "body" | "header" | "footer" | "column" | "footnote-area";
|
|
128
|
+
originTwips: number;
|
|
129
|
+
widthTwips: number;
|
|
130
|
+
heightTwips: number;
|
|
131
|
+
fragmentCount: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PublicBlockFragment {
|
|
135
|
+
fragmentId: string;
|
|
136
|
+
blockId: string;
|
|
137
|
+
pageId: string;
|
|
138
|
+
pageIndex: number;
|
|
139
|
+
regionKind: PublicPageRegion["kind"];
|
|
140
|
+
from: number;
|
|
141
|
+
to: number;
|
|
142
|
+
heightTwips: number;
|
|
143
|
+
orderInRegion: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface PublicLineBox {
|
|
147
|
+
fragmentId: string;
|
|
148
|
+
lineIndex: number;
|
|
149
|
+
baselineTwips: number;
|
|
150
|
+
heightTwips: number;
|
|
151
|
+
widthTwips: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface PublicNoteAllocation {
|
|
155
|
+
noteKind: "footnote" | "endnote";
|
|
156
|
+
noteId: string;
|
|
157
|
+
reservedHeightTwips: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PublicPageAnchor {
|
|
161
|
+
offset: number;
|
|
162
|
+
pageId: string;
|
|
163
|
+
pageIndex: number;
|
|
164
|
+
fragmentId?: string;
|
|
165
|
+
regionKind?: PublicPageRegion["kind"];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface PublicPageSpan {
|
|
169
|
+
firstPageIndex: number;
|
|
170
|
+
lastPageIndex: number;
|
|
171
|
+
pageCount: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface PublicSectionNode {
|
|
175
|
+
sectionIndex: number;
|
|
176
|
+
startOffset: number;
|
|
177
|
+
endOffset: number;
|
|
178
|
+
firstPageIndex: number;
|
|
179
|
+
lastPageIndex: number;
|
|
180
|
+
pageCount: number;
|
|
181
|
+
layout: PageLayoutSnapshot;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface PublicResolvedParagraphFormatting {
|
|
185
|
+
blockId: string;
|
|
186
|
+
spacingBefore: number;
|
|
187
|
+
spacingAfter: number;
|
|
188
|
+
lineHeight: number;
|
|
189
|
+
lineRule: "auto" | "exact" | "atLeast";
|
|
190
|
+
indentLeft: number;
|
|
191
|
+
indentRight: number;
|
|
192
|
+
firstLineIndent: number;
|
|
193
|
+
hangingIndent: number;
|
|
194
|
+
fontSizeHalfPoints: number;
|
|
195
|
+
averageCharWidthTwips: number;
|
|
196
|
+
tabStops: readonly {
|
|
197
|
+
positionTwips: number;
|
|
198
|
+
alignment: string;
|
|
199
|
+
leader?: string;
|
|
200
|
+
}[];
|
|
201
|
+
keepNext: boolean;
|
|
202
|
+
keepLines: boolean;
|
|
203
|
+
pageBreakBefore: boolean;
|
|
204
|
+
widowControl: boolean;
|
|
205
|
+
contextualSpacing: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface PublicResolvedRunFormatting {
|
|
209
|
+
runId: string;
|
|
210
|
+
blockId: string;
|
|
211
|
+
fontFamily?: string;
|
|
212
|
+
fontSizeHalfPoints?: number;
|
|
213
|
+
bold: boolean;
|
|
214
|
+
italic: boolean;
|
|
215
|
+
underline: boolean;
|
|
216
|
+
strikethrough: boolean;
|
|
217
|
+
color?: string;
|
|
218
|
+
highlight?: string;
|
|
219
|
+
verticalAlign: "baseline" | "superscript" | "subscript";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface PublicBlockMeasurement {
|
|
223
|
+
blockId: string;
|
|
224
|
+
lineCount: number;
|
|
225
|
+
/** Total height the block occupies in twips. */
|
|
226
|
+
heightTwips: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type PublicMeasurementFidelity =
|
|
230
|
+
| "empirical"
|
|
231
|
+
| "canvas"
|
|
232
|
+
| "canvas-with-font-loading";
|
|
233
|
+
|
|
234
|
+
export interface PublicFieldDirtinessReport {
|
|
235
|
+
families: readonly string[];
|
|
236
|
+
revision: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type LayoutFacetEvent =
|
|
240
|
+
| {
|
|
241
|
+
kind: "layout_recomputed";
|
|
242
|
+
revision: number;
|
|
243
|
+
reason?: LayoutFacetInvalidationReason;
|
|
244
|
+
}
|
|
245
|
+
| {
|
|
246
|
+
kind: "page_count_changed";
|
|
247
|
+
previous: number;
|
|
248
|
+
current: number;
|
|
249
|
+
revision: number;
|
|
250
|
+
}
|
|
251
|
+
| {
|
|
252
|
+
kind: "page_field_dirtied";
|
|
253
|
+
families: readonly string[];
|
|
254
|
+
revision: number;
|
|
255
|
+
}
|
|
256
|
+
| {
|
|
257
|
+
kind: "measurement_backend_ready";
|
|
258
|
+
fidelity: PublicMeasurementFidelity;
|
|
259
|
+
revision: number;
|
|
260
|
+
}
|
|
261
|
+
| {
|
|
262
|
+
kind: "incremental_relayout";
|
|
263
|
+
revision: number;
|
|
264
|
+
pageRange: { fromPageIndex: number; toPageIndex: number };
|
|
265
|
+
reason?: LayoutFacetInvalidationReason;
|
|
266
|
+
}
|
|
267
|
+
| {
|
|
268
|
+
kind: "render_frame_ready";
|
|
269
|
+
revision: number;
|
|
270
|
+
pageRange?: { fromPageIndex: number; toPageIndex: number };
|
|
271
|
+
}
|
|
272
|
+
| {
|
|
273
|
+
kind: "zoom_changed";
|
|
274
|
+
revision: number;
|
|
275
|
+
zoom: RenderZoomSummary;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Minimal zoom summary carried with `zoom_changed`. The render kernel
|
|
280
|
+
* (when shipped) provides richer zoom metadata; consumers today only need
|
|
281
|
+
* the resolved px-per-twip plus the fit mode to reposition chrome.
|
|
282
|
+
*/
|
|
283
|
+
export interface RenderZoomSummary {
|
|
284
|
+
pxPerTwip: number;
|
|
285
|
+
viewportWidthPx: number;
|
|
286
|
+
fitMode: "fixed" | "page-width" | "one-page";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type LayoutFacetInvalidationReason =
|
|
290
|
+
| { kind: "content-edit"; from: number; to: number }
|
|
291
|
+
| { kind: "section-change"; sectionIndex: number }
|
|
292
|
+
| { kind: "styles-change" }
|
|
293
|
+
| { kind: "theme-change" }
|
|
294
|
+
| { kind: "numbering-change"; numberingInstanceId?: string }
|
|
295
|
+
| { kind: "field-refresh"; family?: string }
|
|
296
|
+
| { kind: "full" };
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Facet interface
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
export interface WordReviewEditorLayoutFacet {
|
|
303
|
+
// Structure ------------------------------------------------------------
|
|
304
|
+
getPageCount(): number;
|
|
305
|
+
getPage(pageIndex: number): PublicPageNode | null;
|
|
306
|
+
getPages(options?: { sectionIndex?: number }): PublicPageNode[];
|
|
307
|
+
getSection(sectionIndex: number): PublicSectionNode | null;
|
|
308
|
+
getSections(): PublicSectionNode[];
|
|
309
|
+
|
|
310
|
+
// Offset / selection navigation ---------------------------------------
|
|
311
|
+
getPageForOffset(
|
|
312
|
+
offset: number,
|
|
313
|
+
story?: EditorStoryTarget,
|
|
314
|
+
): PublicPageNode | null;
|
|
315
|
+
getPageSpanForSelection(selection: SelectionSnapshot): PublicPageSpan | null;
|
|
316
|
+
getFragmentForOffset(
|
|
317
|
+
offset: number,
|
|
318
|
+
story?: EditorStoryTarget,
|
|
319
|
+
): PublicBlockFragment | null;
|
|
320
|
+
getAnchorForOffset(
|
|
321
|
+
offset: number,
|
|
322
|
+
story?: EditorStoryTarget,
|
|
323
|
+
): PublicPageAnchor | null;
|
|
324
|
+
|
|
325
|
+
// Per-page semantic reads ---------------------------------------------
|
|
326
|
+
getActiveStoriesOnPage(pageIndex: number): PublicResolvedPageStories | null;
|
|
327
|
+
getDisplayPageNumber(pageIndex: number): number | null;
|
|
328
|
+
getLineBoxes(
|
|
329
|
+
pageIndex: number,
|
|
330
|
+
options?: { region?: PublicPageRegion["kind"]; columnIndex?: number },
|
|
331
|
+
): PublicLineBox[];
|
|
332
|
+
/** Back-compat alias; prefer `getLineBoxes(pageIndex, { region })`. */
|
|
333
|
+
getLineBoxesForRegion(
|
|
334
|
+
pageIndex: number,
|
|
335
|
+
region: PublicPageRegion["kind"],
|
|
336
|
+
options?: { columnIndex?: number },
|
|
337
|
+
): readonly PublicLineBox[];
|
|
338
|
+
getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
|
|
339
|
+
|
|
340
|
+
// Page-format catalog --------------------------------------------------
|
|
341
|
+
getPageFormatCatalog(): readonly PageFormatDefinition[];
|
|
342
|
+
getActivePageFormat(sectionIndex: number): ActivePageFormat | null;
|
|
343
|
+
getMarginPresetCatalog(): readonly MarginPresetDefinition[];
|
|
344
|
+
getActiveMarginPreset(sectionIndex: number): ActiveMarginPreset | null;
|
|
345
|
+
|
|
346
|
+
// Render-frame access (R1) --------------------------------------------
|
|
347
|
+
/**
|
|
348
|
+
* Return the active `RenderFrame` produced by the runtime-owned render
|
|
349
|
+
* kernel. Optional when the host runtime has not yet installed a kernel
|
|
350
|
+
* — returns `null` in that case so consumers can fall back to the page-
|
|
351
|
+
* graph reads above.
|
|
352
|
+
*/
|
|
353
|
+
getRenderFrame?(
|
|
354
|
+
options?: import("../render/index.ts").RenderFrameQueryOptions,
|
|
355
|
+
): import("../render/index.ts").RenderFrame | null;
|
|
356
|
+
|
|
357
|
+
/** Return the render-kernel zoom, if a kernel is installed. */
|
|
358
|
+
getRenderZoom?(): import("../render/index.ts").RenderZoom | null;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Hit-test a point in the mounted shell's coordinate space against the
|
|
362
|
+
* current render frame. Returns the deepest matching region with runtime
|
|
363
|
+
* offset, block id, fragment id, and line index so chrome surfaces can
|
|
364
|
+
* turn mouse coordinates into canonical positions without consulting the
|
|
365
|
+
* DOM. Returns `null` when no kernel is installed, or when the point
|
|
366
|
+
* falls outside every page.
|
|
367
|
+
*/
|
|
368
|
+
hitTest?(
|
|
369
|
+
pointInRoot: import("../render/index.ts").RenderPoint,
|
|
370
|
+
): import("../render/index.ts").RenderHitResult | null;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve anchor rects for a `RenderAnchorQuery`. Returns `[]` when the
|
|
374
|
+
* kernel is absent or the query does not match any anchor. Chrome
|
|
375
|
+
* surfaces that need a single rect can read `getAnchorRects(q)[0]`;
|
|
376
|
+
* selection-spanning surfaces read the full list and union as needed.
|
|
377
|
+
*/
|
|
378
|
+
getAnchorRects?(
|
|
379
|
+
query: import("../render/index.ts").RenderAnchorQuery,
|
|
380
|
+
): readonly import("../render/index.ts").RenderFrameRect[];
|
|
381
|
+
|
|
382
|
+
// Scope rail segments (R3a) -------------------------------------------
|
|
383
|
+
/**
|
|
384
|
+
* Return workflow rail segments active on a given page. Returns an empty
|
|
385
|
+
* list when the host runtime did not supply workflow data to the facet.
|
|
386
|
+
*/
|
|
387
|
+
getScopeRailSegments(
|
|
388
|
+
pageIndex: number,
|
|
389
|
+
): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
|
|
390
|
+
/** Return every scope rail segment across the document. */
|
|
391
|
+
getAllScopeRailSegments(): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
|
|
392
|
+
|
|
393
|
+
// Measurement exposure -------------------------------------------------
|
|
394
|
+
getResolvedFormatting(blockId: string): PublicResolvedParagraphFormatting | null;
|
|
395
|
+
getResolvedRunFormatting(runId: string): PublicResolvedRunFormatting | null;
|
|
396
|
+
getMeasurement(blockId: string): PublicBlockMeasurement | null;
|
|
397
|
+
getMeasurementFidelity(): PublicMeasurementFidelity;
|
|
398
|
+
whenMeasurementReady(): Promise<void>;
|
|
399
|
+
|
|
400
|
+
// Table render plan (P3e consumed by the render kernel, P4) ------------
|
|
401
|
+
/**
|
|
402
|
+
* Build a `TableRenderPlan` for a table block on a given page. Returns
|
|
403
|
+
* `null` when the blockId does not resolve to a table in the current
|
|
404
|
+
* surface. The plan carries columnsTwips, bandClasses, verticalMerges,
|
|
405
|
+
* repeatedHeaderRows, and columnResizeHandles so chrome can render
|
|
406
|
+
* band-aware cell styling and place column-resize grips without
|
|
407
|
+
* walking canonical state.
|
|
408
|
+
*/
|
|
409
|
+
getTableRenderPlan(
|
|
410
|
+
blockId: string,
|
|
411
|
+
pageIndex: number,
|
|
412
|
+
): import("./table-render-plan.ts").TableRenderPlan | null;
|
|
413
|
+
|
|
414
|
+
// Fields ---------------------------------------------------------------
|
|
415
|
+
getDirtyFieldFamilies(): readonly string[];
|
|
416
|
+
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
417
|
+
|
|
418
|
+
// Events ---------------------------------------------------------------
|
|
419
|
+
subscribe(listener: (event: LayoutFacetEvent) => void): () => void;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Factory
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
export interface CreateLayoutFacetInput {
|
|
427
|
+
engine: LayoutEngineInstance;
|
|
428
|
+
getQueryInput: () => LayoutEngineQueryInput;
|
|
429
|
+
/**
|
|
430
|
+
* Optional render-kernel accessor. When supplied, the facet exposes
|
|
431
|
+
* `getRenderFrame` + `getRenderZoom` that delegate to the kernel.
|
|
432
|
+
*/
|
|
433
|
+
renderKernel?: () =>
|
|
434
|
+
| import("../render/index.ts").RenderKernel
|
|
435
|
+
| null
|
|
436
|
+
| undefined;
|
|
437
|
+
/**
|
|
438
|
+
* Optional workflow-segments accessor. When supplied, the facet computes
|
|
439
|
+
* `getScopeRailSegments` / `getAllScopeRailSegments` by joining the host-
|
|
440
|
+
* supplied workflow state with the current page graph. When omitted,
|
|
441
|
+
* both methods return empty arrays.
|
|
442
|
+
*/
|
|
443
|
+
getWorkflowRailInput?: () =>
|
|
444
|
+
| Omit<
|
|
445
|
+
import("../workflow-rail-segments.ts").CollectScopeRailSegmentsInput,
|
|
446
|
+
"pageGraph"
|
|
447
|
+
>
|
|
448
|
+
| null
|
|
449
|
+
| undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function createLayoutFacet(
|
|
453
|
+
input: CreateLayoutFacetInput,
|
|
454
|
+
): WordReviewEditorLayoutFacet {
|
|
455
|
+
const { engine, getQueryInput } = input;
|
|
456
|
+
|
|
457
|
+
function currentGraph(): RuntimePageGraph {
|
|
458
|
+
return engine.getPageGraph(getQueryInput());
|
|
459
|
+
}
|
|
460
|
+
function currentMapper(): PageFragmentMapper {
|
|
461
|
+
return engine.getFragmentMapper(getQueryInput());
|
|
462
|
+
}
|
|
463
|
+
function currentFormatting(): ResolvedFormattingState {
|
|
464
|
+
return engine.getResolvedFormattingState(getQueryInput());
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const listeners = new Set<(event: LayoutFacetEvent) => void>();
|
|
468
|
+
const unsubscribeEngine = engine.subscribe((event: LayoutEngineEvent) => {
|
|
469
|
+
// `incremental_relayout` needs the last page index so the facet can
|
|
470
|
+
// report a `pageRange: [fromPageIndex, toPageIndex]` without asking the
|
|
471
|
+
// engine for an extra read. Use a best-effort lookup; on zero-page
|
|
472
|
+
// graphs the range collapses to the same index.
|
|
473
|
+
let lastPageIndex = 0;
|
|
474
|
+
try {
|
|
475
|
+
const pageCount = engine.getPageGraph(getQueryInput()).pages.length;
|
|
476
|
+
lastPageIndex = Math.max(0, pageCount - 1);
|
|
477
|
+
} catch {
|
|
478
|
+
lastPageIndex = 0;
|
|
479
|
+
}
|
|
480
|
+
const facetEvent = toFacetEvent(event, lastPageIndex);
|
|
481
|
+
if (!facetEvent) return;
|
|
482
|
+
for (const listener of listeners) {
|
|
483
|
+
try {
|
|
484
|
+
listener(facetEvent);
|
|
485
|
+
} catch {
|
|
486
|
+
// never let listener errors break the engine
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
// Keep the handle alive; the facet instance lives as long as the runtime.
|
|
491
|
+
void unsubscribeEngine;
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
getPageCount() {
|
|
495
|
+
return currentGraph().pages.length;
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
getPage(pageIndex) {
|
|
499
|
+
const graph = currentGraph();
|
|
500
|
+
const node = graph.pages[pageIndex];
|
|
501
|
+
return node ? toPublicPageNode(node, graph) : null;
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
getPages(options) {
|
|
505
|
+
const graph = currentGraph();
|
|
506
|
+
const filtered = options?.sectionIndex !== undefined
|
|
507
|
+
? graph.pages.filter((p) => p.sectionIndex === options.sectionIndex)
|
|
508
|
+
: graph.pages;
|
|
509
|
+
return filtered.map((node) => toPublicPageNode(node, graph));
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
getSection(sectionIndex) {
|
|
513
|
+
const graph = currentGraph();
|
|
514
|
+
return toPublicSectionNode(graph, sectionIndex);
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
getSections() {
|
|
518
|
+
const graph = currentGraph();
|
|
519
|
+
return graph.sections
|
|
520
|
+
.map((section) => toPublicSectionNode(graph, section.index))
|
|
521
|
+
.filter((node): node is PublicSectionNode => node !== null);
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
getPageForOffset(offset, story) {
|
|
525
|
+
const graph = currentGraph();
|
|
526
|
+
const page = findPageForOffsetAndStory(graph, offset, story);
|
|
527
|
+
return page ? toPublicPageNode(page, graph) : null;
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
getPageSpanForSelection(selection) {
|
|
531
|
+
return currentMapper().mapSelectionToPageSpan(selection);
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
getFragmentForOffset(offset, story) {
|
|
535
|
+
const mapper = currentMapper();
|
|
536
|
+
const graph = currentGraph();
|
|
537
|
+
const location = mapper.mapOffsetToFragment(offset, story);
|
|
538
|
+
if (!location) return null;
|
|
539
|
+
const fragment = graph.fragments.find(
|
|
540
|
+
(f) => f.fragmentId === location.fragmentId,
|
|
541
|
+
);
|
|
542
|
+
if (!fragment) return null;
|
|
543
|
+
return toPublicBlockFragment(fragment, graph);
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
getAnchorForOffset(offset, story) {
|
|
547
|
+
const mapper = currentMapper();
|
|
548
|
+
const graph = currentGraph();
|
|
549
|
+
const anchor = mapper.getAnchorForOffset(offset, story);
|
|
550
|
+
if (!anchor) return null;
|
|
551
|
+
const page = graph.pages.find((p) => p.pageId === anchor.pageId);
|
|
552
|
+
if (!page) return null;
|
|
553
|
+
const fragmentLocation = mapper.mapOffsetToFragment(offset, story);
|
|
554
|
+
return {
|
|
555
|
+
offset: anchor.offset,
|
|
556
|
+
pageId: anchor.pageId,
|
|
557
|
+
pageIndex: page.pageIndex,
|
|
558
|
+
...(fragmentLocation?.fragmentId !== undefined
|
|
559
|
+
? { fragmentId: fragmentLocation.fragmentId }
|
|
560
|
+
: {}),
|
|
561
|
+
...(fragmentLocation?.regionKind !== undefined
|
|
562
|
+
? { regionKind: fragmentLocation.regionKind }
|
|
563
|
+
: {}),
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
getActiveStoriesOnPage(pageIndex) {
|
|
568
|
+
const graph = currentGraph();
|
|
569
|
+
const node = graph.pages[pageIndex];
|
|
570
|
+
if (!node) return null;
|
|
571
|
+
return toPublicResolvedPageStories(node.stories);
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
getDisplayPageNumber(pageIndex) {
|
|
575
|
+
const graph = currentGraph();
|
|
576
|
+
const node = graph.pages[pageIndex];
|
|
577
|
+
return node ? node.stories.displayPageNumber : null;
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
getLineBoxes(pageIndex, options) {
|
|
581
|
+
const graph = currentGraph();
|
|
582
|
+
const node = graph.pages[pageIndex];
|
|
583
|
+
if (!node) return [];
|
|
584
|
+
const region = options?.region ?? "body";
|
|
585
|
+
return collectLineBoxesForRegion(
|
|
586
|
+
node,
|
|
587
|
+
region,
|
|
588
|
+
options?.columnIndex,
|
|
589
|
+
).map((box) => toPublicLineBox(box));
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
getLineBoxesForRegion(pageIndex, region, options) {
|
|
593
|
+
const graph = currentGraph();
|
|
594
|
+
const node = graph.pages[pageIndex];
|
|
595
|
+
if (!node) return [];
|
|
596
|
+
return collectLineBoxesForRegion(node, region, options?.columnIndex).map(
|
|
597
|
+
(box) => toPublicLineBox(box),
|
|
598
|
+
);
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
getPageFormatCatalog() {
|
|
602
|
+
return PAGE_FORMAT_CATALOG;
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
getActivePageFormat(sectionIndex) {
|
|
606
|
+
const graph = currentGraph();
|
|
607
|
+
const sectionPages = graph.pages.filter(
|
|
608
|
+
(p) => p.sectionIndex === sectionIndex,
|
|
609
|
+
);
|
|
610
|
+
const firstPage = sectionPages[0];
|
|
611
|
+
if (!firstPage) return null;
|
|
612
|
+
return matchPageFormat({
|
|
613
|
+
sectionIndex,
|
|
614
|
+
widthTwips: firstPage.layout.pageWidth,
|
|
615
|
+
heightTwips: firstPage.layout.pageHeight,
|
|
616
|
+
});
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
getMarginPresetCatalog() {
|
|
620
|
+
return MARGIN_PRESET_CATALOG;
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
getActiveMarginPreset(sectionIndex) {
|
|
624
|
+
const graph = currentGraph();
|
|
625
|
+
const sectionPages = graph.pages.filter(
|
|
626
|
+
(p) => p.sectionIndex === sectionIndex,
|
|
627
|
+
);
|
|
628
|
+
const firstPage = sectionPages[0];
|
|
629
|
+
if (!firstPage) return null;
|
|
630
|
+
const layout = firstPage.layout;
|
|
631
|
+
// `mirrorMargins` is a document-level setting
|
|
632
|
+
// (`w:mirrorMargins`) that the current canonical model does not
|
|
633
|
+
// surface onto the per-section layout snapshot; pass `false` until
|
|
634
|
+
// that plumbing exists so the Mirrored preset only matches when
|
|
635
|
+
// callers pass the preset-builder helper explicitly.
|
|
636
|
+
return matchMarginPreset({
|
|
637
|
+
sectionIndex,
|
|
638
|
+
topTwips: layout.marginTop,
|
|
639
|
+
bottomTwips: layout.marginBottom,
|
|
640
|
+
leftTwips: layout.marginLeft,
|
|
641
|
+
rightTwips: layout.marginRight,
|
|
642
|
+
gutterTwips: layout.gutter,
|
|
643
|
+
mirrored: false,
|
|
644
|
+
});
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
getRenderFrame(options) {
|
|
648
|
+
const kernel = input.renderKernel?.();
|
|
649
|
+
if (!kernel) return null;
|
|
650
|
+
return kernel.getRenderFrame(options);
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
getRenderZoom() {
|
|
654
|
+
const kernel = input.renderKernel?.();
|
|
655
|
+
if (!kernel) return null;
|
|
656
|
+
return kernel.getZoom();
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
hitTest(pointInRoot) {
|
|
660
|
+
const kernel = input.renderKernel?.();
|
|
661
|
+
if (!kernel) return null;
|
|
662
|
+
const frame = kernel.getRenderFrame();
|
|
663
|
+
return resolveHitTest(frame, pointInRoot);
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
getAnchorRects(query) {
|
|
667
|
+
const kernel = input.renderKernel?.();
|
|
668
|
+
if (!kernel) return [];
|
|
669
|
+
const frame = kernel.getRenderFrame();
|
|
670
|
+
return resolveAnchorRects(frame, query);
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
getScopeRailSegments(pageIndex) {
|
|
674
|
+
return collectScopeRailSegmentsForQuery(
|
|
675
|
+
input.getWorkflowRailInput?.(),
|
|
676
|
+
currentGraph(),
|
|
677
|
+
).filter((segment) => segment.pageIndex === pageIndex);
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
getAllScopeRailSegments() {
|
|
681
|
+
return collectScopeRailSegmentsForQuery(
|
|
682
|
+
input.getWorkflowRailInput?.(),
|
|
683
|
+
currentGraph(),
|
|
684
|
+
);
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
getFragmentsForPage(pageIndex) {
|
|
688
|
+
const graph = currentGraph();
|
|
689
|
+
const node = graph.pages[pageIndex];
|
|
690
|
+
if (!node) return [];
|
|
691
|
+
return graph.fragments
|
|
692
|
+
.filter((f) => f.pageId === node.pageId)
|
|
693
|
+
.map((f) => toPublicBlockFragment(f, graph));
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
getResolvedFormatting(blockId) {
|
|
697
|
+
const state = currentFormatting();
|
|
698
|
+
const formatting = state.paragraphs.get(blockId);
|
|
699
|
+
if (!formatting) return null;
|
|
700
|
+
return toPublicParagraphFormatting(blockId, formatting);
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
getResolvedRunFormatting(runId) {
|
|
704
|
+
const state = currentFormatting();
|
|
705
|
+
const run = state.runs.get(runId);
|
|
706
|
+
if (!run) return null;
|
|
707
|
+
const blockId = runId.split(":")[0] ?? "";
|
|
708
|
+
return toPublicRunFormatting(runId, blockId, run);
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
getMeasurement(blockId) {
|
|
712
|
+
const graph = currentGraph();
|
|
713
|
+
const fragments = graph.fragments.filter((f) => f.blockId === blockId);
|
|
714
|
+
if (fragments.length === 0) return null;
|
|
715
|
+
const lineCount = graph.pages.reduce((total, page) => {
|
|
716
|
+
return (
|
|
717
|
+
total +
|
|
718
|
+
page.lineBoxes.filter((line) =>
|
|
719
|
+
fragments.some((f) => f.fragmentId === line.fragmentId),
|
|
720
|
+
).length
|
|
721
|
+
);
|
|
722
|
+
}, 0);
|
|
723
|
+
const heightTwips = fragments.reduce((t, f) => t + f.heightTwips, 0);
|
|
724
|
+
return {
|
|
725
|
+
blockId,
|
|
726
|
+
lineCount,
|
|
727
|
+
heightTwips,
|
|
728
|
+
};
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
getMeasurementFidelity() {
|
|
732
|
+
return engine.measurementFidelity;
|
|
733
|
+
},
|
|
734
|
+
|
|
735
|
+
whenMeasurementReady() {
|
|
736
|
+
return engine.whenMeasurementReady();
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
getTableRenderPlan(blockId, pageIndex) {
|
|
740
|
+
const graph = currentGraph();
|
|
741
|
+
const fragment = graph.fragments.find((f) => f.blockId === blockId);
|
|
742
|
+
if (!fragment) return null;
|
|
743
|
+
const queryInput = getQueryInput();
|
|
744
|
+
const surface = lazyMainSurfaceForFacet(queryInput);
|
|
745
|
+
const tableBlock = findTableBlockByBlockId(surface.blocks, blockId);
|
|
746
|
+
if (!tableBlock) return null;
|
|
747
|
+
const resolved = resolveTableStyleResolutionForPlan(
|
|
748
|
+
tableBlock,
|
|
749
|
+
queryInput.document,
|
|
750
|
+
);
|
|
751
|
+
if (!resolved) return null;
|
|
752
|
+
// Sum all fragments for this block so the grip height spans every
|
|
753
|
+
// page the table occupies.
|
|
754
|
+
const tableHeightTwips = graph.fragments
|
|
755
|
+
.filter((f) => f.blockId === blockId)
|
|
756
|
+
.reduce((total, f) => total + f.heightTwips, 0);
|
|
757
|
+
return buildTableRenderPlan({
|
|
758
|
+
blockId,
|
|
759
|
+
pageIndex,
|
|
760
|
+
block: tableBlock,
|
|
761
|
+
resolved,
|
|
762
|
+
tableHeightTwips,
|
|
763
|
+
});
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
getDirtyFieldFamilies() {
|
|
767
|
+
return engine.getDirtyFieldFamilies();
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
getFieldDirtinessReport() {
|
|
771
|
+
const graph = currentGraph();
|
|
772
|
+
return {
|
|
773
|
+
families: engine.getDirtyFieldFamilies(),
|
|
774
|
+
revision: graph.revision,
|
|
775
|
+
};
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
subscribe(listener) {
|
|
779
|
+
listeners.add(listener);
|
|
780
|
+
return () => {
|
|
781
|
+
listeners.delete(listener);
|
|
782
|
+
};
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// Internal: graph → public clones
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
function toPublicPageNode(
|
|
792
|
+
node: RuntimePageNode,
|
|
793
|
+
graph: RuntimePageGraph,
|
|
794
|
+
): PublicPageNode {
|
|
795
|
+
return {
|
|
796
|
+
pageId: node.pageId,
|
|
797
|
+
pageIndex: node.pageIndex,
|
|
798
|
+
sectionIndex: node.sectionIndex,
|
|
799
|
+
pageInSection: node.pageInSection,
|
|
800
|
+
startOffset: node.startOffset,
|
|
801
|
+
endOffset: node.endOffset,
|
|
802
|
+
isBlankFiller: node.isBlankFiller,
|
|
803
|
+
displayPageNumber: node.stories.displayPageNumber,
|
|
804
|
+
isFirstPage: node.stories.isFirstPage,
|
|
805
|
+
isEvenPage: node.stories.isEvenPage,
|
|
806
|
+
layout: { ...node.layout },
|
|
807
|
+
stories: toPublicResolvedPageStories(node.stories),
|
|
808
|
+
regions: toPublicPageRegions(node.regions),
|
|
809
|
+
lineBoxCount: node.lineBoxes.length,
|
|
810
|
+
noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
|
|
811
|
+
};
|
|
812
|
+
void graph; // reserved for future cross-page derivations
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function toPublicResolvedPageStories(
|
|
816
|
+
stories: ResolvedPageStories,
|
|
817
|
+
): PublicResolvedPageStories {
|
|
818
|
+
return {
|
|
819
|
+
...(stories.header ? { header: { ...stories.header } } : {}),
|
|
820
|
+
...(stories.footer ? { footer: { ...stories.footer } } : {}),
|
|
821
|
+
isFirstPage: stories.isFirstPage,
|
|
822
|
+
isEvenPage: stories.isEvenPage,
|
|
823
|
+
displayPageNumber: stories.displayPageNumber,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function toPublicPageRegions(regions: RuntimePageRegions): PublicPageRegions {
|
|
828
|
+
return {
|
|
829
|
+
body: toPublicPageRegion(regions.body),
|
|
830
|
+
...(regions.header ? { header: toPublicPageRegion(regions.header) } : {}),
|
|
831
|
+
...(regions.footer ? { footer: toPublicPageRegion(regions.footer) } : {}),
|
|
832
|
+
...(regions.columns ? { columns: regions.columns.map(toPublicPageRegion) } : {}),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function toPublicPageRegion(region: RuntimePageRegion): PublicPageRegion {
|
|
837
|
+
return {
|
|
838
|
+
kind: region.kind,
|
|
839
|
+
originTwips: region.originTwips,
|
|
840
|
+
widthTwips: region.widthTwips,
|
|
841
|
+
heightTwips: region.heightTwips,
|
|
842
|
+
fragmentCount: region.fragmentIds.length,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function toPublicBlockFragment(
|
|
847
|
+
fragment: RuntimeBlockFragment,
|
|
848
|
+
graph: RuntimePageGraph,
|
|
849
|
+
): PublicBlockFragment {
|
|
850
|
+
const page = graph.pages.find((p) => p.pageId === fragment.pageId);
|
|
851
|
+
return {
|
|
852
|
+
fragmentId: fragment.fragmentId,
|
|
853
|
+
blockId: fragment.blockId,
|
|
854
|
+
pageId: fragment.pageId,
|
|
855
|
+
pageIndex: page?.pageIndex ?? -1,
|
|
856
|
+
regionKind: fragment.regionKind,
|
|
857
|
+
from: fragment.from,
|
|
858
|
+
to: fragment.to,
|
|
859
|
+
heightTwips: fragment.heightTwips,
|
|
860
|
+
orderInRegion: fragment.orderInRegion,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function toPublicLineBox(box: RuntimeLineBox): PublicLineBox {
|
|
865
|
+
return {
|
|
866
|
+
fragmentId: box.fragmentId,
|
|
867
|
+
lineIndex: box.lineIndex,
|
|
868
|
+
baselineTwips: box.baselineTwips,
|
|
869
|
+
heightTwips: box.heightTwips,
|
|
870
|
+
widthTwips: box.widthTwips,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function toPublicNoteAllocation(note: RuntimeNoteAllocation): PublicNoteAllocation {
|
|
875
|
+
return {
|
|
876
|
+
noteKind: note.noteKind,
|
|
877
|
+
noteId: note.noteId,
|
|
878
|
+
reservedHeightTwips: note.reservedHeightTwips,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function toPublicSectionNode(
|
|
883
|
+
graph: RuntimePageGraph,
|
|
884
|
+
sectionIndex: number,
|
|
885
|
+
): PublicSectionNode | null {
|
|
886
|
+
const section = graph.sections.find((s) => s.index === sectionIndex);
|
|
887
|
+
if (!section) return null;
|
|
888
|
+
const sectionPages = graph.pages.filter(
|
|
889
|
+
(p) => p.sectionIndex === sectionIndex,
|
|
890
|
+
);
|
|
891
|
+
const firstPage = sectionPages[0];
|
|
892
|
+
const lastPage = sectionPages[sectionPages.length - 1];
|
|
893
|
+
if (!firstPage || !lastPage) return null;
|
|
894
|
+
return {
|
|
895
|
+
sectionIndex,
|
|
896
|
+
startOffset: section.start,
|
|
897
|
+
endOffset: section.end,
|
|
898
|
+
firstPageIndex: firstPage.pageIndex,
|
|
899
|
+
lastPageIndex: lastPage.pageIndex,
|
|
900
|
+
pageCount: sectionPages.length,
|
|
901
|
+
layout: { ...firstPage.layout },
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function toPublicParagraphFormatting(
|
|
906
|
+
blockId: string,
|
|
907
|
+
formatting: ResolvedParagraphFormatting,
|
|
908
|
+
): PublicResolvedParagraphFormatting {
|
|
909
|
+
return {
|
|
910
|
+
blockId,
|
|
911
|
+
spacingBefore: formatting.spacingBefore,
|
|
912
|
+
spacingAfter: formatting.spacingAfter,
|
|
913
|
+
lineHeight: formatting.lineHeight,
|
|
914
|
+
lineRule: formatting.lineRule,
|
|
915
|
+
indentLeft: formatting.indentLeft,
|
|
916
|
+
indentRight: formatting.indentRight,
|
|
917
|
+
firstLineIndent: formatting.firstLineIndent,
|
|
918
|
+
hangingIndent: formatting.hangingIndent,
|
|
919
|
+
fontSizeHalfPoints: formatting.fontSizeHalfPoints,
|
|
920
|
+
averageCharWidthTwips: formatting.averageCharWidthTwips,
|
|
921
|
+
tabStops: formatting.tabStops.map((tab) => ({
|
|
922
|
+
positionTwips: tab.position,
|
|
923
|
+
alignment: tab.align,
|
|
924
|
+
...(tab.leader ? { leader: tab.leader } : {}),
|
|
925
|
+
})),
|
|
926
|
+
keepNext: formatting.keepNext,
|
|
927
|
+
keepLines: formatting.keepLines,
|
|
928
|
+
pageBreakBefore: formatting.pageBreakBefore,
|
|
929
|
+
widowControl: formatting.widowControl,
|
|
930
|
+
contextualSpacing: formatting.contextualSpacing,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function toPublicRunFormatting(
|
|
935
|
+
runId: string,
|
|
936
|
+
blockId: string,
|
|
937
|
+
run: ResolvedRunFormatting,
|
|
938
|
+
): PublicResolvedRunFormatting {
|
|
939
|
+
return {
|
|
940
|
+
runId,
|
|
941
|
+
blockId,
|
|
942
|
+
...(run.fontFamily ? { fontFamily: run.fontFamily } : {}),
|
|
943
|
+
...(run.fontSizeHalfPoints !== undefined
|
|
944
|
+
? { fontSizeHalfPoints: run.fontSizeHalfPoints }
|
|
945
|
+
: {}),
|
|
946
|
+
bold: run.bold,
|
|
947
|
+
italic: run.italic,
|
|
948
|
+
underline: run.underline,
|
|
949
|
+
strikethrough: run.strikethrough,
|
|
950
|
+
...(run.color ? { color: run.color } : {}),
|
|
951
|
+
...(run.highlight ? { highlight: run.highlight } : {}),
|
|
952
|
+
verticalAlign: run.verticalAlign,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function toFacetEvent(
|
|
957
|
+
event: LayoutEngineEvent,
|
|
958
|
+
lastPageIndex: number,
|
|
959
|
+
): LayoutFacetEvent | null {
|
|
960
|
+
switch (event.kind) {
|
|
961
|
+
case "layout_recomputed":
|
|
962
|
+
return {
|
|
963
|
+
kind: "layout_recomputed",
|
|
964
|
+
revision: event.revision,
|
|
965
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
966
|
+
};
|
|
967
|
+
case "page_count_changed":
|
|
968
|
+
return {
|
|
969
|
+
kind: "page_count_changed",
|
|
970
|
+
previous: event.previousPageCount ?? 0,
|
|
971
|
+
current: event.currentPageCount ?? 0,
|
|
972
|
+
revision: event.revision,
|
|
973
|
+
};
|
|
974
|
+
case "page_field_dirtied":
|
|
975
|
+
return {
|
|
976
|
+
kind: "page_field_dirtied",
|
|
977
|
+
families: event.dirtyFieldFamilies ?? [],
|
|
978
|
+
revision: event.revision,
|
|
979
|
+
};
|
|
980
|
+
case "measurement_backend_ready":
|
|
981
|
+
return {
|
|
982
|
+
kind: "measurement_backend_ready",
|
|
983
|
+
fidelity:
|
|
984
|
+
(event.fidelity as PublicMeasurementFidelity | undefined) ?? "empirical",
|
|
985
|
+
revision: event.revision,
|
|
986
|
+
};
|
|
987
|
+
case "incremental_relayout": {
|
|
988
|
+
const fromPageIndex =
|
|
989
|
+
event.firstDirtyPageIndex !== undefined
|
|
990
|
+
? Math.max(0, event.firstDirtyPageIndex)
|
|
991
|
+
: 0;
|
|
992
|
+
const toPageIndex = Math.max(fromPageIndex, lastPageIndex);
|
|
993
|
+
return {
|
|
994
|
+
kind: "incremental_relayout",
|
|
995
|
+
revision: event.revision,
|
|
996
|
+
pageRange: { fromPageIndex, toPageIndex },
|
|
997
|
+
...(event.reason ? { reason: event.reason } : {}),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
default:
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolve the page a given offset + story should surface on.
|
|
1007
|
+
*
|
|
1008
|
+
* Contract (per runtime-rendering-and-chrome-phase.md §2.3):
|
|
1009
|
+
* - Main story: the first content page containing the offset (unchanged).
|
|
1010
|
+
* - Header/footer story: the first page in the relevant section that
|
|
1011
|
+
* renders a header/footer matching the target variant. When the
|
|
1012
|
+
* section-index hint is omitted, the first page rendering any
|
|
1013
|
+
* variant of this kind is returned.
|
|
1014
|
+
* - Footnote/endnote story: the first page that reserves a note
|
|
1015
|
+
* allocation for the given note (by note id); falls back to the
|
|
1016
|
+
* first content page so UI surfaces always have an anchor.
|
|
1017
|
+
*/
|
|
1018
|
+
function findPageForOffsetAndStory(
|
|
1019
|
+
graph: RuntimePageGraph,
|
|
1020
|
+
offset: number,
|
|
1021
|
+
story?: EditorStoryTarget,
|
|
1022
|
+
): RuntimePageNode | null {
|
|
1023
|
+
if (!story || story.kind === "main") {
|
|
1024
|
+
for (const page of graph.pages) {
|
|
1025
|
+
if (!page.isBlankFiller && offset < page.endOffset) {
|
|
1026
|
+
return page;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const last = graph.pages[graph.pages.length - 1];
|
|
1030
|
+
return last ?? null;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (story.kind === "header" || story.kind === "footer") {
|
|
1034
|
+
const targetKind = story.kind;
|
|
1035
|
+
const targetVariant = story.variant;
|
|
1036
|
+
const targetRelationshipId = story.relationshipId;
|
|
1037
|
+
const sectionIndex = story.sectionIndex;
|
|
1038
|
+
const candidates = graph.pages.filter((page) => {
|
|
1039
|
+
if (page.isBlankFiller) return false;
|
|
1040
|
+
if (sectionIndex !== undefined && page.sectionIndex !== sectionIndex) {
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
const side =
|
|
1044
|
+
targetKind === "header" ? page.stories.header : page.stories.footer;
|
|
1045
|
+
if (!side) return false;
|
|
1046
|
+
if (side.kind !== "header" && side.kind !== "footer") return false;
|
|
1047
|
+
if (targetVariant !== undefined && side.variant !== targetVariant) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
if (
|
|
1051
|
+
targetRelationshipId !== undefined &&
|
|
1052
|
+
side.relationshipId !== targetRelationshipId
|
|
1053
|
+
) {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
return true;
|
|
1057
|
+
});
|
|
1058
|
+
if (candidates[0]) return candidates[0];
|
|
1059
|
+
// Fallback: any page in the given section (or the first page overall)
|
|
1060
|
+
if (sectionIndex !== undefined) {
|
|
1061
|
+
const fallback = graph.pages.find(
|
|
1062
|
+
(p) => p.sectionIndex === sectionIndex && !p.isBlankFiller,
|
|
1063
|
+
);
|
|
1064
|
+
if (fallback) return fallback;
|
|
1065
|
+
}
|
|
1066
|
+
const first = graph.pages.find((p) => !p.isBlankFiller);
|
|
1067
|
+
return first ?? graph.pages[0] ?? null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (story.kind === "footnote" || story.kind === "endnote") {
|
|
1071
|
+
const noteKind = story.kind;
|
|
1072
|
+
const noteId = story.noteId;
|
|
1073
|
+
if (noteId !== undefined) {
|
|
1074
|
+
const page = graph.pages.find((p) =>
|
|
1075
|
+
p.noteAllocations.some(
|
|
1076
|
+
(a) => a.noteKind === noteKind && a.noteId === noteId,
|
|
1077
|
+
),
|
|
1078
|
+
);
|
|
1079
|
+
if (page) return page;
|
|
1080
|
+
}
|
|
1081
|
+
for (const page of graph.pages) {
|
|
1082
|
+
if (!page.isBlankFiller && offset < page.endOffset) {
|
|
1083
|
+
return page;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const last = graph.pages[graph.pages.length - 1];
|
|
1087
|
+
return last ?? null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Select line boxes that belong to a given region on a page.
|
|
1095
|
+
*
|
|
1096
|
+
* Today the engine populates body line boxes only; header/footer/column/
|
|
1097
|
+
* footnote-area regions produce empty arrays until the render kernel lands
|
|
1098
|
+
* (Phase R1). The region filter is now exact: `body` returns only body
|
|
1099
|
+
* line boxes, everything else returns empty. Consumers should prefer
|
|
1100
|
+
* `getLineBoxes(pageIndex, { region: "body" })` — the other kinds are
|
|
1101
|
+
* scaffolded so UI can start reading through the facet without special-
|
|
1102
|
+
* casing region availability.
|
|
1103
|
+
*/
|
|
1104
|
+
function collectLineBoxesForRegion(
|
|
1105
|
+
node: RuntimePageNode,
|
|
1106
|
+
region: PublicPageRegion["kind"],
|
|
1107
|
+
_columnIndex: number | undefined,
|
|
1108
|
+
): readonly RuntimeLineBoxAlias[] {
|
|
1109
|
+
void _columnIndex;
|
|
1110
|
+
if (region === "body") {
|
|
1111
|
+
return node.lineBoxes;
|
|
1112
|
+
}
|
|
1113
|
+
return EMPTY_LINE_BOXES;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Use a shared alias so the region helper doesn't import the runtime
|
|
1117
|
+
// `RuntimeLineBox` type redundantly (`lineBoxes` is already strongly typed on
|
|
1118
|
+
// `RuntimePageNode`).
|
|
1119
|
+
type RuntimeLineBoxAlias = RuntimePageNode["lineBoxes"][number];
|
|
1120
|
+
const EMPTY_LINE_BOXES: readonly RuntimeLineBoxAlias[] = Object.freeze([]);
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Join the host-supplied workflow rail input with the current page graph.
|
|
1124
|
+
* Returns an empty array when no input is available so callers can always
|
|
1125
|
+
* iterate without null-checking.
|
|
1126
|
+
*/
|
|
1127
|
+
function collectScopeRailSegmentsForQuery(
|
|
1128
|
+
input:
|
|
1129
|
+
| Omit<
|
|
1130
|
+
import("../workflow-rail-segments.ts").CollectScopeRailSegmentsInput,
|
|
1131
|
+
"pageGraph"
|
|
1132
|
+
>
|
|
1133
|
+
| null
|
|
1134
|
+
| undefined,
|
|
1135
|
+
graph: RuntimePageGraph,
|
|
1136
|
+
): ScopeRailSegment[] {
|
|
1137
|
+
if (!input) return [];
|
|
1138
|
+
return collectScopeRailSegments({ ...input, pageGraph: graph });
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function resolveHitTest(
|
|
1142
|
+
frame: import("../render/index.ts").RenderFrame | null,
|
|
1143
|
+
point: import("../render/index.ts").RenderPoint,
|
|
1144
|
+
): import("../render/index.ts").RenderHitResult | null {
|
|
1145
|
+
if (!frame) return null;
|
|
1146
|
+
for (const page of frame.pages) {
|
|
1147
|
+
if (!containsPoint(page.frame, point)) continue;
|
|
1148
|
+
const regionHit = hitTestRegion(page, point, "body");
|
|
1149
|
+
if (regionHit) return regionHit;
|
|
1150
|
+
const header = hitTestRegion(page, point, "header");
|
|
1151
|
+
if (header) return header;
|
|
1152
|
+
const footer = hitTestRegion(page, point, "footer");
|
|
1153
|
+
if (footer) return footer;
|
|
1154
|
+
}
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function hitTestRegion(
|
|
1159
|
+
page: import("../render/index.ts").RenderPage,
|
|
1160
|
+
point: import("../render/index.ts").RenderPoint,
|
|
1161
|
+
kind: "body" | "header" | "footer",
|
|
1162
|
+
): import("../render/index.ts").RenderHitResult | null {
|
|
1163
|
+
const region =
|
|
1164
|
+
kind === "body"
|
|
1165
|
+
? page.regions.body
|
|
1166
|
+
: kind === "header"
|
|
1167
|
+
? page.regions.header
|
|
1168
|
+
: page.regions.footer;
|
|
1169
|
+
if (!region) return null;
|
|
1170
|
+
if (!containsPoint(region.frame, point)) return null;
|
|
1171
|
+
for (const block of region.blocks) {
|
|
1172
|
+
if (!containsPoint(block.frame, point)) continue;
|
|
1173
|
+
let bestLineIndex = -1;
|
|
1174
|
+
let bestLineDistance = Number.POSITIVE_INFINITY;
|
|
1175
|
+
for (let i = 0; i < block.lines.length; i++) {
|
|
1176
|
+
const line = block.lines[i]!;
|
|
1177
|
+
const midY = line.frame.topPx + line.frame.heightPx / 2;
|
|
1178
|
+
const distance = Math.abs(midY - point.yPx);
|
|
1179
|
+
if (distance < bestLineDistance) {
|
|
1180
|
+
bestLineDistance = distance;
|
|
1181
|
+
bestLineIndex = i;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const lineIndex = bestLineIndex >= 0 ? bestLineIndex : 0;
|
|
1185
|
+
const line = block.lines[lineIndex];
|
|
1186
|
+
const runtimeOffset = line?.anchors[0]?.runtimeOffset ?? block.fragment.from;
|
|
1187
|
+
return {
|
|
1188
|
+
pageIndex: page.page.pageIndex,
|
|
1189
|
+
regionKind: region.region.kind,
|
|
1190
|
+
blockId: block.fragment.blockId,
|
|
1191
|
+
fragmentId: block.fragment.fragmentId,
|
|
1192
|
+
lineIndex,
|
|
1193
|
+
runtimeOffset,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
// Point fell inside the region but between blocks — snap to the nearest block.
|
|
1197
|
+
let nearestBlock: import("../render/index.ts").RenderBlock | null = null;
|
|
1198
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
1199
|
+
for (const block of region.blocks) {
|
|
1200
|
+
const distance = Math.min(
|
|
1201
|
+
Math.abs(point.yPx - block.frame.topPx),
|
|
1202
|
+
Math.abs(point.yPx - (block.frame.topPx + block.frame.heightPx)),
|
|
1203
|
+
);
|
|
1204
|
+
if (distance < nearestDistance) {
|
|
1205
|
+
nearestBlock = block;
|
|
1206
|
+
nearestDistance = distance;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (!nearestBlock) return null;
|
|
1210
|
+
return {
|
|
1211
|
+
pageIndex: page.page.pageIndex,
|
|
1212
|
+
regionKind: region.region.kind,
|
|
1213
|
+
blockId: nearestBlock.fragment.blockId,
|
|
1214
|
+
fragmentId: nearestBlock.fragment.fragmentId,
|
|
1215
|
+
lineIndex: 0,
|
|
1216
|
+
runtimeOffset: nearestBlock.fragment.from,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function containsPoint(
|
|
1221
|
+
rect: import("../render/index.ts").RenderFrameRect,
|
|
1222
|
+
point: import("../render/index.ts").RenderPoint,
|
|
1223
|
+
): boolean {
|
|
1224
|
+
return (
|
|
1225
|
+
point.xPx >= rect.leftPx &&
|
|
1226
|
+
point.xPx <= rect.leftPx + rect.widthPx &&
|
|
1227
|
+
point.yPx >= rect.topPx &&
|
|
1228
|
+
point.yPx <= rect.topPx + rect.heightPx
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function resolveAnchorRects(
|
|
1233
|
+
frame: import("../render/index.ts").RenderFrame | null,
|
|
1234
|
+
query: import("../render/index.ts").RenderAnchorQuery,
|
|
1235
|
+
): readonly import("../render/index.ts").RenderFrameRect[] {
|
|
1236
|
+
if (!frame) return [];
|
|
1237
|
+
switch (query.kind) {
|
|
1238
|
+
case "runtime-offset": {
|
|
1239
|
+
const offset =
|
|
1240
|
+
typeof query.value === "number" ? query.value : Number(query.value);
|
|
1241
|
+
if (!Number.isFinite(offset)) return [];
|
|
1242
|
+
const rect = frame.anchorIndex.byRuntimeOffset(offset, query.story);
|
|
1243
|
+
return rect ? [rect] : [];
|
|
1244
|
+
}
|
|
1245
|
+
case "block-id": {
|
|
1246
|
+
const rect = frame.anchorIndex.byBlockId(String(query.value));
|
|
1247
|
+
return rect ? [rect] : [];
|
|
1248
|
+
}
|
|
1249
|
+
case "fragment-id": {
|
|
1250
|
+
const rect = frame.anchorIndex.byFragmentId(String(query.value));
|
|
1251
|
+
return rect ? [rect] : [];
|
|
1252
|
+
}
|
|
1253
|
+
case "page-index": {
|
|
1254
|
+
const pageIndex =
|
|
1255
|
+
typeof query.value === "number" ? query.value : Number(query.value);
|
|
1256
|
+
if (!Number.isFinite(pageIndex)) return [];
|
|
1257
|
+
const rect = frame.anchorIndex.byPageIndex(pageIndex);
|
|
1258
|
+
return rect ? [rect] : [];
|
|
1259
|
+
}
|
|
1260
|
+
case "scope-id": {
|
|
1261
|
+
const id = String(query.value);
|
|
1262
|
+
return frame.decorationIndex.workflow
|
|
1263
|
+
.filter((decoration) => decoration.refId === id)
|
|
1264
|
+
.map((decoration) => decoration.frame);
|
|
1265
|
+
}
|
|
1266
|
+
case "comment-id": {
|
|
1267
|
+
const id = String(query.value);
|
|
1268
|
+
return frame.decorationIndex.comments
|
|
1269
|
+
.filter((decoration) => decoration.refId === id)
|
|
1270
|
+
.map((decoration) => decoration.frame);
|
|
1271
|
+
}
|
|
1272
|
+
case "revision-id": {
|
|
1273
|
+
const id = String(query.value);
|
|
1274
|
+
return frame.decorationIndex.revisions
|
|
1275
|
+
.filter((decoration) => decoration.refId === id)
|
|
1276
|
+
.map((decoration) => decoration.frame);
|
|
1277
|
+
}
|
|
1278
|
+
default: {
|
|
1279
|
+
const exhaustive: never = query.kind;
|
|
1280
|
+
void exhaustive;
|
|
1281
|
+
return [];
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// ---------------------------------------------------------------------------
|
|
1287
|
+
// Table render plan helpers (P4)
|
|
1288
|
+
// ---------------------------------------------------------------------------
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Produce (or reuse) the main surface snapshot the facet needs for table
|
|
1292
|
+
* plan resolution. The engine already rebuilds a surface on every full
|
|
1293
|
+
* relayout, but that output is not cached externally — for a per-call
|
|
1294
|
+
* `getTableRenderPlan` we reconstruct the surface with a static zero
|
|
1295
|
+
* selection. This is a pure read and only runs on chrome requests, so
|
|
1296
|
+
* the cost is bounded.
|
|
1297
|
+
*/
|
|
1298
|
+
function lazyMainSurfaceForFacet(
|
|
1299
|
+
input: LayoutEngineQueryInput,
|
|
1300
|
+
): ReturnType<typeof createEditorSurfaceSnapshot> {
|
|
1301
|
+
return createEditorSurfaceSnapshot(
|
|
1302
|
+
input.document,
|
|
1303
|
+
createSelectionSnapshot(0, 0),
|
|
1304
|
+
MAIN_STORY_TARGET,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Walk a flat surface block list (with recursion into sdt/table nested
|
|
1310
|
+
* children) to find a table block by its projected blockId.
|
|
1311
|
+
*/
|
|
1312
|
+
function findTableBlockByBlockId(
|
|
1313
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
1314
|
+
blockId: string,
|
|
1315
|
+
): Extract<SurfaceBlockSnapshot, { kind: "table" }> | null {
|
|
1316
|
+
for (const block of blocks) {
|
|
1317
|
+
if (block.kind === "table") {
|
|
1318
|
+
if (block.blockId === blockId) return block;
|
|
1319
|
+
// Recurse into nested tables (cell contents may contain tables).
|
|
1320
|
+
for (const row of block.rows) {
|
|
1321
|
+
for (const cell of row.cells) {
|
|
1322
|
+
const nested = findTableBlockByBlockId(cell.content, blockId);
|
|
1323
|
+
if (nested) return nested;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
} else if (block.kind === "sdt_block") {
|
|
1327
|
+
const nested = findTableBlockByBlockId(block.children, blockId);
|
|
1328
|
+
if (nested) return nested;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Resolve the table-style cascade (direct properties → style chain → band
|
|
1336
|
+
* conditional formatting) for a surface table block. Needs the canonical
|
|
1337
|
+
* document to look up the underlying `TableNode` by blockId so the full
|
|
1338
|
+
* resolver runs against canonical state, not the pre-projected surface.
|
|
1339
|
+
*/
|
|
1340
|
+
function resolveTableStyleResolutionForPlan(
|
|
1341
|
+
surfaceTable: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
|
|
1342
|
+
document: LayoutEngineQueryInput["document"],
|
|
1343
|
+
): ReturnType<typeof resolveTableStyleResolution> | null {
|
|
1344
|
+
const canonicalTable = findCanonicalTableByBlockId(
|
|
1345
|
+
document.content.children,
|
|
1346
|
+
surfaceTable.blockId,
|
|
1347
|
+
{ counter: { value: 0 } },
|
|
1348
|
+
);
|
|
1349
|
+
if (!canonicalTable) return null;
|
|
1350
|
+
return resolveTableStyleResolution(
|
|
1351
|
+
canonicalTable,
|
|
1352
|
+
document.styles.tables ?? {},
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Walk canonical content depth-first incrementing the table counter to
|
|
1358
|
+
* locate the `TableNode` whose projected blockId would be `table-${N}`.
|
|
1359
|
+
* Mirrors the counter discipline in `src/runtime/surface-projection.ts`.
|
|
1360
|
+
*/
|
|
1361
|
+
function findCanonicalTableByBlockId(
|
|
1362
|
+
children: import("../../model/canonical-document.ts").BlockNode[],
|
|
1363
|
+
targetBlockId: string,
|
|
1364
|
+
state: { counter: { value: number } },
|
|
1365
|
+
): import("../../model/canonical-document.ts").TableNode | null {
|
|
1366
|
+
for (const child of children) {
|
|
1367
|
+
if (child.type === "table") {
|
|
1368
|
+
const index = state.counter.value;
|
|
1369
|
+
state.counter.value += 1;
|
|
1370
|
+
if (`table-${index}` === targetBlockId) return child;
|
|
1371
|
+
for (const row of child.rows) {
|
|
1372
|
+
for (const cell of row.cells) {
|
|
1373
|
+
const nested = findCanonicalTableByBlockId(
|
|
1374
|
+
cell.children,
|
|
1375
|
+
targetBlockId,
|
|
1376
|
+
state,
|
|
1377
|
+
);
|
|
1378
|
+
if (nested) return nested;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
} else if (child.type === "sdt") {
|
|
1382
|
+
const nested = findCanonicalTableByBlockId(
|
|
1383
|
+
child.children,
|
|
1384
|
+
targetBlockId,
|
|
1385
|
+
state,
|
|
1386
|
+
);
|
|
1387
|
+
if (nested) return nested;
|
|
1388
|
+
} else if (child.type === "custom_xml") {
|
|
1389
|
+
const nested = findCanonicalTableByBlockId(
|
|
1390
|
+
child.children,
|
|
1391
|
+
targetBlockId,
|
|
1392
|
+
state,
|
|
1393
|
+
);
|
|
1394
|
+
if (nested) return nested;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|