@beyondwork/docx-react-component 1.0.49 → 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.
package/README.md CHANGED
@@ -235,7 +235,10 @@ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 fi
235
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
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
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 |
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 |
239
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 |
240
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 |
241
244
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.49",
4
+ "version": "1.0.50",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -1,6 +1,6 @@
1
1
  import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
2
2
  import type { HarnessDebugPorts } from "../internal/harness-debug-ports.ts";
3
- import type { CanonicalParagraphFormatting, CanonicalRunFormatting, TextMark } from "../model/canonical-document.ts";
3
+ import type { BlockNode, CanonicalParagraphFormatting, CanonicalRunFormatting, TextMark } from "../model/canonical-document.ts";
4
4
  import type { WordReviewEditorLayoutFacet } from "../runtime/layout/public-facet.ts";
5
5
  import type { RenderFrameRect } from "../runtime/render/index.ts";
6
6
  import type { ScopeRailPosture } from "../runtime/workflow-rail-segments.ts";
@@ -756,6 +756,17 @@ export interface InsertImageOptions {
756
756
  /** Re-export canonical `TextMark` so hosts can construct explicit-mode directives for I7 `replaceText`. */
757
757
  export type { TextMark };
758
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
+
759
770
  /**
760
771
  * I7 — `replaceText` / `text.insert` formatting directive.
761
772
  *
@@ -2919,6 +2930,14 @@ export interface WordReviewEditorRef {
2919
2930
  target?: EditorAnchorProjection,
2920
2931
  formatting?: TextFormattingDirective,
2921
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;
2922
2941
  toggleBulletedList(): void;
2923
2942
  toggleNumberedList(): void;
2924
2943
  toggleBold(): void;
@@ -49,6 +49,7 @@ import type {
49
49
  RuntimeRenderSnapshot,
50
50
  SectionBreakType,
51
51
  SectionLayoutPatch,
52
+ CanonicalDocumentFragment,
52
53
  SectionPageNumberingPatch,
53
54
  TextFormattingDirective,
54
55
  WorkflowMetadataDefinition,
@@ -88,6 +89,7 @@ import {
88
89
  setHeaderFooterLinkAtSectionIndex,
89
90
  } from "./section-layout-commands.ts";
90
91
  import { insertPageBreak, insertTable } from "./text-commands.ts";
92
+ import { applyFragmentInsert } from "../../runtime/structure-ops/fragment-insert.ts";
91
93
  import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
92
94
 
93
95
  export type ContentChildrenPatch =
@@ -170,6 +172,16 @@ export type EditorCommand =
170
172
  type: "paragraph.split";
171
173
  origin?: CommandOrigin;
172
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
+ }
173
185
  | {
174
186
  type: "runtime.set-read-only";
175
187
  readOnly: boolean;
@@ -631,6 +643,15 @@ export function executeEditorCommand(
631
643
  return applyTextCommand(state, context.timestamp, (document, selection) =>
632
644
  splitParagraph(document, selection, context),
633
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
+ }
634
655
  case "runtime.set-read-only":
635
656
  return createTransaction(
636
657
  {
@@ -760,11 +760,56 @@ function walkInlineNodeForBoundaries(
760
760
  return;
761
761
  }
762
762
  case "t": {
763
- const text = node.children
764
- .filter((child): child is XmlTextNode => child.type === "text")
765
- .map((child) => child.text)
766
- .join("");
767
- setCursor(getCursor() + Array.from(text).length);
763
+ // O8 fix: emit a boundary entry for every interior code-point position
764
+ // so comment anchors landing mid-run resolve to a real source byte
765
+ // offset instead of silently dropping into skippedCommentIds. The walk
766
+ // advances through the raw XML source between each text child's start
767
+ // and end, treating XML entities (& < > " ' &#N;
768
+ // &#xN;) as a single code point whose byte cursor skips the whole
769
+ // entity. Surrogate pairs collapse to one code point to match
770
+ // Array.from(text).iteration used by the existing cursor math.
771
+ let cursor = getCursor();
772
+ for (const child of node.children) {
773
+ if (child.type !== "text") {
774
+ continue;
775
+ }
776
+ let byte = child.start;
777
+ const end = child.end;
778
+ while (byte < end) {
779
+ if (!boundaries.has(cursor)) {
780
+ // Preserve the outer <w:r>-entry at runStart: it maps cursor to
781
+ // node.start (between runs, a valid insertion point), whereas the
782
+ // interior byte here would point inside <w:t> where inserting
783
+ // commentRangeStart/End would corrupt the text node.
784
+ boundaries.set(cursor, byte);
785
+ }
786
+ const ch = sourceXml.charCodeAt(byte);
787
+ if (ch === 0x26 /* & */) {
788
+ const semi = sourceXml.indexOf(";", byte + 1);
789
+ if (semi !== -1 && semi < end) {
790
+ byte = semi + 1;
791
+ } else {
792
+ byte += 1;
793
+ }
794
+ } else if (ch >= 0xd800 && ch <= 0xdbff && byte + 1 < end) {
795
+ // High surrogate followed by low surrogate = 1 code point / 2 units.
796
+ byte += 2;
797
+ } else {
798
+ byte += 1;
799
+ }
800
+ cursor += 1;
801
+ }
802
+ // Trailing boundary: map the cursor immediately after the last
803
+ // character to the byte just before </w:t>, so an anchor at the right
804
+ // edge of this text node resolves inside the run. The outer <w:r>
805
+ // close will overwrite nothing here because it runs after the inner
806
+ // walk and uses boundaries.set (last-write-wins is fine: node.end
807
+ // points to the same logical insertion point between runs).
808
+ if (!boundaries.has(cursor)) {
809
+ boundaries.set(cursor, end);
810
+ }
811
+ }
812
+ setCursor(cursor);
768
813
  return;
