docxodus 9.2.0 → 9.4.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 (38) hide show
  1. package/dist/editor-reconcile.d.ts +5 -0
  2. package/dist/editor-reconcile.d.ts.map +1 -1
  3. package/dist/editor-reconcile.js +23 -2
  4. package/dist/editor-reconcile.js.map +1 -1
  5. package/dist/editor.bundle.js +3338 -162
  6. package/dist/editor.d.ts +120 -0
  7. package/dist/editor.d.ts.map +1 -1
  8. package/dist/editor.js +775 -57
  9. package/dist/editor.js.map +1 -1
  10. package/dist/embed.bundle.js +3401 -178
  11. package/dist/embed.d.ts.map +1 -1
  12. package/dist/embed.iife.js +3388 -178
  13. package/dist/embed.js +8 -0
  14. package/dist/embed.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/ribbon.js +4 -0
  19. package/dist/ribbon.js.map +1 -1
  20. package/dist/session.bundle.js +31 -1
  21. package/dist/session.d.ts +22 -2
  22. package/dist/session.d.ts.map +1 -1
  23. package/dist/session.js +27 -1
  24. package/dist/session.js.map +1 -1
  25. package/dist/types.d.ts +18 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js.map +1 -1
  28. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  29. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  30. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  31. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  32. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  33. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  34. package/dist/wasm/_framework/dotnet.boot.js +5 -5
  35. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  36. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  37. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  38. package/package.json +5 -1
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,27 @@ 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
+ /** Memoized `ValidMoveTargets` answers, keyed by source anchor and dropped whenever an edit
569
+ * lands. The legal-target set is a property of the document, so hovering back and forth over
570
+ * the same blocks between edits must not re-ask the engine. */
571
+ this.blockMoveTargetCache = new Map();
572
+ /** Cancels the pending idle prefetch of the hovered block's targets — see `showBlockHandle`. */
573
+ this.blockMoveTargetPrefetch = null;
574
+ /** Why the last move was refused, verbatim from the engine — diagnostics, not announcement copy. */
575
+ this.lastMoveError = null;
509
576
  /**
510
577
  * The last real (non-collapsed) text selection inside an editable block. A toolbar control that
511
578
  * must take focus to be used — the font-size combobox — blurs the block and collapses the live
@@ -724,7 +791,11 @@ export class DocxEditor {
724
791
  paginated: options.paginated ?? false,
725
792
  scale: options.scale ?? 1,
726
793
  headerFooter: options.headerFooter ?? false,
794
+ blockDrag: options.blockDrag ?? false,
795
+ trackedChanges: options.trackedChanges ?? TrackedChangeMode.Accept,
796
+ revisionAuthor: options.revisionAuthor ?? "docxodus",
727
797
  onEdit: options.onEdit,
798
+ onMove: options.onMove,
728
799
  };
729
800
  // NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
730
801
  // projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
@@ -732,17 +803,22 @@ export class DocxEditor {
732
803
  // save/re-render hop, and it asks for that per call via SaveWithAnchorIds.
733
804
  // emitMarkdownPatch off: the editor re-renders from HTML, never from markdown patches,
734
805
  // so paying a whole-document re-projection per op would be dead weight.
735
- const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"emitMarkdownPatch":false}');
806
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, JSON.stringify({
807
+ emitMarkdownPatch: false,
808
+ trackedChanges: trackedChangeWireName(opts.trackedChanges),
809
+ revisionAuthor: opts.revisionAuthor,
810
+ }));
736
811
  const editor = new DocxEditor(container, exports, handle, opts);
737
812
  editor.refreshAnchorMap();
738
813
  if (opts.headerFooter)
739
814
  editor.createRegion();
740
- const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
815
+ const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale, opts.trackedChanges === TrackedChangeMode.RenderInline));
741
816
  if (opts.paginated)
742
817
  editor.mountPaginated(fullHtml);
743
818
  else
744
819
  editor.mountHtml(fullHtml);
745
820
  editor.syncRegionToBody();
821
+ editor.setupBlockDrag();
746
822
  return editor;
747
823
  }
748
824
  /**
@@ -770,6 +846,7 @@ export class DocxEditor {
770
846
  document.removeEventListener("mouseup", this.onMouseUp, true);
771
847
  }
772
848
  this.clearDragSelection();
849
+ this.teardownBlockDrag();
773
850
  this.exports.DocxSessionBridge.CloseSession(this.handle);
774
851
  }
775
852
  /**
@@ -798,6 +875,522 @@ export class DocxEditor {
798
875
  get sessionHandle() {
799
876
  return this.handle;
800
877
  }
878
+ /** Move one top-level body block relative to another and repaint from the live session. */
879
+ moveBlock(sourceAnchorId, targetAnchorId, position) {
880
+ this.assertOpen();
881
+ const bridge = this.exports.DocxSessionBridge;
882
+ if (typeof bridge.MoveBlock !== "function")
883
+ return false;
884
+ const res = this.parseEdit(bridge.MoveBlock(this.handle, sourceAnchorId, targetAnchorId, position));
885
+ if (!res.success) {
886
+ // The engine's message names an OOXML construct ("a section-break paragraph"); the live
887
+ // region is read aloud, so announce the outcome and keep the raw reason for debugging.
888
+ this.lastMoveError = res.error?.message ?? null;
889
+ this.announceBlockMove("This block cannot be moved there.");
890
+ return false;
891
+ }
892
+ if ((res.created?.length ?? 0) === 0 &&
893
+ (res.modified?.length ?? 0) === 0 &&
894
+ (res.removed?.length ?? 0) === 0) {
895
+ this.announceBlockMove("The block is already in that position.");
896
+ return true;
897
+ }
898
+ const destination = res.created?.[0] ?? res.modified?.[0];
899
+ // Both modes reconcile. A direct move relocates the existing element, so the reconciler moves
900
+ // the exact DOM node it already has. Review rendering additionally rewrites the SOURCE in
901
+ // place — it becomes the move-from half — and that is visible to the diff because the plan
902
+ // signs every unit with a content hash, so the source diffs as an in-place substitution.
903
+ // This used to remount, which on a real charter is seconds for a one-block change.
904
+ this.reconcile();
905
+ const moved = destination
906
+ ? this.bodyUnitNodes().find((el) => el.getAttribute("data-anchor") === destination.unid)
907
+ : null;
908
+ if (moved) {
909
+ moved.classList.add("docx-block-move-flash");
910
+ moved.addEventListener("animationend", () => moved.classList.remove("docx-block-move-flash"), {
911
+ once: true,
912
+ });
913
+ const focusTarget = EDITABLE_TAGS.has(moved.tagName)
914
+ ? moved
915
+ : moved.querySelector('[data-anchor][contenteditable="true"]');
916
+ if (focusTarget) {
917
+ this.activeBlock = focusTarget;
918
+ focusTarget.focus({ preventScroll: true });
919
+ }
920
+ }
921
+ this.options.onMove?.({
922
+ sourceAnchorId,
923
+ destinationAnchorId: destination?.id ?? sourceAnchorId,
924
+ });
925
+ this.announceBlockMove("Block moved.");
926
+ return true;
927
+ }
928
+ /** Resolve any rendered descendant to its top-level body move unit (tables stay whole). */
929
+ blockUnitOf(target) {
930
+ const node = target instanceof Node ? target : null;
931
+ const el = node?.nodeType === Node.ELEMENT_NODE
932
+ ? node
933
+ : node?.parentElement;
934
+ if (!el || !this.editRoot.contains(el))
935
+ return null;
936
+ // Climb rather than scan: this runs on every pointer move over the document, and listing
937
+ // every anchored node to find the one containing the pointer is linear in the document.
938
+ let unit = null;
939
+ for (let candidate = el.closest("[data-anchor]"); candidate && this.editRoot.contains(candidate); candidate = candidate.parentElement?.closest("[data-anchor]") ?? null) {
940
+ unit = candidate;
941
+ }
942
+ if (!unit || unit.closest("section.footnotes, section.endnotes"))
943
+ return null;
944
+ return unit;
945
+ }
946
+ isMovableBlockUnit(unit) {
947
+ if (!unit || !this.anchorIdOf(unit))
948
+ return false;
949
+ // A source representation of an existing tracked move/deletion is evidence, not a new
950
+ // editable block. Its accepted counterpart remains draggable.
951
+ if (unit.matches("[class$='row-del'], [class*='row-del ']") ||
952
+ unit.querySelector("tr[class$='row-del'], tr[class*='row-del '], del[class$='move-from'], del[class*='move-from ']"))
953
+ return false;
954
+ return unit.tagName === "TABLE" || EDITABLE_TAGS.has(unit.tagName);
955
+ }
956
+ currentBlockDragSource() {
957
+ if (this.isMovableBlockUnit(this.blockDragSource) && this.blockDragSource.isConnected)
958
+ return this.blockDragSource;
959
+ const fromActive = this.blockUnitOf(this.activeBlock);
960
+ if (this.isMovableBlockUnit(fromActive)) {
961
+ this.blockDragSource = fromActive;
962
+ return fromActive;
963
+ }
964
+ return null;
965
+ }
966
+ showBlockHandle(unit) {
967
+ const handle = this.blockDragHandle;
968
+ if (!handle || !this.isMovableBlockUnit(unit))
969
+ return;
970
+ const changed = unit !== this.blockDragSource;
971
+ this.blockDragSource = unit;
972
+ // A block the engine will not move anywhere — one owning a section break, or already carrying
973
+ // revision markup a tracked move would have to re-wrap — gets no handle rather than a handle
974
+ // that always fails. Asking costs an engine round trip, so hovering only ever CONSUMES a
975
+ // cached answer and schedules the ask for idle time; the drag start and the menu ask for
976
+ // real. Merely moving the pointer across the document must not run document-scale work.
977
+ const known = this.blockMoveTargetsFor(unit, { cachedOnly: true });
978
+ if (known?.size === 0) {
979
+ handle.style.display = "none";
980
+ return;
981
+ }
982
+ if (changed && known === undefined)
983
+ this.prefetchBlockMoveTargets(unit);
984
+ const rect = unit.getBoundingClientRect();
985
+ handle.style.display = "flex";
986
+ handle.style.left = `${Math.max(4, rect.left - 32)}px`;
987
+ handle.style.top = `${Math.max(4, rect.top + (unit.tagName === "TABLE" ? 6 : Math.max(0, (rect.height - 28) / 2)))}px`;
988
+ const preview = (unit.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 48);
989
+ handle.setAttribute("aria-label", preview ? `Move block: ${preview}` : "Move block");
990
+ // Drop targets are registered when a drag actually begins (and once at mount), not on
991
+ // hover: re-registering one listener per body block every time the pointer crosses a
992
+ // paragraph boundary is work no drop can use.
993
+ }
994
+ hideBlockHandle() {
995
+ if (this.blockDragging || this.blockMoveMenu?.style.display === "block")
996
+ return;
997
+ if (this.blockDragHandle)
998
+ this.blockDragHandle.style.display = "none";
999
+ }
1000
+ positionBlockHandle() {
1001
+ const source = this.currentBlockDragSource();
1002
+ if (source && this.blockDragHandle?.style.display !== "none")
1003
+ this.showBlockHandle(source);
1004
+ }
1005
+ showDropIndicator(target, position) {
1006
+ const indicator = this.blockDropIndicator;
1007
+ if (!indicator)
1008
+ return;
1009
+ const rect = this.unitWrapperOf(target).getBoundingClientRect();
1010
+ indicator.style.display = "block";
1011
+ indicator.style.left = `${rect.left}px`;
1012
+ indicator.style.top = `${position === "before" ? rect.top - 1 : rect.bottom - 1}px`;
1013
+ indicator.style.width = `${Math.max(24, rect.width)}px`;
1014
+ }
1015
+ hideDropIndicator() {
1016
+ if (this.blockDropIndicator)
1017
+ this.blockDropIndicator.style.display = "none";
1018
+ }
1019
+ announceBlockMove(message) {
1020
+ const live = this.blockMoveLive;
1021
+ if (!live)
1022
+ return;
1023
+ live.textContent = "";
1024
+ this.container.ownerDocument.defaultView?.setTimeout(() => { live.textContent = message; }, 0);
1025
+ }
1026
+ /**
1027
+ * Ask the engine which anchors the current source may move next to. One call per drag source,
1028
+ * so the UI offers only drops MoveBlock will accept — a document with section breaks is
1029
+ * partitioned into regions, and drawing an indicator across one only to fail the drop was the
1030
+ * behaviour this replaces. Null (no bridge support) keeps the previous offer-everything path.
1031
+ */
1032
+ refreshBlockMoveTargets(source) {
1033
+ this.blockMoveTargets = source ? this.blockMoveTargetsFor(source) ?? null : null;
1034
+ }
1035
+ /**
1036
+ * This block's legal destinations, from the memo when it is there. Returns `undefined` — not
1037
+ * `null` — for "not asked yet", so a caller can tell an unknown answer from the engine's
1038
+ * "no bridge support, offer everything" one.
1039
+ */
1040
+ blockMoveTargetsFor(source, options = {}) {
1041
+ const bridge = this.exports.DocxSessionBridge;
1042
+ const sourceId = this.anchorIdOf(source);
1043
+ if (!sourceId || typeof bridge.ValidMoveTargets !== "function")
1044
+ return null;
1045
+ if (this.blockMoveTargetCache.has(sourceId))
1046
+ return this.blockMoveTargetCache.get(sourceId);
1047
+ if (options.cachedOnly)
1048
+ return undefined;
1049
+ let targets;
1050
+ try {
1051
+ const parsed = JSON.parse(bridge.ValidMoveTargets(this.handle, sourceId));
1052
+ targets = new Map(parsed.map((t) => [t.anchorId, { before: t.before, after: t.after }]));
1053
+ }
1054
+ catch {
1055
+ targets = null;
1056
+ }
1057
+ this.blockMoveTargetCache.set(sourceId, targets);
1058
+ return targets;
1059
+ }
1060
+ /**
1061
+ * Ask for the hovered block's destinations off the interaction path, and hide the handle if
1062
+ * the answer comes back empty and that block is still the one under the pointer. The handle
1063
+ * therefore appears immediately on hover and withdraws a beat later on the rare immovable
1064
+ * block, instead of every hover paying for the query up front.
1065
+ */
1066
+ prefetchBlockMoveTargets(source) {
1067
+ const view = this.container.ownerDocument.defaultView;
1068
+ if (!view)
1069
+ return;
1070
+ this.blockMoveTargetPrefetch?.();
1071
+ const run = () => {
1072
+ this.blockMoveTargetPrefetch = null;
1073
+ // The pointer has moved on (or the DOM was repainted) — the answer is no longer wanted.
1074
+ if (this.closed || !source.isConnected || this.blockDragSource !== source)
1075
+ return;
1076
+ if (this.blockMoveTargetsFor(source)?.size === 0 && this.blockDragHandle)
1077
+ this.blockDragHandle.style.display = "none";
1078
+ };
1079
+ const idle = view;
1080
+ if (idle.requestIdleCallback && idle.cancelIdleCallback) {
1081
+ const id = idle.requestIdleCallback(run, { timeout: 500 });
1082
+ this.blockMoveTargetPrefetch = () => idle.cancelIdleCallback(id);
1083
+ }
1084
+ else {
1085
+ const id = view.setTimeout(run, 0);
1086
+ this.blockMoveTargetPrefetch = () => view.clearTimeout(id);
1087
+ }
1088
+ }
1089
+ /**
1090
+ * Whether `unit` is a legal destination for the current drag source — for a SPECIFIC side when
1091
+ * one is given. A cross-block range or a section break between the blocks can make one side
1092
+ * legal and the other not, so "this target is reachable" is not enough to pick a position.
1093
+ */
1094
+ isValidMoveTarget(unit, position) {
1095
+ if (!this.blockMoveTargets)
1096
+ return true; // no bridge support: engine stays the only gate
1097
+ const id = this.anchorIdOf(unit);
1098
+ const sides = id ? this.blockMoveTargets.get(id) : undefined;
1099
+ if (!sides)
1100
+ return false;
1101
+ return position ? sides[position] : sides.before || sides.after;
1102
+ }
1103
+ /**
1104
+ * The side of `unit` a drop at `clientY` should land on: the half the pointer is in, snapped to
1105
+ * the other side when only that one is legal. Snapping rather than refusing keeps a reachable
1106
+ * target usable — the illegal side is usually illegal only because a section break or a
1107
+ * cross-block range sits between the two blocks on that side.
1108
+ */
1109
+ dropPositionFor(unit, clientY) {
1110
+ const rect = unit.getBoundingClientRect();
1111
+ const preferred = clientY < rect.top + rect.height / 2 ? "before" : "after";
1112
+ if (this.isValidMoveTarget(unit, preferred))
1113
+ return preferred;
1114
+ const other = preferred === "before" ? "after" : "before";
1115
+ return this.isValidMoveTarget(unit, other) ? other : preferred;
1116
+ }
1117
+ refreshBlockDropTargets() {
1118
+ for (const cleanup of this.blockDragTargetCleanup.splice(0))
1119
+ cleanup();
1120
+ if (!this.blockDragHandle || this.options.paginated)
1121
+ return;
1122
+ for (const unit of this.bodyUnitNodes().filter((el) => this.isMovableBlockUnit(el))) {
1123
+ this.blockDragTargetCleanup.push(dropTargetForElements({
1124
+ element: unit,
1125
+ // A target the engine would refuse is not a drop target at all, so Pragmatic never
1126
+ // fires onDragEnter for it and no indicator is drawn over it.
1127
+ canDrop: ({ source }) => source.data.type === BLOCK_DRAG_TYPE && source.data.sourceAnchorId !== this.anchorIdOf(unit) &&
1128
+ this.isValidMoveTarget(unit),
1129
+ getData: ({ input }) => ({
1130
+ type: BLOCK_DRAG_TYPE,
1131
+ targetAnchorId: this.anchorIdOf(unit),
1132
+ position: this.dropPositionFor(unit, input.clientY),
1133
+ targetElement: unit,
1134
+ }),
1135
+ onDragEnter: ({ self }) => {
1136
+ const pos = self.data.position === "after" ? "after" : "before";
1137
+ this.showDropIndicator(unit, pos);
1138
+ },
1139
+ onDrag: ({ self }) => {
1140
+ const pos = self.data.position === "after" ? "after" : "before";
1141
+ this.showDropIndicator(unit, pos);
1142
+ },
1143
+ onDragLeave: () => this.hideDropIndicator(),
1144
+ }));
1145
+ }
1146
+ }
1147
+ closeBlockMoveMenu(restoreFocus = false) {
1148
+ if (!this.blockMoveMenu || !this.blockDragHandle)
1149
+ return;
1150
+ this.blockMoveMenu.style.display = "none";
1151
+ this.blockDragHandle.setAttribute("aria-expanded", "false");
1152
+ this.blockDragHandle.setAttribute("aria-pressed", "false");
1153
+ if (restoreFocus)
1154
+ this.blockDragHandle.focus({ preventScroll: true });
1155
+ }
1156
+ openBlockMoveMenu() {
1157
+ const menu = this.blockMoveMenu;
1158
+ const handle = this.blockDragHandle;
1159
+ const source = this.currentBlockDragSource();
1160
+ if (!menu || !handle || !source)
1161
+ return;
1162
+ this.refreshBlockMoveTargets(source);
1163
+ menu.querySelectorAll("button[data-move]").forEach((button) => {
1164
+ button.disabled = !this.blockMoveDestination(source, button.dataset.move ?? "");
1165
+ });
1166
+ const rect = handle.getBoundingClientRect();
1167
+ menu.style.display = "block";
1168
+ menu.style.left = `${Math.max(4, rect.right + 6)}px`;
1169
+ menu.style.top = `${Math.max(4, rect.top)}px`;
1170
+ handle.setAttribute("aria-expanded", "true");
1171
+ handle.setAttribute("aria-pressed", "true");
1172
+ menu.querySelector("button:not(:disabled)")?.focus({ preventScroll: true });
1173
+ }
1174
+ /**
1175
+ * Resolve a move-menu action to the block it should land against, considering only destinations
1176
+ * the engine accepts. "Top"/"bottom" therefore mean the ends of the source's own movable region
1177
+ * — on a document with section breaks that is the section, not the document, which is what makes
1178
+ * the commands work at all there instead of always failing.
1179
+ */
1180
+ blockMoveDestination(source, action) {
1181
+ const units = this.bodyUnitNodes().filter((el) => this.isMovableBlockUnit(el));
1182
+ const index = units.indexOf(source);
1183
+ if (index < 0)
1184
+ return null;
1185
+ // Only pairs the engine accepts: a target can be reachable on one side and refused on the
1186
+ // other, so the SIDE is part of what makes a candidate valid.
1187
+ const position = action === "up" || action === "top" ? "before" : "after";
1188
+ const side = position === "before" ? units.slice(0, index) : units.slice(index + 1);
1189
+ const candidates = side.filter((el) => this.isValidMoveTarget(el, position));
1190
+ if (candidates.length === 0)
1191
+ return null;
1192
+ if (action === "up")
1193
+ return { target: candidates[candidates.length - 1], position };
1194
+ if (action === "down")
1195
+ return { target: candidates[0], position };
1196
+ if (action === "top")
1197
+ return { target: candidates[0], position };
1198
+ if (action === "bottom")
1199
+ return { target: candidates[candidates.length - 1], position };
1200
+ return null;
1201
+ }
1202
+ runBlockMoveMenuAction(action) {
1203
+ const source = this.currentBlockDragSource();
1204
+ if (!source)
1205
+ return;
1206
+ const destination = this.blockMoveDestination(source, action);
1207
+ this.closeBlockMoveMenu();
1208
+ if (!destination)
1209
+ return;
1210
+ const sourceId = this.anchorIdOf(source);
1211
+ const targetId = this.anchorIdOf(destination.target);
1212
+ if (sourceId && targetId)
1213
+ this.moveBlock(sourceId, targetId, destination.position);
1214
+ }
1215
+ /** The nearest ancestor that actually scrolls the document flow, for drag autoscroll. */
1216
+ scrollContainer() {
1217
+ const view = this.container.ownerDocument.defaultView;
1218
+ for (let el = this.editRoot; el; el = el.parentElement) {
1219
+ const overflowY = view?.getComputedStyle(el).overflowY ?? "visible";
1220
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight)
1221
+ return el;
1222
+ }
1223
+ return null;
1224
+ }
1225
+ /** Mount one floating handle; PDD owns pointer dragging while the menu owns keyboard moves. */
1226
+ setupBlockDrag() {
1227
+ if (!this.options.blockDrag || this.options.paginated || this.closed || this.blockDragHandle)
1228
+ return;
1229
+ const doc = this.container.ownerDocument;
1230
+ ensureBlockDragStyles(doc);
1231
+ const handle = doc.createElement("div");
1232
+ handle.className = "docx-block-handle";
1233
+ handle.textContent = "⠿";
1234
+ handle.tabIndex = 0;
1235
+ handle.setAttribute("role", "button");
1236
+ handle.setAttribute("contenteditable", "false");
1237
+ handle.setAttribute("aria-haspopup", "menu");
1238
+ handle.setAttribute("aria-expanded", "false");
1239
+ handle.setAttribute("aria-pressed", "false");
1240
+ const indicator = doc.createElement("div");
1241
+ indicator.className = "docx-block-drop-indicator";
1242
+ const menu = doc.createElement("div");
1243
+ menu.className = "docx-block-move-menu";
1244
+ menu.setAttribute("role", "menu");
1245
+ menu.innerHTML = `
1246
+ <button type="button" role="menuitem" data-move="up">Move up</button>
1247
+ <button type="button" role="menuitem" data-move="down">Move down</button>
1248
+ <button type="button" role="menuitem" data-move="top">Move to top</button>
1249
+ <button type="button" role="menuitem" data-move="bottom">Move to bottom</button>`;
1250
+ const live = doc.createElement("div");
1251
+ live.className = "docx-block-live-region";
1252
+ live.setAttribute("role", "status");
1253
+ live.setAttribute("aria-live", "polite");
1254
+ this.container.append(handle, indicator, menu, live);
1255
+ this.blockDragHandle = handle;
1256
+ this.blockDropIndicator = indicator;
1257
+ this.blockMoveMenu = menu;
1258
+ this.blockMoveLive = live;
1259
+ const onPointerMove = (event) => {
1260
+ // Keep the floating draggable stationary between pointerdown and native dragstart.
1261
+ // Repositioning it under the pointer during the browser's drag threshold cancels
1262
+ // drag initiation before PDD can receive dragstart.
1263
+ if (this.blockDragging || this.blockDragPointerDown)
1264
+ return;
1265
+ const unit = this.blockUnitOf(event.target);
1266
+ if (this.isMovableBlockUnit(unit))
1267
+ this.showBlockHandle(unit);
1268
+ };
1269
+ const onFocusIn = (event) => {
1270
+ const unit = this.blockUnitOf(event.target);
1271
+ if (this.isMovableBlockUnit(unit))
1272
+ this.showBlockHandle(unit);
1273
+ };
1274
+ const onPointerLeave = (event) => {
1275
+ const next = event.relatedTarget instanceof Node ? event.relatedTarget : null;
1276
+ if (next && (handle.contains(next) || menu.contains(next)))
1277
+ return;
1278
+ this.hideBlockHandle();
1279
+ };
1280
+ const onViewportChange = () => this.positionBlockHandle();
1281
+ this.container.addEventListener("pointermove", onPointerMove);
1282
+ this.container.addEventListener("focusin", onFocusIn);
1283
+ this.container.addEventListener("pointerleave", onPointerLeave);
1284
+ doc.addEventListener("scroll", onViewportChange, true);
1285
+ doc.defaultView?.addEventListener("resize", onViewportChange);
1286
+ 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));
1287
+ const onHandleClick = () => {
1288
+ if (menu.style.display === "block")
1289
+ this.closeBlockMoveMenu();
1290
+ else
1291
+ this.openBlockMoveMenu();
1292
+ };
1293
+ const onHandlePointerDown = () => { this.blockDragPointerDown = true; };
1294
+ const onPointerUp = () => { this.blockDragPointerDown = false; };
1295
+ const onHandleKeydown = (event) => {
1296
+ if (event.key !== "Enter" && event.key !== " ")
1297
+ return;
1298
+ event.preventDefault();
1299
+ onHandleClick();
1300
+ };
1301
+ const onMenuClick = (event) => {
1302
+ const button = event.target?.closest("button[data-move]");
1303
+ if (button && !button.disabled)
1304
+ this.runBlockMoveMenuAction(button.dataset.move ?? "");
1305
+ };
1306
+ const onMenuKeydown = (event) => {
1307
+ if (event.key === "Escape") {
1308
+ event.preventDefault();
1309
+ this.closeBlockMoveMenu(true);
1310
+ }
1311
+ };
1312
+ handle.addEventListener("click", onHandleClick);
1313
+ handle.addEventListener("pointerdown", onHandlePointerDown);
1314
+ handle.addEventListener("keydown", onHandleKeydown);
1315
+ menu.addEventListener("click", onMenuClick);
1316
+ menu.addEventListener("keydown", onMenuKeydown);
1317
+ doc.addEventListener("pointerup", onPointerUp, true);
1318
+ doc.addEventListener("pointercancel", onPointerUp, true);
1319
+ 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));
1320
+ this.blockDragCleanup.push(draggable({
1321
+ element: handle,
1322
+ canDrag: () => !!this.currentBlockDragSource(),
1323
+ getInitialData: () => {
1324
+ const source = this.currentBlockDragSource();
1325
+ return { type: BLOCK_DRAG_TYPE, sourceAnchorId: source ? this.anchorIdOf(source) : undefined };
1326
+ },
1327
+ onDragStart: () => {
1328
+ this.blockDragging = true;
1329
+ handle.classList.add("docx-block-dragging");
1330
+ this.closeBlockMoveMenu();
1331
+ this.refreshBlockMoveTargets(this.currentBlockDragSource());
1332
+ this.refreshBlockDropTargets();
1333
+ },
1334
+ onDrop: () => {
1335
+ this.blockDragging = false;
1336
+ this.blockDragPointerDown = false;
1337
+ handle.classList.remove("docx-block-dragging");
1338
+ this.hideDropIndicator();
1339
+ },
1340
+ }));
1341
+ this.blockDragCleanup.push(monitorForElements({
1342
+ canMonitor: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
1343
+ onDrop: ({ source, location }) => {
1344
+ this.hideDropIndicator();
1345
+ const drop = location.current.dropTargets[0]?.data;
1346
+ const sourceId = source.data.sourceAnchorId;
1347
+ const targetId = drop?.targetAnchorId;
1348
+ const position = drop?.position === "after" ? "after" : "before";
1349
+ if (typeof sourceId === "string" && typeof targetId === "string")
1350
+ this.moveBlock(sourceId, targetId, position);
1351
+ else
1352
+ // Released outside any valid target. Say so — a silent no-op reads as a broken drag.
1353
+ this.announceBlockMove("Move cancelled — the block was not dropped on a valid position.");
1354
+ },
1355
+ }));
1356
+ // Register on the element that actually scrolls. editRoot is the document flow — in the
1357
+ // ribbon surface the scroller is an ancestor of it, and registering the flow made Pragmatic
1358
+ // log "attached to an element that appears not to be scrollable" on every document open
1359
+ // while doing nothing.
1360
+ const scroller = this.scrollContainer();
1361
+ if (scroller) {
1362
+ this.blockDragCleanup.push(autoScrollForElements({
1363
+ element: scroller,
1364
+ canScroll: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
1365
+ getAllowedAxis: () => "vertical",
1366
+ }));
1367
+ }
1368
+ this.blockDragCleanup.push(autoScrollWindowForElements({
1369
+ canScroll: ({ source }) => source.data.type === BLOCK_DRAG_TYPE,
1370
+ getAllowedAxis: () => "vertical",
1371
+ }));
1372
+ this.refreshBlockDropTargets();
1373
+ }
1374
+ teardownBlockDrag() {
1375
+ this.blockMoveTargetPrefetch?.();
1376
+ this.blockMoveTargetPrefetch = null;
1377
+ this.blockMoveTargetCache.clear();
1378
+ for (const cleanup of this.blockDragTargetCleanup.splice(0))
1379
+ cleanup();
1380
+ for (const cleanup of this.blockDragCleanup.splice(0))
1381
+ cleanup();
1382
+ this.blockDragHandle?.remove();
1383
+ this.blockDropIndicator?.remove();
1384
+ this.blockMoveMenu?.remove();
1385
+ this.blockMoveLive?.remove();
1386
+ this.blockDragHandle = null;
1387
+ this.blockDropIndicator = null;
1388
+ this.blockMoveMenu = null;
1389
+ this.blockMoveLive = null;
1390
+ this.blockDragSource = null;
1391
+ this.blockDragging = false;
1392
+ this.blockDragPointerDown = false;
1393
+ }
801
1394
  // ─── internals ───────────────────────────────────────────────────────
