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