@beyondwork/docx-react-component 1.0.52 → 1.0.53
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/package.json +31 -40
- package/src/api/public-types.ts +32 -0
- package/src/io/chart-preview-resolver.ts +41 -0
- package/src/io/docx-session.ts +187 -17
- package/src/runtime/collab/runtime-collab-sync.ts +87 -6
- package/src/runtime/document-runtime.ts +159 -0
- package/src/runtime/layout/layout-engine-version.ts +40 -2
- package/src/runtime/layout/public-facet.ts +43 -1
- package/src/runtime/prerender/cache-envelope.ts +30 -0
- package/src/runtime/prerender/customxml-cache.ts +17 -3
- package/src/runtime/prerender/prerender-document.ts +17 -1
- package/src/runtime/render/render-kernel.ts +67 -19
- package/src/runtime/surface-projection.ts +28 -0
- package/src/runtime/table-schema.ts +27 -0
- package/src/runtime/table-style-resolver.ts +51 -0
- package/src/ui/editor-runtime-boundary.ts +39 -2
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +224 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +11 -147
- package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
- package/src/ui-tailwind/theme/editor-theme.css +26 -24
- package/src/ui-tailwind/theme/tokens.css +345 -0
- package/src/ui-tailwind/theme/tokens.ts +313 -0
- package/src/ui-tailwind/theme/use-density.ts +60 -0
|
@@ -298,6 +298,27 @@ export interface DocumentRuntime {
|
|
|
298
298
|
context: CommandExecutionContext,
|
|
299
299
|
meta?: Partial<CommandAppliedMeta>,
|
|
300
300
|
): void;
|
|
301
|
+
/**
|
|
302
|
+
* R1 — coalesced remote-replay entry point. Applies N envelopes as ONE
|
|
303
|
+
* commit: per-command transactions are chained (`replayState = txN.nextState`),
|
|
304
|
+
* mapping steps concatenated, `markDirty` ORed, and a single `commitRemote`
|
|
305
|
+
* call drives `finalizeState` / `invalidate` / `refreshRenderSnapshot` /
|
|
306
|
+
* `notify` exactly once. Per-command `meta.activeStory` isolation is
|
|
307
|
+
* preserved (the remote story scopes its own replay; the local cursor
|
|
308
|
+
* stays put). Behavior matches `applyRemoteCommand` invoked N times for
|
|
309
|
+
* a single envelope; for empty arrays it's a no-op.
|
|
310
|
+
*
|
|
311
|
+
* Singular effect collisions (≥2 envelopes both emitting `commentAdded` /
|
|
312
|
+
* `changeAccepted` / etc.) fall back to per-envelope replay so observers
|
|
313
|
+
* still see every effect — typical text-burst replay (all warnings-only
|
|
314
|
+
* effects) takes the fast path.
|
|
315
|
+
*
|
|
316
|
+
* History commands (`history.undo` / `history.redo`) and runtime overlay
|
|
317
|
+
* commands (`workflow.set-overlay`, etc.) inside a batch are processed
|
|
318
|
+
* individually as in the per-event path; they don't participate in the
|
|
319
|
+
* chained commit.
|
|
320
|
+
*/
|
|
321
|
+
applyRemoteCommandBatch(envelopes: ReadonlyArray<RemoteCommandEnvelope>): void;
|
|
301
322
|
emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
|
|
302
323
|
undo(): void;
|
|
303
324
|
redo(): void;
|
|
@@ -433,6 +454,17 @@ export interface CommandAppliedMeta {
|
|
|
433
454
|
priorDocument: CanonicalDocumentEnvelope;
|
|
434
455
|
}
|
|
435
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Single envelope shape consumed by {@link DocumentRuntime.applyRemoteCommandBatch}.
|
|
459
|
+
* Mirrors the `applyRemoteCommand(command, context, meta)` signature so
|
|
460
|
+
* batch and per-event paths stay schema-compatible.
|
|
461
|
+
*/
|
|
462
|
+
export interface RemoteCommandEnvelope {
|
|
463
|
+
command: EditorCommand;
|
|
464
|
+
context: CommandExecutionContext;
|
|
465
|
+
meta?: Partial<CommandAppliedMeta>;
|
|
466
|
+
}
|
|
467
|
+
|
|
436
468
|
export interface CreateDocumentRuntimeOptions {
|
|
437
469
|
documentId: string;
|
|
438
470
|
initialSessionState?: EditorSessionState;
|
|
@@ -2264,6 +2296,133 @@ export function createDocumentRuntime(
|
|
|
2264
2296
|
emitError(toRuntimeError(error));
|
|
2265
2297
|
}
|
|
2266
2298
|
},
|
|
2299
|
+
applyRemoteCommandBatch(envelopes) {
|
|
2300
|
+
if (envelopes.length === 0) return;
|
|
2301
|
+
if (envelopes.length === 1) {
|
|
2302
|
+
const e = envelopes[0]!;
|
|
2303
|
+
this.applyRemoteCommand(e.command, e.context, e.meta);
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
try {
|
|
2308
|
+
// Chain per-command transactions before ONE commitRemote at the end.
|
|
2309
|
+
// Each command runs through executeEditorCommand on the replayed
|
|
2310
|
+
// state; resulting nextState becomes the input to the next command.
|
|
2311
|
+
// History/overlay commands are processed individually as in the
|
|
2312
|
+
// per-event path (they don't participate in the chained commit).
|
|
2313
|
+
let replayState: typeof state = state;
|
|
2314
|
+
const stepsAccumulator: import("../core/selection/mapping.ts").MappingStep[] = [];
|
|
2315
|
+
let anyDirty = false;
|
|
2316
|
+
let lastNextState: typeof state | null = null;
|
|
2317
|
+
let lastCrossStoryReplay = false;
|
|
2318
|
+
const warningsAdded: import("../core/state/editor-state.ts").EditorWarning[] = [];
|
|
2319
|
+
const warningsCleared: Array<{ warningId: string; code: import("../core/state/editor-state.ts").EditorWarning["code"] }> = [];
|
|
2320
|
+
const SINGULAR_EFFECT_KEYS = [
|
|
2321
|
+
"commentAdded",
|
|
2322
|
+
"commentResolved",
|
|
2323
|
+
"commentReopened",
|
|
2324
|
+
"commentReplyAdded",
|
|
2325
|
+
"commentBodyEdited",
|
|
2326
|
+
"changeAccepted",
|
|
2327
|
+
"changeRejected",
|
|
2328
|
+
"revisionAuthored",
|
|
2329
|
+
"commandBlocked",
|
|
2330
|
+
] as const;
|
|
2331
|
+
const singularEffectsCounts = new Map<string, number>();
|
|
2332
|
+
const aggregatedSingular: Record<string, unknown> = {};
|
|
2333
|
+
|
|
2334
|
+
for (const env of envelopes) {
|
|
2335
|
+
const { command, context, meta } = env;
|
|
2336
|
+
if (command.type === "history.undo" || command.type === "history.redo") {
|
|
2337
|
+
// Match per-event path: silently skipped on remote replay.
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
if (isRuntimeStateOverlayCommand(command)) {
|
|
2341
|
+
// Overlays bypass commitRemote in the per-event path; mirror that
|
|
2342
|
+
// here without disturbing the chained replayState.
|
|
2343
|
+
applyRuntimeStateOverlayCommand(command);
|
|
2344
|
+
continue;
|
|
2345
|
+
}
|
|
2346
|
+
const replayStory = meta?.activeStory ?? activeStory;
|
|
2347
|
+
const crossStoryReplay = Boolean(
|
|
2348
|
+
meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory),
|
|
2349
|
+
);
|
|
2350
|
+
let stateForCommand: typeof state;
|
|
2351
|
+
let snapshotForCommand: typeof cachedRenderSnapshot;
|
|
2352
|
+
if (meta?.preSelection) {
|
|
2353
|
+
// Reuse the R5 scratch (avoids per-command allocation across the
|
|
2354
|
+
// burst). Seed from the CURRENT chain state so each command sees
|
|
2355
|
+
// the prior command's nextState as its starting point.
|
|
2356
|
+
Object.assign(r5ScratchReplayState, replayState);
|
|
2357
|
+
r5ScratchReplayState.selection = meta.preSelection;
|
|
2358
|
+
Object.assign(r5ScratchReplaySnapshot, cachedRenderSnapshot);
|
|
2359
|
+
r5ScratchReplaySnapshot.selection = toPublicSelectionSnapshot(meta.preSelection, replayStory);
|
|
2360
|
+
stateForCommand = r5ScratchReplayState;
|
|
2361
|
+
snapshotForCommand = r5ScratchReplaySnapshot;
|
|
2362
|
+
} else {
|
|
2363
|
+
stateForCommand = replayState;
|
|
2364
|
+
snapshotForCommand = cachedRenderSnapshot;
|
|
2365
|
+
}
|
|
2366
|
+
const replayContext = {
|
|
2367
|
+
...context,
|
|
2368
|
+
renderSnapshot: snapshotForCommand,
|
|
2369
|
+
};
|
|
2370
|
+
const transaction = executeEditorCommand(stateForCommand, command, replayContext);
|
|
2371
|
+
replayState = transaction.nextState;
|
|
2372
|
+
stepsAccumulator.push(...transaction.mapping.steps);
|
|
2373
|
+
anyDirty = anyDirty || transaction.markDirty;
|
|
2374
|
+
warningsAdded.push(...transaction.effects.warningsAdded);
|
|
2375
|
+
warningsCleared.push(...transaction.effects.warningsCleared);
|
|
2376
|
+
for (const key of SINGULAR_EFFECT_KEYS) {
|
|
2377
|
+
const v = (transaction.effects as unknown as Record<string, unknown>)[key];
|
|
2378
|
+
if (v !== undefined) {
|
|
2379
|
+
singularEffectsCounts.set(key, (singularEffectsCounts.get(key) ?? 0) + 1);
|
|
2380
|
+
aggregatedSingular[key] = v;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
lastNextState = transaction.nextState;
|
|
2384
|
+
lastCrossStoryReplay = crossStoryReplay;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Singular-effect collision (≥2 envelopes both setting the same
|
|
2388
|
+
// singular field): fall back to per-envelope replay so observers
|
|
2389
|
+
// see every effect. Common burst (all text.insert with empty
|
|
2390
|
+
// effects) takes the fast path above.
|
|
2391
|
+
let singularCollision = false;
|
|
2392
|
+
for (const count of singularEffectsCounts.values()) {
|
|
2393
|
+
if (count > 1) {
|
|
2394
|
+
singularCollision = true;
|
|
2395
|
+
break;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
if (singularCollision) {
|
|
2399
|
+
for (const env of envelopes) {
|
|
2400
|
+
this.applyRemoteCommand(env.command, env.context, env.meta);
|
|
2401
|
+
}
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
if (lastNextState === null) {
|
|
2405
|
+
// All envelopes were skip/overlay — nothing to commit.
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
const consolidated = {
|
|
2409
|
+
nextState: lastCrossStoryReplay
|
|
2410
|
+
? { ...lastNextState, selection: state.selection }
|
|
2411
|
+
: lastNextState,
|
|
2412
|
+
mapping: { steps: stepsAccumulator },
|
|
2413
|
+
effects: {
|
|
2414
|
+
warningsAdded,
|
|
2415
|
+
warningsCleared,
|
|
2416
|
+
...aggregatedSingular,
|
|
2417
|
+
} as import("../core/commands/index.ts").TransactionEffects,
|
|
2418
|
+
historyBoundary: "skip" as const,
|
|
2419
|
+
markDirty: anyDirty,
|
|
2420
|
+
};
|
|
2421
|
+
commitRemote(consolidated);
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
emitError(toRuntimeError(error));
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2267
2426
|
undo() {
|
|
2268
2427
|
this.dispatch({
|
|
2269
2428
|
type: "history.undo",
|
|
@@ -93,8 +93,37 @@
|
|
|
93
93
|
* pixel-geometry change; cache envelopes from v9 invalidate
|
|
94
94
|
* because page-node identity semantics on bounded splices
|
|
95
95
|
* changed.
|
|
96
|
+
* 11 — Lane 3a P10 Phase B2. `toPublicPageNode` memoizes its
|
|
97
|
+
* `RuntimePageNode → PublicPageNode` projection via WeakMap so
|
|
98
|
+
* Phase D1's stable runtime-node references cascade into stable
|
|
99
|
+
* public-facet return values. `facet.getPage(pageIndex)` now
|
|
100
|
+
* returns reference-equal `PublicPageNode`s across bounded
|
|
101
|
+
* relayouts where the underlying runtime node was reused.
|
|
102
|
+
* Cascades into downstream consumers (React.memo-gated page
|
|
103
|
+
* subtrees, per-page test hooks) and closes the Phase B → D1
|
|
104
|
+
* stable-reference chain. No pixel-geometry change; cache
|
|
105
|
+
* envelopes from v10 invalidate because the public-facet
|
|
106
|
+
* contract changed from "fresh object per call" to "stable ref
|
|
107
|
+
* per underlying runtime node".
|
|
108
|
+
* 12 — Lane 3a R3 table row-split: `getTableRenderPlan` now derives
|
|
109
|
+
* `isContinuationPage` from the page graph's fragment slice
|
|
110
|
+
* metadata (`kind === "table-slice" && tableRowRange.from > 0`)
|
|
111
|
+
* and passes it to `buildTableRenderPlan`. Tables split across
|
|
112
|
+
* pages now carry non-empty `repeatedHeaderRows` on continuation
|
|
113
|
+
* pages so chrome can prepend header rows visually. No
|
|
114
|
+
* pixel-geometry change; cache envelopes from v11 invalidate
|
|
115
|
+
* because the table-render-plan contract changed.
|
|
116
|
+
* 13 — Lane 3a P14.c: render-kernel gains a single-slot `DecorationIndex`
|
|
117
|
+
* cache keyed on (revision, activeStory.kind, zoom.pxPerTwip, and
|
|
118
|
+
* reference equality on each decoration source). When layout
|
|
119
|
+
* changes but decoration sources are unchanged, `resolveDecorationIndex`
|
|
120
|
+
* is skipped and the prior result is reused. The frame-level cache
|
|
121
|
+
* already handles repeat reads; this slot targets the post-invalidate
|
|
122
|
+
* rebuild path (on every keystroke that triggers a layout event).
|
|
123
|
+
* No pixel-geometry change; cache envelopes from v12 invalidate
|
|
124
|
+
* because the render-kernel source changed.
|
|
96
125
|
*/
|
|
97
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
126
|
+
export const LAYOUT_ENGINE_VERSION = 13 as const;
|
|
98
127
|
|
|
99
128
|
/**
|
|
100
129
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -113,5 +142,14 @@ export const LAYOUT_ENGINE_VERSION = 10 as const;
|
|
|
113
142
|
* correctly invalidates. v1 envelopes are rejected on load under v2 —
|
|
114
143
|
* no corruption path exists because schemaVersion is the top-level
|
|
115
144
|
* discriminator.
|
|
145
|
+
* 3 — L7 Phase 2 Finale C3: adds optional `compatibilityReport` field so
|
|
146
|
+
* the warm-Plan-B short-circuit can skip `buildCompatibilityReport`
|
|
147
|
+
* (~60–100 ms on extra-large). `generatedAt` inside the cached report
|
|
148
|
+
* is pinned to a fixed sentinel (`"__cache_normalized__"`) at
|
|
149
|
+
* prerender write time so two sequential prerenders produce
|
|
150
|
+
* byte-identical envelopes. The field is optional — v3 envelopes
|
|
151
|
+
* written without the report are still valid; readers fall through
|
|
152
|
+
* to the live `buildCompatibilityReport` call. v2 envelopes are
|
|
153
|
+
* rejected cleanly on v3 readers (schemaVersion mismatch).
|
|
116
154
|
*/
|
|
117
|
-
export const LAYCACHE_SCHEMA_VERSION =
|
|
155
|
+
export const LAYCACHE_SCHEMA_VERSION = 3 as const;
|
|
@@ -1231,12 +1231,27 @@ export function createLayoutFacet(
|
|
|
1231
1231
|
const tableHeightTwips = graph.fragments
|
|
1232
1232
|
.filter((f) => f.blockId === blockId)
|
|
1233
1233
|
.reduce((total, f) => total + f.heightTwips, 0);
|
|
1234
|
+
// Determine if this page holds a continuation slice of the table
|
|
1235
|
+
// (rows after the first slice). Fragment kind "table-slice" with
|
|
1236
|
+
// tableRowRange.from > 0 means the table was split across pages and
|
|
1237
|
+
// this page carries a non-first slice — header rows should repeat.
|
|
1238
|
+
const pageNode = graph.pages.find((p) => p.pageIndex === pageIndex);
|
|
1239
|
+
const sliceFragment = pageNode
|
|
1240
|
+
? graph.fragments.find(
|
|
1241
|
+
(f) => f.blockId === blockId && f.pageId === pageNode.pageId,
|
|
1242
|
+
)
|
|
1243
|
+
: undefined;
|
|
1244
|
+
const isContinuationPage =
|
|
1245
|
+
sliceFragment?.kind === "table-slice" &&
|
|
1246
|
+
(sliceFragment.tableRowRange?.from ?? 0) > 0;
|
|
1247
|
+
|
|
1234
1248
|
return buildTableRenderPlan({
|
|
1235
1249
|
blockId,
|
|
1236
1250
|
pageIndex,
|
|
1237
1251
|
block: tableBlock,
|
|
1238
1252
|
resolved,
|
|
1239
1253
|
tableHeightTwips,
|
|
1254
|
+
isContinuationPage,
|
|
1240
1255
|
});
|
|
1241
1256
|
},
|
|
1242
1257
|
|
|
@@ -1274,11 +1289,36 @@ export function createLayoutFacet(
|
|
|
1274
1289
|
// Internal: graph → public clones
|
|
1275
1290
|
// ---------------------------------------------------------------------------
|
|
1276
1291
|
|
|
1292
|
+
/**
|
|
1293
|
+
* Per-runtime-node cache for `toPublicPageNode`.
|
|
1294
|
+
*
|
|
1295
|
+
* P10 Phase B2. When `spliceGraph` preserves a `RuntimePageNode`
|
|
1296
|
+
* reference across a bounded relayout (Phase D1 tail-convergence
|
|
1297
|
+
* reuse), this cache preserves the derived `PublicPageNode` reference
|
|
1298
|
+
* too — so downstream consumers (React.memo-gated page subtrees in
|
|
1299
|
+
* `TwPageStackChromeLayer`, per-page test hooks) observe stable props
|
|
1300
|
+
* and can skip reconciliation.
|
|
1301
|
+
*
|
|
1302
|
+
* The map is a WeakMap keyed on the runtime node. When a fresh runtime
|
|
1303
|
+
* node replaces a prior one (divergent-tail case), the prior cache
|
|
1304
|
+
* entry is garbage-collected with the prior node; no manual eviction
|
|
1305
|
+
* is required. Structural contract: consumers MUST NOT mutate the
|
|
1306
|
+
* returned `PublicPageNode`, which has always been a read-only clone.
|
|
1307
|
+
*/
|
|
1308
|
+
const publicPageNodeCache = new WeakMap<RuntimePageNode, PublicPageNode>();
|
|
1309
|
+
|
|
1277
1310
|
function toPublicPageNode(
|
|
1278
1311
|
node: RuntimePageNode,
|
|
1279
1312
|
graph: RuntimePageGraph,
|
|
1280
1313
|
): PublicPageNode {
|
|
1281
|
-
|
|
1314
|
+
const cached = publicPageNodeCache.get(node);
|
|
1315
|
+
// Safety guard: reuse only when the runtime node's index still
|
|
1316
|
+
// matches the cached projection. Under normal D1 tail-convergence the
|
|
1317
|
+
// index is preserved, but this belt-and-braces check guarantees we
|
|
1318
|
+
// never hand out a stale clone if a runtime node is ever reused at a
|
|
1319
|
+
// different position by a future splice strategy.
|
|
1320
|
+
if (cached && cached.pageIndex === node.pageIndex) return cached;
|
|
1321
|
+
const built: PublicPageNode = {
|
|
1282
1322
|
pageId: node.pageId,
|
|
1283
1323
|
pageIndex: node.pageIndex,
|
|
1284
1324
|
sectionIndex: node.sectionIndex,
|
|
@@ -1295,7 +1335,9 @@ function toPublicPageNode(
|
|
|
1295
1335
|
lineBoxCount: node.lineBoxes.length,
|
|
1296
1336
|
noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
|
|
1297
1337
|
};
|
|
1338
|
+
publicPageNodeCache.set(node, built);
|
|
1298
1339
|
void graph; // reserved for future cross-page derivations
|
|
1340
|
+
return built;
|
|
1299
1341
|
}
|
|
1300
1342
|
|
|
1301
1343
|
function toPublicResolvedPageStories(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
2
|
import type { CanonicalDocument } from "../../model/canonical-document.ts";
|
|
3
|
+
import type { CompatibilityReport } from "../../core/state/editor-state.ts";
|
|
3
4
|
import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -28,6 +29,17 @@ import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
|
28
29
|
* - `canonicalDocumentHash` — sha256 of sorted-keys JSON. Also the 5th
|
|
29
30
|
* input to `deriveCacheKey`, so style/metadata/comment/preservation
|
|
30
31
|
* mutations correctly invalidate the cache.
|
|
32
|
+
*
|
|
33
|
+
* Phase 2 Finale C3 addition (schema v3):
|
|
34
|
+
* - `compatibilityReport?` — pre-computed `CompatibilityReport`. The
|
|
35
|
+
* report is a pure function of `canonicalDocument` (plus the
|
|
36
|
+
* `generatedAt` timestamp, which is pinned to the fixed sentinel
|
|
37
|
+
* `"__cache_normalized__"` at prerender write time so envelopes are
|
|
38
|
+
* byte-identical across two prerender calls). When present on the
|
|
39
|
+
* Plan B short-circuit path, `loadDocxEditorSessionAsync` skips the
|
|
40
|
+
* live `buildCompatibilityReport` call (60–100 ms on extra-large).
|
|
41
|
+
* The field is optional so v3 writers can ship envelopes without it
|
|
42
|
+
* (graceful degradation to the existing live-computation path).
|
|
31
43
|
*/
|
|
32
44
|
export interface CacheEnvelope {
|
|
33
45
|
readonly schemaVersion: number;
|
|
@@ -38,4 +50,22 @@ export interface CacheEnvelope {
|
|
|
38
50
|
readonly graph: RuntimePageGraph;
|
|
39
51
|
readonly surface: EditorSurfaceSnapshot;
|
|
40
52
|
readonly canonicalDocument: CanonicalDocument;
|
|
53
|
+
/**
|
|
54
|
+
* v3+. Optional even at v3 — absence means "compute live on read".
|
|
55
|
+
* See the Phase 2 Finale C3 banner above.
|
|
56
|
+
*/
|
|
57
|
+
readonly compatibilityReport?: CompatibilityReport;
|
|
41
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fixed sentinel used in place of `new Date().toISOString()` when
|
|
62
|
+
* pre-computing `compatibilityReport` for the envelope. Ensures two
|
|
63
|
+
* sequential `prerenderDocument` calls on identical bytes produce
|
|
64
|
+
* byte-identical cache envelopes.
|
|
65
|
+
*
|
|
66
|
+
* Downstream consumers of `compatibilityReport.generatedAt` are
|
|
67
|
+
* diagnostic-only — the field has never been user-visible in the editor
|
|
68
|
+
* UX. Cache-hit paths observe the sentinel; cache-miss paths observe a
|
|
69
|
+
* live ISO8601 timestamp.
|
|
70
|
+
*/
|
|
71
|
+
export const CACHE_NORMALIZED_GENERATED_AT = "__cache_normalized__" as const;
|
|
@@ -194,7 +194,7 @@ function extractInlineCdata(rawXml: string): string | null {
|
|
|
194
194
|
function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
|
|
195
195
|
if (typeof value !== "object" || value === null) return false;
|
|
196
196
|
const v = value as Record<string, unknown>;
|
|
197
|
-
|
|
197
|
+
const requiredFieldsOk =
|
|
198
198
|
v.schemaVersion === LAYCACHE_SCHEMA_VERSION &&
|
|
199
199
|
v.engineVersion === LAYOUT_ENGINE_VERSION &&
|
|
200
200
|
typeof v.fontFingerprint === "string" &&
|
|
@@ -205,7 +205,21 @@ function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
|
|
|
205
205
|
typeof v.surface === "object" &&
|
|
206
206
|
v.surface !== null &&
|
|
207
207
|
typeof v.canonicalDocument === "object" &&
|
|
208
|
-
v.canonicalDocument !== null
|
|
209
|
-
);
|
|
208
|
+
v.canonicalDocument !== null;
|
|
209
|
+
if (!requiredFieldsOk) return false;
|
|
210
|
+
// C3 (schema v3): `compatibilityReport` is optional — if present it must
|
|
211
|
+
// be a structured object with the expected shape markers. Envelopes
|
|
212
|
+
// without the field degrade gracefully to live-computation on read.
|
|
213
|
+
if (v.compatibilityReport !== undefined) {
|
|
214
|
+
const cr = v.compatibilityReport as Record<string, unknown> | null;
|
|
215
|
+
if (typeof cr !== "object" || cr === null) return false;
|
|
216
|
+
if (cr.reportVersion !== "compatibility-report/1") return false;
|
|
217
|
+
if (typeof cr.generatedAt !== "string") return false;
|
|
218
|
+
if (typeof cr.blockExport !== "boolean") return false;
|
|
219
|
+
if (!Array.isArray(cr.featureEntries)) return false;
|
|
220
|
+
if (!Array.isArray(cr.warnings)) return false;
|
|
221
|
+
if (!Array.isArray(cr.errors)) return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
210
224
|
}
|
|
211
225
|
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
} from "../layout/layout-engine-version.ts";
|
|
11
11
|
import { createLayoutEngine } from "../layout/layout-engine-instance.ts";
|
|
12
12
|
import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
|
|
13
|
-
import
|
|
13
|
+
import { buildCompatibilityReport } from "../../validation/compatibility-engine.ts";
|
|
14
|
+
import {
|
|
15
|
+
CACHE_NORMALIZED_GENERATED_AT,
|
|
16
|
+
type CacheEnvelope,
|
|
17
|
+
} from "./cache-envelope.ts";
|
|
14
18
|
import {
|
|
15
19
|
computeStructuralHash,
|
|
16
20
|
deriveCacheKey,
|
|
@@ -167,6 +171,17 @@ export async function prerenderDocument(
|
|
|
167
171
|
canonicalDocumentHash,
|
|
168
172
|
});
|
|
169
173
|
|
|
174
|
+
// Phase 2 Finale C3: pre-compute `compatibilityReport` so the warm Plan B
|
|
175
|
+
// short-circuit in `loadDocxEditorSessionAsync` can skip the live
|
|
176
|
+
// `buildCompatibilityReport` call (~60–100 ms on extra-large). The report
|
|
177
|
+
// is a pure function of `canonicalDocument` + `generatedAt`; pinning
|
|
178
|
+
// `generatedAt` to `CACHE_NORMALIZED_GENERATED_AT` keeps the envelope
|
|
179
|
+
// byte-identical across two sequential prerenders on identical input.
|
|
180
|
+
const compatibilityReport = buildCompatibilityReport({
|
|
181
|
+
document: envelope,
|
|
182
|
+
generatedAt: CACHE_NORMALIZED_GENERATED_AT,
|
|
183
|
+
});
|
|
184
|
+
|
|
170
185
|
const cacheBlob: CacheEnvelope = {
|
|
171
186
|
schemaVersion: LAYCACHE_SCHEMA_VERSION,
|
|
172
187
|
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
@@ -176,6 +191,7 @@ export async function prerenderDocument(
|
|
|
176
191
|
graph,
|
|
177
192
|
surface,
|
|
178
193
|
canonicalDocument: envelope,
|
|
194
|
+
compatibilityReport,
|
|
179
195
|
};
|
|
180
196
|
|
|
181
197
|
// Plan B B.5 — persistToCustomXml: inject the envelope into the docx's
|
|
@@ -23,12 +23,8 @@ import type {
|
|
|
23
23
|
PublicRegionBlock,
|
|
24
24
|
WordReviewEditorLayoutFacet,
|
|
25
25
|
} from "../layout/public-facet.ts";
|
|
26
|
-
import type {
|
|
27
|
-
|
|
28
|
-
} from "../../ui/headless/comment-decoration-model.ts";
|
|
29
|
-
import type {
|
|
30
|
-
RevisionDecorationModel,
|
|
31
|
-
} from "../../ui/headless/revision-decoration-model.ts";
|
|
26
|
+
import type { CommentDecorationModel } from "../../ui/headless/comment-decoration-model.ts";
|
|
27
|
+
import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model.ts";
|
|
32
28
|
import type { ScopeRailSegment } from "../workflow-rail-segments.ts";
|
|
33
29
|
import {
|
|
34
30
|
resolveDecorationIndex,
|
|
@@ -148,6 +144,24 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
148
144
|
// is an optimizer for repeat reads at the same revision.
|
|
149
145
|
let lastEmittedFrame: RenderFrame | null = null;
|
|
150
146
|
|
|
147
|
+
// P14.c — single-slot DecorationIndex cache. `resolveDecorationIndex`
|
|
148
|
+
// is O(n × m) in decorations × offsets; it is safe to reuse the prior
|
|
149
|
+
// result when the layout revision, active story, zoom, and every source
|
|
150
|
+
// reference are all unchanged from the previous build. The frame-level
|
|
151
|
+
// `cache` already prevents rebuilds for repeat reads; this slot covers
|
|
152
|
+
// the post-invalidate rebuild where layout changed but decorations did not.
|
|
153
|
+
let diCacheKey: {
|
|
154
|
+
revision: number;
|
|
155
|
+
activeStoryKind: string;
|
|
156
|
+
zoomPxPerTwip: number;
|
|
157
|
+
workflowSegments: readonly ScopeRailSegment[] | undefined;
|
|
158
|
+
comments: CommentDecorationModel | null | undefined;
|
|
159
|
+
revisions: RevisionDecorationModel | null | undefined;
|
|
160
|
+
searchMatches: readonly SearchMatchRange[] | undefined;
|
|
161
|
+
lockedRanges: readonly LockedRangeInput[] | undefined;
|
|
162
|
+
} | null = null;
|
|
163
|
+
let diCacheValue: DecorationIndex | null = null;
|
|
164
|
+
|
|
151
165
|
const listeners = new Set<(event: RenderKernelEvent) => void>();
|
|
152
166
|
const unsubscribeFacet = facet.subscribe((event) => {
|
|
153
167
|
// Any layout-changing event invalidates the cached frame. We rely on
|
|
@@ -208,6 +222,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
208
222
|
pendingDeltas,
|
|
209
223
|
zoom.pxPerTwip,
|
|
210
224
|
);
|
|
225
|
+
// Revision: keyed off the engine's current page graph so repeated reads
|
|
226
|
+
// at the same revision return the same cached frame. We derive it
|
|
227
|
+
// from the first page since the engine stamps every page with the
|
|
228
|
+
// graph's revision indirectly via pageId; fall back to 0 if empty.
|
|
229
|
+
const revision = filteredPages[0]
|
|
230
|
+
? Number(extractRevisionFromPageId(filteredPages[0].pageId))
|
|
231
|
+
: 0;
|
|
232
|
+
|
|
211
233
|
const includeDecorations = options?.includeDecorations ?? true;
|
|
212
234
|
const sources = input.getDecorationSources?.();
|
|
213
235
|
const hasSources =
|
|
@@ -217,11 +239,45 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
217
239
|
(sources.revisions?.revisions?.length ?? 0) > 0 ||
|
|
218
240
|
(sources.searchMatches && sources.searchMatches.length > 0) ||
|
|
219
241
|
(sources.lockedRanges && sources.lockedRanges.length > 0));
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
242
|
+
// P14.c — DecorationIndex single-slot cache. Check reference equality
|
|
243
|
+
// on every source before calling the O(n × m) resolver.
|
|
244
|
+
const newDIKey = {
|
|
245
|
+
revision: Number.isFinite(revision) ? revision : 0,
|
|
246
|
+
activeStoryKind: activeStory.kind,
|
|
247
|
+
zoomPxPerTwip: zoom.pxPerTwip,
|
|
248
|
+
workflowSegments: sources?.workflowSegments,
|
|
249
|
+
comments: sources?.comments,
|
|
250
|
+
revisions: sources?.revisions,
|
|
251
|
+
searchMatches: sources?.searchMatches,
|
|
252
|
+
lockedRanges: sources?.lockedRanges,
|
|
253
|
+
};
|
|
254
|
+
const diCacheHit =
|
|
255
|
+
hasSources &&
|
|
256
|
+
diCacheKey !== null &&
|
|
257
|
+
diCacheValue !== null &&
|
|
258
|
+
diCacheKey.revision === newDIKey.revision &&
|
|
259
|
+
diCacheKey.activeStoryKind === newDIKey.activeStoryKind &&
|
|
260
|
+
diCacheKey.zoomPxPerTwip === newDIKey.zoomPxPerTwip &&
|
|
261
|
+
diCacheKey.workflowSegments === newDIKey.workflowSegments &&
|
|
262
|
+
diCacheKey.comments === newDIKey.comments &&
|
|
263
|
+
diCacheKey.revisions === newDIKey.revisions &&
|
|
264
|
+
diCacheKey.searchMatches === newDIKey.searchMatches &&
|
|
265
|
+
diCacheKey.lockedRanges === newDIKey.lockedRanges;
|
|
266
|
+
|
|
267
|
+
let decorationIndex: DecorationIndex;
|
|
268
|
+
if (!includeDecorations) {
|
|
269
|
+
decorationIndex = EMPTY_DECORATION_INDEX;
|
|
270
|
+
} else if (hasSources) {
|
|
271
|
+
if (diCacheHit) {
|
|
272
|
+
decorationIndex = diCacheValue!;
|
|
273
|
+
} else {
|
|
274
|
+
decorationIndex = resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources });
|
|
275
|
+
diCacheKey = newDIKey;
|
|
276
|
+
diCacheValue = decorationIndex;
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
decorationIndex = buildDecorationIndex(renderPages);
|
|
280
|
+
}
|
|
225
281
|
const anchorIndex = buildAnchorIndex(
|
|
226
282
|
renderPages,
|
|
227
283
|
pendingDeltas,
|
|
@@ -229,14 +285,6 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
229
285
|
decorationIndex,
|
|
230
286
|
);
|
|
231
287
|
|
|
232
|
-
// Revision: keyed off the engine's current page graph so repeated reads
|
|
233
|
-
// at the same revision return the same cached frame. We derive it
|
|
234
|
-
// from the first page since the engine stamps every page with the
|
|
235
|
-
// graph's revision indirectly via pageId; fall back to 0 if empty.
|
|
236
|
-
const revision = filteredPages[0]
|
|
237
|
-
? Number(extractRevisionFromPageId(filteredPages[0].pageId))
|
|
238
|
-
: 0;
|
|
239
|
-
|
|
240
288
|
const frame: RenderFrame = {
|
|
241
289
|
revision: Number.isFinite(revision) ? revision : 0,
|
|
242
290
|
measurementFidelity,
|
|
@@ -419,6 +419,9 @@ function createTableBlock(
|
|
|
419
419
|
...(cellBorders.borderRight ? { borderRight: cellBorders.borderRight } : {}),
|
|
420
420
|
...(cellBorders.borderBottom ? { borderBottom: cellBorders.borderBottom } : {}),
|
|
421
421
|
...(cellBorders.borderLeft ? { borderLeft: cellBorders.borderLeft } : {}),
|
|
422
|
+
// R3.a Phase 2: per-cell text-flow direction copied from canonical
|
|
423
|
+
// TableCellNode. Node-view renders `tbRl` / `btLr` as CSS writing-mode.
|
|
424
|
+
...(cell.textDirection ? { textDirection: cell.textDirection } : {}),
|
|
422
425
|
...(bandClasses ? { bandClasses } : {}),
|
|
423
426
|
content: cellContent,
|
|
424
427
|
});
|
|
@@ -437,6 +440,30 @@ function createTableBlock(
|
|
|
437
440
|
});
|
|
438
441
|
}
|
|
439
442
|
|
|
443
|
+
// R3.a Phase 2 — fold the resolver's table-level bundle onto the surface
|
|
444
|
+
// block when any field is populated. Borders carry the typed BorderSpec per
|
|
445
|
+
// side; the node-view converts the four outer sides to CSS shorthand at
|
|
446
|
+
// render time. insideH / insideV flow through for round-trip + future use,
|
|
447
|
+
// but their visual effect is realized via per-cell borders today.
|
|
448
|
+
const tr = resolvedTable.tableResolved;
|
|
449
|
+
const trHasContent =
|
|
450
|
+
tr.width !== undefined ||
|
|
451
|
+
tr.layoutMode !== undefined ||
|
|
452
|
+
tr.cellSpacing !== undefined ||
|
|
453
|
+
tr.borders !== undefined;
|
|
454
|
+
const tableResolvedAttr = trHasContent
|
|
455
|
+
? {
|
|
456
|
+
...(tr.width !== undefined ? { width: tr.width } : {}),
|
|
457
|
+
...(tr.widthType !== undefined ? { widthType: tr.widthType } : {}),
|
|
458
|
+
...(tr.layoutMode !== undefined ? { layoutMode: tr.layoutMode } : {}),
|
|
459
|
+
...(tr.cellSpacing !== undefined ? { cellSpacing: tr.cellSpacing } : {}),
|
|
460
|
+
...(tr.cellSpacingType !== undefined
|
|
461
|
+
? { cellSpacingType: tr.cellSpacingType }
|
|
462
|
+
: {}),
|
|
463
|
+
...(tr.borders ? { borders: tr.borders } : {}),
|
|
464
|
+
}
|
|
465
|
+
: undefined;
|
|
466
|
+
|
|
440
467
|
return {
|
|
441
468
|
block: {
|
|
442
469
|
blockId: `table-${tableIndex}`,
|
|
@@ -447,6 +474,7 @@ function createTableBlock(
|
|
|
447
474
|
gridColumns: table.gridColumns,
|
|
448
475
|
...(resolvedTable.table?.alignment ? { alignment: resolvedTable.table.alignment } : {}),
|
|
449
476
|
tblLook: resolvedTable.effectiveTblLook,
|
|
477
|
+
...(tableResolvedAttr ? { tableResolved: tableResolvedAttr } : {}),
|
|
450
478
|
rows,
|
|
451
479
|
},
|
|
452
480
|
lockedFragmentIds,
|
|
@@ -42,6 +42,8 @@ type TableCellAttrs = {
|
|
|
42
42
|
borderRight?: string | null;
|
|
43
43
|
borderBottom?: string | null;
|
|
44
44
|
borderLeft?: string | null;
|
|
45
|
+
/** R3.a Phase 2 — per-cell text-flow direction. */
|
|
46
|
+
textDirection?: "lrTb" | "tbRl" | "btLr" | null;
|
|
45
47
|
bandClasses?: string | null;
|
|
46
48
|
};
|
|
47
49
|
|
|
@@ -188,6 +190,13 @@ const tableCellSpecAttrs = {
|
|
|
188
190
|
borderRight: { default: null },
|
|
189
191
|
borderBottom: { default: null },
|
|
190
192
|
borderLeft: { default: null },
|
|
193
|
+
/**
|
|
194
|
+
* R3.a Phase 2 — per-cell text-flow direction copied from
|
|
195
|
+
* `TableCellNode.textDirection` ("lrTb" | "tbRl" | "btLr"). Node-view maps
|
|
196
|
+
* `tbRl` → `writing-mode: vertical-rl`, `btLr` → `writing-mode: vertical-lr`,
|
|
197
|
+
* `lrTb` (or null) → unset.
|
|
198
|
+
*/
|
|
199
|
+
textDirection: { default: null },
|
|
191
200
|
/** R2b: space-joined band classes ("band-firstRow band-band1Horz") from the resolved style. */
|
|
192
201
|
bandClasses: { default: null },
|
|
193
202
|
} as const;
|
|
@@ -210,6 +219,24 @@ export const tableNodeSpec: NodeSpec = {
|
|
|
210
219
|
tblLookNoVBand: { default: false },
|
|
211
220
|
/** R2d: raw `w:tblLook/@w:val` hex preserved verbatim so vendor-extended bits survive round-trip. */
|
|
212
221
|
tblLookVal: { default: null },
|
|
222
|
+
// R3.a Phase 2 — table-level resolved properties projected from
|
|
223
|
+
// ResolvedTableLevelProperties. Each is nullable so PM's null-vs-undefined
|
|
224
|
+
// preference is satisfied. Width is in twips (dxa) or fiftieths-of-percent
|
|
225
|
+
// (pct); spacing is twips (dxa). Borders are CSS shorthand strings (e.g.
|
|
226
|
+
// "1px solid #000000") for the four outer sides; insideH / insideV are
|
|
227
|
+
// realized through per-cell border attrs (CSS has no native
|
|
228
|
+
// table-inside-border property — see applyTableAttrs comment).
|
|
229
|
+
tableWidth: { default: null },
|
|
230
|
+
tableWidthType: { default: null },
|
|
231
|
+
tableLayoutMode: { default: null },
|
|
232
|
+
tableCellSpacing: { default: null },
|
|
233
|
+
tableCellSpacingType: { default: null },
|
|
234
|
+
tableBorderTop: { default: null },
|
|
235
|
+
tableBorderRight: { default: null },
|
|
236
|
+
tableBorderBottom: { default: null },
|
|
237
|
+
tableBorderLeft: { default: null },
|
|
238
|
+
tableBorderInsideH: { default: null },
|
|
239
|
+
tableBorderInsideV: { default: null },
|
|
213
240
|
},
|
|
214
241
|
parseDOM: [{ tag: "table" }],
|
|
215
242
|
toDOM(node) {
|