802
1395
  assertOpen() {
803
1396
  if (this.closed)
@@ -946,11 +1539,16 @@ export class DocxEditor {
946
1539
  wireBlock(el) {
947
1540
  if (!EDITABLE_TAGS.has(el.tagName))
948
1541
  return;
1542
+ if (el.closest("tr[class$='row-del'], tr[class*='row-del ']") ||
1543
+ el.querySelector(":scope > del[class$='move-from'], :scope > del[class*='move-from ']")) {
1544
+ el.setAttribute("contenteditable", "false");
1545
+ return;
1546
+ }
949
1547
  const unid = el.getAttribute("data-anchor");
950
1548
  // 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.
1549
+ // table-cell paragraphs (the projection indexes them), so cell text IS editable. Table-aware
1550
+ // key handling keeps structural edits safe while still providing Word-style cell navigation
1551
+ // (see onKeydown). Anything the projection does not index (unstamped content) stays read-only.
954
1552
  // A band block is authoritative via its stamped `data-hf-anchor` even when the unid map
955
1553
  // resolves that unid to a different part (content-addressed unids collide across parts).
956
1554
  if (!unid || !this.anchorIdOf(el))
@@ -1042,7 +1640,7 @@ export class DocxEditor {
1042
1640
  }
1043
1641
  // Plain block: re-render ONLY this block from the live session for canonical HTML. Swapping the
1044
1642
  // 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);
1643
+ const html = this.renderBlockHtml(newAnchor);
1046
1644
  if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
1047
1645
  const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1048
1646
  const inBand = this.isBandBlock(el);
@@ -1089,19 +1687,18 @@ export class DocxEditor {
1089
1687
  return;
1090
1688
  }
1091
1689
  }
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.)
1690
+ // Inside a table cell, Backspace at the cell's start does not merge across the cell
1691
+ // boundary (mid-text Backspace still deletes normally). Enter splits the cell paragraph
1692
+ // WITHIN the same cell the engine keeps the new w:p in the w:tc, so the grid is unchanged.
1693
+ // Tab navigation is handled explicitly below; at the final cell it uses the existing safe
1694
+ // whole-table row insertion path to match Word's add-a-row behavior.
1100
1695
  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") {
1696
+ // Plain Tab / Shift+Tab moves between table cells. Outside tables it retains list
1697
+ // nest/un-nest behavior. Modified Tab chords remain available to the browser/platform.
1698
+ if (ev.key === "Tab" && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.isComposing) {
1103
1699
  if (inTableCell) {
1104
1700
  ev.preventDefault();
1701
+ this.navigateTableCell(el, ev.shiftKey);
1105
1702
  return;
1106
1703
  }
1107
1704
  if (isListBlock(el)) {
@@ -1136,6 +1733,50 @@ export class DocxEditor {
1136
1733
  }
1137
1734
  }
1138
1735
  }
1736
+ /** Editable cells in visual document order, excluding cells from any nested table. */
1737
+ tableCellsFor(el) {
1738
+ const cell = el.closest("td, th");
1739
+ const table = cell?.closest("table");
1740
+ if (!cell || !table)
1741
+ return null;
1742
+ const cells = Array.from(table.querySelectorAll("td, th"))
1743
+ .filter((candidate) => candidate.closest("table") === table);
1744
+ const index = cells.indexOf(cell);
1745
+ return index >= 0 ? { cells, index } : null;
1746
+ }
1747
+ /** First addressable paragraph in a cell, fenced against nested-table descendants. */
1748
+ editableInCell(cell) {
1749
+ return Array.from(cell.querySelectorAll('[data-anchor][contenteditable="true"]')).find((block) => block.closest("td, th") === cell) ?? null;
1750
+ }
1751
+ /** Word-style cell navigation: Shift+Tab goes back, Tab goes forward, and Tab at the
1752
+ * final cell appends a row before entering its first cell. The first cell is a hard
1753
+ * boundary for Shift+Tab so focus never leaks out of the editor table. */
1754
+ navigateTableCell(el, backwards) {
1755
+ const state = this.tableCellsFor(el);
1756
+ if (!state)
1757
+ return;
1758
+ const adjacent = state.cells[state.index + (backwards ? -1 : 1)];
1759
+ if (adjacent) {
1760
+ const target = this.editableInCell(adjacent);
1761
+ if (target)
1762
+ placeCaretAtOffset(target, 0);
1763
+ return;
1764
+ }
1765
+ if (backwards)
1766
+ return;
1767
+ // The current block may contain uncommitted typing. insertTableRow flushes it before the
1768
+ // structural edit, then reconcile/remount restores focus by block index. Resolve the new
1769
+ // adjacent cell from that fresh DOM rather than retaining a node from the replaced table.
1770
+ this.activeBlock = el;
1771
+ this.insertTableRow("below");
1772
+ const fresh = this.activeBlock ? this.tableCellsFor(this.activeBlock) : null;
1773
+ if (!fresh)
1774
+ return;
1775
+ const target = fresh.cells[fresh.index + 1];
1776
+ const block = target ? this.editableInCell(target) : null;
1777
+ if (block)
1778
+ placeCaretAtOffset(block, 0);
1779
+ }
1139
1780
  /** Shift+Enter: insert an intra-paragraph line break at the caret. Delegates to the
1140
1781
  * native `insertLineBreak` command, which inserts a <br> AND positions the caret
1141
1782
  * after it correctly (handling the browser's bogus trailing-<br> rule) so typing
@@ -1309,11 +1950,27 @@ export class DocxEditor {
1309
1950
  }
1310
1951
  /** Render a block by anchor and parse it into a detached element (null on error). */
1311
1952
  renderInto(anchorId) {
1312
- const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
1953
+ const html = this.renderBlockHtml(anchorId);
1313
1954
  if (html.charCodeAt(0) === 0x7b /* error object */)
1314
1955
  return null;
1315
1956
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1316
1957
  }
1958
+ get renderTrackedChanges() {
1959
+ return this.options.trackedChanges === TrackedChangeMode.RenderInline;
1960
+ }
1961
+ renderBlockHtml(anchorId) {
1962
+ const bridge = this.exports.DocxSessionBridge;
1963
+ if (typeof bridge.RenderBlockHtmlForReview === "function") {
1964
+ return bridge.RenderBlockHtmlForReview(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges);
1965
+ }
1966
+ return bridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
1967
+ }
1968
+ renderPlanJson() {
1969
+ const bridge = this.exports.DocxSessionBridge;
1970
+ return typeof bridge.ListRenderedBlocks === "function"
1971
+ ? bridge.ListRenderedBlocks(this.handle, this.renderTrackedChanges)
1972
+ : bridge.ListBlocks(this.handle);
1973
+ }
1317
1974
  /** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
1318
1975
  * the per-render shell/converter setup is paid once instead of twice, which matters on the
1319
1976
  * Enter path (split renders both halves synchronously under the keystroke). Falls back to
@@ -1323,7 +1980,11 @@ export class DocxEditor {
1323
1980
  const bridge = this.exports.DocxSessionBridge;
1324
1981
  if (typeof bridge.RenderBlocksHtml === "function") {
1325
1982
  try {
1326
- const map = JSON.parse(bridge.RenderBlocksHtml(this.handle, JSON.stringify([a, b]), this.options.cssPrefix, this.options.fabricateClasses));
1983
+ const idsJson = JSON.stringify([a, b]);
1984
+ const json = typeof bridge.RenderBlocksHtmlForReview === "function"
1985
+ ? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
1986
+ : bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
1987
+ const map = JSON.parse(json);
1327
1988
  if (!map.error) {
1328
1989
  const parse = (h) => h
1329
1990
  ? new DOMParser().parseFromString(h, "text/html").body
@@ -1349,12 +2010,24 @@ export class DocxEditor {
1349
2010
  }
1350
2011
  parseEdit(json) {
1351
2012
  try {
1352
- return JSON.parse(json);
2013
+ const result = JSON.parse(json);
2014
+ if (result.success)
2015
+ this.invalidateBlockMoveTargets();
2016
+ return result;
1353
2017
  }
1354
2018
  catch {
1355
2019
  return { success: false };
1356
2020
  }
1357
2021
  }
2022
+ /**
2023
+ * Drop the memoized `ValidMoveTargets` answers. Which blocks a block may move next to is a
2024
+ * fact about the DOCUMENT, so it survives hovering but not editing — and the two places a
2025
+ * document changes are `parseEdit` (every mutation that returns an `EditResult`) and
2026
+ * undo/redo, which return a bare boolean and so cannot go through it.
2027
+ */
2028
+ invalidateBlockMoveTargets() {
2029
+ this.blockMoveTargetCache.clear();
2030
+ }
1358
2031
  // ─── M5: formatting commands (ribbon) ────────────────────────────────
1359
2032
  // ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
1360
2033
  /**
@@ -1942,15 +2615,19 @@ export class DocxEditor {
1942
2615
  undo() {
1943
2616
  if (this.closed)
1944
2617
  return;
1945
- if (this.exports.DocxSessionBridge.Undo(this.handle))
1946
- this.reconcile();
2618
+ if (!this.exports.DocxSessionBridge.Undo(this.handle))
2619
+ return;
2620
+ this.invalidateBlockMoveTargets();
2621
+ this.reconcile();
1947
2622
  }
1948
2623
  /** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
1949
2624
  redo() {
1950
2625
  if (this.closed)
1951
2626
  return;
1952
- if (this.exports.DocxSessionBridge.Redo(this.handle))
1953
- this.reconcile();
2627
+ if (!this.exports.DocxSessionBridge.Redo(this.handle))
2628
+ return;
2629
+ this.invalidateBlockMoveTargets();
2630
+ this.reconcile();
1954
2631
  }
1955
2632
  // ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
1956
2633
  /**
@@ -2058,6 +2735,11 @@ export class DocxEditor {
2058
2735
  */
2059
2736
  renderFullHtml() {
2060
2737
  const bridge = this.exports.DocxSessionBridge;
2738
+ if (typeof bridge.RenderHtmlForReview === "function") {
2739
+ const html = bridge.RenderHtmlForReview(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges);
2740
+ if (html.charCodeAt(0) !== 0x7b)
2741
+ return html;
2742
+ }
2061
2743
  if (typeof bridge.RenderHtml === "function") {
2062
2744
  const html = bridge.RenderHtml(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale);
2063
2745
  if (html.charCodeAt(0) !== 0x7b /* not an error object */)
@@ -2070,7 +2752,7 @@ export class DocxEditor {
2070
2752
  const bytes = typeof bridge.SaveWithAnchorIds === "function"
2071
2753
  ? bridge.SaveWithAnchorIds(this.handle)
2072
2754
  : bridge.Save(this.handle);
2073
- return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
2755
+ return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges));
2074
2756
  }
2075
2757
  /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
2076
2758
  editableList() {
@@ -2169,28 +2851,39 @@ export class DocxEditor {
2169
2851
  // Refresh the unid → anchor map FIRST: wiring freshly rendered nodes (wireBlock)
2170
2852
  // resolves through it, and the map must reflect the post-op session.
2171
2853
  this.refreshAnchorMap();
2172
- const plan = JSON.parse(bridge.ListBlocks(this.handle));
2854
+ const plan = JSON.parse(this.renderPlanJson());
2173
2855
  if (plan.error)
2174
2856
  return this.bail(`plan error: ${plan.error}`);
2175
2857
  const oldNodes = this.bodyUnitNodes();
2176
2858
  const oldTokens = oldNodes.map(DocxEditor.domTokenOf);
2177
2859
  const oldKinds = oldNodes.map(DocxEditor.domKindOf);
2178
2860
  const bodyDiff = diffUnits(oldTokens, plan.body);
2179
- if (needsRemount(bodyDiff, plan.body, oldKinds))
2180
- return this.bail("needsRemount (li change or churn)");
2861
+ if (needsRemount(bodyDiff, plan.body, oldKinds)) {
2862
+ // Name the shape, not just the verdict: "churn" and "a list item moved in or out" are very
2863
+ // different findings when a repaint unexpectedly costs a whole-document render.
2864
+ return this.bail(`needsRemount (li change or churn): +${bodyDiff.added.length} -${bodyDiff.removed.length} ` +
2865
+ `~${bodyDiff.substituted.length} moved=${bodyDiff.moved.length} of ${plan.body.length}`);
2866
+ }
2181
2867
  const fnState = this.notesDiff("footnotes", plan.footnotes);
2182
2868
  const enState = this.notesDiff("endnotes", plan.endnotes);
2183
2869
  if (fnState === null || enState === null)
2184
2870
  return this.bail("notes container unstampable/missing");
2185
2871
  // One batch render for everything that needs fresh HTML.
2186
- const addedBodyIds = bodyDiff.added.map((j) => plan.body[j].id);
2872
+ const movedBodyNew = new Set(bodyDiff.moved.map((m) => m.newIndex));
2873
+ const addedBodyIds = bodyDiff.added
2874
+ .filter((j) => !movedBodyNew.has(j))
2875
+ .map((j) => plan.body[j].id);
2187
2876
  const addedNoteIds = fnState.diff.added
2188
2877
  .map((j) => plan.footnotes[j].id)
2189
2878
  .concat(enState.diff.added.map((j) => plan.endnotes[j].id));
2190
2879
  const allIds = addedBodyIds.concat(addedNoteIds);
2191
2880
  let rendered = {};
2192
2881
  if (allIds.length > 0) {
2193
- rendered = JSON.parse(bridge.RenderBlocksHtml(this.handle, JSON.stringify(allIds), this.options.cssPrefix, this.options.fabricateClasses));
2882
+ const idsJson = JSON.stringify(allIds);
2883
+ const renderedJson = typeof bridge.RenderBlocksHtmlForReview === "function"
2884
+ ? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
2885
+ : bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
2886
+ rendered = JSON.parse(renderedJson);
2194
2887
  if (rendered.error)
2195
2888
  return this.bail(`render error: ${rendered.error}`);
2196
2889
  for (const id of allIds)
@@ -2209,7 +2902,7 @@ export class DocxEditor {
2209
2902
  return el;
2210
2903
  };
2211
2904
  const freshBody = new Map();
2212
- for (const j of bodyDiff.added) {
2905
+ for (const j of bodyDiff.added.filter((j) => !movedBodyNew.has(j))) {
2213
2906
  const el = parse(rendered[plan.body[j].id]);
2214
2907
  if (!el)
2215
2908
  return this.bail(`unparseable render: ${plan.body[j].id}`);
@@ -2267,16 +2960,6 @@ export class DocxEditor {
2267
2960
  * bail-reason string (parent ambiguity, order violation, wrapper semantics) — the
2268
2961
  * session is already correct, so bailing just means a full repaint. */
2269
2962
  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
2963
  // In-place substitutions first: replace at WRAPPER level so a table swaps with its
2281
2964
  // alignment div. A LEAF render replacing a wrapped node would break the wrapper's
2282
2965
  // semantics (border <div> grouping) — that is remount territory.
@@ -2307,31 +2990,34 @@ export class DocxEditor {
2307
2990
  }
2308
2991
  this.wireUnit(freshRoot, units[nj]);
2309
2992
  }
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));
2993
+ const movedOldByNew = new Map(diff.moved.map((m) => [m.newIndex, m.oldIndex]));
2994
+ const movedNew = new Set(diff.moved.map((m) => m.newIndex));
2995
+ const movedOld = new Set(diff.moved.map((m) => m.oldIndex));
2996
+ const pureAdded = diff.added.filter((j) => !subOldByNew.has(j) && !movedNew.has(j));
2997
+ const pureRemoved = diff.removed.filter((i) => !diff.substituted.some((s) => s.oldIndex === i) && !movedOld.has(i));
2313
2998
  const nodeAt = (j) => {
2314
2999
  const oi = diff.keep.get(j);
2315
3000
  if (oi !== undefined)
2316
3001
  return oldNodes[oi];
3002
+ const movedOi = movedOldByNew.get(j);
3003
+ if (movedOi !== undefined)
3004
+ return oldNodes[movedOi];
2317
3005
  if (subOldByNew.has(j))
2318
3006
  return fresh.get(j);
2319
3007
  const f = fresh.get(j);
2320
3008
  return f && f.isConnected ? f : null;
2321
3009
  };
3010
+ // Preserve the established incremental insertion path: body render output may
3011
+ // legitimately use several wrapper parents (for example border groups), so it is
3012
+ // not valid to normalize the whole body against one common parent.
2322
3013
  for (const j of pureAdded) {
2323
3014
  const el = fresh.get(j);
2324
3015
  let prev = null;
2325
3016
  for (let k = j - 1; k >= 0 && !prev; k--)
2326
3017
  prev = nodeAt(k);
2327
3018
  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
- }
3019
+ for (let k = j + 1; k < units.length && !next; k++)
3020
+ next = nodeAt(k);
2335
3021
  const prevW = prev ? this.unitWrapperOf(prev) : null;
2336
3022
  const nextW = next ? this.unitWrapperOf(next) : null;
2337
3023
  if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
@@ -2341,13 +3027,43 @@ export class DocxEditor {
2341
3027
  else if (nextW)
2342
3028
  nextW.before(el);
2343
3029
  else
2344
- return "insert with no anchored neighbor"; // empty container — nowhere provably correct
3030
+ return "insert with no anchored neighbor";
2345
3031
  this.wireUnit(el, units[j]);
2346
3032
  }
2347
- // Pure removals last, taking now-empty generated wrappers with them.
3033
+ // Pure removals take their now-empty generated wrappers with them. Exact-token
3034
+ // move sources remain live even though the LCS also reports them as removed.
2348
3035
  for (const i of pureRemoved) {
2349
- const wrapper = this.unitWrapperOf(oldNodes[i]);
2350
- wrapper.remove();
3036
+ this.unitWrapperOf(oldNodes[i]).remove();
3037
+ }
3038
+ // A block move changes one exact-token unit's slot. Move only that existing
3039
+ // wrapper beside its final neighbor; this preserves identity without disturbing
3040
+ // unrelated nodes or assuming every rendered unit shares one DOM parent.
3041
+ for (const { newIndex } of [...diff.moved].sort((a, b) => a.newIndex - b.newIndex)) {
3042
+ const moving = nodeAt(newIndex);
3043
+ if (!moving?.isConnected)
3044
+ return "move source is not connected";
3045
+ const movingW = this.unitWrapperOf(moving);
3046
+ let prevW = null;
3047
+ for (let k = newIndex - 1; k >= 0 && !prevW; k--) {
3048
+ const n = nodeAt(k);
3049
+ if (n?.isConnected)
3050
+ prevW = this.unitWrapperOf(n);
3051
+ }
3052
+ let nextW = null;
3053
+ for (let k = newIndex + 1; k < units.length && !nextW; k++) {
3054
+ const n = nodeAt(k);
3055
+ if (n?.isConnected)
3056
+ nextW = this.unitWrapperOf(n);
3057
+ }
3058
+ if (prevW && prevW !== movingW && prevW.parentElement === movingW.parentElement) {
3059
+ prevW.after(movingW);
3060
+ }
3061
+ else if (nextW && nextW !== movingW && nextW.parentElement === movingW.parentElement) {
3062
+ nextW.before(movingW);
3063
+ }
3064
+ else {
3065
+ return "move neighbors in different parents";
3066
+ }
2351
3067
  }
2352
3068
  return true;
2353
3069
  }
@@ -2490,7 +3206,7 @@ export class DocxEditor {
2490
3206
  if (typeof bridge.ListBlocks !== "function")
2491
3207
  return;
2492
3208
  try {
2493
- const plan = JSON.parse(bridge.ListBlocks(this.handle));
3209
+ const plan = JSON.parse(this.renderPlanJson());
2494
3210
  if (plan.error)
2495
3211
  return;
2496
3212
  // Positional pairing: a fresh full mount renders exactly the plan's units in
@@ -2527,6 +3243,7 @@ export class DocxEditor {
2527
3243
  * block's content-hashed unid changes across the save/reproject a remount performs.
2528
3244
  */
2529
3245
  remount(focusIndex = -1, caretAtEnd = false) {
3246
+ this.teardownBlockDrag();
2530
3247
  this.refreshAnchorMap();
2531
3248
  const fullHtml = this.renderFullHtml();
2532
3249
  this.activeBlock = null;
@@ -2545,6 +3262,7 @@ export class DocxEditor {
2545
3262
  // A remount rebuilds the body from the live session; re-resolve the section so undo/redo of a
2546
3263
  // section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
2547
3264
  this.syncRegionToBody(this.activeBlock ?? undefined);
3265
+ this.setupBlockDrag();
2548
3266
  }
2549
3267
  }
2550
3268
  //# sourceMappingURL=editor.js.map