docxodus 9.1.1 → 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.
Files changed (43) hide show
  1. package/README.md +39 -18
  2. package/dist/editor-reconcile.d.ts +5 -0
  3. package/dist/editor-reconcile.d.ts.map +1 -1
  4. package/dist/editor-reconcile.js +23 -2
  5. package/dist/editor-reconcile.js.map +1 -1
  6. package/dist/editor.bundle.js +5099 -283
  7. package/dist/editor.d.ts +112 -0
  8. package/dist/editor.d.ts.map +1 -1
  9. package/dist/editor.js +742 -55
  10. package/dist/editor.js.map +1 -1
  11. package/dist/embed.bundle.js +5215 -331
  12. package/dist/embed.d.ts +25 -0
  13. package/dist/embed.d.ts.map +1 -1
  14. package/dist/embed.iife.js +5201 -330
  15. package/dist/embed.js +77 -0
  16. package/dist/embed.js.map +1 -1
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/ribbon-chrome.d.ts +45 -0
  22. package/dist/ribbon-chrome.d.ts.map +1 -0
  23. package/dist/ribbon-chrome.js +965 -0
  24. package/dist/ribbon-chrome.js.map +1 -0
  25. package/dist/ribbon.d.ts +153 -0
  26. package/dist/ribbon.d.ts.map +1 -0
  27. package/dist/ribbon.js +834 -0
  28. package/dist/ribbon.js.map +1 -0
  29. package/dist/session.bundle.js +6 -0
  30. package/dist/session.d.ts +2 -0
  31. package/dist/session.d.ts.map +1 -1
  32. package/dist/session.js +4 -0
  33. package/dist/session.js.map +1 -1
  34. package/dist/types.d.ts +10 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  38. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  39. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  40. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  41. package/dist/wasm/_framework/dotnet.boot.js +3 -3
  42. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  43. package/package.json +7 -3
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
@@ -580,7 +641,14 @@ export class DocxEditor {
580
641
  const anchor = caretPointFromClient(document, event.clientX, event.clientY);
581
642
  if (!anchor || this.editableBlockOf(anchor.node) !== origin)
582
643
  return;
583
- this.dragSelection = { anchor, origin, crossedBlockBoundary: false };
644
+ this.clearDragSelection();
645
+ this.dragSelection = {
646
+ anchor,
647
+ origin,
648
+ crossedBlockBoundary: false,
649
+ focus: null,
650
+ frame: null,
651
+ };
584
652
  };
585
653
  /**
586
654
  * Browsers fence native mouse selection at a contenteditable host boundary. Once a drag reaches
@@ -593,7 +661,7 @@ export class DocxEditor {
593
661
  if (!drag)
594
662
  return;
595
663
  if ((event.buttons & 1) === 0) {
596
- this.dragSelection = null;
664
+ this.clearDragSelection();
597
665
  return;
598
666
  }
599
667
  const focus = caretPointFromClient(document, event.clientX, event.clientY);
@@ -607,7 +675,7 @@ export class DocxEditor {
607
675
  if (!drag.crossedBlockBoundary)
608
676
  return;
609
677
  event.preventDefault();
610
- setSelectionBetween(drag.anchor, focus);
678
+ this.queueDragSelection(drag, focus);
611
679
  };
612
680
  /** Commit the final cross-block endpoint before releasing the gesture state. */
613
681
  this.onMouseUp = (event) => {
@@ -622,7 +690,7 @@ export class DocxEditor {
622
690
  setSelectionBetween(drag.anchor, focus);
623
691
  }
624
692
  }
625
- this.dragSelection = null;
693
+ this.clearDragSelection();
626
694
  };
627
695
  this.container = container;
628
696
  this.exports = exports;
@@ -636,6 +704,37 @@ export class DocxEditor {
636
704
  document.addEventListener("mouseup", this.onMouseUp, true);
637
705
  }
638
706
  }
