@beyondwork/docx-react-component 1.0.42 → 1.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -62,8 +62,12 @@ import {
|
|
|
62
62
|
type ScopeRailSegment,
|
|
63
63
|
} from "../workflow-rail-segments.ts";
|
|
64
64
|
import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
|
|
65
|
+
import { storyTargetKey } from "../story-targeting.ts";
|
|
65
66
|
import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
|
|
66
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
createSelectionSnapshot,
|
|
69
|
+
type CanonicalDocumentEnvelope,
|
|
70
|
+
} from "../../core/state/editor-state.ts";
|
|
67
71
|
import { resolveTableStyleResolution } from "../table-style-resolver.ts";
|
|
68
72
|
import { buildTableRenderPlan } from "./table-render-plan.ts";
|
|
69
73
|
import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
|
|
@@ -124,6 +128,13 @@ export interface PublicPageRegions {
|
|
|
124
128
|
header?: PublicPageRegion;
|
|
125
129
|
footer?: PublicPageRegion;
|
|
126
130
|
columns?: readonly PublicPageRegion[];
|
|
131
|
+
/**
|
|
132
|
+
* P8.3 — Footnote regions reserved at the bottom of the page (above the
|
|
133
|
+
* footer band). Mirrors `RuntimePageRegions.footnotes`. Present only
|
|
134
|
+
* when at least one allocation on this page produced a fragment — see
|
|
135
|
+
* `noteAllocations` for per-note metadata. Additive / back-compat safe.
|
|
136
|
+
*/
|
|
137
|
+
footnotes?: readonly PublicPageRegion[];
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
export interface PublicPageRegion {
|
|
@@ -134,6 +145,15 @@ export interface PublicPageRegion {
|
|
|
134
145
|
fragmentCount: number;
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* P8 — Region kind discriminator used by `PublicRegionBlock` and the
|
|
150
|
+
* `getStoryBlocksForRegion` API. Mirrors `PublicPageRegion["kind"]` plus
|
|
151
|
+
* `"endnote-area"`, which is only ever surfaced via
|
|
152
|
+
* `getDocumentEndnoteBlocks` (endnotes have document-end placement and
|
|
153
|
+
* never sit on a body page).
|
|
154
|
+
*/
|
|
155
|
+
export type PublicRegionKind = PublicPageRegion["kind"] | "endnote-area";
|
|
156
|
+
|
|
137
157
|
export interface PublicBlockFragment {
|
|
138
158
|
fragmentId: string;
|
|
139
159
|
blockId: string;
|
|
@@ -146,6 +166,29 @@ export interface PublicBlockFragment {
|
|
|
146
166
|
orderInRegion: number;
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
/**
|
|
170
|
+
* P8 — One block snapshot rendered into a region of a page. Returned by
|
|
171
|
+
* `WordReviewEditorLayoutFacet.getStoryBlocksForRegion` and
|
|
172
|
+
* `getDocumentEndnoteBlocks`.
|
|
173
|
+
*
|
|
174
|
+
* The `blockSnapshot` field is the same `SurfaceBlockSnapshot` consumed by
|
|
175
|
+
* the body block renderer (`tw-page-block-view`), so region renderers reuse
|
|
176
|
+
* body typography verbatim. For body fragments `runtimeFromOffset`/`To`
|
|
177
|
+
* mirror the underlying `RuntimeBlockFragment.from`/`to`. For header /
|
|
178
|
+
* footer / footnote-area blocks the offsets are local to the host story
|
|
179
|
+
* (header story, footer story, or note body).
|
|
180
|
+
*/
|
|
181
|
+
export interface PublicRegionBlock {
|
|
182
|
+
blockId: string;
|
|
183
|
+
fragmentId: string;
|
|
184
|
+
pageIndex: number;
|
|
185
|
+
regionKind: PublicRegionKind;
|
|
186
|
+
runtimeFromOffset: number;
|
|
187
|
+
runtimeToOffset: number;
|
|
188
|
+
heightTwips: number;
|
|
189
|
+
blockSnapshot: SurfaceBlockSnapshot;
|
|
190
|
+
}
|
|
191
|
+
|
|
149
192
|
export interface PublicLineBox {
|
|
150
193
|
fragmentId: string;
|
|
151
194
|
lineIndex: number;
|
|
@@ -376,6 +419,29 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
376
419
|
getStoryRegionsOnPage(pageIndex: number): readonly PublicPageRegion[];
|
|
377
420
|
getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
|
|
378
421
|
|
|
422
|
+
/**
|
|
423
|
+
* P8 — Returns per-region block snapshots for a page. Body resolves the
|
|
424
|
+
* page's body fragments to their `SurfaceBlockSnapshot`s; header / footer
|
|
425
|
+
* resolve via the page's active story target + `subParts.headers/footers`
|
|
426
|
+
* lookup; footnote-area resolves per-page `noteAllocations` against
|
|
427
|
+
* `subParts.footnoteCollection.footnotes`. Endnote-area is always empty
|
|
428
|
+
* per-page — document-end placement is handled by
|
|
429
|
+
* `getDocumentEndnoteBlocks`. Column falls through to body (multi-column
|
|
430
|
+
* splitting is a P10 follow-up). Cached per revision; busts on
|
|
431
|
+
* `graph.revision` change.
|
|
432
|
+
*/
|
|
433
|
+
getStoryBlocksForRegion(
|
|
434
|
+
pageIndex: number,
|
|
435
|
+
region: PublicPageRegion["kind"],
|
|
436
|
+
options?: { columnIndex?: number },
|
|
437
|
+
): readonly PublicRegionBlock[];
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* P8 — Returns endnote bodies in document order for document-end
|
|
441
|
+
* placement (per OOXML default `w:endnotePr/w:pos="docEnd"`).
|
|
442
|
+
*/
|
|
443
|
+
getDocumentEndnoteBlocks(): readonly PublicRegionBlock[];
|
|
444
|
+
|
|
379
445
|
// Page-format catalog --------------------------------------------------
|
|
380
446
|
getPageFormatCatalog(): readonly PageFormatDefinition[];
|
|
381
447
|
getActivePageFormat(sectionIndex: number): ActivePageFormat | null;
|
|
@@ -498,6 +564,19 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
498
564
|
getDirtyFieldFamilies(): readonly string[];
|
|
499
565
|
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
500
566
|
|
|
567
|
+
// Viewport culling (L7 Phase 2) ----------------------------------------
|
|
568
|
+
/**
|
|
569
|
+
* Notifies the runtime that the visible block range changed. Delegates to
|
|
570
|
+
* `DocumentRuntime.setVisibleBlockRange`. Safe to call at any frequency;
|
|
571
|
+
* identical ranges are a no-op inside the runtime.
|
|
572
|
+
*/
|
|
573
|
+
setVisibleBlockRange(range: { start: number; end: number }): void;
|
|
574
|
+
/**
|
|
575
|
+
* Triggers a surface-only refresh applying the latest visible block range.
|
|
576
|
+
* Delegates to `DocumentRuntime.requestViewportRefresh`.
|
|
577
|
+
*/
|
|
578
|
+
requestViewportRefresh(): void;
|
|
579
|
+
|
|
501
580
|
// Events ---------------------------------------------------------------
|
|
502
581
|
subscribe(listener: (event: LayoutFacetEvent) => void): () => void;
|
|
503
582
|
}
|
|
@@ -509,6 +588,13 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
509
588
|
export interface CreateLayoutFacetInput {
|
|
510
589
|
engine: LayoutEngineInstance;
|
|
511
590
|
getQueryInput: () => LayoutEngineQueryInput;
|
|
591
|
+
/**
|
|
592
|
+
* P8 — Canonical document accessor used by `getStoryBlocksForRegion` /
|
|
593
|
+
* `getDocumentEndnoteBlocks` to resolve header / footer / footnote /
|
|
594
|
+
* endnote stories in `subParts`. When omitted the facet falls back to
|
|
595
|
+
* the document available on the engine query input.
|
|
596
|
+
*/
|
|
597
|
+
canonicalDocument?: () => CanonicalDocumentEnvelope;
|
|
512
598
|
/**
|
|
513
599
|
* Optional render-kernel accessor. When supplied, the facet exposes
|
|
514
600
|
* `getRenderFrame` + `getRenderZoom` that delegate to the kernel.
|
|
@@ -541,6 +627,14 @@ export interface CreateLayoutFacetInput {
|
|
|
541
627
|
| readonly import("../../api/public-types.ts").WorkflowMetadataMarkup[]
|
|
542
628
|
| null
|
|
543
629
|
| undefined;
|
|
630
|
+
/**
|
|
631
|
+
* L7 Phase 2 — optional viewport culling hooks wired from the owning
|
|
632
|
+
* `DocumentRuntime`. When supplied, `facet.setVisibleBlockRange` /
|
|
633
|
+
* `facet.requestViewportRefresh` delegate directly to these; when omitted
|
|
634
|
+
* (e.g. tests, inert facet, standalone use) both methods are no-ops.
|
|
635
|
+
*/
|
|
636
|
+
setVisibleBlockRange?: (range: { start: number; end: number }) => void;
|
|
637
|
+
requestViewportRefresh?: () => void;
|
|
544
638
|
/**
|
|
545
639
|
* R3 — optional suggestions snapshot accessor. Used by
|
|
546
640
|
* `getAllScopeCardModels` to attach `SuggestionGroup` entries whose
|
|
@@ -593,6 +687,151 @@ export function createLayoutFacet(
|
|
|
593
687
|
// Keep the handle alive; the facet instance lives as long as the runtime.
|
|
594
688
|
void unsubscribeEngine;
|
|
595
689
|
|
|
690
|
+
// P8 — per-revision cache for getStoryBlocksForRegion. Identity is
|
|
691
|
+
// preserved within a single graph revision so consumers can `===`-compare
|
|
692
|
+
// results across band renders, and the cache flushes whenever the engine
|
|
693
|
+
// produces a fresh page graph.
|
|
694
|
+
let regionBlocksCache: {
|
|
695
|
+
revision: number;
|
|
696
|
+
byKey: Map<string, readonly PublicRegionBlock[]>;
|
|
697
|
+
} = { revision: -1, byKey: new Map() };
|
|
698
|
+
// P8.2.1 — secondary cache keyed by header/footer story-target identity.
|
|
699
|
+
// A 38-page doc with one default header projects the story once instead of
|
|
700
|
+
// 38 times; per-page `PublicRegionBlock[]`s still differ (each carries the
|
|
701
|
+
// page's `pageIndex`), but the underlying `SurfaceBlockSnapshot[]` and
|
|
702
|
+
// every `blockSnapshot` reference are shared. Busts together with
|
|
703
|
+
// `regionBlocksCache` on `graph.revision` change.
|
|
704
|
+
let storyProjectionCache: {
|
|
705
|
+
revision: number;
|
|
706
|
+
byKey: Map<string, readonly SurfaceBlockSnapshot[]>;
|
|
707
|
+
} = { revision: -1, byKey: new Map() };
|
|
708
|
+
// P8 — per-revision cache for getDocumentEndnoteBlocks (single key).
|
|
709
|
+
let endnoteBlocksCache: {
|
|
710
|
+
revision: number;
|
|
711
|
+
blocks: readonly PublicRegionBlock[] | null;
|
|
712
|
+
} = { revision: -1, blocks: null };
|
|
713
|
+
|
|
714
|
+
function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
|
|
715
|
+
if (input.canonicalDocument) {
|
|
716
|
+
return input.canonicalDocument();
|
|
717
|
+
}
|
|
718
|
+
return getQueryInput().document;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Build the body story's `SurfaceBlockSnapshot[]` so body fragment
|
|
723
|
+
* `blockId`s can be resolved. Cached against the canonical document
|
|
724
|
+
* identity to avoid re-walking the body on every region read.
|
|
725
|
+
*/
|
|
726
|
+
let cachedBodySurface: {
|
|
727
|
+
document: CanonicalDocumentEnvelope;
|
|
728
|
+
blocks: readonly SurfaceBlockSnapshot[];
|
|
729
|
+
} | null = null;
|
|
730
|
+
function bodySurfaceBlocks(): readonly SurfaceBlockSnapshot[] {
|
|
731
|
+
const document = resolveCanonicalDocument();
|
|
732
|
+
if (cachedBodySurface && cachedBodySurface.document === document) {
|
|
733
|
+
return cachedBodySurface.blocks;
|
|
734
|
+
}
|
|
735
|
+
const surface = createEditorSurfaceSnapshot(
|
|
736
|
+
document,
|
|
737
|
+
createSelectionSnapshot(0, 0),
|
|
738
|
+
MAIN_STORY_TARGET,
|
|
739
|
+
);
|
|
740
|
+
cachedBodySurface = { document, blocks: surface.blocks };
|
|
741
|
+
return surface.blocks;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function findBodyBlockById(
|
|
745
|
+
blockId: string,
|
|
746
|
+
): SurfaceBlockSnapshot | undefined {
|
|
747
|
+
return bodySurfaceBlocks().find((b) => b.blockId === blockId);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function getRegionBlocksCached(
|
|
751
|
+
pageIndex: number,
|
|
752
|
+
region: PublicPageRegion["kind"],
|
|
753
|
+
columnIndex: number | undefined,
|
|
754
|
+
): readonly PublicRegionBlock[] {
|
|
755
|
+
const graph = currentGraph();
|
|
756
|
+
if (regionBlocksCache.revision !== graph.revision) {
|
|
757
|
+
regionBlocksCache = { revision: graph.revision, byKey: new Map() };
|
|
758
|
+
// P8.2.1 — bust the secondary story-projection cache in lock-step so
|
|
759
|
+
// shared `SurfaceBlockSnapshot`s never outlive their graph revision.
|
|
760
|
+
storyProjectionCache = { revision: graph.revision, byKey: new Map() };
|
|
761
|
+
// Body surface depends on the canonical document; the document
|
|
762
|
+
// identity check inside `bodySurfaceBlocks` keeps it warm across
|
|
763
|
+
// pure-layout invalidations (zoom, font, etc.).
|
|
764
|
+
}
|
|
765
|
+
const key = `${pageIndex}:${region}:${columnIndex ?? "_"}`;
|
|
766
|
+
const cached = regionBlocksCache.byKey.get(key);
|
|
767
|
+
if (cached) return cached;
|
|
768
|
+
const fresh = computeRegionBlocks(pageIndex, region, columnIndex, graph);
|
|
769
|
+
regionBlocksCache.byKey.set(key, fresh);
|
|
770
|
+
return fresh;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* P8.2.1 — shared projection for a header/footer story. Returns the same
|
|
775
|
+
* `SurfaceBlockSnapshot[]` array identity for repeated calls at the same
|
|
776
|
+
* revision so per-page `PublicRegionBlock[]`s can share every
|
|
777
|
+
* `blockSnapshot` reference.
|
|
778
|
+
*/
|
|
779
|
+
function getStoryProjectionCached(
|
|
780
|
+
storyTarget: EditorStoryTarget,
|
|
781
|
+
document: CanonicalDocumentEnvelope,
|
|
782
|
+
): readonly SurfaceBlockSnapshot[] {
|
|
783
|
+
const key = storyTargetKey(storyTarget);
|
|
784
|
+
const cached = storyProjectionCache.byKey.get(key);
|
|
785
|
+
if (cached) return cached;
|
|
786
|
+
const surface = createEditorSurfaceSnapshot(
|
|
787
|
+
document,
|
|
788
|
+
createSelectionSnapshot(0, 0),
|
|
789
|
+
storyTarget,
|
|
790
|
+
);
|
|
791
|
+
const fresh = surface.blocks;
|
|
792
|
+
storyProjectionCache.byKey.set(key, fresh);
|
|
793
|
+
return fresh;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function computeRegionBlocks(
|
|
797
|
+
pageIndex: number,
|
|
798
|
+
region: PublicPageRegion["kind"],
|
|
799
|
+
columnIndex: number | undefined,
|
|
800
|
+
graph: RuntimePageGraph,
|
|
801
|
+
): readonly PublicRegionBlock[] {
|
|
802
|
+
const node = graph.pages[pageIndex];
|
|
803
|
+
if (!node) return Object.freeze([]);
|
|
804
|
+
const document = resolveCanonicalDocument();
|
|
805
|
+
if (region === "body") {
|
|
806
|
+
return resolveBodyRegionBlocks(node, graph, findBodyBlockById);
|
|
807
|
+
}
|
|
808
|
+
if (region === "header" || region === "footer") {
|
|
809
|
+
const story =
|
|
810
|
+
region === "header" ? node.stories.header : node.stories.footer;
|
|
811
|
+
if (!story) return Object.freeze([]);
|
|
812
|
+
// P8.2.1 — share the projected `SurfaceBlockSnapshot[]` across every
|
|
813
|
+
// page that renders the same story target.
|
|
814
|
+
const projectedBlocks = getStoryProjectionCached(story, document);
|
|
815
|
+
return resolveHeaderFooterRegionBlocks(
|
|
816
|
+
node.pageIndex,
|
|
817
|
+
region,
|
|
818
|
+
story,
|
|
819
|
+
projectedBlocks,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
if (region === "footnote-area") {
|
|
823
|
+
return resolveFootnoteAreaRegionBlocks(node, document);
|
|
824
|
+
}
|
|
825
|
+
if (region === "column") {
|
|
826
|
+
// Multi-column body splitting is a P10 follow-up. For now reuse
|
|
827
|
+
// the body fragments for the indexed column.
|
|
828
|
+
void columnIndex;
|
|
829
|
+
return resolveBodyRegionBlocks(node, graph, findBodyBlockById);
|
|
830
|
+
}
|
|
831
|
+
// endnote-area: empty per-page; document-end via getDocumentEndnoteBlocks.
|
|
832
|
+
return Object.freeze([]);
|
|
833
|
+
}
|
|
834
|
+
|
|
596
835
|
return {
|
|
597
836
|
getPageCount() {
|
|
598
837
|
return currentGraph().pages.length;
|
|
@@ -732,6 +971,69 @@ export function createLayoutFacet(
|
|
|
732
971
|
return result;
|
|
733
972
|
},
|
|
734
973
|
|
|
974
|
+
getStoryBlocksForRegion(pageIndex, region, options) {
|
|
975
|
+
return getRegionBlocksCached(pageIndex, region, options?.columnIndex);
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
getDocumentEndnoteBlocks() {
|
|
979
|
+
const graph = currentGraph();
|
|
980
|
+
if (endnoteBlocksCache.revision === graph.revision && endnoteBlocksCache.blocks) {
|
|
981
|
+
return endnoteBlocksCache.blocks;
|
|
982
|
+
}
|
|
983
|
+
const document = resolveCanonicalDocument();
|
|
984
|
+
const collection = document.subParts?.footnoteCollection;
|
|
985
|
+
const blocks: PublicRegionBlock[] = [];
|
|
986
|
+
if (collection) {
|
|
987
|
+
// P8.2.1 — batch every endnote body into one
|
|
988
|
+
// `createEditorSurfaceSnapshot` call. Previously we called the
|
|
989
|
+
// projector once per block (50 endnotes × 1 block → 50 full
|
|
990
|
+
// projections); now we project the flat concatenation once and
|
|
991
|
+
// slice the result back into per-note groups using the original
|
|
992
|
+
// block counts. `runtimeFromOffset` / `runtimeToOffset` inherit
|
|
993
|
+
// the projection's cumulative cursor (a stable "all-endnotes"
|
|
994
|
+
// meta-story offset); fragment ids still scope per-note so
|
|
995
|
+
// consumers can tell which endnote a block belongs to.
|
|
996
|
+
const groups: Array<{ noteId: string; count: number }> = [];
|
|
997
|
+
const flat: import("../../model/canonical-document.ts").BlockNode[] = [];
|
|
998
|
+
for (const noteId of Object.keys(collection.endnotes)) {
|
|
999
|
+
const note = collection.endnotes[noteId];
|
|
1000
|
+
if (!note || note.blocks.length === 0) continue;
|
|
1001
|
+
groups.push({ noteId, count: note.blocks.length });
|
|
1002
|
+
for (const block of note.blocks) flat.push(block);
|
|
1003
|
+
}
|
|
1004
|
+
if (flat.length > 0) {
|
|
1005
|
+
const surface = createEditorSurfaceSnapshot(
|
|
1006
|
+
{
|
|
1007
|
+
...document,
|
|
1008
|
+
content: { type: "doc", children: flat },
|
|
1009
|
+
} as CanonicalDocumentEnvelope,
|
|
1010
|
+
createSelectionSnapshot(0, 0),
|
|
1011
|
+
);
|
|
1012
|
+
let cursor = 0;
|
|
1013
|
+
for (const group of groups) {
|
|
1014
|
+
for (let i = 0; i < group.count; i += 1) {
|
|
1015
|
+
const blockSnapshot = surface.blocks[cursor + i];
|
|
1016
|
+
if (!blockSnapshot) continue;
|
|
1017
|
+
blocks.push({
|
|
1018
|
+
blockId: blockSnapshot.blockId,
|
|
1019
|
+
fragmentId: `endnote-${group.noteId}-${i}`,
|
|
1020
|
+
pageIndex: -1,
|
|
1021
|
+
regionKind: "endnote-area",
|
|
1022
|
+
runtimeFromOffset: blockSnapshot.from,
|
|
1023
|
+
runtimeToOffset: blockSnapshot.to,
|
|
1024
|
+
heightTwips: 0,
|
|
1025
|
+
blockSnapshot,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
cursor += group.count;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const frozen = Object.freeze(blocks);
|
|
1033
|
+
endnoteBlocksCache = { revision: graph.revision, blocks: frozen };
|
|
1034
|
+
return frozen;
|
|
1035
|
+
},
|
|
1036
|
+
|
|
735
1037
|
getPageFormatCatalog() {
|
|
736
1038
|
return PAGE_FORMAT_CATALOG;
|
|
737
1039
|
},
|
|
@@ -950,6 +1252,15 @@ export function createLayoutFacet(
|
|
|
950
1252
|
};
|
|
951
1253
|
},
|
|
952
1254
|
|
|
1255
|
+
// L7 Phase 2 — viewport culling delegates
|
|
1256
|
+
setVisibleBlockRange(range) {
|
|
1257
|
+
input.setVisibleBlockRange?.(range);
|
|
1258
|
+
},
|
|
1259
|
+
|
|
1260
|
+
requestViewportRefresh() {
|
|
1261
|
+
input.requestViewportRefresh?.();
|
|
1262
|
+
},
|
|
1263
|
+
|
|
953
1264
|
subscribe(listener) {
|
|
954
1265
|
listeners.add(listener);
|
|
955
1266
|
return () => {
|
|
@@ -1005,6 +1316,9 @@ function toPublicPageRegions(regions: RuntimePageRegions): PublicPageRegions {
|
|
|
1005
1316
|
...(regions.header ? { header: toPublicPageRegion(regions.header) } : {}),
|
|
1006
1317
|
...(regions.footer ? { footer: toPublicPageRegion(regions.footer) } : {}),
|
|
1007
1318
|
...(regions.columns ? { columns: regions.columns.map(toPublicPageRegion) } : {}),
|
|
1319
|
+
...(regions.footnotes && regions.footnotes.length > 0
|
|
1320
|
+
? { footnotes: regions.footnotes.map(toPublicPageRegion) }
|
|
1321
|
+
: {}),
|
|
1008
1322
|
};
|
|
1009
1323
|
}
|
|
1010
1324
|
|
|
@@ -1648,3 +1962,118 @@ function findCanonicalTableByBlockId(
|
|
|
1648
1962
|
}
|
|
1649
1963
|
return null;
|
|
1650
1964
|
}
|
|
1965
|
+
|
|
1966
|
+
// ---------------------------------------------------------------------------
|
|
1967
|
+
// P8 — region-block resolvers
|
|
1968
|
+
// ---------------------------------------------------------------------------
|
|
1969
|
+
|
|
1970
|
+
function resolveBodyRegionBlocks(
|
|
1971
|
+
node: RuntimePageNode,
|
|
1972
|
+
graph: RuntimePageGraph,
|
|
1973
|
+
findBodyBlockById: (blockId: string) => SurfaceBlockSnapshot | undefined,
|
|
1974
|
+
): readonly PublicRegionBlock[] {
|
|
1975
|
+
const fragmentsById = new Map<string, RuntimeBlockFragment>();
|
|
1976
|
+
for (const fragment of graph.fragments) {
|
|
1977
|
+
fragmentsById.set(fragment.fragmentId, fragment);
|
|
1978
|
+
}
|
|
1979
|
+
const blocks: PublicRegionBlock[] = [];
|
|
1980
|
+
for (const fragmentId of node.regions.body.fragmentIds) {
|
|
1981
|
+
const fragment = fragmentsById.get(fragmentId);
|
|
1982
|
+
if (!fragment) continue;
|
|
1983
|
+
const blockSnapshot = findBodyBlockById(fragment.blockId);
|
|
1984
|
+
if (!blockSnapshot) continue;
|
|
1985
|
+
blocks.push({
|
|
1986
|
+
blockId: fragment.blockId,
|
|
1987
|
+
fragmentId,
|
|
1988
|
+
pageIndex: node.pageIndex,
|
|
1989
|
+
regionKind: "body",
|
|
1990
|
+
runtimeFromOffset: fragment.from,
|
|
1991
|
+
runtimeToOffset: fragment.to,
|
|
1992
|
+
heightTwips: fragment.heightTwips,
|
|
1993
|
+
blockSnapshot,
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
return Object.freeze(blocks);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function resolveHeaderFooterRegionBlocks(
|
|
2000
|
+
pageIndex: number,
|
|
2001
|
+
regionKind: "header" | "footer",
|
|
2002
|
+
storyTarget: EditorStoryTarget,
|
|
2003
|
+
projectedBlocks: readonly SurfaceBlockSnapshot[],
|
|
2004
|
+
): readonly PublicRegionBlock[] {
|
|
2005
|
+
if (storyTarget.kind !== regionKind) return Object.freeze([]);
|
|
2006
|
+
if (projectedBlocks.length === 0) return Object.freeze([]);
|
|
2007
|
+
const fragmentBase = `story-${storyTarget.kind}-${storyTarget.relationshipId}-${storyTarget.variant}-${storyTarget.sectionIndex ?? "_"}`;
|
|
2008
|
+
return Object.freeze(
|
|
2009
|
+
projectedBlocks.map((blockSnapshot, index): PublicRegionBlock => ({
|
|
2010
|
+
blockId: blockSnapshot.blockId,
|
|
2011
|
+
fragmentId: `${fragmentBase}-${index}`,
|
|
2012
|
+
pageIndex,
|
|
2013
|
+
regionKind,
|
|
2014
|
+
runtimeFromOffset: blockSnapshot.from,
|
|
2015
|
+
runtimeToOffset: blockSnapshot.to,
|
|
2016
|
+
// Header/footer fragments aren't measured per-block — consumers read
|
|
2017
|
+
// the region's `heightTwips` from `getStoryRegionsOnPage`.
|
|
2018
|
+
heightTwips: 0,
|
|
2019
|
+
blockSnapshot,
|
|
2020
|
+
})),
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function resolveFootnoteAreaRegionBlocks(
|
|
2025
|
+
node: RuntimePageNode,
|
|
2026
|
+
document: CanonicalDocumentEnvelope,
|
|
2027
|
+
): readonly PublicRegionBlock[] {
|
|
2028
|
+
const collection = document.subParts?.footnoteCollection;
|
|
2029
|
+
if (!collection) return Object.freeze([]);
|
|
2030
|
+
// P8.2.1 — batch every allocation's body into one
|
|
2031
|
+
// `createEditorSurfaceSnapshot` call and slice the result back into
|
|
2032
|
+
// per-allocation groups. Amplification per-page is small (a handful of
|
|
2033
|
+
// footnotes), but this mirrors the endnote batching so both paths take
|
|
2034
|
+
// the same shape. The existence guard on `note` is sufficient — the
|
|
2035
|
+
// previous `void note;` marker is gone.
|
|
2036
|
+
const groups: Array<{
|
|
2037
|
+
allocation: RuntimeNoteAllocation;
|
|
2038
|
+
count: number;
|
|
2039
|
+
}> = [];
|
|
2040
|
+
const flat: import("../../model/canonical-document.ts").BlockNode[] = [];
|
|
2041
|
+
for (const allocation of node.noteAllocations) {
|
|
2042
|
+
if (allocation.noteKind !== "footnote") continue;
|
|
2043
|
+
const note = collection.footnotes[allocation.noteId];
|
|
2044
|
+
if (!note || note.blocks.length === 0) continue;
|
|
2045
|
+
groups.push({ allocation, count: note.blocks.length });
|
|
2046
|
+
for (const block of note.blocks) flat.push(block);
|
|
2047
|
+
}
|
|
2048
|
+
if (flat.length === 0) return Object.freeze([]);
|
|
2049
|
+
const surface = createEditorSurfaceSnapshot(
|
|
2050
|
+
{
|
|
2051
|
+
...document,
|
|
2052
|
+
content: { type: "doc", children: flat },
|
|
2053
|
+
} as CanonicalDocumentEnvelope,
|
|
2054
|
+
createSelectionSnapshot(0, 0),
|
|
2055
|
+
);
|
|
2056
|
+
const blocks: PublicRegionBlock[] = [];
|
|
2057
|
+
let cursor = 0;
|
|
2058
|
+
for (const group of groups) {
|
|
2059
|
+
const fragmentBase = group.allocation.fragmentId
|
|
2060
|
+
?? `note-${node.pageIndex}-${group.allocation.noteId}`;
|
|
2061
|
+
for (let i = 0; i < group.count; i += 1) {
|
|
2062
|
+
const blockSnapshot = surface.blocks[cursor + i];
|
|
2063
|
+
if (!blockSnapshot) continue;
|
|
2064
|
+
blocks.push({
|
|
2065
|
+
blockId: blockSnapshot.blockId,
|
|
2066
|
+
fragmentId: `${fragmentBase}-${i}`,
|
|
2067
|
+
pageIndex: node.pageIndex,
|
|
2068
|
+
regionKind: "footnote-area",
|
|
2069
|
+
runtimeFromOffset: blockSnapshot.from,
|
|
2070
|
+
runtimeToOffset: blockSnapshot.to,
|
|
2071
|
+
heightTwips: group.allocation.reservedHeightTwips,
|
|
2072
|
+
blockSnapshot,
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
cursor += group.count;
|
|
2076
|
+
}
|
|
2077
|
+
return Object.freeze(blocks);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight string-keyed counters for L7 render-perf instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* Used by the runtime to prove (in tests and benchmarks) that a given
|
|
5
|
+
* facet rebuild ran or did not run during a sequence of operations.
|
|
6
|
+
* Phase 0 wires `refresh.all`; Phase 1 will add `facet.<name>.build`
|
|
7
|
+
* keyed on the per-facet builder.
|
|
8
|
+
*
|
|
9
|
+
* Cost guarantee: a `Map<string, number>` increment is < 100 ns. We do
|
|
10
|
+
* not need atomic counters — JS is single-threaded — and we deliberately
|
|
11
|
+
* keep the surface tiny so this module can never become a perf footgun
|
|
12
|
+
* on its own.
|
|
13
|
+
*/
|
|
14
|
+
export class PerfCounters {
|
|
15
|
+
private readonly counts = new Map<string, number>();
|
|
16
|
+
|
|
17
|
+
increment(key: string, delta = 1): void {
|
|
18
|
+
this.counts.set(key, (this.counts.get(key) ?? 0) + delta);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
snapshot(): Record<string, number> {
|
|
22
|
+
return Object.fromEntries(this.counts);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
reset(): void {
|
|
26
|
+
this.counts.clear();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
|
+
import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* L7 Phase 2.5 Task 2.5.3 — prerender cache envelope shape.
|
|
6
|
+
*
|
|
7
|
+
* The envelope is the unit written to IndexedDB (Plan A) and — after Plan B
|
|
8
|
+
* ships — to the `laycache` customXml editor-state namespace. Two consumers
|
|
9
|
+
* must agree on this shape: the prerender pipeline that populates it, and
|
|
10
|
+
* the warm-path loader that rehydrates it.
|
|
11
|
+
*
|
|
12
|
+
* Load-time invariants checked by consumers before trusting the envelope:
|
|
13
|
+
* - `schemaVersion === LAYCACHE_SCHEMA_VERSION` — bump invalidates
|
|
14
|
+
* - `engineVersion === LAYOUT_ENGINE_VERSION` — bump invalidates
|
|
15
|
+
* - `graph.revision === 0` — canonical marker
|
|
16
|
+
*
|
|
17
|
+
* The envelope MUST be structured-clone-safe because IndexedDB and Plan B's
|
|
18
|
+
* customXml path both rely on structured-clone semantics. Keep fields as
|
|
19
|
+
* JSON-serializable primitives, plain objects, or arrays — no class
|
|
20
|
+
* instances, functions, or symbols.
|
|
21
|
+
*/
|
|
22
|
+
export interface CacheEnvelope {
|
|
23
|
+
readonly schemaVersion: number;
|
|
24
|
+
readonly engineVersion: number;
|
|
25
|
+
readonly fontFingerprint: string;
|
|
26
|
+
readonly structuralHash: string;
|
|
27
|
+
readonly graph: RuntimePageGraph;
|
|
28
|
+
readonly surface: EditorSurfaceSnapshot;
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7 Phase 2.5 Task 2.5.1 — prerender cache-key derivation.
|
|
3
|
+
*
|
|
4
|
+
* The cache key is the composite identity the IndexedDB (Plan A) and
|
|
5
|
+
* customXml (Plan B) backends index on. It has five inputs:
|
|
6
|
+
*
|
|
7
|
+
* 1. structuralHash(blocks) — sha256 of the ordered kind:blockId list.
|
|
8
|
+
* Stable across text-only edits; changes on
|
|
9
|
+
* insert/delete/reorder because blockIds are
|
|
10
|
+
* kind-counter pairs (paragraph-5 stays 5
|
|
11
|
+
* under typing, shifts to paragraph-6 after
|
|
12
|
+
* an insert).
|
|
13
|
+
* 2. fontFingerprint — identifies the measurement-backend + font-
|
|
14
|
+
* metric source. "empirical-backend" in Plan A;
|
|
15
|
+
* a real font-derived string after Phase 8.
|
|
16
|
+
* 3. engineVersion — LAYOUT_ENGINE_VERSION from src/runtime/
|
|
17
|
+
* layout/layout-engine-version.ts. Bumped by
|
|
18
|
+
* CI gate on any layout/render shape change.
|
|
19
|
+
* 4. schemaVersion — LAYCACHE_SCHEMA_VERSION for envelope format.
|
|
20
|
+
*
|
|
21
|
+
* Returns a 64-char lower-case hex digest. Uses the Web Crypto API
|
|
22
|
+
* (globalThis.crypto.subtle), available in Node 18+ and all target browsers —
|
|
23
|
+
* keeping src/** free of node: builtins so the shipped browser bundle stays
|
|
24
|
+
* clean.
|
|
25
|
+
*/
|
|
26
|
+
export interface CacheKeyBlock {
|
|
27
|
+
readonly kind: string;
|
|
28
|
+
readonly blockId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CacheKeyInputs {
|
|
32
|
+
readonly blocks: readonly CacheKeyBlock[];
|
|
33
|
+
readonly fontFingerprint: string;
|
|
34
|
+
readonly engineVersion: string | number;
|
|
35
|
+
readonly schemaVersion: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BLOCK_SEPARATOR = "\u0000";
|
|
39
|
+
const FIELD_SEPARATOR = "|";
|
|
40
|
+
const textEncoder = new TextEncoder();
|
|
41
|
+
|
|
42
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
43
|
+
const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(input));
|
|
44
|
+
const bytes = new Uint8Array(digest);
|
|
45
|
+
let hex = "";
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
+
hex += bytes[i]!.toString(16).padStart(2, "0");
|
|
48
|
+
}
|
|
49
|
+
return hex;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function computeStructuralHash(blocks: readonly CacheKeyBlock[]): Promise<string> {
|
|
53
|
+
const payload = blocks.map((block) => `${block.kind}:${block.blockId}`).join(BLOCK_SEPARATOR);
|
|
54
|
+
return sha256Hex(payload);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function deriveCacheKey(inputs: CacheKeyInputs): Promise<string> {
|
|
58
|
+
const structuralHash = await computeStructuralHash(inputs.blocks);
|
|
59
|
+
const composite = [
|
|
60
|
+
structuralHash,
|
|
61
|
+
inputs.fontFingerprint,
|
|
62
|
+
String(inputs.engineVersion),
|
|
63
|
+
String(inputs.schemaVersion),
|
|
64
|
+
].join(FIELD_SEPARATOR);
|
|
65
|
+
return sha256Hex(composite);
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7 Phase 2.5 Task 2.5.1 — font fingerprint stub for the prerender cache.
|
|
3
|
+
*
|
|
4
|
+
* The cache key composition includes a font fingerprint so cache entries
|
|
5
|
+
* invalidate when the set of fonts available to the measurement backend
|
|
6
|
+
* changes. In Plan A the prerender pipeline forces the empirical
|
|
7
|
+
* measurement backend (see `createLayoutEngine({ autoUpgradeToCanvasBackend:
|
|
8
|
+
* false })`), which is deterministic and font-metric-agnostic — so the
|
|
9
|
+
* fingerprint is a constant literal.
|
|
10
|
+
*
|
|
11
|
+
* Phase 8 (font-metrics precompute via fontkit) replaces this stub with a
|
|
12
|
+
* real font-derived fingerprint that makes Plan B customXml caches portable
|
|
13
|
+
* across machines with different installed fonts.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveFontFingerprint(): string {
|
|
16
|
+
return "empirical-backend";
|
|
17
|
+
}
|