docxodus 9.3.0 → 9.5.0
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/dist/editor.bundle.js +588 -114
- package/dist/editor.d.ts +87 -11
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +317 -100
- package/dist/editor.js.map +1 -1
- package/dist/embed.bundle.js +621 -115
- package/dist/embed.iife.js +621 -115
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/page-geometry.d.ts +74 -0
- package/dist/page-geometry.d.ts.map +1 -0
- package/dist/page-geometry.js +105 -0
- package/dist/page-geometry.js.map +1 -0
- package/dist/pagination.bundle.js +8 -6
- package/dist/pagination.d.ts +2 -25
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +2 -45
- package/dist/pagination.js.map +1 -1
- package/dist/ribbon-chrome.d.ts +1 -1
- package/dist/ribbon-chrome.d.ts.map +1 -1
- package/dist/ribbon-chrome.js +18 -18
- package/dist/ribbon.js +2 -0
- package/dist/ribbon.js.map +1 -1
- package/dist/session.bundle.js +25 -1
- package/dist/session.d.ts +20 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +23 -1
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewport.d.ts +80 -0
- package/dist/viewport.d.ts.map +1 -0
- package/dist/viewport.js +139 -0
- package/dist/viewport.js.map +1 -0
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +5 -5
- package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
- package/package.json +1 -1
package/dist/editor.js
CHANGED
|
@@ -17,14 +17,24 @@
|
|
|
17
17
|
* full fidelity, and save() is lossless for them.
|
|
18
18
|
*/
|
|
19
19
|
import { paginateHtml } from "./pagination.js";
|
|
20
|
+
import { DocumentViewport } from "./viewport.js";
|
|
20
21
|
import { HeaderFooterRegion } from "./editor-headerfooter.js";
|
|
21
22
|
import { draggable, dropTargetForElements, monitorForElements, } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
|
23
|
+
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
|
24
|
+
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
|
22
25
|
import { autoScrollForElements, autoScrollWindowForElements, } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
|
23
26
|
import { TrackedChangeMode } from "./types.js";
|
|
24
27
|
import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
|
|
25
28
|
const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
26
29
|
const BLOCK_DRAG_TYPE = "docxodus-block";
|
|
27
30
|
const blockDragStyledDocuments = new WeakSet();
|
|
31
|
+
/** One-line description of a block, for the handle's label and the drag preview chip. */
|
|
32
|
+
function blockPreviewText(unit) {
|
|
33
|
+
if (unit.tagName === "TABLE")
|
|
34
|
+
return "Table";
|
|
35
|
+
const text = (unit.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
36
|
+
return text.length > 48 ? `${text.slice(0, 48)}…` : text;
|
|
37
|
+
}
|
|
28
38
|
function ensureBlockDragStyles(doc) {
|
|
29
39
|
if (blockDragStyledDocuments.has(doc))
|
|
30
40
|
return;
|
|
@@ -40,10 +50,28 @@ function ensureBlockDragStyles(doc) {
|
|
|
40
50
|
}
|
|
41
51
|
.docx-block-handle:hover, .docx-block-handle:focus-visible { color: #344054; border-color: #98a2b3; outline: none; }
|
|
42
52
|
.docx-block-handle[aria-pressed="true"] { color: #175cd3; border-color: #84adff; background: #eff8ff; }
|
|
43
|
-
.docx-block-handle.docx-block-dragging { cursor: grabbing; opacity: .
|
|
53
|
+
.docx-block-handle.docx-block-dragging { cursor: grabbing; opacity: .35; }
|
|
54
|
+
/* The block being carried. Dimming it is the "a drag is happening" signal that survives the
|
|
55
|
+
pointer being anywhere on screen — the drop line only says where, not what. */
|
|
56
|
+
.docx-block-drag-source { opacity: .38; transition: opacity 120ms ease-out; }
|
|
57
|
+
/* Positioned by transform so tracking the pointer costs no layout. Flipping display none→block
|
|
58
|
+
restarts the fade — one cheap entry animation per appearance, none while it tracks. */
|
|
44
59
|
.docx-block-drop-indicator {
|
|
45
|
-
position: fixed; z-index: 2147482999; display: none; height:
|
|
46
|
-
|
|
60
|
+
position: fixed; top: 0; left: 0; z-index: 2147482999; display: none; height: 0;
|
|
61
|
+
pointer-events: none; border-top: 2px solid #2e90fa;
|
|
62
|
+
filter: drop-shadow(0 1px 2px rgba(46,144,250,.5));
|
|
63
|
+
animation: docx-block-drop-in 110ms ease-out;
|
|
64
|
+
}
|
|
65
|
+
.docx-block-drop-indicator::before {
|
|
66
|
+
content: ""; position: absolute; top: -5px; left: -2px; width: 8px; height: 8px;
|
|
67
|
+
border-radius: 50%; background: #2e90fa;
|
|
68
|
+
}
|
|
69
|
+
@keyframes docx-block-drop-in { from { opacity: 0; } to { opacity: 1; } }
|
|
70
|
+
.docx-block-drag-preview {
|
|
71
|
+
max-width: 320px; padding: 6px 10px; border: 1px solid #b2ddff; border-radius: 6px;
|
|
72
|
+
background: #eff8ff; color: #175cd3; box-shadow: 0 6px 16px rgba(16,24,40,.18);
|
|
73
|
+
font: 500 13px/1.35 system-ui, sans-serif; white-space: nowrap; overflow: hidden;
|
|
74
|
+
text-overflow: ellipsis;
|
|
47
75
|
}
|
|
48
76
|
.docx-block-move-menu {
|
|
49
77
|
position: fixed; z-index: 2147483001; display: none; min-width: 150px; padding: 5px;
|
|
@@ -559,12 +587,22 @@ export class DocxEditor {
|
|
|
559
587
|
this.blockMoveLive = null;
|
|
560
588
|
this.blockDragSource = null;
|
|
561
589
|
this.blockDragCleanup = [];
|
|
562
|
-
|
|
590
|
+
/** Block boxes measured at drag start — see `BlockDropZone`. Empty when no drag is in flight. */
|
|
591
|
+
this.dropZones = [];
|
|
592
|
+
/** Combined scroll offset when `dropZones` was measured, and the scroller measured against. */
|
|
593
|
+
this.dropZoneOrigin = 0;
|
|
594
|
+
this.dropZoneScroller = null;
|
|
563
595
|
this.blockDragging = false;
|
|
564
596
|
this.blockDragPointerDown = false;
|
|
565
597
|
/** Anchors the current drag source may legally move next to, per the engine's own rules.
|
|
566
598
|
* Null when the bridge predates ValidMoveTargets — then every block is offered, as before. */
|
|
567
599
|
this.blockMoveTargets = null;
|
|
600
|
+
/** Memoized `ValidMoveTargets` answers, keyed by source anchor and dropped whenever an edit
|
|
601
|
+
* lands. The legal-target set is a property of the document, so hovering back and forth over
|
|
602
|
+
* the same blocks between edits must not re-ask the engine. */
|
|
603
|
+
this.blockMoveTargetCache = new Map();
|
|
604
|
+
/** Cancels the pending idle prefetch of the hovered block's targets — see `showBlockHandle`. */
|
|
605
|
+
this.blockMoveTargetPrefetch = null;
|
|
568
606
|
/** Why the last move was refused, verbatim from the engine — diagnostics, not announcement copy. */
|
|
569
607
|
this.lastMoveError = null;
|
|
570
608
|
/**
|
|
@@ -697,6 +735,11 @@ export class DocxEditor {
|
|
|
697
735
|
this.handle = handle;
|
|
698
736
|
this.options = options;
|
|
699
737
|
this.editRoot = container;
|
|
738
|
+
this.viewport = new DocumentViewport(container, {
|
|
739
|
+
columnWidth: options.columnWidth,
|
|
740
|
+
fitToWidth: options.fitToWidth,
|
|
741
|
+
scale: options.scale,
|
|
742
|
+
});
|
|
700
743
|
if (typeof document !== "undefined") {
|
|
701
744
|
document.addEventListener("selectionchange", this.onSelectionChange);
|
|
702
745
|
document.addEventListener("mousedown", this.onMouseDown, true);
|
|
@@ -784,6 +827,8 @@ export class DocxEditor {
|
|
|
784
827
|
editable: options.editable ?? true,
|
|
785
828
|
paginated: options.paginated ?? false,
|
|
786
829
|
scale: options.scale ?? 1,
|
|
830
|
+
columnWidth: options.columnWidth ?? "section",
|
|
831
|
+
fitToWidth: options.fitToWidth ?? true,
|
|
787
832
|
headerFooter: options.headerFooter ?? false,
|
|
788
833
|
blockDrag: options.blockDrag ?? false,
|
|
789
834
|
trackedChanges: options.trackedChanges ?? TrackedChangeMode.Accept,
|
|
@@ -841,6 +886,7 @@ export class DocxEditor {
|
|
|
841
886
|
}
|
|
842
887
|
this.clearDragSelection();
|
|
843
888
|
this.teardownBlockDrag();
|
|
889
|
+
this.viewport.dispose();
|
|
844
890
|
this.exports.DocxSessionBridge.CloseSession(this.handle);
|
|
845
891
|
}
|
|
846
892
|
/**
|
|
@@ -859,6 +905,14 @@ export class DocxEditor {
|
|
|
859
905
|
get root() {
|
|
860
906
|
return this.container;
|
|
861
907
|
}
|
|
908
|
+
/**
|
|
909
|
+
* The zoom the viewport is currently applying (1 = 100%). Below 1 the page is wider than the
|
|
910
|
+
* host and has been scaled to fit rather than reflowed — the honest thing to show a user who
|
|
911
|
+
* is wondering why a phone shows the whole page.
|
|
912
|
+
*/
|
|
913
|
+
get zoom() {
|
|
914
|
+
return this.viewport.scale;
|
|
915
|
+
}
|
|
862
916
|
/**
|
|
863
917
|
* The live `DocxSession` handle backing this editor — the model of record.
|
|
864
918
|
*
|
|
@@ -890,13 +944,12 @@ export class DocxEditor {
|
|
|
890
944
|
return true;
|
|
891
945
|
}
|
|
892
946
|
const destination = res.created?.[0] ?? res.modified?.[0];
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
this.reconcile();
|
|
947
|
+
// Both modes reconcile. A direct move relocates the existing element, so the reconciler moves
|
|
948
|
+
// the exact DOM node it already has. Review rendering additionally rewrites the SOURCE in
|
|
949
|
+
// place — it becomes the move-from half — and that is visible to the diff because the plan
|
|
950
|
+
// signs every unit with a content hash, so the source diffs as an in-place substitution.
|
|
951
|
+
// This used to remount, which on a real charter is seconds for a one-block change.
|
|
952
|
+
this.reconcile();
|
|
900
953
|
const moved = destination
|
|
901
954
|
? this.bodyUnitNodes().find((el) => el.getAttribute("data-anchor") === destination.unid)
|
|
902
955
|
: null;
|
|
@@ -928,7 +981,15 @@ export class DocxEditor {
|
|
|
928
981
|
: node?.parentElement;
|
|
929
982
|
if (!el || !this.editRoot.contains(el))
|
|
930
983
|
return null;
|
|
931
|
-
|
|
984
|
+
// Climb rather than scan: this runs on every pointer move over the document, and listing
|
|
985
|
+
// every anchored node to find the one containing the pointer is linear in the document.
|
|
986
|
+
let unit = null;
|
|
987
|
+
for (let candidate = el.closest("[data-anchor]"); candidate && this.editRoot.contains(candidate); candidate = candidate.parentElement?.closest("[data-anchor]") ?? null) {
|
|
988
|
+
unit = candidate;
|
|
989
|
+
}
|
|
990
|
+
if (!unit || unit.closest("section.footnotes, section.endnotes"))
|
|
991
|
+
return null;
|
|
992
|
+
return unit;
|
|
932
993
|
}
|
|
933
994
|
isMovableBlockUnit(unit) {
|
|
934
995
|
if (!unit || !this.anchorIdOf(unit))
|
|
@@ -956,23 +1017,24 @@ export class DocxEditor {
|
|
|
956
1017
|
return;
|
|
957
1018
|
const changed = unit !== this.blockDragSource;
|
|
958
1019
|
this.blockDragSource = unit;
|
|
959
|
-
if (changed)
|
|
960
|
-
this.refreshBlockMoveTargets(unit);
|
|
961
1020
|
// A block the engine will not move anywhere — one owning a section break, or already carrying
|
|
962
1021
|
// revision markup a tracked move would have to re-wrap — gets no handle rather than a handle
|
|
963
|
-
// that always fails.
|
|
964
|
-
|
|
1022
|
+
// that always fails. Asking costs an engine round trip, so hovering only ever CONSUMES a
|
|
1023
|
+
// cached answer and schedules the ask for idle time; the drag start and the menu ask for
|
|
1024
|
+
// real. Merely moving the pointer across the document must not run document-scale work.
|
|
1025
|
+
const known = this.blockMoveTargetsFor(unit, { cachedOnly: true });
|
|
1026
|
+
if (known?.size === 0) {
|
|
965
1027
|
handle.style.display = "none";
|
|
966
1028
|
return;
|
|
967
1029
|
}
|
|
1030
|
+
if (changed && known === undefined)
|
|
1031
|
+
this.prefetchBlockMoveTargets(unit);
|
|
968
1032
|
const rect = unit.getBoundingClientRect();
|
|
969
1033
|
handle.style.display = "flex";
|
|
970
1034
|
handle.style.left = `${Math.max(4, rect.left - 32)}px`;
|
|
971
1035
|
handle.style.top = `${Math.max(4, rect.top + (unit.tagName === "TABLE" ? 6 : Math.max(0, (rect.height - 28) / 2)))}px`;
|
|
972
|
-
const preview = (unit
|
|
1036
|
+
const preview = blockPreviewText(unit);
|
|
973
1037
|
handle.setAttribute("aria-label", preview ? `Move block: ${preview}` : "Move block");
|
|
974
|
-
if (changed)
|
|
975
|
-
this.refreshBlockDropTargets();
|
|
976
1038
|
}
|
|
977
1039
|
hideBlockHandle() {
|
|
978
1040
|
if (this.blockDragging || this.blockMoveMenu?.style.display === "block")
|
|
@@ -985,15 +1047,42 @@ export class DocxEditor {
|
|
|
985
1047
|
if (source && this.blockDragHandle?.style.display !== "none")
|
|
986
1048
|
this.showBlockHandle(source);
|
|
987
1049
|
}
|
|
988
|
-
|
|
1050
|
+
/** Draw the drop line on `zone`'s requested edge, or take it away when there is no target. */
|
|
1051
|
+
paintDropIndicator(data) {
|
|
989
1052
|
const indicator = this.blockDropIndicator;
|
|
1053
|
+
const zone = data.zone;
|
|
990
1054
|
if (!indicator)
|
|
991
1055
|
return;
|
|
992
|
-
|
|
1056
|
+
if (!zone) {
|
|
1057
|
+
this.hideDropIndicator();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const y = Math.round(this.dropEdgeY(zone, data.position === "after" ? "after" : "before")
|
|
1061
|
+
+ this.dropZoneShift()) - 1;
|
|
1062
|
+
// Position before revealing, so the entry fade never plays at a stale spot.
|
|
1063
|
+
indicator.style.transform = `translate3d(${Math.round(zone.left)}px, ${y}px, 0)`;
|
|
1064
|
+
indicator.style.width = `${Math.max(24, Math.round(zone.width))}px`;
|
|
993
1065
|
indicator.style.display = "block";
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Where to draw the line for an insertion on `position` of `zone` — the MIDDLE of the gap to
|
|
1069
|
+
* the neighbour on that side, not the zone's own border-box edge. A paragraph's `w:spacing`
|
|
1070
|
+
* becomes a CSS margin, which sits outside the box, so drawing on the edge underlines the
|
|
1071
|
+
* block's last line instead of reading as a gap between two blocks. Falls back to the raw edge
|
|
1072
|
+
* at the ends of the flow, and degrades to the same value when blocks are contiguous.
|
|
1073
|
+
*/
|
|
1074
|
+
dropEdgeY(zone, position) {
|
|
1075
|
+
const neighbour = this.dropZones[zone.index + (position === "after" ? 1 : -1)];
|
|
1076
|
+
// At the ends of the flow there is nothing to bisect against, so half the block's own
|
|
1077
|
+
// margin stands in for half the gap — the same position, one contributor instead of two.
|
|
1078
|
+
if (!neighbour) {
|
|
1079
|
+
return position === "after"
|
|
1080
|
+
? zone.bottom + zone.marginAfter / 2
|
|
1081
|
+
: zone.top - zone.marginBefore / 2;
|
|
1082
|
+
}
|
|
1083
|
+
return position === "after"
|
|
1084
|
+
? (zone.bottom + neighbour.top) / 2
|
|
1085
|
+
: (neighbour.bottom + zone.top) / 2;
|
|
997
1086
|
}
|
|
998
1087
|
hideDropIndicator() {
|
|
999
1088
|
if (this.blockDropIndicator)
|
|
@@ -1013,18 +1102,60 @@ export class DocxEditor {
|
|
|
1013
1102
|
* behaviour this replaces. Null (no bridge support) keeps the previous offer-everything path.
|
|
1014
1103
|
*/
|
|
1015
1104
|
refreshBlockMoveTargets(source) {
|
|
1105
|
+
this.blockMoveTargets = source ? this.blockMoveTargetsFor(source) ?? null : null;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* This block's legal destinations, from the memo when it is there. Returns `undefined` — not
|
|
1109
|
+
* `null` — for "not asked yet", so a caller can tell an unknown answer from the engine's
|
|
1110
|
+
* "no bridge support, offer everything" one.
|
|
1111
|
+
*/
|
|
1112
|
+
blockMoveTargetsFor(source, options = {}) {
|
|
1016
1113
|
const bridge = this.exports.DocxSessionBridge;
|
|
1017
|
-
const sourceId =
|
|
1018
|
-
if (!sourceId || typeof bridge.ValidMoveTargets !== "function")
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1114
|
+
const sourceId = this.anchorIdOf(source);
|
|
1115
|
+
if (!sourceId || typeof bridge.ValidMoveTargets !== "function")
|
|
1116
|
+
return null;
|
|
1117
|
+
if (this.blockMoveTargetCache.has(sourceId))
|
|
1118
|
+
return this.blockMoveTargetCache.get(sourceId);
|
|
1119
|
+
if (options.cachedOnly)
|
|
1120
|
+
return undefined;
|
|
1121
|
+
let targets;
|
|
1022
1122
|
try {
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1123
|
+
const parsed = JSON.parse(bridge.ValidMoveTargets(this.handle, sourceId));
|
|
1124
|
+
targets = new Map(parsed.map((t) => [t.anchorId, { before: t.before, after: t.after }]));
|
|
1025
1125
|
}
|
|
1026
1126
|
catch {
|
|
1027
|
-
|
|
1127
|
+
targets = null;
|
|
1128
|
+
}
|
|
1129
|
+
this.blockMoveTargetCache.set(sourceId, targets);
|
|
1130
|
+
return targets;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Ask for the hovered block's destinations off the interaction path, and hide the handle if
|
|
1134
|
+
* the answer comes back empty and that block is still the one under the pointer. The handle
|
|
1135
|
+
* therefore appears immediately on hover and withdraws a beat later on the rare immovable
|
|
1136
|
+
* block, instead of every hover paying for the query up front.
|
|
1137
|
+
*/
|
|
1138
|
+
prefetchBlockMoveTargets(source) {
|
|
1139
|
+
const view = this.container.ownerDocument.defaultView;
|
|
1140
|
+
if (!view)
|
|
1141
|
+
return;
|
|
1142
|
+
this.blockMoveTargetPrefetch?.();
|
|
1143
|
+
const run = () => {
|
|
1144
|
+
this.blockMoveTargetPrefetch = null;
|
|
1145
|
+
// The pointer has moved on (or the DOM was repainted) — the answer is no longer wanted.
|
|
1146
|
+
if (this.closed || !source.isConnected || this.blockDragSource !== source)
|
|
1147
|
+
return;
|
|
1148
|
+
if (this.blockMoveTargetsFor(source)?.size === 0 && this.blockDragHandle)
|
|
1149
|
+
this.blockDragHandle.style.display = "none";
|
|
1150
|
+
};
|
|
1151
|
+
const idle = view;
|
|
1152
|
+
if (idle.requestIdleCallback && idle.cancelIdleCallback) {
|
|
1153
|
+
const id = idle.requestIdleCallback(run, { timeout: 500 });
|
|
1154
|
+
this.blockMoveTargetPrefetch = () => idle.cancelIdleCallback(id);
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
const id = view.setTimeout(run, 0);
|
|
1158
|
+
this.blockMoveTargetPrefetch = () => view.clearTimeout(id);
|
|
1028
1159
|
}
|
|
1029
1160
|
}
|
|
1030
1161
|
/**
|
|
@@ -1041,49 +1172,74 @@ export class DocxEditor {
|
|
|
1041
1172
|
return false;
|
|
1042
1173
|
return position ? sides[position] : sides.before || sides.after;
|
|
1043
1174
|
}
|
|
1175
|
+
/** Measure every movable block once, at drag start. See `BlockDropZone`. */
|
|
1176
|
+
captureDropZones() {
|
|
1177
|
+
const view = this.container.ownerDocument.defaultView;
|
|
1178
|
+
this.dropZoneScroller = this.scrollContainer();
|
|
1179
|
+
this.dropZoneOrigin = this.scrollOffsetSum();
|
|
1180
|
+
this.dropZones = [];
|
|
1181
|
+
const boxes = [];
|
|
1182
|
+
for (const unit of this.bodyUnitNodes()) {
|
|
1183
|
+
const anchorId = this.isMovableBlockUnit(unit) ? this.anchorIdOf(unit) : null;
|
|
1184
|
+
if (!anchorId)
|
|
1185
|
+
continue;
|
|
1186
|
+
const box = this.unitWrapperOf(unit);
|
|
1187
|
+
const rect = box.getBoundingClientRect();
|
|
1188
|
+
boxes.push(box);
|
|
1189
|
+
this.dropZones.push({
|
|
1190
|
+
unit, anchorId, index: this.dropZones.length,
|
|
1191
|
+
top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width,
|
|
1192
|
+
marginBefore: 0, marginAfter: 0,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
// Only the flow's two ends ever consult a margin, so only they are worth a style read.
|
|
1196
|
+
const ends = new Set([0, this.dropZones.length - 1].filter((i) => i >= 0 && i < boxes.length));
|
|
1197
|
+
for (const i of ends) {
|
|
1198
|
+
const style = view?.getComputedStyle(boxes[i]);
|
|
1199
|
+
this.dropZones[i].marginBefore = parseFloat(style?.marginTop ?? "0") || 0;
|
|
1200
|
+
this.dropZones[i].marginAfter = parseFloat(style?.marginBottom ?? "0") || 0;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
scrollOffsetSum() {
|
|
1204
|
+
const view = this.container.ownerDocument.defaultView;
|
|
1205
|
+
return (view?.scrollY ?? 0) + (this.dropZoneScroller?.scrollTop ?? 0);
|
|
1206
|
+
}
|
|
1207
|
+
/** How far the measured boxes have travelled since capture, from scrolling (drag autoscroll). */
|
|
1208
|
+
dropZoneShift() {
|
|
1209
|
+
return this.dropZoneOrigin - this.scrollOffsetSum();
|
|
1210
|
+
}
|
|
1044
1211
|
/**
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1212
|
+
* Where a drop at `clientY` lands, or null when nothing there is legal.
|
|
1213
|
+
*
|
|
1214
|
+
* Resolution is by VERTICAL GEOMETRY over the measured blocks, not by which element the pointer
|
|
1215
|
+
* is over: the drag handle floats in the page margin, so a drag straight down the gutter — the
|
|
1216
|
+
* natural gesture — never crosses a paragraph box, and element hit testing gave those drags no
|
|
1217
|
+
* indicator and no drop at all. The nearest block by vertical distance is the target; the half
|
|
1218
|
+
* the pointer is in picks the side, snapped to the other side when only that one is legal
|
|
1219
|
+
* (a section break or a cross-block range usually makes exactly one side illegal). When neither
|
|
1220
|
+
* side is legal — the pointer is in a region this block cannot reach — there is no drop, and
|
|
1221
|
+
* nothing is drawn.
|
|
1049
1222
|
*/
|
|
1050
|
-
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
return;
|
|
1063
|
-
for (const unit of this.bodyUnitNodes().filter((el) => this.isMovableBlockUnit(el))) {
|
|
1064
|
-
this.blockDragTargetCleanup.push(dropTargetForElements({
|
|
1065
|
-
element: unit,
|
|
1066
|
-
// A target the engine would refuse is not a drop target at all, so Pragmatic never
|
|
1067
|
-
// fires onDragEnter for it and no indicator is drawn over it.
|
|
1068
|
-
canDrop: ({ source }) => source.data.type === BLOCK_DRAG_TYPE && source.data.sourceAnchorId !== this.anchorIdOf(unit) &&
|
|
1069
|
-
this.isValidMoveTarget(unit),
|
|
1070
|
-
getData: ({ input }) => ({
|
|
1071
|
-
type: BLOCK_DRAG_TYPE,
|
|
1072
|
-
targetAnchorId: this.anchorIdOf(unit),
|
|
1073
|
-
position: this.dropPositionFor(unit, input.clientY),
|
|
1074
|
-
targetElement: unit,
|
|
1075
|
-
}),
|
|
1076
|
-
onDragEnter: ({ self }) => {
|
|
1077
|
-
const pos = self.data.position === "after" ? "after" : "before";
|
|
1078
|
-
this.showDropIndicator(unit, pos);
|
|
1079
|
-
},
|
|
1080
|
-
onDrag: ({ self }) => {
|
|
1081
|
-
const pos = self.data.position === "after" ? "after" : "before";
|
|
1082
|
-
this.showDropIndicator(unit, pos);
|
|
1083
|
-
},
|
|
1084
|
-
onDragLeave: () => this.hideDropIndicator(),
|
|
1085
|
-
}));
|
|
1223
|
+
resolveDropAt(clientY) {
|
|
1224
|
+
const y = clientY - this.dropZoneShift();
|
|
1225
|
+
let best = null;
|
|
1226
|
+
let bestGap = Infinity;
|
|
1227
|
+
for (const zone of this.dropZones) {
|
|
1228
|
+
const gap = y < zone.top ? zone.top - y : y > zone.bottom ? y - zone.bottom : 0;
|
|
1229
|
+
if (gap < bestGap) {
|
|
1230
|
+
best = zone;
|
|
1231
|
+
bestGap = gap;
|
|
1232
|
+
}
|
|
1233
|
+
if (gap === 0)
|
|
1234
|
+
break; // inside this block; zones are in document order and never overlap
|
|
1086
1235
|
}
|
|
1236
|
+
if (!best || best.unit === this.blockDragSource)
|
|
1237
|
+
return null;
|
|
1238
|
+
const preferred = y < (best.top + best.bottom) / 2 ? "before" : "after";
|
|
1239
|
+
if (this.isValidMoveTarget(best.unit, preferred))
|
|
1240
|
+
return { zone: best, position: preferred };
|
|
1241
|
+
const other = preferred === "before" ? "after" : "before";
|
|
1242
|
+
return this.isValidMoveTarget(best.unit, other) ? { zone: best, position: other } : null;
|
|
1087
1243
|
}
|
|
1088
1244
|
closeBlockMoveMenu(restoreFocus = false) {
|
|
1089
1245
|
if (!this.blockMoveMenu || !this.blockDragHandle)
|
|
@@ -1126,11 +1282,8 @@ export class DocxEditor {
|
|
|
1126
1282
|
// Only pairs the engine accepts: a target can be reachable on one side and refused on the
|
|
1127
1283
|
// other, so the SIDE is part of what makes a candidate valid.
|
|
1128
1284
|
const position = action === "up" || action === "top" ? "before" : "after";
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
.filter((el) => (position === "before"
|
|
1132
|
-
? units.indexOf(el) < index
|
|
1133
|
-
: units.indexOf(el) > index));
|
|
1285
|
+
const side = position === "before" ? units.slice(0, index) : units.slice(index + 1);
|
|
1286
|
+
const candidates = side.filter((el) => this.isValidMoveTarget(el, position));
|
|
1134
1287
|
if (candidates.length === 0)
|
|
1135
1288
|
return null;
|
|
1136
1289
|
if (action === "up")
|
|
@@ -1268,20 +1421,59 @@ export class DocxEditor {
|
|
|
1268
1421
|
const source = this.currentBlockDragSource();
|
|
1269
1422
|
return { type: BLOCK_DRAG_TYPE, sourceAnchorId: source ? this.anchorIdOf(source) : undefined };
|
|
1270
1423
|
},
|
|
1424
|
+
// The browser would otherwise ghost the 26px grip, which says nothing about what is moving.
|
|
1425
|
+
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
|
1426
|
+
const source = this.currentBlockDragSource();
|
|
1427
|
+
setCustomNativeDragPreview({
|
|
1428
|
+
nativeSetDragImage,
|
|
1429
|
+
getOffset: pointerOutsideOfPreview({ x: "14px", y: "10px" }),
|
|
1430
|
+
render: ({ container }) => {
|
|
1431
|
+
const chip = doc.createElement("div");
|
|
1432
|
+
chip.className = "docx-block-drag-preview";
|
|
1433
|
+
chip.textContent = (source && blockPreviewText(source)) || "Move block";
|
|
1434
|
+
container.appendChild(chip);
|
|
1435
|
+
return () => chip.remove();
|
|
1436
|
+
},
|
|
1437
|
+
});
|
|
1438
|
+
},
|
|
1271
1439
|
onDragStart: () => {
|
|
1440
|
+
const source = this.currentBlockDragSource();
|
|
1272
1441
|
this.blockDragging = true;
|
|
1273
1442
|
handle.classList.add("docx-block-dragging");
|
|
1443
|
+
// After the preview snapshot, so the chip is not dimmed too.
|
|
1444
|
+
source?.classList.add("docx-block-drag-source");
|
|
1274
1445
|
this.closeBlockMoveMenu();
|
|
1275
|
-
this.refreshBlockMoveTargets(
|
|
1276
|
-
this.
|
|
1446
|
+
this.refreshBlockMoveTargets(source);
|
|
1447
|
+
this.captureDropZones();
|
|
1277
1448
|
},
|
|
1278
1449
|
onDrop: () => {
|
|
1279
1450
|
this.blockDragging = false;
|
|
1280
1451
|
this.blockDragPointerDown = false;
|
|
1452
|
+
this.dropZones = [];
|
|
1281
1453
|
handle.classList.remove("docx-block-dragging");
|
|
1454
|
+
// Cleared by selector rather than from a captured reference: a completed move may have
|
|
1455
|
+
// re-rendered the block, and the node that carries the class outlives either handle on it.
|
|
1456
|
+
doc.querySelectorAll(".docx-block-drag-source")
|
|
1457
|
+
.forEach((el) => el.classList.remove("docx-block-drag-source"));
|
|
1282
1458
|
this.hideDropIndicator();
|
|
1283
1459
|
},
|
|
1284
1460
|
}));
|
|
1461
|
+
// ONE drop target for the whole flow. Which block a drop belongs to is decided by
|
|
1462
|
+
// `resolveDropAt` from the pointer's vertical position, so the gutter the handle is dragged
|
|
1463
|
+
// down counts as being over the document — see that method.
|
|
1464
|
+
this.blockDragCleanup.push(dropTargetForElements({
|
|
1465
|
+
element: this.editRoot,
|
|
1466
|
+
canDrop: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
|
|
1467
|
+
getData: ({ input }) => {
|
|
1468
|
+
const hit = this.resolveDropAt(input.clientY);
|
|
1469
|
+
return hit
|
|
1470
|
+
? { type: BLOCK_DRAG_TYPE, targetAnchorId: hit.zone.anchorId, position: hit.position, zone: hit.zone }
|
|
1471
|
+
: { type: BLOCK_DRAG_TYPE };
|
|
1472
|
+
},
|
|
1473
|
+
onDragEnter: ({ self }) => this.paintDropIndicator(self.data),
|
|
1474
|
+
onDrag: ({ self }) => this.paintDropIndicator(self.data),
|
|
1475
|
+
onDragLeave: () => this.hideDropIndicator(),
|
|
1476
|
+
}));
|
|
1285
1477
|
this.blockDragCleanup.push(monitorForElements({
|
|
1286
1478
|
canMonitor: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
|
|
1287
1479
|
onDrop: ({ source, location }) => {
|
|
@@ -1313,11 +1505,13 @@ export class DocxEditor {
|
|
|
1313
1505
|
canScroll: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
|
|
1314
1506
|
getAllowedAxis: () => "vertical",
|
|
1315
1507
|
}));
|
|
1316
|
-
this.refreshBlockDropTargets();
|
|
1317
1508
|
}
|
|
1318
1509
|
teardownBlockDrag() {
|
|
1319
|
-
|
|
1320
|
-
|
|
1510
|
+
this.blockMoveTargetPrefetch?.();
|
|
1511
|
+
this.blockMoveTargetPrefetch = null;
|
|
1512
|
+
this.blockMoveTargetCache.clear();
|
|
1513
|
+
this.dropZones = [];
|
|
1514
|
+
this.dropZoneScroller = null;
|
|
1321
1515
|
for (const cleanup of this.blockDragCleanup.splice(0))
|
|
1322
1516
|
cleanup();
|
|
1323
1517
|
this.blockDragHandle?.remove();
|
|
@@ -1413,21 +1607,19 @@ export class DocxEditor {
|
|
|
1413
1607
|
bodyRoot.after(this.region.footerBand);
|
|
1414
1608
|
this.region.refreshAll();
|
|
1415
1609
|
}
|
|
1416
|
-
/**
|
|
1610
|
+
/**
|
|
1611
|
+
* Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks.
|
|
1612
|
+
*
|
|
1613
|
+
* The body always gets its own `.docx-body-flow` wrapper — not only when bands are docked.
|
|
1614
|
+
* It is the sheet: the element the viewport gives page geometry to and zooms, and the one
|
|
1615
|
+
* the bands dock around. Without it the container would have to be both the scrolling host
|
|
1616
|
+
* and the scaled page, which are different boxes.
|
|
1617
|
+
*/
|
|
1417
1618
|
mountHtml(fullHtml) {
|
|
1418
1619
|
const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
|
|
1419
1620
|
const styles = Array.from(parsed.querySelectorAll("style"))
|
|
1420
1621
|
.map((s) => s.outerHTML)
|
|
1421
1622
|
.join("");
|
|
1422
|
-
if (!this.region) {
|
|
1423
|
-
this.container.innerHTML = styles + parsed.body.innerHTML;
|
|
1424
|
-
this.editRoot = this.container;
|
|
1425
|
-
if (this.options.editable)
|
|
1426
|
-
this.wireBlocks(this.container);
|
|
1427
|
-
this.stampPlanState();
|
|
1428
|
-
return;
|
|
1429
|
-
}
|
|
1430
|
-
// With bands docked, the body flow needs its own wrapper to be the edit root.
|
|
1431
1623
|
this.container.innerHTML = styles;
|
|
1432
1624
|
const flow = document.createElement("div");
|
|
1433
1625
|
flow.className = "docx-body-flow";
|
|
@@ -1437,7 +1629,9 @@ export class DocxEditor {
|
|
|
1437
1629
|
if (this.options.editable)
|
|
1438
1630
|
this.wireBlocks(flow);
|
|
1439
1631
|
this.stampPlanState();
|
|
1440
|
-
this.
|
|
1632
|
+
if (this.region)
|
|
1633
|
+
this.dockBands(flow);
|
|
1634
|
+
this.viewport.attach(flow, true);
|
|
1441
1635
|
}
|
|
1442
1636
|
/** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
|
|
1443
1637
|
mountPaginated(fullHtml) {
|
|
@@ -1473,6 +1667,9 @@ export class DocxEditor {
|
|
|
1473
1667
|
// around the page stack, so there is still exactly one addressable node per story paragraph.
|
|
1474
1668
|
if (this.region)
|
|
1475
1669
|
this.dockBands(target);
|
|
1670
|
+
// Page boxes already carry the section's dimensions, so the viewport contributes only the
|
|
1671
|
+
// fit zoom — a full page is far wider than a phone, and clipping it is not an option.
|
|
1672
|
+
this.viewport.attach(pageRoot, false);
|
|
1476
1673
|
}
|
|
1477
1674
|
wireBlocks(root) {
|
|
1478
1675
|
root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
|
|
@@ -1951,12 +2148,24 @@ export class DocxEditor {
|
|
|
1951
2148
|
}
|
|
1952
2149
|
parseEdit(json) {
|
|
1953
2150
|
try {
|
|
1954
|
-
|
|
2151
|
+
const result = JSON.parse(json);
|
|
2152
|
+
if (result.success)
|
|
2153
|
+
this.invalidateBlockMoveTargets();
|
|
2154
|
+
return result;
|
|
1955
2155
|
}
|
|
1956
2156
|
catch {
|
|
1957
2157
|
return { success: false };
|
|
1958
2158
|
}
|
|
1959
2159
|
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Drop the memoized `ValidMoveTargets` answers. Which blocks a block may move next to is a
|
|
2162
|
+
* fact about the DOCUMENT, so it survives hovering but not editing — and the two places a
|
|
2163
|
+
* document changes are `parseEdit` (every mutation that returns an `EditResult`) and
|
|
2164
|
+
* undo/redo, which return a bare boolean and so cannot go through it.
|
|
2165
|
+
*/
|
|
2166
|
+
invalidateBlockMoveTargets() {
|
|
2167
|
+
this.blockMoveTargetCache.clear();
|
|
2168
|
+
}
|
|
1960
2169
|
// ─── M5: formatting commands (ribbon) ────────────────────────────────
|
|
1961
2170
|
// ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
|
|
1962
2171
|
/**
|
|
@@ -2544,15 +2753,19 @@ export class DocxEditor {
|
|
|
2544
2753
|
undo() {
|
|
2545
2754
|
if (this.closed)
|
|
2546
2755
|
return;
|
|
2547
|
-
if (this.exports.DocxSessionBridge.Undo(this.handle))
|
|
2548
|
-
|
|
2756
|
+
if (!this.exports.DocxSessionBridge.Undo(this.handle))
|
|
2757
|
+
return;
|
|
2758
|
+
this.invalidateBlockMoveTargets();
|
|
2759
|
+
this.reconcile();
|
|
2549
2760
|
}
|
|
2550
2761
|
/** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
|
|
2551
2762
|
redo() {
|
|
2552
2763
|
if (this.closed)
|
|
2553
2764
|
return;
|
|
2554
|
-
if (this.exports.DocxSessionBridge.Redo(this.handle))
|
|
2555
|
-
|
|
2765
|
+
if (!this.exports.DocxSessionBridge.Redo(this.handle))
|
|
2766
|
+
return;
|
|
2767
|
+
this.invalidateBlockMoveTargets();
|
|
2768
|
+
this.reconcile();
|
|
2556
2769
|
}
|
|
2557
2770
|
// ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
|
|
2558
2771
|
/**
|
|
@@ -2783,8 +2996,12 @@ export class DocxEditor {
|
|
|
2783
2996
|
const oldTokens = oldNodes.map(DocxEditor.domTokenOf);
|
|
2784
2997
|
const oldKinds = oldNodes.map(DocxEditor.domKindOf);
|
|
2785
2998
|
const bodyDiff = diffUnits(oldTokens, plan.body);
|
|
2786
|
-
if (needsRemount(bodyDiff, plan.body, oldKinds))
|
|
2787
|
-
|
|
2999
|
+
if (needsRemount(bodyDiff, plan.body, oldKinds)) {
|
|
3000
|
+
// Name the shape, not just the verdict: "churn" and "a list item moved in or out" are very
|
|
3001
|
+
// different findings when a repaint unexpectedly costs a whole-document render.
|
|
3002
|
+
return this.bail(`needsRemount (li change or churn): +${bodyDiff.added.length} -${bodyDiff.removed.length} ` +
|
|
3003
|
+
`~${bodyDiff.substituted.length} moved=${bodyDiff.moved.length} of ${plan.body.length}`);
|
|
3004
|
+
}
|
|
2788
3005
|
const fnState = this.notesDiff("footnotes", plan.footnotes);
|
|
2789
3006
|
const enState = this.notesDiff("endnotes", plan.endnotes);
|
|
2790
3007
|
if (fnState === null || enState === null)
|