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.
- package/dist/editor-reconcile.d.ts +5 -0
- package/dist/editor-reconcile.d.ts.map +1 -1
- package/dist/editor-reconcile.js +23 -2
- package/dist/editor-reconcile.js.map +1 -1
- package/dist/editor.bundle.js +3338 -162
- package/dist/editor.d.ts +120 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +775 -57
- package/dist/editor.js.map +1 -1
- package/dist/embed.bundle.js +3401 -178
- package/dist/embed.d.ts.map +1 -1
- package/dist/embed.iife.js +3388 -178
- package/dist/embed.js +8 -0
- package/dist/embed.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ribbon.js +4 -0
- package/dist/ribbon.js.map +1 -1
- package/dist/session.bundle.js +31 -1
- package/dist/session.d.ts +22 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +27 -1
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +5 -5
- package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
- package/package.json +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
|
-
|
|
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,
|
|
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
|
|
952
|
-
//
|
|
953
|
-
//
|
|
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.
|
|
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,
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
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
|
|
1102
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2311
|
-
const
|
|
2312
|
-
const
|
|
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
|
-
|
|
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";
|
|
3030
|
+
return "insert with no anchored neighbor";
|
|
2345
3031
|
this.wireUnit(el, units[j]);
|
|
2346
3032
|
}
|
|
2347
|
-
// Pure removals
|
|
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
|
-
|
|
2350
|
-
|
|
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(
|
|
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
|