769
814
  }
770
815
  case "tab":
@@ -0,0 +1,114 @@
1
+ /**
2
+ * I2 Tier B Slice 2 — Office-clipboard / WordprocessingML paste parser.
3
+ *
4
+ * Adapts the authoritative `parseMainDocumentXml` → `normalizeParsedTextDocument`
5
+ * pipeline to a clipboard-paste payload. Inputs from the browser clipboard under
6
+ * the Office MIME types (`application/x-docx-fragment`,
7
+ * `application/vnd.ms-word.wordprocessingml.paste`) arrive either as:
8
+ *
9
+ * - a full `<w:document><w:body>…</w:body></w:document>` wrapper, or
10
+ * - a bare `<w:body>…</w:body>` fragment.
11
+ *
12
+ * This adapter auto-wraps the bare form, runs the full parse + normalize, and
13
+ * returns the resulting canonical `BlockNode`s as a `CanonicalDocumentFragment`.
14
+ * Errors (XML parse failure, missing body) are returned as a structured result
15
+ * instead of thrown, so `pm-command-bridge.ts` `handlePaste` can gracefully fall
16
+ * through to HTML or plain-text Tier A.
17
+ */
18
+
19
+ import type { CanonicalDocumentFragment } from "../../api/public-types.ts";
20
+ import type { DocumentRootNode } from "../../model/canonical-document.ts";
21
+ import { parseMainDocumentXml } from "../ooxml/parse-main-document.ts";
22
+ import { normalizeParsedTextDocument } from "../normalize/normalize-text.ts";
23
+ import { serializeMainDocument } from "../export/serialize-main-document.ts";
24
+
25
+ export type ParseCanonicalFragmentResult =
26
+ | { ok: true; fragment: CanonicalDocumentFragment }
27
+ | { ok: false; reason: string };
28
+
29
+ const WORD_NS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`;
30
+
31
+ export function parseCanonicalFragmentFromWordML(xml: string): ParseCanonicalFragmentResult {
32
+ if (typeof xml !== "string" || xml.length === 0) {
33
+ return { ok: false, reason: "empty WordML payload" };
34
+ }
35
+
36
+ const prepared = ensureDocumentShell(xml);
37
+
38
+ try {
39
+ const parsed = parseMainDocumentXml(prepared);
40
+ const normalized = normalizeParsedTextDocument(parsed);
41
+ return {
42
+ ok: true,
43
+ fragment: { blocks: normalized.content.children },
44
+ };
45
+ } catch (error) {
46
+ return {
47
+ ok: false,
48
+ reason: error instanceof Error ? error.message : "unknown WordML parse error",
49
+ };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Normalize the incoming clipboard payload into a full `<w:document><w:body>…`
55
+ * shell. Handles three shapes:
56
+ *
57
+ * 1. Already-wrapped `<w:document>…</w:document>` → pass-through with the XML
58
+ * declaration normalized.
59
+ * 2. Bare `<w:body>…</w:body>` — wrap in `<w:document>…</w:document>`.
60
+ * 3. Any other fragment — wrap the whole input in `<w:document><w:body>…`.
61
+ *
62
+ * Namespace hygiene: if the input lacks the `xmlns:w` declaration on whatever
63
+ * outer element survives, it's added to the outer `<w:document>` wrapper.
64
+ */
65
+ function ensureDocumentShell(xml: string): string {
66
+ const trimmed = xml.trim();
67
+ const withoutDecl = trimmed.replace(/^<\?xml[^?]*\?>/, "").trim();
68
+
69
+ const hasDocumentWrapper = /^<w:document[\s>]/i.test(withoutDecl);
70
+ if (hasDocumentWrapper) {
71
+ return ensureXmlDecl(trimmed);
72
+ }
73
+
74
+ const hasBodyWrapper = /^<w:body[\s>]/i.test(withoutDecl);
75
+ const inner = hasBodyWrapper ? withoutDecl : `<w:body>${withoutDecl}</w:body>`;
76
+
77
+ const wrapped = `<w:document ${WORD_NS}>${stripRedundantNs(inner)}</w:document>`;
78
+ return ensureXmlDecl(wrapped);
79
+ }
80
+
81
+ function ensureXmlDecl(xml: string): string {
82
+ if (/^\s*<\?xml/.test(xml)) return xml;
83
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>${xml}`;
84
+ }
85
+
86
+ /**
87
+ * Strip `xmlns:w="…"` from the first child element so the outer
88
+ * `<w:document>` declaration is the single authoritative binding. The XML
89
+ * parser accepts both, but removing the duplicate keeps output clean.
90
+ */
91
+ function stripRedundantNs(xml: string): string {
92
+ return xml.replace(/\s+xmlns:w="[^"]*"/, "");
93
+ }
94
+
95
+ /**
96
+ * I2 Tier B Slice 4a — inverse of `parseCanonicalFragmentFromWordML`. Produces
97
+ * a full `<w:document><w:body>…</w:body></w:document>` payload suitable for
98
+ * writing to the system clipboard under the Office MIME types, or for exchange
99
+ * with agents that expect WordML.
100
+ *
101
+ * Implementation reuses the authoritative `serializeMainDocument` pipeline by
102
+ * wrapping `fragment.blocks` in a synthetic `DocumentRootNode`. The output is
103
+ * the full document XML — parsers (browsers, Word, our own Slice 2 adapter)
104
+ * accept the full envelope, so there's no need to strip it down to a bare
105
+ * `<w:body>` fragment.
106
+ */
107
+ export function serializeFragmentToWordML(fragment: CanonicalDocumentFragment): string {
108
+ const root: DocumentRootNode = {
109
+ type: "doc",
110
+ children: fragment.blocks,
111
+ };
112
+ const serialized = serializeMainDocument(root);
113
+ return serialized.documentXml;
114
+ }
@@ -30,6 +30,7 @@ import type {
30
30
  DocumentTextToken,
31
31
  EditorSessionState,
32
32
  EditorAnchorProjection,
33
+ CanonicalDocumentFragment,
33
34
  TextFormattingDirective,
34
35
  EditorError,
35
36
  EditorStoryTarget,
@@ -266,7 +267,8 @@ export type ActiveStoryTextCommand =
266
267
  | Extract<EditorCommand, { type: "text.insert-tab" }>
267
268
  | Extract<EditorCommand, { type: "text.outdent-tab" }>
268
269
  | Extract<EditorCommand, { type: "text.insert-hard-break" }>
269
- | Extract<EditorCommand, { type: "paragraph.split" }>;
270
+ | Extract<EditorCommand, { type: "paragraph.split" }>
271
+ | Extract<EditorCommand, { type: "fragment.insert" }>;
270
272
 
271
273
  export interface DocumentRuntime {
272
274
  subscribe(listener: () => void): Unsubscribe;
@@ -275,6 +277,7 @@ export interface DocumentRuntime {
275
277
  getCanonicalDocument(): CanonicalDocumentEnvelope;
276
278
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
277
279
  replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
280
+ insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
278
281
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
279
282
  dispatch(command: EditorCommand): void;
280
283
  /**
@@ -2314,6 +2317,26 @@ export function createDocumentRuntime(
2314
2317
  emitError(toRuntimeError(error));
2315
2318
  }
2316
2319
  },
2320
+ insertFragment(fragment, target) {
2321
+ // I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
2322
+ // runtime command handler routes into `applyFragmentInsert` (structure-ops).
2323
+ try {
2324
+ const timestamp = clock();
2325
+ applyTextCommandInActiveStory(
2326
+ {
2327
+ type: "fragment.insert",
2328
+ fragment,
2329
+ origin: createOrigin("api", timestamp),
2330
+ },
2331
+ {
2332
+ selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
2333
+ blockedCommandName: "insertFragment",
2334
+ },
2335
+ );
2336
+ } catch (error) {
2337
+ emitError(toRuntimeError(error));
2338
+ }
2339
+ },
2317
2340
  applyActiveStoryTextCommand(command) {
2318
2341
  try {
2319
2342
  return applyTextCommandInActiveStory(command);
@@ -42,8 +42,18 @@
42
42
  * file under `src/runtime/layout/**` changed. Safe to treat
43
43
  * versions 3, 4, and 5 as cache-compatible if a migration ever
44
44
  * needs to collapse them.
45
+ * 6 — Lane 3a P9 Phase A. `RenderAnchorIndex` gains three chrome-kind
46
+ * resolvers (`byScopeId`, `byCommentId`, `byRevisionId`) sourced
47
+ * from the resolved `DecorationIndex`, and `buildAnchorIndex`
48
+ * runs in two phases inside the render kernel so the final index
49
+ * carries decoration-aware lookups. No cached-geometry change;
50
+ * consumers (Lane 1 R.1 SelectionLayer, Lane 6 P11 chrome rails)
51
+ * can now query one unified API instead of reaching into
52
+ * `frame.decorationIndex`. Cache envelopes from version 5 are
53
+ * invalidated on load because the anchor-index public shape
54
+ * changed even though pixel geometry did not.
45
55
  */
46
- export const LAYOUT_ENGINE_VERSION = 5 as const;
56
+ export const LAYOUT_ENGINE_VERSION = 6 as const;
47
57
 
48
58
  /**
49
59
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -1824,22 +1824,15 @@ function resolveAnchorRects(
1824
1824
  return rect ? [rect] : [];
1825
1825
  }
1826
1826
  case "scope-id": {
1827
- const id = String(query.value);
1828
- return frame.decorationIndex.workflow
1829
- .filter((decoration) => decoration.refId === id)
1830
- .map((decoration) => decoration.frame);
1827
+ return frame.anchorIndex.byScopeId(String(query.value));
1831
1828
  }
1832
1829
  case "comment-id": {
1833
- const id = String(query.value);
1834
- return frame.decorationIndex.comments
1835
- .filter((decoration) => decoration.refId === id)
1836
- .map((decoration) => decoration.frame);
1830
+ const rect = frame.anchorIndex.byCommentId(String(query.value));
1831
+ return rect ? [rect] : [];
1837
1832
  }
1838
1833
  case "revision-id": {
1839
- const id = String(query.value);
1840
- return frame.decorationIndex.revisions
1841
- .filter((decoration) => decoration.refId === id)
1842
- .map((decoration) => decoration.frame);
1834
+ const rect = frame.anchorIndex.byRevisionId(String(query.value));
1835
+ return rect ? [rect] : [];
1843
1836
  }
1844
1837
  default: {
1845
1838
  const exhaustive: never = query.kind;
@@ -230,6 +230,20 @@ export interface RenderAnchorIndex {
230
230
  tableBlockId: string,
231
231
  rowIndex: number,
232
232
  ): RenderFrameRect | null;
233
+ /**
234
+ * Chrome-kind resolvers (P9 Phase A). Read against the frame's
235
+ * `decorationIndex` so chrome surfaces (scope rails, comment balloons,
236
+ * revision margin bars, Lane 1 R.1 SelectionLayer) query one unified
237
+ * API instead of reaching into `frame.decorationIndex` directly.
238
+ *
239
+ * `byScopeId` returns every rect because a single workflow scope may
240
+ * cover multiple pages (one `RenderBlockDecoration` per page); chrome
241
+ * rails read the list. `byCommentId` and `byRevisionId` return a single
242
+ * rect — `resolveDecorationIndex` emits one entry per thread/revision.
243
+ */
244
+ byScopeId(scopeId: string): readonly RenderFrameRect[];
245
+ byCommentId(commentId: string): RenderFrameRect | null;
246
+ byRevisionId(revisionId: string): RenderFrameRect | null;
233
247
  }
234
248
 
235
249
  // ---------------------------------------------------------------------------
@@ -192,7 +192,16 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
192
192
  }
193
193
 
194
194
  const pendingDeltas = input.getPendingOpDeltas?.() ?? [];
195
- const anchorIndex = buildAnchorIndex(renderPages, pendingDeltas, zoom.pxPerTwip);
195
+ // P9 Phase A two-phase anchor-index build. The decoration resolver
196
+ // reads the anchor index to map runtime ranges to frame rects; the
197
+ // final anchor index then exposes chrome-kind resolvers that read
198
+ // back from the resolved decoration index. Rebuilding the index with
199
+ // the resolved decoration data avoids a post-hoc mutation seam.
200
+ const baseAnchorIndex = buildAnchorIndex(
201
+ renderPages,
202
+ pendingDeltas,
203
+ zoom.pxPerTwip,
204
+ );
196
205
  const includeDecorations = options?.includeDecorations ?? true;
197
206
  const sources = input.getDecorationSources?.();
198
207
  const hasSources =
@@ -205,8 +214,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
205
214
  const decorationIndex: DecorationIndex = !includeDecorations
206
215
  ? EMPTY_DECORATION_INDEX
207
216
  : hasSources
208
- ? resolveDecorationIndex({ anchorIndex, ...sources })
217
+ ? resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources })
209
218
  : buildDecorationIndex(renderPages);
