docxodus 7.0.1 → 8.0.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/docxodus.worker.js +2 -2
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor-headerfooter.d.ts +130 -0
- package/dist/editor-headerfooter.d.ts.map +1 -0
- package/dist/editor-headerfooter.js +414 -0
- package/dist/editor-headerfooter.js.map +1 -0
- package/dist/editor.bundle.js +1075 -81
- package/dist/editor.d.ts +82 -4
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +304 -63
- package/dist/editor.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/pagination.bundle.js +480 -19
- package/dist/pagination.d.ts +81 -0
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +528 -26
- package/dist/pagination.js.map +1 -1
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +6 -4
- package/dist/react.js.map +1 -1
- package/dist/session.bundle.js +41 -0
- package/dist/session.d.ts +33 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +41 -0
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +59 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -11
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
- package/dist/wasm/_framework/System.Console.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Memory.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
- package/dist/wasm/_framework/System.wasm +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +41 -41
- package/dist/wasm/_framework/dotnet.js +1 -1
- package/dist/wasm/_framework/dotnet.js.map +1 -1
- package/dist/wasm/_framework/dotnet.native.js +3 -3
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.runtime.js +1 -1
- package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
- package/dist/wasm/index.html +2 -1
- package/package.json +1 -1
package/dist/editor.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* full fidelity, and save() is lossless for them.
|
|
18
18
|
*/
|
|
19
19
|
import { paginateHtml } from "./pagination.js";
|
|
20
|
+
import { HeaderFooterRegion } from "./editor-headerfooter.js";
|
|
20
21
|
const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
21
22
|
function fontWeightIsBold(w) {
|
|
22
23
|
if (w === "bold" || w === "bolder")
|
|
@@ -221,11 +222,36 @@ function blockContentText(block) {
|
|
|
221
222
|
* trimmed length keeps the split offset consistent with what was committed.
|
|
222
223
|
*/
|
|
223
224
|
function trimmedSplitOffset(block, domOffset) {
|
|
224
|
-
const
|
|
225
|
-
const leading = content.length - content.replace(/^\s+/, "").length;
|
|
226
|
-
const trimmedLen = content.trim().length;
|
|
225
|
+
const { leading, trimmedLen } = trimBounds(block);
|
|
227
226
|
return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
|
|
228
227
|
}
|
|
228
|
+
/** How far `block`'s DOM content text is offset from, and longer than, its committed form. */
|
|
229
|
+
function trimBounds(block) {
|
|
230
|
+
const content = blockContentText(block);
|
|
231
|
+
return {
|
|
232
|
+
leading: content.length - content.replace(/^\s+/, "").length,
|
|
233
|
+
trimmedLen: content.trim().length,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Map a DOM content SPAN into the run-text space the session holds after a commit — the span
|
|
238
|
+
* analogue of {@link trimmedSplitOffset}, and for the same reason.
|
|
239
|
+
*
|
|
240
|
+
* A block rendered with edge whitespace produces a span longer than the text the commit stores:
|
|
241
|
+
* an empty header/footer story renders as a lone NBSP placeholder, so typing into it leaves a
|
|
242
|
+
* trailing NBSP that `serializeInlineMarkdown(...).trim()` removes (JS `trim()` treats U+00A0 as
|
|
243
|
+
* whitespace). "Select all, then Bold" then asks `ApplyFormat` for [0, len+1) — one past the
|
|
244
|
+
* committed end — and the op is REJECTED with OffsetOutOfRange, so the format silently does
|
|
245
|
+
* nothing. The demo's format buttons preventDefault on mousedown to keep the selection alive,
|
|
246
|
+
* which is exactly the path that computes the span before `syncBlock` commits, so this is the
|
|
247
|
+
* ordinary case rather than an edge one.
|
|
248
|
+
*/
|
|
249
|
+
function trimmedSpan(block, span) {
|
|
250
|
+
const { leading, trimmedLen } = trimBounds(block);
|
|
251
|
+
const start = Math.max(0, Math.min(span.start - Math.min(span.start, leading), trimmedLen));
|
|
252
|
+
const end = Math.max(start, Math.min(span.start + span.length - leading, trimmedLen));
|
|
253
|
+
return { start, length: end - start };
|
|
254
|
+
}
|
|
229
255
|
/**
|
|
230
256
|
* DOM (node, offset) for content offset `offset` within `el` — the same content-offset
|
|
231
257
|
* space as contentOffsetOf (marker text and injected bidi marks excluded). Clamps past-end
|
|
@@ -314,7 +340,10 @@ function selectionSpanIn(block) {
|
|
|
314
340
|
return null;
|
|
315
341
|
const start = contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
316
342
|
const end = contentOffsetOf(block, range.endContainer, range.endOffset);
|
|
317
|
-
|
|
343
|
+
// Normalized into the committed run-text space: every consumer feeds this straight to a
|
|
344
|
+
// DocxSession op, which rejects a span that overshoots the committed length.
|
|
345
|
+
const span = trimmedSpan(block, { start: Math.min(start, end), length: Math.abs(end - start) });
|
|
346
|
+
return span.length > 0 ? span : null;
|
|
318
347
|
}
|
|
319
348
|
/** Restore a content-text selection spanning [start, start+length) within `el` (skips markers). */
|
|
320
349
|
function selectRange(el, start, length) {
|
|
@@ -422,6 +451,8 @@ export class DocxEditor {
|
|
|
422
451
|
* block, and cleared when a caret is collapsed inside a block (so it never goes stale).
|
|
423
452
|
*/
|
|
424
453
|
this.lastSelection = null;
|
|
454
|
+
/** The docked header/footer bands, when `options.headerFooter` is on. */
|
|
455
|
+
this.region = null;
|
|
425
456
|
/** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
|
|
426
457
|
this.onSelectionChange = () => {
|
|
427
458
|
if (this.closed)
|
|
@@ -452,13 +483,40 @@ export class DocxEditor {
|
|
|
452
483
|
if (typeof document !== "undefined")
|
|
453
484
|
document.addEventListener("selectionchange", this.onSelectionChange);
|
|
454
485
|
}
|
|
455
|
-
/** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
|
|
486
|
+
/** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
|
|
487
|
+
* Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
|
|
488
|
+
* body edit root by design — also register. The fence still rejects other editors on the page. */
|
|
456
489
|
editableBlockOf(node) {
|
|
457
490
|
if (!node)
|
|
458
491
|
return null;
|
|
459
492
|
const start = node.nodeType === 1 ? node : node.parentElement;
|
|
460
493
|
const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
|
|
461
|
-
return block && this.
|
|
494
|
+
return block && this.container.contains(block) ? block : null;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The root owning `el`'s sibling block list: its header/footer band's story container, else the
|
|
498
|
+
* body edit root. Keeps a multi-block selection from spanning a band and the body, whose block
|
|
499
|
+
* lists belong to different OOXML parts.
|
|
500
|
+
*/
|
|
501
|
+
ownerRoot(el) {
|
|
502
|
+
return this.region?.blockRootOf(el) ?? this.editRoot;
|
|
503
|
+
}
|
|
504
|
+
/** True when `el` is a header/footer band block rather than a body block. */
|
|
505
|
+
isBandBlock(el) {
|
|
506
|
+
return !!this.region?.contains(el);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Repaint after an edit to `block` that would otherwise remount the whole document: a band
|
|
510
|
+
* repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
|
|
511
|
+
* user's place in it — untouched.
|
|
512
|
+
*/
|
|
513
|
+
refreshAfter(block, focusIndex, caretAtEnd = false) {
|
|
514
|
+
const band = this.region?.bandOf(block);
|
|
515
|
+
if (band) {
|
|
516
|
+
this.region.refresh(this.region.whichOf(band));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
this.remount(focusIndex, caretAtEnd);
|
|
462
520
|
}
|
|
463
521
|
/** Open a document, render it into `container`, and wire up editing. */
|
|
464
522
|
static open(container, bytes, exports, options = {}) {
|
|
@@ -468,6 +526,7 @@ export class DocxEditor {
|
|
|
468
526
|
editable: options.editable ?? true,
|
|
469
527
|
paginated: options.paginated ?? false,
|
|
470
528
|
scale: options.scale ?? 1,
|
|
529
|
+
headerFooter: options.headerFooter ?? false,
|
|
471
530
|
onEdit: options.onEdit,
|
|
472
531
|
};
|
|
473
532
|
// persistAnchorIds=true keeps PtOpenXml:Unid attributes in Save() output, so a remount's
|
|
@@ -477,11 +536,14 @@ export class DocxEditor {
|
|
|
477
536
|
const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
|
|
478
537
|
const editor = new DocxEditor(container, exports, handle, opts);
|
|
479
538
|
editor.refreshAnchorMap();
|
|
539
|
+
if (opts.headerFooter)
|
|
540
|
+
editor.createRegion();
|
|
480
541
|
const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
|
|
481
542
|
if (opts.paginated)
|
|
482
543
|
editor.mountPaginated(fullHtml);
|
|
483
544
|
else
|
|
484
545
|
editor.mountHtml(fullHtml);
|
|
546
|
+
editor.syncRegionToBody();
|
|
485
547
|
return editor;
|
|
486
548
|
}
|
|
487
549
|
/**
|
|
@@ -527,28 +589,117 @@ export class DocxEditor {
|
|
|
527
589
|
if (this.closed)
|
|
528
590
|
throw new Error("DocxEditor is closed");
|
|
529
591
|
}
|
|
530
|
-
/**
|
|
592
|
+
/**
|
|
593
|
+
* Rebuild unid → full-anchor-id from the live session projection.
|
|
594
|
+
*
|
|
595
|
+
* Unids are CONTENT-ADDRESSED, so blocks with identical content in DIFFERENT parts collide —
|
|
596
|
+
* e.g. a document with empty default/first/even header stories has one unid for several
|
|
597
|
+
* header parts. A collision must resolve to the BODY entry: body blocks carry only
|
|
598
|
+
* `data-anchor` (the bare unid) and have nothing else to resolve through, whereas a
|
|
599
|
+
* header/footer band block carries its full anchor in `data-hf-anchor` and is resolved from
|
|
600
|
+
* that (see `anchorIdOf`). Letting a non-body scope win here would silently redirect a body
|
|
601
|
+
* edit into a header part.
|
|
602
|
+
*/
|
|
531
603
|
refreshAnchorMap() {
|
|
532
604
|
const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
|
|
533
605
|
this.unidToFullId.clear();
|
|
606
|
+
const bodyOwned = new Set();
|
|
534
607
|
for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
|
|
608
|
+
const isBody = target.scope === "body";
|
|
609
|
+
if (!isBody && bodyOwned.has(target.unid))
|
|
610
|
+
continue;
|
|
535
611
|
this.unidToFullId.set(target.unid, fullId);
|
|
612
|
+
if (isBody)
|
|
613
|
+
bodyOwned.add(target.unid);
|
|
536
614
|
}
|
|
537
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* The full `kind:scope:unid` anchor for a rendered block. A header/footer band block carries
|
|
618
|
+
* its own — the unid map cannot disambiguate one, since several parts' story paragraphs can
|
|
619
|
+
* share a content-addressed unid (a real Word document with empty default/first/even stories
|
|
620
|
+
* does exactly that, and a unid-keyed lookup would land the edit in the wrong header part).
|
|
621
|
+
*/
|
|
622
|
+
anchorIdOf(el) {
|
|
623
|
+
const stamped = el.getAttribute("data-hf-anchor");
|
|
624
|
+
if (stamped)
|
|
625
|
+
return stamped;
|
|
626
|
+
const unid = el.getAttribute("data-anchor");
|
|
627
|
+
return unid ? this.unidToFullId.get(unid) : undefined;
|
|
628
|
+
}
|
|
629
|
+
/** Build the header/footer region (called once, before the first mount). */
|
|
630
|
+
createRegion() {
|
|
631
|
+
this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, { cssPrefix: this.options.cssPrefix, fabricateClasses: this.options.fabricateClasses }, {
|
|
632
|
+
wireBlock: (el) => this.wireBlock(el),
|
|
633
|
+
refreshAnchorMap: () => this.refreshAnchorMap(),
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Point the bands at the section governing the current focus (or the first body block).
|
|
638
|
+
* Called after every mount, and on focus of a body block, so a multi-section document shows
|
|
639
|
+
* the stories that actually apply where the caret is.
|
|
640
|
+
*/
|
|
641
|
+
syncRegionToBody(fromBlock) {
|
|
642
|
+
if (!this.region)
|
|
643
|
+
return;
|
|
644
|
+
const block = fromBlock ?? this.editableList()[0];
|
|
645
|
+
const unid = block?.getAttribute("data-anchor");
|
|
646
|
+
const id = unid ? this.unidToFullId.get(unid) : undefined;
|
|
647
|
+
this.region.syncToBody(id ?? null);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Insert the bands around `bodyRoot` and make `bodyRoot` the edit root. Band blocks must stay
|
|
651
|
+
* OUT of the edit root: `editableList()`/`blockIndex()` enumerate it to compute remount focus
|
|
652
|
+
* indices, and band blocks in that list would shift every index.
|
|
653
|
+
*/
|
|
654
|
+
dockBands(bodyRoot) {
|
|
655
|
+
if (!this.region)
|
|
656
|
+
return;
|
|
657
|
+
bodyRoot.before(this.region.headerBand);
|
|
658
|
+
bodyRoot.after(this.region.footerBand);
|
|
659
|
+
this.region.refreshAll();
|
|
660
|
+
}
|
|
538
661
|
/** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
|
|
539
662
|
mountHtml(fullHtml) {
|
|
540
663
|
const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
|
|
541
664
|
const styles = Array.from(parsed.querySelectorAll("style"))
|
|
542
665
|
.map((s) => s.outerHTML)
|
|
543
666
|
.join("");
|
|
544
|
-
this.
|
|
545
|
-
|
|
667
|
+
if (!this.region) {
|
|
668
|
+
this.container.innerHTML = styles + parsed.body.innerHTML;
|
|
669
|
+
this.editRoot = this.container;
|
|
670
|
+
if (this.options.editable)
|
|
671
|
+
this.wireBlocks(this.container);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
// With bands docked, the body flow needs its own wrapper to be the edit root.
|
|
675
|
+
this.container.innerHTML = styles;
|
|
676
|
+
const flow = document.createElement("div");
|
|
677
|
+
flow.className = "docx-body-flow";
|
|
678
|
+
flow.innerHTML = parsed.body.innerHTML;
|
|
679
|
+
this.container.appendChild(flow);
|
|
680
|
+
this.editRoot = flow;
|
|
546
681
|
if (this.options.editable)
|
|
547
|
-
this.wireBlocks(
|
|
682
|
+
this.wireBlocks(flow);
|
|
683
|
+
this.dockBands(flow);
|
|
548
684
|
}
|
|
549
685
|
/** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
|
|
550
686
|
mountPaginated(fullHtml) {
|
|
551
|
-
|
|
687
|
+
// With bands docked, pagination writes into its own wrapper so the bands can sit outside the
|
|
688
|
+
// page stack (and so pagination's innerHTML reset can never eat them).
|
|
689
|
+
let target = this.container;
|
|
690
|
+
if (this.region) {
|
|
691
|
+
this.container.innerHTML = "";
|
|
692
|
+
target = document.createElement("div");
|
|
693
|
+
target.className = "docx-body-flow";
|
|
694
|
+
this.container.appendChild(target);
|
|
695
|
+
}
|
|
696
|
+
// Fragmented paragraphs intentionally have only one addressable head and
|
|
697
|
+
// are therefore unsuitable for the editor's one-block editing model.
|
|
698
|
+
paginateHtml(fullHtml, target, {
|
|
699
|
+
scale: this.options.scale,
|
|
700
|
+
cssPrefix: "page-",
|
|
701
|
+
fragmentParagraphs: false,
|
|
702
|
+
});
|
|
552
703
|
// pagination.ts measures the hidden #pagination-staging subtree ONCE, then flows CLONES of its
|
|
553
704
|
// blocks into the visible page boxes. Leaving staging in the live DOM is a trap: every
|
|
554
705
|
// data-anchor exists twice (staging + page-box copy), so document.querySelector('[data-anchor]')
|
|
@@ -557,10 +708,14 @@ export class DocxEditor {
|
|
|
557
708
|
// is a transient measurement scaffold; drop it so the page-box copies are the single source of
|
|
558
709
|
// truth. A remount (setPaginated, list/undo edits) rebuilds staging fresh from the live session.
|
|
559
710
|
this.container.querySelector("#pagination-staging, .page-staging")?.remove();
|
|
560
|
-
const pageRoot =
|
|
711
|
+
const pageRoot = target.querySelector("#pagination-container") ?? target;
|
|
561
712
|
this.editRoot = pageRoot;
|
|
562
713
|
if (this.options.editable)
|
|
563
714
|
this.wireBlocks(pageRoot);
|
|
715
|
+
// The page boxes render their own (read-only) header/footer margins; the editable bands dock
|
|
716
|
+
// around the page stack, so there is still exactly one addressable node per story paragraph.
|
|
717
|
+
if (this.region)
|
|
718
|
+
this.dockBands(target);
|
|
564
719
|
}
|
|
565
720
|
wireBlocks(root) {
|
|
566
721
|
root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
|
|
@@ -573,7 +728,9 @@ export class DocxEditor {
|
|
|
573
728
|
// table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
|
|
574
729
|
// keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
|
|
575
730
|
// table structure. Anything the projection does not index (unstamped content) stays read-only.
|
|
576
|
-
|
|
731
|
+
// A band block is authoritative via its stamped `data-hf-anchor` even when the unid map
|
|
732
|
+
// resolves that unid to a different part (content-addressed unids collide across parts).
|
|
733
|
+
if (!unid || !this.anchorIdOf(el))
|
|
577
734
|
return;
|
|
578
735
|
el.setAttribute("contenteditable", "true");
|
|
579
736
|
// Generated list markers (number/bullet + suffix) are not editable content — keep the
|
|
@@ -582,9 +739,21 @@ export class DocxEditor {
|
|
|
582
739
|
// Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
|
|
583
740
|
// matching the session's flat run-text offset space.
|
|
584
741
|
el.dataset.committedText = blockContentText(el);
|
|
585
|
-
el.addEventListener("focus", () => {
|
|
742
|
+
el.addEventListener("focus", () => {
|
|
743
|
+
this.activeBlock = el;
|
|
744
|
+
// Follow the caret's section so a cover-page-plus-body document shows the stories that
|
|
745
|
+
// actually apply. Focusing a BAND block must not re-sync: it has no governing section of
|
|
746
|
+
// its own, and re-resolving would clobber the user's kind selection.
|
|
747
|
+
if (this.region && !this.isBandBlock(el))
|
|
748
|
+
this.syncRegionToBody(el);
|
|
749
|
+
});
|
|
586
750
|
el.addEventListener("blur", () => this.commitBlock(el));
|
|
587
751
|
el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
|
|
752
|
+
// A band block re-rendered by an incremental swap is a fresh DOM node; re-adopt it (with the
|
|
753
|
+
// anchor its caller already stamped) so the band chrome can still address it.
|
|
754
|
+
const stamped = el.getAttribute("data-hf-anchor");
|
|
755
|
+
if (stamped && this.region?.contains(el))
|
|
756
|
+
this.region.adoptBlock(el, stamped);
|
|
588
757
|
}
|
|
589
758
|
/**
|
|
590
759
|
* Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
|
|
@@ -619,7 +788,7 @@ export class DocxEditor {
|
|
|
619
788
|
const unid = el.getAttribute("data-anchor");
|
|
620
789
|
if (!unid)
|
|
621
790
|
return;
|
|
622
|
-
const fullId = this.
|
|
791
|
+
const fullId = this.anchorIdOf(el);
|
|
623
792
|
if (!fullId)
|
|
624
793
|
return;
|
|
625
794
|
const result = this.commitTextChange(el, fullId);
|
|
@@ -651,9 +820,17 @@ export class DocxEditor {
|
|
|
651
820
|
const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
|
|
652
821
|
if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
|
|
653
822
|
const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
|
|
823
|
+
const inBand = this.isBandBlock(el);
|
|
654
824
|
if (fresh && this.replaceNode(el, fresh)) {
|
|
655
|
-
|
|
656
|
-
|
|
825
|
+
// Band blocks resolve through `data-hf-anchor`; their unid can collide with another
|
|
826
|
+
// part's, so writing it into the map would corrupt that entry (see anchorIdOf).
|
|
827
|
+
if (inBand) {
|
|
828
|
+
this.region.adoptBlock(fresh, newAnchor);
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
this.unidToFullId.delete(unid);
|
|
832
|
+
this.unidToFullId.set(newUnid, newAnchor);
|
|
833
|
+
}
|
|
657
834
|
this.wireBlock(fresh);
|
|
658
835
|
if (this.activeBlock === el)
|
|
659
836
|
this.activeBlock = fresh; // keep ribbon target valid
|
|
@@ -748,7 +925,7 @@ export class DocxEditor {
|
|
|
748
925
|
const unid = el.getAttribute("data-anchor");
|
|
749
926
|
if (rawOffset == null || !unid)
|
|
750
927
|
return;
|
|
751
|
-
let fullId = this.
|
|
928
|
+
let fullId = this.anchorIdOf(el);
|
|
752
929
|
if (!fullId)
|
|
753
930
|
return;
|
|
754
931
|
// The session commits trimmed text, so map the DOM caret offset into the trimmed run-text
|
|
@@ -770,7 +947,7 @@ export class DocxEditor {
|
|
|
770
947
|
// document re-render so numbering continues / border <div>s regroup correctly. An in-place node
|
|
771
948
|
// swap would leave the new paragraph inside the old border div (the rule's line under its text).
|
|
772
949
|
if (this.affectsList(res) || wrappedInBorder) {
|
|
773
|
-
this.
|
|
950
|
+
this.refreshAfter(el, idx + 1, false);
|
|
774
951
|
this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
|
|
775
952
|
return;
|
|
776
953
|
}
|
|
@@ -780,11 +957,19 @@ export class DocxEditor {
|
|
|
780
957
|
return;
|
|
781
958
|
// el is the focused block — replaceNode guards the re-entrant blur→commit and tolerates a
|
|
782
959
|
// node detached mid-focus-transfer; replacing with both new blocks at once keeps them adjacent.
|
|
960
|
+
const inBand = this.isBandBlock(el);
|
|
783
961
|
if (!this.replaceNode(el, firstEl, secondEl))
|
|
784
962
|
return;
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
963
|
+
// Band blocks resolve through `data-hf-anchor` (their unids can collide across parts).
|
|
964
|
+
if (inBand) {
|
|
965
|
+
this.region.adoptBlock(firstEl, first.id);
|
|
966
|
+
this.region.adoptBlock(secondEl, second.id);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
this.unidToFullId.delete(unid);
|
|
970
|
+
this.unidToFullId.set(first.unid, first.id);
|
|
971
|
+
this.unidToFullId.set(second.unid, second.id);
|
|
972
|
+
}
|
|
788
973
|
this.wireBlock(firstEl);
|
|
789
974
|
this.wireBlock(secondEl);
|
|
790
975
|
placeCaretAtOffset(secondEl, 0);
|
|
@@ -796,8 +981,8 @@ export class DocxEditor {
|
|
|
796
981
|
const thisUnid = el.getAttribute("data-anchor");
|
|
797
982
|
if (!prevUnid || !thisUnid)
|
|
798
983
|
return;
|
|
799
|
-
let prevId = this.
|
|
800
|
-
let thisId = this.
|
|
984
|
+
let prevId = this.anchorIdOf(prev);
|
|
985
|
+
let thisId = this.anchorIdOf(el);
|
|
801
986
|
if (!prevId || !thisId)
|
|
802
987
|
return;
|
|
803
988
|
const prevIdx = this.blockIndex(prev); // capture before the op
|
|
@@ -816,7 +1001,7 @@ export class DocxEditor {
|
|
|
816
1001
|
// Merging list items renumbers the list, and merging across a border <div> boundary changes the
|
|
817
1002
|
// border grouping — both need a whole-document re-render (caret at the merge boundary).
|
|
818
1003
|
if (this.affectsList(res) || wrappedInBorder) {
|
|
819
|
-
this.
|
|
1004
|
+
this.refreshAfter(el, prevIdx, true);
|
|
820
1005
|
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
821
1006
|
return;
|
|
822
1007
|
}
|
|
@@ -824,12 +1009,19 @@ export class DocxEditor {
|
|
|
824
1009
|
if (!mergedEl)
|
|
825
1010
|
return;
|
|
826
1011
|
// prev may be focused — replaceNode guards re-entrancy and tolerates a detached node.
|
|
1012
|
+
const inBand = this.isBandBlock(prev);
|
|
827
1013
|
if (!this.replaceNode(prev, mergedEl))
|
|
828
1014
|
return;
|
|
829
1015
|
el.remove();
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1016
|
+
// Band blocks resolve through `data-hf-anchor` (their unids can collide across parts).
|
|
1017
|
+
if (inBand) {
|
|
1018
|
+
this.region.adoptBlock(mergedEl, merged.id);
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
this.unidToFullId.delete(prevUnid);
|
|
1022
|
+
this.unidToFullId.delete(thisUnid);
|
|
1023
|
+
this.unidToFullId.set(merged.unid, merged.id);
|
|
1024
|
+
}
|
|
833
1025
|
this.wireBlock(mergedEl);
|
|
834
1026
|
placeCaretAtOffset(mergedEl, caret);
|
|
835
1027
|
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
@@ -896,9 +1088,9 @@ export class DocxEditor {
|
|
|
896
1088
|
return null;
|
|
897
1089
|
return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
|
|
898
1090
|
}
|
|
899
|
-
/** The editable block immediately before `el`
|
|
1091
|
+
/** The editable block immediately before `el` within its own root, or null. */
|
|
900
1092
|
previousEditable(el) {
|
|
901
|
-
const all = Array.from(this.
|
|
1093
|
+
const all = Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
902
1094
|
const i = all.indexOf(el);
|
|
903
1095
|
return i > 0 ? all[i - 1] : null;
|
|
904
1096
|
}
|
|
@@ -918,7 +1110,10 @@ export class DocxEditor {
|
|
|
918
1110
|
* A collapsed or single-block selection yields just the active block. */
|
|
919
1111
|
selectedBlocks() {
|
|
920
1112
|
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
921
|
-
|
|
1113
|
+
// Enumerate the ACTIVE block's own root — a band's story container, else the body edit root —
|
|
1114
|
+
// so a selection can never span a band and the body (different OOXML parts).
|
|
1115
|
+
const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
|
|
1116
|
+
const all = Array.from(root.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
922
1117
|
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
|
923
1118
|
const range = sel.getRangeAt(0);
|
|
924
1119
|
const hit = all.filter((b) => {
|
|
@@ -948,16 +1143,18 @@ export class DocxEditor {
|
|
|
948
1143
|
const hasEnd = block.contains(range.endContainer);
|
|
949
1144
|
if (hasStart && hasEnd)
|
|
950
1145
|
return selectionSpanIn(block);
|
|
1146
|
+
// Partial/whole slices are normalized into the committed run-text space for the same reason
|
|
1147
|
+
// selectionSpanIn is (see trimmedSpan): a session op rejects a span past the committed end.
|
|
951
1148
|
const contentLen = blockContentText(block).length;
|
|
952
1149
|
if (hasStart) {
|
|
953
1150
|
const start = contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
954
|
-
return { start, length: Math.max(0, contentLen - start) };
|
|
1151
|
+
return trimmedSpan(block, { start, length: Math.max(0, contentLen - start) });
|
|
955
1152
|
}
|
|
956
1153
|
if (hasEnd) {
|
|
957
1154
|
const end = contentOffsetOf(block, range.endContainer, range.endOffset);
|
|
958
|
-
return { start: 0, length: end };
|
|
1155
|
+
return trimmedSpan(block, { start: 0, length: end });
|
|
959
1156
|
}
|
|
960
|
-
return { start: 0, length: contentLen }; // fully-spanned middle block
|
|
1157
|
+
return trimmedSpan(block, { start: 0, length: contentLen }); // fully-spanned middle block
|
|
961
1158
|
}
|
|
962
1159
|
/** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
|
|
963
1160
|
* the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
|
|
@@ -1005,7 +1202,7 @@ export class DocxEditor {
|
|
|
1005
1202
|
return {
|
|
1006
1203
|
block: b,
|
|
1007
1204
|
unid,
|
|
1008
|
-
fullId:
|
|
1205
|
+
fullId: this.anchorIdOf(b),
|
|
1009
1206
|
span: this.blockSpanForSelection(b),
|
|
1010
1207
|
res: null,
|
|
1011
1208
|
};
|
|
@@ -1029,7 +1226,7 @@ export class DocxEditor {
|
|
|
1029
1226
|
// need a reflow — keep the (pre-existing) full remount there until M4 lands a scoped
|
|
1030
1227
|
// re-paginate. Continuous mode reconciles incrementally.
|
|
1031
1228
|
if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
|
|
1032
|
-
this.
|
|
1229
|
+
this.refreshAfter(edited[0].block, -1, false);
|
|
1033
1230
|
return;
|
|
1034
1231
|
}
|
|
1035
1232
|
const swapped = [];
|
|
@@ -1081,7 +1278,7 @@ export class DocxEditor {
|
|
|
1081
1278
|
const unid = block.getAttribute("data-anchor");
|
|
1082
1279
|
if (!unid)
|
|
1083
1280
|
return;
|
|
1084
|
-
let fullId = this.
|
|
1281
|
+
let fullId = this.anchorIdOf(block);
|
|
1085
1282
|
if (!fullId)
|
|
1086
1283
|
return;
|
|
1087
1284
|
const span = selectionSpanIn(block);
|
|
@@ -1095,7 +1292,7 @@ export class DocxEditor {
|
|
|
1095
1292
|
if (!res.success)
|
|
1096
1293
|
return;
|
|
1097
1294
|
if (this.affectsList(res)) {
|
|
1098
|
-
this.
|
|
1295
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
1099
1296
|
return;
|
|
1100
1297
|
}
|
|
1101
1298
|
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
@@ -1119,7 +1316,7 @@ export class DocxEditor {
|
|
|
1119
1316
|
const unid = block.getAttribute("data-anchor");
|
|
1120
1317
|
if (!unid)
|
|
1121
1318
|
return;
|
|
1122
|
-
let fullId = this.
|
|
1319
|
+
let fullId = this.anchorIdOf(block);
|
|
1123
1320
|
if (!fullId)
|
|
1124
1321
|
return;
|
|
1125
1322
|
// Use the live selection; if the font-size combobox stole focus and collapsed it, fall back to
|
|
@@ -1132,7 +1329,7 @@ export class DocxEditor {
|
|
|
1132
1329
|
if (!res.success)
|
|
1133
1330
|
return;
|
|
1134
1331
|
if (this.affectsList(res)) {
|
|
1135
|
-
this.
|
|
1332
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
1136
1333
|
return;
|
|
1137
1334
|
}
|
|
1138
1335
|
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
@@ -1158,7 +1355,7 @@ export class DocxEditor {
|
|
|
1158
1355
|
const unid = block.getAttribute("data-anchor");
|
|
1159
1356
|
if (!unid)
|
|
1160
1357
|
return;
|
|
1161
|
-
let fullId = this.
|
|
1358
|
+
let fullId = this.anchorIdOf(block);
|
|
1162
1359
|
if (!fullId)
|
|
1163
1360
|
return;
|
|
1164
1361
|
let span = selectionSpanIn(block);
|
|
@@ -1169,7 +1366,7 @@ export class DocxEditor {
|
|
|
1169
1366
|
if (!res.success)
|
|
1170
1367
|
return;
|
|
1171
1368
|
if (this.affectsList(res)) {
|
|
1172
|
-
this.
|
|
1369
|
+
this.refreshAfter(block, this.blockIndex(block), false);
|
|
1173
1370
|
return;
|
|
1174
1371
|
}
|
|
1175
1372
|
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
@@ -1194,7 +1391,7 @@ export class DocxEditor {
|
|
|
1194
1391
|
const unid = block.getAttribute("data-anchor");
|
|
1195
1392
|
if (!unid)
|
|
1196
1393
|
return;
|
|
1197
|
-
let fullId = this.
|
|
1394
|
+
let fullId = this.anchorIdOf(block);
|
|
1198
1395
|
if (!fullId)
|
|
1199
1396
|
return;
|
|
1200
1397
|
const idx = this.blockIndex(block);
|
|
@@ -1204,7 +1401,7 @@ export class DocxEditor {
|
|
|
1204
1401
|
return;
|
|
1205
1402
|
// remount from the active block's index re-renders the new rule whether it landed just
|
|
1206
1403
|
// above (at idx) or just below (at idx+1) the active block.
|
|
1207
|
-
this.
|
|
1404
|
+
this.refreshAfter(block, idx, false);
|
|
1208
1405
|
}
|
|
1209
1406
|
/**
|
|
1210
1407
|
* Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
|
|
@@ -1218,7 +1415,7 @@ export class DocxEditor {
|
|
|
1218
1415
|
const unid = block.getAttribute("data-anchor");
|
|
1219
1416
|
if (!unid)
|
|
1220
1417
|
return;
|
|
1221
|
-
let fullId = this.
|
|
1418
|
+
let fullId = this.anchorIdOf(block);
|
|
1222
1419
|
if (!fullId)
|
|
1223
1420
|
return;
|
|
1224
1421
|
const idx = this.blockIndex(block);
|
|
@@ -1230,7 +1427,7 @@ export class DocxEditor {
|
|
|
1230
1427
|
const res = this.parseEdit(this.exports.DocxSessionBridge.InsertTable(this.handle, fullId, emptyHere ? "before" : "after", rows, cols, options ? JSON.stringify(options) : ""));
|
|
1231
1428
|
if (!res.success)
|
|
1232
1429
|
return;
|
|
1233
|
-
this.
|
|
1430
|
+
this.refreshAfter(block, idx, false);
|
|
1234
1431
|
}
|
|
1235
1432
|
// ─── Table row / column editing (active block must be inside a table cell) ──────────
|
|
1236
1433
|
/** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
|
|
@@ -1241,7 +1438,7 @@ export class DocxEditor {
|
|
|
1241
1438
|
const unid = block.getAttribute("data-anchor");
|
|
1242
1439
|
if (!unid)
|
|
1243
1440
|
return;
|
|
1244
|
-
let fullId = this.
|
|
1441
|
+
let fullId = this.anchorIdOf(block);
|
|
1245
1442
|
if (!fullId)
|
|
1246
1443
|
return;
|
|
1247
1444
|
const idx = this.blockIndex(block);
|
|
@@ -1249,7 +1446,7 @@ export class DocxEditor {
|
|
|
1249
1446
|
const res = this.parseEdit(run(fullId));
|
|
1250
1447
|
if (!res.success)
|
|
1251
1448
|
return;
|
|
1252
|
-
this.
|
|
1449
|
+
this.refreshAfter(block, idx, false);
|
|
1253
1450
|
}
|
|
1254
1451
|
/** Insert a row above/below the active cell's row. No-op outside a table. */
|
|
1255
1452
|
insertTableRow(where) {
|
|
@@ -1288,7 +1485,7 @@ export class DocxEditor {
|
|
|
1288
1485
|
const unid = block.getAttribute("data-anchor");
|
|
1289
1486
|
if (!unid)
|
|
1290
1487
|
return;
|
|
1291
|
-
let fullId = this.
|
|
1488
|
+
let fullId = this.anchorIdOf(block);
|
|
1292
1489
|
if (!fullId)
|
|
1293
1490
|
return;
|
|
1294
1491
|
const idx = this.blockIndex(block);
|
|
@@ -1298,7 +1495,7 @@ export class DocxEditor {
|
|
|
1298
1495
|
return;
|
|
1299
1496
|
// A level change ripples through the whole list's numbering — re-render with full document
|
|
1300
1497
|
// context (a single-block render can't compute nested numbering), keeping the caret in place.
|
|
1301
|
-
this.
|
|
1498
|
+
this.refreshAfter(block, idx, false);
|
|
1302
1499
|
}
|
|
1303
1500
|
/** Toggle (or set) page-break-before on the active block. */
|
|
1304
1501
|
pageBreakBefore(value = true) {
|
|
@@ -1315,7 +1512,7 @@ export class DocxEditor {
|
|
|
1315
1512
|
const unid = block.getAttribute("data-anchor");
|
|
1316
1513
|
if (!unid)
|
|
1317
1514
|
return;
|
|
1318
|
-
let fullId = this.
|
|
1515
|
+
let fullId = this.anchorIdOf(block);
|
|
1319
1516
|
if (!fullId)
|
|
1320
1517
|
return;
|
|
1321
1518
|
let membership = null;
|
|
@@ -1332,7 +1529,7 @@ export class DocxEditor {
|
|
|
1332
1529
|
return;
|
|
1333
1530
|
// Numbering continuation across the list needs whole-document context — re-render fully
|
|
1334
1531
|
// (a single-block render would show every numbered item as "1.").
|
|
1335
|
-
this.
|
|
1532
|
+
this.refreshAfter(block, idx, false);
|
|
1336
1533
|
}
|
|
1337
1534
|
/** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
|
|
1338
1535
|
* or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
|
|
@@ -1358,14 +1555,14 @@ export class DocxEditor {
|
|
|
1358
1555
|
const unid = block.getAttribute("data-anchor");
|
|
1359
1556
|
if (!unid)
|
|
1360
1557
|
return;
|
|
1361
|
-
const fullId = this.
|
|
1558
|
+
const fullId = this.anchorIdOf(block);
|
|
1362
1559
|
if (!fullId)
|
|
1363
1560
|
return;
|
|
1364
1561
|
const idx = this.blockIndex(block);
|
|
1365
1562
|
const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
|
|
1366
1563
|
if (!res.success)
|
|
1367
1564
|
return;
|
|
1368
|
-
this.
|
|
1565
|
+
this.refreshAfter(block, Math.max(0, idx - 1), true);
|
|
1369
1566
|
}
|
|
1370
1567
|
applyParagraphFormat(op) {
|
|
1371
1568
|
const block = this.activeBlock;
|
|
@@ -1380,7 +1577,7 @@ export class DocxEditor {
|
|
|
1380
1577
|
const unid = block.getAttribute("data-anchor");
|
|
1381
1578
|
if (!unid)
|
|
1382
1579
|
return;
|
|
1383
|
-
let fullId = this.
|
|
1580
|
+
let fullId = this.anchorIdOf(block);
|
|
1384
1581
|
if (!fullId)
|
|
1385
1582
|
return;
|
|
1386
1583
|
const idx = this.blockIndex(block);
|
|
@@ -1391,7 +1588,7 @@ export class DocxEditor {
|
|
|
1391
1588
|
// A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
|
|
1392
1589
|
// it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
|
|
1393
1590
|
if (this.affectsList(res) || op.clearBorders) {
|
|
1394
|
-
this.
|
|
1591
|
+
this.refreshAfter(block, idx, false);
|
|
1395
1592
|
return;
|
|
1396
1593
|
}
|
|
1397
1594
|
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
@@ -1409,7 +1606,7 @@ export class DocxEditor {
|
|
|
1409
1606
|
const unid = block.getAttribute("data-anchor");
|
|
1410
1607
|
if (!unid)
|
|
1411
1608
|
return;
|
|
1412
|
-
let fullId = this.
|
|
1609
|
+
let fullId = this.anchorIdOf(block);
|
|
1413
1610
|
if (!fullId)
|
|
1414
1611
|
return;
|
|
1415
1612
|
const idx = this.blockIndex(block);
|
|
@@ -1418,7 +1615,7 @@ export class DocxEditor {
|
|
|
1418
1615
|
if (!res.success)
|
|
1419
1616
|
return;
|
|
1420
1617
|
if (this.affectsList(res)) {
|
|
1421
|
-
this.
|
|
1618
|
+
this.refreshAfter(block, idx, false);
|
|
1422
1619
|
return;
|
|
1423
1620
|
}
|
|
1424
1621
|
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
@@ -1437,6 +1634,39 @@ export class DocxEditor {
|
|
|
1437
1634
|
if (this.exports.DocxSessionBridge.Redo(this.handle))
|
|
1438
1635
|
this.remount();
|
|
1439
1636
|
}
|
|
1637
|
+
// ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
|
|
1638
|
+
/**
|
|
1639
|
+
* Select which story kind a band edits (`"default"` / `"first"` / `"even"`). A kind with no
|
|
1640
|
+
* existing part is created empty, so the band always presents something editable. `"first"`
|
|
1641
|
+
* sets the section's `w:titlePg`; `"even"` sets the document-global `w:evenAndOddHeaders`
|
|
1642
|
+
* (which also governs footers — the band surfaces that caveat inline).
|
|
1643
|
+
*/
|
|
1644
|
+
setHeaderFooterKind(which, kind) {
|
|
1645
|
+
this.assertOpen();
|
|
1646
|
+
this.region?.setKind(which, kind);
|
|
1647
|
+
}
|
|
1648
|
+
/** The story kind a band is currently editing, or null when the region is off. */
|
|
1649
|
+
headerFooterKind(which) {
|
|
1650
|
+
return this.region?.kindOf(which) ?? null;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Append a page-number field to the focused header/footer story paragraph (falling back to the
|
|
1654
|
+
* band's last paragraph — Word's convention). No-op outside a band.
|
|
1655
|
+
*/
|
|
1656
|
+
insertPageNumber(field = "currentPage") {
|
|
1657
|
+
this.assertOpen();
|
|
1658
|
+
if (!this.region)
|
|
1659
|
+
return;
|
|
1660
|
+
const block = this.activeBlock;
|
|
1661
|
+
const band = block ? this.region.bandOf(block) : null;
|
|
1662
|
+
const anchorId = block?.getAttribute("data-hf-anchor");
|
|
1663
|
+
if (band && anchorId) {
|
|
1664
|
+
this.region.insertPageNumber(this.region.whichOf(band), anchorId, field);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
// No band block focused — target the footer, where page numbers overwhelmingly live.
|
|
1668
|
+
this.region.insertPageNumberInBand("footer", field);
|
|
1669
|
+
}
|
|
1440
1670
|
/** Which inline formats the current selection carries — for ribbon button highlighting. */
|
|
1441
1671
|
queryFormatState() {
|
|
1442
1672
|
const block = this.activeBlock ?? this.editRoot;
|
|
@@ -1452,15 +1682,23 @@ export class DocxEditor {
|
|
|
1452
1682
|
}
|
|
1453
1683
|
/** Re-render one block from the live session by EditResult ref, swapping it in place. */
|
|
1454
1684
|
swapBlock(oldEl, oldUnid, ref) {
|
|
1455
|
-
const
|
|
1685
|
+
const inBand = this.isBandBlock(oldEl);
|
|
1686
|
+
const anchorId = ref?.id ?? this.anchorIdOf(oldEl);
|
|
1456
1687
|
const newUnid = ref?.unid ?? oldUnid;
|
|
1457
1688
|
if (!anchorId)
|
|
1458
1689
|
return null;
|
|
1459
1690
|
const fresh = this.renderInto(anchorId);
|
|
1460
1691
|
if (!fresh || !this.replaceNode(oldEl, fresh))
|
|
1461
1692
|
return null;
|
|
1462
|
-
|
|
1463
|
-
|
|
1693
|
+
// A band block resolves through its stamped `data-hf-anchor`, never the unid map — and its
|
|
1694
|
+
// unid can collide with another part's, so writing it here would corrupt that entry.
|
|
1695
|
+
if (!inBand) {
|
|
1696
|
+
this.unidToFullId.delete(oldUnid);
|
|
1697
|
+
this.unidToFullId.set(newUnid, anchorId);
|
|
1698
|
+
}
|
|
1699
|
+
// Stamp BEFORE wiring so wireBlock's own resolution sees the authoritative id.
|
|
1700
|
+
if (inBand)
|
|
1701
|
+
this.region.adoptBlock(fresh, anchorId);
|
|
1464
1702
|
this.wireBlock(fresh);
|
|
1465
1703
|
this.activeBlock = fresh;
|
|
1466
1704
|
this.options.onEdit?.({ anchorId, unid: newUnid });
|
|
@@ -1483,12 +1721,12 @@ export class DocxEditor {
|
|
|
1483
1721
|
const bytes = bridge.Save(this.handle);
|
|
1484
1722
|
return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
|
|
1485
1723
|
}
|
|
1486
|
-
/** Editable blocks in document order. */
|
|
1724
|
+
/** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
|
|
1487
1725
|
editableList() {
|
|
1488
1726
|
return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
1489
1727
|
}
|
|
1490
1728
|
blockIndex(el) {
|
|
1491
|
-
return this.
|
|
1729
|
+
return Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')).indexOf(el);
|
|
1492
1730
|
}
|
|
1493
1731
|
/**
|
|
1494
1732
|
* True when an edit produced or touched a list item (kind "li"). List markers and
|
|
@@ -1520,6 +1758,9 @@ export class DocxEditor {
|
|
|
1520
1758
|
placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
|
|
1521
1759
|
}
|
|
1522
1760
|
}
|
|
1761
|
+
// A remount rebuilds the body from the live session; re-resolve the section so undo/redo of a
|
|
1762
|
+
// section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
|
|
1763
|
+
this.syncRegionToBody(this.activeBlock ?? undefined);
|
|
1523
1764
|
}
|
|
1524
1765
|
}
|
|
1525
1766
|
//# sourceMappingURL=editor.js.map
|