@beyondwork/docx-react-component 1.0.38 → 1.0.39
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 +41 -31
- package/src/api/public-types.ts +183 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +134 -18
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +40 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/ui/WordReviewEditor.tsx +285 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +4 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +1 -5
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
persistedSnapshotFromEditorSessionState,
|
|
83
83
|
} from "../api/session-state.ts";
|
|
84
84
|
import {
|
|
85
|
+
type CommandExecutionContext,
|
|
85
86
|
executeEditorCommand,
|
|
86
87
|
selectionChanged,
|
|
87
88
|
type CommandOrigin,
|
|
@@ -179,6 +180,8 @@ import {
|
|
|
179
180
|
setCaretAffinity as applyCaretAffinity,
|
|
180
181
|
setActivePageRegion as applyActivePageRegion,
|
|
181
182
|
setActiveObjectFrame as applyActiveObjectFrame,
|
|
183
|
+
setEditorRole as applyEditorRole,
|
|
184
|
+
setChromePin as applyChromePins,
|
|
182
185
|
createEditorViewStateSnapshot,
|
|
183
186
|
type ViewState,
|
|
184
187
|
} from "./view-state.ts";
|
|
@@ -229,6 +232,24 @@ export interface DocumentRuntime {
|
|
|
229
232
|
replaceText(text: string, target?: EditorAnchorProjection): void;
|
|
230
233
|
applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
|
|
231
234
|
dispatch(command: EditorCommand): void;
|
|
235
|
+
/**
|
|
236
|
+
* Apply a command received from a remote collaborator. The command
|
|
237
|
+
* executes through `executeEditorCommand` exactly like a local dispatch,
|
|
238
|
+
* but:
|
|
239
|
+
* - `onCommandApplied` is NOT fired (no echo back to the network)
|
|
240
|
+
* - the local undo/redo history is NOT mutated (remote edits are
|
|
241
|
+
* not undoable by the local user)
|
|
242
|
+
* - local workflow/blocked-command checks are bypassed (the remote
|
|
243
|
+
* already performed them)
|
|
244
|
+
*
|
|
245
|
+
* Used by runtime-level collaboration sync to replay `CommandEvent`s
|
|
246
|
+
* from the shared event log.
|
|
247
|
+
*/
|
|
248
|
+
applyRemoteCommand(
|
|
249
|
+
command: EditorCommand,
|
|
250
|
+
context: CommandExecutionContext,
|
|
251
|
+
meta?: Partial<CommandAppliedMeta>,
|
|
252
|
+
): void;
|
|
232
253
|
emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
|
|
233
254
|
undo(): void;
|
|
234
255
|
redo(): void;
|
|
@@ -255,6 +276,8 @@ export interface DocumentRuntime {
|
|
|
255
276
|
getProtectionSnapshot(): ProtectionSnapshot;
|
|
256
277
|
setWorkspaceMode(mode: WorkspaceMode): void;
|
|
257
278
|
setZoom(level: ZoomLevel): void;
|
|
279
|
+
setEditorRole(role: import("./view-state.ts").ViewState["editorRole"]): void;
|
|
280
|
+
setChromePin(surface: import("../api/public-types.ts").ChromePinSurface, pin: import("../api/public-types.ts").PinState | null): void;
|
|
258
281
|
getPageLayoutSnapshot(): PageLayoutSnapshot | null;
|
|
259
282
|
getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
|
|
260
283
|
/**
|
|
@@ -319,6 +342,11 @@ export interface DocumentRuntime {
|
|
|
319
342
|
): RuntimeContextAnalyticsSnapshot | null;
|
|
320
343
|
}
|
|
321
344
|
|
|
345
|
+
export interface CommandAppliedMeta {
|
|
346
|
+
preSelection: import("../core/state/editor-state.ts").SelectionSnapshot;
|
|
347
|
+
activeStory: EditorStoryTarget;
|
|
348
|
+
}
|
|
349
|
+
|
|
322
350
|
export interface CreateDocumentRuntimeOptions {
|
|
323
351
|
documentId: string;
|
|
324
352
|
initialSessionState?: EditorSessionState;
|
|
@@ -338,6 +366,23 @@ export interface CreateDocumentRuntimeOptions {
|
|
|
338
366
|
onEvent?: (event: DocumentRuntimeEvent) => void;
|
|
339
367
|
onWarning?: (warning: EditorWarning) => void;
|
|
340
368
|
onError?: (error: EditorError) => void;
|
|
369
|
+
/**
|
|
370
|
+
* Fired AFTER a command has been applied locally via `dispatch()` or
|
|
371
|
+
* `applyActiveStoryTextCommand()`. Used by collaboration sync to
|
|
372
|
+
* broadcast the command to remote clients. NOT fired for remote commands
|
|
373
|
+
* applied via `applyRemoteCommand()` — this prevents echo loops.
|
|
374
|
+
*
|
|
375
|
+
* Not fired for:
|
|
376
|
+
* - `history.undo` / `history.redo` (applied via `applyHistory()` which
|
|
377
|
+
* does not pass through `commit()`'s new hook call site)
|
|
378
|
+
* - Remote replays applied via `applyRemoteCommand()`
|
|
379
|
+
*/
|
|
380
|
+
onCommandApplied?: (
|
|
381
|
+
command: EditorCommand,
|
|
382
|
+
transaction: EditorTransaction,
|
|
383
|
+
context: CommandExecutionContext,
|
|
384
|
+
meta: CommandAppliedMeta,
|
|
385
|
+
) => void;
|
|
341
386
|
initialViewState?: Partial<ViewState>;
|
|
342
387
|
protectionSnapshot?: ProtectionSnapshot;
|
|
343
388
|
}
|
|
@@ -1611,12 +1656,40 @@ export function createDocumentRuntime(
|
|
|
1611
1656
|
return;
|
|
1612
1657
|
}
|
|
1613
1658
|
try {
|
|
1614
|
-
const
|
|
1615
|
-
timestamp: command.origin?.timestamp ?? clock(),
|
|
1659
|
+
const context = {
|
|
1660
|
+
timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
|
|
1616
1661
|
documentMode: getEffectiveDocumentMode(commandSelection),
|
|
1617
1662
|
defaultAuthorId: defaultAuthorId ?? undefined,
|
|
1618
|
-
}
|
|
1663
|
+
} as const;
|
|
1664
|
+
const preSelection = commandSelection;
|
|
1665
|
+
const preActiveStory = activeStory;
|
|
1666
|
+
const transaction = executeEditorCommand(state, command, context);
|
|
1619
1667
|
commit(transaction);
|
|
1668
|
+
options.onCommandApplied?.(command, transaction, context, {
|
|
1669
|
+
preSelection,
|
|
1670
|
+
activeStory: preActiveStory,
|
|
1671
|
+
});
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
emitError(toRuntimeError(error));
|
|
1674
|
+
}
|
|
1675
|
+
},
|
|
1676
|
+
applyRemoteCommand(command, context, meta) {
|
|
1677
|
+
try {
|
|
1678
|
+
if (command.type === "history.undo" || command.type === "history.redo") {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
|
|
1682
|
+
activeStory = meta.activeStory;
|
|
1683
|
+
storySelections.set(
|
|
1684
|
+
storyTargetKey(activeStory),
|
|
1685
|
+
meta.preSelection ?? state.selection,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
const replayState = meta?.preSelection
|
|
1689
|
+
? { ...state, selection: meta.preSelection }
|
|
1690
|
+
: state;
|
|
1691
|
+
const transaction = executeEditorCommand(replayState, command, context);
|
|
1692
|
+
commitRemote(transaction);
|
|
1620
1693
|
} catch (error) {
|
|
1621
1694
|
emitError(toRuntimeError(error));
|
|
1622
1695
|
}
|
|
@@ -1890,6 +1963,20 @@ export function createDocumentRuntime(
|
|
|
1890
1963
|
listener();
|
|
1891
1964
|
}
|
|
1892
1965
|
},
|
|
1966
|
+
setEditorRole(role) {
|
|
1967
|
+
viewState = applyEditorRole(viewState, role);
|
|
1968
|
+
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
1969
|
+
for (const listener of listeners) {
|
|
1970
|
+
listener();
|
|
1971
|
+
}
|
|
1972
|
+
},
|
|
1973
|
+
setChromePin(surface, pin) {
|
|
1974
|
+
viewState = applyChromePins(viewState, surface, pin);
|
|
1975
|
+
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
1976
|
+
for (const listener of listeners) {
|
|
1977
|
+
listener();
|
|
1978
|
+
}
|
|
1979
|
+
},
|
|
1893
1980
|
getPageLayoutSnapshot() {
|
|
1894
1981
|
return getCachedPageLayoutSnapshot(state, activeStory);
|
|
1895
1982
|
},
|
|
@@ -2346,13 +2433,21 @@ export function createDocumentRuntime(
|
|
|
2346
2433
|
}
|
|
2347
2434
|
|
|
2348
2435
|
function commit(transaction: EditorTransaction): void {
|
|
2349
|
-
const previous = state;
|
|
2350
|
-
|
|
2351
2436
|
if (transaction.historyBoundary === "push") {
|
|
2352
2437
|
history.past.push(state);
|
|
2353
2438
|
history.future = [];
|
|
2354
2439
|
}
|
|
2355
2440
|
|
|
2441
|
+
applyTransactionToState(transaction);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function commitRemote(transaction: EditorTransaction): void {
|
|
2445
|
+
applyTransactionToState(transaction);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function applyTransactionToState(transaction: EditorTransaction): void {
|
|
2449
|
+
const previous = state;
|
|
2450
|
+
|
|
2356
2451
|
protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
|
|
2357
2452
|
state = finalizeState(transaction.nextState, transaction.markDirty, clock());
|
|
2358
2453
|
storySelections.set(storyTargetKey(activeStory), state.selection);
|
|
@@ -2562,13 +2657,13 @@ export function createDocumentRuntime(
|
|
|
2562
2657
|
|
|
2563
2658
|
function applyTextCommandInActiveStory(
|
|
2564
2659
|
command: ActiveStoryTextCommand,
|
|
2565
|
-
|
|
2660
|
+
textOptions: {
|
|
2566
2661
|
selection?: EditorState["selection"];
|
|
2567
2662
|
blockedCommandName?: string;
|
|
2568
2663
|
} = {},
|
|
2569
2664
|
): TextCommandAck {
|
|
2570
2665
|
const opId = (command.origin as { opId?: string } | undefined)?.opId;
|
|
2571
|
-
const selection =
|
|
2666
|
+
const selection = textOptions.selection ?? state.selection;
|
|
2572
2667
|
if (
|
|
2573
2668
|
activeStory.kind !== "main" &&
|
|
2574
2669
|
getEffectiveDocumentMode(selection) === "suggesting" &&
|
|
@@ -2578,7 +2673,7 @@ export function createDocumentRuntime(
|
|
|
2578
2673
|
emit({
|
|
2579
2674
|
type: "command_blocked",
|
|
2580
2675
|
documentId: state.documentId,
|
|
2581
|
-
command:
|
|
2676
|
+
command: textOptions.blockedCommandName ?? command.type,
|
|
2582
2677
|
reasons: [{
|
|
2583
2678
|
code: "suggesting_unsupported",
|
|
2584
2679
|
message,
|
|
@@ -2597,7 +2692,7 @@ export function createDocumentRuntime(
|
|
|
2597
2692
|
emit({
|
|
2598
2693
|
type: "command_blocked",
|
|
2599
2694
|
documentId: state.documentId,
|
|
2600
|
-
command:
|
|
2695
|
+
command: textOptions.blockedCommandName ?? command.type,
|
|
2601
2696
|
reasons: blockedReasons,
|
|
2602
2697
|
});
|
|
2603
2698
|
return {
|
|
@@ -2608,7 +2703,7 @@ export function createDocumentRuntime(
|
|
|
2608
2703
|
};
|
|
2609
2704
|
}
|
|
2610
2705
|
|
|
2611
|
-
const timestamp = command.origin?.timestamp ?? clock();
|
|
2706
|
+
const timestamp = normalizeCommandTimestamp(command.origin?.timestamp) ?? clock();
|
|
2612
2707
|
const context = {
|
|
2613
2708
|
timestamp,
|
|
2614
2709
|
documentMode: getEffectiveDocumentMode(selection),
|
|
@@ -2621,9 +2716,15 @@ export function createDocumentRuntime(
|
|
|
2621
2716
|
selection,
|
|
2622
2717
|
};
|
|
2623
2718
|
|
|
2719
|
+
const preSelection = selection;
|
|
2720
|
+
const preActiveStory = activeStory;
|
|
2624
2721
|
if (activeStory.kind === "main") {
|
|
2625
2722
|
const mainTransaction = executeEditorCommand(baseState, command, context);
|
|
2626
2723
|
commit(mainTransaction);
|
|
2724
|
+
options.onCommandApplied?.(command, mainTransaction, context, {
|
|
2725
|
+
preSelection,
|
|
2726
|
+
activeStory: preActiveStory,
|
|
2727
|
+
});
|
|
2627
2728
|
return classifyAck({
|
|
2628
2729
|
command,
|
|
2629
2730
|
opId,
|
|
@@ -2682,16 +2783,17 @@ export function createDocumentRuntime(
|
|
|
2682
2783
|
activeStory,
|
|
2683
2784
|
),
|
|
2684
2785
|
};
|
|
2786
|
+
const broadcastCommand: EditorCommand = {
|
|
2787
|
+
type: "document.replace",
|
|
2788
|
+
document: nextDocumentWithReview,
|
|
2789
|
+
selection: localTransaction.nextState.selection,
|
|
2790
|
+
mapping: createEmptyMapping(),
|
|
2791
|
+
protectionSelection: selection,
|
|
2792
|
+
origin: command.origin,
|
|
2793
|
+
};
|
|
2685
2794
|
const fullTransaction = executeEditorCommand(
|
|
2686
2795
|
baseState,
|
|
2687
|
-
|
|
2688
|
-
type: "document.replace",
|
|
2689
|
-
document: nextDocumentWithReview,
|
|
2690
|
-
selection: localTransaction.nextState.selection,
|
|
2691
|
-
mapping: createEmptyMapping(),
|
|
2692
|
-
protectionSelection: selection,
|
|
2693
|
-
origin: command.origin,
|
|
2694
|
-
},
|
|
2796
|
+
broadcastCommand,
|
|
2695
2797
|
context,
|
|
2696
2798
|
);
|
|
2697
2799
|
|
|
@@ -2700,6 +2802,10 @@ export function createDocumentRuntime(
|
|
|
2700
2802
|
effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
|
|
2701
2803
|
};
|
|
2702
2804
|
commit(mergedTransaction);
|
|
2805
|
+
options.onCommandApplied?.(broadcastCommand, mergedTransaction, context, {
|
|
2806
|
+
preSelection,
|
|
2807
|
+
activeStory: preActiveStory,
|
|
2808
|
+
});
|
|
2703
2809
|
return classifyAck({
|
|
2704
2810
|
command,
|
|
2705
2811
|
opId,
|
|
@@ -2988,6 +3094,16 @@ function createEntityId(
|
|
|
2988
3094
|
return nextId;
|
|
2989
3095
|
}
|
|
2990
3096
|
|
|
3097
|
+
function normalizeCommandTimestamp(value: unknown): string | undefined {
|
|
3098
|
+
if (typeof value === "string" && value.length > 0) {
|
|
3099
|
+
return value;
|
|
3100
|
+
}
|
|
3101
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3102
|
+
return new Date(value).toISOString();
|
|
3103
|
+
}
|
|
3104
|
+
return undefined;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2991
3107
|
function finalizeState(
|
|
2992
3108
|
state: EditorState,
|
|
2993
3109
|
markDirty: boolean,
|
|
@@ -158,6 +158,8 @@ export {
|
|
|
158
158
|
type EmbeddedFontBytes,
|
|
159
159
|
} from "./docx-font-loader.ts";
|
|
160
160
|
|
|
161
|
+
export { createCanvasBackend } from "./measurement-backend-canvas.ts";
|
|
162
|
+
|
|
161
163
|
// ---------------------------------------------------------------------------
|
|
162
164
|
// Public facet (Phase 7)
|
|
163
165
|
// ---------------------------------------------------------------------------
|
|
@@ -51,6 +51,8 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
|
|
|
51
51
|
getMeasurement: () => null,
|
|
52
52
|
getMeasurementFidelity: () => fidelity,
|
|
53
53
|
whenMeasurementReady: () => Promise.resolve(),
|
|
54
|
+
getFirstPageIndexForBlock: () => null,
|
|
55
|
+
swapMeasurementProvider: () => undefined,
|
|
54
56
|
getTableRenderPlan: () => null,
|
|
55
57
|
getDirtyFieldFamilies: () => [],
|
|
56
58
|
getFieldDirtinessReport: () => emptyReport,
|
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
import {
|
|
46
46
|
buildPageStack,
|
|
47
47
|
buildPageStackFrom,
|
|
48
|
+
buildPageStackFromWithSplits,
|
|
49
|
+
buildPageStackWithSplits,
|
|
48
50
|
type LayoutInvalidationReason,
|
|
49
51
|
} from "./paginated-layout-engine.ts";
|
|
50
52
|
import {
|
|
@@ -195,6 +197,14 @@ function recordFullRebuildReason(reasonKind: string): void {
|
|
|
195
197
|
export interface CreateLayoutEngineOptions {
|
|
196
198
|
/** Optional measurement provider. Defaults to empirical. */
|
|
197
199
|
measurementProvider?: LayoutMeasurementProvider;
|
|
200
|
+
/**
|
|
201
|
+
* When true and a browser-like `document` global is available, the engine
|
|
202
|
+
* dynamically imports the Canvas2D measurement backend and swaps to it at
|
|
203
|
+
* init time, emitting `measurement_backend_ready`. SSR stays on the
|
|
204
|
+
* empirical backend. Callers that want to stay on empirical (for
|
|
205
|
+
* determinism or tests) pass `false`. Default: true.
|
|
206
|
+
*/
|
|
207
|
+
autoUpgradeToCanvasBackend?: boolean;
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
export function createLayoutEngine(
|
|
@@ -202,6 +212,7 @@ export function createLayoutEngine(
|
|
|
202
212
|
): LayoutEngineInstance {
|
|
203
213
|
let measurementProvider: LayoutMeasurementProvider =
|
|
204
214
|
options.measurementProvider ?? createEmpiricalMeasurementProvider();
|
|
215
|
+
const autoUpgradeToCanvas = options.autoUpgradeToCanvasBackend !== false;
|
|
205
216
|
const dirtyFieldFamilies = new Set<string>();
|
|
206
217
|
const listeners = new Set<(event: LayoutEngineEvent) => void>();
|
|
207
218
|
let cachedKey: CacheKey | null = null;
|
|
@@ -244,11 +255,18 @@ export function createLayoutEngine(
|
|
|
244
255
|
MAIN_STORY_TARGET,
|
|
245
256
|
);
|
|
246
257
|
const sections = buildResolvedSections(document);
|
|
247
|
-
const
|
|
258
|
+
const pageStack = buildPageStackWithSplits(
|
|
259
|
+
document,
|
|
260
|
+
sections,
|
|
261
|
+
mainSurface,
|
|
262
|
+
measurementProvider,
|
|
263
|
+
);
|
|
264
|
+
const pages = pageStack.pages;
|
|
248
265
|
const stories = resolvePageStories(pages);
|
|
249
266
|
const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
|
|
250
267
|
mainSurface,
|
|
251
268
|
pages,
|
|
269
|
+
pageStack.splits,
|
|
252
270
|
);
|
|
253
271
|
const graph = buildPageGraph({
|
|
254
272
|
pages,
|
|
@@ -320,7 +338,7 @@ export function createLayoutEngine(
|
|
|
320
338
|
const sections = buildResolvedSections(document);
|
|
321
339
|
|
|
322
340
|
const dirtyPage = priorGraph.pages[firstDirty]!;
|
|
323
|
-
const
|
|
341
|
+
const freshResult = buildPageStackFromWithSplits(
|
|
324
342
|
document,
|
|
325
343
|
sections,
|
|
326
344
|
mainSurface,
|
|
@@ -330,6 +348,7 @@ export function createLayoutEngine(
|
|
|
330
348
|
},
|
|
331
349
|
measurementProvider,
|
|
332
350
|
);
|
|
351
|
+
const freshSnapshots = freshResult.pages;
|
|
333
352
|
|
|
334
353
|
// Convert fresh DocumentPageSnapshots into RuntimePageNodes via the
|
|
335
354
|
// standard buildPageGraph pipeline — this keeps region, story, and
|
|
@@ -341,10 +360,18 @@ export function createLayoutEngine(
|
|
|
341
360
|
return null;
|
|
342
361
|
}
|
|
343
362
|
const freshStories = resolvePageStories(freshSnapshots);
|
|
363
|
+
// Project fragments for the fresh tail pages, threading paragraph
|
|
364
|
+
// line-range splits produced by intra-paragraph pagination.
|
|
365
|
+
const freshFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
|
|
366
|
+
mainSurface,
|
|
367
|
+
freshSnapshots,
|
|
368
|
+
freshResult.splits,
|
|
369
|
+
);
|
|
344
370
|
const freshGraph = buildPageGraph({
|
|
345
371
|
pages: freshSnapshots,
|
|
346
372
|
sections,
|
|
347
373
|
stories: freshStories,
|
|
374
|
+
fragmentsByPageIndex: freshFragmentsByPageIndex,
|
|
348
375
|
});
|
|
349
376
|
const freshNodes = freshGraph.pages;
|
|
350
377
|
|
|
@@ -444,6 +471,46 @@ export function createLayoutEngine(
|
|
|
444
471
|
return cachedFormatting!;
|
|
445
472
|
}
|
|
446
473
|
|
|
474
|
+
// -----------------------------------------------------------------------
|
|
475
|
+
// Auto-upgrade to the Canvas2D measurement backend in browsers. Dynamic
|
|
476
|
+
// import keeps SSR bundles lean. We only attempt the upgrade when the
|
|
477
|
+
// caller didn't provide their own provider and `document` is available.
|
|
478
|
+
// -----------------------------------------------------------------------
|
|
479
|
+
if (
|
|
480
|
+
autoUpgradeToCanvas &&
|
|
481
|
+
options.measurementProvider === undefined &&
|
|
482
|
+
typeof document !== "undefined" &&
|
|
483
|
+
typeof HTMLCanvasElement !== "undefined"
|
|
484
|
+
) {
|
|
485
|
+
// Swallow errors silently — staying on empirical is correct behavior
|
|
486
|
+
// when the upgrade fails. Perf probe increments the fallback counter
|
|
487
|
+
// through the emitted event (listeners observe fidelity).
|
|
488
|
+
const readCachedRevision = (): number => cachedGraph?.revision ?? 0;
|
|
489
|
+
void (async () => {
|
|
490
|
+
try {
|
|
491
|
+
const mod = await import("./measurement-backend-canvas.ts");
|
|
492
|
+
const canvasProvider = mod.createCanvasBackend();
|
|
493
|
+
measurementProvider = canvasProvider;
|
|
494
|
+
// Invalidate the cached graph/formatting/mapper so the next read
|
|
495
|
+
// recomputes with canvas-measured font metrics. Without this,
|
|
496
|
+
// the first render after the async import still uses empirical
|
|
497
|
+
// numbers and the chrome shifts by a few pixels on the next
|
|
498
|
+
// real invalidation.
|
|
499
|
+
cachedKey = null;
|
|
500
|
+
cachedGraph = null;
|
|
501
|
+
cachedFormatting = null;
|
|
502
|
+
cachedMapper = null;
|
|
503
|
+
emit({
|
|
504
|
+
kind: "measurement_backend_ready",
|
|
505
|
+
revision: readCachedRevision(),
|
|
506
|
+
fidelity: canvasProvider.fidelity,
|
|
507
|
+
});
|
|
508
|
+
} catch {
|
|
509
|
+
// Stay on empirical. No-op.
|
|
510
|
+
}
|
|
511
|
+
})();
|
|
512
|
+
}
|
|
513
|
+
|
|
447
514
|
return {
|
|
448
515
|
get measurementFidelity() {
|
|
449
516
|
return measurementProvider.fidelity;
|
|
@@ -93,12 +93,21 @@ export function analyzeInvalidation(
|
|
|
93
93
|
return analyzeSectionChange(reason, graph);
|
|
94
94
|
|
|
95
95
|
case "numbering-change":
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
if (!reason.numberingInstanceId) {
|
|
97
|
+
return {
|
|
98
|
+
scope: "full",
|
|
99
|
+
requiresFullRecompute: true,
|
|
100
|
+
dirtyFieldFamilies: [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
99
103
|
return {
|
|
100
|
-
scope: "
|
|
101
|
-
requiresFullRecompute:
|
|
104
|
+
scope: "bounded",
|
|
105
|
+
requiresFullRecompute: false,
|
|
106
|
+
dirtyPageRange: {
|
|
107
|
+
firstPageIndex: 0,
|
|
108
|
+
lastPageIndex: Math.max(0, graph.pages.length - 1),
|
|
109
|
+
},
|
|
110
|
+
dirtySectionRange: null,
|
|
102
111
|
dirtyFieldFamilies: [],
|
|
103
112
|
};
|
|
104
113
|
|
|
@@ -102,6 +102,42 @@ export interface RuntimeBlockFragment {
|
|
|
102
102
|
to: number;
|
|
103
103
|
/** Height consumed on this page (twips). */
|
|
104
104
|
heightTwips: number;
|
|
105
|
+
/**
|
|
106
|
+
* Fragment classification.
|
|
107
|
+
* - `"whole"` (default): the fragment represents the entire block; no slicing.
|
|
108
|
+
* - `"paragraph-slice"`: one of several fragments produced by intra-paragraph
|
|
109
|
+
* line-box splitting. `paragraphLineRange` identifies which lines this
|
|
110
|
+
* slice renders.
|
|
111
|
+
* - `"table-slice"`: one of several fragments produced by row-boundary table
|
|
112
|
+
* splitting (emitted by the table-fidelity workstream).
|
|
113
|
+
* `tableRowRange` identifies which canonical rows this slice renders.
|
|
114
|
+
* Consumers that predate multi-fragment blocks may treat an absent `kind`
|
|
115
|
+
* as `"whole"`.
|
|
116
|
+
*/
|
|
117
|
+
kind?: "whole" | "paragraph-slice" | "table-slice";
|
|
118
|
+
/**
|
|
119
|
+
* For `kind === "paragraph-slice"`, the inclusive-exclusive line-box index
|
|
120
|
+
* range rendered by this slice plus the total line count for the source
|
|
121
|
+
* paragraph. `from`/`to` still span the full paragraph offset range on
|
|
122
|
+
* every slice — only the visible lines differ.
|
|
123
|
+
*/
|
|
124
|
+
paragraphLineRange?: {
|
|
125
|
+
from: number;
|
|
126
|
+
to: number;
|
|
127
|
+
totalLines: number;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* For `kind === "table-slice"`, the inclusive-exclusive row-index range
|
|
131
|
+
* rendered by this slice. Repeated header rows (when the table has
|
|
132
|
+
* `isHeader` rows and `continuation` pages) are implied by the owning
|
|
133
|
+
* table's canonical row list — consumers prepend header rows for slices
|
|
134
|
+
* whose `from > 0`.
|
|
135
|
+
*/
|
|
136
|
+
tableRowRange?: {
|
|
137
|
+
from: number;
|
|
138
|
+
to: number;
|
|
139
|
+
totalRows: number;
|
|
140
|
+
};
|
|
105
141
|
}
|
|
106
142
|
|
|
107
143
|
export interface RuntimeLineBox {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-box splitter for paragraph pagination.
|
|
3
|
+
*
|
|
4
|
+
* Given a paragraph that doesn't fit on the current page, decide how many of
|
|
5
|
+
* its lines belong on the current page and how many continue on the next.
|
|
6
|
+
* Honors the four pagination attributes that govern this in OOXML /
|
|
7
|
+
* ECMA-376 §17.3.1.33:
|
|
8
|
+
*
|
|
9
|
+
* - `keepLines` — if true, never split the paragraph.
|
|
10
|
+
* - `widowControl` — if true (Word default), keep ≥ WIDOW_MIN lines on
|
|
11
|
+
* each side of any split. Applies independently at
|
|
12
|
+
* the top (orphan) and bottom (widow) of a split.
|
|
13
|
+
* - `keepNext` — orthogonal to splitting itself; the engine's
|
|
14
|
+
* outer loop uses it to bundle the paragraph with
|
|
15
|
+
* the next. We only honour it here to avoid an
|
|
16
|
+
* awkward split on a paragraph that is about to be
|
|
17
|
+
* kept with the next (no gain from splitting).
|
|
18
|
+
* - `pageBreakBefore` — handled upstream via an unconditional page break;
|
|
19
|
+
* if set, callers do not consult this splitter.
|
|
20
|
+
*
|
|
21
|
+
* Pure function. No DOM, no side effects. Unit-testable in isolation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_WIDOW_MIN_LINES = 2;
|
|
25
|
+
|
|
26
|
+
export interface LineSplitInput {
|
|
27
|
+
/** Total number of line boxes in the paragraph, ≥ 1. */
|
|
28
|
+
totalLines: number;
|
|
29
|
+
/**
|
|
30
|
+
* How many lines can still fit on the current page given the remaining
|
|
31
|
+
* vertical space and the paragraph's resolved line height. Callers compute
|
|
32
|
+
* this by dividing the remaining column height by the per-line height.
|
|
33
|
+
* Must be ≥ 0. Zero is legal and short-circuits to "no split" (the whole
|
|
34
|
+
* paragraph moves to the next page).
|
|
35
|
+
*/
|
|
36
|
+
availableLines: number;
|
|
37
|
+
/** OOXML `w:keepLines`. */
|
|
38
|
+
keepLines: boolean;
|
|
39
|
+
/** OOXML `w:widowControl` (true in Word's default). */
|
|
40
|
+
widowControl: boolean;
|
|
41
|
+
/** OOXML `w:keepNext`. */
|
|
42
|
+
keepNext: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this paragraph is the last block on the page-in-flight. If true,
|
|
45
|
+
* splitting buys nothing (there's no subsequent content we're squeezing in
|
|
46
|
+
* below) and we leave the whole paragraph on the current page.
|
|
47
|
+
*/
|
|
48
|
+
isLastBlockOnPage: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Minimum lines required on each side of a widow/orphan-controlled split.
|
|
51
|
+
* Defaults to {@link DEFAULT_WIDOW_MIN_LINES}. Exposed for fixtures that
|
|
52
|
+
* exercise narrow paragraphs.
|
|
53
|
+
*/
|
|
54
|
+
widowMinLines?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LineSplitResult {
|
|
58
|
+
linesOnCurrent: number;
|
|
59
|
+
linesOnNext: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decide how to split a paragraph across a page boundary.
|
|
64
|
+
*
|
|
65
|
+
* Returns:
|
|
66
|
+
* - `null` when no split should happen. The caller's existing "move the
|
|
67
|
+
* whole paragraph to the next page" behavior applies.
|
|
68
|
+
* - A `{ linesOnCurrent, linesOnNext }` split with `linesOnCurrent +
|
|
69
|
+
* linesOnNext === totalLines` and both values ≥ 1. The caller must
|
|
70
|
+
* render `linesOnCurrent` lines in the remaining space on the current
|
|
71
|
+
* page and `linesOnNext` lines at the top of the next page.
|
|
72
|
+
*/
|
|
73
|
+
export function paginateParagraphLines(
|
|
74
|
+
input: LineSplitInput,
|
|
75
|
+
): LineSplitResult | null {
|
|
76
|
+
const totalLines = Math.max(1, Math.floor(input.totalLines));
|
|
77
|
+
const availableLines = Math.max(0, Math.floor(input.availableLines));
|
|
78
|
+
const widowMin = Math.max(1, input.widowMinLines ?? DEFAULT_WIDOW_MIN_LINES);
|
|
79
|
+
|
|
80
|
+
// keepLines wins unconditionally.
|
|
81
|
+
if (input.keepLines) return null;
|
|
82
|
+
|
|
83
|
+
// If the paragraph fits wholesale on the current page, no split.
|
|
84
|
+
if (totalLines <= availableLines) return null;
|
|
85
|
+
|
|
86
|
+
// If there's nothing worth keeping on the current page (zero lines fit, or
|
|
87
|
+
// only a single line under widow control would fit), fall through to the
|
|
88
|
+
// caller's "move whole paragraph" path.
|
|
89
|
+
const effectiveOrphanMin = input.widowControl ? widowMin : 1;
|
|
90
|
+
if (availableLines < effectiveOrphanMin) return null;
|
|
91
|
+
|
|
92
|
+
// Don't split trivially-short paragraphs whose final lines could fit a
|
|
93
|
+
// page move (keeping them intact preserves Word's visual intent).
|
|
94
|
+
if (totalLines < effectiveOrphanMin * 2) return null;
|
|
95
|
+
|
|
96
|
+
// If this paragraph is the last thing on this page anyway, splitting has
|
|
97
|
+
// no downstream benefit — the next page is empty.
|
|
98
|
+
if (input.isLastBlockOnPage) return null;
|
|
99
|
+
|
|
100
|
+
// keepNext paragraphs are meant to travel with the next block. Splitting
|
|
101
|
+
// them would move half-and-half across the break, which usually contradicts
|
|
102
|
+
// author intent. Leave the paragraph intact; the outer loop will handle
|
|
103
|
+
// the keep-with-next pairing on the next page.
|
|
104
|
+
if (input.keepNext) return null;
|
|
105
|
+
|
|
106
|
+
// Candidate split: fill the current page, put the rest on the next.
|
|
107
|
+
let linesOnCurrent = availableLines;
|
|
108
|
+
let linesOnNext = totalLines - linesOnCurrent;
|
|
109
|
+
|
|
110
|
+
// Widow control: the tail on the next page needs ≥ widowMin lines too.
|
|
111
|
+
if (input.widowControl && linesOnNext < widowMin) {
|
|
112
|
+
// Pull enough lines back to the next page to meet the widow minimum.
|
|
113
|
+
const deficit = widowMin - linesOnNext;
|
|
114
|
+
linesOnCurrent -= deficit;
|
|
115
|
+
linesOnNext += deficit;
|
|
116
|
+
|
|
117
|
+
// If pulling back violates the orphan minimum, abandon the split —
|
|
118
|
+
// the whole paragraph goes on the next page.
|
|
119
|
+
if (linesOnCurrent < widowMin) return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Sanity clamp (the arithmetic above should guarantee these but be
|
|
123
|
+
// defensive against bad input).
|
|
124
|
+
if (linesOnCurrent < 1 || linesOnNext < 1) return null;
|
|
125
|
+
if (linesOnCurrent + linesOnNext !== totalLines) return null;
|
|
126
|
+
|
|
127
|
+
return { linesOnCurrent, linesOnNext };
|
|
128
|
+
}
|