@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
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)
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.45",
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;
@@ -2693,11 +2721,50 @@ export interface EditorTelemetryEvent {
2693
2721
  detail?: Record<string, unknown>;
2694
2722
  }
2695
2723
 
2724
+ /**
2725
+ * Stage 0B.1 — parameters passed to `EditorHostAdapter.renderChartPreview`
2726
+ * when the importer encounters a `c:chartSpace` that has no cached
2727
+ * `mc:Fallback` bitmap. Hosts receive the raw chart-part XML plus the
2728
+ * theme XML and the intended display size, and return either preview
2729
+ * bytes (typically PNG or SVG) or `null` to fall back to the typed
2730
+ * badge.
2731
+ *
2732
+ * Field stability: only additive changes; no field is ever renamed or
2733
+ * removed. Host implementations should use structural narrowing to
2734
+ * ignore unknown fields in future versions.
2735
+ */
2736
+ export interface ChartPreviewResolveParams {
2737
+ /** Chart part body (`word/charts/chartN.xml`). UTF-8 string. */
2738
+ chartXml: string;
2739
+ /** Absolute package path of the chart part (e.g. `/word/charts/chart1.xml`). Useful as a cache key. */
2740
+ chartPartPath: string;
2741
+ /** Body of `theme1.xml` when the package ships one; `undefined` otherwise. */
2742
+ themeXml: string | undefined;
2743
+ /** Intended display width in EMU (extracted from the drawing's `wp:extent`). */
2744
+ widthEmu: number;
2745
+ /** Intended display height in EMU. */
2746
+ heightEmu: number;
2747
+ }
2748
+
2696
2749
  export interface EditorHostAdapter {
2697
2750
  load?(params: LoadRequest): Promise<LoadResult>;
2698
2751
  saveSession?(params: SaveSessionParams): Promise<SaveSessionResult>;
2699
2752
  saveExport?(params: SaveExportParams): Promise<SaveExportResult>;
2700
2753
  logEvent?(event: EditorTelemetryEvent): void;
2754
+ /**
2755
+ * Stage 0B.1: render a chart to bitmap/SVG preview bytes. Called at
2756
+ * import time for every `c:chartSpace` that ships without a cached
2757
+ * `mc:Fallback` blip. Return `null` to fall back to the typed badge
2758
+ * (Stage 0 behavior). Return a `Uint8Array` to inject the bytes as
2759
+ * a synthetic `MediaItem` and render the preview through the
2760
+ * existing `chart_atom` path.
2761
+ *
2762
+ * Content type is inferred from the first few bytes: `image/png`
2763
+ * for a PNG magic number, `image/svg+xml` otherwise. Hosts that
2764
+ * need a different content type should extend this contract in a
2765
+ * follow-up.
2766
+ */
2767
+ renderChartPreview?(params: ChartPreviewResolveParams): Promise<Uint8Array | null>;
2701
2768
  }
2702
2769
 