219
+ const anchorIndex = buildAnchorIndex(
220
+ renderPages,
221
+ pendingDeltas,
222
+ zoom.pxPerTwip,
223
+ decorationIndex,
224
+ );
210
225
 
211
226
  // Revision: keyed off the engine's current page graph so repeated reads
212
227
  // at the same revision return the same cached frame. We derive it
@@ -634,6 +649,7 @@ function buildAnchorIndex(
634
649
  pages: readonly RenderPage[],
635
650
  pendingDeltas: readonly PendingOpDelta[] = [],
636
651
  pxPerTwip = 1,
652
+ decorationIndex: DecorationIndex = EMPTY_DECORATION_INDEX,
637
653
  ): RenderAnchorIndex {
638
654
  const byRuntimeOffset = new Map<number, RenderFrameRect>();
639
655
  const byFragmentId = new Map<string, RenderFrameRect>();
@@ -739,6 +755,28 @@ function buildAnchorIndex(
739
755
  byTableRowEdge(tableBlockId, rowIndex) {
740
756
  return tableRowEdges.get(`${tableBlockId}:${rowIndex}`) ?? null;
741
757
  },
758
+ // P9 Phase A — chrome-kind resolvers sourced from the resolved
759
+ // decoration index. Empty by default (the initial frame build passes
760
+ // `EMPTY_DECORATION_INDEX` until decoration resolution runs); the
761
+ // kernel re-invokes `buildAnchorIndex` with the resolved index so the
762
+ // final anchor index carries chrome-aware lookups.
763
+ byScopeId(scopeId) {
764
+ return decorationIndex.workflow
765
+ .filter((decoration) => decoration.refId === scopeId)
766
+ .map((decoration) => decoration.frame);
767
+ },
768
+ byCommentId(commentId) {
769
+ const match = decorationIndex.comments.find(
770
+ (decoration) => decoration.refId === commentId,
771
+ );
772
+ return match?.frame ?? null;
773
+ },
774
+ byRevisionId(revisionId) {
775
+ const match = decorationIndex.revisions.find(
776
+ (decoration) => decoration.refId === revisionId,
777
+ );
778
+ return match?.frame ?? null;
779
+ },
742
780
  };
743
781
  }
