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.
Files changed (48) hide show
  1. package/dist/editor.bundle.js +588 -114
  2. package/dist/editor.d.ts +87 -11
  3. package/dist/editor.d.ts.map +1 -1
  4. package/dist/editor.js +317 -100
  5. package/dist/editor.js.map +1 -1
  6. package/dist/embed.bundle.js +621 -115
  7. package/dist/embed.iife.js +621 -115
  8. package/dist/index.d.ts +4 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/page-geometry.d.ts +74 -0
  13. package/dist/page-geometry.d.ts.map +1 -0
  14. package/dist/page-geometry.js +105 -0
  15. package/dist/page-geometry.js.map +1 -0
  16. package/dist/pagination.bundle.js +8 -6
  17. package/dist/pagination.d.ts +2 -25
  18. package/dist/pagination.d.ts.map +1 -1
  19. package/dist/pagination.js +2 -45
  20. package/dist/pagination.js.map +1 -1
  21. package/dist/ribbon-chrome.d.ts +1 -1
  22. package/dist/ribbon-chrome.d.ts.map +1 -1
  23. package/dist/ribbon-chrome.js +18 -18
  24. package/dist/ribbon.js +2 -0
  25. package/dist/ribbon.js.map +1 -1
  26. package/dist/session.bundle.js +25 -1
  27. package/dist/session.d.ts +20 -2
  28. package/dist/session.d.ts.map +1 -1
  29. package/dist/session.js +23 -1
  30. package/dist/session.js.map +1 -1
  31. package/dist/types.d.ts +8 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/dist/viewport.d.ts +80 -0
  35. package/dist/viewport.d.ts.map +1 -0
  36. package/dist/viewport.js +139 -0
  37. package/dist/viewport.js.map +1 -0
  38. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  39. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  40. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  41. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  42. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  43. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  44. package/dist/wasm/_framework/dotnet.boot.js +5 -5
  45. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  46. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  47. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  48. 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: .78; }
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: 3px; pointer-events: none;
46
- border-radius: 999px; background: #2e90fa; box-shadow: 0 0 0 1px rgba(255,255,255,.85);
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
- this.blockDragTargetCleanup = [];
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
- // Review rendering creates a visible move-from and move-to pair. A full remount keeps
894
- // their revision wrappers and table-row styling canonical; direct moves use the ordered
895
- // incremental reconciler and retain the exact existing DOM node.
896
- if (this.renderTrackedChanges)
897
- this.remount();
898
- else
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
- return this.bodyUnitNodes().find((unit) => unit === el || unit.contains(el)) ?? null;
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
- if (this.blockMoveTargets?.size === 0) {
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.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 48);
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
- showDropIndicator(target, position) {
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
- const rect = this.unitWrapperOf(target).getBoundingClientRect();
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
- indicator.style.left = `${rect.left}px`;
995
- indicator.style.top = `${position === "before" ? rect.top - 1 : rect.bottom - 1}px`;
996
- indicator.style.width = `${Math.max(24, rect.width)}px`;
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 = source ? this.anchorIdOf(source) : null;
1018
- if (!sourceId || typeof bridge.ValidMoveTargets !== "function") {
1019
- this.blockMoveTargets = null;
1020
- return;
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 targets = JSON.parse(bridge.ValidMoveTargets(this.handle, sourceId));
1024
- this.blockMoveTargets = new Map(targets.map((t) => [t.anchorId, { before: t.before, after: t.after }]));
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
- this.blockMoveTargets = null;
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
- * The side of `unit` a drop at `clientY` should land on: the half the pointer is in, snapped to
1046
- * the other side when only that one is legal. Snapping rather than refusing keeps a reachable
1047
- * target usable the illegal side is usually illegal only because a section break or a
1048
- * cross-block range sits between the two blocks on that side.
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
- dropPositionFor(unit, clientY) {
1051
- const rect = unit.getBoundingClientRect();
1052
- const preferred = clientY < rect.top + rect.height / 2 ? "before" : "after";
1053
- if (this.isValidMoveTarget(unit, preferred))
1054
- return preferred;
1055
- const other = preferred === "before" ? "after" : "before";
1056
- return this.isValidMoveTarget(unit, other) ? other : preferred;
1057
- }
1058
- refreshBlockDropTargets() {
1059
- for (const cleanup of this.blockDragTargetCleanup.splice(0))
1060
- cleanup();
1061
- if (!this.blockDragHandle || this.options.paginated)
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 candidates = units
1130
- .filter((el) => el !== source && this.isValidMoveTarget(el, position))
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(this.currentBlockDragSource());
1276
- this.refreshBlockDropTargets();
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
- for (const cleanup of this.blockDragTargetCleanup.splice(0))
1320
- cleanup();
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
- /** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
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.dockBands(flow);
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
- return JSON.parse(json);
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
- this.reconcile();
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
- this.reconcile();
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
- return this.bail("needsRemount (li change or churn)");
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)