@beyondwork/docx-react-component 1.0.43 → 1.0.46

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 (50) hide show
  1. package/README.md +35 -1
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +156 -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 +351 -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/event-refresh-hints.ts +1 -0
  26. package/src/runtime/layout/docx-font-loader.ts +30 -11
  27. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  28. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  29. package/src/runtime/layout/layout-engine-version.ts +41 -0
  30. package/src/runtime/layout/public-facet.ts +30 -0
  31. package/src/runtime/prerender/cache-envelope.ts +29 -0
  32. package/src/runtime/prerender/cache-key.ts +66 -0
  33. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  34. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  35. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  36. package/src/runtime/prerender/prerender-document.ts +145 -0
  37. package/src/runtime/render/block-fragment-projection.ts +2 -0
  38. package/src/runtime/selection/post-edit-validator.ts +77 -0
  39. package/src/runtime/surface-projection.ts +35 -2
  40. package/src/ui/WordReviewEditor.tsx +75 -192
  41. package/src/ui/editor-runtime-boundary.ts +5 -1
  42. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  44. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  45. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  46. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  47. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  48. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  49. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  50. package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
package/README.md CHANGED
@@ -216,9 +216,26 @@ Current integration honesty:
216
216
 
217
217
  - [`docs/maintainers/README.md`](docs/maintainers/README.md)
218
218
  - [`docs/ccep-contract-templates/README.md`](docs/ccep-contract-templates/README.md)
219
+ - [`docs/plans/README.md`](docs/plans/README.md) — active development lanes + open-issues register
219
220
 
220
221
  The CCEP corpus is kept on `main` as a maintainer-safe smoke-doc source set for agreement-heavy validation and wrapper or agent benchmarking.
221
222
 
223
+ ### Active Development Lanes
224
+
225
+ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 final) — full plan index, status, and worktree map at [`docs/plans/README.md`](docs/plans/README.md). Each lane has a single self-contained doc at `docs/plans/lane-N-<name>.md`:
226
+
227
+ | Lane | Mission | Status |
228
+ |---|---|---|
229
+ | 1. [**Editing Foundation**](docs/plans/lane-1-editing-foundation.md) | Close I1–I6 + ship the SelectionLayer / EditLayer / StructureLayer / SurfaceLayer split | active |
230
+ | 2. [**Render Performance**](docs/plans/lane-2-render-performance.md) | <32 ms typing P50 + <1 s cold-open on 200-page CCEP; never stuck on loading | active |
231
+ | 3. [**Layout Engine + OOXML Fidelity**](docs/plans/lane-3-layout-engine-ooxml-fidelity.md) | Land P9 anchor-index + P10 incremental relayout + drain O2/O3/O4 round-trip + tables/TOC/page numbers/front pages/image structures | active |
232
+ | 4. [**Collab + CLM/Vallor Integration**](docs/plans/lane-4-collab-clm-vallor.md) | Promote collab plumbing → first-class user mode; wire Sunday CLM × React E2E demo | active |
233
+ | 5. [**Charts (independent)**](docs/plans/lane-5-charts.md) | Move charts from preserve-only opaque preview to Word-accurate native rendering (Stages 1–7) | active |
234
+ | 6. [**Visual Chrome / Layout Polish**](docs/plans/lane-6-visual-chrome-layout-polish.md) | True-to-Word visuals: discrete paper cards, native page chrome, float-wrap, validation bar | LATER |
235
+ | 7. [**Bugs / Gaps / Cross-cutting**](docs/plans/lane-7-bugs-gaps-cross-cutting.md) | Drain V#/O#/X# register; trigger-gated work; infrastructure hardening | LATER |
236
+ | 8. [**API Ergonomics / Errors / Backwards Compatibility**](docs/plans/lane-8-api-ergonomics.md) | Refactor `docs/reference/public-api.md` end-to-end with groups + per-code error catalog + ergonomics fixes; maintain backwards compat | LATER (Tracks A+C shipped 2026-04-19) |
237
+ | 9. [**Shipping**](docs/plans/lane-9-shipping.md) | Production-readiness: API freeze, semver discipline, changelog, telemetry, customer migration guides, doc completeness audit | FINAL |
238
+
222
239
  ### Technical Wiki
