@beyondwork/docx-react-component 1.0.48 → 1.0.49

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 (45) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +84 -12
  4. package/src/core/commands/index.ts +9 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-main-document.ts +9 -0
  12. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  13. package/src/io/export/serialize-run-formatting.ts +10 -1
  14. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  15. package/src/io/ooxml/chart/color-palette.ts +101 -0
  16. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  17. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  18. package/src/io/ooxml/chart/parse-series.ts +76 -11
  19. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  20. package/src/io/ooxml/chart/types.ts +30 -11
  21. package/src/io/ooxml/parse-complex-content.ts +6 -3
  22. package/src/io/ooxml/parse-main-document.ts +41 -0
  23. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  24. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  25. package/src/io/ooxml/property-grab-bag.ts +211 -0
  26. package/src/model/canonical-document.ts +69 -3
  27. package/src/runtime/collab/index.ts +7 -0
  28. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  29. package/src/runtime/collab/workflow-shared.ts +247 -0
  30. package/src/runtime/document-locations.ts +1 -9
  31. package/src/runtime/document-outline.ts +1 -9
  32. package/src/runtime/document-runtime.ts +74 -49
  33. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  34. package/src/runtime/surface-projection.ts +94 -36
  35. package/src/runtime/theme-color-resolver.ts +188 -0
  36. package/src/runtime/workflow-markup.ts +7 -18
  37. package/src/ui/WordReviewEditor.tsx +18 -2
  38. package/src/ui/editor-runtime-boundary.ts +36 -0
  39. package/src/ui/headless/selection-helpers.ts +10 -23
  40. package/src/ui/unsupported-previews-policy.ts +23 -0
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  42. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  45. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
package/README.md CHANGED
@@ -224,17 +224,22 @@ The CCEP corpus is kept on `main` as a maintainer-safe smoke-doc source set for
224
224
 
225
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
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 |
227
+ **Progress snapshot — 2026-04-19 (post PR #189 + #190 + #191 + #192 merged to `main`; Lane 3 split into 3a + 3b on 2026-04-19 deep evening):**
228
+
229
+ | # | Lane | % to goal | Status summary |
230
+ |---|---|---|---|
231
+ | 1 | [**Editing Foundation**](docs/plans/lane-1-editing-foundation.md) | **65%** | I1–I6 + I3 widening + I4 + S1 + X3 + I8 shipped; I2B / I7 / R.1–R.5 backlog |
232
+ | 2 | [**Render Performance**](docs/plans/lane-2-render-performance.md) | **85%** | All Phases 1.x + 2.2 + 2.4b + 2.5 (A+B) + 2.9 shipped; Tasks 2.1 / 2.3 left |
233
+ | 3a | [**Layout & Render Engine**](docs/plans/lane-3a-layout-render-engine.md) | **45%** | P1–P6 + P8 + P14.a/b/e shipped; P9 (now unblocked) + P10 + P11 + P12 + P14.c/d backlog |
234
+ | 3b | [**OOXML Fidelity & Round-Trip**](docs/plans/lane-3b-ooxml-fidelity.md) | **55%** | V1 + O3 + O4 + O2 (all 4 slices) + V7 closed; 🚨 O8 + L2.c + V6 + V2a/b/c + R3–R5 backlog |
235
+ | 4 | [**Collab + CLM/Vallor**](docs/plans/lane-4-collab-clm-vallor.md) | **80%** | P1–P14 + all P11 sub-bullets + P12 + perf-parity + P13 A/B/C shipped; P15 / P16 / P17 left |
236
+ | 5 | [**Charts (independent)**](docs/plans/lane-5-charts.md) | **30%** | Stages 0–2 shipped (parsers + theme); Stages 3–7 (SVG renderers + pixel-diff) left |
237
+ | 6 | [**Visual Chrome / Layout Polish**](docs/plans/lane-6-visual-chrome-layout-polish.md) | **0%** | LATER activates after Lane 3b V2c + Lane 2 Phase 2.2 ship; discrete paper cards, native chrome, float-wrap, validation bar |
238
+ | 7 | [**Bugs / Gaps / Cross-cutting**](docs/plans/lane-7-bugs-gaps-cross-cutting.md) | **0%** | LATER — drain V#/O#/X# register, trigger-gated work, infrastructure hardening |
239
+ | 8 | [**API Ergonomics / Errors / BC**](docs/plans/lane-8-api-ergonomics.md) | **40%** | LATER — Tracks A+C shipped (error catalog + ergonomics fixes); Tracks B+D+E + public-api.md end-to-end refactor remain |
240
+ | 9 | [**Shipping (v2.0.0)**](docs/plans/lane-9-shipping.md) | **0%** | FINAL — API freeze, semver discipline, changelog, telemetry, customer migration guides, doc completeness audit |
241
+
242
+ **Aggregate v2.0.0 readiness:** ~70% — long poles are Lane 3 O8 + P9/P10, Lane 4 P15/P16/P17, Lane 1 R.1–R.5, Lane 5 Stage 4 SVG renderers (Lane 5 ships on independent v2.x cadence). Detailed task-completeness factor + per-lane backlog inventory in [`docs/plans/coordination-prompt.md`](docs/plans/coordination-prompt.md).
238
243
 
239
244
  ### Technical Wiki
240
245
 
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.48",
4
+ "version": "1.0.49",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
- "packageManager": "pnpm@10.30.3",
7
6
  "type": "module",
8
7
  "sideEffects": [
9
8
  "**/*.css"
@@ -93,35 +92,6 @@
93
92
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
94
93
  },
95
94
  "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
- },
125
95
  "keywords": [
126
96
  "docx",
127
97
  "word",
@@ -205,14 +175,33 @@
205
175
  "y-protocols": "^1.0.7",
206
176
  "yjs": "^13.6.30"
207
177
  },
