@beyondwork/docx-react-component 1.0.48 → 1.0.50

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 (53) hide show
  1. package/README.md +19 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +103 -12
  4. package/src/core/commands/index.ts +30 -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-comments.ts +50 -5
  12. package/src/io/export/serialize-main-document.ts +9 -0
  13. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  14. package/src/io/export/serialize-run-formatting.ts +10 -1
  15. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  16. package/src/io/ooxml/chart/color-palette.ts +101 -0
  17. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  18. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  19. package/src/io/ooxml/chart/parse-series.ts +76 -11
  20. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  21. package/src/io/ooxml/chart/types.ts +30 -11
  22. package/src/io/ooxml/parse-complex-content.ts +6 -3
  23. package/src/io/ooxml/parse-main-document.ts +41 -0
  24. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  25. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  26. package/src/io/ooxml/property-grab-bag.ts +211 -0
  27. package/src/io/paste/word-clipboard.ts +114 -0
  28. package/src/model/canonical-document.ts +69 -3
  29. package/src/runtime/collab/index.ts +7 -0
  30. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  31. package/src/runtime/collab/workflow-shared.ts +247 -0
  32. package/src/runtime/document-locations.ts +1 -9
  33. package/src/runtime/document-outline.ts +1 -9
  34. package/src/runtime/document-runtime.ts +98 -50
  35. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  36. package/src/runtime/layout/layout-engine-version.ts +11 -1
  37. package/src/runtime/layout/public-facet.ts +5 -12
  38. package/src/runtime/render/render-frame-types.ts +14 -0
  39. package/src/runtime/render/render-kernel.ts +40 -2
  40. package/src/runtime/structure-ops/fragment-insert.ts +134 -0
  41. package/src/runtime/surface-projection.ts +94 -36
  42. package/src/runtime/theme-color-resolver.ts +188 -0
  43. package/src/runtime/workflow-markup.ts +7 -18
  44. package/src/ui/WordReviewEditor.tsx +22 -4
  45. package/src/ui/editor-runtime-boundary.ts +37 -0
  46. package/src/ui/headless/selection-helpers.ts +10 -23
  47. package/src/ui/unsupported-previews-policy.ts +23 -0
  48. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  49. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  50. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
  51. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  52. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  53. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
package/README.md CHANGED
@@ -224,17 +224,25 @@ 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
+ | 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **0%** | LATER (gated on 6a+6b) — V5 covers, V6 REF/PAGEREF, V7 cascade audit |
239
+ | 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a+6b) — X4.a/b structural table revisions, X5 ffData, move-pairing |
240
+ | 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a+6b) — O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
241
+ | 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a+6b) — harness-crash-hardening, fastload activation, worktree consolidation |
242
+ | 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 |
243
+ | 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 |
244
+
245
+ **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
246
 
239
247
  ### Technical Wiki
