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.
Files changed (80) hide show
  1. package/dist/docxodus.worker.js +2 -2
  2. package/dist/docxodus.worker.js.map +1 -1
  3. package/dist/editor-headerfooter.d.ts +130 -0
  4. package/dist/editor-headerfooter.d.ts.map +1 -0
  5. package/dist/editor-headerfooter.js +414 -0
  6. package/dist/editor-headerfooter.js.map +1 -0
  7. package/dist/editor.bundle.js +1075 -81
  8. package/dist/editor.d.ts +82 -4
  9. package/dist/editor.d.ts.map +1 -1
  10. package/dist/editor.js +304 -63
  11. package/dist/editor.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/pagination.bundle.js +480 -19
  16. package/dist/pagination.d.ts +81 -0
  17. package/dist/pagination.d.ts.map +1 -1
  18. package/dist/pagination.js +528 -26
  19. package/dist/pagination.js.map +1 -1
  20. package/dist/react.d.ts +3 -1
  21. package/dist/react.d.ts.map +1 -1
  22. package/dist/react.js +6 -4
  23. package/dist/react.js.map +1 -1
  24. package/dist/session.bundle.js +41 -0
  25. package/dist/session.d.ts +33 -1
  26. package/dist/session.d.ts.map +1 -1
  27. package/dist/session.js +41 -0
  28. package/dist/session.js.map +1 -1
  29. package/dist/types.d.ts +59 -10
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/types.js +9 -11
  32. package/dist/types.js.map +1 -1
  33. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  34. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  35. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  36. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  37. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  38. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  39. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  40. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  41. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  42. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  43. package/dist/wasm/_framework/System.Console.wasm +0 -0
  44. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  45. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  46. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  47. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  48. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  49. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  50. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  51. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  52. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  53. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  54. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  55. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  56. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  57. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  58. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  59. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  60. package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
  61. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  62. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  63. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  64. package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
  65. package/dist/wasm/_framework/System.Threading.wasm +0 -0
  66. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  67. package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
  68. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  69. package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
  70. package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
  71. package/dist/wasm/_framework/System.wasm +0 -0
  72. package/dist/wasm/_framework/dotnet.boot.js +41 -41
  73. package/dist/wasm/_framework/dotnet.js +1 -1
  74. package/dist/wasm/_framework/dotnet.js.map +1 -1
  75. package/dist/wasm/_framework/dotnet.native.js +3 -3
  76. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  77. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  78. package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
  79. package/dist/wasm/index.html +2 -1
  80. 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 content = blockContentText(block);
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
- return { start: Math.min(start, end), length: Math.abs(end - start) };
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.editRoot.contains(block) ? block : null;
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
- /** Rebuild unid → full-anchor-id from the live session projection. */
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.container.innerHTML = styles + parsed.body.innerHTML;
545
- this.editRoot = this.container;
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(this.container);
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
- paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
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 = this.container.querySelector("#pagination-container") ?? this.container;
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
- if (!unid || !this.unidToFullId.has(unid))
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", () => { this.activeBlock = el; });
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.unidToFullId.get(unid);
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
- this.unidToFullId.delete(unid);
656
- this.unidToFullId.set(newUnid, newAnchor);
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.unidToFullId.get(unid);
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.remount(idx + 1, false);
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
- this.unidToFullId.delete(unid);
786
- this.unidToFullId.set(first.unid, first.id);
787
- this.unidToFullId.set(second.unid, second.id);
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.unidToFullId.get(prevUnid);
800
- let thisId = this.unidToFullId.get(thisUnid);
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.remount(prevIdx, true);
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
- this.unidToFullId.delete(prevUnid);
831
- this.unidToFullId.delete(thisUnid);
832
- this.unidToFullId.set(merged.unid, merged.id);
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` in document order, or null. */
1091
+ /** The editable block immediately before `el` within its own root, or null. */
900
1092
  previousEditable(el) {
901
- const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
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
- const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
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: unid ? this.unidToFullId.get(unid) : undefined,
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.remount();
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.unidToFullId.get(unid);
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.remount(this.blockIndex(block), false);
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.unidToFullId.get(unid);
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.remount(this.blockIndex(block), false);
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.unidToFullId.get(unid);
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.remount(this.blockIndex(block), false);
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.unidToFullId.get(unid);
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.remount(idx, false);
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.unidToFullId.get(unid);
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.remount(idx, false);
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.unidToFullId.get(unid);
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.remount(idx, false);
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.unidToFullId.get(unid);
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.remount(idx, false);
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.unidToFullId.get(unid);
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.remount(idx, false);
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.unidToFullId.get(unid);
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.remount(Math.max(0, idx - 1), true);
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.unidToFullId.get(unid);
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.remount(idx, false);
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.unidToFullId.get(unid);
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.remount(idx, false);
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 anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
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
- this.unidToFullId.delete(oldUnid);
1463
- this.unidToFullId.set(newUnid, anchorId);
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.editableList().indexOf(el);
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