@beyondwork/docx-react-component 1.0.43 → 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 +44 -32
- package/src/api/public-types.ts +139 -3
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +27 -2
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +16 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +21 -1
- 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/normalize/normalize-text.ts +33 -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/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/document-runtime.ts +316 -25
- 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-surface/capabilities.ts +411 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +46 -0
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/public-facet.ts +30 -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/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +35 -2
- package/src/ui/WordReviewEditor.tsx +75 -192
- package/src/ui/editor-runtime-boundary.ts +5 -1
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
|
@@ -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";
|
|
@@ -244,6 +247,7 @@ export type ActiveStoryTextCommand =
|
|
|
244
247
|
| Extract<EditorCommand, { type: "text.delete-backward" }>
|
|
245
248
|
| Extract<EditorCommand, { type: "text.delete-forward" }>
|
|
246
249
|
| Extract<EditorCommand, { type: "text.insert-tab" }>
|
|
250
|
+
| Extract<EditorCommand, { type: "text.outdent-tab" }>
|
|
247
251
|
| Extract<EditorCommand, { type: "text.insert-hard-break" }>
|
|
248
252
|
| Extract<EditorCommand, { type: "paragraph.split" }>;
|
|
249
253
|
|
|
@@ -281,11 +285,11 @@ export interface DocumentRuntime {
|
|
|
281
285
|
blur(): void;
|
|
282
286
|
setDefaultAuthorId?(authorId?: string): void;
|
|
283
287
|
getDefaultAuthorId?(): string | undefined;
|
|
284
|
-
addComment(params: AddCommentParams):
|
|
288
|
+
addComment(params: AddCommentParams): AddCommentResult;
|
|
285
289
|
openComment(commentId: string): void;
|
|
286
290
|
resolveComment(commentId: string): void;
|
|
287
291
|
reopenComment(commentId: string): void;
|
|
288
|
-
addCommentReply(commentId: string, body: string, authorId?: string):
|
|
292
|
+
addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
|
|
289
293
|
editCommentBody(commentId: string, body: string): void;
|
|
290
294
|
acceptChange(changeId: string): void;
|
|
291
295
|
rejectChange(changeId: string): void;
|
|
@@ -382,6 +386,21 @@ export interface DocumentRuntime {
|
|
|
382
386
|
*/
|
|
383
387
|
getPerfCountersSnapshot(): Record<string, number>;
|
|
384
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;
|
|
385
404
|
}
|
|
386
405
|
|
|
387
406
|
export interface CommandAppliedMeta {
|
|
@@ -437,6 +456,17 @@ export interface CreateDocumentRuntimeOptions {
|
|
|
437
456
|
* See `docs/plans/fastload.md` and `src/io/load-scheduler.ts`.
|
|
438
457
|
*/
|
|
439
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;
|
|
440
470
|
}
|
|
441
471
|
|
|
442
472
|
interface HistoryState {
|
|
@@ -444,6 +474,84 @@ interface HistoryState {
|
|
|
444
474
|
future: EditorState[];
|
|
445
475
|
}
|
|
446
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
|
+
|
|
447
555
|
export function createDocumentRuntime(
|
|
448
556
|
options: CreateDocumentRuntimeOptions,
|
|
449
557
|
): DocumentRuntime {
|
|
@@ -537,6 +645,16 @@ export function createDocumentRuntime(
|
|
|
537
645
|
// upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
|
|
538
646
|
// emits `measurement_backend_ready` so chrome consumers can re-read metrics.
|
|
539
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
|
+
}
|
|
540
658
|
const fontLoader: DocxFontLoader = createDocxFontLoader(
|
|
541
659
|
collectFontLoaderInput(state.document),
|
|
542
660
|
);
|
|
@@ -579,11 +697,40 @@ export function createDocumentRuntime(
|
|
|
579
697
|
// runtime already builds for comment/revision/search consumers.
|
|
580
698
|
getWorkflowMarkupMetadata: () =>
|
|
581
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
|
+
},
|
|
582
726
|
});
|
|
583
727
|
renderKernelRef = createRenderKernel({
|
|
584
728
|
facet: layoutFacet,
|
|
585
729
|
getActiveStory: () => activeStory,
|
|
586
730
|
});
|
|
731
|
+
// L7 Phase 2 — viewport block range for surface culling.
|
|
732
|
+
let viewportBlockRange: { start: number; end: number } | null = null;
|
|
733
|
+
|
|
587
734
|
let cachedSurface:
|
|
588
735
|
| {
|
|
589
736
|
revisionToken: string;
|
|
@@ -620,17 +767,62 @@ export function createDocumentRuntime(
|
|
|
620
767
|
}
|
|
621
768
|
| undefined;
|
|
622
769
|
// L7 Phase 1 — review-work snapshot cache. Independent of selection.
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
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.
|
|
627
822
|
let cachedReviewWork:
|
|
628
823
|
| {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
workflowMarkup: WorkflowMarkupSnapshot;
|
|
632
|
-
document: CanonicalDocumentEnvelope;
|
|
633
|
-
navigation: DocumentNavigationSnapshot;
|
|
824
|
+
workflowMarkupHash: string;
|
|
825
|
+
reviewStateHash: string;
|
|
634
826
|
snapshot: ReviewWorkSnapshot;
|
|
635
827
|
}
|
|
636
828
|
| undefined;
|
|
@@ -725,7 +917,7 @@ export function createDocumentRuntime(
|
|
|
725
917
|
return cachedSurface.snapshot;
|
|
726
918
|
}
|
|
727
919
|
|
|
728
|
-
const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory);
|
|
920
|
+
const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, { viewportBlockRange });
|
|
729
921
|
recordPerfSample("snapshot.surface");
|
|
730
922
|
incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
|
|
731
923
|
cachedSurface = {
|
|
@@ -1523,13 +1715,25 @@ export function createDocumentRuntime(
|
|
|
1523
1715
|
|
|
1524
1716
|
const tReview = performance.now();
|
|
1525
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);
|
|
1526
1733
|
if (
|
|
1527
1734
|
cachedReviewWork &&
|
|
1528
|
-
cachedReviewWork.
|
|
1529
|
-
cachedReviewWork.
|
|
1530
|
-
cachedReviewWork.workflowMarkup === wfMarkup &&
|
|
1531
|
-
cachedReviewWork.document === state.document &&
|
|
1532
|
-
cachedReviewWork.navigation === navigation
|
|
1735
|
+
cachedReviewWork.workflowMarkupHash === wfMarkupHash &&
|
|
1736
|
+
cachedReviewWork.reviewStateHash === reviewStateHash
|
|
1533
1737
|
) {
|
|
1534
1738
|
reviewWork = cachedReviewWork.snapshot;
|
|
1535
1739
|
perfCounters.increment("ctxa.reviewWork.cacheHit");
|
|
@@ -1542,11 +1746,8 @@ export function createDocumentRuntime(
|
|
|
1542
1746
|
navigation,
|
|
1543
1747
|
});
|
|
1544
1748
|
cachedReviewWork = {
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
workflowMarkup: wfMarkup,
|
|
1548
|
-
document: state.document,
|
|
1549
|
-
navigation,
|
|
1749
|
+
workflowMarkupHash: wfMarkupHash,
|
|
1750
|
+
reviewStateHash,
|
|
1550
1751
|
snapshot: reviewWork,
|
|
1551
1752
|
};
|
|
1552
1753
|
perfCounters.increment("ctxa.reviewWork.cacheMiss");
|
|
@@ -1702,6 +1903,38 @@ export function createDocumentRuntime(
|
|
|
1702
1903
|
};
|
|
1703
1904
|
}
|
|
1704
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
|
+
|
|
1705
1938
|
function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
|
|
1706
1939
|
const activeStoryKey = storyTargetKey(activeStory);
|
|
1707
1940
|
const pageLayout = cachedRenderSnapshot.pageLayout;
|
|
@@ -2029,13 +2262,13 @@ export function createDocumentRuntime(
|
|
|
2029
2262
|
const selection = params.anchor
|
|
2030
2263
|
? createSelectionFromPublicAnchor(params.anchor)
|
|
2031
2264
|
: state.selection;
|
|
2032
|
-
if (!canCreateDocxCommentAnchor(
|
|
2265
|
+
if (!canCreateDocxCommentAnchor(cachedRenderSnapshot.surface, anchor)) {
|
|
2033
2266
|
const error: InternalEditorError = {
|
|
2034
2267
|
errorId: createSessionId("comment-anchor", clock()),
|
|
2035
2268
|
code: "validation_failed",
|
|
2036
2269
|
isFatal: false,
|
|
2037
2270
|
message:
|
|
2038
|
-
"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.",
|
|
2039
2272
|
source: "runtime",
|
|
2040
2273
|
details: {
|
|
2041
2274
|
reason: "invalid_comment_anchor",
|
|
@@ -2077,7 +2310,10 @@ export function createDocumentRuntime(
|
|
|
2077
2310
|
origin: createOrigin("api", clock()),
|
|
2078
2311
|
});
|
|
2079
2312
|
|
|
2080
|
-
return
|
|
2313
|
+
return {
|
|
2314
|
+
commentId,
|
|
2315
|
+
anchor: toPublicAnchorProjection(anchor),
|
|
2316
|
+
};
|
|
2081
2317
|
},
|
|
2082
2318
|
openComment(commentId) {
|
|
2083
2319
|
this.dispatch({
|
|
@@ -2102,6 +2338,8 @@ export function createDocumentRuntime(
|
|
|
2102
2338
|
});
|
|
2103
2339
|
},
|
|
2104
2340
|
addCommentReply(commentId, body, authorId) {
|
|
2341
|
+
const priorEntryCount =
|
|
2342
|
+
state.document.review.comments[commentId]?.entries?.length ?? 0;
|
|
2105
2343
|
this.dispatch({
|
|
2106
2344
|
type: "comment.add-reply",
|
|
2107
2345
|
commentId,
|
|
@@ -2109,6 +2347,8 @@ export function createDocumentRuntime(
|
|
|
2109
2347
|
authorId: authorId ?? defaultAuthorId,
|
|
2110
2348
|
origin: createOrigin("api", clock()),
|
|
2111
2349
|
});
|
|
2350
|
+
const entryId = `${commentId}-entry-${priorEntryCount + 1}`;
|
|
2351
|
+
return { commentId, entryId };
|
|
2112
2352
|
},
|
|
2113
2353
|
editCommentBody(commentId, body) {
|
|
2114
2354
|
this.dispatch({
|
|
@@ -2658,6 +2898,21 @@ export function createDocumentRuntime(
|
|
|
2658
2898
|
resetPerfCounters() {
|
|
2659
2899
|
perfCounters.reset();
|
|
2660
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
|
+
},
|
|
2661
2916
|
};
|
|
2662
2917
|
|
|
2663
2918
|
function applyHistory(direction: "undo" | "redo"): void {
|
|
@@ -2731,6 +2986,42 @@ export function createDocumentRuntime(
|
|
|
2731
2986
|
}
|
|
2732
2987
|
perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
|
|
2733
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
|
+
}
|
|
3024
|
+
|
|
2734
3025
|
const tRefresh0 = performance.now();
|
|
2735
3026
|
cachedRenderSnapshot = refreshRenderSnapshot();
|
|
2736
3027
|
perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { DocumentRuntime } from "../document-runtime.ts";
|
|
2
|
+
import {
|
|
3
|
+
applyListAwareTextCommand,
|
|
4
|
+
type DispatchTextCommand,
|
|
5
|
+
type ListAwareDispatchDeps,
|
|
6
|
+
type ListAwareMutationResult,
|
|
7
|
+
type StoryMutationContext,
|
|
8
|
+
} from "./list-aware-dispatch.ts";
|
|
9
|
+
|
|
10
|
+
export type { DispatchTextCommand };
|
|
11
|
+
|
|
12
|
+
// TODO(Part 2): collapse DispatchContext into the runtime layer.
|
|
13
|
+
// The four UI-private helpers (getStoryMutationContext, dispatchStoryMutationResult,
|
|
14
|
+
// resolveActiveParagraphContext, toRuntimeSelectionSnapshot) currently live in
|
|
15
|
+
// WordReviewEditor.tsx for historical reasons but are pure runtime adapters with
|
|
16
|
+
// no React/UI dependencies. The four-shell refactor (R.1-R.4) will move them
|
|
17
|
+
// into src/runtime/edit-dispatch/, at which point this DI bundle dissolves.
|
|
18
|
+
export interface DispatchContext extends ListAwareDispatchDeps {
|
|
19
|
+
getStoryMutationContext(
|
|
20
|
+
runtime: DocumentRuntime,
|
|
21
|
+
command?: string,
|
|
22
|
+
): StoryMutationContext | null;
|
|
23
|
+
dispatchStoryMutationResult(
|
|
24
|
+
runtime: DocumentRuntime,
|
|
25
|
+
context: StoryMutationContext,
|
|
26
|
+
result: ListAwareMutationResult,
|
|
27
|
+
timestamp: string,
|
|
28
|
+
): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function dispatchTextCommand(
|
|
32
|
+
runtime: DocumentRuntime,
|
|
33
|
+
command: DispatchTextCommand,
|
|
34
|
+
deps: DispatchContext,
|
|
35
|
+
): void {
|
|
36
|
+
const context = deps.getStoryMutationContext(runtime, getMountedTextCommandName(command));
|
|
37
|
+
if (!context) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const effectiveSelectionMode = runtime.getInteractionGuardSnapshot().effectiveMode;
|
|
42
|
+
const listAwareResult = applyListAwareTextCommand(context, command, deps);
|
|
43
|
+
if (effectiveSelectionMode === "suggest" && listAwareResult) {
|
|
44
|
+
runtime.emitBlockedCommand(getMountedTextCommandName(command), [{
|
|
45
|
+
code: "suggesting_unsupported",
|
|
46
|
+
message: "List structure changes are not supported in suggesting mode.",
|
|
47
|
+
}]);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (listAwareResult) {
|
|
52
|
+
deps.dispatchStoryMutationResult(runtime, context, listAwareResult, context.timestamp);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
switch (command.type) {
|
|
57
|
+
case "insert-text":
|
|
58
|
+
runtime.applyActiveStoryTextCommand({ type: "text.insert", text: command.text });
|
|
59
|
+
return;
|
|
60
|
+
case "delete-backward":
|
|
61
|
+
runtime.applyActiveStoryTextCommand({ type: "text.delete-backward" });
|
|
62
|
+
return;
|
|
63
|
+
case "delete-forward":
|
|
64
|
+
runtime.applyActiveStoryTextCommand({ type: "text.delete-forward" });
|
|
65
|
+
return;
|
|
66
|
+
case "insert-tab":
|
|
67
|
+
runtime.applyActiveStoryTextCommand({ type: "text.insert-tab" });
|
|
68
|
+
return;
|
|
69
|
+
case "outdent-tab":
|
|
70
|
+
runtime.applyActiveStoryTextCommand({ type: "text.outdent-tab" });
|
|
71
|
+
return;
|
|
72
|
+
case "insert-hard-break":
|
|
73
|
+
runtime.applyActiveStoryTextCommand({ type: "text.insert-hard-break" });
|
|
74
|
+
return;
|
|
75
|
+
case "split-paragraph":
|
|
76
|
+
runtime.applyActiveStoryTextCommand({ type: "paragraph.split" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getMountedTextCommandName(command: DispatchTextCommand): string {
|
|
82
|
+
switch (command.type) {
|
|
83
|
+
case "insert-text":
|
|
84
|
+
return "text.insert";
|
|
85
|
+
case "delete-backward":
|
|
86
|
+
return "text.delete-backward";
|
|
87
|
+
case "delete-forward":
|
|
88
|
+
return "text.delete-forward";
|
|
89
|
+
case "insert-tab":
|
|
90
|
+
return "text.insert-tab";
|
|
91
|
+
case "outdent-tab":
|
|
92
|
+
return "text.outdent-tab";
|
|
93
|
+
case "insert-hard-break":
|
|
94
|
+
return "text.insert-hard-break";
|
|
95
|
+
case "split-paragraph":
|
|
96
|
+
return "paragraph.split";
|
|
97
|
+
}
|
|
98
|
+
}
|