208
- "pnpm": {
209
- "onlyBuiltDependencies": [
210
- "esbuild",
211
- "sharp"
212
- ],
213
- "overrides": {
214
- "react": "19.2.4",
215
- "react-dom": "19.2.4"
216
- }
178
+ "scripts": {
179
+ "build": "tsup",
180
+ "test": "bash scripts/run-workspace-tests.sh",
181
+ "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
182
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
183
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
184
+ "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
185
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
186
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
187
+ "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
188
+ "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
189
+ "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
190
+ "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
191
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
192
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
193
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
194
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
195
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
196
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
197
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
198
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
199
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
200
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
201
+ "wave:status": "bash scripts/wave-status.sh",
202
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
203
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
204
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
205
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
217
206
  }
218
- }
207
+ }
@@ -1,8 +1,10 @@
1
1
  import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
2
- import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
2
+ import type { HarnessDebugPorts } from "../internal/harness-debug-ports.ts";
3
+ import type { CanonicalParagraphFormatting, CanonicalRunFormatting, TextMark } from "../model/canonical-document.ts";
3
4
  import type { WordReviewEditorLayoutFacet } from "../runtime/layout/public-facet.ts";
4
5
  import type { RenderFrameRect } from "../runtime/render/index.ts";
5
6
  import type { ScopeRailPosture } from "../runtime/workflow-rail-segments.ts";