744
782
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Slice 1 of the I2 Tier B rich-paste sub-plan (`docs/plans/lane-1-i2-tier-b-rich-paste.md`).
3
+ *
4
+ * `applyFragmentInsert` is the canonical splicer for `CanonicalDocumentFragment` —
5
+ * the block-level payload that the HTML and Word-clipboard paste parsers (Slices 2+3)
6
+ * will produce. Slice 1 ships the shape and a baseline "split-and-splice" semantic
7
+ * without any parser in front of it, so the public `insertFragment` method can be
8
+ * driven directly from tests + future hosts.
9
+ *
10
+ * Baseline semantics:
11
+ * 1. Empty fragment → no-op (no revisionToken bump).
12
+ * 2. Range selection → the range is deleted first via `applyTextTransaction`, then
13
+ * the caret is the range start.
14
+ * 3. Caret paragraph is split via `splitParagraph`; fragment blocks are spliced
15
+ * between the two halves. Empty halves at document boundaries are preserved —
16
+ * callers can trim them if desired.
17
+ *
18
+ * What Slice 1 deliberately does NOT do:
19
+ * - Merge-intent (pre-Slice-1 drafts had `firstParagraphMergeIntent` on the type).
20
+ * Merge semantics will land in a follow-up slice once we have a paste fixture
21
+ * that demonstrates the need.
22
+ * - Fragment insertion inside table cells beyond the trivial case. Table-cell
23
+ * splicing is currently best-effort: the target paragraph within the cell is
24
+ * split, but cross-cell fragments are rejected as a no-op.
25
+ * - Comment/revision remapping across the fragment boundary — the splicer returns
26
+ * an empty mapping; follow-up slices will produce richer mappings.
27
+ */
28
+
29
+ import type { CanonicalDocumentFragment } from "../../api/public-types.ts";
30
+ import {
31
+ type CanonicalDocumentEnvelope,
32
+ type SelectionSnapshot,
33
+ createSelectionSnapshot,
34
+ } from "../../core/state/editor-state.ts";
35
+ import { applyTextTransaction } from "../../core/state/text-transaction.ts";
36
+ import { splitParagraph, type TextCommandContext } from "../../core/commands/text-commands.ts";
37
+ import { resolveParagraphScope, type StructuralMutationResult } from "../../core/commands/structural-helpers.ts";
38
+ import type { BlockNode, DocumentRootNode, ParagraphNode } from "../../model/canonical-document.ts";
39
+ import { createEmptyMapping } from "../../core/selection/mapping.ts";
40
+
41
+ export function applyFragmentInsert(
42
+ document: CanonicalDocumentEnvelope,
43
+ selection: SelectionSnapshot,
44
+ fragment: CanonicalDocumentFragment,
45
+ context: TextCommandContext,
46
+ ): StructuralMutationResult {
47
+ if (fragment.blocks.length === 0) {
48
+ return {
49
+ changed: false,
50
+ document,
51
+ selection,
52
+ };
53
+ }
54
+
55
+ // Collapse any range selection by first deleting the selected content. The
56
+ // resulting caret is at `min(anchor, head)`.
57
+ let workingDocument = document;
58
+ let workingSelection = selection;
59
+ if (selection.anchor !== selection.head) {
60
+ const collapseResult = applyTextTransaction(
61
+ workingDocument,
62
+ workingSelection,
63
+ { type: "replace", insertion: [] },
64
+ context,
65
+ );
66
+ workingDocument = collapseResult.document;
67
+ workingSelection = collapseResult.selection;
68
+ }
69
+
70
+ // Split the caret paragraph; the fragment blocks go between the two halves.
71
+ const splitResult = splitParagraph(workingDocument, workingSelection, context);
72
+ const splitRoot = splitResult.document.content;
73
+ if (!splitRoot || splitRoot.type !== "doc") {
74
+ return {
75
+ changed: false,
76
+ document,
77
+ selection,
78
+ };
79
+ }
80
+
81
+ // Locate the split boundary by re-resolving the paragraph scope against the
82
+ // pre-split snapshot. The right-half index = scope.blockIndex + 1.
83
+ const scope = resolveParagraphScope(workingDocument, workingSelection);
84
+ if (!scope || scope.kind !== "top-level") {
85
+ // Table-cell fragment insert is out of scope for Slice 1.
86
+ return {
87
+ changed: false,
88
+ document,
89
+ selection,
90
+ };
91
+ }
92
+
93
+ const rightHalfIndex = scope.blockIndex + 1;
94
+ const splicedChildren: BlockNode[] = [
95
+ ...splitRoot.children.slice(0, rightHalfIndex),
96
+ ...fragment.blocks.map((block) => cloneBlock(block)),
97
+ ...splitRoot.children.slice(rightHalfIndex),
98
+ ];
99
+
100
+ const nextRoot: DocumentRootNode = {
101
+ ...splitRoot,
102
+ children: splicedChildren,
103
+ };
104
+
105
+ const nextDocument: CanonicalDocumentEnvelope = {
106
+ ...splitResult.document,
107
+ updatedAt: context.timestamp,
108
+ content: nextRoot,
109
+ };
110
+
111
+ // Caret lands at the end of the last fragment block. For Slice 1 we approximate
112
+ // this with a collapsed selection at position 0 of the new document — richer
113
+ // caret placement follows once parsers drive this.
114
+ const nextSelection = createSelectionSnapshot(0, 0);
115
+
116
+ return {
117
+ changed: true,
118
+ document: nextDocument,
119
+ selection: nextSelection,
120
+ mapping: createEmptyMapping(),
121
+ };
122
+ }
123
+
124
+ function cloneBlock(block: BlockNode): BlockNode {
125
+ // Slice 1 uses a structural clone; no need to deep-clone formatting attrs since
126
+ // fragment blocks are presumed freshly minted by the caller.
127
+ if (block.type === "paragraph") {
128
+ return {
129
+ ...block,
130
+ children: block.children.map((child) => ({ ...child })),
131
+ } as ParagraphNode;
132
+ }
133
+ return JSON.parse(JSON.stringify(block)) as BlockNode;
134
+ }
@@ -486,7 +486,8 @@ export function __createWordReviewEditorRefBridge(
486
486
  blur: () => runtime.blur(),
487
487
  undo: () => runtime.undo(),
488
488
  redo: () => runtime.redo(),
489
- replaceText: (text, target) => runtime.replaceText(text, target),
489
+ replaceText: (text, target, formatting) => runtime.replaceText(text, target, formatting),
490
+ insertFragment: (fragment, target) => runtime.insertFragment(fragment, target),
490
491
  addComment: (params) => runtime.addComment(params),
491
492
  openComment: (commentId) => runtime.openComment(commentId),
492
493
  resolveComment: (commentId) => runtime.resolveComment(commentId),
@@ -1476,7 +1477,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1476
1477
  blur: () => activeRuntime.blur(),
1477
1478
  undo: () => activeRuntime.undo(),
1478
1479
  redo: () => activeRuntime.redo(),
1479
- replaceText: (text, target) => activeRuntime.replaceText(text, target),
1480
+ replaceText: (text, target, formatting) => activeRuntime.replaceText(text, target, formatting),
1481
+ insertFragment: (fragment, target) => activeRuntime.insertFragment(fragment, target),
1480
1482
  addComment: (params) =>
1481
1483
  activeRuntime.addComment({
1482
1484
  ...params,
@@ -912,6 +912,7 @@ function createLoadingRuntimeBridge(input: {
912
912
  getCanonicalDocument: () => input.sessionState.canonicalDocument,
913
913
  getSourcePackage: () => input.sessionState.sourcePackage,
914
914
  replaceText: () => undefined,
915
+ insertFragment: () => undefined,
915
916
  applyActiveStoryTextCommand: () => ({
916
917
  kind: "rejected",
917
918
  newRevisionToken: "",
@@ -11,8 +11,29 @@ import {
11
11
  extractPlainTextSegments,
12
12
  type PastePlainSegment,
13
13
  } from "./paste-plain-text";
14
+ import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
14
15
  import type { PositionMap } from "./pm-position-map";
15
16
 
17
+ /**
18
+ * I2 Tier B Slice 2 — MIME types Word + the browser use for WordprocessingML
19
+ * clipboard payloads. The first one is the legacy MS-Office HTML-embedded
20
+ * format; the second is the native Word clipboard type. Browsers expose both
21
+ * under `ClipboardEvent.clipboardData.getData(mime)`.
22
+ */
23
+ const WORDML_MIMES = [
24
+ "application/x-docx-fragment",
25
+ "application/vnd.ms-word.wordprocessingml.paste",
26
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
27
+ ] as const;
28
+
29
+ function readWordMLPayload(clipboard: DataTransfer): string | null {
30
+ for (const mime of WORDML_MIMES) {
31
+ const value = clipboard.getData(mime);
32
+ if (value && value.trim().length > 0) return value;
33
+ }
34
+ return null;
35
+ }
36
+
16
37
  /**
17
38
  * Callback subset used by paste / drop dispatch. Exported so tests can
18
39
  * record dispatch order without constructing the full
@@ -93,6 +114,18 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
93
114
  charCount: number;
94
115
  source: "paste" | "drop";
95
116
  }) => void;
117
+ /**
118
+ * I2 Tier B Slice 2 — optional. Fires when the paste handler detects an
119
+ * Office-clipboard WordprocessingML payload and parses it successfully into
120
+ * a canonical fragment. The host is responsible for dispatching
121
+ * `runtime.insertFragment(fragment)`; the bridge does not reach into the
122
+ * runtime directly so this plumbing stays consistent with the Tier A
123
+ * plain-text callback pattern.
124
+ */
125
+ onPasteFragment?: (meta: {
126
+ fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
127
+ source: "wordml";
128
+ }) => void;
96
129
  /**
97
130
  * Optional. Fires on `compositionstart` (true) and `compositionend`
98
131
  * (false). The surface forwards this to the predicted lane's session
@@ -195,11 +228,17 @@ export function createCommandBridgePlugins(
195
228
  return true; // Block PM from processing
196
229
  },
197
230
 
198
- // Plain-text paste: extract text/plain from the clipboard and
199
- // dispatch through the runtime-owned callbacks that typing uses.
200
- // Rich paste (HTML, Office clipboard) stays blocked — hosts that
201
- // listen for onBlockedInput still get notified when a non-plain-
202
- // text payload arrives. See docs/plans/editor-paste-drop.md.
231
+ // I2 paste handler Tier B (WordML) preferred, Tier A (plain) fallback.
232
+ //
233
+ // Preference order per `docs/plans/lane-1-i2-tier-b-rich-paste.md`:
234
+ // 1. Office-clipboard WordprocessingML payload if the host wired
235
+ // `onPasteFragment` AND the clipboard carries the MIME. Parsed via
236
+ // `parseCanonicalFragmentFromWordML`.
237
+ // 2. Plain text via `extractPlainTextSegments` (Tier A).
238
+ // 3. `onBlockedInput` for HTML-only / empty payloads.
239
+ //
240
+ // Rich-paste fallback on parse failure or missing host callback: fall
241
+ // through to Tier A so the user isn't left with a silent no-op.
203
242
  handlePaste(_view, event) {
204
243
  if (isComposing) return true;
205
244
  const clipboard = event.clipboardData;
@@ -207,6 +246,22 @@ export function createCommandBridgePlugins(
207
246
  callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
208
247
  return true;
209
248
  }
249
+
250
+ // Tier B: WordprocessingML
251
+ if (callbacks.onPasteFragment) {
252
+ const wordml = readWordMLPayload(clipboard);
253
+ if (wordml) {
254
+ const parsed = parseCanonicalFragmentFromWordML(wordml);
255
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
256
+ callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
257
+ return true;
258
+ }
259
+ // Parse failed or empty — fall through to plain-text so the paste
260
+ // still does something (defensive against malformed clipboard payloads).
261
+ }
262
+ }
263
+
264
+ // Tier A: plain text
210
265
  const plain = clipboard.getData("text/plain");
211
266
  if (!plain) {
212
267
  callbacks.onBlockedInput?.(