707
+ /**
708
+ * Apply the latest cross-block endpoint after the browser finishes its native mousemove
709
+ * selection update. Firefox rewrites Selection back into the originating contenteditable after
710
+ * event dispatch even when mousemove is cancelled; writing in requestAnimationFrame wins that
711
+ * race and happens before paint. Coalescing also avoids rebuilding a Range for every raw pointer
712
+ * event when the mouse is moving faster than the display can refresh.
713
+ */
714
+ queueDragSelection(drag, focus) {
715
+ drag.focus = focus;
716
+ if (drag.frame !== null)
717
+ return;
718
+ const view = this.container.ownerDocument.defaultView;
719
+ if (!view) {
720
+ setSelectionBetween(drag.anchor, focus);
721
+ return;
722
+ }
723
+ drag.frame = view.requestAnimationFrame(() => {
724
+ drag.frame = null;
725
+ if (this.dragSelection !== drag || !drag.focus)
726
+ return;
727
+ setSelectionBetween(drag.anchor, drag.focus);
728
+ });
729
+ }
730
+ /** Cancel a queued repaint and discard the current gesture. */
731
+ clearDragSelection() {
732
+ const drag = this.dragSelection;
733
+ if (drag && drag.frame !== null) {
734
+ this.container.ownerDocument.defaultView?.cancelAnimationFrame(drag.frame);
735
+ }
736
+ this.dragSelection = null;
737
+ }
639
738
  /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
640
739
  * Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
641
740
  * body edit root by design — also register. The fence still rejects other editors on the page. */
@@ -686,7 +785,11 @@ export class DocxEditor {
686
785
  paginated: options.paginated ?? false,
687
786
  scale: options.scale ?? 1,
688
787
  headerFooter: options.headerFooter ?? false,
788
+ blockDrag: options.blockDrag ?? false,
789
+ trackedChanges: options.trackedChanges ?? TrackedChangeMode.Accept,
790
+ revisionAuthor: options.revisionAuthor ?? "docxodus",
689
791
  onEdit: options.onEdit,
792
+ onMove: options.onMove,
690
793
  };
691
794
  // NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
692
795
  // projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
@@ -694,17 +797,22 @@ export class DocxEditor {
694
797
  // save/re-render hop, and it asks for that per call via SaveWithAnchorIds.
695
798
  // emitMarkdownPatch off: the editor re-renders from HTML, never from markdown patches,
696
799
  // so paying a whole-document re-projection per op would be dead weight.
697
- 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
+ }));
698
805
  const editor = new DocxEditor(container, exports, handle, opts);
699
806
  editor.refreshAnchorMap();
700
807
  if (opts.headerFooter)
701
808
  editor.createRegion();
702
- 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));
703
810
  if (opts.paginated)
704
811
  editor.mountPaginated(fullHtml);
705
812
  else
706
813
  editor.mountHtml(fullHtml);
707
814
  editor.syncRegionToBody();
815
+ editor.setupBlockDrag();
708
816
  return editor;
709
817
  }
710
818
  /**
@@ -731,7 +839,8 @@ export class DocxEditor {
731
839
  document.removeEventListener("mousemove", this.onMouseMove, true);
732
840
  document.removeEventListener("mouseup", this.onMouseUp, true);
733
841
  }
734
- this.dragSelection = null;
842
+ this.clearDragSelection();
843
+ this.teardownBlockDrag();
735
844
  this.exports.DocxSessionBridge.CloseSession(this.handle);
736
845
  }
737
846
  /**
@@ -750,6 +859,479 @@ export class DocxEditor {
750
859
  get root() {
751
860
  return this.container;
752
861
  }
862
+ /**
863
+ * The live `DocxSession` handle backing this editor — the model of record.
864
+ *
865
+ * Surfaced because chrome around the editor (the anchor rail) reports it as engine
866
+ * state, and reaching into the private field from a host page only worked because
867
+ * the bundle erases TypeScript's visibility.
868
+ */
869
+ get sessionHandle() {
870
+ return this.handle;
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
+ }
753
1335
  // ─── internals ───────────────────────────────────────────────────────