7
+ import type { SharedWorkflowState } from "../runtime/collab/workflow-shared.ts";
6
8
  import type {
7
9
  MetadataPersistenceMode,
8
10
  ScopeMetadataPersistence,
@@ -46,6 +48,7 @@ export type {
46
48
  };
47
49
 
48
50
  export type { CanonicalParagraphFormatting, CanonicalRunFormatting };
51
+ export type { SharedWorkflowState };
49
52
 
50
53
  export type {
51
54
  WordReviewEditorLayoutFacet,
@@ -750,6 +753,25 @@ export interface InsertImageOptions {
750
753
  altText?: string;
751
754
  }
752
755
 
756
+ /** Re-export canonical `TextMark` so hosts can construct explicit-mode directives for I7 `replaceText`. */
757
+ export type { TextMark };
758
+
759
+ /**
760
+ * I7 — `replaceText` / `text.insert` formatting directive.
761
+ *
762
+ * - `paragraph-default` (default): inserted text carries no run marks; the paragraph-level
763
+ * cascade applies. Pre-I7 behavior.
764
+ * - `match-replaced-range`: walk text units within the replaced range; if all share the
765
+ * same marks, apply them to the inserted text. Mixed ranges fall back to
766
+ * `paragraph-default`. For collapsed (empty) ranges the marks of the run immediately
767
+ * left of the caret are used (Word-matching behavior).
768
+ * - `explicit`: apply the caller-supplied `marks` verbatim.
769
+ */
770
+ export type TextFormattingDirective =
771
+ | { mode: "paragraph-default" }
772
+ | { mode: "match-replaced-range" }
773
+ | { mode: "explicit"; marks: TextMark[] };
774
+
753
775
  export type SurfaceTextMark =
754
776
  | "bold"
755
777
  | "italic"
@@ -1685,6 +1707,16 @@ export interface AddCommentParams {
1685
1707
  anchor?: EditorAnchorProjection;
1686
1708
  body?: string;
1687
1709
  authorId?: string;
1710
+ /**
1711
+ * I8 — When `true`, anchors that would be rejected with reason
1712
+ * `comment_anchor_table_adjacent` are auto-snapped to the enclosing
1713
+ * paragraph boundaries before the comment is created. Defaults to
1714
+ * `false` for back-compat. Removed once Lane 3 §O8 lands the deep
1715
+ * serializer fix and mid-run-near-table anchors no longer need
1716
+ * mitigation — the option stays useful for hosts that prefer
1717
+ * boundary-aligned comments regardless.
1718
+ */
1719
+ snapToSafeBoundary?: boolean;
1688
1720
  }
1689
1721
 
1690
1722
  export interface AddCommentResult {
@@ -2007,6 +2039,7 @@ export interface WorkflowBlockedCommandReason {
2007
2039
  code:
2008
2040
  | "outside_workflow_scope"
2009
2041
  | "workflow_comment_only"
2042
+ | "workflow_round_locked"
2010
2043
  | "workflow_view_only"
2011
2044
  | "workflow_preserve_only"
2012
2045
  | "workflow_blocked_import"
@@ -2881,7 +2914,11 @@ export interface WordReviewEditorRef {
2881
2914
  isDirty(): boolean;
2882
2915
  getFormattingState(): FormattingStateSnapshot;
2883
2916
  getStyleCatalog(): StyleCatalogSnapshot;
2884
- replaceText(text: string, target?: EditorAnchorProjection): void;
2917
+ replaceText(
2918
+ text: string,
2919
+ target?: EditorAnchorProjection,
2920
+ formatting?: TextFormattingDirective,
2921
+ ): void;
2885
2922
  toggleBulletedList(): void;
2886
2923
  toggleNumberedList(): void;
2887
2924
  toggleBold(): void;
@@ -2969,6 +3006,7 @@ export interface WordReviewEditorRef {
2969
3006
  setImageFrame(mediaId: string, offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number }): void;
2970
3007
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
2971
3008
  clearWorkflowOverlay(): void;
3009
+ setSharedWorkflowState(state: SharedWorkflowState | null): void;
2972
3010
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
2973
3011
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
2974
3012
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -3208,20 +3246,54 @@ export interface WordReviewEditorProps {
3208
3246
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
3209
3247
  markupDisplay?: "clean" | "simple" | "all";
3210
3248
  /**
3211
- * Harness/debug-only preview toggle for preserve-only features
3212
- * (charts, SmartArt, shapes, WordArt, VML rendered as typed atoms;
3213
- * the "N preserve-only features detected" banner; lock-callouts).
3249
+ * @internal HARNESS-ONLY debug-ports token.
3214
3250
  *
3215
- * MUST default to `false`. Consumer hosts MUST NOT hard-code `true`
3216
- * this is a dev opt-in gated by the harness dev drawer's
3217
- * `debugMode && showUnsupportedObjectPreviews` AND-gate. Flipping the
3218
- * default or dropping the AND-gate is a regression that has landed
3219
- * three times (PRs #124, #131, #160); the invariant is locked by
3251
+ * **Do not set this from downstream consumer code.** This prop
3252
+ * replaces the former `showUnsupportedObjectPreviews?: boolean`
3253
+ * toggle, which regressed to `true` three times through merges
3254
+ * (PRs #124, #131, #160) and leaked preserve-only preview chrome
3255
+ * (charts, SmartArt, shapes, WordArt, VML, "N preserve-only
3256
+ * features detected" banner, lock-callouts) into consumer apps.
3257
+ *
3258
+ * A valid value is only obtainable from the internal module
3259
+ * `src/internal/harness-debug-ports.ts` via
3260
+ * `__createHarnessDebugPorts()`. That module is deliberately **not
3261
+ * listed in `package.json#exports`**, so downstream consumers
3262
+ * cannot import it through any public subpath. The token type is
3263
+ * branded with a module-local `unique symbol`, so downstream
3264
+ * callers cannot construct a value structurally either — any
3265
+ * attempt to set this prop from outside the in-repo harness will
3266
+ * produce a TypeScript error.
3267
+ *
3268
+ * At runtime, even a successfully-constructed token is
3269
+ * gated by `globalThis.__DOCX_REACT_COMPONENT_HARNESS__ === true`,
3270
+ * which only the harness sets via `__markHarnessEnvironment()`.
3271
+ * The permissions field (`unsupportedObjectPreviews`) is forced
3272
+ * to `false` outside the harness environment regardless of input.
3273
+ *
3274
+ * Invariant locked at
3220
3275
  * `test/ui/unsupported-previews-invariant.test.ts`.
3276
+ */
3277
+ __harnessDebugPorts?: HarnessDebugPorts;
3278
+ /**
3279
+ * Declarative policy for showing preserve-only previews
3280
+ * (charts, SmartArt, shapes, WordArt, VML) and the
3281
+ * "N preserve-only features detected" banner. Orthogonal to the
3282
+ * harness-only `__harnessDebugPorts` token: hosts can opt into
3283
+ * previews without a harness environment.
3284
+ *
3285
+ * - `"never"` — previews are hidden (default).
3286
+ * - `"review-only"` — previews show when `reviewMode === "review"`
3287
+ * and hide when `reviewMode === "editing"`.
3288
+ * - `"always"` — previews show in every review mode.
3289
+ *
3290
+ * Effective visibility rule:
3291
+ * `policy === "always"` OR
3292
+ * (`policy === "review-only"` AND `reviewMode === "review"`)
3221
3293
  *
3222
- * @default false
3294
+ * @default "never"
3223
3295
  */
3224
- showUnsupportedObjectPreviews?: boolean;
3296
+ unsupportedPreviewsPolicy?: "never" | "review-only" | "always";
3225
3297
  showReviewPanel?: boolean;
3226
3298
  chromeVisibility?: Partial<WordReviewEditorChromeVisibility>;
3227
3299
  hostAdapter?: EditorHostAdapter;
@@ -50,6 +50,7 @@ import type {
50
50
  SectionBreakType,
51
51
  SectionLayoutPatch,
52
52
  SectionPageNumberingPatch,
53
+ TextFormattingDirective,
53
54
  WorkflowMetadataDefinition,
54
55
  WorkflowMetadataEntry,
55
56
  WorkflowOverlay,
@@ -136,6 +137,13 @@ export type EditorCommand =
136
137
  | {
137
138
  type: "text.insert";
138
139
  text: string;
140
+ /**
141
+ * I7 — optional directive controlling which character-level marks the inserted
142
+ * text carries. Defaults to `{ mode: "paragraph-default" }` (today's behavior:
143
+ * no inherited run marks). See `src/api/public-types.ts` `TextFormattingDirective`
144
+ * for semantics.
145
+ */
146
+ formatting?: TextFormattingDirective;
139
147
  origin?: CommandOrigin;
140
148
  }
141
149
  | {
@@ -556,7 +564,7 @@ export function executeEditorCommand(
556
564
  : undefined;
557
565
  if (suggestingResult) return suggestingResult;
558
566
  return applyTextCommand(state, context.timestamp, (document, selection) =>
559
- insertText(document, selection, command.text, context),
567
+ insertText(document, selection, command.text, context, command.formatting),
560
568
  );
561
569
  }
562
570
  case "text.delete-backward": {
@@ -1,4 +1,4 @@
1
- import type { InsertTableOptions } from "../../api/public-types";
1
+ import type { InsertTableOptions, TextFormattingDirective } from "../../api/public-types";
2
2
  import type {
3
3
  DocumentRootNode,
4
4
  ParagraphNode,
@@ -126,6 +126,7 @@ export function insertText(
126
126
  selection: SelectionSnapshot,
127
127
  text: string,
128
128
  context: TextCommandContext,
129
+ formatting?: TextFormattingDirective,
129
130
  ): TextTransactionResult {
130
131
  return applyTextTransaction(
131
132
  document,
@@ -138,6 +139,7 @@ export function insertText(
138
139
  text,
139
140
  },
140
141
  ],
142
+ formatting,
141
143
  },
142
144
  context,
143
145
  );
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Canonical boundary between the internal `EditorAnchorProjection` shape
3
+ * (`src/core/selection/mapping.ts` — `{ kind: "range", range: DocRange,
4
+ * assoc }`) and the public `EditorAnchorProjection` shape
5
+ * (`src/api/public-types.ts` — `{ kind: "range", from, to, assoc }`).
6
+ *
7
+ * Two shapes stay split intentionally: the internal shape uses `DocRange`
8
+ * so mapping helpers compose ranges independently of assoc semantics;
9
+ * the public shape is flat so host consumers can destructure without
10
+ * reaching through a nested `range` member.
11
+ *
12
+ * Every conversion MUST go through this module. Every creation of a
13
+ * public-shape anchor MUST go through this module. A regression test
14
+ * in `test/api/anchor-boundary-invariants.test.ts` enforces the "no
15
+ * ad-hoc helpers outside this file" rule.
16
+ */
17
+
18
+ import type { EditorAnchorProjection as PublicEditorAnchorProjection } from "../../api/public-types";
19
+ import {
20
+ DEFAULT_BOUNDARY_ASSOC,
21
+ createDetachedAnchor,
22
+ createNodeAnchor,
23
+ createRangeAnchor,
24
+ normalizeRange,
25
+ type DocRange,
26
+ type EditorAnchorProjection as InternalEditorAnchorProjection,
27
+ } from "./mapping.ts";
28
+
29
+ /**
30
+ * Default boundary-associativity used by public-shape range anchors
31
+ * when the caller does not provide one. Matches the inline default the
32
+ * three ad-hoc `createPublicRangeAnchor` helpers all used before this
33
+ * module existed (`{ start: -1, end: 1 }` — the "outward-biased"
34
+ * selection).
35
+ */
36
+ export const DEFAULT_PUBLIC_ASSOC: { start: -1 | 1; end: -1 | 1 } = {
37
+ start: -1,
38
+ end: 1,
39
+ };
40
+
41
+ type PublicRangeAssoc = { start: -1 | 1; end: -1 | 1 };
42
+
43
+ export function createPublicRangeAnchor(
44
+ from: number,
45
+ to: number,
46
+ assoc: PublicRangeAssoc = DEFAULT_PUBLIC_ASSOC,
47
+ ): PublicEditorAnchorProjection {
48
+ const range = normalizeRange({ from, to });
49
+ return {
50
+ kind: "range",
51
+ from: range.from,
52
+ to: range.to,
53
+ assoc,
54
+ };
55
+ }
56
+
57
+ export function createPublicNodeAnchor(
58
+ at: number,
59
+ assoc: -1 | 1 = 1,
60
+ ): PublicEditorAnchorProjection {
61
+ return { kind: "node", at, assoc };
62
+ }
63
+
64
+ export function createPublicDetachedAnchor(
65
+ lastKnownRange: DocRange,
66
+ reason: "deleted" | "invalidatedByStructureChange" | "importAmbiguity",
67
+ ): PublicEditorAnchorProjection {
68
+ return {
69
+ kind: "detached",
70
+ lastKnownRange: normalizeRange(lastKnownRange),
71
+ reason,
72
+ };
73
+ }
74
+
75
+ export function toPublicAnchorProjection(
76
+ anchor: InternalEditorAnchorProjection,
77
+ ): PublicEditorAnchorProjection {
78
+ switch (anchor.kind) {
79
+ case "range":
80
+ return {
81
+ kind: "range",
82
+ from: anchor.range.from,
83
+ to: anchor.range.to,
84
+ assoc: anchor.assoc,
85
+ };
86
+ case "node":
87
+ return { kind: "node", at: anchor.at, assoc: anchor.assoc };
88
+ case "detached":
89
+ return {
90
+ kind: "detached",
91
+ lastKnownRange: anchor.lastKnownRange,
92
+ reason: anchor.reason,
93
+ };
94
+ }
95
+ }
96
+
97
+ export function toInternalAnchorProjection(
98
+ anchor: PublicEditorAnchorProjection,
99
+ ): InternalEditorAnchorProjection {
100
+ switch (anchor.kind) {
101
+ case "range":
102
+ return createRangeAnchor(
103
+ anchor.from,
104
+ anchor.to,
105
+ anchor.assoc ?? DEFAULT_BOUNDARY_ASSOC,
106
+ );
107
+ case "node":
108
+ return createNodeAnchor(anchor.at, anchor.assoc);
109
+ case "detached":
110
+ return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
111
+ }
112
+ }
@@ -103,20 +103,125 @@ export function rangeStaysWithinSingleParagraph(
103
103
  return true;
104
104
  }
105
105
 
106
+ /**
107
+ * I8 — "mid-run-near-table" guard. Comment anchors whose endpoints
108
+ * land strictly inside a paragraph that sits adjacent to a table
109
+ * block are rejected: the serializer's per-paragraph offset walker
110
+ * (Lane 3 §O8) produces invalid OOXML for these anchors. Removed
111
+ * once O8 ships.
112
+ */
113
+ export const TABLE_ADJACENT_WINDOW = 1;
114
+
115
+ export type CommentAnchorRejectionReason =
116
+ | "invalid_comment_anchor"
117
+ | "comment_anchor_table_adjacent";
118
+
106
119
  export function canCreateDocxCommentAnchor(
107
120
  content: unknown,
108
121
  anchor: ReviewAnchor,
109
122
  ): boolean {
123
+ return commentAnchorRejectionReason(content, anchor) === null;
124
+ }
125
+
126
+ export function commentAnchorRejectionReason(
127
+ content: unknown,
128
+ anchor: ReviewAnchor,
129
+ ): CommentAnchorRejectionReason | null {
110
130
  if (anchor.kind !== "range") {
111
- return false;
131
+ return "invalid_comment_anchor";
112
132
  }
113
133
 
114
134
  const normalized = normalizeRange(anchor.range);
115
135
  if (normalized.from === normalized.to) {
116
- return false;
136
+ return "invalid_comment_anchor";
137
+ }
138
+
139
+ if (!rangeStaysWithinCommentableStory(content, normalized)) {
140
+ return "invalid_comment_anchor";
141
+ }
142
+
143
+ if (rangeLandsMidRunNearTableBoundary(content, normalized)) {
144
+ return "comment_anchor_table_adjacent";
117
145
  }
118
146
 
119
- return rangeStaysWithinCommentableStory(content, normalized);
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * I8.3 — Snap a rejected mid-run-near-table anchor to paragraph
152
+ * boundaries so downstream serialization stays safe. Returns `null`
153
+ * if the anchor cannot be rescued (e.g. crosses an opaque block).
154
+ */
155
+ export function snapCommentAnchorAwayFromTable(
156
+ content: unknown,
157
+ anchor: ReviewAnchor,
158
+ ): ReviewAnchor | null {
159
+ if (anchor.kind !== "range") return null;
160
+
161
+ const normalized = normalizeRange(anchor.range);
162
+ if (normalized.from === normalized.to) return null;
163
+
164
+ const reason = commentAnchorRejectionReason(content, anchor);
165
+ if (reason === null) return anchor;
166
+ if (reason !== "comment_anchor_table_adjacent") return null;
167
+
168
+ const surfaceBlocks = readSurfaceBlocks(content);
169
+ if (!surfaceBlocks) return null;
170
+
171
+ const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.from, "start");
172
+ const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, normalized.to, "end");
173
+ if (!fromOwner || !toOwner) return null;
174
+
175
+ const snappedFrom = normalized.from > fromOwner.from && normalized.from < fromOwner.to
176
+ ? fromOwner.from
177
+ : normalized.from;
178
+ const snappedTo = normalized.to > toOwner.from && normalized.to < toOwner.to
179
+ ? toOwner.to
180
+ : normalized.to;
181
+
182
+ if (snappedFrom === snappedTo) return null;
183
+
184
+ const snapped = createRangeAnchor(snappedFrom, snappedTo);
185
+ return canCreateDocxCommentAnchor(content, snapped) ? snapped : null;
186
+ }
187
+
188
+ function rangeLandsMidRunNearTableBoundary(
189
+ content: unknown,
190
+ range: DocRange,
191
+ ): boolean {
192
+ const surfaceBlocks = readSurfaceBlocks(content);
193
+ if (!surfaceBlocks) return false;
194
+
195
+ const tableBlocks = surfaceBlocks.filter((block) => block.kind === "table");
196
+ if (tableBlocks.length === 0) return false;
197
+
198
+ const fromOwner = findContainingParagraphForEndpoint(surfaceBlocks, range.from, "start");
199
+ const toOwner = findContainingParagraphForEndpoint(surfaceBlocks, range.to, "end");
200
+ if (!fromOwner || !toOwner) return false;
201
+
202
+ const fromMidRun = range.from > fromOwner.from && range.from < fromOwner.to;
203
+ const toMidRun = range.to > toOwner.from && range.to < toOwner.to;
204
+ if (!fromMidRun && !toMidRun) return false;
205
+
206
+ const fromAdjacent = fromMidRun && paragraphIsTableAdjacent(fromOwner, tableBlocks);
207
+ const toAdjacent = toMidRun && paragraphIsTableAdjacent(toOwner, tableBlocks);
208
+ return fromAdjacent || toAdjacent;
209
+ }
210
+
211
+ function paragraphIsTableAdjacent(
212
+ paragraph: FlattenedSurfaceBlock,
213
+ tableBlocks: readonly FlattenedSurfaceBlock[],
214
+ ): boolean {
215
+ return tableBlocks.some((table) => {
216
+ if (table.tableId !== null && table.tableId === paragraph.tableId) {
217
+ // Skip: a table block at the same cell scope would be a
218
+ // descendant of the paragraph's containing cell, not a sibling.
219
+ return false;
220
+ }
221
+ const leftGap = Math.abs(paragraph.from - table.to);
222
+ const rightGap = Math.abs(paragraph.to - table.from);
223
+ return leftGap <= TABLE_ADJACENT_WINDOW || rightGap <= TABLE_ADJACENT_WINDOW;
224
+ });
120
225
  }
121
226
 
122
227
  export function rangeStaysWithinCommentableStory(