223
240
 
224
241
  - [`docs/wiki/`](docs/wiki/) — Feature-by-feature technical documentation (25+ topics covering OOXML, ProseMirror, runtime, and platform)
@@ -691,7 +708,21 @@ Fired when a comment is resolved (via `resolveComment` or the sidebar).
691
708
  }
692
709
  ```
693
710
 
694
- There is no separate `comment_removed` event deletions are silent. Query `getComments()` after a `dirty_changed` event if you need to detect deletions.
711
+ For a general-purpose "something about comments changed" signal (covering deletions, reply appends, body edits, reopen, and every other mutation including Yjs-driven mutations that route through the editor's imperative ref), subscribe to `comments_changed`.
712
+
713
+ #### `comments_changed`
714
+
715
+ Fired once per commit whenever the runtime's comment snapshot differs from the previous commit. Use this when you render comments in a sibling component and need to know when to re-fetch via `editorRef.current.getComments()`.
716
+
717
+ ```ts
718
+ {
719
+ type: "comments_changed";
720
+ documentId: string;
721
+ changedCommentIds: string[]; // always non-empty
722
+ }
723
+ ```
724
+
725
+ Covers: `addComment`, `deleteComment`, `resolveComment`, `reopenComment`, `addCommentReply`, `editCommentBody`, and any remote-origin mutation that funnels through the same runtime APIs. Does NOT fire on selection/focus changes.
695
726
 
696
727
  #### `change_accepted`
697
728
 
@@ -927,6 +958,9 @@ function CommentButton({ editorRef }: { editorRef: React.RefObject<WordReviewEdi
927
958
  case "comment_resolved":
928
959
  console.log("comment resolved:", event.commentId);
929
960
  break;
961
+ case "comments_changed":
962
+ console.log("comments changed:", event.changedCommentIds);
963
+ break;
930
964
  case "change_accepted":
931
965
  console.log("change accepted:", event.changeId);
932
966
  break;
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.43",
4
+ "version": "1.0.46",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
+ "packageManager": "pnpm@10.30.3",
6
7
  "type": "module",
7
8
  "sideEffects": [
8
9
  "**/*.css"
@@ -92,6 +93,35 @@
92
93
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
93
94
  },
94
95
  "types": "./src/index.ts",
96
+ "scripts": {
97
+ "build": "tsup",
98
+ "test": "bash scripts/run-workspace-tests.sh",
99
+ "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
100
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
101
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
102
+ "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
103
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
104
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
105
+ "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
106
+ "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
107
+ "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
108
+ "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
109
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
110
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
111
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
112
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
113
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
114
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
115
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
116
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
117
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
118
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
119
+ "wave:status": "bash scripts/wave-status.sh",
120
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
121
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
122
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
123
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
124
+ },
95
125
  "keywords": [
96
126
  "docx",
97
127
  "word",
@@ -135,9 +165,9 @@
135
165
  "react": "^19.2.0",
136
166
  "react-dom": "^19.2.0",
137
167
  "tailwindcss": "^4.2.2",
138
- "yjs": "^13.6.0",
139
168
  "y-prosemirror": "^1.2.0",
140
- "y-protocols": "^1.0.0"
169
+ "y-protocols": "^1.0.0",
170
+ "yjs": "^13.6.0"
141
171
  },
142
172
  "peerDependenciesMeta": {
143
173
  "yjs": {
@@ -156,6 +186,7 @@
156
186
  "@types/react": "19.2.14",
157
187
  "@types/react-dom": "19.2.3",
158
188
  "@typescript/native-preview": "7.0.0-dev.20260409.1",
189
+ "fake-indexeddb": "^6.2.5",
159
190
  "jsdom": "^29.0.1",
160
191
  "pixelmatch": "^7.1.0",
161
192
  "pngjs": "^7.0.0",
@@ -174,33 +205,14 @@
174
205
  "y-protocols": "^1.0.7",
175
206
  "yjs": "^13.6.30"
176
207
  },
177
- "scripts": {
178
- "build": "tsup",
179
- "test": "bash scripts/run-workspace-tests.sh",
180
- "test:repo": "node scripts/run-repo-tests.mjs core",
181
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
182
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
183
- "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
184
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
185
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
186
- "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
187
- "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
188
- "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
189
- "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
190
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
191
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
192
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
193
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
194
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
195
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
196
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
197
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
198
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
199
- "wave:launch:managed": "bash scripts/wave-launch.sh",
200
- "wave:status": "bash scripts/wave-status.sh",
201
- "wave:watch": "bash scripts/wave-watch.sh --follow",
202
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
203
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
204
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
208
+ "pnpm": {
209
+ "onlyBuiltDependencies": [
210
+ "esbuild",
211
+ "sharp"
212
+ ],
213
+ "overrides": {
214
+ "react": "19.2.4",
215
+ "react-dom": "19.2.4"
216
+ }
205
217
  }
206
- }
218
+ }
@@ -981,7 +981,16 @@ export type SurfaceBlockSnapshot =
981
981
  detail: string;
982
982
  featureKey?: string;
983
983
  blockedReasonCode?: "workflow_preserve_only" | "workflow_blocked_import";
984
- state: "locked-preserve-only";
984
+ /**
985
+ * When set, this opaque_block is a size-preserving placeholder generated
986
+ * by viewport culling, NOT a real preserved fragment. The PM schema uses
987
+ * this to claim the original block's position span without rendering
988
+ * content.
989
+ *
990
+ * See docs/plans/lane-2-render-performance.md.
991
+ */
992
+ placeholderSize?: number;
993
+ state: "locked-preserve-only" | "placeholder-culled";
985
994
  };
986
995
 
987
996
  export interface SecondaryStorySurface {
@@ -997,6 +1006,15 @@ export interface EditorSurfaceSnapshot {
997
1006
  blocks: SurfaceBlockSnapshot[];
998
1007
  lockedFragmentIds: string[];
999
1008
  secondaryStories: SecondaryStorySurface[];
1009
+ /**
1010
+ * Block index range rendered as real (non-placeholder) in this snapshot.
1011
+ * Blocks outside this range are placeholder opaque_blocks carrying the
1012
+ * original position range but no content. `null` (default) = all blocks
1013
+ * are real (legacy behavior).
1014
+ *
1015
+ * See docs/plans/lane-2-render-performance.md.
1016
+ */
1017
+ viewportBlockRange: { start: number; end: number } | null;
1000
1018
  }
1001
1019
 
1002
1020
  export type EditorWarningCode =
@@ -1669,6 +1687,16 @@ export interface AddCommentParams {
1669
1687
  authorId?: string;
1670
1688
  }
1671
1689
 
1690
+ export interface AddCommentResult {
1691
+ commentId: string;
1692
+ anchor: EditorAnchorProjection;
1693
+ }
1694
+
1695
+ export interface AddCommentReplyResult {
1696
+ commentId: string;
1697
+ entryId: string;
1698
+ }
1699
+
1672
1700
  export interface ExportDocxOptions {
1673
1701
  fileName?: string;
1674
1702
  reason?: string;
@@ -2388,6 +2416,23 @@ export type WordReviewEditorEvent =
2388
2416
  documentId: string;
2389
2417
  commentId: string;
2390
2418
  }
2419
+ | {
2420
+ type: "comments_changed";
2421
+ documentId: string;
2422
+ /**
2423
+ * Stable ids of the comment threads whose state differs between
2424
+ * the previous and current runtime render snapshot. Includes
2425
+ * additions, deletions, body edits, reply appends, and
2426
+ * resolution-state transitions. Consumers can call
2427
+ * `editorRef.current.getComments()` to obtain the full updated
2428
+ * `CommentSidebarSnapshot`.
2429
+ *
2430
+ * The array is always non-empty when this event fires. When the
2431
+ * snapshot's `comments` reference is unchanged between commits,
2432
+ * no event fires.
2433
+ */
2434
+ changedCommentIds: string[];
2435
+ }
2391
2436
  | {
2392
2437
  type: "change_accepted";
2393
2438
  documentId: string;
@@ -2693,11 +2738,50 @@ export interface EditorTelemetryEvent {
2693
2738
  detail?: Record<string, unknown>;
2694
2739
  }
2695
2740
 
2741
+ /**
2742
+ * Stage 0B.1 — parameters passed to `EditorHostAdapter.renderChartPreview`
2743
+ * when the importer encounters a `c:chartSpace` that has no cached
2744
+ * `mc:Fallback` bitmap. Hosts receive the raw chart-part XML plus the
2745
+ * theme XML and the intended display size, and return either preview
2746
+ * bytes (typically PNG or SVG) or `null` to fall back to the typed
2747
+ * badge.
2748
+ *
2749
+ * Field stability: only additive changes; no field is ever renamed or
2750
+ * removed. Host implementations should use structural narrowing to
2751
+ * ignore unknown fields in future versions.
2752
+ */
2753
+ export interface ChartPreviewResolveParams {
2754
+ /** Chart part body (`word/charts/chartN.xml`). UTF-8 string. */
2755
+ chartXml: string;
2756
+ /** Absolute package path of the chart part (e.g. `/word/charts/chart1.xml`). Useful as a cache key. */
2757
+ chartPartPath: string;
2758
+ /** Body of `theme1.xml` when the package ships one; `undefined` otherwise. */
2759
+ themeXml: string | undefined;
2760
+ /** Intended display width in EMU (extracted from the drawing's `wp:extent`). */
2761
+ widthEmu: number;
2762
+ /** Intended display height in EMU. */
2763
+ heightEmu: number;
2764
+ }
2765
+
2696
2766
  export interface EditorHostAdapter {
2697
2767
  load?(params: LoadRequest): Promise<LoadResult>;
2698
2768
  saveSession?(params: SaveSessionParams): Promise<SaveSessionResult>;
2699
2769
  saveExport?(params: SaveExportParams): Promise<SaveExportResult>;
2700
2770
  logEvent?(event: EditorTelemetryEvent): void;
2771
+ /**
2772
+ * Stage 0B.1: render a chart to bitmap/SVG preview bytes. Called at
2773
+ * import time for every `c:chartSpace` that ships without a cached
2774
+ * `mc:Fallback` blip. Return `null` to fall back to the typed badge
2775
+ * (Stage 0 behavior). Return a `Uint8Array` to inject the bytes as
2776
+ * a synthetic `MediaItem` and render the preview through the
2777
+ * existing `chart_atom` path.
2778
+ *
2779
+ * Content type is inferred from the first few bytes: `image/png`
2780
+ * for a PNG magic number, `image/svg+xml` otherwise. Hosts that
2781
+ * need a different content type should extend this contract in a
2782
+ * follow-up.
2783
+ */
2784
+ renderChartPreview?(params: ChartPreviewResolveParams): Promise<Uint8Array | null>;
2701
2785
  }
2702
2786
 
2703
2787
  export interface EditorDatastoreAdapter {
@@ -2712,11 +2796,11 @@ export interface WordReviewEditorRef {
2712
2796
  blur(): void;
2713
2797
  undo(): void;
2714
2798
  redo(): void;
2715
- addComment(params: AddCommentParams): string;
2799
+ addComment(params: AddCommentParams): AddCommentResult;
2716
2800
  openComment(commentId: string): void;
2717
2801
  resolveComment(commentId: string): void;
2718
2802
  reopenComment(commentId: string): void;
2719
- addCommentReply(commentId: string, body: string): void;
2803
+ addCommentReply(commentId: string, body: string): AddCommentReplyResult;
2720
2804
  editCommentBody(commentId: string, body: string): void;
2721
2805
  deleteComment(commentId: string): void;
2722
2806
  acceptChange(changeId: string): void;
@@ -3073,6 +3157,20 @@ export interface WordReviewEditorProps {
3073
3157
  chromePreset?: WordReviewEditorChromePreset;
3074
3158
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
3075
3159
  markupDisplay?: "clean" | "simple" | "all";
3160
+ /**
3161
+ * Harness/debug-only preview toggle for preserve-only features
3162
+ * (charts, SmartArt, shapes, WordArt, VML rendered as typed atoms;
3163
+ * the "N preserve-only features detected" banner; lock-callouts).
3164
+ *
3165
+ * MUST default to `false`. Consumer hosts MUST NOT hard-code `true` —
3166
+ * this is a dev opt-in gated by the harness dev drawer's
3167
+ * `debugMode && showUnsupportedObjectPreviews` AND-gate. Flipping the
3168
+ * default or dropping the AND-gate is a regression that has landed
3169
+ * three times (PRs #124, #131, #160); the invariant is locked by
3170
+ * `test/ui/unsupported-previews-invariant.test.ts`.
3171
+ *
3172
+ * @default false
3173
+ */
3076
3174
  showUnsupportedObjectPreviews?: boolean;
3077
3175
  showReviewPanel?: boolean;
3078
3176
  chromeVisibility?: Partial<WordReviewEditorChromeVisibility>;
@@ -3097,6 +3195,61 @@ export interface WordReviewEditorProps {
3097
3195
  * inline "Comments panel" icon appears only when a callback is wired.
3098
3196
  */
3099
3197
  onReviewSidebarComments?: () => void;
3198
+ /**
3199
+ * Optional: fires when the user invokes the Find shortcut
3200
+ * (Ctrl/Cmd+F) with the editor focused. When a host wires this
3201
+ * callback, the editor treats the shortcut as host-delegated and
3202
+ * suppresses the browser's native Find flow; when omitted, the
3203
+ * shortcut falls through to the browser default. The callback
3204
+ * receives the current selection so the host can pre-populate its
3205
+ * Find panel with the selected text.
3206
+ *
3207
+ * Capability id: `shortcut.find`. See
3208
+ * `src/runtime/editor-surface/capabilities.ts` for the full
3209
+ * capability contract.
3210
+ */
3211
+ onFindRequested?: (context: ShortcutDelegationContext) => void;
3212
+ /**
3213
+ * Optional: fires when the user invokes Print (Ctrl/Cmd+P) with
3214
+ * the editor focused. When wired, the editor calls this callback
3215
+ * and suppresses the browser's native print dialog — the host is
3216
+ * expected to open its own print flow (e.g. a PDF export pipeline
3217
+ * that preserves review chrome). When omitted, Ctrl/Cmd+P falls
3218
+ * through to the browser's print dialog.
3219
+ *
3220
+ * Capability id: `shortcut.print`.
3221
+ */
3222
+ onPrintRequested?: () => void;
3223
+ /**
3224
+ * Optional: fires when the user invokes a zoom shortcut
3225
+ * (Ctrl/Cmd+Plus, Ctrl/Cmd+Minus, Ctrl/Cmd+0). When wired, the
3226
+ * editor suppresses the browser's native zoom and delegates the
3227
+ * direction to the host. When omitted, the browser handles zoom
3228
+ * as usual.
3229
+ *
3230
+ * Capability ids: `shortcut.zoom-in`, `shortcut.zoom-out`,
3231
+ * `shortcut.zoom-reset`.
3232
+ */
3233
+ onZoomRequested?: (direction: "in" | "out" | "reset") => void;
3234
+ }
3235
+
3236
+ /**
3237
+ * Selection context handed to host-delegated shortcut callbacks
3238
+ * (`onFindRequested`, future `onReplaceRequested`, etc.) so the host
3239
+ * can pre-populate its own UI with the user's current selection.
3240
+ *
3241
+ * - `selectionText` is truncated to the first 500 characters —
3242
+ * Find / Replace panels typically only need a snippet, and unbounded
3243
+ * text would be wasteful for large selections.
3244
+ * - `selectionRange` is the same shape exposed via the
3245
+ * `selection_changed` editor event, so hosts can reuse selection
3246
+ * plumbing.
3247
+ */
3248
+ export interface ShortcutDelegationContext {
3249
+ /** The user-visible text of the selection, truncated to 500 chars. Empty string when collapsed. */
3250
+ selectionText: string;
3251
+ /** The selection range as a SelectionSnapshot. */
3252
+ selectionRange: SelectionSnapshot;
3100
3253
  }
3101
3254
 
3102
3255
  export interface WordReviewEditorChromeVisibility {
@@ -624,7 +624,13 @@ function applyAlignment(
624
624
  return true;
625
625
  }
626
626
 
627
- function applyIndentation(paragraph: ParagraphNode, delta: -1 | 1): boolean {
627
+ /**
628
+ * Adjusts the paragraph's indentation (or list level if the paragraph carries
629
+ * `numbering`) by ±INDENT_STEP_TWIPS. Mutates `paragraph` in place — caller
630
+ * must clone first if the source is shared. Returns `false` when no change
631
+ * occurred (already at the 0 / 8 bound, or no-op).
632
+ */
633
+ export function applyIndentation(paragraph: ParagraphNode, delta: -1 | 1): boolean {
628
634
  if (paragraph.numbering) {
629
635
  const nextLevel = clamp(paragraph.numbering.level + delta, 0, 8);
630
636
  if (nextLevel === paragraph.numbering.level) {
@@ -31,6 +31,7 @@ import {
31
31
  insertHardBreak,
32
32
  insertTab,
33
33
  insertText,
34
+ outdentParagraphAtSelection,
34
35
  splitParagraph,
35
36
  } from "./text-commands.ts";
36
37
  import type { RevisionRecord as CanonicalRevisionRecord } from "../../model/canonical-document.ts";
@@ -128,6 +129,10 @@ export type EditorCommand =
128
129
  type: "text.insert-tab";
129
130
  origin?: CommandOrigin;
130
131
  }
132
+ | {
133
+ type: "text.outdent-tab";
134
+ origin?: CommandOrigin;
135
+ }
131
136
  | {
132
137
  type: "text.insert-hard-break";
133
138
  origin?: CommandOrigin;
@@ -367,7 +372,7 @@ export interface TransactionEffects {
367
372
  commentAdded?: { commentId: string; anchor: EditorAnchorProjection };
368
373
  commentResolved?: { commentId: string };
369
374
  commentReopened?: { commentId: string };
370
- commentReplyAdded?: { commentId: string };
375
+ commentReplyAdded?: { commentId: string; entryId: string };
371
376
  commentBodyEdited?: { commentId: string };
372
377
  changeAccepted?: { changeId: string };
373
378
  changeRejected?: { changeId: string };
@@ -513,6 +518,26 @@ export function executeEditorCommand(
513
518
  insertTab(document, selection, context),
514
519
  );
515
520
  }
521
+ case "text.outdent-tab": {
522
+ // No suggesting-mode branch: outdent is a paragraph-format change, not a
523
+ // text-content change, so it bypasses the suggesting/track-changes pipeline.
524
+ // (If a tracked-changes test fails, copy the suggesting branch from
525
+ // text.insert-tab.)
526
+ //
527
+ // We use `buildDocumentReplaceTransaction` (not `applyTextCommand`) so the
528
+ // no-op cases — already at zero indent or list paragraph — skip the
529
+ // transaction entirely and do not bump revisionToken. This matches the
530
+ // pattern used by the formatting-indent path.
531
+ const result = outdentParagraphAtSelection(state.document, state.selection, {
532
+ timestamp: context.timestamp,
533
+ });
534
+ return buildDocumentReplaceTransaction(state, context, {
535
+ changed: result.changed,
536
+ document: result.document,
537
+ selection: result.selection,
538
+ mapping: result.mapping,
539
+ });
540
+ }
516
541
  case "text.insert-hard-break": {
517
542
  const suggestingResult = context.documentMode === "suggesting"
518
543
  ? applySuggestingInsertUnit(state, "hard_break", context)
@@ -785,7 +810,7 @@ export function executeEditorCommand(
785
810
  {
786
811
  historyBoundary: "push",
787
812
  markDirty: true,
788
- effects: { commentReplyAdded: { commentId: command.commentId } },
813
+ effects: { commentReplyAdded: { commentId: command.commentId, entryId } },
789
814
  },
790
815
  );
791
816
  }
@@ -23,6 +23,8 @@ import {
23
23
  resolveParagraphScope,
24
24
  type StructuralMutationResult,
25
25
  } from "./structural-helpers.ts";
26
+ import { applyIndentation } from "./formatting-commands.ts";
27
+ import { createEmptyMapping, type TransactionMapping } from "../selection/mapping.ts";
26
28
  import { createEditorSurfaceSnapshot } from "../../runtime/surface-projection.ts";
27
29
 
28
30
  export interface TextCommandContext {
@@ -197,6 +199,63 @@ export function insertTab(
197
199
  );
198
200
  }
199
201
 
202
+ /**
203
+ * Reduce the active paragraph's left indent by one half-inch step (720 twips).
204
+ * Mirrors Word's Shift+Tab behavior on a non-list paragraph.
205
+ *
206
+ * Defensive list-check: if the paragraph carries `numbering`, this is a no-op —
207
+ * list-level changes are owned by the list-aware dispatcher in
208
+ * `src/runtime/edit-dispatch/list-aware-dispatch.ts`, which intercepts before
209
+ * we are called. The runtime command itself never mutates list level.
210
+ *
211
+ * No text positions change, so `mapping` is empty and the selection is preserved.
212
+ */
213
+ export function outdentParagraphAtSelection(
214
+ document: CanonicalDocumentEnvelope,
215
+ selection: SelectionSnapshot,
216
+ context: TextCommandContext,
217
+ ): {
218
+ changed: boolean;
219
+ document: CanonicalDocumentEnvelope;
220
+ selection: SelectionSnapshot;
221
+ mapping: TransactionMapping;
222
+ } {
223
+ const noop = {
224
+ changed: false,
225
+ document,
226
+ selection,
227
+ mapping: createEmptyMapping(),
228
+ };
229
+
230
+ const scope = resolveParagraphScope(document, selection);
231
+ if (!scope) {
232
+ return noop;
233
+ }
234
+
235
+ // Defensive: list paragraphs are owned by the list-aware dispatcher.
236
+ if (scope.paragraph.numbering) {
237
+ return noop;
238
+ }
239
+
240
+ // `resolveParagraphScope` already returns a cloned paragraph, so it is
241
+ // safe to mutate in place.
242
+ const changed = applyIndentation(scope.paragraph, -1);
243
+ if (!changed) {
244
+ return noop;
245
+ }
246
+
247
+ const nextDocument = replaceParagraphScope(document, scope, [scope.paragraph]);
248
+ return {
249
+ changed: true,
250
+ document: {
251
+ ...nextDocument,
252
+ updatedAt: context.timestamp,
253
+ },
254
+ selection,
255
+ mapping: createEmptyMapping(),
256
+ };
257
+ }
258
+
200
259
  export function insertHardBreak(
201
260
  document: CanonicalDocumentEnvelope,
202
261
  selection: SelectionSnapshot,