754
1336
  assertOpen() {
755
1337
  if (this.closed)
@@ -898,11 +1480,16 @@ export class DocxEditor {
898
1480
  wireBlock(el) {
899
1481
  if (!EDITABLE_TAGS.has(el.tagName))
900
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
+ }
901
1488
  const unid = el.getAttribute("data-anchor");
902
1489
  // Only blocks the markdown projection addresses are editable via the text path. This INCLUDES
903
- // table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
904
- // keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
905
- // 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.
906
1493
  // A band block is authoritative via its stamped `data-hf-anchor` even when the unid map
907
1494
  // resolves that unid to a different part (content-addressed unids collide across parts).
908
1495
  if (!unid || !this.anchorIdOf(el))
@@ -994,7 +1581,7 @@ export class DocxEditor {
994
1581
  }
995
1582
  // Plain block: re-render ONLY this block from the live session for canonical HTML. Swapping the
996
1583
  // just-blurred node here is safe (verified — focus stays on the newly-clicked block).
997
- const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
1584
+ const html = this.renderBlockHtml(newAnchor);
998
1585
  if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
999
1586
  const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1000
1587
  const inBand = this.isBandBlock(el);
@@ -1041,19 +1628,18 @@ export class DocxEditor {
1041
1628
  return;
1042
1629
  }
1043
1630
  }
1044
- // Inside a table cell, structural ops that change the TABLE GRID (cross-cell merge,
1045
- // list-nest, focus-jumping Tab) stay INERT — the single-block model can't give them
1046
- // whole-table context. Tab is swallowed (no focus escape / literal tab); Backspace at
1047
- // the cell's start does not merge across the cell boundary (mid-text Backspace still
1048
- // deletes normally). Enter, however, splits the cell paragraph into two paragraphs
1049
- // WITHIN the same cell — the engine keeps the new w:p in the w:tc, the grid is
1050
- // unchanged, so it's safe (it's how a cell holds stacked lines: value over a smaller
1051
- // 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.
1052
1636
  const inTableCell = !!el.closest("table");
1053
- // Tab / Shift+Tab on a list item nests / un-nests it (changes list level).
1054
- 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) {
1055
1640
  if (inTableCell) {
1056
1641
  ev.preventDefault();
1642
+ this.navigateTableCell(el, ev.shiftKey);
1057
1643
  return;
1058
1644
  }
1059
1645
  if (isListBlock(el)) {
@@ -1088,6 +1674,50 @@ export class DocxEditor {
1088
1674
  }
1089
1675
  }
1090
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
+ }
1091
1721
  /** Shift+Enter: insert an intra-paragraph line break at the caret. Delegates to the
1092
1722
  * native `insertLineBreak` command, which inserts a <br> AND positions the caret
1093
1723
  * after it correctly (handling the browser's bogus trailing-<br> rule) so typing
@@ -1261,11 +1891,27 @@ export class DocxEditor {
1261
1891
  }
1262
1892
  /** Render a block by anchor and parse it into a detached element (null on error). */
1263
1893
  renderInto(anchorId) {
1264
- const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
1894
+ const html = this.renderBlockHtml(anchorId);
1265
1895
  if (html.charCodeAt(0) === 0x7b /* error object */)
1266
1896
  return null;
1267
1897
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1268
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
+ }
1269
1915
  /** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
1270
1916
  * the per-render shell/converter setup is paid once instead of twice, which matters on the
1271
1917
  * Enter path (split renders both halves synchronously under the keystroke). Falls back to
@@ -1275,7 +1921,11 @@ export class DocxEditor {
1275
1921
  const bridge = this.exports.DocxSessionBridge;
1276
1922
  if (typeof bridge.RenderBlocksHtml === "function") {
1277
1923
  try {
1278
- 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);
1279
1929
  if (!map.error) {
1280
1930
  const parse = (h) => h
1281
1931
  ? new DOMParser().parseFromString(h, "text/html").body
@@ -2010,6 +2660,11 @@ export class DocxEditor {
2010
2660
  */
