@beyondwork/docx-react-component 1.0.42 → 1.0.45
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 +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- 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/runtime-shortcut-dispatch.ts +28 -68
- 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-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- 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 +47 -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/page-stack/use-visible-block-range.ts +157 -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/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from "../core/state/editor-state.ts";
|
|
15
15
|
import type {
|
|
16
16
|
AddCommentParams,
|
|
17
|
+
AddCommentReplyResult,
|
|
18
|
+
AddCommentResult,
|
|
17
19
|
CommentSidebarSnapshot,
|
|
18
20
|
CommentSidebarThreadSnapshot,
|
|
19
21
|
CompatibilityReport,
|
|
@@ -113,6 +115,7 @@ import {
|
|
|
113
115
|
type RevisionStore,
|
|
114
116
|
} from "../review/store/revision-store.ts";
|
|
115
117
|
import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
|
|
118
|
+
import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
|
|
116
119
|
import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
|
|
117
120
|
import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
|
|
118
121
|
import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
|
|
@@ -204,6 +207,30 @@ import {
|
|
|
204
207
|
incrementInvalidationCounter,
|
|
205
208
|
recordPerfSample,
|
|
206
209
|
} from "../ui-tailwind/editor-surface/perf-probe.ts";
|
|
210
|
+
import {
|
|
211
|
+
createLoadScheduler,
|
|
212
|
+
type LoadScheduler,
|
|
213
|
+
} from "../io/load-scheduler.ts";
|
|
214
|
+
import {
|
|
215
|
+
createEditorStateChannel,
|
|
216
|
+
type EditorStateChannel,
|
|
217
|
+
} from "./editor-state-channel.ts";
|
|
218
|
+
import { PerfCounters } from "./perf-counters.ts";
|
|
219
|
+
import type {
|
|
220
|
+
EditorStateNamespace,
|
|
221
|
+
EditorStatePolicy,
|
|
222
|
+
EditorStateResolver,
|
|
223
|
+
EditorStatePersister,
|
|
224
|
+
} from "../api/editor-state-types.ts";
|
|
225
|
+
import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
|
|
226
|
+
import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
|
|
227
|
+
|
|
228
|
+
/** Internal extension of ExportDocxOptions that threads the collected
|
|
229
|
+
* editorState payload from the runtime to the docx serializer. */
|
|
230
|
+
interface InternalExportDocxOptions extends ExportDocxOptions {
|
|
231
|
+
/** @internal Schema 1.2 — collected by runtime before serialize. */
|
|
232
|
+
_editorState?: EditorStatePayload;
|
|
233
|
+
}
|
|
207
234
|
|
|
208
235
|
export type Unsubscribe = () => void;
|
|
209
236
|
|
|
@@ -220,6 +247,7 @@ export type ActiveStoryTextCommand =
|
|
|
220
247
|
| Extract<EditorCommand, { type: "text.delete-backward" }>
|
|
221
248
|
| Extract<EditorCommand, { type: "text.delete-forward" }>
|
|
222
249
|
| Extract<EditorCommand, { type: "text.insert-tab" }>
|
|
250
|
+
| Extract<EditorCommand, { type: "text.outdent-tab" }>
|
|
223
251
|
| Extract<EditorCommand, { type: "text.insert-hard-break" }>
|
|
224
252
|
| Extract<EditorCommand, { type: "paragraph.split" }>;
|
|
225
253
|
|
|
@@ -257,11 +285,11 @@ export interface DocumentRuntime {
|
|
|
257
285
|
blur(): void;
|
|
258
286
|
setDefaultAuthorId?(authorId?: string): void;
|
|
259
287
|
getDefaultAuthorId?(): string | undefined;
|
|
260
|
-
addComment(params: AddCommentParams):
|
|
288
|
+
addComment(params: AddCommentParams): AddCommentResult;
|
|
261
289
|
openComment(commentId: string): void;
|
|
262
290
|
resolveComment(commentId: string): void;
|
|
263
291
|
reopenComment(commentId: string): void;
|
|
264
|
-
addCommentReply(commentId: string, body: string, authorId?: string):
|
|
292
|
+
addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
|
|
265
293
|
editCommentBody(commentId: string, body: string): void;
|
|
266
294
|
acceptChange(changeId: string): void;
|
|
267
295
|
rejectChange(changeId: string): void;
|
|
@@ -341,6 +369,38 @@ export interface DocumentRuntime {
|
|
|
341
369
|
getRuntimeContextAnalytics(
|
|
342
370
|
query?: RuntimeContextAnalyticsQuery,
|
|
343
371
|
): RuntimeContextAnalyticsSnapshot | null;
|
|
372
|
+
// Schema 1.2 — EditorStateChannel delegation
|
|
373
|
+
configureEditorStatePolicy(policy: EditorStatePolicy): void;
|
|
374
|
+
registerEditorStateResolver(resolver: EditorStateResolver | null): void;
|
|
375
|
+
registerEditorStatePersister(persister: EditorStatePersister | null): void;
|
|
376
|
+
getEditorStateKey(namespace: EditorStateNamespace): string | undefined;
|
|
377
|
+
retryPendingPersist(namespace?: EditorStateNamespace): Promise<void>;
|
|
378
|
+
/** Internal: exposes the channel for load-path hydration in editor-runtime-boundary. */
|
|
379
|
+
readonly editorStateChannel: EditorStateChannel;
|
|
380
|
+
/**
|
|
381
|
+
* L7 render-perf instrumentation. Returns a string→number map of internal
|
|
382
|
+
* perf counters incremented during runtime activity. Used by perf benches
|
|
383
|
+
* and tests to assert that a given facet rebuild ran (or did not run) for
|
|
384
|
+
* a given operation. Counter names are not part of the public API contract
|
|
385
|
+
* — they may be added or renamed across L7 phases.
|
|
386
|
+
*/
|
|
387
|
+
getPerfCountersSnapshot(): Record<string, number>;
|
|
388
|
+
resetPerfCounters(): void;
|
|
389
|
+
/**
|
|
390
|
+
* Notifies the runtime that the UI's visible block range changed.
|
|
391
|
+
* The next `refreshRenderSnapshot()` (triggered by any mutation) uses this
|
|
392
|
+
* range to cull off-screen blocks in the surface projection. Calling this
|
|
393
|
+
* method alone does NOT trigger a snapshot rebuild — a subsequent mutation
|
|
394
|
+
* or an explicit `requestViewportRefresh()` is required.
|
|
395
|
+
*
|
|
396
|
+
* Safe to call at any frequency; identical ranges are a no-op.
|
|
397
|
+
*/
|
|
398
|
+
setVisibleBlockRange(range: { start: number; end: number }): void;
|
|
399
|
+
/**
|
|
400
|
+
* Triggers a surface-only refresh that applies the latest visible block range
|
|
401
|
+
* without running the full commit pipeline. Used by scroll handlers.
|
|
402
|
+
*/
|
|
403
|
+
requestViewportRefresh(): void;
|
|
344
404
|
}
|
|
345
405
|
|
|
346
406
|
export interface CommandAppliedMeta {
|
|
@@ -386,6 +446,27 @@ export interface CreateDocumentRuntimeOptions {
|
|
|
386
446
|
) => void;
|
|
387
447
|
initialViewState?: Partial<ViewState>;
|
|
388
448
|
protectionSnapshot?: ProtectionSnapshot;
|
|
449
|
+
/**
|
|
450
|
+
* Optional main-thread time-slicing scheduler. When provided, the runtime
|
|
451
|
+
* schedules post-`ready` idle work (e.g., sub-part hydration, compatibility
|
|
452
|
+
* report — reserved for future fastload passes) through it. When omitted,
|
|
453
|
+
* the runtime falls back to the sync scheduler — behavior is byte-identical
|
|
454
|
+
* to the pre-fastload runtime.
|
|
455
|
+
*
|
|
456
|
+
* See `docs/plans/fastload.md` and `src/io/load-scheduler.ts`.
|
|
457
|
+
*/
|
|
458
|
+
loadScheduler?: LoadScheduler;
|
|
459
|
+
/**
|
|
460
|
+
* L7 Phase 2.5 — prerender cache seed. When set, the internal
|
|
461
|
+
* LayoutEngineInstance is seeded with this graph so the first
|
|
462
|
+
* `getPageGraph()` call returns it directly, skipping `fullRebuild`.
|
|
463
|
+
* The seeded graph must have been produced with the current
|
|
464
|
+
* `LAYOUT_ENGINE_VERSION` (callers rely on the cache-key scheme in
|
|
465
|
+
* `src/runtime/prerender/cache-key.ts` to guarantee this).
|
|
466
|
+
*
|
|
467
|
+
* See docs/plans/lane-2-render-performance.md §Phase 2.5 §3.7.
|
|
468
|
+
*/
|
|
469
|
+
seedLayoutCache?: import("./layout/page-graph.ts").RuntimePageGraph;
|
|
389
470
|
}
|
|
390
471
|
|
|
391
472
|
interface HistoryState {
|
|
@@ -393,6 +474,84 @@ interface HistoryState {
|
|
|
393
474
|
future: EditorState[];
|
|
394
475
|
}
|
|
395
476
|
|
|
477
|
+
/**
|
|
478
|
+
* L7 Phase 1.7.4 — structural-counts hash for the reviewWork cache key.
|
|
479
|
+
*
|
|
480
|
+
* `getCachedWorkflowMarkupSnapshot()` returns a NEW object on every
|
|
481
|
+
* `revisionToken` bump, so the previous reference-equality check
|
|
482
|
+
* (`cachedReviewWork.workflowMarkup === wfMarkup`) always failed on pure
|
|
483
|
+
* text edits — forcing `createReviewWorkSnapshot` to re-walk every comment,
|
|
484
|
+
* revision, protected range, and opaque fragment on every keystroke
|
|
485
|
+
* (94 % of typing latency on large-tables, 97 % on extra-large).
|
|
486
|
+
*
|
|
487
|
+
* The hash is a colon-joined string of per-category item counts. Pure text
|
|
488
|
+
* edits within unchanged blocks do not change any count, so the hash
|
|
489
|
+
* compares equal and the cached snapshot is reused. Structural changes
|
|
490
|
+
* (comment added, revision authored, block inserted/deleted) alter at
|
|
491
|
+
* least one count, so the hash diverges and the cache misses correctly.
|
|
492
|
+
*
|
|
493
|
+
* Correctness: the cached `items` include absolute-position `anchor`s that
|
|
494
|
+
* get stale under a text edit. That is safe here because this cache lives
|
|
495
|
+
* ONLY inside `getCachedRuntimeContextAnalytics`, whose consumers
|
|
496
|
+
* (`buildDocumentAnalytics`, `buildSelectionAnalytics`, etc.) read only the
|
|
497
|
+
* numeric `openCommentCount` and `actionableRevisionCount` fields — the
|
|
498
|
+
* stale `items[].anchor` never reaches a consumer. The direct
|
|
499
|
+
* `runtime.getReviewWorkSnapshot()` API does not use this cache.
|
|
500
|
+
*/
|
|
501
|
+
function computeWorkflowMarkupStructuralHash(
|
|
502
|
+
wfMarkup: WorkflowMarkupSnapshot,
|
|
503
|
+
): string {
|
|
504
|
+
return [
|
|
505
|
+
wfMarkup.totalCount,
|
|
506
|
+
wfMarkup.highlights.length,
|
|
507
|
+
wfMarkup.metadata.length,
|
|
508
|
+
wfMarkup.comments.length,
|
|
509
|
+
wfMarkup.revisions.length,
|
|
510
|
+
wfMarkup.fields.length,
|
|
511
|
+
wfMarkup.protectedRanges.length,
|
|
512
|
+
wfMarkup.opaqueFragments.length,
|
|
513
|
+
].join(":");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* L7 Phase 1.7.4 — review-status hash for the reviewWork cache key.
|
|
518
|
+
*
|
|
519
|
+
* `state.document.review.comments` and `.revisions` are rebuilt with new
|
|
520
|
+
* object identities on every text command (via `remapReviewStateAfterContent
|
|
521
|
+
* Change` → `remapCommentThreads` → `Object.fromEntries`), so a reference
|
|
522
|
+
* check on those records always fails on pure text edits. But the
|
|
523
|
+
* `reviewWork` snapshot's observable output depends only on:
|
|
524
|
+
* - which comments are OPEN (`openCommentCount = openCommentIds.length`)
|
|
525
|
+
* - which revisions are ACTIONABLE
|
|
526
|
+
* (`actionableRevisionCount = actionableChangeIds.length`)
|
|
527
|
+
* plus the per-category item counts already captured by
|
|
528
|
+
* `computeWorkflowMarkupStructuralHash`.
|
|
529
|
+
*
|
|
530
|
+
* `comments` / `revisions` keys and statuses are stable across text edits
|
|
531
|
+
* (remap only rewrites anchors and wraps in `{...thread, anchor}` — status
|
|
532
|
+
* is carried through). Hashing "id+status" for each record is O(N) over
|
|
533
|
+
* the small review set and captures the semantic identity we need.
|
|
534
|
+
*
|
|
535
|
+
* We intentionally do NOT include anchor positions in the hash: anchor
|
|
536
|
+
* shifts are exactly the staleness the cache is allowed to tolerate (see
|
|
537
|
+
* `cachedReviewWork` correctness note above).
|
|
538
|
+
*/
|
|
539
|
+
function computeReviewStateHash(
|
|
540
|
+
review: CanonicalDocumentEnvelope["review"],
|
|
541
|
+
): string {
|
|
542
|
+
const commentParts: string[] = [];
|
|
543
|
+
for (const commentId of Object.keys(review.comments).sort()) {
|
|
544
|
+
const thread = review.comments[commentId];
|
|
545
|
+
commentParts.push(`${commentId}=${thread?.status ?? ""}`);
|
|
546
|
+
}
|
|
547
|
+
const revisionParts: string[] = [];
|
|
548
|
+
for (const revisionId of Object.keys(review.revisions).sort()) {
|
|
549
|
+
const revision = review.revisions[revisionId];
|
|
550
|
+
revisionParts.push(`${revisionId}=${revision?.status ?? ""}`);
|
|
551
|
+
}
|
|
552
|
+
return `${commentParts.join(",")}|${revisionParts.join(",")}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
396
555
|
export function createDocumentRuntime(
|
|
397
556
|
options: CreateDocumentRuntimeOptions,
|
|
398
557
|
): DocumentRuntime {
|
|
@@ -402,6 +561,29 @@ export function createDocumentRuntime(
|
|
|
402
561
|
const sessionId = createSessionId(options.documentId, clock());
|
|
403
562
|
const listeners = new Set<() => void>();
|
|
404
563
|
const eventListeners = new Set<(event: DocumentRuntimeEvent) => void>();
|
|
564
|
+
// Fastload P2: optional time-slicing scheduler. When caller does not pass
|
|
565
|
+
// one, use the sync backend so the runtime keeps its pre-fastload behavior
|
|
566
|
+
// end-to-end (no deferred work, idle tasks run inline). Future fastload
|
|
567
|
+
// passes (P6 async loader) will consume `scheduler.scheduleIdle` for
|
|
568
|
+
// post-ready work.
|
|
569
|
+
const loadScheduler: LoadScheduler =
|
|
570
|
+
options.loadScheduler ?? createLoadScheduler({ backendOverride: "sync" });
|
|
571
|
+
|
|
572
|
+
// L7 Phase 0 — perf counters. Each `refreshRenderSnapshot` call increments
|
|
573
|
+
// `refresh.all`. Phase 1 will add per-facet `facet.<name>.build` counters
|
|
574
|
+
// wired to the per-facet cached builders. Cost is one Map.set per call.
|
|
575
|
+
const perfCounters = new PerfCounters();
|
|
576
|
+
|
|
577
|
+
// L7 Phase 1 — context-analytics emit coalescing flag. Multiple events that
|
|
578
|
+
// each trigger context-analytics within one synchronous call stack collapse
|
|
579
|
+
// to a single microtask-deferred emit. Declared early because emit() (which
|
|
580
|
+
// checks this flag) runs during construction.
|
|
581
|
+
let analyticsEmitScheduled = false;
|
|
582
|
+
|
|
583
|
+
// Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
|
|
584
|
+
// Instantiated once per runtime; forwarded to the public interface.
|
|
585
|
+
const editorStateChannel = createEditorStateChannel();
|
|
586
|
+
|
|
405
587
|
const history: HistoryState = {
|
|
406
588
|
past: [],
|
|
407
589
|
future: [],
|
|
@@ -463,6 +645,16 @@ export function createDocumentRuntime(
|
|
|
463
645
|
// upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
|
|
464
646
|
// emits `measurement_backend_ready` so chrome consumers can re-read metrics.
|
|
465
647
|
const layoutEngine: LayoutEngineInstance = createLayoutEngine();
|
|
648
|
+
if (options.seedLayoutCache) {
|
|
649
|
+
// L7 Phase 2.5 — seed the cached graph from the prerender envelope so
|
|
650
|
+
// the first getPageGraph call skips fullRebuild. Seed is keyed on the
|
|
651
|
+
// runtime's current document identity so `keyEqual` passes without
|
|
652
|
+
// triggering a rebuild. The cache-key scheme in
|
|
653
|
+
// src/runtime/prerender/cache-key.ts guarantees the seed is produced
|
|
654
|
+
// by the same LAYOUT_ENGINE_VERSION.
|
|
655
|
+
layoutEngine.seedCachedGraph(options.seedLayoutCache, state.document);
|
|
656
|
+
perfCounters.increment("loadSession.layoutCache.seeded");
|
|
657
|
+
}
|
|
466
658
|
const fontLoader: DocxFontLoader = createDocxFontLoader(
|
|
467
659
|
collectFontLoaderInput(state.document),
|
|
468
660
|
);
|
|
@@ -480,6 +672,7 @@ export function createDocumentRuntime(
|
|
|
480
672
|
zoomLevel: viewState.zoomLevel,
|
|
481
673
|
},
|
|
482
674
|
}),
|
|
675
|
+
canonicalDocument: () => state.document,
|
|
483
676
|
renderKernel: () => renderKernelRef,
|
|
484
677
|
getWorkflowRailInput: () => {
|
|
485
678
|
if (!workflowOverlay) return null;
|
|
@@ -504,11 +697,40 @@ export function createDocumentRuntime(
|
|
|
504
697
|
// runtime already builds for comment/revision/search consumers.
|
|
505
698
|
getWorkflowMarkupMetadata: () =>
|
|
506
699
|
getCachedWorkflowMarkupSnapshot().metadata,
|
|
700
|
+
// L7 Phase 2 — delegate viewport culling through the facet so the
|
|
701
|
+
// workspace (which only holds a `layoutFacet` ref) can drive culling
|
|
702
|
+
// without a separate `DocumentRuntime` prop. The lambdas forward to
|
|
703
|
+
// the runtime's own `setVisibleBlockRange` / `requestViewportRefresh`
|
|
704
|
+
// implementations which are defined below. Capturing by closure works
|
|
705
|
+
// because the lambdas are only invoked after the full runtime object is
|
|
706
|
+
// built and the methods below have been assigned.
|
|
707
|
+
// TODO(L7 Phase 2 post-merge): this lambda duplicates the setVisibleBlockRange
|
|
708
|
+
// logic implemented on `runtime` at line ~2874. Consolidate after Phase 2 ships
|
|
709
|
+
// so the facet forwards to the runtime method. Forward-reference pattern matches
|
|
710
|
+
// `renderKernelRef` — see that example. See spec-review notes on commit 0b03bfa7.
|
|
711
|
+
setVisibleBlockRange: (range) => {
|
|
712
|
+
if (
|
|
713
|
+
viewportBlockRange &&
|
|
714
|
+
viewportBlockRange.start === range.start &&
|
|
715
|
+
viewportBlockRange.end === range.end
|
|
716
|
+
) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
viewportBlockRange = { start: range.start, end: range.end };
|
|
720
|
+
perfCounters.increment("runtime.viewport.updates");
|
|
721
|
+
},
|
|
722
|
+
requestViewportRefresh: () => {
|
|
723
|
+
perfCounters.increment("runtime.viewport.refreshes");
|
|
724
|
+
refreshSurfaceOnly();
|
|
725
|
+
},
|
|
507
726
|
});
|
|
508
727
|
renderKernelRef = createRenderKernel({
|
|
509
728
|
facet: layoutFacet,
|
|
510
729
|
getActiveStory: () => activeStory,
|
|
511
730
|
});
|
|
731
|
+
// L7 Phase 2 — viewport block range for surface culling.
|
|
732
|
+
let viewportBlockRange: { start: number; end: number } | null = null;
|
|
733
|
+
|
|
512
734
|
let cachedSurface:
|
|
513
735
|
| {
|
|
514
736
|
revisionToken: string;
|
|
@@ -544,6 +766,66 @@ export function createDocumentRuntime(
|
|
|
544
766
|
snapshot: SuggestionsSnapshot;
|
|
545
767
|
}
|
|
546
768
|
| undefined;
|
|
769
|
+
// L7 Phase 1 — review-work snapshot cache. Independent of selection.
|
|
770
|
+
//
|
|
771
|
+
// What we cache. `createReviewWorkSnapshot`'s observable output is:
|
|
772
|
+
// - `openCommentCount` = `comments.openCommentIds.length`
|
|
773
|
+
// - `actionableRevisionCount` = `trackedChanges.actionableChangeIds.length`
|
|
774
|
+
// - `items[]` (each with stale-tolerable `anchor`/`sectionIndex`/
|
|
775
|
+
// `pageIndex` — see "Correctness" below)
|
|
776
|
+
//
|
|
777
|
+
// Cache-busting inputs (L7 Phase 1.7.4 — two complementary hashes):
|
|
778
|
+
// - `workflowMarkupHash` — structural-counts hash over wfMarkup.
|
|
779
|
+
// Replaces the old `workflowMarkup === wfMarkup` reference check,
|
|
780
|
+
// which always failed on pure text edits because
|
|
781
|
+
// `getCachedWorkflowMarkupSnapshot()` is keyed on revisionToken.
|
|
782
|
+
// Pure text edits keep all per-category item counts stable → hash
|
|
783
|
+
// equal. A comment ADDITION, revision AUTHORED, protected-range added,
|
|
784
|
+
// opaque-fragment added → at least one count changes → hash diverges.
|
|
785
|
+
// - `reviewStateHash` — "id+status" hash over `state.document.review`.
|
|
786
|
+
// Captures the open/resolved/detached distribution that item COUNTS
|
|
787
|
+
// alone can't see (a comment RESOLVED leaves the count stable but
|
|
788
|
+
// shifts openCommentCount). Stable across text edits because
|
|
789
|
+
// `remapCommentThreads` preserves status on every record.
|
|
790
|
+
//
|
|
791
|
+
// Note we intentionally DO NOT key on `cachedRenderSnapshot.comments` or
|
|
792
|
+
// `.trackedChanges` (the snapshot references) because those projections
|
|
793
|
+
// are rebuilt on every revisionToken bump — their text previews depend on
|
|
794
|
+
// live document text. And we DO NOT key on `state.document.review.comments`
|
|
795
|
+
// or `.revisions` directly: every text command rebuilds those records via
|
|
796
|
+
// `remapReviewStateAfterContentChange`, producing new object identities
|
|
797
|
+
// even when no anchor or status changed. The two hashes above capture the
|
|
798
|
+
// semantic identity that actually matters for ReviewWorkSnapshot output
|
|
799
|
+
// without churning on each keystroke.
|
|
800
|
+
//
|
|
801
|
+
// We also DO NOT store a `document` or `navigation` reference: both churn
|
|
802
|
+
// on every revisionToken bump, so those checks forced a 100 % miss rate
|
|
803
|
+
// on text edits with no compensating correctness gain.
|
|
804
|
+
//
|
|
805
|
+
// Correctness. Any change that affects the cached snapshot's observable
|
|
806
|
+
// output (`openCommentCount`, `actionableRevisionCount`, or the set of
|
|
807
|
+
// workflow items) shifts at least one of (workflowMarkupHash,
|
|
808
|
+
// reviewStateHash). The per-item `anchor` positions stored on stale cached
|
|
809
|
+
// items can be out of date, but this cache is consumed only inside
|
|
810
|
+
// `getCachedRuntimeContextAnalytics` where downstream
|
|
811
|
+
// `buildDocumentAnalytics` / `buildSelectionAnalytics` /
|
|
812
|
+
// `buildWorkflowScopeAnalytics` / `buildWorkItemAnalytics` read ONLY
|
|
813
|
+
// `reviewWorkSnapshot.openCommentCount` and `.actionableRevisionCount`
|
|
814
|
+
// (numbers). The stale `items[].anchor` never reaches a consumer. The
|
|
815
|
+
// public `runtime.getReviewWorkSnapshot()` API calls
|
|
816
|
+
// `createReviewWorkSnapshot` fresh and does not use this cache.
|
|
817
|
+
//
|
|
818
|
+
// Per-keystroke profiling on CCEP fixtures showed createReviewWorkSnapshot
|
|
819
|
+
// at 188 ms/call on large-tables (40 pp) and ~5 s/call on extra-large
|
|
820
|
+
// (100 pp) — dominated by per-item createDocumentLocationSnapshot. Phase
|
|
821
|
+
// 1.7.4 takes the 20/20 miss rate on pure text edits to ≤2/20.
|
|
822
|
+
let cachedReviewWork:
|
|
823
|
+
| {
|
|
824
|
+
workflowMarkupHash: string;
|
|
825
|
+
reviewStateHash: string;
|
|
826
|
+
snapshot: ReviewWorkSnapshot;
|
|
827
|
+
}
|
|
828
|
+
| undefined;
|
|
547
829
|
let cachedPageLayout:
|
|
548
830
|
| {
|
|
549
831
|
revisionToken: string;
|
|
@@ -606,7 +888,9 @@ export function createDocumentRuntime(
|
|
|
606
888
|
{
|
|
607
889
|
revisionToken: string;
|
|
608
890
|
activeStoryKey: string;
|
|
609
|
-
|
|
891
|
+
// L7 Phase 1.6: null for non-selection queries (doc/scope/work_item)
|
|
892
|
+
// so selection movement doesn't invalidate these scope-stable analytics.
|
|
893
|
+
selection: EditorState["selection"] | null;
|
|
610
894
|
readOnly: boolean;
|
|
611
895
|
documentMode: DocumentMode;
|
|
612
896
|
workflowOverlay: WorkflowOverlay | null;
|
|
@@ -633,7 +917,7 @@ export function createDocumentRuntime(
|
|
|
633
917
|
return cachedSurface.snapshot;
|
|
634
918
|
}
|
|
635
919
|
|
|
636
|
-
const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory);
|
|
920
|
+
const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, { viewportBlockRange });
|
|
637
921
|
recordPerfSample("snapshot.surface");
|
|
638
922
|
incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
|
|
639
923
|
cachedSurface = {
|
|
@@ -1366,6 +1650,10 @@ export function createDocumentRuntime(
|
|
|
1366
1650
|
protectionSnapshot,
|
|
1367
1651
|
preservation: state.document.preservation,
|
|
1368
1652
|
workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
|
|
1653
|
+
perfStage: (name, durationMs) => {
|
|
1654
|
+
perfCounters.increment(`wfMarkup.${name}.us`, Math.round(durationMs * 1000));
|
|
1655
|
+
perfCounters.increment(`wfMarkup.${name}.calls`);
|
|
1656
|
+
},
|
|
1369
1657
|
});
|
|
1370
1658
|
cachedWorkflowMarkupSnapshot = {
|
|
1371
1659
|
revisionToken: state.revisionToken,
|
|
@@ -1386,11 +1674,21 @@ export function createDocumentRuntime(
|
|
|
1386
1674
|
const activeStoryKey = storyTargetKey(activeStory);
|
|
1387
1675
|
const queryKey = getRuntimeContextAnalyticsQueryKey(query);
|
|
1388
1676
|
const cachedEntry = cachedContextAnalyticsSnapshots.get(queryKey);
|
|
1677
|
+
// L7 Phase 1.6 — only `scopeKind: "selection"` queries actually read
|
|
1678
|
+
// selection-anchored data (buildSelectionAnalytics filters markup items
|
|
1679
|
+
// that intersect the selection anchor). For document / workflow_scope /
|
|
1680
|
+
// work_item queries the selection is not an input, so changing it
|
|
1681
|
+
// shouldn't invalidate the cache. When selection moves within the same
|
|
1682
|
+
// workflow scope without text editing, document/scope/work_item analytics
|
|
1683
|
+
// hit; cursor-navigation bench drops accordingly.
|
|
1684
|
+
const effectiveScopeKind = query?.scopeKind ?? "selection";
|
|
1685
|
+
const selectionCacheKey =
|
|
1686
|
+
effectiveScopeKind === "selection" ? state.selection : null;
|
|
1389
1687
|
if (
|
|
1390
1688
|
cachedEntry &&
|
|
1391
1689
|
cachedEntry.revisionToken === state.revisionToken &&
|
|
1392
1690
|
cachedEntry.activeStoryKey === activeStoryKey &&
|
|
1393
|
-
cachedEntry.selection ===
|
|
1691
|
+
cachedEntry.selection === selectionCacheKey &&
|
|
1394
1692
|
cachedEntry.readOnly === state.readOnly &&
|
|
1395
1693
|
cachedEntry.documentMode === viewState.documentMode &&
|
|
1396
1694
|
cachedEntry.workflowOverlay === workflowOverlay &&
|
|
@@ -1401,28 +1699,85 @@ export function createDocumentRuntime(
|
|
|
1401
1699
|
return cachedEntry.snapshot;
|
|
1402
1700
|
}
|
|
1403
1701
|
|
|
1702
|
+
const tWf = performance.now();
|
|
1703
|
+
const wfScope = getCachedWorkflowScopeSnapshot();
|
|
1704
|
+
const wfGuard = getCachedInteractionGuardSnapshot();
|
|
1705
|
+
const wfMarkup = getCachedWorkflowMarkupSnapshot();
|
|
1706
|
+
perfCounters.increment("ctxa.workflow.us", Math.round((performance.now() - tWf) * 1000));
|
|
1707
|
+
|
|
1708
|
+
const tSugg = performance.now();
|
|
1709
|
+
const suggestions = getCachedSuggestionsSnapshot(state);
|
|
1710
|
+
perfCounters.increment("ctxa.suggestions.us", Math.round((performance.now() - tSugg) * 1000));
|
|
1711
|
+
|
|
1712
|
+
const tNav = performance.now();
|
|
1713
|
+
const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
|
|
1714
|
+
perfCounters.increment("ctxa.navigation.us", Math.round((performance.now() - tNav) * 1000));
|
|
1715
|
+
|
|
1716
|
+
const tReview = performance.now();
|
|
1717
|
+
let reviewWork: ReviewWorkSnapshot;
|
|
1718
|
+
// L7 Phase 1.7.4 — two structural hashes replace the old reference
|
|
1719
|
+
// checks. `workflowMarkupHash` captures per-category wfMarkup item
|
|
1720
|
+
// counts (see computeWorkflowMarkupStructuralHash); `reviewStateHash`
|
|
1721
|
+
// captures id+status over state.document.review (see
|
|
1722
|
+
// computeReviewStateHash). Together they bust on any mutation that can
|
|
1723
|
+
// change ReviewWorkSnapshot's observable output
|
|
1724
|
+
// (openCommentCount, actionableRevisionCount, or the item set), while
|
|
1725
|
+
// staying stable under pure text edits so the expensive
|
|
1726
|
+
// createReviewWorkSnapshot walk is skipped. The prior `comments` /
|
|
1727
|
+
// `trackedChanges` / `workflowMarkup` / `document` / `navigation`
|
|
1728
|
+
// reference checks are dropped: all of them were churned on every
|
|
1729
|
+
// revisionToken bump and forced a 20/20 miss rate. See the
|
|
1730
|
+
// `cachedReviewWork` declaration above for the full rationale.
|
|
1731
|
+
const wfMarkupHash = computeWorkflowMarkupStructuralHash(wfMarkup);
|
|
1732
|
+
const reviewStateHash = computeReviewStateHash(state.document.review);
|
|
1733
|
+
if (
|
|
1734
|
+
cachedReviewWork &&
|
|
1735
|
+
cachedReviewWork.workflowMarkupHash === wfMarkupHash &&
|
|
1736
|
+
cachedReviewWork.reviewStateHash === reviewStateHash
|
|
1737
|
+
) {
|
|
1738
|
+
reviewWork = cachedReviewWork.snapshot;
|
|
1739
|
+
perfCounters.increment("ctxa.reviewWork.cacheHit");
|
|
1740
|
+
} else {
|
|
1741
|
+
reviewWork = createReviewWorkSnapshot({
|
|
1742
|
+
comments: cachedRenderSnapshot.comments,
|
|
1743
|
+
trackedChanges: cachedRenderSnapshot.trackedChanges,
|
|
1744
|
+
workflowMarkup: wfMarkup,
|
|
1745
|
+
document: state.document,
|
|
1746
|
+
navigation,
|
|
1747
|
+
});
|
|
1748
|
+
cachedReviewWork = {
|
|
1749
|
+
workflowMarkupHash: wfMarkupHash,
|
|
1750
|
+
reviewStateHash,
|
|
1751
|
+
snapshot: reviewWork,
|
|
1752
|
+
};
|
|
1753
|
+
perfCounters.increment("ctxa.reviewWork.cacheMiss");
|
|
1754
|
+
}
|
|
1755
|
+
perfCounters.increment("ctxa.reviewWork.us", Math.round((performance.now() - tReview) * 1000));
|
|
1756
|
+
|
|
1757
|
+
const tCompat = performance.now();
|
|
1758
|
+
const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
|
|
1759
|
+
perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
|
|
1760
|
+
|
|
1761
|
+
const tBuild = performance.now();
|
|
1404
1762
|
const snapshot = createRuntimeContextAnalyticsSnapshot({
|
|
1405
1763
|
query,
|
|
1406
1764
|
renderSnapshot: cachedRenderSnapshot,
|
|
1407
1765
|
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
|
-
}),
|
|
1766
|
+
workflowScopeSnapshot: wfScope,
|
|
1767
|
+
interactionGuardSnapshot: wfGuard,
|
|
1768
|
+
workflowMarkupSnapshot: wfMarkup,
|
|
1769
|
+
suggestionsSnapshot: suggestions,
|
|
1770
|
+
reviewWorkSnapshot: reviewWork,
|
|
1419
1771
|
warnings: state.warnings.map(toPublicWarning),
|
|
1420
|
-
compatibility:
|
|
1772
|
+
compatibility: compat,
|
|
1421
1773
|
});
|
|
1774
|
+
perfCounters.increment("ctxa.build.us", Math.round((performance.now() - tBuild) * 1000));
|
|
1422
1775
|
cachedContextAnalyticsSnapshots.set(queryKey, {
|
|
1423
1776
|
revisionToken: state.revisionToken,
|
|
1424
1777
|
activeStoryKey,
|
|
1425
|
-
|
|
1778
|
+
// L7 Phase 1.6: store the selectionCacheKey we tested against, so the
|
|
1779
|
+
// same scopeKind invariant applies to future reads.
|
|
1780
|
+
selection: selectionCacheKey,
|
|
1426
1781
|
readOnly: state.readOnly,
|
|
1427
1782
|
documentMode: viewState.documentMode,
|
|
1428
1783
|
workflowOverlay,
|
|
@@ -1509,8 +1864,17 @@ export function createDocumentRuntime(
|
|
|
1509
1864
|
return true;
|
|
1510
1865
|
}
|
|
1511
1866
|
|
|
1867
|
+
function timeFacet<T>(name: string, fn: () => T): T {
|
|
1868
|
+
const t0 = performance.now();
|
|
1869
|
+
const result = fn();
|
|
1870
|
+
perfCounters.increment(`facet.${name}.us`, Math.round((performance.now() - t0) * 1000));
|
|
1871
|
+
perfCounters.increment(`facet.${name}.calls`);
|
|
1872
|
+
return result;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1512
1875
|
function refreshRenderSnapshot(): RuntimeRenderSnapshot {
|
|
1513
|
-
|
|
1876
|
+
perfCounters.increment("refresh.all");
|
|
1877
|
+
const surface = timeFacet("surface", () => getCachedSurface(state.document, activeStory));
|
|
1514
1878
|
return {
|
|
1515
1879
|
documentId: state.documentId,
|
|
1516
1880
|
sessionId: state.sessionId,
|
|
@@ -1522,11 +1886,11 @@ export function createDocumentRuntime(
|
|
|
1522
1886
|
documentMode: viewState.documentMode,
|
|
1523
1887
|
selection: toPublicSelectionSnapshot(state.selection, activeStory),
|
|
1524
1888
|
activeStory,
|
|
1525
|
-
pageLayout: getCachedPageLayoutSnapshot(state, activeStory) ?? undefined,
|
|
1889
|
+
pageLayout: timeFacet("layout", () => getCachedPageLayoutSnapshot(state, activeStory)) ?? undefined,
|
|
1526
1890
|
documentStats: toPublicDocumentStats(state),
|
|
1527
|
-
comments: getCachedCommentSidebarSnapshot(state),
|
|
1528
|
-
trackedChanges: getCachedTrackedChangesSnapshot(state, surface),
|
|
1529
|
-
compatibility: getCachedCompatibilityReport(state),
|
|
1891
|
+
comments: timeFacet("comments", () => getCachedCommentSidebarSnapshot(state)),
|
|
1892
|
+
trackedChanges: timeFacet("trackedChanges", () => getCachedTrackedChangesSnapshot(state, surface)),
|
|
1893
|
+
compatibility: timeFacet("compatibility", () => getCachedCompatibilityReport(state)),
|
|
1530
1894
|
warnings: state.warnings.map((warning) => toPublicWarning(warning)),
|
|
1531
1895
|
fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
|
|
1532
1896
|
commandState: {
|
|
@@ -1539,6 +1903,38 @@ export function createDocumentRuntime(
|
|
|
1539
1903
|
};
|
|
1540
1904
|
}
|
|
1541
1905
|
|
|
1906
|
+
/**
|
|
1907
|
+
* L7 Phase 2 — surface-only refresh triggered by viewport scroll.
|
|
1908
|
+
* Rebuilds only the surface facet (with the current viewportBlockRange) and
|
|
1909
|
+
* splices it into cachedRenderSnapshot. Does NOT rebuild layout, comments,
|
|
1910
|
+
* trackedChanges, or compatibility. Does NOT increment "refresh.all".
|
|
1911
|
+
*/
|
|
1912
|
+
function refreshSurfaceOnly(): void {
|
|
1913
|
+
const newSurface = createEditorSurfaceSnapshot(
|
|
1914
|
+
state.document,
|
|
1915
|
+
state.selection,
|
|
1916
|
+
activeStory,
|
|
1917
|
+
{ viewportBlockRange },
|
|
1918
|
+
);
|
|
1919
|
+
// Refresh the cache with the just-built snapshot so a subsequent
|
|
1920
|
+
// refreshRenderSnapshot (same revisionToken + activeStoryKey) hits cache.
|
|
1921
|
+
// Without this repopulate, the next non-mutation refresh wastes a second
|
|
1922
|
+
// createEditorSurfaceSnapshot call on an identical rebuild.
|
|
1923
|
+
cachedSurface = {
|
|
1924
|
+
revisionToken: state.revisionToken,
|
|
1925
|
+
activeStoryKey: storyTargetKey(activeStory),
|
|
1926
|
+
snapshot: newSurface,
|
|
1927
|
+
};
|
|
1928
|
+
cachedRenderSnapshot = {
|
|
1929
|
+
...cachedRenderSnapshot,
|
|
1930
|
+
surface: newSurface,
|
|
1931
|
+
};
|
|
1932
|
+
// Notify listeners using the same pattern as other setX methods.
|
|
1933
|
+
for (const listener of listeners) {
|
|
1934
|
+
listener();
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1542
1938
|
function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
|
|
1543
1939
|
const activeStoryKey = storyTargetKey(activeStory);
|
|
1544
1940
|
const pageLayout = cachedRenderSnapshot.pageLayout;
|
|
@@ -1604,6 +2000,40 @@ export function createDocumentRuntime(
|
|
|
1604
2000
|
});
|
|
1605
2001
|
}
|
|
1606
2002
|
|
|
2003
|
+
// Schema 1.2: forward channel events to the runtime event bus.
|
|
2004
|
+
editorStateChannel.addListener((event) => {
|
|
2005
|
+
switch (event.kind) {
|
|
2006
|
+
case "load_failed":
|
|
2007
|
+
emit({
|
|
2008
|
+
type: "editor_state_part_load_failed",
|
|
2009
|
+
documentId: state.documentId,
|
|
2010
|
+
failure: event.failure,
|
|
2011
|
+
});
|
|
2012
|
+
break;
|
|
2013
|
+
case "persist_failed":
|
|
2014
|
+
emit({
|
|
2015
|
+
type: "editor_state_part_persist_failed",
|
|
2016
|
+
documentId: state.documentId,
|
|
2017
|
+
failure: event.failure,
|
|
2018
|
+
});
|
|
2019
|
+
break;
|
|
2020
|
+
case "policy_migrated":
|
|
2021
|
+
emit({
|
|
2022
|
+
type: "editor_state_policy_migrated",
|
|
2023
|
+
documentId: state.documentId,
|
|
2024
|
+
migration: event.migration,
|
|
2025
|
+
});
|
|
2026
|
+
break;
|
|
2027
|
+
case "unknown_namespace":
|
|
2028
|
+
emit({
|
|
2029
|
+
type: "editor_state_unknown_namespace",
|
|
2030
|
+
documentId: state.documentId,
|
|
2031
|
+
namespace: event.namespace,
|
|
2032
|
+
});
|
|
2033
|
+
break;
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
|
|
1607
2037
|
return {
|
|
1608
2038
|
subscribe(listener) {
|
|
1609
2039
|
listeners.add(listener);
|
|
@@ -1832,13 +2262,13 @@ export function createDocumentRuntime(
|
|
|
1832
2262
|
const selection = params.anchor
|
|
1833
2263
|
? createSelectionFromPublicAnchor(params.anchor)
|
|
1834
2264
|
: state.selection;
|
|
1835
|
-
if (!canCreateDocxCommentAnchor(
|
|
2265
|
+
if (!canCreateDocxCommentAnchor(cachedRenderSnapshot.surface, anchor)) {
|
|
1836
2266
|
const error: InternalEditorError = {
|
|
1837
2267
|
errorId: createSessionId("comment-anchor", clock()),
|
|
1838
2268
|
code: "validation_failed",
|
|
1839
2269
|
isFatal: false,
|
|
1840
2270
|
message:
|
|
1841
|
-
"DOCX comments must use a non-empty range that stays within a single
|
|
2271
|
+
"DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
|
|
1842
2272
|
source: "runtime",
|
|
1843
2273
|
details: {
|
|
1844
2274
|
reason: "invalid_comment_anchor",
|
|
@@ -1880,7 +2310,10 @@ export function createDocumentRuntime(
|
|
|
1880
2310
|
origin: createOrigin("api", clock()),
|
|
1881
2311
|
});
|
|
1882
2312
|
|
|
1883
|
-
return
|
|
2313
|
+
return {
|
|
2314
|
+
commentId,
|
|
2315
|
+
anchor: toPublicAnchorProjection(anchor),
|
|
2316
|
+
};
|
|
1884
2317
|
},
|
|
1885
2318
|
openComment(commentId) {
|
|
1886
2319
|
this.dispatch({
|
|
@@ -1905,6 +2338,8 @@ export function createDocumentRuntime(
|
|
|
1905
2338
|
});
|
|
1906
2339
|
},
|
|
1907
2340
|
addCommentReply(commentId, body, authorId) {
|
|
2341
|
+
const priorEntryCount =
|
|
2342
|
+
state.document.review.comments[commentId]?.entries?.length ?? 0;
|
|
1908
2343
|
this.dispatch({
|
|
1909
2344
|
type: "comment.add-reply",
|
|
1910
2345
|
commentId,
|
|
@@ -1912,6 +2347,8 @@ export function createDocumentRuntime(
|
|
|
1912
2347
|
authorId: authorId ?? defaultAuthorId,
|
|
1913
2348
|
origin: createOrigin("api", clock()),
|
|
1914
2349
|
});
|
|
2350
|
+
const entryId = `${commentId}-entry-${priorEntryCount + 1}`;
|
|
2351
|
+
return { commentId, entryId };
|
|
1915
2352
|
},
|
|
1916
2353
|
editCommentBody(commentId, body) {
|
|
1917
2354
|
this.dispatch({
|
|
@@ -2254,7 +2691,37 @@ export function createDocumentRuntime(
|
|
|
2254
2691
|
throw toStructuredRuntimeException(error);
|
|
2255
2692
|
}
|
|
2256
2693
|
|
|
2257
|
-
|
|
2694
|
+
// Schema 1.2: collect editor-state payload (flushes pending persists).
|
|
2695
|
+
const collectedEditorState = await collectEditorStateForSerialize({
|
|
2696
|
+
channel: editorStateChannel,
|
|
2697
|
+
getNamespaceData: (ns) => {
|
|
2698
|
+
switch (ns) {
|
|
2699
|
+
case "hostAnnotations": {
|
|
2700
|
+
const snap = deriveHostAnnotationSnapshot();
|
|
2701
|
+
if (!snap || snap.annotations.length === 0) return null;
|
|
2702
|
+
return { schemaVersion: "host-annotation-overlay/1", data: snap };
|
|
2703
|
+
}
|
|
2704
|
+
case "workflowOverlay": {
|
|
2705
|
+
const ov = workflowOverlay;
|
|
2706
|
+
if (!ov) return null;
|
|
2707
|
+
return { schemaVersion: "workflow-overlay/1", data: ov };
|
|
2708
|
+
}
|
|
2709
|
+
case "workflowMetadata": {
|
|
2710
|
+
const meta = deriveWorkflowMetadataSnapshot();
|
|
2711
|
+
if (!meta || (meta.definitions.length === 0 && meta.entries.length === 0)) return null;
|
|
2712
|
+
return { schemaVersion: "workflow-metadata/1", data: meta };
|
|
2713
|
+
}
|
|
2714
|
+
case "workItems":
|
|
2715
|
+
return null;
|
|
2716
|
+
}
|
|
2717
|
+
},
|
|
2718
|
+
});
|
|
2719
|
+
const internalOptions: InternalExportDocxOptions = {
|
|
2720
|
+
...exportOptions,
|
|
2721
|
+
_editorState: collectedEditorState,
|
|
2722
|
+
};
|
|
2723
|
+
|
|
2724
|
+
const result = await options.exportDocx(this.getSessionState(), internalOptions);
|
|
2258
2725
|
|
|
2259
2726
|
emit({
|
|
2260
2727
|
type: "export_completed",
|
|
@@ -2270,6 +2737,11 @@ export function createDocumentRuntime(
|
|
|
2270
2737
|
overlay,
|
|
2271
2738
|
origin: createOrigin("api", clock()),
|
|
2272
2739
|
});
|
|
2740
|
+
editorStateChannel.recordMutation("workflowOverlay", {
|
|
2741
|
+
namespace: "workflowOverlay",
|
|
2742
|
+
schemaVersion: "workflow-overlay/1",
|
|
2743
|
+
data: overlay,
|
|
2744
|
+
});
|
|
2273
2745
|
},
|
|
2274
2746
|
clearWorkflowOverlay() {
|
|
2275
2747
|
this.dispatch({
|
|
@@ -2308,6 +2780,11 @@ export function createDocumentRuntime(
|
|
|
2308
2780
|
entries,
|
|
2309
2781
|
origin: createOrigin("api", clock()),
|
|
2310
2782
|
});
|
|
2783
|
+
editorStateChannel.recordMutation("workflowMetadata", {
|
|
2784
|
+
namespace: "workflowMetadata",
|
|
2785
|
+
schemaVersion: "workflow-metadata/1",
|
|
2786
|
+
data: entries,
|
|
2787
|
+
});
|
|
2311
2788
|
},
|
|
2312
2789
|
clearWorkflowMetadataEntries() {
|
|
2313
2790
|
this.dispatch({
|
|
@@ -2324,6 +2801,11 @@ export function createDocumentRuntime(
|
|
|
2324
2801
|
overlay,
|
|
2325
2802
|
origin: createOrigin("api", clock()),
|
|
2326
2803
|
});
|
|
2804
|
+
editorStateChannel.recordMutation("hostAnnotations", {
|
|
2805
|
+
namespace: "hostAnnotations",
|
|
2806
|
+
schemaVersion: "host-annotation-overlay/1",
|
|
2807
|
+
data: overlay,
|
|
2808
|
+
});
|
|
2327
2809
|
},
|
|
2328
2810
|
clearHostAnnotationOverlay() {
|
|
2329
2811
|
this.dispatch({
|
|
@@ -2391,6 +2873,46 @@ export function createDocumentRuntime(
|
|
|
2391
2873
|
getRuntimeContextAnalytics(query) {
|
|
2392
2874
|
return getCachedRuntimeContextAnalytics(query);
|
|
2393
2875
|
},
|
|
2876
|
+
// Schema 1.2 — EditorStateChannel delegation
|
|
2877
|
+
configureEditorStatePolicy(policy) {
|
|
2878
|
+
editorStateChannel.setPolicy(policy);
|
|
2879
|
+
},
|
|
2880
|
+
registerEditorStateResolver(resolver) {
|
|
2881
|
+
editorStateChannel.setResolver(resolver);
|
|
2882
|
+
},
|
|
2883
|
+
registerEditorStatePersister(persister) {
|
|
2884
|
+
editorStateChannel.setPersister(persister);
|
|
2885
|
+
},
|
|
2886
|
+
getEditorStateKey(namespace) {
|
|
2887
|
+
return editorStateChannel.getKey(namespace);
|
|
2888
|
+
},
|
|
2889
|
+
async retryPendingPersist(namespace) {
|
|
2890
|
+
await editorStateChannel.retry(namespace);
|
|
2891
|
+
},
|
|
2892
|
+
get editorStateChannel() {
|
|
2893
|
+
return editorStateChannel;
|
|
2894
|
+
},
|
|
2895
|
+
getPerfCountersSnapshot() {
|
|
2896
|
+
return perfCounters.snapshot();
|
|
2897
|
+
},
|
|
2898
|
+
resetPerfCounters() {
|
|
2899
|
+
perfCounters.reset();
|
|
2900
|
+
},
|
|
2901
|
+
setVisibleBlockRange(range) {
|
|
2902
|
+
if (
|
|
2903
|
+
viewportBlockRange &&
|
|
2904
|
+
viewportBlockRange.start === range.start &&
|
|
2905
|
+
viewportBlockRange.end === range.end
|
|
2906
|
+
) {
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
viewportBlockRange = { start: range.start, end: range.end };
|
|
2910
|
+
perfCounters.increment("runtime.viewport.updates");
|
|
2911
|
+
},
|
|
2912
|
+
requestViewportRefresh() {
|
|
2913
|
+
perfCounters.increment("runtime.viewport.refreshes");
|
|
2914
|
+
refreshSurfaceOnly();
|
|
2915
|
+
},
|
|
2394
2916
|
};
|
|
2395
2917
|
|
|
2396
2918
|
function applyHistory(direction: "undo" | "redo"): void {
|
|
@@ -2438,15 +2960,14 @@ export function createDocumentRuntime(
|
|
|
2438
2960
|
function applyTransactionToState(transaction: EditorTransaction): void {
|
|
2439
2961
|
const previous = state;
|
|
2440
2962
|
|
|
2963
|
+
const tApply0 = performance.now();
|
|
2441
2964
|
protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
|
|
2965
|
+
const tFinalize0 = performance.now();
|
|
2442
2966
|
state = finalizeState(transaction.nextState, transaction.markDirty, clock());
|
|
2967
|
+
perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
|
|
2443
2968
|
storySelections.set(storyTargetKey(activeStory), state.selection);
|
|
2444
2969
|
|
|
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.
|
|
2970
|
+
const tInvalidate0 = performance.now();
|
|
2450
2971
|
if (transaction.markDirty && transaction.mapping.steps.length > 0) {
|
|
2451
2972
|
let minFrom = Infinity;
|
|
2452
2973
|
let maxTo = -Infinity;
|
|
@@ -2459,22 +2980,56 @@ export function createDocumentRuntime(
|
|
|
2459
2980
|
layoutEngine.invalidate({ kind: "content-edit", from: minFrom, to: maxTo });
|
|
2460
2981
|
}
|
|
2461
2982
|
}
|
|
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
2983
|
if (previous.document.subParts !== state.document.subParts) {
|
|
2472
2984
|
fontLoader.refresh(collectFontLoaderInput(state.document));
|
|
2473
2985
|
layoutEngine.invalidateMeasurementCache();
|
|
2474
2986
|
}
|
|
2987
|
+
perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
|
|
2988
|
+
|
|
2989
|
+
// I5: validate post-mutation selection against the new document bound.
|
|
2990
|
+
// First call to getCachedSurface() here computes the post-mutation surface
|
|
2991
|
+
// snapshot (cache miss — revisionToken just changed in finalizeState).
|
|
2992
|
+
// refreshRenderSnapshot's internal call below hits the cache and reuses it.
|
|
2993
|
+
// Net cost: identical to pre-I5 — one surface walk per commit, just shifted
|
|
2994
|
+
// a few lines earlier so the validator can read storySize.
|
|
2995
|
+
//
|
|
2996
|
+
// Wired ONLY at this chokepoint. The other 12 `cachedRenderSnapshot =
|
|
2997
|
+
// refreshRenderSnapshot()` sites in this file are intentionally skipped:
|
|
2998
|
+
// - Initial setup (line ~1878): no prior selection to validate.
|
|
2999
|
+
// - View-mode setters (lines ~2313–2351): zoom/mode/preview changes don't
|
|
3000
|
+
// change state.document; validator pass would always be identity.
|
|
3001
|
+
// - applyHistory / undo-redo (line ~2813): restores a {document, selection}
|
|
3002
|
+
// snapshot that was already validated at the commit that produced it.
|
|
3003
|
+
// - Workflow overlay / error / story switch (lines ~3398, 3416, 3582, 3608):
|
|
3004
|
+
// substitute a stored selection that was validated at the commit that
|
|
3005
|
+
// stored it. If a future structural command can mutate state.document
|
|
3006
|
+
// without going through applyTransactionToState, validation must be added
|
|
3007
|
+
// at that site.
|
|
3008
|
+
//
|
|
3009
|
+
// Using the post-mutation storySize (not cachedRenderSnapshot's pre-mutation
|
|
3010
|
+
// storySize) avoids clamping the caret behind every keystroke at end-of-doc.
|
|
3011
|
+
// The return type of getCachedSurface is `RuntimeRenderSnapshot["surface"]`
|
|
3012
|
+
// which is optional in the public API for shape-only reasons — the helper
|
|
3013
|
+
// itself always returns a defined snapshot.
|
|
3014
|
+
const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
|
|
3015
|
+
const validatedSelection = validateSelectionAgainstDocument(
|
|
3016
|
+
state.document,
|
|
3017
|
+
state.selection,
|
|
3018
|
+
surfaceForValidation.storySize,
|
|
3019
|
+
);
|
|
3020
|
+
if (validatedSelection !== state.selection) {
|
|
3021
|
+
state = { ...state, selection: validatedSelection };
|
|
3022
|
+
storySelections.set(storyTargetKey(activeStory), state.selection);
|
|
3023
|
+
}
|
|
2475
3024
|
|
|
3025
|
+
const tRefresh0 = performance.now();
|
|
2476
3026
|
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
3027
|
+
perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
|
|
3028
|
+
|
|
3029
|
+
const tNotify0 = performance.now();
|
|
2477
3030
|
notify(previous, state, transaction);
|
|
3031
|
+
perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
|
|
3032
|
+
perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
|
|
2478
3033
|
}
|
|
2479
3034
|
|
|
2480
3035
|
function notify(
|
|
@@ -2935,10 +3490,39 @@ export function createDocumentRuntime(
|
|
|
2935
3490
|
return nextReview;
|
|
2936
3491
|
}
|
|
2937
3492
|
|
|
3493
|
+
// L7 Phase 1 — context-analytics emissions are coalesced per microtask.
|
|
3494
|
+
// A single keystroke commit fires multiple events that each independently
|
|
3495
|
+
// trigger `emitContextAnalyticsChanged` (selection_changed, change_authored,
|
|
3496
|
+
// …). Each emission re-walks every workflow markup item (~211 ms on a
|
|
3497
|
+
// 40-page CCEP fixture) so unbatched, an N-event commit costs N × 211 ms.
|
|
3498
|
+
// Coalescing collapses the burst to a single emit at the tail of the
|
|
3499
|
+
// synchronous call stack — one per commit. (Flag declared above near
|
|
3500
|
+
// perfCounters so it is initialized before construction-time emits run.)
|
|
3501
|
+
function scheduleContextAnalyticsEmit(): void {
|
|
3502
|
+
if (analyticsEmitScheduled) {
|
|
3503
|
+
perfCounters.increment("emit.contextAnalytics.coalesced");
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
analyticsEmitScheduled = true;
|
|
3507
|
+
queueMicrotask(() => {
|
|
3508
|
+
// Reset BEFORE the emit so any synchronous re-entrant emits triggered
|
|
3509
|
+
// by listener callbacks schedule a fresh second microtask (no lost
|
|
3510
|
+
// updates). Reversing this order would drop bursts originating in
|
|
3511
|
+
// listeners.
|
|
3512
|
+
analyticsEmitScheduled = false;
|
|
3513
|
+
const t = performance.now();
|
|
3514
|
+
emitContextAnalyticsChanged();
|
|
3515
|
+
perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
|
|
2938
3519
|
function emit(event: DocumentRuntimeEvent): void {
|
|
3520
|
+
perfCounters.increment(`emit.${event.type}.calls`);
|
|
3521
|
+
const t0 = performance.now();
|
|
2939
3522
|
emitInternal(event);
|
|
3523
|
+
perfCounters.increment(`emit.${event.type}.internal.us`, Math.round((performance.now() - t0) * 1000));
|
|
2940
3524
|
if (shouldEmitContextAnalyticsChanged(event)) {
|
|
2941
|
-
|
|
3525
|
+
scheduleContextAnalyticsEmit();
|
|
2942
3526
|
}
|
|
2943
3527
|
}
|
|
2944
3528
|
|
|
@@ -3085,7 +3669,23 @@ export function createDocumentRuntime(
|
|
|
3085
3669
|
}
|
|
3086
3670
|
|
|
3087
3671
|
function emitContextAnalyticsChanged(): void {
|
|
3672
|
+
// L7 Phase 1 — short-circuit when no consumer can observe the event.
|
|
3673
|
+
// The downstream cost is dominated by createRuntimeContextAnalyticsSnapshot,
|
|
3674
|
+
// which assembles ~8 facets (suggestions, navigation, review-work,
|
|
3675
|
+
// workflow-markup, etc.) and runs > 300 ms on a 40-page CCEP fixture
|
|
3676
|
+
// because its cache key includes `selection` and selection moves on
|
|
3677
|
+
// every keystroke. Skipping here when nobody is subscribed eliminates
|
|
3678
|
+
// the cost in headless/SSR/test usage. Production keeps its `onEvent`
|
|
3679
|
+
// handler, so the production typing-latency win lives in Phase 1.5
|
|
3680
|
+
// (microtask coalescing) and Phase 1.6 (per-input-axis caching of the
|
|
3681
|
+
// analytics inputs).
|
|
3682
|
+
if (options.onEvent === undefined && eventListeners.size === 0) {
|
|
3683
|
+
perfCounters.increment("emit.contextAnalytics.skippedNoListeners");
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
const t0 = performance.now();
|
|
3088
3687
|
const trackedSnapshots = collectTrackedContextAnalyticsSnapshots();
|
|
3688
|
+
perfCounters.increment("emit.contextAnalytics.collect.us", Math.round((performance.now() - t0) * 1000));
|
|
3089
3689
|
if (
|
|
3090
3690
|
lastEmittedContextAnalyticsSnapshots !== undefined &&
|
|
3091
3691
|
trackedContextAnalyticsSnapshotsEqual(lastEmittedContextAnalyticsSnapshots, trackedSnapshots)
|
|
@@ -4438,6 +5038,25 @@ function resolveSupportedFieldDisplay(
|
|
|
4438
5038
|
if (field.fieldFamily === "TOC") {
|
|
4439
5039
|
return undefined;
|
|
4440
5040
|
}
|
|
5041
|
+
if (field.fieldFamily === "PAGE") {
|
|
5042
|
+
const page = resolveRepresentativePageForStory(navigation, storyTarget);
|
|
5043
|
+
if (!page) {
|
|
5044
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
5045
|
+
}
|
|
5046
|
+
return {
|
|
5047
|
+
displayText: String(resolveDisplayedPageNumber(page)),
|
|
5048
|
+
refreshStatus: "current",
|
|
5049
|
+
};
|
|
5050
|
+
}
|
|
5051
|
+
if (field.fieldFamily === "NUMPAGES") {
|
|
5052
|
+
if (navigation.pageCount === 0) {
|
|
5053
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
5054
|
+
}
|
|
5055
|
+
return {
|
|
5056
|
+
displayText: String(navigation.pageCount),
|
|
5057
|
+
refreshStatus: "current",
|
|
5058
|
+
};
|
|
5059
|
+
}
|
|
4441
5060
|
if (!field.fieldTarget) {
|
|
4442
5061
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
4443
5062
|
}
|