@beyondwork/docx-react-component 1.0.42 → 1.0.43
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 +30 -41
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +194 -1
- package/src/core/commands/index.ts +33 -8
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +13 -0
- package/src/io/docx-session.ts +672 -2
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +364 -36
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- 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 +17 -2
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +400 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +67 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +110 -11
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
|
@@ -204,6 +204,30 @@ import {
|
|
|
204
204
|
incrementInvalidationCounter,
|
|
205
205
|
recordPerfSample,
|
|
206
206
|
} from "../ui-tailwind/editor-surface/perf-probe.ts";
|
|
207
|
+
import {
|
|
208
|
+
createLoadScheduler,
|
|
209
|
+
type LoadScheduler,
|
|
210
|
+
} from "../io/load-scheduler.ts";
|
|
211
|
+
import {
|
|
212
|
+
createEditorStateChannel,
|
|
213
|
+
type EditorStateChannel,
|
|
214
|
+
} from "./editor-state-channel.ts";
|
|
215
|
+
import { PerfCounters } from "./perf-counters.ts";
|
|
216
|
+
import type {
|
|
217
|
+
EditorStateNamespace,
|
|
218
|
+
EditorStatePolicy,
|
|
219
|
+
EditorStateResolver,
|
|
220
|
+
EditorStatePersister,
|
|
221
|
+
} from "../api/editor-state-types.ts";
|
|
222
|
+
import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
|
|
223
|
+
import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
|
|
224
|
+
|
|
225
|
+
/** Internal extension of ExportDocxOptions that threads the collected
|
|
226
|
+
* editorState payload from the runtime to the docx serializer. */
|
|
227
|
+
interface InternalExportDocxOptions extends ExportDocxOptions {
|
|
228
|
+
/** @internal Schema 1.2 — collected by runtime before serialize. */
|
|
229
|
+
_editorState?: EditorStatePayload;
|
|
230
|
+
}
|
|
207
231
|
|
|
208
232
|
export type Unsubscribe = () => void;
|
|
209
233
|
|
|
@@ -341,6 +365,23 @@ export interface DocumentRuntime {
|
|
|
341
365
|
getRuntimeContextAnalytics(
|
|
342
366
|
query?: RuntimeContextAnalyticsQuery,
|
|
343
367
|
): RuntimeContextAnalyticsSnapshot | null;
|
|
368
|
+
// Schema 1.2 — EditorStateChannel delegation
|
|
369
|
+
configureEditorStatePolicy(policy: EditorStatePolicy): void;
|
|
370
|
+
registerEditorStateResolver(resolver: EditorStateResolver | null): void;
|
|
371
|
+
registerEditorStatePersister(persister: EditorStatePersister | null): void;
|
|
372
|
+
getEditorStateKey(namespace: EditorStateNamespace): string | undefined;
|
|
373
|
+
retryPendingPersist(namespace?: EditorStateNamespace): Promise<void>;
|
|
374
|
+
/** Internal: exposes the channel for load-path hydration in editor-runtime-boundary. */
|
|
375
|
+
readonly editorStateChannel: EditorStateChannel;
|
|
376
|
+
/**
|
|
377
|
+
* L7 render-perf instrumentation. Returns a string→number map of internal
|
|
378
|
+
* perf counters incremented during runtime activity. Used by perf benches
|
|
379
|
+
* and tests to assert that a given facet rebuild ran (or did not run) for
|
|
380
|
+
* a given operation. Counter names are not part of the public API contract
|
|
381
|
+
* — they may be added or renamed across L7 phases.
|
|
382
|
+
*/
|
|
383
|
+
getPerfCountersSnapshot(): Record<string, number>;
|
|
384
|
+
resetPerfCounters(): void;
|
|
344
385
|
}
|
|
345
386
|
|
|
346
387
|
export interface CommandAppliedMeta {
|
|
@@ -386,6 +427,16 @@ export interface CreateDocumentRuntimeOptions {
|
|
|
386
427
|
) => void;
|
|
387
428
|
initialViewState?: Partial<ViewState>;
|
|
388
429
|
protectionSnapshot?: ProtectionSnapshot;
|
|
430
|
+
/**
|
|
431
|
+
* Optional main-thread time-slicing scheduler. When provided, the runtime
|
|
432
|
+
* schedules post-`ready` idle work (e.g., sub-part hydration, compatibility
|
|
433
|
+
* report — reserved for future fastload passes) through it. When omitted,
|
|
434
|
+
* the runtime falls back to the sync scheduler — behavior is byte-identical
|
|
435
|
+
* to the pre-fastload runtime.
|
|
436
|
+
*
|
|
437
|
+
* See `docs/plans/fastload.md` and `src/io/load-scheduler.ts`.
|
|
438
|
+
*/
|
|
439
|
+
loadScheduler?: LoadScheduler;
|
|
389
440
|
}
|
|
390
441
|
|
|
391
442
|
interface HistoryState {
|
|
@@ -402,6 +453,29 @@ export function createDocumentRuntime(
|
|
|
402
453
|
const sessionId = createSessionId(options.documentId, clock());
|
|
403
454
|
const listeners = new Set<() => void>();
|
|
404
455
|
const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
|
|
456
|
+
// Fastload P2: optional time-slicing scheduler. When caller does not pass
|
|
457
|
+
// one, use the sync backend so the runtime keeps its pre-fastload behavior
|
|
458
|
+
// end-to-end (no deferred work, idle tasks run inline). Future fastload
|
|
459
|
+
// passes (P6 async loader) will consume `scheduler.scheduleIdle` for
|
|
460
|
+
// post-ready work.
|
|
461
|
+
const loadScheduler: LoadScheduler =
|
|
462
|
+
options.loadScheduler ?? createLoadScheduler({ backendOverride: "sync" });
|
|
463
|
+
|
|
464
|
+
// L7 Phase 0 — perf counters. Each `refreshRenderSnapshot` call increments
|
|
465
|
+
// `refresh.all`. Phase 1 will add per-facet `facet.<name>.build` counters
|
|
466
|
+
// wired to the per-facet cached builders. Cost is one Map.set per call.
|
|
467
|
+
const perfCounters = new PerfCounters();
|
|
468
|
+
|
|
469
|
+
// L7 Phase 1 — context-analytics emit coalescing flag. Multiple events that
|
|
470
|
+
// each trigger context-analytics within one synchronous call stack collapse
|
|
471
|
+
// to a single microtask-deferred emit. Declared early because emit() (which
|
|
472
|
+
// checks this flag) runs during construction.
|
|
473
|
+
let analyticsEmitScheduled = false;
|
|
474
|
+
|
|
475
|
+
// Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
|
|
476
|
+
// Instantiated once per runtime; forwarded to the public interface.
|
|
477
|
+
const editorStateChannel = createEditorStateChannel();
|
|
478
|
+
|
|
405
479
|
const history: HistoryState = {
|
|
406
480
|
past: [],
|
|
407
481
|
future: [],
|
|
@@ -480,6 +554,7 @@ export function createDocumentRuntime(
|
|
|
480
554
|
zoomLevel: viewState.zoomLevel,
|
|
481
555
|
},
|
|
482
556
|
}),
|
|
557
|
+
canonicalDocument: () => state.document,
|
|
483
558
|
renderKernel: () => renderKernelRef,
|
|
484
559
|
getWorkflowRailInput: () => {
|
|
485
560
|
if (!workflowOverlay) return null;
|
|
@@ -544,6 +619,21 @@ export function createDocumentRuntime(
|
|
|
544
619
|
snapshot: SuggestionsSnapshot;
|
|
545
620
|
}
|
|
546
621
|
| undefined;
|
|
622
|
+
// L7 Phase 1 — review-work snapshot cache. Independent of selection.
|
|
623
|
+
// Inputs that bust it: workflowMarkup, comments, trackedChanges,
|
|
624
|
+
// document, navigation references. Per-keystroke profiling on the
|
|
625
|
+
// 40-page CCEP fixture showed createReviewWorkSnapshot at 211ms/call
|
|
626
|
+
// with no cache, dominated by per-item createDocumentLocationSnapshot.
|
|
627
|
+
let cachedReviewWork:
|
|
628
|
+
| {
|
|
629
|
+
comments: CommentSidebarSnapshot;
|
|
630
|
+
trackedChanges: TrackedChangesSnapshot;
|
|
631
|
+
workflowMarkup: WorkflowMarkupSnapshot;
|
|
632
|
+
document: CanonicalDocumentEnvelope;
|
|
633
|
+
navigation: DocumentNavigationSnapshot;
|
|
634
|
+
snapshot: ReviewWorkSnapshot;
|
|
635
|
+
}
|
|
636
|
+
| undefined;
|
|
547
637
|
let cachedPageLayout:
|
|
548
638
|
| {
|
|
549
639
|
revisionToken: string;
|
|
@@ -606,7 +696,9 @@ export function createDocumentRuntime(
|
|
|
606
696
|
{
|
|
607
697
|
revisionToken: string;
|
|
608
698
|
activeStoryKey: string;
|
|
609
|
-
|
|
699
|
+
// L7 Phase 1.6: null for non-selection queries (doc/scope/work_item)
|
|
700
|
+
// so selection movement doesn't invalidate these scope-stable analytics.
|
|
701
|
+
selection: EditorState["selection"] | null;
|
|
610
702
|
readOnly: boolean;
|
|
611
703
|
documentMode: DocumentMode;
|
|
612
704
|
workflowOverlay: WorkflowOverlay | null;
|
|
@@ -1366,6 +1458,10 @@ export function createDocumentRuntime(
|
|
|
1366
1458
|
protectionSnapshot,
|
|
1367
1459
|
preservation: state.document.preservation,
|
|
1368
1460
|
workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
|
|
1461
|
+
perfStage: (name, durationMs) => {
|
|
1462
|
+
perfCounters.increment(`wfMarkup.${name}.us`, Math.round(durationMs * 1000));
|
|
1463
|
+
perfCounters.increment(`wfMarkup.${name}.calls`);
|
|
1464
|
+
},
|
|
1369
1465
|
});
|
|
1370
1466
|
cachedWorkflowMarkupSnapshot = {
|
|
1371
1467
|
revisionToken: state.revisionToken,
|
|
@@ -1386,11 +1482,21 @@ export function createDocumentRuntime(
|
|
|
1386
1482
|
const activeStoryKey = storyTargetKey(activeStory);
|
|
1387
1483
|
const queryKey = getRuntimeContextAnalyticsQueryKey(query);
|
|
1388
1484
|
const cachedEntry = cachedContextAnalyticsSnapshots.get(queryKey);
|
|
1485
|
+
// L7 Phase 1.6 — only `scopeKind: "selection"` queries actually read
|
|
1486
|
+
// selection-anchored data (buildSelectionAnalytics filters markup items
|
|
1487
|
+
// that intersect the selection anchor). For document / workflow_scope /
|
|
1488
|
+
// work_item queries the selection is not an input, so changing it
|
|
1489
|
+
// shouldn't invalidate the cache. When selection moves within the same
|
|
1490
|
+
// workflow scope without text editing, document/scope/work_item analytics
|
|
1491
|
+
// hit; cursor-navigation bench drops accordingly.
|
|
1492
|
+
const effectiveScopeKind = query?.scopeKind ?? "selection";
|
|
1493
|
+
const selectionCacheKey =
|
|
1494
|
+
effectiveScopeKind === "selection" ? state.selection : null;
|
|
1389
1495
|
if (
|
|
1390
1496
|
cachedEntry &&
|
|
1391
1497
|
cachedEntry.revisionToken === state.revisionToken &&
|
|
1392
1498
|
cachedEntry.activeStoryKey === activeStoryKey &&
|
|
1393
|
-
cachedEntry.selection ===
|
|
1499
|
+
cachedEntry.selection === selectionCacheKey &&
|
|
1394
1500
|
cachedEntry.readOnly === state.readOnly &&
|
|
1395
1501
|
cachedEntry.documentMode === viewState.documentMode &&
|
|
1396
1502
|
cachedEntry.workflowOverlay === workflowOverlay &&
|
|
@@ -1401,28 +1507,76 @@ export function createDocumentRuntime(
|
|
|
1401
1507
|
return cachedEntry.snapshot;
|
|
1402
1508
|
}
|
|
1403
1509
|
|
|
1510
|
+
const tWf = performance.now();
|
|
1511
|
+
const wfScope = getCachedWorkflowScopeSnapshot();
|
|
1512
|
+
const wfGuard = getCachedInteractionGuardSnapshot();
|
|
1513
|
+
const wfMarkup = getCachedWorkflowMarkupSnapshot();
|
|
1514
|
+
perfCounters.increment("ctxa.workflow.us", Math.round((performance.now() - tWf) * 1000));
|
|
1515
|
+
|
|
1516
|
+
const tSugg = performance.now();
|
|
1517
|
+
const suggestions = getCachedSuggestionsSnapshot(state);
|
|
1518
|
+
perfCounters.increment("ctxa.suggestions.us", Math.round((performance.now() - tSugg) * 1000));
|
|
1519
|
+
|
|
1520
|
+
const tNav = performance.now();
|
|
1521
|
+
const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
1522
|
+
perfCounters.increment("ctxa.navigation.us", Math.round((performance.now() - tNav) * 1000));
|
|
1523
|
+
|
|
1524
|
+
const tReview = performance.now();
|
|
1525
|
+
let reviewWork: ReviewWorkSnapshot;
|
|
1526
|
+
if (
|
|
1527
|
+
cachedReviewWork &&
|
|
1528
|
+
cachedReviewWork.comments === cachedRenderSnapshot.comments &&
|
|
1529
|
+
cachedReviewWork.trackedChanges === cachedRenderSnapshot.trackedChanges &&
|
|
1530
|
+
cachedReviewWork.workflowMarkup === wfMarkup &&
|
|
1531
|
+
cachedReviewWork.document === state.document &&
|
|
1532
|
+
cachedReviewWork.navigation === navigation
|
|
1533
|
+
) {
|
|
1534
|
+
reviewWork = cachedReviewWork.snapshot;
|
|
1535
|
+
perfCounters.increment("ctxa.reviewWork.cacheHit");
|
|
1536
|
+
} else {
|
|
1537
|
+
reviewWork = createReviewWorkSnapshot({
|
|
1538
|
+
comments: cachedRenderSnapshot.comments,
|
|
1539
|
+
trackedChanges: cachedRenderSnapshot.trackedChanges,
|
|
1540
|
+
workflowMarkup: wfMarkup,
|
|
1541
|
+
document: state.document,
|
|
1542
|
+
navigation,
|
|
1543
|
+
});
|
|
1544
|
+
cachedReviewWork = {
|
|
1545
|
+
comments: cachedRenderSnapshot.comments,
|
|
1546
|
+
trackedChanges: cachedRenderSnapshot.trackedChanges,
|
|
1547
|
+
workflowMarkup: wfMarkup,
|
|
1548
|
+
document: state.document,
|
|
1549
|
+
navigation,
|
|
1550
|
+
snapshot: reviewWork,
|
|
1551
|
+
};
|
|
1552
|
+
perfCounters.increment("ctxa.reviewWork.cacheMiss");
|
|
1553
|
+
}
|
|
1554
|
+
perfCounters.increment("ctxa.reviewWork.us", Math.round((performance.now() - tReview) * 1000));
|
|
1555
|
+
|
|
1556
|
+
const tCompat = performance.now();
|
|
1557
|
+
const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
|
|
1558
|
+
perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
|
|
1559
|
+
|
|
1560
|
+
const tBuild = performance.now();
|
|
1404
1561
|
const snapshot = createRuntimeContextAnalyticsSnapshot({
|
|
1405
1562
|
query,
|
|
1406
1563
|
renderSnapshot: cachedRenderSnapshot,
|
|
1407
1564
|
workflowOverlay,
|
|
1408
|
-
workflowScopeSnapshot:
|
|
1409
|
-
interactionGuardSnapshot:
|
|
1410
|
-
workflowMarkupSnapshot:
|
|
1411
|
-
suggestionsSnapshot:
|
|
1412
|
-
reviewWorkSnapshot:
|
|
1413
|
-
comments: cachedRenderSnapshot.comments,
|
|
1414
|
-
trackedChanges: cachedRenderSnapshot.trackedChanges,
|
|
1415
|
-
workflowMarkup: getCachedWorkflowMarkupSnapshot(),
|
|
1416
|
-
document: state.document,
|
|
1417
|
-
navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
|
|
1418
|
-
}),
|
|
1565
|
+
workflowScopeSnapshot: wfScope,
|
|
1566
|
+
interactionGuardSnapshot: wfGuard,
|
|
1567
|
+
workflowMarkupSnapshot: wfMarkup,
|
|
1568
|
+
suggestionsSnapshot: suggestions,
|
|
1569
|
+
reviewWorkSnapshot: reviewWork,
|
|
1419
1570
|
warnings: state.warnings.map(toPublicWarning),
|
|
1420
|
-
compatibility:
|
|
1571
|
+
compatibility: compat,
|
|
1421
1572
|
});
|
|
1573
|
+
perfCounters.increment("ctxa.build.us", Math.round((performance.now() - tBuild) * 1000));
|
|
1422
1574
|
cachedContextAnalyticsSnapshots.set(queryKey, {
|
|
1423
1575
|
revisionToken: state.revisionToken,
|
|
1424
1576
|
activeStoryKey,
|
|
1425
|
-
|
|
1577
|
+
// L7 Phase 1.6: store the selectionCacheKey we tested against, so the
|
|
1578
|
+
// same scopeKind invariant applies to future reads.
|
|
1579
|
+
selection: selectionCacheKey,
|
|
1426
1580
|
readOnly: state.readOnly,
|
|
1427
1581
|
documentMode: viewState.documentMode,
|
|
1428
1582
|
workflowOverlay,
|
|
@@ -1509,8 +1663,17 @@ export function createDocumentRuntime(
|
|
|
1509
1663
|
return true;
|
|
1510
1664
|
}
|
|
1511
1665
|
|
|
1666
|
+
function timeFacet<T>(name: string, fn: () => T): T {
|
|
1667
|
+
const t0 = performance.now();
|
|
1668
|
+
const result = fn();
|
|
1669
|
+
perfCounters.increment(`facet.${name}.us`, Math.round((performance.now() - t0) * 1000));
|
|
1670
|
+
perfCounters.increment(`facet.${name}.calls`);
|
|
1671
|
+
return result;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1512
1674
|
function refreshRenderSnapshot(): RuntimeRenderSnapshot {
|
|
1513
|
-
|
|
1675
|
+
perfCounters.increment("refresh.all");
|
|
1676
|
+
const surface = timeFacet("surface", () => getCachedSurface(state.document, activeStory));
|
|
1514
1677
|
return {
|
|
1515
1678
|
documentId: state.documentId,
|
|
1516
1679
|
sessionId: state.sessionId,
|
|
@@ -1522,11 +1685,11 @@ export function createDocumentRuntime(
|
|
|
1522
1685
|
documentMode: viewState.documentMode,
|
|
1523
1686
|
selection: toPublicSelectionSnapshot(state.selection, activeStory),
|
|
1524
1687
|
activeStory,
|
|
1525
|
-
pageLayout: getCachedPageLayoutSnapshot(state, activeStory) ?? undefined,
|
|
1688
|
+
pageLayout: timeFacet("layout", () => getCachedPageLayoutSnapshot(state, activeStory)) ?? undefined,
|
|
1526
1689
|
documentStats: toPublicDocumentStats(state),
|
|
1527
|
-
comments: getCachedCommentSidebarSnapshot(state),
|
|
1528
|
-
trackedChanges: getCachedTrackedChangesSnapshot(state, surface),
|
|
1529
|
-
compatibility: getCachedCompatibilityReport(state),
|
|
1690
|
+
comments: timeFacet("comments", () => getCachedCommentSidebarSnapshot(state)),
|
|
1691
|
+
trackedChanges: timeFacet("trackedChanges", () => getCachedTrackedChangesSnapshot(state, surface)),
|
|
1692
|
+
compatibility: timeFacet("compatibility", () => getCachedCompatibilityReport(state)),
|
|
1530
1693
|
warnings: state.warnings.map((warning) => toPublicWarning(warning)),
|
|
1531
1694
|
fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
|
|
1532
1695
|
commandState: {
|
|
@@ -1604,6 +1767,40 @@ export function createDocumentRuntime(
|
|
|
1604
1767
|
});
|
|
1605
1768
|
}
|
|
1606
1769
|
|
|
1770
|
+
// Schema 1.2: forward channel events to the runtime event bus.
|
|
1771
|
+
editorStateChannel.addListener((event) => {
|
|
1772
|
+
switch (event.kind) {
|
|
1773
|
+
case "load_failed":
|
|
1774
|
+
emit({
|
|
1775
|
+
type: "editor_state_part_load_failed",
|
|
1776
|
+
documentId: state.documentId,
|
|
1777
|
+
failure: event.failure,
|
|
1778
|
+
});
|
|
1779
|
+
break;
|
|
1780
|
+
case "persist_failed":
|
|
1781
|
+
emit({
|
|
1782
|
+
type: "editor_state_part_persist_failed",
|
|
1783
|
+
documentId: state.documentId,
|
|
1784
|
+
failure: event.failure,
|
|
1785
|
+
});
|
|
1786
|
+
break;
|
|
1787
|
+
case "policy_migrated":
|
|
1788
|
+
emit({
|
|
1789
|
+
type: "editor_state_policy_migrated",
|
|
1790
|
+
documentId: state.documentId,
|
|
1791
|
+
migration: event.migration,
|
|
1792
|
+
});
|
|
1793
|
+
break;
|
|
1794
|
+
case "unknown_namespace":
|
|
1795
|
+
emit({
|
|
1796
|
+
type: "editor_state_unknown_namespace",
|
|
1797
|
+
documentId: state.documentId,
|
|
1798
|
+
namespace: event.namespace,
|
|
1799
|
+
});
|
|
1800
|
+
break;
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1607
1804
|
return {
|
|
1608
1805
|
subscribe(listener) {
|
|
1609
1806
|
listeners.add(listener);
|
|
@@ -2254,7 +2451,37 @@ export function createDocumentRuntime(
|
|
|
2254
2451
|
throw toStructuredRuntimeException(error);
|
|
2255
2452
|
}
|
|
2256
2453
|
|
|
2257
|
-
|
|
2454
|
+
// Schema 1.2: collect editor-state payload (flushes pending persists).
|
|
2455
|
+
const collectedEditorState = await collectEditorStateForSerialize({
|
|
2456
|
+
channel: editorStateChannel,
|
|
2457
|
+
getNamespaceData: (ns) => {
|
|
2458
|
+
switch (ns) {
|
|
2459
|
+
case "hostAnnotations": {
|
|
2460
|
+
const snap = deriveHostAnnotationSnapshot();
|
|
2461
|
+
if (!snap || snap.annotations.length === 0) return null;
|
|
2462
|
+
return { schemaVersion: "host-annotation-overlay/1", data: snap };
|
|
2463
|
+
}
|
|
2464
|
+
case "workflowOverlay": {
|
|
2465
|
+
const ov = workflowOverlay;
|
|
2466
|
+
if (!ov) return null;
|
|
2467
|
+
return { schemaVersion: "workflow-overlay/1", data: ov };
|
|
2468
|
+
}
|
|
2469
|
+
case "workflowMetadata": {
|
|
2470
|
+
const meta = deriveWorkflowMetadataSnapshot();
|
|
2471
|
+
if (!meta || (meta.definitions.length === 0 && meta.entries.length === 0)) return null;
|
|
2472
|
+
return { schemaVersion: "workflow-metadata/1", data: meta };
|
|
2473
|
+
}
|
|
2474
|
+
case "workItems":
|
|
2475
|
+
return null;
|
|
2476
|
+
}
|
|
2477
|
+
},
|
|
2478
|
+
});
|
|
2479
|
+
const internalOptions: InternalExportDocxOptions = {
|
|
2480
|
+
...exportOptions,
|
|
2481
|
+
_editorState: collectedEditorState,
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2484
|
+
const result = await options.exportDocx(this.getSessionState(), internalOptions);
|
|
2258
2485
|
|
|
2259
2486
|
emit({
|
|
2260
2487
|
type: "export_completed",
|
|
@@ -2270,6 +2497,11 @@ export function createDocumentRuntime(
|
|
|
2270
2497
|
overlay,
|
|
2271
2498
|
origin: createOrigin("api", clock()),
|
|
2272
2499
|
});
|
|
2500
|
+
editorStateChannel.recordMutation("workflowOverlay", {
|
|
2501
|
+
namespace: "workflowOverlay",
|
|
2502
|
+
schemaVersion: "workflow-overlay/1",
|
|
2503
|
+
data: overlay,
|
|
2504
|
+
});
|
|
2273
2505
|
},
|
|
2274
2506
|
clearWorkflowOverlay() {
|
|
2275
2507
|
this.dispatch({
|
|
@@ -2308,6 +2540,11 @@ export function createDocumentRuntime(
|
|
|
2308
2540
|
entries,
|
|
2309
2541
|
origin: createOrigin("api", clock()),
|
|
2310
2542
|
});
|
|
2543
|
+
editorStateChannel.recordMutation("workflowMetadata", {
|
|
2544
|
+
namespace: "workflowMetadata",
|
|
2545
|
+
schemaVersion: "workflow-metadata/1",
|
|
2546
|
+
data: entries,
|
|
2547
|
+
});
|
|
2311
2548
|
},
|
|
2312
2549
|
clearWorkflowMetadataEntries() {
|
|
2313
2550
|
this.dispatch({
|
|
@@ -2324,6 +2561,11 @@ export function createDocumentRuntime(
|
|
|
2324
2561
|
overlay,
|
|
2325
2562
|
origin: createOrigin("api", clock()),
|
|
2326
2563
|
});
|
|
2564
|
+
editorStateChannel.recordMutation("hostAnnotations", {
|
|
2565
|
+
namespace: "hostAnnotations",
|
|
2566
|
+
schemaVersion: "host-annotation-overlay/1",
|
|
2567
|
+
data: overlay,
|
|
2568
|
+
});
|
|
2327
2569
|
},
|
|
2328
2570
|
clearHostAnnotationOverlay() {
|
|
2329
2571
|
this.dispatch({
|
|
@@ -2391,6 +2633,31 @@ export function createDocumentRuntime(
|
|
|
2391
2633
|
getRuntimeContextAnalytics(query) {
|
|
2392
2634
|
return getCachedRuntimeContextAnalytics(query);
|
|
2393
2635
|
},
|
|
2636
|
+
// Schema 1.2 — EditorStateChannel delegation
|
|
2637
|
+
configureEditorStatePolicy(policy) {
|
|
2638
|
+
editorStateChannel.setPolicy(policy);
|
|
2639
|
+
},
|
|
2640
|
+
registerEditorStateResolver(resolver) {
|
|
2641
|
+
editorStateChannel.setResolver(resolver);
|
|
2642
|
+
},
|
|
2643
|
+
registerEditorStatePersister(persister) {
|
|
2644
|
+
editorStateChannel.setPersister(persister);
|
|
2645
|
+
},
|
|
2646
|
+
getEditorStateKey(namespace) {
|
|
2647
|
+
return editorStateChannel.getKey(namespace);
|
|
2648
|
+
},
|
|
2649
|
+
async retryPendingPersist(namespace) {
|
|
2650
|
+
await editorStateChannel.retry(namespace);
|
|
2651
|
+
},
|
|
2652
|
+
get editorStateChannel() {
|
|
2653
|
+
return editorStateChannel;
|
|
2654
|
+
},
|
|
2655
|
+
getPerfCountersSnapshot() {
|
|
2656
|
+
return perfCounters.snapshot();
|
|
2657
|
+
},
|
|
2658
|
+
resetPerfCounters() {
|
|
2659
|
+
perfCounters.reset();
|
|
2660
|
+
},
|
|
2394
2661
|
};
|
|
2395
2662
|
|
|
2396
2663
|
function applyHistory(direction: "undo" | "redo"): void {
|
|
@@ -2438,15 +2705,14 @@ export function createDocumentRuntime(
|
|
|
2438
2705
|
function applyTransactionToState(transaction: EditorTransaction): void {
|
|
2439
2706
|
const previous = state;
|
|
2440
2707
|
|
|
2708
|
+
const tApply0 = performance.now();
|
|
2441
2709
|
protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
|
|
2710
|
+
const tFinalize0 = performance.now();
|
|
2442
2711
|
state = finalizeState(transaction.nextState, transaction.markDirty, clock());
|
|
2712
|
+
perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
|
|
2443
2713
|
storySelections.set(storyTargetKey(activeStory), state.selection);
|
|
2444
2714
|
|
|
2445
|
-
|
|
2446
|
-
// next layout query can splice rather than rebuild the full graph. The
|
|
2447
|
-
// engine analyzes the reason against its cached graph and falls back to
|
|
2448
|
-
// a full rebuild when the edit crosses section boundaries or reaches a
|
|
2449
|
-
// page the engine cannot safely resume from.
|
|
2715
|
+
const tInvalidate0 = performance.now();
|
|
2450
2716
|
if (transaction.markDirty && transaction.mapping.steps.length > 0) {
|
|
2451
2717
|
let minFrom = Infinity;
|
|
2452
2718
|
let maxTo = -Infinity;
|
|
@@ -2459,22 +2725,20 @@ export function createDocumentRuntime(
|
|
|
2459
2725
|
layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
|
|
2460
2726
|
}
|
|
2461
2727
|
}
|
|
2462
|
-
|
|
2463
|
-
// Font-loader refresh on subParts identity change — this is the
|
|
2464
|
-
// lightweight proxy for "a change that could affect which fonts the
|
|
2465
|
-
// canvas backend measures against". Typing edits don't rebuild
|
|
2466
|
-
// subParts; style + font + numbering imports do. Hardening: also
|
|
2467
|
-
// invalidate the measurement provider's glyph cache AND the engine's
|
|
2468
|
-
// cached graph so the next pagination run re-measures with the
|
|
2469
|
-
// newly-registered FontFaces (pre-hardening the canvas backend kept
|
|
2470
|
-
// returning pre-refresh widths from its cache).
|
|
2471
2728
|
if (previous.document.subParts !== state.document.subParts) {
|
|
2472
2729
|
fontLoader.refresh(collectFontLoaderInput(state.document));
|
|
2473
2730
|
layoutEngine.invalidateMeasurementCache();
|
|
2474
2731
|
}
|
|
2732
|
+
perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
|
|
2475
2733
|
|
|
2734
|
+
const tRefresh0 = performance.now();
|
|
2476
2735
|
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
2736
|
+
perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
|
|
2737
|
+
|
|
2738
|
+
const tNotify0 = performance.now();
|
|
2477
2739
|
notify(previous, state, transaction);
|
|
2740
|
+
perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
|
|
2741
|
+
perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
|
|
2478
2742
|
}
|
|
2479
2743
|
|
|
2480
2744
|
function notify(
|
|
@@ -2935,10 +3199,39 @@ export function createDocumentRuntime(
|
|
|
2935
3199
|
return nextReview;
|
|
2936
3200
|
}
|
|
2937
3201
|
|
|
3202
|
+
// L7 Phase 1 — context-analytics emissions are coalesced per microtask.
|
|
3203
|
+
// A single keystroke commit fires multiple events that each independently
|
|
3204
|
+
// trigger `emitContextAnalyticsChanged` (selection_changed, change_authored,
|
|
3205
|
+
// …). Each emission re-walks every workflow markup item (~211 ms on a
|
|
3206
|
+
// 40-page CCEP fixture) so unbatched, an N-event commit costs N × 211 ms.
|
|
3207
|
+
// Coalescing collapses the burst to a single emit at the tail of the
|
|
3208
|
+
// synchronous call stack — one per commit. (Flag declared above near
|
|
3209
|
+
// perfCounters so it is initialized before construction-time emits run.)
|
|
3210
|
+
function scheduleContextAnalyticsEmit(): void {
|
|
3211
|
+
if (analyticsEmitScheduled) {
|
|
3212
|
+
perfCounters.increment("emit.contextAnalytics.coalesced");
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
analyticsEmitScheduled = true;
|
|
3216
|
+
queueMicrotask(() => {
|
|
3217
|
+
// Reset BEFORE the emit so any synchronous re-entrant emits triggered
|
|
3218
|
+
// by listener callbacks schedule a fresh second microtask (no lost
|
|
3219
|
+
// updates). Reversing this order would drop bursts originating in
|
|
3220
|
+
// listeners.
|
|
3221
|
+
analyticsEmitScheduled = false;
|
|
3222
|
+
const t = performance.now();
|
|
3223
|
+
emitContextAnalyticsChanged();
|
|
3224
|
+
perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
|
|
2938
3228
|
function emit(event: DocumentRuntimeEvent): void {
|
|
3229
|
+
perfCounters.increment(`emit.${event.type}.calls`);
|
|
3230
|
+
const t0 = performance.now();
|
|
2939
3231
|
emitInternal(event);
|
|
3232
|
+
perfCounters.increment(`emit.${event.type}.internal.us`, Math.round((performance.now() - t0) * 1000));
|
|
2940
3233
|
if (shouldEmitContextAnalyticsChanged(event)) {
|
|
2941
|
-
|
|
3234
|
+
scheduleContextAnalyticsEmit();
|
|
2942
3235
|
}
|
|
2943
3236
|
}
|
|
2944
3237
|
|
|
@@ -3085,7 +3378,23 @@ export function createDocumentRuntime(
|
|
|
3085
3378
|
}
|
|
3086
3379
|
|
|
3087
3380
|
function emitContextAnalyticsChanged(): void {
|
|
3381
|
+
// L7 Phase 1 — short-circuit when no consumer can observe the event.
|
|
3382
|
+
// The downstream cost is dominated by createRuntimeContextAnalyticsSnapshot,
|
|
3383
|
+
// which assembles ~8 facets (suggestions, navigation, review-work,
|
|
3384
|
+
// workflow-markup, etc.) and runs > 300 ms on a 40-page CCEP fixture
|
|
3385
|
+
// because its cache key includes `selection` and selection moves on
|
|
3386
|
+
// every keystroke. Skipping here when nobody is subscribed eliminates
|
|
3387
|
+
// the cost in headless/SSR/test usage. Production keeps its `onEvent`
|
|
3388
|
+
// handler, so the production typing-latency win lives in Phase 1.5
|
|
3389
|
+
// (microtask coalescing) and Phase 1.6 (per-input-axis caching of the
|
|
3390
|
+
// analytics inputs).
|
|
3391
|
+
if (options.onEvent === undefined && eventListeners.size === 0) {
|
|
3392
|
+
perfCounters.increment("emit.contextAnalytics.skippedNoListeners");
|
|
3393
|
+
return;
|
|
3394
|
+
}
|
|
3395
|
+
const t0 = performance.now();
|
|
3088
3396
|
const trackedSnapshots = collectTrackedContextAnalyticsSnapshots();
|
|
3397
|
+
perfCounters.increment("emit.contextAnalytics.collect.us", Math.round((performance.now() - t0) * 1000));
|
|
3089
3398
|
if (
|
|
3090
3399
|
lastEmittedContextAnalyticsSnapshots !== undefined &&
|
|
3091
3400
|
trackedContextAnalyticsSnapshotsEqual(lastEmittedContextAnalyticsSnapshots, trackedSnapshots)
|
|
@@ -4438,6 +4747,25 @@ function resolveSupportedFieldDisplay(
|
|
|
4438
4747
|
if (field.fieldFamily === "TOC") {
|
|
4439
4748
|
return undefined;
|
|
4440
4749
|
}
|
|
4750
|
+
if (field.fieldFamily === "PAGE") {
|
|
4751
|
+
const page = resolveRepresentativePageForStory(navigation, storyTarget);
|
|
4752
|
+
if (!page) {
|
|
4753
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
4754
|
+
}
|
|
4755
|
+
return {
|
|
4756
|
+
displayText: String(resolveDisplayedPageNumber(page)),
|
|
4757
|
+
refreshStatus: "current",
|
|
4758
|
+
};
|
|
4759
|
+
}
|
|
4760
|
+
if (field.fieldFamily === "NUMPAGES") {
|
|
4761
|
+
if (navigation.pageCount === 0) {
|
|
4762
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
4763
|
+
}
|
|
4764
|
+
return {
|
|
4765
|
+
displayText: String(navigation.pageCount),
|
|
4766
|
+
refreshStatus: "current",
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4441
4769
|
if (!field.fieldTarget) {
|
|
4442
4770
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
4443
4771
|
}
|