docxodus 9.2.0 → 9.3.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.js CHANGED
@@ -18,8 +18,54 @@
18
18
  */
19
19
  import { paginateHtml } from "./pagination.js";
20
20
  import { HeaderFooterRegion } from "./editor-headerfooter.js";
21
+ import { draggable, dropTargetForElements, monitorForElements, } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
22
+ import { autoScrollForElements, autoScrollWindowForElements, } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
23
+ import { TrackedChangeMode } from "./types.js";
21
24
  import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
22
25
  const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
26
+ const BLOCK_DRAG_TYPE = "docxodus-block";
27
+ const blockDragStyledDocuments = new WeakSet();
28
+ function ensureBlockDragStyles(doc) {
29
+ if (blockDragStyledDocuments.has(doc))
30
+ return;
31
+ blockDragStyledDocuments.add(doc);
32
+ const style = doc.createElement("style");
33
+ style.dataset.docxodusBlockDrag = "true";
34
+ style.textContent = `
35
+ .docx-block-handle {
36
+ position: fixed; z-index: 2147483000; display: none; width: 26px; height: 28px;
37
+ align-items: center; justify-content: center; padding: 0; border: 1px solid #d0d5dd;
38
+ border-radius: 6px; background: #fff; color: #667085; box-shadow: 0 1px 3px rgba(16,24,40,.12);
39
+ cursor: grab; font: 700 17px/1 system-ui, sans-serif; user-select: none; touch-action: none;
40
+ }
41
+ .docx-block-handle:hover, .docx-block-handle:focus-visible { color: #344054; border-color: #98a2b3; outline: none; }
42
+ .docx-block-handle[aria-pressed="true"] { color: #175cd3; border-color: #84adff; background: #eff8ff; }
43
+ .docx-block-handle.docx-block-dragging { cursor: grabbing; opacity: .78; }
44
+ .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);
47
+ }
48
+ .docx-block-move-menu {
49
+ position: fixed; z-index: 2147483001; display: none; min-width: 150px; padding: 5px;
50
+ border: 1px solid #d0d5dd; border-radius: 8px; background: #fff;
51
+ box-shadow: 0 8px 24px rgba(16,24,40,.18); font: 13px/1.35 system-ui, sans-serif;
52
+ }
53
+ .docx-block-move-menu button { display: block; width: 100%; padding: 7px 9px; border: 0; border-radius: 5px; background: transparent; color: #344054; text-align: left; }
54
+ .docx-block-move-menu button:hover, .docx-block-move-menu button:focus-visible { background: #f2f4f7; outline: none; }
55
+ .docx-block-move-menu button:disabled { color: #98a2b3; background: transparent; }
56
+ .docx-block-move-flash { animation: docx-block-move-flash 800ms ease-out; }
57
+ @keyframes docx-block-move-flash { from { box-shadow: 0 0 0 3px rgba(46,144,250,.55); } to { box-shadow: none; } }
58
+ .docx-block-live-region { position: fixed; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
59
+ `;
60
+ (doc.head ?? doc.documentElement).appendChild(style);
61
+ }
62
+ function trackedChangeWireName(mode) {
63
+ if (mode === TrackedChangeMode.RenderInline)
64
+ return "render_inline";
65
+ if (mode === TrackedChangeMode.StripDeletions)
66
+ return "strip_deletions";
67
+ return "accept";
68
+ }
23
69
  function fontWeightIsBold(w) {
24
70
  if (w === "bold" || w === "bolder")
25
71
  return true;
@@ -480,7 +526,7 @@ function selectionHasFormat(key, fallback) {
480
526
  }
481
527
  }
482
528
  /** Build the full ConvertDocxToHtmlComplete arg list (stampAnchors = last arg). */
483
- function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
529
+ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale, renderTrackedChanges) {
484
530
  return [
485
531
  bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
486
532
  /* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
@@ -489,7 +535,7 @@ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
489
535
  // paragraphs editable. Must stay in step with DocxSessionOps.RenderHtml (the remount path),
490
536
  // whose output has to match this first paint byte-for-byte.
491
537
  /* renderFootnotesAndEndnotes */ true, /* renderHeadersAndFooters */ paginated,
492
- false, true, true, false, null, /* stampAnchors */ true,
538
+ renderTrackedChanges, true, true, false, null, /* stampAnchors */ true,
493
539
  ];
494
540
  }