240
248
 
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.50",
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 { BlockNode, 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,36 @@ 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
+ * I2 Tier B Slice 1 — a block-level payload for `insertFragment` and the paste
761
+ * parsers that will drive it in later slices. Carries zero or more canonical
762
+ * block nodes (paragraphs, tables, SDTs, etc.); a host that constructs one
763
+ * directly is responsible for the blocks being well-formed per the canonical
764
+ * schema. The HTML / Word-clipboard parsers (Slices 2+3) will produce these.
765
+ */
766
+ export interface CanonicalDocumentFragment {
767
+ blocks: BlockNode[];
768
+ }
769
+
770
+ /**
771
+ * I7 — `replaceText` / `text.insert` formatting directive.
772
+ *
773
+ * - `paragraph-default` (default): inserted text carries no run marks; the paragraph-level
774
+ * cascade applies. Pre-I7 behavior.
775
+ * - `match-replaced-range`: walk text units within the replaced range; if all share the
776
+ * same marks, apply them to the inserted text. Mixed ranges fall back to
777
+ * `paragraph-default`. For collapsed (empty) ranges the marks of the run immediately
778
+ * left of the caret are used (Word-matching behavior).
779
+ * - `explicit`: apply the caller-supplied `marks` verbatim.
780
+ */
781
+ export type TextFormattingDirective =
782
+ | { mode: "paragraph-default" }
783
+ | { mode: "match-replaced-range" }
784
+ | { mode: "explicit"; marks: TextMark[] };
785
+
753
786
  export type SurfaceTextMark =
754
787
  | "bold"
755
788
  | "italic"
@@ -1685,6 +1718,16 @@ export interface AddCommentParams {
1685
1718
  anchor?: EditorAnchorProjection;
1686
1719
  body?: string;
1687
1720
  authorId?: string;
1721
+ /**
1722
+ * I8 — When `true`, anchors that would be rejected with reason
1723
+ * `comment_anchor_table_adjacent` are auto-snapped to the enclosing
1724
+ * paragraph boundaries before the comment is created. Defaults to
1725
+ * `false` for back-compat. Removed once Lane 3 §O8 lands the deep
1726
+ * serializer fix and mid-run-near-table anchors no longer need
1727
+ * mitigation — the option stays useful for hosts that prefer
1728
+ * boundary-aligned comments regardless.
1729
+ */
1730
+ snapToSafeBoundary?: boolean;
1688
1731
  }
1689
1732
 
1690
1733
  export interface AddCommentResult {
@@ -2007,6 +2050,7 @@ export interface WorkflowBlockedCommandReason {
2007
2050
  code:
2008
2051
  | "outside_workflow_scope"
2009
2052
  | "workflow_comment_only"
2053
+ | "workflow_round_locked"
2010
2054
  | "workflow_view_only"
2011
2055
  | "workflow_preserve_only"
2012
2056
  | "workflow_blocked_import"
@@ -2881,7 +2925,19 @@ export interface WordReviewEditorRef {
2881
2925
  isDirty(): boolean;
2882
2926
  getFormattingState(): FormattingStateSnapshot;
2883
2927
  getStyleCatalog(): StyleCatalogSnapshot;
2884
- replaceText(text: string, target?: EditorAnchorProjection): void;
2928
+ replaceText(
2929
+ text: string,
2930
+ target?: EditorAnchorProjection,
2931
+ formatting?: TextFormattingDirective,
2932
+ ): void;
2933
+ /**
2934
+ * I2 Tier B Slice 1 — splice a canonical block-level fragment at the target
2935
+ * anchor (or at the current selection when omitted). Empty fragment = no-op.
2936
+ * Baseline semantic: split the caret paragraph and insert fragment blocks
2937
+ * between the halves; range selections are deleted first. Future slices will
2938
+ * add merge-intent + richer caret placement as paste parsers come online.
2939
+ */
2940
+ insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
2885
2941
  toggleBulletedList(): void;
2886
2942
  toggleNumberedList(): void;
2887
2943
  toggleBold(): void;
@@ -2969,6 +3025,7 @@ export interface WordReviewEditorRef {
2969
3025
  setImageFrame(mediaId: string, offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number }): void;
2970
3026
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
2971
3027
  clearWorkflowOverlay(): void;
3028
+ setSharedWorkflowState(state: SharedWorkflowState | null): void;
2972
3029
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
2973
3030
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
2974
3031
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -3208,20 +3265,54 @@ export interface WordReviewEditorProps {
3208
3265
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
3209
3266
  markupDisplay?: "clean" | "simple" | "all";
3210
3267
  /**
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).
3268
+ * @internal HARNESS-ONLY debug-ports token.
3269
+ *
3270
+ * **Do not set this from downstream consumer code.** This prop
3271
+ * replaces the former `showUnsupportedObjectPreviews?: boolean`
3272
+ * toggle, which regressed to `true` three times through merges
3273
+ * (PRs #124, #131, #160) and leaked preserve-only preview chrome
3274
+ * (charts, SmartArt, shapes, WordArt, VML, "N preserve-only
3275
+ * features detected" banner, lock-callouts) into consumer apps.
3276
+ *
3277
+ * A valid value is only obtainable from the internal module
3278
+ * `src/internal/harness-debug-ports.ts` via
3279
+ * `__createHarnessDebugPorts()`. That module is deliberately **not
3280
+ * listed in `package.json#exports`**, so downstream consumers
3281
+ * cannot import it through any public subpath. The token type is
3282
+ * branded with a module-local `unique symbol`, so downstream
3283
+ * callers cannot construct a value structurally either — any
3284
+ * attempt to set this prop from outside the in-repo harness will
3285
+ * produce a TypeScript error.
3214
3286
  *
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
3287
+ * At runtime, even a successfully-constructed token is
3288
+ * gated by `globalThis.__DOCX_REACT_COMPONENT_HARNESS__ === true`,
3289
+ * which only the harness sets via `__markHarnessEnvironment()`.
3290
+ * The permissions field (`unsupportedObjectPreviews`) is forced
3291
+ * to `false` outside the harness environment regardless of input.
3292
+ *
3293
+ * Invariant locked at
3220
3294
  * `test/ui/unsupported-previews-invariant.test.ts`.
3295
+ */
3296
+ __harnessDebugPorts?: HarnessDebugPorts;
3297
+ /**
3298
+ * Declarative policy for showing preserve-only previews
3299
+ * (charts, SmartArt, shapes, WordArt, VML) and the
3300
+ * "N preserve-only features detected" banner. Orthogonal to the
3301
+ * harness-only `__harnessDebugPorts` token: hosts can opt into
3302
+ * previews without a harness environment.
3303
+ *
3304
+ * - `"never"` — previews are hidden (default).
3305
+ * - `"review-only"` — previews show when `reviewMode === "review"`
3306
+ * and hide when `reviewMode === "editing"`.
3307
+ * - `"always"` — previews show in every review mode.
3308
+ *
3309
+ * Effective visibility rule:
3310
+ * `policy === "always"` OR
3311
+ * (`policy === "review-only"` AND `reviewMode === "review"`)
3221
3312
  *
3222
- * @default false
3313
+ * @default "never"
3223
3314
  */
3224
- showUnsupportedObjectPreviews?: boolean;
3315
+ unsupportedPreviewsPolicy?: "never" | "review-only" | "always";
3225
3316
  showReviewPanel?: boolean;
3226
3317
  chromeVisibility?: Partial<WordReviewEditorChromeVisibility>;
3227
3318
  hostAdapter?: EditorHostAdapter;
@@ -49,7 +49,9 @@ import type {
49
49
  RuntimeRenderSnapshot,
50
50
  SectionBreakType,
51
51
  SectionLayoutPatch,
52
+ CanonicalDocumentFragment,
52
53
  SectionPageNumberingPatch,
54
+ TextFormattingDirective,
53
55
  WorkflowMetadataDefinition,
54
56
  WorkflowMetadataEntry,
55
57
  WorkflowOverlay,
@@ -87,6 +89,7 @@ import {
87
89
  setHeaderFooterLinkAtSectionIndex,
88
90
  } from "./section-layout-commands.ts";
89
91
  import { insertPageBreak, insertTable } from "./text-commands.ts";
92
+ import { applyFragmentInsert } from "../../runtime/structure-ops/fragment-insert.ts";
90
93
  import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
91
94
 
92
95
  export type ContentChildrenPatch =
@@ -136,6 +139,13 @@ export type EditorCommand =
136
139
  | {
137
140
  type: "text.insert";
138
141
  text: string;
142
+ /**
143
+ * I7 — optional directive controlling which character-level marks the inserted
144
+ * text carries. Defaults to `{ mode: "paragraph-default" }` (today's behavior:
145
+ * no inherited run marks). See `src/api/public-types.ts` `TextFormattingDirective`
146
+ * for semantics.
147
+ */
148
+ formatting?: TextFormattingDirective;
139
149
  origin?: CommandOrigin;
140
150
  }
141
151
  | {
@@ -162,6 +172,16 @@ export type EditorCommand =
162
172
  type: "paragraph.split";
163
173
  origin?: CommandOrigin;
164
174
  }
175
+ | {
176
+ /**
177
+ * I2 Tier B Slice 1 — splice a `CanonicalDocumentFragment` at the current
178
+ * selection. Baseline semantic: split the caret paragraph and insert
179
+ * fragment blocks between the halves; range selections are deleted first.
180
+ */
181
+ type: "fragment.insert";
182
+ fragment: CanonicalDocumentFragment;
183
+ origin?: CommandOrigin;
184
+ }
165
185
  | {
166
186
  type: "runtime.set-read-only";
167
187
  readOnly: boolean;
@@ -556,7 +576,7 @@ export function executeEditorCommand(
556
576
  : undefined;
557
577
  if (suggestingResult) return suggestingResult;
558
578
  return applyTextCommand(state, context.timestamp, (document, selection) =>
559
- insertText(document, selection, command.text, context),
579
+ insertText(document, selection, command.text, context, command.formatting),
560
580
  );
561
581
  }
562
582
  case "text.delete-backward": {
@@ -623,6 +643,15 @@ export function executeEditorCommand(
623
643
  return applyTextCommand(state, context.timestamp, (document, selection) =>
624
644
  splitParagraph(document, selection, context),
625
645
  );
646
+ case "fragment.insert": {
647
+ // I2 Tier B Slice 1 — route through the structure-ops splicer. No
648
+ // suggesting-mode branch yet; fragment insertion always lands as a direct
649
+ // edit. Future slices will gate behind track-changes when a fixture needs it.
650
+ const result = applyFragmentInsert(state.document, state.selection, command.fragment, {
651
+ timestamp: context.timestamp,
652
+ });
653
+ return buildDocumentReplaceTransaction(state, context, result);
654
+ }
626
655
  case "runtime.set-read-only":
627
656
  return createTransaction(
628
657
  {
@@ -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(