2703
2770
  export interface EditorDatastoreAdapter {
@@ -2712,11 +2779,11 @@ export interface WordReviewEditorRef {
2712
2779
  blur(): void;
2713
2780
  undo(): void;
2714
2781
  redo(): void;
2715
- addComment(params: AddCommentParams): string;
2782
+ addComment(params: AddCommentParams): AddCommentResult;
2716
2783
  openComment(commentId: string): void;
2717
2784
  resolveComment(commentId: string): void;
2718
2785
  reopenComment(commentId: string): void;
2719
- addCommentReply(commentId: string, body: string): void;
2786
+ addCommentReply(commentId: string, body: string): AddCommentReplyResult;
2720
2787
  editCommentBody(commentId: string, body: string): void;
2721
2788
  deleteComment(commentId: string): void;
2722
2789
  acceptChange(changeId: string): void;
@@ -3073,6 +3140,20 @@ export interface WordReviewEditorProps {
3073
3140
  chromePreset?: WordReviewEditorChromePreset;
3074
3141
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
3075
3142
  markupDisplay?: "clean" | "simple" | "all";
3143
+ /**
3144
+ * Harness/debug-only preview toggle for preserve-only features
3145
+ * (charts, SmartArt, shapes, WordArt, VML rendered as typed atoms;
3146
+ * the "N preserve-only features detected" banner; lock-callouts).
3147
+ *
3148
+ * MUST default to `false`. Consumer hosts MUST NOT hard-code `true` —
3149
+ * this is a dev opt-in gated by the harness dev drawer's
3150
+ * `debugMode && showUnsupportedObjectPreviews` AND-gate. Flipping the
3151
+ * default or dropping the AND-gate is a regression that has landed
3152
+ * three times (PRs #124, #131, #160); the invariant is locked by
3153
+ * `test/ui/unsupported-previews-invariant.test.ts`.
3154
+ *
3155
+ * @default false
3156
+ */
3076
3157
  showUnsupportedObjectPreviews?: boolean;
3077
3158
  showReviewPanel?: boolean;
3078
3159
  chromeVisibility?: Partial<WordReviewEditorChromeVisibility>;
@@ -3097,6 +3178,61 @@ export interface WordReviewEditorProps {
3097
3178
  * inline "Comments panel" icon appears only when a callback is wired.
3098
3179
  */
3099
3180
  onReviewSidebarComments?: () => void;
3181
+ /**
3182
+ * Optional: fires when the user invokes the Find shortcut
3183
+ * (Ctrl/Cmd+F) with the editor focused. When a host wires this
3184
+ * callback, the editor treats the shortcut as host-delegated and
3185
+ * suppresses the browser's native Find flow; when omitted, the
3186
+ * shortcut falls through to the browser default. The callback
3187
+ * receives the current selection so the host can pre-populate its
3188
+ * Find panel with the selected text.
3189
+ *
3190
+ * Capability id: `shortcut.find`. See
3191
+ * `src/runtime/editor-surface/capabilities.ts` for the full
3192
+ * capability contract.
3193
+ */
3194
+ onFindRequested?: (context: ShortcutDelegationContext) => void;
3195
+ /**
3196
+ * Optional: fires when the user invokes Print (Ctrl/Cmd+P) with
3197
+ * the editor focused. When wired, the editor calls this callback
3198
+ * and suppresses the browser's native print dialog — the host is
3199
+ * expected to open its own print flow (e.g. a PDF export pipeline
3200
+ * that preserves review chrome). When omitted, Ctrl/Cmd+P falls
3201
+ * through to the browser's print dialog.
3202
+ *
3203
+ * Capability id: `shortcut.print`.
3204
+ */
3205
+ onPrintRequested?: () => void;
3206
+ /**
3207
+ * Optional: fires when the user invokes a zoom shortcut
3208
+ * (Ctrl/Cmd+Plus, Ctrl/Cmd+Minus, Ctrl/Cmd+0). When wired, the
3209
+ * editor suppresses the browser's native zoom and delegates the
3210
+ * direction to the host. When omitted, the browser handles zoom
3211
+ * as usual.
3212
+ *
3213
+ * Capability ids: `shortcut.zoom-in`, `shortcut.zoom-out`,
3214
+ * `shortcut.zoom-reset`.
3215
+ */
3216
+ onZoomRequested?: (direction: "in" | "out" | "reset") => void;
3217
+ }
3218
+
3219
+ /**
3220
+ * Selection context handed to host-delegated shortcut callbacks
3221
+ * (`onFindRequested`, future `onReplaceRequested`, etc.) so the host
3222
+ * can pre-populate its own UI with the user's current selection.
3223
+ *
3224
+ * - `selectionText` is truncated to the first 500 characters —
3225
+ * Find / Replace panels typically only need a snippet, and unbounded
3226
+ * text would be wasteful for large selections.
3227
+ * - `selectionRange` is the same shape exposed via the
3228
+ * `selection_changed` editor event, so hosts can reuse selection
3229
+ * plumbing.
3230
+ */
3231
+ export interface ShortcutDelegationContext {
3232
+ /** The user-visible text of the selection, truncated to 500 chars. Empty string when collapsed. */
3233
+ selectionText: string;
3234
+ /** The selection range as a SelectionSnapshot. */
3235
+ selectionRange: SelectionSnapshot;
3100
3236
  }
3101
3237
 
3102
3238
  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,