495
541
  export class DocxEditor {
@@ -506,6 +552,21 @@ export class DocxEditor {
506
552
  * structural edit (split/merge/format) is lost. While this flag is set, commitBlock no-ops.
507
553
  */
508
554
  this.replacing = false;
555
+ /** Editor-owned block-move chrome. It is deliberately outside the rendered unit tree. */
556
+ this.blockDragHandle = null;
557
+ this.blockDropIndicator = null;
558
+ this.blockMoveMenu = null;
559
+ this.blockMoveLive = null;
560
+ this.blockDragSource = null;
561
+ this.blockDragCleanup = [];
562
+ this.blockDragTargetCleanup = [];
563
+ this.blockDragging = false;
564
+ this.blockDragPointerDown = false;
565
+ /** Anchors the current drag source may legally move next to, per the engine's own rules.
566
+ * Null when the bridge predates ValidMoveTargets — then every block is offered, as before. */
567
+ this.blockMoveTargets = null;
568
+ /** Why the last move was refused, verbatim from the engine — diagnostics, not announcement copy. */
569
+ this.lastMoveError = null;
509
570
  /**
510
571
  * The last real (non-collapsed) text selection inside an editable block. A toolbar control that
511
572
  * must take focus to be used — the font-size combobox — blurs the block and collapses the live
@@ -724,7 +785,11 @@ export class DocxEditor {
724
785
  paginated: options.paginated ?? false,
725
786
  scale: options.scale ?? 1,
726
787
  headerFooter: options.headerFooter ?? false,
788
+ blockDrag: options.blockDrag ?? false,
789
+ trackedChanges: options.trackedChanges ?? TrackedChangeMode.Accept,
790
+ revisionAuthor: options.revisionAuthor ?? "docxodus",
727
791
  onEdit: options.onEdit,
792
+ onMove: options.onMove,
728
793
  };
729
794
  // NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
730
795
  // projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
@@ -732,17 +797,22 @@ export class DocxEditor {
732
797
  // save/re-render hop, and it asks for that per call via SaveWithAnchorIds.
733
798
  // emitMarkdownPatch off: the editor re-renders from HTML, never from markdown patches,
734
799
  // so paying a whole-document re-projection per op would be dead weight.
735
- const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"emitMarkdownPatch":false}');
800
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, JSON.stringify({
801
+ emitMarkdownPatch: false,
802
+ trackedChanges: trackedChangeWireName(opts.trackedChanges),
803
+ revisionAuthor: opts.revisionAuthor,
804
+ }));
736
805
  const editor = new DocxEditor(container, exports, handle, opts);
737
806
  editor.refreshAnchorMap();
738
807
  if (opts.headerFooter)
739
808
  editor.createRegion();
740
- const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
809
+ const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale, opts.trackedChanges === TrackedChangeMode.RenderInline));
741
810
  if (opts.paginated)
742
811
  editor.mountPaginated(fullHtml);
743
812
  else
744
813
  editor.mountHtml(fullHtml);
745
814
  editor.syncRegionToBody();
815
+ editor.setupBlockDrag();
746
816
  return editor;
747
817
  }
748
818
  /**
@@ -770,6 +840,7 @@ export class DocxEditor {
770
840
  document.removeEventListener("mouseup", this.onMouseUp, true);
771
841
  }
772
842
  this.clearDragSelection();
843
+ this.teardownBlockDrag();
773
844
  this.exports.DocxSessionBridge.CloseSession(this.handle);
774
845
  }
775
846
  /**
@@ -798,6 +869,469 @@ export class DocxEditor {
798
869
  get sessionHandle() {
799
870
  return this.handle;
800
871
  }
872
+ /** Move one top-level body block relative to another and repaint from the live session. */
873
+ moveBlock(sourceAnchorId, targetAnchorId, position) {
874
+ this.assertOpen();
875
+ const bridge = this.exports.DocxSessionBridge;
876
+ if (typeof bridge.MoveBlock !== "function")
877
+ return false;
878
+ const res = this.parseEdit(bridge.MoveBlock(this.handle, sourceAnchorId, targetAnchorId, position));
879
+ if (!res.success) {
880
+ // The engine's message names an OOXML construct ("a section-break paragraph"); the live
881
+ // region is read aloud, so announce the outcome and keep the raw reason for debugging.
882
+ this.lastMoveError = res.error?.message ?? null;
883
+ this.announceBlockMove("This block cannot be moved there.");
884
+ return false;
885
+ }
886
+ if ((res.created?.length ?? 0) === 0 &&
887
+ (res.modified?.length ?? 0) === 0 &&
888
+ (res.removed?.length ?? 0) === 0) {
889
+ this.announceBlockMove("The block is already in that position.");
890
+ return true;
891
+ }
892
+ 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();
900
+ const moved = destination
901
+ ? this.bodyUnitNodes().find((el) => el.getAttribute("data-anchor") === destination.unid)
902
+ : null;
903
+ if (moved) {
904
+ moved.classList.add("docx-block-move-flash");
905
+ moved.addEventListener("animationend", () => moved.classList.remove("docx-block-move-flash"), {
906
+ once: true,
907
+ });
908
+ const focusTarget = EDITABLE_TAGS.has(moved.tagName)
909
+ ? moved
910
+ : moved.querySelector('[data-anchor][contenteditable="true"]');
911
+ if (focusTarget) {
912
+ this.activeBlock = focusTarget;
913
+ focusTarget.focus({ preventScroll: true });
914
+ }
915
+ }
916
+ this.options.onMove?.({
917
+ sourceAnchorId,
918
+ destinationAnchorId: destination?.id ?? sourceAnchorId,
919
+ });
920
+ this.announceBlockMove("Block moved.");
921
+ return true;
922
+ }
923
+ /** Resolve any rendered descendant to its top-level body move unit (tables stay whole). */
924
+ blockUnitOf(target) {
925
+ const node = target instanceof Node ? target : null;
926
+ const el = node?.nodeType === Node.ELEMENT_NODE
927
+ ? node
928
+ : node?.parentElement;
929
+ if (!el || !this.editRoot.contains(el))
930
+ return null;
931
+ return this.bodyUnitNodes().find((unit) => unit === el || unit.contains(el)) ?? null;
932
+ }
933
+ isMovableBlockUnit(unit) {
934
+ if (!unit || !this.anchorIdOf(unit))
935
+ return false;
936
+ // A source representation of an existing tracked move/deletion is evidence, not a new
937
+ // editable block. Its accepted counterpart remains draggable.
938
+ if (unit.matches("[class$='row-del'], [class*='row-del ']") ||
939
+ unit.querySelector("tr[class$='row-del'], tr[class*='row-del '], del[class$='move-from'], del[class*='move-from ']"))
940
+ return false;
941
+ return unit.tagName === "TABLE" || EDITABLE_TAGS.has(unit.tagName);
942
+ }
943
+ currentBlockDragSource() {
944
+ if (this.isMovableBlockUnit(this.blockDragSource) && this.blockDragSource.isConnected)
945
+ return this.blockDragSource;
946
+ const fromActive = this.blockUnitOf(this.activeBlock);
947
+ if (this.isMovableBlockUnit(fromActive)) {
948
+ this.blockDragSource = fromActive;
949
+ return fromActive;
950
+ }
951
+ return null;
952
+ }
953
+ showBlockHandle(unit) {
954
+ const handle = this.blockDragHandle;
955
+ if (!handle || !this.isMovableBlockUnit(unit))
956
+ return;
957
+ const changed = unit !== this.blockDragSource;
958
+ this.blockDragSource = unit;
959
+ if (changed)
960
+ this.refreshBlockMoveTargets(unit);
961
+ // A block the engine will not move anywhere — one owning a section break, or already carrying
962
+ // 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) {
965
+ handle.style.display = "none";
966
+ return;
967
+ }
968
+ const rect = unit.getBoundingClientRect();
969
+ handle.style.display = "flex";
970
+ handle.style.left = `${Math.max(4, rect.left - 32)}px`;
971
+ 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);
973
+ handle.setAttribute("aria-label", preview ? `Move block: ${preview}` : "Move block");
974
+ if (changed)
975
+ this.refreshBlockDropTargets();
976
+ }
977
+ hideBlockHandle() {
978
+ if (this.blockDragging || this.blockMoveMenu?.style.display === "block")
979
+ return;
980
+ if (this.blockDragHandle)
981
+ this.blockDragHandle.style.display = "none";
982
+ }
983
+ positionBlockHandle() {
984
+ const source = this.currentBlockDragSource();
985
+ if (source && this.blockDragHandle?.style.display !== "none")
986
+ this.showBlockHandle(source);
987
+ }
988
+ showDropIndicator(target, position) {
989
+ const indicator = this.blockDropIndicator;
990
+ if (!indicator)
991
+ return;
992
+ const rect = this.unitWrapperOf(target).getBoundingClientRect();
993
+ 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`;
997
+ }
998
+ hideDropIndicator() {
999
+ if (this.blockDropIndicator)
1000
+ this.blockDropIndicator.style.display = "none";
1001
+ }
1002
+ announceBlockMove(message) {
1003
+ const live = this.blockMoveLive;
1004
+ if (!live)
1005
+ return;
1006
+ live.textContent = "";
1007
+ this.container.ownerDocument.defaultView?.setTimeout(() => { live.textContent = message; }, 0);
1008
+ }
1009
+ /**
1010
+ * Ask the engine which anchors the current source may move next to. One call per drag source,
1011
+ * so the UI offers only drops MoveBlock will accept — a document with section breaks is
1012
+ * partitioned into regions, and drawing an indicator across one only to fail the drop was the
1013
+ * behaviour this replaces. Null (no bridge support) keeps the previous offer-everything path.
1014
+ */
1015
+ refreshBlockMoveTargets(source) {
1016
+ 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
+ }
1022
+ 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 }]));
1025
+ }
1026
+ catch {
1027
+ this.blockMoveTargets = null;
1028
+ }
1029
+ }
1030
+ /**
1031
+ * Whether `unit` is a legal destination for the current drag source — for a SPECIFIC side when
1032
+ * one is given. A cross-block range or a section break between the blocks can make one side
1033
+ * legal and the other not, so "this target is reachable" is not enough to pick a position.
1034
+ */
1035
+ isValidMoveTarget(unit, position) {
1036
+ if (!this.blockMoveTargets)
1037
+ return true; // no bridge support: engine stays the only gate
1038
+ const id = this.anchorIdOf(unit);
1039
+ const sides = id ? this.blockMoveTargets.get(id) : undefined;
1040
+ if (!sides)
1041
+ return false;
1042
+ return position ? sides[position] : sides.before || sides.after;
1043
+ }
1044
+ /**
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.
1049
+ */
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
+ }));
1086
+ }
1087
+ }
1088
+ closeBlockMoveMenu(restoreFocus = false) {
1089
+ if (!this.blockMoveMenu || !this.blockDragHandle)
1090
+ return;
1091
+ this.blockMoveMenu.style.display = "none";
1092
+ this.blockDragHandle.setAttribute("aria-expanded", "false");
1093
+ this.blockDragHandle.setAttribute("aria-pressed", "false");
1094
+ if (restoreFocus)
1095
+ this.blockDragHandle.focus({ preventScroll: true });
1096
+ }
1097
+ openBlockMoveMenu() {
1098
+ const menu = this.blockMoveMenu;
1099
+ const handle = this.blockDragHandle;
1100
+ const source = this.currentBlockDragSource();
1101
+ if (!menu || !handle || !source)
1102
+ return;
1103
+ this.refreshBlockMoveTargets(source);
1104
+ menu.querySelectorAll("button[data-move]").forEach((button) => {
1105
+ button.disabled = !this.blockMoveDestination(source, button.dataset.move ?? "");
1106
+ });
1107
+ const rect = handle.getBoundingClientRect();
1108
+ menu.style.display = "block";
1109
+ menu.style.left = `${Math.max(4, rect.right + 6)}px`;
1110
+ menu.style.top = `${Math.max(4, rect.top)}px`;
1111
+ handle.setAttribute("aria-expanded", "true");
1112
+ handle.setAttribute("aria-pressed", "true");
1113
+ menu.querySelector("button:not(:disabled)")?.focus({ preventScroll: true });
1114
+ }
1115
+ /**
1116
+ * Resolve a move-menu action to the block it should land against, considering only destinations
1117
+ * the engine accepts. "Top"/"bottom" therefore mean the ends of the source's own movable region
1118
+ * — on a document with section breaks that is the section, not the document, which is what makes
1119
+ * the commands work at all there instead of always failing.
1120
+ */
1121
+ blockMoveDestination(source, action) {
1122
+ const units = this.bodyUnitNodes().filter((el) => this.isMovableBlockUnit(el));
1123
+ const index = units.indexOf(source);
1124
+ if (index < 0)
1125
+ return null;
1126
+ // Only pairs the engine accepts: a target can be reachable on one side and refused on the
1127
+ // other, so the SIDE is part of what makes a candidate valid.
1128
+ 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));
1134
+ if (candidates.length === 0)
1135
+ return null;
1136
+ if (action === "up")
1137
+ return { target: candidates[candidates.length - 1], position };
1138
+ if (action === "down")
1139
+ return { target: candidates[0], position };
1140
+ if (action === "top")
1141
+ return { target: candidates[0], position };
1142
+ if (action === "bottom")
1143
+ return { target: candidates[candidates.length - 1], position };
1144
+ return null;
1145
+ }
1146
+ runBlockMoveMenuAction(action) {
1147
+ const source = this.currentBlockDragSource();
1148
+ if (!source)
1149
+ return;
1150
+ const destination = this.blockMoveDestination(source, action);
1151
+ this.closeBlockMoveMenu();
1152
+ if (!destination)
1153
+ return;
1154
+ const sourceId = this.anchorIdOf(source);
1155
+ const targetId = this.anchorIdOf(destination.target);
1156
+ if (sourceId && targetId)
1157
+ this.moveBlock(sourceId, targetId, destination.position);
1158
+ }
1159
+ /** The nearest ancestor that actually scrolls the document flow, for drag autoscroll. */
1160
+ scrollContainer() {
1161
+ const view = this.container.ownerDocument.defaultView;
1162
+ for (let el = this.editRoot; el; el = el.parentElement) {
1163
+ const overflowY = view?.getComputedStyle(el).overflowY ?? "visible";
1164
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight)
1165
+ return el;
1166
+ }
1167
+ return null;
1168
+ }
1169
+ /** Mount one floating handle; PDD owns pointer dragging while the menu owns keyboard moves. */
1170
+ setupBlockDrag() {
1171
+ if (!this.options.blockDrag || this.options.paginated || this.closed || this.blockDragHandle)
1172
+ return;
1173
+ const doc = this.container.ownerDocument;
1174
+ ensureBlockDragStyles(doc);
1175
+ const handle = doc.createElement("div");
1176
+ handle.className = "docx-block-handle";
1177
+ handle.textContent = "⠿";
1178
+ handle.tabIndex = 0;
1179
+ handle.setAttribute("role", "button");
1180
+ handle.setAttribute("contenteditable", "false");
1181
+ handle.setAttribute("aria-haspopup", "menu");
1182
+ handle.setAttribute("aria-expanded", "false");
1183
+ handle.setAttribute("aria-pressed", "false");
1184
+ const indicator = doc.createElement("div");
1185
+ indicator.className = "docx-block-drop-indicator";
1186
+ const menu = doc.createElement("div");
1187
+ menu.className = "docx-block-move-menu";
1188
+ menu.setAttribute("role", "menu");
1189
+ menu.innerHTML = `
1190
+ <button type="button" role="menuitem" data-move="up">Move up</button>
1191
+ <button type="button" role="menuitem" data-move="down">Move down</button>
1192
+ <button type="button" role="menuitem" data-move="top">Move to top</button>
1193
+ <button type="button" role="menuitem" data-move="bottom">Move to bottom</button>`;
1194
+ const live = doc.createElement("div");
1195
+ live.className = "docx-block-live-region";
1196
+ live.setAttribute("role", "status");
1197
+ live.setAttribute("aria-live", "polite");
1198
+ this.container.append(handle, indicator, menu, live);
1199
+ this.blockDragHandle = handle;
1200
+ this.blockDropIndicator = indicator;
1201
+ this.blockMoveMenu = menu;
1202
+ this.blockMoveLive = live;
1203
+ const onPointerMove = (event) => {
1204
+ // Keep the floating draggable stationary between pointerdown and native dragstart.
1205
+ // Repositioning it under the pointer during the browser's drag threshold cancels
1206
+ // drag initiation before PDD can receive dragstart.
1207
+ if (this.blockDragging || this.blockDragPointerDown)
1208
+ return;
1209
+ const unit = this.blockUnitOf(event.target);
1210
+ if (this.isMovableBlockUnit(unit))
1211
+ this.showBlockHandle(unit);
1212
+ };
1213
+ const onFocusIn = (event) => {
1214
+ const unit = this.blockUnitOf(event.target);
1215
+ if (this.isMovableBlockUnit(unit))
1216
+ this.showBlockHandle(unit);
1217
+ };
1218
+ const onPointerLeave = (event) => {
1219
+ const next = event.relatedTarget instanceof Node ? event.relatedTarget : null;
1220
+ if (next && (handle.contains(next) || menu.contains(next)))
1221
+ return;
1222
+ this.hideBlockHandle();
1223
+ };
1224
+ const onViewportChange = () => this.positionBlockHandle();
1225
+ this.container.addEventListener("pointermove", onPointerMove);
1226
+ this.container.addEventListener("focusin", onFocusIn);
1227
+ this.container.addEventListener("pointerleave", onPointerLeave);
1228
+ doc.addEventListener("scroll", onViewportChange, true);
1229
+ doc.defaultView?.addEventListener("resize", onViewportChange);
1230
+ this.blockDragCleanup.push(() => this.container.removeEventListener("pointermove", onPointerMove), () => this.container.removeEventListener("focusin", onFocusIn), () => this.container.removeEventListener("pointerleave", onPointerLeave), () => doc.removeEventListener("scroll", onViewportChange, true), () => doc.defaultView?.removeEventListener("resize", onViewportChange));
1231
+ const onHandleClick = () => {
1232
+ if (menu.style.display === "block")
1233
+ this.closeBlockMoveMenu();
1234
+ else
1235
+ this.openBlockMoveMenu();
1236
+ };
1237
+ const onHandlePointerDown = () => { this.blockDragPointerDown = true; };
1238
+ const onPointerUp = () => { this.blockDragPointerDown = false; };
1239
+ const onHandleKeydown = (event) => {
1240
+ if (event.key !== "Enter" && event.key !== " ")
1241
+ return;
1242
+ event.preventDefault();
1243
+ onHandleClick();
1244
+ };
1245
+ const onMenuClick = (event) => {
1246
+ const button = event.target?.closest("button[data-move]");
1247
+ if (button && !button.disabled)
1248
+ this.runBlockMoveMenuAction(button.dataset.move ?? "");
1249
+ };
1250
+ const onMenuKeydown = (event) => {
1251
+ if (event.key === "Escape") {
1252
+ event.preventDefault();
1253
+ this.closeBlockMoveMenu(true);
1254
+ }
1255
+ };
1256
+ handle.addEventListener("click", onHandleClick);
1257
+ handle.addEventListener("pointerdown", onHandlePointerDown);
1258
+ handle.addEventListener("keydown", onHandleKeydown);
1259
+ menu.addEventListener("click", onMenuClick);
1260
+ menu.addEventListener("keydown", onMenuKeydown);
1261
+ doc.addEventListener("pointerup", onPointerUp, true);
1262
+ doc.addEventListener("pointercancel", onPointerUp, true);
1263
+ this.blockDragCleanup.push(() => handle.removeEventListener("click", onHandleClick), () => handle.removeEventListener("pointerdown", onHandlePointerDown), () => handle.removeEventListener("keydown", onHandleKeydown), () => menu.removeEventListener("click", onMenuClick), () => menu.removeEventListener("keydown", onMenuKeydown), () => doc.removeEventListener("pointerup", onPointerUp, true), () => doc.removeEventListener("pointercancel", onPointerUp, true));
1264
+ this.blockDragCleanup.push(draggable({
1265
+ element: handle,
1266
+ canDrag: () => !!this.currentBlockDragSource(),
1267
+ getInitialData: () => {
1268
+ const source = this.currentBlockDragSource();
1269
+ return { type: BLOCK_DRAG_TYPE, sourceAnchorId: source ? this.anchorIdOf(source) : undefined };
1270
+ },
1271
+ onDragStart: () => {
1272
+ this.blockDragging = true;
1273
+ handle.classList.add("docx-block-dragging");
1274
+ this.closeBlockMoveMenu();
1275
+ this.refreshBlockMoveTargets(this.currentBlockDragSource());
1276
+ this.refreshBlockDropTargets();
1277
+ },
1278
+ onDrop: () => {
1279
+ this.blockDragging = false;
1280
+ this.blockDragPointerDown = false;
1281
+ handle.classList.remove("docx-block-dragging");
1282
+ this.hideDropIndicator();
1283
+ },
1284
+ }));
1285
+ this.blockDragCleanup.push(monitorForElements({
1286
+ canMonitor: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
1287
+ onDrop: ({ source, location }) => {
1288
+ this.hideDropIndicator();
1289
+ const drop = location.current.dropTargets[0]?.data;
1290
+ const sourceId = source.data.sourceAnchorId;
1291
+ const targetId = drop?.targetAnchorId;
1292
+ const position = drop?.position === "after" ? "after" : "before";
1293
+ if (typeof sourceId === "string" && typeof targetId === "string")
1294
+ this.moveBlock(sourceId, targetId, position);
1295
+ else
1296
+ // Released outside any valid target. Say so — a silent no-op reads as a broken drag.
1297
+ this.announceBlockMove("Move cancelled — the block was not dropped on a valid position.");
1298
+ },
1299
+ }));
1300
+ // Register on the element that actually scrolls. editRoot is the document flow — in the
1301
+ // ribbon surface the scroller is an ancestor of it, and registering the flow made Pragmatic
1302
+ // log "attached to an element that appears not to be scrollable" on every document open
1303
+ // while doing nothing.
1304
+ const scroller = this.scrollContainer();
1305
+ if (scroller) {
1306
+ this.blockDragCleanup.push(autoScrollForElements({
1307
+ element: scroller,
1308
+ canScroll: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
1309
+ getAllowedAxis: () => "vertical",
1310
+ }));
1311
+ }
1312
+ this.blockDragCleanup.push(autoScrollWindowForElements({
1313
+ canScroll: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
1314
+ getAllowedAxis: () => "vertical",
1315
+ }));
1316
+ this.refreshBlockDropTargets();
1317
+ }
1318
+ teardownBlockDrag() {
1319
+ for (const cleanup of this.blockDragTargetCleanup.splice(0))
1320
+ cleanup();
1321
+ for (const cleanup of this.blockDragCleanup.splice(0))
1322
+ cleanup();
1323
+ this.blockDragHandle?.remove();
1324
+ this.blockDropIndicator?.remove();
1325
+ this.blockMoveMenu?.remove();
1326
+ this.blockMoveLive?.remove();
1327
+ this.blockDragHandle = null;
1328
+ this.blockDropIndicator = null;
1329
+ this.blockMoveMenu = null;
1330
+ this.blockMoveLive = null;
1331
+ this.blockDragSource = null;
1332
+ this.blockDragging = false;
1333
+ this.blockDragPointerDown = false;
1334
+ }
801
1335
  // ─── internals ───────────────────────────────────────────────────────
802
1336
  assertOpen() {
803
1337
  if (this.closed)
@@ -946,11 +1480,16 @@ export class DocxEditor {
946
1480
  wireBlock(el) {
947
1481
  if (!EDITABLE_TAGS.has(el.tagName))
948
1482
  return;
1483
+ if (el.closest("tr[class$='row-del'], tr[class*='row-del ']") ||
1484
+ el.querySelector(":scope > del[class$='move-from'], :scope > del[class*='move-from ']")) {
1485
+ el.setAttribute("contenteditable", "false");
1486
+ return;
1487
+ }
949
1488
  const unid = el.getAttribute("data-anchor");
950
1489
  // Only blocks the markdown projection addresses are editable via the text path. This INCLUDES
951
- // table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
952
- // keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
953
- // table structure. Anything the projection does not index (unstamped content) stays read-only.
1490
+ // table-cell paragraphs (the projection indexes them), so cell text IS editable. Table-aware
1491
+ // key handling keeps structural edits safe while still providing Word-style cell navigation
1492
+ // (see onKeydown). Anything the projection does not index (unstamped content) stays read-only.
954
1493
  // A band block is authoritative via its stamped `data-hf-anchor` even when the unid map
955
1494
  // resolves that unid to a different part (content-addressed unids collide across parts).
956
1495
  if (!unid || !this.anchorIdOf(el))
@@ -1042,7 +1581,7 @@ export class DocxEditor {
1042
1581
  }
1043
1582
  // Plain block: re-render ONLY this block from the live session for canonical HTML. Swapping the
1044
1583
  // just-blurred node here is safe (verified — focus stays on the newly-clicked block).
1045
- const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
1584
+ const html = this.renderBlockHtml(newAnchor);
1046
1585
  if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
1047
1586
  const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1048
1587
  const inBand = this.isBandBlock(el);
@@ -1089,19 +1628,18 @@ export class DocxEditor {
1089
1628
  return;
1090
1629
  }
1091
1630
  }
1092
- // Inside a table cell, structural ops that change the TABLE GRID (cross-cell merge,
1093
- // list-nest, focus-jumping Tab) stay INERT the single-block model can't give them
1094
- // whole-table context. Tab is swallowed (no focus escape / literal tab); Backspace at
1095
- // the cell's start does not merge across the cell boundary (mid-text Backspace still
1096
- // deletes normally). Enter, however, splits the cell paragraph into two paragraphs
1097
- // WITHIN the same cell — the engine keeps the new w:p in the w:tc, the grid is
1098
- // unchanged, so it's safe (it's how a cell holds stacked lines: value over a smaller
1099
- // label, multi-line addresses). (GAP3.)
1631
+ // Inside a table cell, Backspace at the cell's start does not merge across the cell
1632
+ // boundary (mid-text Backspace still deletes normally). Enter splits the cell paragraph
1633
+ // WITHIN the same cell the engine keeps the new w:p in the w:tc, so the grid is unchanged.
1634
+ // Tab navigation is handled explicitly below; at the final cell it uses the existing safe
1635
+ // whole-table row insertion path to match Word's add-a-row behavior.
1100
1636
  const inTableCell = !!el.closest("table");
1101
- // Tab / Shift+Tab on a list item nests / un-nests it (changes list level).
1102
- if (ev.key === "Tab") {
1637
+ // Plain Tab / Shift+Tab moves between table cells. Outside tables it retains list
1638
+ // nest/un-nest behavior. Modified Tab chords remain available to the browser/platform.
1639
+ if (ev.key === "Tab" && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.isComposing) {
1103
1640
  if (inTableCell) {
1104
1641
  ev.preventDefault();
1642
+ this.navigateTableCell(el, ev.shiftKey);
1105
1643
  return;
1106
1644
  }
1107
1645
  if (isListBlock(el)) {
@@ -1136,6 +1674,50 @@ export class DocxEditor {
1136
1674
  }
1137
1675
  }
1138
1676
  }
1677
+ /** Editable cells in visual document order, excluding cells from any nested table. */
1678
+ tableCellsFor(el) {
1679
+ const cell = el.closest("td, th");
1680
+ const table = cell?.closest("table");
1681
+ if (!cell || !table)
1682
+ return null;
1683
+ const cells = Array.from(table.querySelectorAll("td, th"))
1684
+ .filter((candidate) => candidate.closest("table") === table);
1685
+ const index = cells.indexOf(cell);
1686
+ return index >= 0 ? { cells, index } : null;
1687
+ }
1688
+ /** First addressable paragraph in a cell, fenced against nested-table descendants. */
1689
+ editableInCell(cell) {
1690
+ return Array.from(cell.querySelectorAll('[data-anchor][contenteditable="true"]')).find((block) => block.closest("td, th") === cell) ?? null;
1691
+ }
1692
+ /** Word-style cell navigation: Shift+Tab goes back, Tab goes forward, and Tab at the
1693
+ * final cell appends a row before entering its first cell. The first cell is a hard
1694
+ * boundary for Shift+Tab so focus never leaks out of the editor table. */
1695
+ navigateTableCell(el, backwards) {
1696
+ const state = this.tableCellsFor(el);
1697
+ if (!state)
1698
+ return;
1699
+ const adjacent = state.cells[state.index + (backwards ? -1 : 1)];
1700
+ if (adjacent) {
1701
+ const target = this.editableInCell(adjacent);
1702
+ if (target)
1703
+ placeCaretAtOffset(target, 0);
1704
+ return;
1705
+ }
1706
+ if (backwards)
1707
+ return;
1708
+ // The current block may contain uncommitted typing. insertTableRow flushes it before the
1709
+ // structural edit, then reconcile/remount restores focus by block index. Resolve the new
1710
+ // adjacent cell from that fresh DOM rather than retaining a node from the replaced table.
1711
+ this.activeBlock = el;
1712
+ this.insertTableRow("below");
1713
+ const fresh = this.activeBlock ? this.tableCellsFor(this.activeBlock) : null;
1714
+ if (!fresh)
1715
+ return;
1716
+ const target = fresh.cells[fresh.index + 1];
1717
+ const block = target ? this.editableInCell(target) : null;
1718
+ if (block)
1719
+ placeCaretAtOffset(block, 0);
1720
+ }
1139
1721
  /** Shift+Enter: insert an intra-paragraph line break at the caret. Delegates to the
1140
1722
  * native `insertLineBreak` command, which inserts a <br> AND positions the caret
1141
1723
  * after it correctly (handling the browser's bogus trailing-<br> rule) so typing
@@ -1309,11 +1891,27 @@ export class DocxEditor {
1309
1891
  }
1310
1892
  /** Render a block by anchor and parse it into a detached element (null on error). */
1311
1893
  renderInto(anchorId) {
1312
- const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
1894
+ const html = this.renderBlockHtml(anchorId);
1313
1895
  if (html.charCodeAt(0) === 0x7b /* error object */)
1314
1896
  return null;
1315
1897
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1316
1898
  }
1899
+ get renderTrackedChanges() {
1900
+ return this.options.trackedChanges === TrackedChangeMode.RenderInline;
1901
+ }
1902
+ renderBlockHtml(anchorId) {
1903
+ const bridge = this.exports.DocxSessionBridge;
1904
+ if (typeof bridge.RenderBlockHtmlForReview === "function") {
1905
+ return bridge.RenderBlockHtmlForReview(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges);
1906
+ }
1907
+ return bridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
1908
+ }
1909
+ renderPlanJson() {
1910
+ const bridge = this.exports.DocxSessionBridge;
1911
+ return typeof bridge.ListRenderedBlocks === "function"
1912
+ ? bridge.ListRenderedBlocks(this.handle, this.renderTrackedChanges)
1913
+ : bridge.ListBlocks(this.handle);
1914
+ }
1317
1915
  /** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
1318
1916
  * the per-render shell/converter setup is paid once instead of twice, which matters on the
1319
1917
  * Enter path (split renders both halves synchronously under the keystroke). Falls back to
@@ -1323,7 +1921,11 @@ export class DocxEditor {
1323
1921
  const bridge = this.exports.DocxSessionBridge;
1324
1922
  if (typeof bridge.RenderBlocksHtml === "function") {
1325
1923
  try {
1326
- const map = JSON.parse(bridge.RenderBlocksHtml(this.handle, JSON.stringify([a, b]), this.options.cssPrefix, this.options.fabricateClasses));
1924
+ const idsJson = JSON.stringify([a, b]);
1925
+ const json = typeof bridge.RenderBlocksHtmlForReview === "function"
1926
+ ? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
1927
+ : bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
1928
+ const map = JSON.parse(json);
1327
1929
  if (!map.error) {
1328
1930
  const parse = (h) => h
1329
1931
  ? new DOMParser().parseFromString(h, "text/html").body
@@ -2058,6 +2660,11 @@ export class DocxEditor {
2058
2660
  */
2059
2661
  renderFullHtml() {
2060
2662
  const bridge = this.exports.DocxSessionBridge;
2663
+ if (typeof bridge.RenderHtmlForReview === "function") {
2664
+ const html = bridge.RenderHtmlForReview(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges);
2665
+ if (html.charCodeAt(0) !== 0x7b)
2666
+ return html;
2667
+ }
2061
2668
  if (typeof bridge.RenderHtml === "function") {
2062
2669
  const html = bridge.RenderHtml(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale);
2063
2670
  if (html.charCodeAt(0) !== 0x7b /* not an error object */)
@@ -2070,7 +2677,7 @@ export class DocxEditor {
2070
2677
  const bytes = typeof bridge.SaveWithAnchorIds === "function"
2071
2678
  ? bridge.SaveWithAnchorIds(this.handle)
2072
2679
  : bridge.Save(this.handle);
2073
- return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
2680
+ return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges));
2074
2681
  }
2075
2682
  /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
2076
2683
  editableList() {
@@ -2169,7 +2776,7 @@ export class DocxEditor {
2169
2776
  // Refresh the unid → anchor map FIRST: wiring freshly rendered nodes (wireBlock)
2170
2777
  // resolves through it, and the map must reflect the post-op session.
2171
2778
  this.refreshAnchorMap();
2172
- const plan = JSON.parse(bridge.ListBlocks(this.handle));
2779
+ const plan = JSON.parse(this.renderPlanJson());
2173
2780
  if (plan.error)
2174
2781
  return this.bail(`plan error: ${plan.error}`);
2175
2782
  const oldNodes = this.bodyUnitNodes();
@@ -2183,14 +2790,21 @@ export class DocxEditor {
2183
2790
  if (fnState === null || enState === null)
2184
2791
  return this.bail("notes container unstampable/missing");
2185
2792
  // One batch render for everything that needs fresh HTML.
2186
- const addedBodyIds = bodyDiff.added.map((j) => plan.body[j].id);
2793
+ const movedBodyNew = new Set(bodyDiff.moved.map((m) => m.newIndex));
2794
+ const addedBodyIds = bodyDiff.added
2795
+ .filter((j) => !movedBodyNew.has(j))
2796
+ .map((j) => plan.body[j].id);
2187
2797
  const addedNoteIds = fnState.diff.added
2188
2798
  .map((j) => plan.footnotes[j].id)
2189
2799
  .concat(enState.diff.added.map((j) => plan.endnotes[j].id));
2190
2800
  const allIds = addedBodyIds.concat(addedNoteIds);
2191
2801
  let rendered = {};
2192
2802
  if (allIds.length > 0) {
2193
- rendered = JSON.parse(bridge.RenderBlocksHtml(this.handle, JSON.stringify(allIds), this.options.cssPrefix, this.options.fabricateClasses));
2803
+ const idsJson = JSON.stringify(allIds);
2804
+ const renderedJson = typeof bridge.RenderBlocksHtmlForReview === "function"
2805
+ ? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
2806
+ : bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
2807
+ rendered = JSON.parse(renderedJson);
2194
2808
  if (rendered.error)
2195
2809
  return this.bail(`render error: ${rendered.error}`);
2196
2810
  for (const id of allIds)
@@ -2209,7 +2823,7 @@ export class DocxEditor {
2209
2823
  return el;
2210
2824
  };
2211
2825
  const freshBody = new Map();
2212
- for (const j of bodyDiff.added) {
2826
+ for (const j of bodyDiff.added.filter((j) => !movedBodyNew.has(j))) {
2213
2827
  const el = parse(rendered[plan.body[j].id]);
2214
2828
  if (!el)
2215
2829
  return this.bail(`unparseable render: ${plan.body[j].id}`);
@@ -2267,16 +2881,6 @@ export class DocxEditor {
2267
2881
  * bail-reason string (parent ambiguity, order violation, wrapper semantics) — the
2268
2882
  * session is already correct, so bailing just means a full repaint. */
2269
2883
  applyBodyDiff(oldNodes, units, diff, fresh) {
2270
- // Kept nodes must appear in increasing old order (no move support in v1).
2271
- let lastOld = -1;
2272
- for (let j = 0; j < units.length; j++) {
2273
- const oi = diff.keep.get(j);
2274
- if (oi === undefined)
2275
- continue;
2276
- if (oi < lastOld)
2277
- return "kept order violation";
2278
- lastOld = oi;
2279
- }
2280
2884
  // In-place substitutions first: replace at WRAPPER level so a table swaps with its
2281
2885
  // alignment div. A LEAF render replacing a wrapped node would break the wrapper's
2282
2886
  // semantics (border <div> grouping) — that is remount territory.
@@ -2307,31 +2911,34 @@ export class DocxEditor {
2307
2911
  }
2308
2912
  this.wireUnit(freshRoot, units[nj]);
2309
2913
  }
2310
- // Pure inserts against kept/substituted neighbors (at wrapper level).
2311
- const pureAdded = diff.added.filter((j) => !subOldByNew.has(j));
2312
- const pureRemoved = diff.removed.filter((i) => !diff.substituted.some((s) => s.oldIndex === i));
2914
+ const movedOldByNew = new Map(diff.moved.map((m) => [m.newIndex, m.oldIndex]));
2915
+ const movedNew = new Set(diff.moved.map((m) => m.newIndex));
2916
+ const movedOld = new Set(diff.moved.map((m) => m.oldIndex));
2917
+ const pureAdded = diff.added.filter((j) => !subOldByNew.has(j) && !movedNew.has(j));
2918
+ const pureRemoved = diff.removed.filter((i) => !diff.substituted.some((s) => s.oldIndex === i) && !movedOld.has(i));
2313
2919
  const nodeAt = (j) => {
2314
2920
  const oi = diff.keep.get(j);
2315
2921
  if (oi !== undefined)
2316
2922
  return oldNodes[oi];
2923
+ const movedOi = movedOldByNew.get(j);
2924
+ if (movedOi !== undefined)
2925
+ return oldNodes[movedOi];
2317
2926
  if (subOldByNew.has(j))
2318
2927
  return fresh.get(j);
2319
2928
  const f = fresh.get(j);
2320
2929
  return f && f.isConnected ? f : null;
2321
2930
  };
2931
+ // Preserve the established incremental insertion path: body render output may
2932
+ // legitimately use several wrapper parents (for example border groups), so it is
2933
+ // not valid to normalize the whole body against one common parent.
2322
2934
  for (const j of pureAdded) {
2323
2935
  const el = fresh.get(j);
2324
2936
  let prev = null;
2325
2937
  for (let k = j - 1; k >= 0 && !prev; k--)
2326
2938
  prev = nodeAt(k);
2327
2939
  let next = null;
2328
- for (let k = j + 1; k < units.length && !next; k++) {
2329
- const oi = diff.keep.get(k);
2330
- if (oi !== undefined)
2331
- next = oldNodes[oi];
2332
- else if (subOldByNew.has(k))
2333
- next = fresh.get(k);
2334
- }
2940
+ for (let k = j + 1; k < units.length && !next; k++)
2941
+ next = nodeAt(k);
2335
2942
  const prevW = prev ? this.unitWrapperOf(prev) : null;
2336
2943
  const nextW = next ? this.unitWrapperOf(next) : null;
2337
2944
  if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
@@ -2341,13 +2948,43 @@ export class DocxEditor {
2341
2948
  else if (nextW)
2342
2949
  nextW.before(el);
2343
2950
  else
2344
- return "insert with no anchored neighbor"; // empty container — nowhere provably correct
2951
+ return "insert with no anchored neighbor";
2345
2952
  this.wireUnit(el, units[j]);
2346
2953
  }
2347
- // Pure removals last, taking now-empty generated wrappers with them.
2954
+ // Pure removals take their now-empty generated wrappers with them. Exact-token
2955
+ // move sources remain live even though the LCS also reports them as removed.
2348
2956
  for (const i of pureRemoved) {
2349
- const wrapper = this.unitWrapperOf(oldNodes[i]);
2350
- wrapper.remove();
2957
+ this.unitWrapperOf(oldNodes[i]).remove();
2958
+ }
2959
+ // A block move changes one exact-token unit's slot. Move only that existing
2960
+ // wrapper beside its final neighbor; this preserves identity without disturbing
2961
+ // unrelated nodes or assuming every rendered unit shares one DOM parent.
2962
+ for (const { newIndex } of [...diff.moved].sort((a, b) => a.newIndex - b.newIndex)) {
2963
+ const moving = nodeAt(newIndex);
2964
+ if (!moving?.isConnected)
2965
+ return "move source is not connected";
2966
+ const movingW = this.unitWrapperOf(moving);
2967
+ let prevW = null;
2968
+ for (let k = newIndex - 1; k >= 0 && !prevW; k--) {
2969
+ const n = nodeAt(k);
2970
+ if (n?.isConnected)
2971
+ prevW = this.unitWrapperOf(n);
2972
+ }
2973
+ let nextW = null;
2974
+ for (let k = newIndex + 1; k < units.length && !nextW; k++) {
2975
+ const n = nodeAt(k);
2976
+ if (n?.isConnected)
2977
+ nextW = this.unitWrapperOf(n);
2978
+ }
2979
+ if (prevW && prevW !== movingW && prevW.parentElement === movingW.parentElement) {
2980
+ prevW.after(movingW);
2981
+ }
2982
+ else if (nextW && nextW !== movingW && nextW.parentElement === movingW.parentElement) {
2983
+ nextW.before(movingW);
2984
+ }
2985
+ else {
2986
+ return "move neighbors in different parents";
2987
+ }
2351
2988
  }
2352
2989
  return true;
2353
2990
  }
@@ -2490,7 +3127,7 @@ export class DocxEditor {
2490
3127
  if (typeof bridge.ListBlocks !== "function")
2491
3128
  return;
2492
3129
  try {
2493
- const plan = JSON.parse(bridge.ListBlocks(this.handle));
3130
+ const plan = JSON.parse(this.renderPlanJson());
2494
3131
  if (plan.error)
2495
3132
  return;
2496
3133
  // Positional pairing: a fresh full mount renders exactly the plan's units in
@@ -2527,6 +3164,7 @@ export class DocxEditor {
2527
3164
  * block's content-hashed unid changes across the save/reproject a remount performs.
2528
3165
  */
2529
3166
  remount(focusIndex = -1, caretAtEnd = false) {
3167
+ this.teardownBlockDrag();
2530
3168
  this.refreshAnchorMap();
2531
3169
  const fullHtml = this.renderFullHtml();
2532
3170
  this.activeBlock = null;
@@ -2545,6 +3183,7 @@ export class DocxEditor {
2545
3183
  // A remount rebuilds the body from the live session; re-resolve the section so undo/redo of a
2546
3184
  // section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
2547
3185
  this.syncRegionToBody(this.activeBlock ?? undefined);
3186
+ this.setupBlockDrag();
2548
3187
  }
2549
3188
  }
2550
3189
  //# sourceMappingURL=editor.js.map