@beyondwork/docx-react-component 1.0.36 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +402 -1
- package/src/core/commands/index.ts +18 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +815 -55
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +328 -50
- package/src/io/export/serialize-numbering.ts +114 -24
- package/src/io/export/serialize-tables.ts +87 -11
- package/src/io/export/table-properties-xml.ts +174 -20
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +20 -0
- package/src/io/ooxml/parse-footnotes.ts +62 -1
- package/src/io/ooxml/parse-headers-footers.ts +62 -1
- package/src/io/ooxml/parse-main-document.ts +158 -1
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +45 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +2 -306
- package/src/runtime/document-runtime.ts +287 -11
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +233 -0
- package/src/runtime/layout/inert-layout-facet.ts +59 -0
- package/src/runtime/layout/layout-engine-instance.ts +628 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +452 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +921 -0
- package/src/runtime/layout/project-block-fragments.ts +91 -0
- package/src/runtime/layout/public-facet.ts +1398 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/layout/table-render-plan.ts +229 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +755 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +99 -15
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-shell-view.tsx +6 -0
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +501 -0
- package/src/ui/headless/scoped-chrome-policy.ts +183 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
- package/src/ui-tailwind/index.ts +33 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +505 -144
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
|
@@ -47,12 +47,14 @@ import type {
|
|
|
47
47
|
RuntimeContextAnalyticsQuery,
|
|
48
48
|
RuntimeContextAnalyticsSnapshot,
|
|
49
49
|
RuntimeRenderSnapshot,
|
|
50
|
+
ScopeTagTouch,
|
|
50
51
|
SelectionSnapshot,
|
|
51
52
|
SnapshotRefreshHints,
|
|
52
53
|
SuggestionsSnapshot,
|
|
53
54
|
SurfaceBlockSnapshot,
|
|
54
55
|
SurfaceInlineSegment,
|
|
55
56
|
StoryTextStreamSnapshot,
|
|
57
|
+
TextCommandAck,
|
|
56
58
|
TocSnapshot,
|
|
57
59
|
StyleCatalogSnapshot,
|
|
58
60
|
TocRefreshOptions,
|
|
@@ -121,6 +123,17 @@ import {
|
|
|
121
123
|
createDocumentNavigationSnapshot,
|
|
122
124
|
findPageForOffset,
|
|
123
125
|
} from "./document-navigation.ts";
|
|
126
|
+
import {
|
|
127
|
+
createDocxFontLoader,
|
|
128
|
+
createLayoutEngine,
|
|
129
|
+
createLayoutFacet,
|
|
130
|
+
createMeasurementProvider,
|
|
131
|
+
type DocxFontLoader,
|
|
132
|
+
type LayoutEngineInstance,
|
|
133
|
+
type LayoutMeasurementProvider,
|
|
134
|
+
type WordReviewEditorLayoutFacet,
|
|
135
|
+
} from "./layout/index.ts";
|
|
136
|
+
import { createRenderKernel, type RenderKernel } from "./render/index.ts";
|
|
124
137
|
import {
|
|
125
138
|
createDocumentOutlineSnapshot,
|
|
126
139
|
createDocumentSectionSnapshots,
|
|
@@ -150,6 +163,7 @@ import {
|
|
|
150
163
|
resolveActiveSection,
|
|
151
164
|
} from "./document-layout.ts";
|
|
152
165
|
import { normalizeHeaderFooterTarget } from "./story-context.ts";
|
|
166
|
+
import { computeAdjustedRange as computeAdjustedRangeImpl } from "./text-ack-range.ts";
|
|
153
167
|
import {
|
|
154
168
|
getStoryBlocks,
|
|
155
169
|
replaceStoryBlocks,
|
|
@@ -213,7 +227,7 @@ export interface DocumentRuntime {
|
|
|
213
227
|
getCanonicalDocument(): CanonicalDocumentEnvelope;
|
|
214
228
|
getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
|
|
215
229
|
replaceText(text: string, target?: EditorAnchorProjection): void;
|
|
216
|
-
applyActiveStoryTextCommand(command: ActiveStoryTextCommand):
|
|
230
|
+
applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
|
|
217
231
|
dispatch(command: EditorCommand): void;
|
|
218
232
|
emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
|
|
219
233
|
undo(): void;
|
|
@@ -243,6 +257,12 @@ export interface DocumentRuntime {
|
|
|
243
257
|
setZoom(level: ZoomLevel): void;
|
|
244
258
|
getPageLayoutSnapshot(): PageLayoutSnapshot | null;
|
|
245
259
|
getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
|
|
260
|
+
/**
|
|
261
|
+
* Runtime-owned layout facet. Provides graph-aware queries, fragment
|
|
262
|
+
* resolution, formatting inspection, and layout events. Prefer this over
|
|
263
|
+
* the opaque snapshot methods for new integration code.
|
|
264
|
+
*/
|
|
265
|
+
readonly layout: WordReviewEditorLayoutFacet;
|
|
246
266
|
getCurrentLocation(): DocumentLocationSnapshot | null;
|
|
247
267
|
getLocationForSelection(selection: SelectionSnapshot): DocumentLocationSnapshot | null;
|
|
248
268
|
getLocationForAnchor(
|
|
@@ -385,6 +405,57 @@ export function createDocumentRuntime(
|
|
|
385
405
|
fatalError: options.fatalError as never,
|
|
386
406
|
});
|
|
387
407
|
storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
|
|
408
|
+
|
|
409
|
+
// Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
|
|
410
|
+
// The engine caches graph + resolved-formatting + fragment mapper keyed on
|
|
411
|
+
// (content, styles, subParts). It is the single internal source of truth
|
|
412
|
+
// for page composition, story resolution, and layout invalidation.
|
|
413
|
+
//
|
|
414
|
+
// R0 measurement wiring: the engine starts with the sync empirical backend
|
|
415
|
+
// so the runtime is available immediately, then we kick off
|
|
416
|
+
// `createMeasurementProvider({ preference: "auto", fontLoader })` which
|
|
417
|
+
// upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
|
|
418
|
+
// emits `measurement_backend_ready` so chrome consumers can re-read metrics.
|
|
419
|
+
const layoutEngine: LayoutEngineInstance = createLayoutEngine();
|
|
420
|
+
const fontLoader: DocxFontLoader = createDocxFontLoader(
|
|
421
|
+
collectFontLoaderInput(state.document),
|
|
422
|
+
);
|
|
423
|
+
void upgradeMeasurementProvider(layoutEngine, fontLoader);
|
|
424
|
+
// `renderKernelRef` is a forward reference so the facet can reach the
|
|
425
|
+
// kernel after it is created below (kernel creation needs the facet).
|
|
426
|
+
let renderKernelRef: RenderKernel | null = null;
|
|
427
|
+
const layoutFacet: WordReviewEditorLayoutFacet = createLayoutFacet({
|
|
428
|
+
engine: layoutEngine,
|
|
429
|
+
getQueryInput: () => ({
|
|
430
|
+
document: state.document,
|
|
431
|
+
viewState: {
|
|
432
|
+
activeStory,
|
|
433
|
+
workspaceMode: viewState.workspaceMode,
|
|
434
|
+
zoomLevel: viewState.zoomLevel,
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
renderKernel: () => renderKernelRef,
|
|
438
|
+
getWorkflowRailInput: () => {
|
|
439
|
+
if (!workflowOverlay) return null;
|
|
440
|
+
const activeWorkItemId = workflowOverlay.activeWorkItemId ?? null;
|
|
441
|
+
const activeWorkItem =
|
|
442
|
+
activeWorkItemId !== null
|
|
443
|
+
? workflowOverlay.workItems?.find(
|
|
444
|
+
(item) => item.workItemId === activeWorkItemId,
|
|
445
|
+
)
|
|
446
|
+
: undefined;
|
|
447
|
+
return {
|
|
448
|
+
scopes: workflowOverlay.scopes,
|
|
449
|
+
candidates: workflowOverlay.candidates,
|
|
450
|
+
activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
|
|
451
|
+
activeStory,
|
|
452
|
+
};
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
renderKernelRef = createRenderKernel({
|
|
456
|
+
facet: layoutFacet,
|
|
457
|
+
getActiveStory: () => activeStory,
|
|
458
|
+
});
|
|
388
459
|
let cachedSurface:
|
|
389
460
|
| {
|
|
390
461
|
revisionToken: string;
|
|
@@ -1604,9 +1675,21 @@ export function createDocumentRuntime(
|
|
|
1604
1675
|
},
|
|
1605
1676
|
applyActiveStoryTextCommand(command) {
|
|
1606
1677
|
try {
|
|
1607
|
-
applyTextCommandInActiveStory(command);
|
|
1678
|
+
return applyTextCommandInActiveStory(command);
|
|
1608
1679
|
} catch (error) {
|
|
1609
|
-
|
|
1680
|
+
const runtimeError = toRuntimeError(error);
|
|
1681
|
+
emitError(runtimeError);
|
|
1682
|
+
return {
|
|
1683
|
+
kind: "rejected",
|
|
1684
|
+
opId: (command.origin as { opId?: string } | undefined)?.opId,
|
|
1685
|
+
newRevisionToken: "",
|
|
1686
|
+
blockedReasons: [
|
|
1687
|
+
{
|
|
1688
|
+
code: runtimeError.code ?? "runtime_error",
|
|
1689
|
+
message: runtimeError.message,
|
|
1690
|
+
},
|
|
1691
|
+
],
|
|
1692
|
+
};
|
|
1610
1693
|
}
|
|
1611
1694
|
},
|
|
1612
1695
|
addComment(params) {
|
|
@@ -1813,6 +1896,7 @@ export function createDocumentRuntime(
|
|
|
1813
1896
|
getDocumentNavigationSnapshot() {
|
|
1814
1897
|
return getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
1815
1898
|
},
|
|
1899
|
+
layout: layoutFacet,
|
|
1816
1900
|
getCurrentLocation() {
|
|
1817
1901
|
const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
1818
1902
|
return createCurrentLocation({
|
|
@@ -2272,6 +2356,33 @@ export function createDocumentRuntime(
|
|
|
2272
2356
|
protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
|
|
2273
2357
|
state = finalizeState(transaction.nextState, transaction.markDirty, clock());
|
|
2274
2358
|
storySelections.set(storyTargetKey(activeStory), state.selection);
|
|
2359
|
+
|
|
2360
|
+
// Signal a bounded content-edit invalidation to the layout engine so the
|
|
2361
|
+
// next layout query can splice rather than rebuild the full graph. The
|
|
2362
|
+
// engine analyzes the reason against its cached graph and falls back to
|
|
2363
|
+
// a full rebuild when the edit crosses section boundaries or reaches a
|
|
2364
|
+
// page the engine cannot safely resume from.
|
|
2365
|
+
if (transaction.markDirty && transaction.mapping.steps.length > 0) {
|
|
2366
|
+
let minFrom = Infinity;
|
|
2367
|
+
let maxTo = -Infinity;
|
|
2368
|
+
for (const step of transaction.mapping.steps) {
|
|
2369
|
+
if (step.from < minFrom) minFrom = step.from;
|
|
2370
|
+
const end = step.from + step.insertSize;
|
|
2371
|
+
if (end > maxTo) maxTo = end;
|
|
2372
|
+
}
|
|
2373
|
+
if (minFrom < maxTo) {
|
|
2374
|
+
layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
// Font-loader refresh on subParts identity change — this is the
|
|
2379
|
+
// lightweight proxy for "a change that could affect which fonts the
|
|
2380
|
+
// canvas backend measures against". Typing edits don't rebuild
|
|
2381
|
+
// subParts; style + font + numbering imports do.
|
|
2382
|
+
if (previous.document.subParts !== state.document.subParts) {
|
|
2383
|
+
fontLoader.refresh(collectFontLoaderInput(state.document));
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2275
2386
|
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
2276
2387
|
notify(previous, state, transaction);
|
|
2277
2388
|
}
|
|
@@ -2455,24 +2566,31 @@ export function createDocumentRuntime(
|
|
|
2455
2566
|
selection?: EditorState["selection"];
|
|
2456
2567
|
blockedCommandName?: string;
|
|
2457
2568
|
} = {},
|
|
2458
|
-
):
|
|
2569
|
+
): TextCommandAck {
|
|
2570
|
+
const opId = (command.origin as { opId?: string } | undefined)?.opId;
|
|
2459
2571
|
const selection = options.selection ?? state.selection;
|
|
2460
2572
|
if (
|
|
2461
2573
|
activeStory.kind !== "main" &&
|
|
2462
2574
|
getEffectiveDocumentMode(selection) === "suggesting" &&
|
|
2463
2575
|
command.type === "paragraph.split"
|
|
2464
2576
|
) {
|
|
2577
|
+
const message = `"${command.type}" is not supported in suggesting mode for this story.`;
|
|
2465
2578
|
emit({
|
|
2466
2579
|
type: "command_blocked",
|
|
2467
2580
|
documentId: state.documentId,
|
|
2468
2581
|
command: options.blockedCommandName ?? command.type,
|
|
2469
2582
|
reasons: [{
|
|
2470
2583
|
code: "suggesting_unsupported",
|
|
2471
|
-
message
|
|
2584
|
+
message,
|
|
2472
2585
|
storyTarget: activeStory,
|
|
2473
2586
|
}],
|
|
2474
2587
|
});
|
|
2475
|
-
return
|
|
2588
|
+
return {
|
|
2589
|
+
kind: "rejected",
|
|
2590
|
+
opId,
|
|
2591
|
+
newRevisionToken: "",
|
|
2592
|
+
blockedReasons: [{ code: "suggesting_unsupported", message }],
|
|
2593
|
+
};
|
|
2476
2594
|
}
|
|
2477
2595
|
const blockedReasons = evaluateWorkflowBlockedReasons(selection, command.type);
|
|
2478
2596
|
if (blockedReasons.length > 0) {
|
|
@@ -2482,7 +2600,12 @@ export function createDocumentRuntime(
|
|
|
2482
2600
|
command: options.blockedCommandName ?? command.type,
|
|
2483
2601
|
reasons: blockedReasons,
|
|
2484
2602
|
});
|
|
2485
|
-
return
|
|
2603
|
+
return {
|
|
2604
|
+
kind: "rejected",
|
|
2605
|
+
opId,
|
|
2606
|
+
newRevisionToken: "",
|
|
2607
|
+
blockedReasons: blockedReasons.map((r) => ({ code: r.code, message: r.message })),
|
|
2608
|
+
};
|
|
2486
2609
|
}
|
|
2487
2610
|
|
|
2488
2611
|
const timestamp = command.origin?.timestamp ?? clock();
|
|
@@ -2499,8 +2622,15 @@ export function createDocumentRuntime(
|
|
|
2499
2622
|
};
|
|
2500
2623
|
|
|
2501
2624
|
if (activeStory.kind === "main") {
|
|
2502
|
-
|
|
2503
|
-
|
|
2625
|
+
const mainTransaction = executeEditorCommand(baseState, command, context);
|
|
2626
|
+
commit(mainTransaction);
|
|
2627
|
+
return classifyAck({
|
|
2628
|
+
command,
|
|
2629
|
+
opId,
|
|
2630
|
+
priorState: baseState,
|
|
2631
|
+
transaction: mainTransaction,
|
|
2632
|
+
newRevisionToken: state.revisionToken,
|
|
2633
|
+
});
|
|
2504
2634
|
}
|
|
2505
2635
|
|
|
2506
2636
|
const localState = createEditorState({
|
|
@@ -2531,7 +2661,11 @@ export function createDocumentRuntime(
|
|
|
2531
2661
|
historyBoundary: "skip",
|
|
2532
2662
|
markDirty: false,
|
|
2533
2663
|
});
|
|
2534
|
-
return
|
|
2664
|
+
return {
|
|
2665
|
+
kind: "equivalent",
|
|
2666
|
+
opId,
|
|
2667
|
+
newRevisionToken: state.revisionToken,
|
|
2668
|
+
};
|
|
2535
2669
|
}
|
|
2536
2670
|
|
|
2537
2671
|
const nextDocument = replaceStoryBlocks(
|
|
@@ -2561,12 +2695,87 @@ export function createDocumentRuntime(
|
|
|
2561
2695
|
context,
|
|
2562
2696
|
);
|
|
2563
2697
|
|
|
2564
|
-
|
|
2698
|
+
const mergedTransaction: EditorTransaction = {
|
|
2565
2699
|
...fullTransaction,
|
|
2566
2700
|
effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
|
|
2701
|
+
};
|
|
2702
|
+
commit(mergedTransaction);
|
|
2703
|
+
return classifyAck({
|
|
2704
|
+
command,
|
|
2705
|
+
opId,
|
|
2706
|
+
priorState: baseState,
|
|
2707
|
+
transaction: mergedTransaction,
|
|
2708
|
+
newRevisionToken: state.revisionToken,
|
|
2567
2709
|
});
|
|
2568
2710
|
}
|
|
2569
2711
|
|
|
2712
|
+
function classifyAck(params: {
|
|
2713
|
+
command: ActiveStoryTextCommand;
|
|
2714
|
+
opId: string | undefined;
|
|
2715
|
+
priorState: EditorState;
|
|
2716
|
+
transaction: EditorTransaction;
|
|
2717
|
+
newRevisionToken: string;
|
|
2718
|
+
}): TextCommandAck {
|
|
2719
|
+
const { opId, priorState, transaction, newRevisionToken } = params;
|
|
2720
|
+
const meta = transaction.mapping.metadata ?? {};
|
|
2721
|
+
const touches: readonly ScopeTagTouch[] =
|
|
2722
|
+
(meta.scopeTagTouches as readonly ScopeTagTouch[] | undefined) ?? [];
|
|
2723
|
+
|
|
2724
|
+
if (meta.invalidatesStructures) {
|
|
2725
|
+
return {
|
|
2726
|
+
kind: "structural-divergence",
|
|
2727
|
+
opId,
|
|
2728
|
+
newRevisionToken,
|
|
2729
|
+
scopeTagTouches: touches,
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// A real touch means the runtime actually changed a tag anchor — not
|
|
2734
|
+
// merely "a text edit happened and might conceivably have touched one".
|
|
2735
|
+
// The coarse `affectsComments` / `affectsRevisions` flags today are set
|
|
2736
|
+
// unconditionally by the text-transaction pipeline, so we cannot trust
|
|
2737
|
+
// them to distinguish equivalent from adjusted. `scopeTagTouches` is the
|
|
2738
|
+
// fine-grained truth that the predicted lane needs.
|
|
2739
|
+
const touchedForAdjusted = touches.some(
|
|
2740
|
+
(t) =>
|
|
2741
|
+
t.behavior === "extended" ||
|
|
2742
|
+
t.behavior === "trimmed" ||
|
|
2743
|
+
t.behavior === "split" ||
|
|
2744
|
+
t.behavior === "detached",
|
|
2745
|
+
);
|
|
2746
|
+
|
|
2747
|
+
if (touchedForAdjusted) {
|
|
2748
|
+
const adjustedRange = computeAdjustedRange(priorState, transaction);
|
|
2749
|
+
return {
|
|
2750
|
+
kind: "adjusted",
|
|
2751
|
+
opId,
|
|
2752
|
+
newRevisionToken,
|
|
2753
|
+
adjustedRange,
|
|
2754
|
+
scopeTagTouches: touches,
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
return {
|
|
2759
|
+
kind: "equivalent",
|
|
2760
|
+
opId,
|
|
2761
|
+
newRevisionToken,
|
|
2762
|
+
scopeTagTouches: touches,
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
function computeAdjustedRange(
|
|
2767
|
+
prior: EditorState,
|
|
2768
|
+
transaction: EditorTransaction,
|
|
2769
|
+
): { fromRuntime: number; toRuntime: number } {
|
|
2770
|
+
return computeAdjustedRangeImpl(
|
|
2771
|
+
{ from: prior.selection.anchor, to: prior.selection.head },
|
|
2772
|
+
transaction.mapping.steps.map((step) => ({
|
|
2773
|
+
from: step.from,
|
|
2774
|
+
insertSize: step.insertSize,
|
|
2775
|
+
})),
|
|
2776
|
+
);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2570
2779
|
function mergeTransactionEffects(
|
|
2571
2780
|
base: EditorTransaction["effects"],
|
|
2572
2781
|
local: EditorTransaction["effects"],
|
|
@@ -4228,3 +4437,70 @@ function remapProtectionSnapshot(
|
|
|
4228
4437
|
preservedRangeCount: nextRanges.filter((range) => !range.enforced).length,
|
|
4229
4438
|
};
|
|
4230
4439
|
}
|
|
4440
|
+
|
|
4441
|
+
// ---------------------------------------------------------------------------
|
|
4442
|
+
// Measurement provider wiring (R0)
|
|
4443
|
+
// ---------------------------------------------------------------------------
|
|
4444
|
+
|
|
4445
|
+
/**
|
|
4446
|
+
* Build the initial input the `DocxFontLoader` needs: a list of font
|
|
4447
|
+
* families the document actively uses, plus any embedded font payloads the
|
|
4448
|
+
* import pipeline may have extracted.
|
|
4449
|
+
*
|
|
4450
|
+
* Walks the document content tree once per call. Embedded font extraction
|
|
4451
|
+
* is not yet wired into the canonical model; we pass an empty map today and
|
|
4452
|
+
* let the loader register system fonts it finds via
|
|
4453
|
+
* `document.fonts.check(...)`.
|
|
4454
|
+
*/
|
|
4455
|
+
function collectFontLoaderInput(
|
|
4456
|
+
document: CanonicalDocumentEnvelope,
|
|
4457
|
+
): { families: readonly string[] } {
|
|
4458
|
+
try {
|
|
4459
|
+
const families = new Set<string>();
|
|
4460
|
+
const visit = (node: unknown): void => {
|
|
4461
|
+
if (!node || typeof node !== "object") return;
|
|
4462
|
+
const record = node as Record<string, unknown>;
|
|
4463
|
+
const rpr = record["runProperties"] as
|
|
4464
|
+
| Record<string, unknown>
|
|
4465
|
+
| undefined;
|
|
4466
|
+
if (rpr && typeof rpr["fontFamily"] === "string") {
|
|
4467
|
+
families.add(rpr["fontFamily"] as string);
|
|
4468
|
+
}
|
|
4469
|
+
for (const value of Object.values(record)) {
|
|
4470
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
4471
|
+
else if (value && typeof value === "object") visit(value);
|
|
4472
|
+
}
|
|
4473
|
+
};
|
|
4474
|
+
visit(document.content);
|
|
4475
|
+
if (document.styles) {
|
|
4476
|
+
visit(document.styles);
|
|
4477
|
+
}
|
|
4478
|
+
return { families: Array.from(families) };
|
|
4479
|
+
} catch {
|
|
4480
|
+
return { families: [] };
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
/**
|
|
4485
|
+
* Asynchronously upgrade the engine's measurement backend to canvas once
|
|
4486
|
+
* the platform supports it and fonts have resolved. Errors are swallowed
|
|
4487
|
+
* so a failure in the upgrade path can never break the empirical baseline.
|
|
4488
|
+
*/
|
|
4489
|
+
async function upgradeMeasurementProvider(
|
|
4490
|
+
engine: LayoutEngineInstance,
|
|
4491
|
+
fontLoader: DocxFontLoader,
|
|
4492
|
+
): Promise<void> {
|
|
4493
|
+
try {
|
|
4494
|
+
const provider: LayoutMeasurementProvider = await createMeasurementProvider({
|
|
4495
|
+
preference: "auto",
|
|
4496
|
+
fontLoader,
|
|
4497
|
+
});
|
|
4498
|
+
// If the host is running in SSR or a jsdom test shell, the factory will
|
|
4499
|
+
// fall back to the empirical backend. In that case swapping is a no-op
|
|
4500
|
+
// but still emits `measurement_backend_ready` with `empirical` which is
|
|
4501
|
+
// informational; chrome consumers use the event to refresh metrics.
|
|
4502
|
+
engine.swapMeasurementProvider(provider);
|
|
4503
|
+
} catch {
|
|
4504
|
+
// fall through — the empirical backend remains in place
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware default page format.
|
|
3
|
+
*
|
|
4
|
+
* Word historically defaults to US Letter for `en-US` hosts and A4 everywhere
|
|
5
|
+
* else. This module is the single place that decides which format a
|
|
6
|
+
* newly-created document uses when no section carries an explicit `w:pgSz`.
|
|
7
|
+
*
|
|
8
|
+
* It never overrides an existing section's page size on import — importers
|
|
9
|
+
* always preserve what the source document specified. The default is
|
|
10
|
+
* consulted in two places:
|
|
11
|
+
*
|
|
12
|
+
* 1. `serialize-main-document.ts` when the canonical model carries a
|
|
13
|
+
* section with no `pageSize` (e.g. programmatic document construction).
|
|
14
|
+
* 2. `DocumentRuntime` when rendering a brand-new blank document.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
getPageFormatById,
|
|
19
|
+
type PageFormatDefinition,
|
|
20
|
+
} from "./page-format-catalog.ts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Locale resolution
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which BCP-47 language tags should fall back to Letter.
|
|
28
|
+
*
|
|
29
|
+
* Anything else defaults to A4. This intentionally uses a small whitelist
|
|
30
|
+
* rather than a `startsWith("en")` check — `en-GB`, `en-AU`, `en-IN` all
|
|
31
|
+
* expect ISO paper sizes, not US Letter.
|
|
32
|
+
*/
|
|
33
|
+
const LETTER_LOCALES = new Set<string>([
|
|
34
|
+
"en-us",
|
|
35
|
+
"en-ca",
|
|
36
|
+
"fr-ca",
|
|
37
|
+
"es-mx",
|
|
38
|
+
"es-cl",
|
|
39
|
+
"fil-ph",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export interface ResolveDefaultPageFormatOptions {
|
|
43
|
+
/** Explicit BCP-47 locale (e.g. "en-US", "de-DE"). */
|
|
44
|
+
locale?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return the default `PageFormatDefinition` for a given locale.
|
|
49
|
+
*
|
|
50
|
+
* When `locale` is omitted the function consults `Intl.DateTimeFormat` to
|
|
51
|
+
* infer the current locale. When `Intl` is unavailable (unusual in modern
|
|
52
|
+
* runtimes) it falls back to A4 as the safer international default.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveDefaultPageFormat(
|
|
55
|
+
options: ResolveDefaultPageFormatOptions = {},
|
|
56
|
+
): PageFormatDefinition {
|
|
57
|
+
const raw = options.locale ?? tryResolveHostLocale();
|
|
58
|
+
if (!raw) {
|
|
59
|
+
return getPageFormatById("a4");
|
|
60
|
+
}
|
|
61
|
+
const normalized = raw.toLowerCase();
|
|
62
|
+
if (LETTER_LOCALES.has(normalized)) {
|
|
63
|
+
return getPageFormatById("letter");
|
|
64
|
+
}
|
|
65
|
+
// Match just the language-region prefix (e.g. "en-us-1234" → "en-us")
|
|
66
|
+
const region = normalized.split("-").slice(0, 2).join("-");
|
|
67
|
+
if (LETTER_LOCALES.has(region)) {
|
|
68
|
+
return getPageFormatById("letter");
|
|
69
|
+
}
|
|
70
|
+
return getPageFormatById("a4");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tryResolveHostLocale(): string | undefined {
|
|
74
|
+
try {
|
|
75
|
+
if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function") {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Return the default `w:pgSz` payload (width/height in twips) for a given
|
|
86
|
+
* locale. Convenience wrapper used by the export pipeline.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveDefaultPageSizeTwips(
|
|
89
|
+
options: ResolveDefaultPageFormatOptions = {},
|
|
90
|
+
): { widthTwips: number; heightTwips: number } {
|
|
91
|
+
const format = resolveDefaultPageFormat(options);
|
|
92
|
+
return {
|
|
93
|
+
widthTwips: format.portraitWidthTwips,
|
|
94
|
+
heightTwips: format.portraitHeightTwips,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocxFontLoader — best-effort registration of the document's declared font
|
|
3
|
+
* families with the browser's FontFace registry.
|
|
4
|
+
*
|
|
5
|
+
* Scope:
|
|
6
|
+
* - For each font family the document uses, resolve whether the browser
|
|
7
|
+
* can already render it.
|
|
8
|
+
* - If the package ships embedded font binary data, register each face via
|
|
9
|
+
* `new FontFace(family, data).load()` then `document.fonts.add(face)`.
|
|
10
|
+
* - Wait on `document.fonts.ready` to know when layout-affecting fonts are
|
|
11
|
+
* available, so the Canvas backend is measuring against real metrics.
|
|
12
|
+
*
|
|
13
|
+
* Non-goals:
|
|
14
|
+
* - This loader does not attempt to fetch fonts from external CDNs.
|
|
15
|
+
* - It does not attempt style-matching with Panose; that belongs in a
|
|
16
|
+
* font-substitution pass if we ever need it.
|
|
17
|
+
* - SSR and gRPC never run this loader.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface FontLoaderInput {
|
|
21
|
+
/** Font family names the document actively uses. */
|
|
22
|
+
families: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Optional embedded font payloads keyed by family name (uppercase-insensitive).
|
|
25
|
+
* Each entry holds binary data for regular / bold / italic / bold-italic
|
|
26
|
+
* variants. Callers may omit any variant; the loader will register only
|
|
27
|
+
* what is provided.
|
|
28
|
+
*/
|
|
29
|
+
embeddedFontBytes?: Map<string, EmbeddedFontBytes>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EmbeddedFontBytes {
|
|
33
|
+
regular?: ArrayBuffer;
|
|
34
|
+
bold?: ArrayBuffer;
|
|
35
|
+
italic?: ArrayBuffer;
|
|
36
|
+
boldItalic?: ArrayBuffer;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DocxFontLoader {
|
|
40
|
+
whenReady(): Promise<void>;
|
|
41
|
+
isSupported(): boolean;
|
|
42
|
+
/** Which families are currently registered or detected as available. */
|
|
43
|
+
getRegisteredFamilies(): readonly string[];
|
|
44
|
+
/** Force re-resolution of the ready promise (e.g. after adding more fonts). */
|
|
45
|
+
refresh(input: FontLoaderInput): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
|
|
49
|
+
const supported =
|
|
50
|
+
typeof document !== "undefined" &&
|
|
51
|
+
typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
|
|
52
|
+
// Guard against jsdom which exposes FontFace but not document.fonts
|
|
53
|
+
Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
|
|
54
|
+
|
|
55
|
+
let current: FontLoaderInput = initial;
|
|
56
|
+
let readyPromise: Promise<void>;
|
|
57
|
+
const registered = new Set<string>();
|
|
58
|
+
|
|
59
|
+
function run(input: FontLoaderInput): Promise<void> {
|
|
60
|
+
if (!supported) return Promise.resolve();
|
|
61
|
+
const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
|
|
62
|
+
if (!fontSet) return Promise.resolve();
|
|
63
|
+
|
|
64
|
+
const pending: Array<Promise<unknown>> = [];
|
|
65
|
+
|
|
66
|
+
if (input.embeddedFontBytes) {
|
|
67
|
+
for (const [familyRaw, variants] of input.embeddedFontBytes) {
|
|
68
|
+
const family = familyRaw.trim();
|
|
69
|
+
if (!family) continue;
|
|
70
|
+
|
|
71
|
+
for (const [descriptor, data] of variantsOf(variants)) {
|
|
72
|
+
try {
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
+
const FontFaceCtor = (globalThis as any).FontFace as {
|
|
75
|
+
new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
|
|
76
|
+
};
|
|
77
|
+
const face = new FontFaceCtor(family, data, descriptor);
|
|
78
|
+
pending.push(
|
|
79
|
+
face.load().then((loaded) => {
|
|
80
|
+
fontSet.add(loaded);
|
|
81
|
+
registered.add(family);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
// Single-face failures should not fail the whole batch.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Mark declared families as registered if the browser already resolves
|
|
92
|
+
// them (e.g. system fonts like Calibri, Arial).
|
|
93
|
+
for (const family of input.families) {
|
|
94
|
+
try {
|
|
95
|
+
const probe = `12px "${family.replace(/"/g, "'")}", serif`;
|
|
96
|
+
if (fontSet.check(probe)) {
|
|
97
|
+
registered.add(family);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Promise.all(pending)
|
|
105
|
+
.then(() => fontSet.ready)
|
|
106
|
+
.then(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
readyPromise = run(current);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
whenReady() {
|
|
113
|
+
return readyPromise;
|
|
114
|
+
},
|
|
115
|
+
isSupported() {
|
|
116
|
+
return supported;
|
|
117
|
+
},
|
|
118
|
+
getRegisteredFamilies() {
|
|
119
|
+
return Array.from(registered);
|
|
120
|
+
},
|
|
121
|
+
refresh(input: FontLoaderInput) {
|
|
122
|
+
current = input;
|
|
123
|
+
readyPromise = run(current);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function* variantsOf(
|
|
129
|
+
variants: EmbeddedFontBytes,
|
|
130
|
+
): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
|
|
131
|
+
if (variants.regular) {
|
|
132
|
+
yield [{ weight: "400", style: "normal" }, variants.regular];
|
|
133
|
+
}
|
|
134
|
+
if (variants.bold) {
|
|
135
|
+
yield [{ weight: "700", style: "normal" }, variants.bold];
|
|
136
|
+
}
|
|
137
|
+
if (variants.italic) {
|
|
138
|
+
yield [{ weight: "400", style: "italic" }, variants.italic];
|
|
139
|
+
}
|
|
140
|
+
if (variants.boldItalic) {
|
|
141
|
+
yield [{ weight: "700", style: "italic" }, variants.boldItalic];
|
|
142
|
+
}
|
|
143
|
+
}
|