@beyondwork/docx-react-component 1.0.43 → 1.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +44 -32
- package/src/api/public-types.ts +139 -3
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +27 -2
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +16 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +21 -1
- 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/normalize/normalize-text.ts +33 -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/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/document-runtime.ts +316 -25
- 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-surface/capabilities.ts +411 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +46 -0
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/public-facet.ts +30 -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/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +35 -2
- package/src/ui/WordReviewEditor.tsx +75 -192
- package/src/ui/editor-runtime-boundary.ts +5 -1
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RuntimeBlockFragment,
|
|
3
|
+
RuntimePageAnchor,
|
|
4
|
+
RuntimePageGraph,
|
|
5
|
+
RuntimePageNode,
|
|
6
|
+
RuntimePageRegion,
|
|
7
|
+
RuntimePageRegions,
|
|
8
|
+
} from "../layout/page-graph.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* L7 Phase 2.5 Task 2.5.3 — graph canonicalization (determinism fix).
|
|
12
|
+
*
|
|
13
|
+
* `src/runtime/layout/page-graph.ts` increments a module-level
|
|
14
|
+
* `graphRevision` counter on every `buildPageGraph()` call and embeds it
|
|
15
|
+
* into `pageId = page-${revision}-${index}` and dependent
|
|
16
|
+
* `fragmentId = ${pageId}-<suffix>` strings. Two consecutive
|
|
17
|
+
* `prerenderDocument(buf)` calls in the same process would therefore emit
|
|
18
|
+
* different pageIds — the cacheBlob hash would flip on every call even
|
|
19
|
+
* though the document bytes were identical.
|
|
20
|
+
*
|
|
21
|
+
* This module rewrites all identifier strings to a canonical revision-free
|
|
22
|
+
* shape:
|
|
23
|
+
* `page-${index}` (canonical pageId)
|
|
24
|
+
* `page-${index}-<suffix>` (canonical fragmentId)
|
|
25
|
+
*
|
|
26
|
+
* The live runtime's graphRevision contract stays intact — only the
|
|
27
|
+
* serialized cache envelope is canonicalized.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const PAGE_PREFIX = "page-";
|
|
31
|
+
|
|
32
|
+
export function canonicalizeGraph(graph: RuntimePageGraph): RuntimePageGraph {
|
|
33
|
+
// Build a map from live pageId → canonical pageId ("page-<index>").
|
|
34
|
+
const rename = new Map<string, string>();
|
|
35
|
+
for (const page of graph.pages) {
|
|
36
|
+
rename.set(page.pageId, `${PAGE_PREFIX}${page.pageIndex}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rewriteId = (id: string): string => {
|
|
40
|
+
// pageIds map directly.
|
|
41
|
+
const direct = rename.get(id);
|
|
42
|
+
if (direct !== undefined) return direct;
|
|
43
|
+
// fragmentIds have shape `${livePageId}-<suffix>`; find the matching
|
|
44
|
+
// live prefix and swap it for the canonical pageId.
|
|
45
|
+
for (const [livePageId, canonPageId] of rename) {
|
|
46
|
+
if (id.startsWith(`${livePageId}-`)) {
|
|
47
|
+
return `${canonPageId}${id.slice(livePageId.length)}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return id;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const canonicalPages: RuntimePageNode[] = graph.pages.map((page) => ({
|
|
54
|
+
...page,
|
|
55
|
+
pageId: rewriteId(page.pageId),
|
|
56
|
+
regions: rewriteRegions(page.regions, rewriteId),
|
|
57
|
+
lineBoxes: page.lineBoxes.map((line) => ({
|
|
58
|
+
...line,
|
|
59
|
+
fragmentId: rewriteId(line.fragmentId),
|
|
60
|
+
})),
|
|
61
|
+
noteAllocations: page.noteAllocations.map((note) =>
|
|
62
|
+
note.fragmentId === undefined
|
|
63
|
+
? note
|
|
64
|
+
: { ...note, fragmentId: rewriteId(note.fragmentId) },
|
|
65
|
+
),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const canonicalFragments: RuntimeBlockFragment[] = graph.fragments.map((fragment) => ({
|
|
69
|
+
...fragment,
|
|
70
|
+
fragmentId: rewriteId(fragment.fragmentId),
|
|
71
|
+
pageId: rewriteId(fragment.pageId),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const canonicalAnchors: RuntimePageAnchor[] = graph.anchors.map((anchor) => ({
|
|
75
|
+
...anchor,
|
|
76
|
+
pageId: rewriteId(anchor.pageId),
|
|
77
|
+
fragmentId: anchor.fragmentId === undefined ? undefined : rewriteId(anchor.fragmentId),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...graph,
|
|
82
|
+
revision: 0,
|
|
83
|
+
pages: canonicalPages,
|
|
84
|
+
fragments: canonicalFragments,
|
|
85
|
+
anchors: canonicalAnchors,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rewriteRegions(
|
|
90
|
+
regions: RuntimePageRegions,
|
|
91
|
+
rewriteId: (id: string) => string,
|
|
92
|
+
): RuntimePageRegions {
|
|
93
|
+
const result: {
|
|
94
|
+
body: RuntimePageRegion;
|
|
95
|
+
header?: RuntimePageRegion;
|
|
96
|
+
footer?: RuntimePageRegion;
|
|
97
|
+
columns?: RuntimePageRegion[];
|
|
98
|
+
footnotes?: RuntimePageRegion[];
|
|
99
|
+
} = {
|
|
100
|
+
body: rewriteRegion(regions.body, rewriteId),
|
|
101
|
+
};
|
|
102
|
+
if (regions.header !== undefined) result.header = rewriteRegion(regions.header, rewriteId);
|
|
103
|
+
if (regions.footer !== undefined) result.footer = rewriteRegion(regions.footer, rewriteId);
|
|
104
|
+
if (regions.columns !== undefined) {
|
|
105
|
+
result.columns = regions.columns.map((region) => rewriteRegion(region, rewriteId));
|
|
106
|
+
}
|
|
107
|
+
if (regions.footnotes !== undefined) {
|
|
108
|
+
result.footnotes = regions.footnotes.map((region) => rewriteRegion(region, rewriteId));
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function rewriteRegion(
|
|
114
|
+
region: RuntimePageRegion,
|
|
115
|
+
rewriteId: (id: string) => string,
|
|
116
|
+
): RuntimePageRegion {
|
|
117
|
+
return {
|
|
118
|
+
...region,
|
|
119
|
+
fragmentIds: region.fragmentIds.map(rewriteId),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7 Phase 2.5 Task 2.5.2 — IndexedDB cache backend (Plan A).
|
|
3
|
+
*
|
|
4
|
+
* Validation-stage storage for the prerender cache. Stores the cache
|
|
5
|
+
* envelope (see cache-envelope.ts in Task 2.5.3) keyed by the SHA-256
|
|
6
|
+
* cacheKey from cache-key.ts. LRU eviction caps the store at
|
|
7
|
+
* LAYCACHE_MAX_ENTRIES so the cache never unbounds.
|
|
8
|
+
*
|
|
9
|
+
* Uses the Web IndexedDB API directly — no idb wrapper, keeping this
|
|
10
|
+
* module dependency-free. Tests use `fake-indexeddb/auto` which installs
|
|
11
|
+
* a spec-compliant in-memory IDB on globalThis.
|
|
12
|
+
*
|
|
13
|
+
* Plan B (customXml persistence) will layer on top of this: customXml is
|
|
14
|
+
* consulted first, IndexedDB second. For Plan A, this is the only backend.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DB_NAME = "beyondwork-laycache";
|
|
18
|
+
const DB_VERSION = 1;
|
|
19
|
+
const STORE_NAME = "envelopes";
|
|
20
|
+
const ACCESSED_AT_INDEX = "accessedAt";
|
|
21
|
+
|
|
22
|
+
export const LAYCACHE_MAX_ENTRIES = 100;
|
|
23
|
+
|
|
24
|
+
interface CacheRecord<T> {
|
|
25
|
+
readonly cacheKey: string;
|
|
26
|
+
readonly envelope: T;
|
|
27
|
+
readonly accessedAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Monotonic "logical timestamp" for LRU ordering. `Date.now()` returns the
|
|
34
|
+
* same millisecond for writes issued in rapid succession (common in tests
|
|
35
|
+
* and in our own LRU-eviction scan), which makes cursor-based eviction
|
|
36
|
+
* non-deterministic when entries collide. A process-lifetime counter
|
|
37
|
+
* produces strictly increasing values regardless of wall-clock resolution.
|
|
38
|
+
* Persisted across DB opens: the store's max stored `accessedAt` seeds the
|
|
39
|
+
* counter on first open so a reopen still respects prior recency order.
|
|
40
|
+
*/
|
|
41
|
+
let accessedAtCounter = 0;
|
|
42
|
+
|
|
43
|
+
function nextAccessedAt(): number {
|
|
44
|
+
accessedAtCounter += 1;
|
|
45
|
+
return accessedAtCounter;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requestIndexedDB(): IDBFactory {
|
|
49
|
+
const factory = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
|
|
50
|
+
if (!factory) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"indexeddb-cache: globalThis.indexedDB is undefined. In tests, import 'fake-indexeddb/auto'.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return factory;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
request.onsuccess = () => resolve(request.result);
|
|
61
|
+
request.onerror = () => reject(request.error ?? new Error("indexeddb-cache: request failed"));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function promisifyTransaction(tx: IDBTransaction): Promise<void> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
tx.oncomplete = () => resolve();
|
|
68
|
+
tx.onerror = () => reject(tx.error ?? new Error("indexeddb-cache: transaction failed"));
|
|
69
|
+
tx.onabort = () => reject(tx.error ?? new Error("indexeddb-cache: transaction aborted"));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function openDatabase(): Promise<IDBDatabase> {
|
|
74
|
+
if (dbPromise !== null) return dbPromise;
|
|
75
|
+
dbPromise = new Promise((resolve, reject) => {
|
|
76
|
+
const request = requestIndexedDB().open(DB_NAME, DB_VERSION);
|
|
77
|
+
request.onupgradeneeded = () => {
|
|
78
|
+
const db = request.result;
|
|
79
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
80
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: "cacheKey" });
|
|
81
|
+
store.createIndex(ACCESSED_AT_INDEX, "accessedAt", { unique: false });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
request.onsuccess = () => {
|
|
85
|
+
const db = request.result;
|
|
86
|
+
void seedCounterFromStore(db).then(() => resolve(db));
|
|
87
|
+
};
|
|
88
|
+
request.onerror = () => reject(request.error ?? new Error("indexeddb-cache: open failed"));
|
|
89
|
+
});
|
|
90
|
+
return dbPromise;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function seedCounterFromStore(db: IDBDatabase): Promise<void> {
|
|
94
|
+
// On first open, initialise the counter from the highest persisted
|
|
95
|
+
// `accessedAt`. On subsequent opens inside the same process we keep the
|
|
96
|
+
// existing counter — it's already ahead of anything in the store.
|
|
97
|
+
if (accessedAtCounter > 0) return;
|
|
98
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
99
|
+
const index = tx.objectStore(STORE_NAME).index(ACCESSED_AT_INDEX);
|
|
100
|
+
const request = index.openCursor(null, "prev");
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
request.onsuccess = () => {
|
|
103
|
+
const cursor = request.result;
|
|
104
|
+
if (cursor) {
|
|
105
|
+
const value = cursor.value as CacheRecord<unknown>;
|
|
106
|
+
if (typeof value.accessedAt === "number" && value.accessedAt > accessedAtCounter) {
|
|
107
|
+
accessedAtCounter = value.accessedAt;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
resolve();
|
|
111
|
+
};
|
|
112
|
+
request.onerror = () =>
|
|
113
|
+
reject(request.error ?? new Error("indexeddb-cache: seedCounterFromStore failed"));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function readCache<T>(cacheKey: string): Promise<T | null> {
|
|
118
|
+
const db = await openDatabase();
|
|
119
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
120
|
+
const store = tx.objectStore(STORE_NAME);
|
|
121
|
+
const existing = (await promisifyRequest(store.get(cacheKey))) as CacheRecord<T> | undefined;
|
|
122
|
+
if (!existing) {
|
|
123
|
+
await promisifyTransaction(tx);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const touched: CacheRecord<T> = { ...existing, accessedAt: nextAccessedAt() };
|
|
127
|
+
store.put(touched);
|
|
128
|
+
await promisifyTransaction(tx);
|
|
129
|
+
return existing.envelope;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function writeCache<T>(cacheKey: string, envelope: T): Promise<void> {
|
|
133
|
+
const db = await openDatabase();
|
|
134
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
135
|
+
const store = tx.objectStore(STORE_NAME);
|
|
136
|
+
const record: CacheRecord<T> = { cacheKey, envelope, accessedAt: nextAccessedAt() };
|
|
137
|
+
store.put(record);
|
|
138
|
+
const count = await promisifyRequest(store.count());
|
|
139
|
+
if (count > LAYCACHE_MAX_ENTRIES) {
|
|
140
|
+
const toDrop = count - LAYCACHE_MAX_ENTRIES;
|
|
141
|
+
await evictOldest(store, toDrop);
|
|
142
|
+
}
|
|
143
|
+
await promisifyTransaction(tx);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function evictOldest(store: IDBObjectStore, count: number): Promise<void> {
|
|
147
|
+
if (count <= 0) return;
|
|
148
|
+
const index = store.index(ACCESSED_AT_INDEX);
|
|
149
|
+
let remaining = count;
|
|
150
|
+
await new Promise<void>((resolve, reject) => {
|
|
151
|
+
const request = index.openCursor();
|
|
152
|
+
request.onsuccess = () => {
|
|
153
|
+
const cursor = request.result;
|
|
154
|
+
if (!cursor || remaining === 0) {
|
|
155
|
+
resolve();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
cursor.delete();
|
|
159
|
+
remaining -= 1;
|
|
160
|
+
cursor.continue();
|
|
161
|
+
};
|
|
162
|
+
request.onerror = () =>
|
|
163
|
+
reject(request.error ?? new Error("indexeddb-cache: eviction cursor failed"));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function clearCache(): Promise<void> {
|
|
168
|
+
const db = await openDatabase();
|
|
169
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
170
|
+
tx.objectStore(STORE_NAME).clear();
|
|
171
|
+
await promisifyTransaction(tx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Close the cached connection. Used by tests to tear down between cases so
|
|
176
|
+
* the singleton dbPromise does not leak state across runs. Production code
|
|
177
|
+
* can ignore this — the connection is process-scoped and lives until exit.
|
|
178
|
+
*/
|
|
179
|
+
export async function closeCacheDatabase(): Promise<void> {
|
|
180
|
+
if (dbPromise === null) return;
|
|
181
|
+
const db = await dbPromise;
|
|
182
|
+
db.close();
|
|
183
|
+
dbPromise = null;
|
|
184
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
|
+
import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
3
|
+
import { loadDocxEditorSessionAsync } from "../../io/docx-session.ts";
|
|
4
|
+
import { createLoadScheduler } from "../../io/load-scheduler.ts";
|
|
5
|
+
import {
|
|
6
|
+
LAYCACHE_SCHEMA_VERSION,
|
|
7
|
+
LAYOUT_ENGINE_VERSION,
|
|
8
|
+
} from "../layout/layout-engine-version.ts";
|
|
9
|
+
import { createLayoutEngine } from "../layout/layout-engine-instance.ts";
|
|
10
|
+
import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
|
|
11
|
+
import type { CacheEnvelope } from "./cache-envelope.ts";
|
|
12
|
+
import {
|
|
13
|
+
computeStructuralHash,
|
|
14
|
+
deriveCacheKey,
|
|
15
|
+
type CacheKeyBlock,
|
|
16
|
+
} from "./cache-key.ts";
|
|
17
|
+
import { resolveFontFingerprint } from "./font-fingerprint.ts";
|
|
18
|
+
import { canonicalizeGraph } from "./graph-canonicalize.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* L7 Phase 2.5 Task 2.5.3 — prerenderDocument public API.
|
|
22
|
+
*
|
|
23
|
+
* Runs the parse + layout pipeline for a .docx buffer without mounting
|
|
24
|
+
* React, PM, or any DOM. Produces a CacheEnvelope suitable for writing to
|
|
25
|
+
* the IndexedDB backend (Plan A) or the `laycache` customXml namespace
|
|
26
|
+
* (Plan B, follow-up PR).
|
|
27
|
+
*
|
|
28
|
+
* Determinism contract:
|
|
29
|
+
* - Identical input bytes produce identical cacheKey on two sequential
|
|
30
|
+
* calls. Two sources of non-determinism are handled:
|
|
31
|
+
* (1) `page-graph.ts` graphRevision counter — stripped by
|
|
32
|
+
* `canonicalizeGraph`.
|
|
33
|
+
* (2) A fixed `documentId` ("prerender") is passed to the loader so
|
|
34
|
+
* `createCanonicalDocumentId` is deterministic. Timestamps in
|
|
35
|
+
* `canonicalDocument.createdAt/updatedAt` are loader-controlled
|
|
36
|
+
* but do not enter the envelope.
|
|
37
|
+
*
|
|
38
|
+
* Node-safety:
|
|
39
|
+
* - Uses `createLoadScheduler({ backendOverride: "sync" })` so the
|
|
40
|
+
* async loader runs without DOM scheduler.yield.
|
|
41
|
+
* - Layout engine is constructed with
|
|
42
|
+
* `autoUpgradeToCanvasBackend: false` so the empirical measurement
|
|
43
|
+
* backend is used (no Canvas2D, no node-canvas).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export interface PrerenderOptions {
|
|
47
|
+
readonly fontFingerprint?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Plan B hook — when true, prerenderDocument will inject the cache
|
|
50
|
+
* envelope into the document's `laycache` customXml namespace and
|
|
51
|
+
* return the re-serialized bytes in `docWithCustomXml`. In Plan A
|
|
52
|
+
* (this task) the flag is accepted for API stability but ignored;
|
|
53
|
+
* `docWithCustomXml` returns the input unchanged.
|
|
54
|
+
*/
|
|
55
|
+
readonly persistToCustomXml?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PrerenderCounters {
|
|
59
|
+
readonly blockCount: number;
|
|
60
|
+
readonly pageCount: number;
|
|
61
|
+
readonly prerenderMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PrerenderResult {
|
|
65
|
+
readonly docWithCustomXml: Uint8Array;
|
|
66
|
+
readonly cacheBlob: CacheEnvelope;
|
|
67
|
+
readonly cacheKey: string;
|
|
68
|
+
readonly counters: PrerenderCounters;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PRERENDER_DOCUMENT_ID = "prerender";
|
|
72
|
+
|
|
73
|
+
function toUint8Array(input: ArrayBuffer | Uint8Array): Uint8Array {
|
|
74
|
+
if (input instanceof Uint8Array) return input;
|
|
75
|
+
return new Uint8Array(input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function blocksToCacheKeyBlocks(surface: EditorSurfaceSnapshot): CacheKeyBlock[] {
|
|
79
|
+
return surface.blocks.map((block) => ({ kind: block.kind, blockId: block.blockId }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function prerenderDocument(
|
|
83
|
+
input: ArrayBuffer | Uint8Array,
|
|
84
|
+
options: PrerenderOptions = {},
|
|
85
|
+
): Promise<PrerenderResult> {
|
|
86
|
+
const t0 = performance.now();
|
|
87
|
+
const bytes = toUint8Array(input);
|
|
88
|
+
const fontFingerprint = options.fontFingerprint ?? resolveFontFingerprint();
|
|
89
|
+
|
|
90
|
+
const scheduler = createLoadScheduler({ backendOverride: "sync" });
|
|
91
|
+
let session;
|
|
92
|
+
try {
|
|
93
|
+
session = await loadDocxEditorSessionAsync({
|
|
94
|
+
documentId: PRERENDER_DOCUMENT_ID,
|
|
95
|
+
bytes,
|
|
96
|
+
editorBuild: "prerender",
|
|
97
|
+
scheduler,
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
scheduler.dispose();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (session.fatalError) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`prerenderDocument: failed to load input — ${session.fatalError.message ?? "fatal error"}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const envelope = session.initialSessionState.canonicalDocument;
|
|
110
|
+
const surface = createEditorSurfaceSnapshot(envelope, createSelectionSnapshot(), { kind: "main" });
|
|
111
|
+
|
|
112
|
+
const engine = createLayoutEngine({ autoUpgradeToCanvasBackend: false });
|
|
113
|
+
const rawGraph = engine.getPageGraph({ document: envelope });
|
|
114
|
+
const graph = canonicalizeGraph(rawGraph);
|
|
115
|
+
|
|
116
|
+
const structuralHash = await computeStructuralHash(blocksToCacheKeyBlocks(surface));
|
|
117
|
+
const cacheKey = await deriveCacheKey({
|
|
118
|
+
blocks: blocksToCacheKeyBlocks(surface),
|
|
119
|
+
fontFingerprint,
|
|
120
|
+
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
121
|
+
schemaVersion: LAYCACHE_SCHEMA_VERSION,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const cacheBlob: CacheEnvelope = {
|
|
125
|
+
schemaVersion: LAYCACHE_SCHEMA_VERSION,
|
|
126
|
+
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
127
|
+
fontFingerprint,
|
|
128
|
+
structuralHash,
|
|
129
|
+
graph,
|
|
130
|
+
surface,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const prerenderMs = performance.now() - t0;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
docWithCustomXml: bytes,
|
|
137
|
+
cacheBlob,
|
|
138
|
+
cacheKey,
|
|
139
|
+
counters: {
|
|
140
|
+
blockCount: surface.blocks.length,
|
|
141
|
+
pageCount: graph.pages.length,
|
|
142
|
+
prerenderMs,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* emitted by `src/runtime/surface-projection.ts`:
|
|
11
11
|
* - `paragraph-*` → "paragraph"
|
|
12
12
|
* - `table-*` → "table"
|
|
13
|
+
* - `placeholder-culled-*` → "opaque" (viewport-culled size-preserving stub)
|
|
13
14
|
* - `opaque-*` → "opaque"
|
|
14
15
|
* - `section-break-*` → "opaque" (read-only boundary marker)
|
|
15
16
|
* - `sdt-*`, `sdt-wrapper-*` → "opaque"
|
|
@@ -25,6 +26,7 @@ import type { RenderBlock } from "./render-frame-types.ts";
|
|
|
25
26
|
export function classifyBlockKind(blockId: string): RenderBlock["kind"] {
|
|
26
27
|
if (blockId.startsWith("paragraph")) return "paragraph";
|
|
27
28
|
if (blockId.startsWith("table")) return "table";
|
|
29
|
+
if (blockId.startsWith("placeholder-culled-")) return "opaque";
|
|
28
30
|
if (blockId.startsWith("opaque")) return "opaque";
|
|
29
31
|
if (blockId.startsWith("section-break")) return "opaque";
|
|
30
32
|
if (blockId.startsWith("sdt")) return "opaque";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
2
|
+
import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Snap a selection to a valid position relative to the document.
|
|
6
|
+
*
|
|
7
|
+
* Pure function. O(1) on the identity (in-bounds) fast path — returns
|
|
8
|
+
* the SAME object reference when no change is needed. Callers should
|
|
9
|
+
* compare with `!==` to detect a snap (e.g. to decide whether to
|
|
10
|
+
* re-spread runtime state).
|
|
11
|
+
*
|
|
12
|
+
* Wired into the runtime snapshot-emit chokepoint
|
|
13
|
+
* (`applyTransactionToState` -> `cachedRenderSnapshot = refreshRenderSnapshot()`),
|
|
14
|
+
* so it runs once per transaction commit. Must NOT walk the document;
|
|
15
|
+
* the caller is responsible for passing a valid `maxOffset` (the
|
|
16
|
+
* POST-mutation `surface.storySize`, primed via
|
|
17
|
+
* `getCachedSurface(state.document, activeStory).storySize`).
|
|
18
|
+
*
|
|
19
|
+
* NodeAnchor invalidation is deferred until CanonicalDocumentEnvelope
|
|
20
|
+
* grows an O(1) node-by-id accessor. Until then, NodeAnchor selections
|
|
21
|
+
* are returned unchanged (identity).
|
|
22
|
+
*
|
|
23
|
+
* @param document The post-mutation canonical document. Currently
|
|
24
|
+
* unused except for the deferred NodeAnchor branch;
|
|
25
|
+
* the parameter is kept for API stability.
|
|
26
|
+
* @param selection The selection to validate.
|
|
27
|
+
* @param maxOffset The POST-mutation maximum story offset. Caller
|
|
28
|
+
* passes `getCachedSurface(state.document,
|
|
29
|
+
* activeStory).storySize` (which primes the cache
|
|
30
|
+
* that `refreshRenderSnapshot` reuses on its next
|
|
31
|
+
* call — no extra surface walk). The validator does
|
|
32
|
+
* NOT walk the document to compute this. Do NOT pass
|
|
33
|
+
* the pre-mutation snapshot's storySize: at end-of-doc
|
|
34
|
+
* inserts, the new selection legitimately exceeds the
|
|
35
|
+
* old bound and the validator would clamp the caret
|
|
36
|
+
* backward by one position per keystroke. Pass
|
|
37
|
+
* `Number.POSITIVE_INFINITY` to skip the upper-bound
|
|
38
|
+
* clamp.
|
|
39
|
+
*/
|
|
40
|
+
export function validateSelectionAgainstDocument(
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for deferred NodeAnchor lookup
|
|
42
|
+
document: CanonicalDocumentEnvelope,
|
|
43
|
+
selection: SelectionSnapshot,
|
|
44
|
+
maxOffset: number,
|
|
45
|
+
): SelectionSnapshot {
|
|
46
|
+
if (selection.activeRange.kind === "node") {
|
|
47
|
+
// Deferred: NodeAnchor invalidation requires an O(1) node-by-id
|
|
48
|
+
// accessor on CanonicalDocumentEnvelope. Until that lands, return
|
|
49
|
+
// identity so we never falsely invalidate a still-valid node anchor.
|
|
50
|
+
return selection;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const anchor = clamp(selection.anchor, 0, maxOffset);
|
|
54
|
+
const head = clamp(selection.head, 0, maxOffset);
|
|
55
|
+
|
|
56
|
+
if (anchor === selection.anchor && head === selection.head) {
|
|
57
|
+
// Identity fast path — no allocation, same reference returned.
|
|
58
|
+
return selection;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const range = { from: Math.min(anchor, head), to: Math.max(anchor, head) };
|
|
62
|
+
const assoc =
|
|
63
|
+
selection.activeRange.kind === "range"
|
|
64
|
+
? selection.activeRange.assoc
|
|
65
|
+
: { start: 1 as const, end: 1 as const };
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
anchor,
|
|
69
|
+
head,
|
|
70
|
+
isCollapsed: anchor === head,
|
|
71
|
+
activeRange: { kind: "range", range, assoc },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
76
|
+
return n < lo ? lo : n > hi ? hi : n;
|
|
77
|
+
}
|