@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.
Files changed (48) hide show
  1. package/README.md +17 -0
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +139 -3
  4. package/src/core/commands/formatting-commands.ts +7 -1
  5. package/src/core/commands/index.ts +27 -2
  6. package/src/core/commands/text-commands.ts +59 -0
  7. package/src/core/selection/review-anchors.ts +131 -21
  8. package/src/index.ts +16 -1
  9. package/src/io/chart-preview-resolver.ts +281 -0
  10. package/src/io/docx-session.ts +21 -1
  11. package/src/io/export/build-app-properties-xml.ts +1 -1
  12. package/src/io/export/serialize-comments.ts +38 -9
  13. package/src/io/export/twip.ts +1 -1
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-comments.ts +0 -33
  16. package/src/io/ooxml/parse-complex-content.ts +14 -0
  17. package/src/io/ooxml/parse-main-document.ts +4 -0
  18. package/src/preservation/opaque-region.ts +5 -0
  19. package/src/review/store/comment-remapping.ts +2 -2
  20. package/src/runtime/document-runtime.ts +316 -25
  21. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  22. package/src/runtime/edit-dispatch/index.ts +2 -0
  23. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  24. package/src/runtime/editor-surface/capabilities.ts +411 -0
  25. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  26. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  27. package/src/runtime/layout/layout-engine-version.ts +41 -0
  28. package/src/runtime/layout/public-facet.ts +30 -0
  29. package/src/runtime/prerender/cache-envelope.ts +29 -0
  30. package/src/runtime/prerender/cache-key.ts +66 -0
  31. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  32. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  33. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  34. package/src/runtime/prerender/prerender-document.ts +145 -0
  35. package/src/runtime/render/block-fragment-projection.ts +2 -0
  36. package/src/runtime/selection/post-edit-validator.ts +77 -0
  37. package/src/runtime/surface-projection.ts +35 -2
  38. package/src/ui/WordReviewEditor.tsx +75 -192
  39. package/src/ui/editor-runtime-boundary.ts +5 -1
  40. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  41. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  42. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  43. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  44. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  45. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  46. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  47. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  48. 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): string;
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): void;
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
- // 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.
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
- comments: CommentSidebarSnapshot;
630
- trackedChanges: TrackedChangesSnapshot;
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.comments === cachedRenderSnapshot.comments &&
1529
- cachedReviewWork.trackedChanges === cachedRenderSnapshot.trackedChanges &&
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
- comments: cachedRenderSnapshot.comments,
1546
- trackedChanges: cachedRenderSnapshot.trackedChanges,
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(state.document.content, anchor)) {
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 paragraph.",
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 commentId;
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
+ }
@@ -0,0 +1,2 @@
1
+ export { dispatchTextCommand } from "./dispatch-text-command.ts";
2
+ export type { DispatchTextCommand, DispatchContext } from "./dispatch-text-command.ts";