@beyondwork/docx-react-component 1.0.47 → 1.0.48
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 +1 -1
- package/src/api/public-types.ts +115 -1
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/state/text-transaction.ts +17 -5
- package/src/io/chart-preview-resolver.ts +27 -0
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +37 -0
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
- package/src/io/ooxml/chart/parse-series.ts +570 -0
- package/src/io/ooxml/chart/resolve-color.ts +251 -0
- package/src/io/ooxml/chart/types.ts +420 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +87 -2
- package/src/io/ooxml/parse-main-document.ts +115 -1
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +94 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +279 -0
- package/src/runtime/document-runtime.ts +214 -16
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +8 -1
- package/src/ui/WordReviewEditor.tsx +30 -0
- package/src/ui/editor-runtime-boundary.ts +6 -1
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
|
@@ -16,6 +16,8 @@ import type {
|
|
|
16
16
|
AddCommentParams,
|
|
17
17
|
AddCommentReplyResult,
|
|
18
18
|
AddCommentResult,
|
|
19
|
+
AddScopeParams,
|
|
20
|
+
AddScopeResult,
|
|
19
21
|
CommentSidebarSnapshot,
|
|
20
22
|
CommentSidebarThreadSnapshot,
|
|
21
23
|
CompatibilityReport,
|
|
@@ -74,6 +76,7 @@ import type {
|
|
|
74
76
|
WorkflowMetadataSnapshot,
|
|
75
77
|
WorkflowMarkupSnapshot,
|
|
76
78
|
WorkflowOverlay,
|
|
79
|
+
WorkflowScope,
|
|
77
80
|
WorkflowScopeSnapshot,
|
|
78
81
|
WorkspaceMode,
|
|
79
82
|
WordReviewEditorEvent,
|
|
@@ -116,6 +119,11 @@ import {
|
|
|
116
119
|
} from "../review/store/revision-store.ts";
|
|
117
120
|
import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
|
|
118
121
|
import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
|
|
122
|
+
import { resolveScope } from "./scope-resolver.ts";
|
|
123
|
+
import {
|
|
124
|
+
insertScopeMarkers,
|
|
125
|
+
removeScopeMarkers,
|
|
126
|
+
} from "../core/commands/add-scope.ts";
|
|
119
127
|
import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
|
|
120
128
|
import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
|
|
121
129
|
import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
|
|
@@ -291,6 +299,9 @@ export interface DocumentRuntime {
|
|
|
291
299
|
reopenComment(commentId: string): void;
|
|
292
300
|
addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
|
|
293
301
|
editCommentBody(commentId: string, body: string): void;
|
|
302
|
+
addScope(params: AddScopeParams): AddScopeResult;
|
|
303
|
+
getScope(scopeId: string): WorkflowScope | null;
|
|
304
|
+
removeScope(scopeId: string): void;
|
|
294
305
|
acceptChange(changeId: string): void;
|
|
295
306
|
rejectChange(changeId: string): void;
|
|
296
307
|
acceptAllChanges(): void;
|
|
@@ -2035,6 +2046,16 @@ export function createDocumentRuntime(
|
|
|
2035
2046
|
}
|
|
2036
2047
|
});
|
|
2037
2048
|
|
|
2049
|
+
// R5 scratch snapshot: single pre-allocated object reused for every
|
|
2050
|
+
// `applyRemoteCommand(cmd, ctx, meta)` call with a `meta.preSelection`
|
|
2051
|
+
// override. Avoids the per-remote-command `{ ...cachedRenderSnapshot }`
|
|
2052
|
+
// + `{ ...state }` allocations that CLAUDE.md rule 4 warns against.
|
|
2053
|
+
// Mutated in place below — callers MUST NOT hold references to it
|
|
2054
|
+
// across dispatches; `executeEditorCommand` + `commitRemote` consume
|
|
2055
|
+
// synchronously within `applyRemoteCommand`, so this is safe today.
|
|
2056
|
+
const r5ScratchReplayState: typeof state = { ...state };
|
|
2057
|
+
const r5ScratchReplaySnapshot: typeof cachedRenderSnapshot = { ...cachedRenderSnapshot };
|
|
2058
|
+
|
|
2038
2059
|
return {
|
|
2039
2060
|
subscribe(listener) {
|
|
2040
2061
|
listeners.add(listener);
|
|
@@ -2147,28 +2168,56 @@ export function createDocumentRuntime(
|
|
|
2147
2168
|
applyRuntimeStateOverlayCommand(command);
|
|
2148
2169
|
return;
|
|
2149
2170
|
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2171
|
+
// Story-target isolation: the remote's `meta.activeStory` scopes
|
|
2172
|
+
// the replay (so the command lands in the intended region of the
|
|
2173
|
+
// shared document), but must NEVER overwrite the local user's
|
|
2174
|
+
// closure-level `activeStory`. Before P11 this assignment stole
|
|
2175
|
+
// focus every time a remote event arrived for a different story —
|
|
2176
|
+
// a local user authoring in a header would get yanked into the
|
|
2177
|
+
// main body as soon as a peer edited main.
|
|
2178
|
+
const replayStory = meta?.activeStory ?? activeStory;
|
|
2179
|
+
const crossStoryReplay = Boolean(
|
|
2180
|
+
meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory),
|
|
2181
|
+
);
|
|
2182
|
+
let replayState: typeof state;
|
|
2183
|
+
let replaySnapshot: typeof cachedRenderSnapshot;
|
|
2184
|
+
if (meta?.preSelection) {
|
|
2185
|
+
// Refresh scratch with current `state` / `cachedRenderSnapshot` (both
|
|
2186
|
+
// mutate on every commit), then override only the selection fields.
|
|
2187
|
+
Object.assign(r5ScratchReplayState, state);
|
|
2188
|
+
r5ScratchReplayState.selection = meta.preSelection;
|
|
2189
|
+
Object.assign(r5ScratchReplaySnapshot, cachedRenderSnapshot);
|
|
2190
|
+
r5ScratchReplaySnapshot.selection = toPublicSelectionSnapshot(meta.preSelection, replayStory);
|
|
2191
|
+
replayState = r5ScratchReplayState;
|
|
2192
|
+
replaySnapshot = r5ScratchReplaySnapshot;
|
|
2193
|
+
} else {
|
|
2194
|
+
replayState = state;
|
|
2195
|
+
replaySnapshot = cachedRenderSnapshot;
|
|
2156
2196
|
}
|
|
2157
|
-
const replayState = meta?.preSelection
|
|
2158
|
-
? { ...state, selection: meta.preSelection }
|
|
2159
|
-
: state;
|
|
2160
|
-
const replaySnapshot = meta?.preSelection
|
|
2161
|
-
? {
|
|
2162
|
-
...cachedRenderSnapshot,
|
|
2163
|
-
selection: toPublicSelectionSnapshot(meta.preSelection, activeStory),
|
|
2164
|
-
}
|
|
2165
|
-
: cachedRenderSnapshot;
|
|
2166
2197
|
const replayContext = {
|
|
2167
2198
|
...context,
|
|
2168
2199
|
renderSnapshot: replaySnapshot,
|
|
2169
2200
|
};
|
|
2170
2201
|
const transaction = executeEditorCommand(replayState, command, replayContext);
|
|
2171
|
-
|
|
2202
|
+
if (crossStoryReplay) {
|
|
2203
|
+
// Cross-story replay: the transaction's resulting selection is
|
|
2204
|
+
// in the remote story's region. Don't leak that into local
|
|
2205
|
+
// `state.selection` — preserve the pre-replay local selection
|
|
2206
|
+
// so the local caret stays where the user is focused. The
|
|
2207
|
+
// document delta still applies; only the selection is filtered.
|
|
2208
|
+
// Full position-mapping of the local cursor through the remote
|
|
2209
|
+
// edit is a separate P11 sub-bullet (Awareness cursor transaction
|
|
2210
|
+
// mapping).
|
|
2211
|
+
commitRemote({
|
|
2212
|
+
...transaction,
|
|
2213
|
+
nextState: {
|
|
2214
|
+
...transaction.nextState,
|
|
2215
|
+
selection: state.selection,
|
|
2216
|
+
},
|
|
2217
|
+
});
|
|
2218
|
+
} else {
|
|
2219
|
+
commitRemote(transaction);
|
|
2220
|
+
}
|
|
2172
2221
|
} catch (error) {
|
|
2173
2222
|
emitError(toRuntimeError(error));
|
|
2174
2223
|
}
|
|
@@ -2362,6 +2411,155 @@ export function createDocumentRuntime(
|
|
|
2362
2411
|
origin: createOrigin("api", clock()),
|
|
2363
2412
|
});
|
|
2364
2413
|
},
|
|
2414
|
+
addScope(params): AddScopeResult {
|
|
2415
|
+
const scopeId =
|
|
2416
|
+
params.scopeId ??
|
|
2417
|
+
`scope-${clock().replace(/[^0-9]/gu, "")}-${Math.floor(Math.random() * 1e6)}`;
|
|
2418
|
+
const anchor =
|
|
2419
|
+
params.anchor.kind === "range"
|
|
2420
|
+
? { from: params.anchor.from, to: params.anchor.to }
|
|
2421
|
+
: null;
|
|
2422
|
+
|
|
2423
|
+
if (!anchor) {
|
|
2424
|
+
return {
|
|
2425
|
+
scopeId,
|
|
2426
|
+
anchor: params.anchor,
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
const { document: nextDocument } = insertScopeMarkers(state.document, {
|
|
2431
|
+
scopeId,
|
|
2432
|
+
from: anchor.from,
|
|
2433
|
+
to: anchor.to,
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
if (nextDocument !== state.document) {
|
|
2437
|
+
this.dispatch({
|
|
2438
|
+
type: "document.replace",
|
|
2439
|
+
document: nextDocument,
|
|
2440
|
+
origin: createOrigin("api", clock()),
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const resolved = resolveScope(state.document, scopeId);
|
|
2445
|
+
const publicAnchor: EditorAnchorProjection =
|
|
2446
|
+
resolved && resolved.kind === "range"
|
|
2447
|
+
? resolved
|
|
2448
|
+
: {
|
|
2449
|
+
kind: "range",
|
|
2450
|
+
from: anchor.from,
|
|
2451
|
+
to: anchor.to,
|
|
2452
|
+
assoc: { start: -1, end: 1 },
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
const currentOverlay: WorkflowOverlay = workflowOverlay ?? {
|
|
2456
|
+
overlayVersion: "workflow-overlay/1",
|
|
2457
|
+
scopes: [],
|
|
2458
|
+
};
|
|
2459
|
+
const existingScopes = currentOverlay.scopes.filter(
|
|
2460
|
+
(existing) => existing.scopeId !== scopeId,
|
|
2461
|
+
);
|
|
2462
|
+
const scope: WorkflowScope = {
|
|
2463
|
+
scopeId,
|
|
2464
|
+
mode: params.mode ?? "comment",
|
|
2465
|
+
anchor: publicAnchor,
|
|
2466
|
+
...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
|
|
2467
|
+
...(params.label ? { label: params.label } : {}),
|
|
2468
|
+
};
|
|
2469
|
+
this.dispatch({
|
|
2470
|
+
type: "workflow.set-overlay",
|
|
2471
|
+
overlay: {
|
|
2472
|
+
...currentOverlay,
|
|
2473
|
+
scopes: [...existingScopes, scope],
|
|
2474
|
+
},
|
|
2475
|
+
origin: createOrigin("api", clock()),
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
if (params.persistence && params.persistence !== "runtime-only") {
|
|
2479
|
+
const entry: WorkflowMetadataEntry = {
|
|
2480
|
+
entryId: `scope-metadata-${scopeId}`,
|
|
2481
|
+
metadataId: "workflow.scope",
|
|
2482
|
+
anchor: publicAnchor,
|
|
2483
|
+
scopeId,
|
|
2484
|
+
value:
|
|
2485
|
+
params.persistence === "document-metadata"
|
|
2486
|
+
? { ...(params.metadata?.value ?? {}), label: params.label }
|
|
2487
|
+
: params.metadata?.value,
|
|
2488
|
+
metadataPersistence:
|
|
2489
|
+
params.persistence === "session" ? "external" : "internal",
|
|
2490
|
+
};
|
|
2491
|
+
this.dispatch({
|
|
2492
|
+
type: "workflow.set-metadata-entries",
|
|
2493
|
+
entries: [...(workflowMetadataEntries ?? []), entry],
|
|
2494
|
+
origin: createOrigin("api", clock()),
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
return {
|
|
2499
|
+
scopeId,
|
|
2500
|
+
anchor: publicAnchor,
|
|
2501
|
+
};
|
|
2502
|
+
},
|
|
2503
|
+
getScope(scopeId) {
|
|
2504
|
+
const resolved = resolveScope(state.document, scopeId);
|
|
2505
|
+
if (!resolved) {
|
|
2506
|
+
const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
|
|
2507
|
+
return stored ?? null;
|
|
2508
|
+
}
|
|
2509
|
+
const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
|
|
2510
|
+
if (!stored) {
|
|
2511
|
+
return {
|
|
2512
|
+
scopeId,
|
|
2513
|
+
mode: "comment",
|
|
2514
|
+
anchor: resolved,
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
return {
|
|
2518
|
+
...stored,
|
|
2519
|
+
anchor: resolved,
|
|
2520
|
+
};
|
|
2521
|
+
},
|
|
2522
|
+
removeScope(scopeId) {
|
|
2523
|
+
// Step 1: drop the scope from the overlay FIRST. If the scope's mode was
|
|
2524
|
+
// "comment" / "view" the workflow-blocked-reasons gate in `dispatch`
|
|
2525
|
+
// would otherwise refuse the subsequent `document.replace` with
|
|
2526
|
+
// `workflow_comment_only` / `workflow_view_only`. Overlay commands are
|
|
2527
|
+
// routed through `applyRuntimeStateOverlayCommand` and bypass that gate.
|
|
2528
|
+
if (workflowOverlay) {
|
|
2529
|
+
const nextScopes = workflowOverlay.scopes.filter(
|
|
2530
|
+
(scope) => scope.scopeId !== scopeId,
|
|
2531
|
+
);
|
|
2532
|
+
if (nextScopes.length !== workflowOverlay.scopes.length) {
|
|
2533
|
+
this.dispatch({
|
|
2534
|
+
type: "workflow.set-overlay",
|
|
2535
|
+
overlay: { ...workflowOverlay, scopes: nextScopes },
|
|
2536
|
+
origin: createOrigin("api", clock()),
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
// Step 2: now that the scope is gone, strip the markers from the doc.
|
|
2541
|
+
const nextDocument = removeScopeMarkers(state.document, scopeId);
|
|
2542
|
+
if (nextDocument !== state.document) {
|
|
2543
|
+
this.dispatch({
|
|
2544
|
+
type: "document.replace",
|
|
2545
|
+
document: nextDocument,
|
|
2546
|
+
origin: createOrigin("api", clock()),
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
// Step 3: clear any customXml-persisted metadata entries.
|
|
2550
|
+
if (workflowMetadataEntries) {
|
|
2551
|
+
const nextEntries = workflowMetadataEntries.filter(
|
|
2552
|
+
(entry) => entry.scopeId !== scopeId,
|
|
2553
|
+
);
|
|
2554
|
+
if (nextEntries.length !== workflowMetadataEntries.length) {
|
|
2555
|
+
this.dispatch({
|
|
2556
|
+
type: "workflow.set-metadata-entries",
|
|
2557
|
+
entries: nextEntries,
|
|
2558
|
+
origin: createOrigin("api", clock()),
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2365
2563
|
acceptChange(changeId) {
|
|
2366
2564
|
this.dispatch({
|
|
2367
2565
|
type: "change.accept",
|
|
@@ -45,7 +45,8 @@ export type CapabilityCategory =
|
|
|
45
45
|
| "tracked-changes"
|
|
46
46
|
| "comments"
|
|
47
47
|
| "structure"
|
|
48
|
-
| "system"
|
|
48
|
+
| "system"
|
|
49
|
+
| "workflow";
|
|
49
50
|
|
|
50
51
|
export interface CapabilityShortcut {
|
|
51
52
|
/** Canonical Windows / Linux binding string (e.g. "Ctrl+B", "Shift+Tab"). */
|
|
@@ -301,87 +302,99 @@ export const EDITOR_CAPABILITIES: readonly EditorCapability[] = [
|
|
|
301
302
|
shortcut: { winLinux: "Ctrl+0", mac: "Cmd+0" },
|
|
302
303
|
hostEvent: "onZoomRequested",
|
|
303
304
|
},
|
|
304
|
-
|
|
305
|
-
// ---------------------------------------------------------------
|
|
306
|
-
// Blocked — Word shortcuts the mounted editor does not implement
|
|
307
|
-
// ---------------------------------------------------------------
|
|
308
305
|
{
|
|
309
|
-
id: "
|
|
310
|
-
kind: "
|
|
306
|
+
id: "shortcut.replace",
|
|
307
|
+
kind: "host-delegated",
|
|
311
308
|
category: "navigation",
|
|
312
309
|
label: "Find and replace",
|
|
313
310
|
shortcut: { winLinux: "Ctrl+H", mac: "Ctrl+H" },
|
|
314
|
-
|
|
315
|
-
code: UNSUPPORTED_SURFACE,
|
|
316
|
-
message: "Replace shortcuts are not supported in the mounted editor yet.",
|
|
317
|
-
},
|
|
311
|
+
hostEvent: "onReplaceRequested",
|
|
318
312
|
},
|
|
319
313
|
{
|
|
320
|
-
id: "
|
|
321
|
-
kind: "
|
|
314
|
+
id: "shortcut.go-to",
|
|
315
|
+
kind: "host-delegated",
|
|
322
316
|
category: "navigation",
|
|
323
|
-
label: "Go to",
|
|
317
|
+
label: "Go to page / bookmark / line",
|
|
324
318
|
shortcut: { winLinux: "Ctrl+G", mac: "Cmd+Option+G" },
|
|
325
|
-
|
|
326
|
-
code: UNSUPPORTED_SURFACE,
|
|
327
|
-
message: "Go To shortcuts are not supported in the mounted editor yet.",
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
{
|
|
331
|
-
id: "toggleTrackChanges",
|
|
332
|
-
kind: "blocked",
|
|
333
|
-
category: "tracked-changes",
|
|
334
|
-
label: "Toggle track-changes authoring mode",
|
|
335
|
-
shortcut: { winLinux: "Ctrl+Shift+E", mac: "Cmd+Shift+E" },
|
|
336
|
-
blockReason: {
|
|
337
|
-
code: UNSUPPORTED_SURFACE,
|
|
338
|
-
message: "Track changes authoring shortcuts are not supported in the mounted editor.",
|
|
339
|
-
},
|
|
319
|
+
hostEvent: "onGoToRequested",
|
|
340
320
|
},
|
|
341
321
|
{
|
|
342
|
-
id: "
|
|
343
|
-
kind: "
|
|
322
|
+
id: "shortcut.spell",
|
|
323
|
+
kind: "host-delegated",
|
|
344
324
|
category: "system",
|
|
345
325
|
label: "Check spelling",
|
|
346
326
|
shortcut: { winLinux: "F7", mac: "F7" },
|
|
347
|
-
|
|
348
|
-
code: UNSUPPORTED_SURFACE,
|
|
349
|
-
message: "Spelling shortcuts are not supported in the mounted editor.",
|
|
350
|
-
},
|
|
327
|
+
hostEvent: "onSpellRequested",
|
|
351
328
|
},
|
|
352
329
|
{
|
|
353
|
-
id: "
|
|
354
|
-
kind: "
|
|
330
|
+
id: "shortcut.thesaurus",
|
|
331
|
+
kind: "host-delegated",
|
|
355
332
|
category: "system",
|
|
356
333
|
label: "Open thesaurus",
|
|
357
334
|
shortcut: { winLinux: "Shift+F7", mac: "Shift+F7" },
|
|
358
|
-
|
|
359
|
-
code: UNSUPPORTED_SURFACE,
|
|
360
|
-
message: "Thesaurus shortcuts are not supported in the mounted editor.",
|
|
361
|
-
},
|
|
335
|
+
hostEvent: "onThesaurusRequested",
|
|
362
336
|
},
|
|
363
337
|
{
|
|
364
|
-
id: "
|
|
365
|
-
kind: "
|
|
338
|
+
id: "shortcut.extend-selection",
|
|
339
|
+
kind: "host-delegated",
|
|
366
340
|
category: "selection",
|
|
367
341
|
label: "Extend-selection mode",
|
|
368
342
|
shortcut: { winLinux: "F8", mac: "F8" },
|
|
369
|
-
|
|
370
|
-
code: UNSUPPORTED_SURFACE,
|
|
371
|
-
message: "Extend-selection shortcuts are not supported in the mounted editor.",
|
|
372
|
-
},
|
|
343
|
+
hostEvent: "onExtendSelectionRequested",
|
|
373
344
|
},
|
|
374
345
|
{
|
|
375
|
-
id: "
|
|
376
|
-
kind: "
|
|
346
|
+
id: "shortcut.last-edit",
|
|
347
|
+
kind: "host-delegated",
|
|
377
348
|
category: "navigation",
|
|
378
349
|
label: "Return to last edit",
|
|
379
350
|
shortcut: { winLinux: "Shift+F5", mac: "Shift+F5" },
|
|
351
|
+
hostEvent: "onLastEditRequested",
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------
|
|
355
|
+
// Blocked — Word shortcuts the mounted editor does not implement
|
|
356
|
+
// ---------------------------------------------------------------
|
|
357
|
+
{
|
|
358
|
+
id: "toggleTrackChanges",
|
|
359
|
+
kind: "blocked",
|
|
360
|
+
category: "tracked-changes",
|
|
361
|
+
label: "Toggle track-changes authoring mode",
|
|
362
|
+
shortcut: { winLinux: "Ctrl+Shift+E", mac: "Cmd+Shift+E" },
|
|
380
363
|
blockReason: {
|
|
381
364
|
code: UNSUPPORTED_SURFACE,
|
|
382
|
-
message: "
|
|
365
|
+
message: "Track changes authoring shortcuts are not supported in the mounted editor.",
|
|
383
366
|
},
|
|
384
367
|
},
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------
|
|
370
|
+
// Workflow ref-methods (supported, no shortcut)
|
|
371
|
+
//
|
|
372
|
+
// These entries document ref-method API surface that has no
|
|
373
|
+
// keyboard binding — `WordReviewEditorRef.addScope` / `getScope`
|
|
374
|
+
// / `removeScope`, shipped as S1. They appear in the capability
|
|
375
|
+
// table so host integrations can introspect the full contract
|
|
376
|
+
// without parsing multiple sources. Do not remove the `kind:
|
|
377
|
+
// "supported"` discipline — they are runtime-owned mutations, not
|
|
378
|
+
// host-delegated.
|
|
379
|
+
// ---------------------------------------------------------------
|
|
380
|
+
{
|
|
381
|
+
id: "scope.add",
|
|
382
|
+
kind: "supported",
|
|
383
|
+
category: "workflow",
|
|
384
|
+
label: "Attach a workflow scope to a selection or range (ref method)",
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: "scope.get",
|
|
388
|
+
kind: "supported",
|
|
389
|
+
category: "workflow",
|
|
390
|
+
label: "Resolve a scopeId to a live WorkflowScope with current anchor (ref method)",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "scope.remove",
|
|
394
|
+
kind: "supported",
|
|
395
|
+
category: "workflow",
|
|
396
|
+
label: "Remove a scope's markers + metadata record (ref method)",
|
|
397
|
+
},
|
|
385
398
|
];
|
|
386
399
|
|
|
387
400
|
/**
|
|
@@ -55,5 +55,12 @@ export const LAYOUT_ENGINE_VERSION = 5 as const;
|
|
|
55
55
|
* 1 — initial envelope shape: { schemaVersion, engineVersion,
|
|
56
56
|
* fontFingerprint, structuralHash, graph, surface }. Ships with
|
|
57
57
|
* L7 Phase 2.5 Plan A.
|
|
58
|
+
* 2 — L7 Phase 2.5 Plan B: adds `canonicalDocument` + `canonicalDocumentHash`
|
|
59
|
+
* fields so the receiving client can skip the DOCX parse entirely on
|
|
60
|
+
* cache hit. `canonicalDocumentHash` is also a 5th input to the cache
|
|
61
|
+
* key so any state mutation (styles, metadata, comments, preservation)
|
|
62
|
+
* correctly invalidates. v1 envelopes are rejected on load under v2 —
|
|
63
|
+
* no corruption path exists because schemaVersion is the top-level
|
|
64
|
+
* discriminator.
|
|
58
65
|
*/
|
|
59
|
-
export const LAYCACHE_SCHEMA_VERSION =
|
|
66
|
+
export const LAYCACHE_SCHEMA_VERSION = 2 as const;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
|
+
import type { CanonicalDocument } from "../../model/canonical-document.ts";
|
|
2
3
|
import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* L7 Phase 2.5
|
|
6
|
+
* L7 Phase 2.5 — prerender cache envelope shape.
|
|
6
7
|
*
|
|
7
|
-
* The envelope is the unit written to IndexedDB (Plan A) and —
|
|
8
|
-
*
|
|
9
|
-
* must agree on this shape: the prerender pipeline that populates
|
|
10
|
-
* the warm-path loader that rehydrates it.
|
|
8
|
+
* The envelope is the unit written to IndexedDB (Plan A) and — under Plan B
|
|
9
|
+
* (schema v2) — to the `laycache` customXml editor-state namespace. Two
|
|
10
|
+
* consumers must agree on this shape: the prerender pipeline that populates
|
|
11
|
+
* it, and the warm-path loader that rehydrates it.
|
|
11
12
|
*
|
|
12
13
|
* Load-time invariants checked by consumers before trusting the envelope:
|
|
13
14
|
* - `schemaVersion === LAYCACHE_SCHEMA_VERSION` — bump invalidates
|
|
@@ -15,15 +16,26 @@ import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
|
15
16
|
* - `graph.revision === 0` — canonical marker
|
|
16
17
|
*
|
|
17
18
|
* The envelope MUST be structured-clone-safe because IndexedDB and Plan B's
|
|
18
|
-
* customXml path both rely on structured-clone semantics. Keep fields
|
|
19
|
-
* JSON-serializable primitives, plain objects, or arrays — no class
|
|
19
|
+
* customXml path both rely on structured-clone / JSON semantics. Keep fields
|
|
20
|
+
* as JSON-serializable primitives, plain objects, or arrays — no class
|
|
20
21
|
* instances, functions, or symbols.
|
|
22
|
+
*
|
|
23
|
+
* Plan B additions (schema v2):
|
|
24
|
+
* - `canonicalDocument` — the full parsed model, so the warm-path loader
|
|
25
|
+
* can skip `parseMainDocumentXml` + `createImportedCanonicalDocument` +
|
|
26
|
+
* `buildCompatibilityReport` + `createImportedSnapshot` on cache hit.
|
|
27
|
+
* Saves ~584 ms of the 976 ms cold-upload on `extra-large` CCEP.
|
|
28
|
+
* - `canonicalDocumentHash` — sha256 of sorted-keys JSON. Also the 5th
|
|
29
|
+
* input to `deriveCacheKey`, so style/metadata/comment/preservation
|
|
30
|
+
* mutations correctly invalidate the cache.
|
|
21
31
|
*/
|
|
22
32
|
export interface CacheEnvelope {
|
|
23
33
|
readonly schemaVersion: number;
|
|
24
34
|
readonly engineVersion: number;
|
|
25
35
|
readonly fontFingerprint: string;
|
|
26
36
|
readonly structuralHash: string;
|
|
37
|
+
readonly canonicalDocumentHash: string;
|
|
27
38
|
readonly graph: RuntimePageGraph;
|
|
28
39
|
readonly surface: EditorSurfaceSnapshot;
|
|
40
|
+
readonly canonicalDocument: CanonicalDocument;
|
|
29
41
|
}
|
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* L7 Phase 2.5
|
|
2
|
+
* L7 Phase 2.5 — prerender cache-key derivation.
|
|
3
3
|
*
|
|
4
4
|
* The cache key is the composite identity the IndexedDB (Plan A) and
|
|
5
5
|
* customXml (Plan B) backends index on. It has five inputs:
|
|
6
6
|
*
|
|
7
|
-
* 1. structuralHash(blocks)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 2. fontFingerprint
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
7
|
+
* 1. structuralHash(blocks) — sha256 of the ordered kind:blockId list.
|
|
8
|
+
* Stable across text-only edits; changes on
|
|
9
|
+
* insert/delete/reorder because blockIds are
|
|
10
|
+
* kind-counter pairs (paragraph-5 stays 5
|
|
11
|
+
* under typing, shifts to paragraph-6 after
|
|
12
|
+
* an insert).
|
|
13
|
+
* 2. fontFingerprint — identifies the measurement-backend + font-
|
|
14
|
+
* metric source. "empirical-backend" in
|
|
15
|
+
* Plan A; a real font-derived string after
|
|
16
|
+
* Phase 8.
|
|
17
|
+
* 3. engineVersion — LAYOUT_ENGINE_VERSION from src/runtime/
|
|
18
|
+
* layout/layout-engine-version.ts. Bumped by
|
|
19
|
+
* CI gate on any layout/render shape change.
|
|
20
|
+
* 4. schemaVersion — LAYCACHE_SCHEMA_VERSION for envelope
|
|
21
|
+
* format.
|
|
22
|
+
* 5. canonicalDocumentHash — (Plan B, schema v2) sha256 of sorted-keys
|
|
23
|
+
* JSON of the CanonicalDocument. Catches
|
|
24
|
+
* non-structural mutations (styles,
|
|
25
|
+
* metadata, comments, preservation) that
|
|
26
|
+
* `structuralHash` alone misses. Computed
|
|
27
|
+
* via `computeCanonicalDocumentHash()` from
|
|
28
|
+
* `./canonical-document-hash.ts`.
|
|
20
29
|
*
|
|
21
30
|
* Returns a 64-char lower-case hex digest. Uses the Web Crypto API
|
|
22
31
|
* (globalThis.crypto.subtle), available in Node 18+ and all target browsers —
|
|
@@ -33,6 +42,7 @@ export interface CacheKeyInputs {
|
|
|
33
42
|
readonly fontFingerprint: string;
|
|
34
43
|
readonly engineVersion: string | number;
|
|
35
44
|
readonly schemaVersion: number;
|
|
45
|
+
readonly canonicalDocumentHash: string;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
const BLOCK_SEPARATOR = "\u0000";
|
|
@@ -61,6 +71,7 @@ export async function deriveCacheKey(inputs: CacheKeyInputs): Promise<string> {
|
|
|
61
71
|
inputs.fontFingerprint,
|
|
62
72
|
String(inputs.engineVersion),
|
|
63
73
|
String(inputs.schemaVersion),
|
|
74
|
+
inputs.canonicalDocumentHash,
|
|
64
75
|
].join(FIELD_SEPARATOR);
|
|
65
76
|
return sha256Hex(composite);
|
|
66
77
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CanonicalDocument } from "../../model/canonical-document.ts";
|
|
2
|
+
import { stableStringify } from "../../model/cds-1.0.0.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* L7 Phase 2.5 Plan B — deterministic hash of a CanonicalDocument.
|
|
6
|
+
*
|
|
7
|
+
* Used as the fifth input to `deriveCacheKey` so non-structural mutations
|
|
8
|
+
* (style edits, metadata changes, comment/revision edits, preservation
|
|
9
|
+
* fragment updates) correctly invalidate the cache. `structuralHash`
|
|
10
|
+
* alone — which keys on the block-id list — misses these because the
|
|
11
|
+
* block structure is unchanged by such mutations.
|
|
12
|
+
*
|
|
13
|
+
* Determinism: uses `stableStringify` from `cds-1.0.0.ts` (the same
|
|
14
|
+
* ordering the canonical-document model already uses for equality
|
|
15
|
+
* comparison), so two runs on structurally-identical documents produce
|
|
16
|
+
* byte-identical JSON → byte-identical SHA-256. Cross-process / cross-
|
|
17
|
+
* machine agreement holds as long as the CanonicalDocument shape matches.
|
|
18
|
+
*
|
|
19
|
+
* **Session-birth metadata is excluded from the hash** — `createdAt` and
|
|
20
|
+
* `updatedAt` are set to `new Date().toISOString()` at session load (see
|
|
21
|
+
* `docx-session.ts`), and `docId` derives from a runtime-allocated UUID
|
|
22
|
+
* when the host does not pin one. These fields are not document identity
|
|
23
|
+
* for cache-validity purposes: a save-and-reload should hit the cache if
|
|
24
|
+
* the document content is unchanged, even though `updatedAt` shifted.
|
|
25
|
+
*
|
|
26
|
+
* Cost budget: <50 ms on `extra-large` CCEP (~250 KB canonical). Dominated
|
|
27
|
+
* by `JSON.stringify` + Web Crypto SHA-256; the key-sort pass inside
|
|
28
|
+
* `stableStringify` is a single depth-first walk.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const textEncoder = new TextEncoder();
|
|
32
|
+
const NORMALIZED_SENTINEL = "__hash_normalized__";
|
|
33
|
+
|
|
34
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
35
|
+
const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(input));
|
|
36
|
+
const bytes = new Uint8Array(digest);
|
|
37
|
+
let hex = "";
|
|
38
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
39
|
+
hex += bytes[i]!.toString(16).padStart(2, "0");
|
|
40
|
+
}
|
|
41
|
+
return hex;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Produces a copy of `doc` with session-birth metadata replaced by fixed
|
|
46
|
+
* sentinel values. Keeps the hash stable across two sequential
|
|
47
|
+
* `prerenderDocument` calls on identical bytes (both calls set
|
|
48
|
+
* `createdAt`/`updatedAt` from `Date.now()` so would otherwise diverge).
|
|
49
|
+
*/
|
|
50
|
+
function normalizeForHashing(doc: CanonicalDocument): CanonicalDocument {
|
|
51
|
+
return {
|
|
52
|
+
...doc,
|
|
53
|
+
docId: NORMALIZED_SENTINEL as CanonicalDocument["docId"],
|
|
54
|
+
createdAt: NORMALIZED_SENTINEL as CanonicalDocument["createdAt"],
|
|
55
|
+
updatedAt: NORMALIZED_SENTINEL as CanonicalDocument["updatedAt"],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function computeCanonicalDocumentHash(
|
|
60
|
+
doc: CanonicalDocument,
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
return sha256Hex(stableStringify(normalizeForHashing(doc)));
|
|
63
|
+
}
|