2011
2661
  renderFullHtml() {
2012
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
+ }
2013
2668
  if (typeof bridge.RenderHtml === "function") {
2014
2669
  const html = bridge.RenderHtml(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale);
2015
2670
  if (html.charCodeAt(0) !== 0x7b /* not an error object */)
@@ -2022,7 +2677,7 @@ export class DocxEditor {
2022
2677
  const bytes = typeof bridge.SaveWithAnchorIds === "function"
2023
2678
  ? bridge.SaveWithAnchorIds(this.handle)
2024
2679
  : bridge.Save(this.handle);
2025
- 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));
2026
2681
  }
2027
2682
  /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
2028
2683
  editableList() {
@@ -2121,7 +2776,7 @@ export class DocxEditor {
2121
2776
  // Refresh the unid → anchor map FIRST: wiring freshly rendered nodes (wireBlock)
2122
2777
  // resolves through it, and the map must reflect the post-op session.
2123
2778
  this.refreshAnchorMap();
2124
- const plan = JSON.parse(bridge.ListBlocks(this.handle));
2779
+ const plan = JSON.parse(this.renderPlanJson());
2125
2780
  if (plan.error)
2126
2781
  return this.bail(`plan error: ${plan.error}`);
2127
2782
  const oldNodes = this.bodyUnitNodes();
@@ -2135,14 +2790,21 @@ export class DocxEditor {
2135
2790
  if (fnState === null || enState === null)
2136
2791
  return this.bail("notes container unstampable/missing");
2137
2792
  // One batch render for everything that needs fresh HTML.
2138
- 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);
2139
2797
  const addedNoteIds = fnState.diff.added
2140
2798
  .map((j) => plan.footnotes[j].id)
2141
2799
  .concat(enState.diff.added.map((j) => plan.endnotes[j].id));
2142
2800
  const allIds = addedBodyIds.concat(addedNoteIds);
2143
2801
  let rendered = {};
2144
2802
  if (allIds.length > 0) {
2145
- 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);
2146
2808
  if (rendered.error)
2147
2809
  return this.bail(`render error: ${rendered.error}`);
2148
2810
  for (const id of allIds)
@@ -2161,7 +2823,7 @@ export class DocxEditor {
2161
2823
  return el;
2162
2824
  };
2163
2825
  const freshBody = new Map();
2164
- for (const j of bodyDiff.added) {
2826
+ for (const j of bodyDiff.added.filter((j) => !movedBodyNew.has(j))) {
2165
2827
  const el = parse(rendered[plan.body[j].id]);
2166
2828
  if (!el)
2167
2829
  return this.bail(`unparseable render: ${plan.body[j].id}`);
@@ -2219,16 +2881,6 @@ export class DocxEditor {
2219
2881
  * bail-reason string (parent ambiguity, order violation, wrapper semantics) — the
2220
2882
  * session is already correct, so bailing just means a full repaint. */
2221
2883
  applyBodyDiff(oldNodes, units, diff, fresh) {
2222
- // Kept nodes must appear in increasing old order (no move support in v1).
2223
- let lastOld = -1;
2224
- for (let j = 0; j < units.length; j++) {
2225
- const oi = diff.keep.get(j);
2226
- if (oi === undefined)
2227
- continue;
2228
- if (oi < lastOld)
2229
- return "kept order violation";
2230
- lastOld = oi;
2231
- }
2232
2884
  // In-place substitutions first: replace at WRAPPER level so a table swaps with its
2233
2885
  // alignment div. A LEAF render replacing a wrapped node would break the wrapper's
2234
2886
  // semantics (border <div> grouping) — that is remount territory.
@@ -2259,31 +2911,34 @@ export class DocxEditor {
2259
2911
  }
2260
2912
  this.wireUnit(freshRoot, units[nj]);
2261
2913
  }
2262
- // Pure inserts against kept/substituted neighbors (at wrapper level).
2263
- const pureAdded = diff.added.filter((j) => !subOldByNew.has(j));
2264
- 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));
2265
2919
  const nodeAt = (j) => {
2266
2920
  const oi = diff.keep.get(j);
2267
2921
  if (oi !== undefined)
2268
2922
  return oldNodes[oi];
2923
+ const movedOi = movedOldByNew.get(j);
2924
+ if (movedOi !== undefined)
2925
+ return oldNodes[movedOi];
2269
2926
  if (subOldByNew.has(j))
2270
2927
  return fresh.get(j);
2271
2928
  const f = fresh.get(j);
2272
2929
  return f && f.isConnected ? f : null;
2273
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.
2274
2934
  for (const j of pureAdded) {
2275
2935
  const el = fresh.get(j);
2276
2936
  let prev = null;
2277
2937
  for (let k = j - 1; k >= 0 && !prev; k--)
2278
2938
  prev = nodeAt(k);
2279
2939
  let next = null;
2280
- for (let k = j + 1; k < units.length && !next; k++) {
2281
- const oi = diff.keep.get(k);
2282
- if (oi !== undefined)
2283
- next = oldNodes[oi];
2284
- else if (subOldByNew.has(k))
2285
- next = fresh.get(k);
2286
- }
2940
+ for (let k = j + 1; k < units.length && !next; k++)
2941
+ next = nodeAt(k);
2287
2942
  const prevW = prev ? this.unitWrapperOf(prev) : null;
2288
2943
  const nextW = next ? this.unitWrapperOf(next) : null;
2289
2944
  if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
@@ -2293,13 +2948,43 @@ export class DocxEditor {
2293
2948
  else if (nextW)
2294
2949
  nextW.before(el);
2295
2950
  else
2296
- return "insert with no anchored neighbor"; // empty container — nowhere provably correct
2951
+ return "insert with no anchored neighbor";
2297
2952
  this.wireUnit(el, units[j]);
2298
2953
  }
2299
- // 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.
2300
2956
  for (const i of pureRemoved) {
2301
- const wrapper = this.unitWrapperOf(oldNodes[i]);
2302
- 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
+ }
2303
2988
  }
2304
2989
  return true;
2305
2990
  }
@@ -2442,7 +3127,7 @@ export class DocxEditor {
2442
3127
  if (typeof bridge.ListBlocks !== "function")
2443
3128
  return;
2444
3129
  try {
2445
- const plan = JSON.parse(bridge.ListBlocks(this.handle));
3130
+ const plan = JSON.parse(this.renderPlanJson());
2446
3131
  if (plan.error)
2447
3132
  return;
2448
3133
  // Positional pairing: a fresh full mount renders exactly the plan's units in
@@ -2479,6 +3164,7 @@ export class DocxEditor {
2479
3164
  * block's content-hashed unid changes across the save/reproject a remount performs.
2480
3165
  */
2481
3166
  remount(focusIndex = -1, caretAtEnd = false) {
3167
+ this.teardownBlockDrag();
2482
3168
  this.refreshAnchorMap();
2483
3169
  const fullHtml = this.renderFullHtml();
2484
3170
  this.activeBlock = null;
@@ -2497,6 +3183,7 @@ export class DocxEditor {
2497
3183
  // A remount rebuilds the body from the live session; re-resolve the section so undo/redo of a
2498
3184
  // section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
2499
3185
  this.syncRegionToBody(this.activeBlock ?? undefined);
3186
+ this.setupBlockDrag();
2500
3187
  }
2501
3188
  }
2502
3189
  //# sourceMappingURL=editor.js.map