@beyondwork/docx-react-component 1.0.56 → 1.0.58

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 (113) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +330 -0
  4. package/src/compare/diff-engine.ts +3 -0
  5. package/src/core/commands/formatting-commands.ts +1 -0
  6. package/src/core/commands/index.ts +17 -11
  7. package/src/core/selection/mapping.ts +18 -1
  8. package/src/core/selection/review-anchors.ts +29 -18
  9. package/src/io/chart-preview-resolver.ts +175 -41
  10. package/src/io/docx-session.ts +57 -2
  11. package/src/io/export/serialize-main-document.ts +82 -0
  12. package/src/io/export/serialize-styles.ts +61 -3
  13. package/src/io/export/table-properties-xml.ts +19 -4
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-anchor.ts +182 -0
  16. package/src/io/ooxml/parse-drawing.ts +319 -0
  17. package/src/io/ooxml/parse-fields.ts +115 -2
  18. package/src/io/ooxml/parse-fill.ts +215 -0
  19. package/src/io/ooxml/parse-font-table.ts +190 -0
  20. package/src/io/ooxml/parse-footnotes.ts +52 -1
  21. package/src/io/ooxml/parse-main-document.ts +241 -1
  22. package/src/io/ooxml/parse-numbering.ts +96 -0
  23. package/src/io/ooxml/parse-picture.ts +158 -0
  24. package/src/io/ooxml/parse-settings.ts +34 -0
  25. package/src/io/ooxml/parse-shapes.ts +87 -0
  26. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  27. package/src/io/ooxml/parse-styles.ts +74 -1
  28. package/src/io/ooxml/parse-theme.ts +60 -0
  29. package/src/io/paste/html-clipboard.ts +449 -0
  30. package/src/io/paste/word-clipboard.ts +5 -1
  31. package/src/legal/_document-root.ts +26 -0
  32. package/src/legal/bookmarks.ts +4 -3
  33. package/src/legal/cross-references.ts +3 -2
  34. package/src/legal/defined-terms.ts +2 -1
  35. package/src/legal/signature-blocks.ts +2 -1
  36. package/src/model/canonical-document.ts +421 -3
  37. package/src/runtime/chart/chart-model-store.ts +73 -10
  38. package/src/runtime/document-runtime.ts +760 -41
  39. package/src/runtime/document-search.ts +61 -0
  40. package/src/runtime/edit-ops/index.ts +129 -0
  41. package/src/runtime/event-refresh-hints.ts +7 -0
  42. package/src/runtime/field-resolver.ts +341 -0
  43. package/src/runtime/footnote-resolver.ts +55 -0
  44. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  45. package/src/runtime/object-grab/index.ts +51 -0
  46. package/src/runtime/paragraph-style-resolver.ts +105 -0
  47. package/src/runtime/query-scopes.ts +186 -0
  48. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  49. package/src/runtime/scope-resolver.ts +60 -0
  50. package/src/runtime/selection/cursor-ops.ts +186 -15
  51. package/src/runtime/selection/index.ts +17 -1
  52. package/src/runtime/structure-ops/index.ts +77 -0
  53. package/src/runtime/styles-cascade.ts +33 -0
  54. package/src/runtime/surface-projection.ts +192 -12
  55. package/src/runtime/theme-color-resolver.ts +189 -44
  56. package/src/runtime/units.ts +46 -0
  57. package/src/runtime/view-state.ts +13 -2
  58. package/src/ui/WordReviewEditor.tsx +239 -11
  59. package/src/ui/editor-runtime-boundary.ts +97 -1
  60. package/src/ui/editor-shell-view.tsx +1 -1
  61. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  62. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  63. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  64. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  65. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  66. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  67. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  68. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  69. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  70. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  71. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  72. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  73. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  74. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  75. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  76. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  77. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  78. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  79. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  80. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  81. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
  86. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  88. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  89. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  90. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  91. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  92. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  93. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  94. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  95. package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
  96. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
  97. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  98. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  99. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  100. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  101. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  102. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  103. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  104. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  105. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  106. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  107. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  108. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  109. package/src/ui-tailwind/theme/tokens.css +6 -0
  110. package/src/ui-tailwind/theme/tokens.ts +10 -0
  111. package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
  112. package/src/validation/compatibility-engine.ts +2 -0
  113. package/src/validation/docx-comment-proof.ts +12 -3
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Lane 6d — Slice N9 (P7.1 + P7.3): pure float-wrap resolver.
3
+ *
4
+ * Maps `SurfaceDrawingAnchor` (the V2c.4-projected DrawingFrame anchor) to
5
+ * a CSS-style descriptor that the `image_atom` / `shape_atom` toDOM can
6
+ * stamp onto the wrapper span. Pure — no DOM access, no React, no schema
7
+ * dependency.
8
+ *
9
+ * v1 coverage (per the lane-6d MVP plan):
10
+ * - `square` → CSS float + `shape-outside: margin-box`
11
+ * - `topAndBottom` → block-level `clear: both`
12
+ * - `none` + `behindDoc` → absolute-positioned, `z-index: -1` (behind text)
13
+ * - `tight` / `through` → fall back to `square` and emit a one-shot
14
+ * warn so telemetry can flag uncovered shapes. v2 will consume
15
+ * `wrapPolygon` data once Lane 3b V2c.x extends `AnchorGeometry`.
16
+ *
17
+ * EMU constant: 9525 EMU = 1 px at 96 dpi (OOXML standard).
18
+ */
19
+
20
+ import type { SurfaceDrawingAnchor } from "../../api/public-types";
21
+ import { EMU_PER_PX } from "../../runtime/units";
22
+
23
+ export interface FloatWrapStyle {
24
+ /** CSS `float` value when the anchor wraps inline text. */
25
+ float?: "left" | "right";
26
+ /** CSS `shape-outside` for `square` wrap so adjacent text honors the
27
+ * picture's margin box. */
28
+ shapeOutside?: string;
29
+ /** CSS `clear` — set on `topAndBottom` so the picture renders on its
30
+ * own line. */
31
+ clear?: "both";
32
+ /** Block-level signal for `topAndBottom` so the wrapper switches from
33
+ * inline-block to block. */
34
+ display?: "block";
35
+ /** Absolute positioning — used for `behindDoc` shapes that sit beneath
36
+ * content. */
37
+ positionAbsolute?: { topPx: number; leftPx: number; zIndex: number };
38
+ /** When the resolver fell back to a default for an unsupported wrap
39
+ * mode, this carries the original mode for telemetry. */
40
+ fallbackFromMode?: "tight" | "through";
41
+ }
42
+
43
+ const warnedFallbacks = new Set<string>();
44
+
45
+ /**
46
+ * Emit a one-shot console warning per wrap mode so noisy CCEP corpora
47
+ * don't flood the console but coverage gaps still surface. Skips SSR
48
+ * (no `window`) so server-render passes don't spam logs.
49
+ */
50
+ function warnFallbackOnce(mode: "tight" | "through"): void {
51
+ if (warnedFallbacks.has(mode)) return;
52
+ warnedFallbacks.add(mode);
53
+ if (typeof window === "undefined") return;
54
+ // Quiet by default — only fires once per mode per session.
55
+ console.warn(
56
+ `[lane-6d/N9] wrapMode="${mode}" rendered as "square" v1 fallback (polygon clipping pending Lane 3b V2c.x).`,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Reset the fallback warn dedup map. Test-only helper — production code
62
+ * never calls this.
63
+ */
64
+ export function _resetFloatWrapWarnings(): void {
65
+ warnedFallbacks.clear();
66
+ }
67
+
68
+ /**
69
+ * Pure resolver: anchor → CSS-style descriptor. Returns `null` when
70
+ * the anchor is inline (no float treatment needed) or has no wrapMode.
71
+ */
72
+ export function resolveFloatStyle(
73
+ anchor: SurfaceDrawingAnchor | undefined,
74
+ ): FloatWrapStyle | null {
75
+ if (!anchor) return null;
76
+ if (anchor.display !== "floating") return null;
77
+
78
+ switch (anchor.wrapMode) {
79
+ case "square": {
80
+ const float = anchor.positionH?.align === "right" ? "right" : "left";
81
+ return { float, shapeOutside: "margin-box" };
82
+ }
83
+ case "topAndBottom":
84
+ return { clear: "both", display: "block" };
85
+ case "tight":
86
+ warnFallbackOnce("tight");
87
+ return {
88
+ float: anchor.positionH?.align === "right" ? "right" : "left",
89
+ shapeOutside: "margin-box",
90
+ fallbackFromMode: "tight",
91
+ };
92
+ case "through":
93
+ warnFallbackOnce("through");
94
+ return {
95
+ float: anchor.positionH?.align === "right" ? "right" : "left",
96
+ shapeOutside: "margin-box",
97
+ fallbackFromMode: "through",
98
+ };
99
+ case "none": {
100
+ // `none` + behindDoc → absolute positioned underneath the text.
101
+ // `none` + !behindDoc → above text (in front). Either way we use
102
+ // absolute positioning when offsets are present.
103
+ if (
104
+ anchor.positionH?.offset !== undefined ||
105
+ anchor.positionV?.offset !== undefined
106
+ ) {
107
+ return {
108
+ positionAbsolute: {
109
+ topPx: Math.round((anchor.positionV?.offset ?? 0) / EMU_PER_PX),
110
+ leftPx: Math.round((anchor.positionH?.offset ?? 0) / EMU_PER_PX),
111
+ zIndex: anchor.behindDoc ? -1 : 1,
112
+ },
113
+ };
114
+ }
115
+ // `none` with no offsets is rare — treat as inline-overlap.
116
+ return null;
117
+ }
118
+ }
119
+ }
@@ -1,5 +1,6 @@
1
1
  import { NodeSelection, Plugin, PluginKey } from "prosemirror-state";
2
- import { columnResizing, goToNextCell, isInTable, tableEditing } from "prosemirror-tables";
2
+ import { columnResizing, goToNextCell, isInTable, selectedRect, tableEditing, TableMap } from "prosemirror-tables";
3
+ import type { EditorState } from "prosemirror-state";
3
4
 
4
5
  import type { SelectionSnapshot } from "../../api/public-types";
5
6
  import {
@@ -12,6 +13,7 @@ import {
12
13
  type PastePlainSegment,
13
14
  } from "./paste-plain-text";
14
15
  import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
16
+ import { parseCanonicalFragmentFromHtml } from "../../io/paste/html-clipboard";
15
17
  import type { PositionMap } from "./pm-position-map";
16
18
 
17
19
  /**
@@ -34,6 +36,71 @@ function readWordMLPayload(clipboard: DataTransfer): string | null {
34
36
  return null;
35
37
  }
36
38
 
39
+ /**
40
+ * I2 Tier B Slice 5 follow-up — MIME types for image paste. `DataTransfer.items`
41
+ * exposes binary payloads (unlike `getData` which is string-only), so we
42
+ * iterate items looking for an image-typed entry.
43
+ */
44
+ const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
45
+
46
+ /**
47
+ * Synchronous check for whether the clipboard holds at least one image item.
48
+ * Used to decide whether to `return true` from `handlePaste` (blocking PM's
49
+ * default) before the async `readImagePayload` resolves. Side-effect free.
50
+ */
51
+ function hasImageItem(clipboard: DataTransfer): boolean {
52
+ const items = clipboard.items;
53
+ if (!items || items.length === 0) return false;
54
+ for (let i = 0; i < items.length; i += 1) {
55
+ const item = items[i];
56
+ if (item.kind === "file" && IMAGE_MIMES.has(item.type)) return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ async function readImagePayload(
62
+ clipboard: DataTransfer,
63
+ ): Promise<{ data: Uint8Array; mimeType: string } | null> {
64
+ const items = clipboard.items;
65
+ if (!items || items.length === 0) return null;
66
+ for (let i = 0; i < items.length; i += 1) {
67
+ const item = items[i];
68
+ if (item.kind !== "file") continue;
69
+ if (!IMAGE_MIMES.has(item.type)) continue;
70
+ const file = item.getAsFile();
71
+ if (!file) continue;
72
+ const buffer = await file.arrayBuffer();
73
+ return { data: new Uint8Array(buffer), mimeType: item.type };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * I3 widening tail — detect whether the PM selection is inside the last cell
80
+ * of the last row of its containing table. Used to gate Tab-at-last-cell
81
+ * implicit row-insert. Returns false if not in a table, if the cell resolution
82
+ * fails, or if the selection is anywhere other than the bottom-right cell.
83
+ *
84
+ * Uses `TableMap` + `selectedRect` from prosemirror-tables — the same
85
+ * primitives the table command surface consumes — so the computation matches
86
+ * what the runtime sees when it dispatches `addRowAfter`.
87
+ */
88
+ function isAtLastCellOfTable(state: EditorState): boolean {
89
+ if (!isInTable(state)) return false;
90
+ try {
91
+ const rect = selectedRect(state);
92
+ const { map } = rect;
93
+ // `rect.bottom` / `rect.right` are one past the selection's bottom-right
94
+ // cell coordinates; a "tail cell" means the selection is in the last
95
+ // row AND the last column of the table.
96
+ return rect.bottom === map.height && rect.right === map.width;
97
+ } catch {
98
+ // selectedRect throws if there's no cell at the selection (rare edge
99
+ // case). Treat as "not at tail" — safest fallback.
100
+ return false;
101
+ }
102
+ }
103
+
37
104
  /**
38
105
  * Callback subset used by paste / drop dispatch. Exported so tests can
39
106
  * record dispatch order without constructing the full
@@ -98,6 +165,13 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
98
165
  onInsertHardBreak: () => void;
99
166
  onInsertTab: () => void;
100
167
  onOutdentTab?: () => void;
168
+ /**
169
+ * I3 widening tail — optional. Fires on Tab at the last cell of the last
170
+ * row of a table (Word-matching row-insert behavior). Host should dispatch
171
+ * `addRowAfter` via the runtime so track-changes + collab replay stay
172
+ * consistent. When omitted, Tab at table tail is a no-op.
173
+ */
174
+ onTableInsertRowBelow?: () => void;
101
175
  onListIndent?: () => void;
102
176
  onListOutdent?: () => void;
103
177
  onUndo: () => void;
@@ -124,7 +198,37 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
124
198
  */
125
199
  onPasteFragment?: (meta: {
126
200
  fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
127
- source: "wordml";
201
+ source: "wordml" | "html";
202
+ }) => void;
203
+ /**
204
+ * I2 Tier B Slice 5 follow-up — optional. Fires when the paste / drop
205
+ * payload carries an image MIME type (`image/png`, `image/jpeg`,
206
+ * `image/gif`, `image/webp`). The host wires this to
207
+ * `WordReviewEditorRef.insertImage({ data, mimeType })`. Without this
208
+ * callback, image payloads fall through to `onBlockedInput` so the host
209
+ * can show its own "image paste not supported" toast.
210
+ */
211
+ onPasteImage?: (meta: {
212
+ data: Uint8Array;
213
+ mimeType: string;
214
+ source: "paste" | "drop";
215
+ }) => void;
216
+ /**
217
+ * I2 Tier B Slice 5 — optional. Fires when a DataTransfer drop carries a
218
+ * WordprocessingML fragment payload. `effect` reflects the standard
219
+ * move-vs-copy convention: Ctrl (or Cmd on macOS) held → "copy" (source
220
+ * untouched), otherwise "move".
221
+ *
222
+ * `sourceRange` is populated (v5 follow-up) when the drag originated from
223
+ * within this editor — it gives the host enough information to delete the
224
+ * source range AFTER it has inserted the fragment at the drop point,
225
+ * completing the drag-to-move round trip. When `undefined`, the drag came
226
+ * from outside the editor (another app) and the OS owns source deletion.
227
+ */
228
+ onDropFragment?: (meta: {
229
+ fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
230
+ effect: "move" | "copy";
231
+ sourceRange?: { from: number; to: number };
128
232
  }) => void;
129
233
  /**
130
234
  * Optional. Fires on `compositionstart` (true) and `compositionend`
@@ -187,6 +291,12 @@ export function createCommandBridgePlugins(
187
291
  options?: CommandBridgePluginOptions,
188
292
  ): Plugin[] {
189
293
  let isComposing = false;
294
+ // v5 Slice 5 follow-up: same-editor drag tracking. Captured on `dragstart`
295
+ // (if the selection is non-empty at drag time), consumed on `drop` when
296
+ // `effect === "move"`, cleared on `dragend`. `null` outside of active
297
+ // drag cycles OR for external drags (payloads originating from another
298
+ // application, where the OS handles source removal).
299
+ let dragSourceRange: { from: number; to: number } | null = null;
190
300
 
191
301
  const filterPlugin = callbacks.gate ?? new Plugin({
192
302
  key: bridgeKey,
@@ -219,6 +329,40 @@ export function createCommandBridgePlugins(
219
329
  callbacks.onCompositionChange?.(false);
220
330
  return false;
221
331
  },
332
+ // v5 Slice 5 follow-up: capture the selection range at drag-start so
333
+ // drop-handler can delete the source on move-effect drop. Without
334
+ // this, same-editor drag-to-move is observationally identical to
335
+ // drag-to-copy — the fragment lands at the drop site but the source
336
+ // stays, leaving a duplicate.
337
+ dragstart(view, event) {
338
+ const dragEvent = event as DragEvent;
339
+ const sel = view.state.selection;
340
+ if (!sel || sel.empty) {
341
+ dragSourceRange = null;
342
+ return false;
343
+ }
344
+ const posMap = callbacks.getPositionMap?.();
345
+ if (!posMap) {
346
+ dragSourceRange = null;
347
+ return false;
348
+ }
349
+ const from = posMap.pmToRuntime(sel.from);
350
+ const to = posMap.pmToRuntime(sel.to);
351
+ if (from === to) {
352
+ dragSourceRange = null;
353
+ return false;
354
+ }
355
+ dragSourceRange = { from, to };
356
+ // Don't preventDefault — let PM/browser handle the visual drag.
357
+ return false;
358
+ },
359
+ dragend() {
360
+ // Clear the captured range after the drag cycle completes. For
361
+ // copy-effect drops this runs without having consumed the range;
362
+ // for move-effect drops the drop-handler already deleted it.
363
+ dragSourceRange = null;
364
+ return false;
365
+ },
222
366
  },
223
367
  handleTextInput(_view, _from, _to, text) {
224
368
  if (isComposing) {
@@ -247,7 +391,32 @@ export function createCommandBridgePlugins(
247
391
  return true;
248
392
  }
249
393
 
250
- // Tier B: WordprocessingML
394
+ // v5 close-out: Image paste — detect image MIME before WordML/HTML.
395
+ // Real-world copy-image-from-browser / copy-image-from-Word sources
396
+ // put the image as a binary clipboard item alongside an HTML fallback
397
+ // that links to the same image; the fallback chain would otherwise
398
+ // prefer the HTML (lower fidelity). Image check first wins.
399
+ if (callbacks.onPasteImage) {
400
+ // readImagePayload is async; fire-and-forget via the Promise chain.
401
+ // We return true immediately to block PM's default; if the promise
402
+ // resolves with no image, we've already blocked and the paste
403
+ // silently drops. Hosts that need strict "image or nothing"
404
+ // semantics can inspect `clipboardData.items` themselves before
405
+ // wiring the callback.
406
+ const imagePromise = readImagePayload(clipboard);
407
+ void imagePromise.then((img) => {
408
+ if (img) {
409
+ callbacks.onPasteImage!({ ...img, source: "paste" });
410
+ }
411
+ });
412
+ // Synchronous fast-path: if items tells us there's an image item
413
+ // available right now, consume the paste to avoid double-processing.
414
+ if (hasImageItem(clipboard)) {
415
+ return true;
416
+ }
417
+ }
418
+
419
+ // Tier B: WordprocessingML (highest fidelity when available)
251
420
  if (callbacks.onPasteFragment) {
252
421
  const wordml = readWordMLPayload(clipboard);
253
422
  if (wordml) {
@@ -256,8 +425,20 @@ export function createCommandBridgePlugins(
256
425
  callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
257
426
  return true;
258
427
  }
259
- // Parse failed or empty — fall through to plain-text so the paste
260
- // still does something (defensive against malformed clipboard payloads).
428
+ // Parse failed or empty — fall through to HTML / plain-text so the
429
+ // paste still does something (defensive against malformed payloads).
430
+ }
431
+ }
432
+
433
+ // Tier B: HTML (Google Docs, Word web, Outlook-lite)
434
+ if (callbacks.onPasteFragment) {
435
+ const htmlPayload = clipboard.getData("text/html");
436
+ if (htmlPayload && htmlPayload.trim().length > 0) {
437
+ const parsed = parseCanonicalFragmentFromHtml(htmlPayload);
438
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
439
+ callbacks.onPasteFragment({ fragment: parsed.fragment, source: "html" });
440
+ return true;
441
+ }
261
442
  }
262
443
  }
263
444
 
@@ -280,15 +461,51 @@ export function createCommandBridgePlugins(
280
461
  return true;
281
462
  },
282
463
 
283
- // Plain-text drop: symmetric path extract text/plain from the
284
- // DataTransfer and dispatch through the same callbacks paste uses.
464
+ // Drop handler I2 Tier B Slice 5 adds fragment-drop + Ctrl-modifier
465
+ // (move vs. copy) semantics on top of the existing Tier A plain-text path.
466
+ //
467
+ // Preference order:
468
+ // 1. WordML fragment payload → dispatch via onDropFragment (fragment
469
+ // route, mirrors handlePaste → onPasteFragment). Ctrl flips
470
+ // move→copy semantics; the runtime decides source-deletion.
471
+ // 2. Plain text → dispatch through onInsertText + friends (Tier A).
472
+ // 3. Empty / non-text payload → onBlockedInput.
285
473
  handleDrop(_view, event) {
286
474
  if (isComposing) return true;
287
- const dt = (event as DragEvent).dataTransfer;
475
+ const dragEvent = event as DragEvent;
476
+ const dt = dragEvent.dataTransfer;
288
477
  if (!dt) {
289
478
  callbacks.onBlockedInput?.("drop", "Drop data was not available.");
290
479
  return true;
291
480
  }
481
+
482
+ // Tier B drop: WordprocessingML fragment
483
+ if (callbacks.onDropFragment) {
484
+ const wordml = readWordMLPayload(dt);
485
+ if (wordml) {
486
+ const parsed = parseCanonicalFragmentFromWordML(wordml);
487
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
488
+ const ctrlModifier = Boolean(dragEvent.ctrlKey || dragEvent.metaKey);
489
+ const effect = ctrlModifier ? "copy" : "move";
490
+ // Pass the captured source range only for move-effect drops
491
+ // originating from the same editor. `dragSourceRange` was set
492
+ // on `dragstart` if we're inside a same-editor drag cycle.
493
+ const sourceRange =
494
+ effect === "move" && dragSourceRange ? dragSourceRange : undefined;
495
+ callbacks.onDropFragment({
496
+ fragment: parsed.fragment,
497
+ effect,
498
+ ...(sourceRange ? { sourceRange } : {}),
499
+ });
500
+ // Clear immediately — the drop consumed the range; `dragend`
501
+ // is a no-op safety net.
502
+ dragSourceRange = null;
503
+ return true;
504
+ }
505
+ }
506
+ }
507
+
508
+ // Tier A drop: plain text (existing path)
292
509
  const plain = dt.getData("text/plain");
293
510
  if (!plain) {
294
511
  callbacks.onBlockedInput?.(
@@ -338,7 +555,10 @@ export function createCommandBridgePlugins(
338
555
  altKey: event.altKey,
339
556
  shiftKey: event.shiftKey,
340
557
  },
341
- { inTable: isInTable(view.state) },
558
+ {
559
+ inTable: isInTable(view.state),
560
+ isAtTableTail: isAtLastCellOfTable(view.state),
561
+ },
342
562
  );
343
563
 
344
564
  switch (resolution.kind) {
@@ -364,6 +584,13 @@ export function createCommandBridgePlugins(
364
584
  return true;
365
585
  case "navigate-table-cell":
366
586
  return goToNextCell(resolution.direction)(view.state, view.dispatch, view);
587
+ case "table-insert-row-below":
588
+ // I3 widening tail — Tab at the last cell of the last row inserts
589
+ // a new row below. Host routes the dispatch through the runtime's
590
+ // `addRowAfter` ref method so track-changes + collab replay stay
591
+ // consistent with every other row-insert path.
592
+ callbacks.onTableInsertRowBelow?.();
593
+ return true;
367
594
  case "history":
368
595
  if (resolution.history === "undo") {
369
596
  callbacks.onUndo();