@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/api/public-types.ts +330 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +17 -11
- package/src/core/selection/mapping.ts +18 -1
- package/src/core/selection/review-anchors.ts +29 -18
- package/src/io/chart-preview-resolver.ts +175 -41
- package/src/io/docx-session.ts +57 -2
- package/src/io/export/serialize-main-document.ts +82 -0
- package/src/io/export/serialize-styles.ts +61 -3
- package/src/io/export/table-properties-xml.ts +19 -4
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-anchor.ts +182 -0
- package/src/io/ooxml/parse-drawing.ts +319 -0
- package/src/io/ooxml/parse-fields.ts +115 -2
- package/src/io/ooxml/parse-fill.ts +215 -0
- package/src/io/ooxml/parse-font-table.ts +190 -0
- package/src/io/ooxml/parse-footnotes.ts +52 -1
- package/src/io/ooxml/parse-main-document.ts +241 -1
- package/src/io/ooxml/parse-numbering.ts +96 -0
- package/src/io/ooxml/parse-picture.ts +158 -0
- package/src/io/ooxml/parse-settings.ts +34 -0
- package/src/io/ooxml/parse-shapes.ts +87 -0
- package/src/io/ooxml/parse-solid-fill.ts +11 -0
- package/src/io/ooxml/parse-styles.ts +74 -1
- package/src/io/ooxml/parse-theme.ts +60 -0
- package/src/io/paste/html-clipboard.ts +449 -0
- package/src/io/paste/word-clipboard.ts +5 -1
- package/src/legal/_document-root.ts +26 -0
- package/src/legal/bookmarks.ts +4 -3
- package/src/legal/cross-references.ts +3 -2
- package/src/legal/defined-terms.ts +2 -1
- package/src/legal/signature-blocks.ts +2 -1
- package/src/model/canonical-document.ts +421 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +760 -41
- package/src/runtime/document-search.ts +61 -0
- package/src/runtime/edit-ops/index.ts +129 -0
- package/src/runtime/event-refresh-hints.ts +7 -0
- package/src/runtime/field-resolver.ts +341 -0
- package/src/runtime/footnote-resolver.ts +55 -0
- package/src/runtime/hyperlink-color-resolver.ts +13 -10
- package/src/runtime/object-grab/index.ts +51 -0
- package/src/runtime/paragraph-style-resolver.ts +105 -0
- package/src/runtime/query-scopes.ts +186 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/scope-resolver.ts +60 -0
- package/src/runtime/selection/cursor-ops.ts +186 -15
- package/src/runtime/selection/index.ts +17 -1
- package/src/runtime/structure-ops/index.ts +77 -0
- package/src/runtime/styles-cascade.ts +33 -0
- package/src/runtime/surface-projection.ts +192 -12
- package/src/runtime/theme-color-resolver.ts +189 -44
- package/src/runtime/units.ts +46 -0
- package/src/runtime/view-state.ts +13 -2
- package/src/ui/WordReviewEditor.tsx +239 -11
- package/src/ui/editor-runtime-boundary.ts +97 -1
- package/src/ui/editor-shell-view.tsx +1 -1
- package/src/ui/runtime-shortcut-dispatch.ts +17 -3
- package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
- package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
- package/src/ui-tailwind/chart/render/area.tsx +22 -4
- package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
- package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
- package/src/ui-tailwind/chart/render/combo.tsx +37 -4
- package/src/ui-tailwind/chart/render/line.tsx +28 -5
- package/src/ui-tailwind/chart/render/pie.tsx +36 -16
- package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
- package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
- package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
- package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
- package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
- package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
- package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
- package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
- package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
- package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
- package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
- package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
- package/src/ui-tailwind/theme/editor-theme.css +1 -0
- package/src/ui-tailwind/theme/tokens.css +6 -0
- package/src/ui-tailwind/theme/tokens.ts +10 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
- package/src/validation/compatibility-engine.ts +2 -0
- 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
|
-
//
|
|
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
|
|
260
|
-
// still does something (defensive against malformed
|
|
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
|
-
//
|
|
284
|
-
//
|
|
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
|
|
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
|
-
{
|
|
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();
|