docxodus 7.1.0 → 9.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 (91) hide show
  1. package/README.md +65 -24
  2. package/dist/docxodus.worker.js +2 -2
  3. package/dist/docxodus.worker.js.map +1 -1
  4. package/dist/editor-headerfooter.d.ts +166 -0
  5. package/dist/editor-headerfooter.d.ts.map +1 -0
  6. package/dist/editor-headerfooter.js +530 -0
  7. package/dist/editor-headerfooter.js.map +1 -0
  8. package/dist/editor-reconcile.d.ts +75 -0
  9. package/dist/editor-reconcile.d.ts.map +1 -0
  10. package/dist/editor-reconcile.js +125 -0
  11. package/dist/editor-reconcile.js.map +1 -0
  12. package/dist/editor.bundle.js +1861 -110
  13. package/dist/editor.d.ts +200 -6
  14. package/dist/editor.d.ts.map +1 -1
  15. package/dist/editor.js +868 -86
  16. package/dist/editor.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/page-number-format.d.ts +22 -0
  22. package/dist/page-number-format.d.ts.map +1 -0
  23. package/dist/page-number-format.js +60 -0
  24. package/dist/page-number-format.js.map +1 -0
  25. package/dist/pagination.bundle.js +635 -37
  26. package/dist/pagination.d.ts +119 -0
  27. package/dist/pagination.d.ts.map +1 -1
  28. package/dist/pagination.js +706 -52
  29. package/dist/pagination.js.map +1 -1
  30. package/dist/react.d.ts +3 -1
  31. package/dist/react.d.ts.map +1 -1
  32. package/dist/react.js +6 -4
  33. package/dist/react.js.map +1 -1
  34. package/dist/session.bundle.js +241 -4
  35. package/dist/session.d.ts +160 -6
  36. package/dist/session.d.ts.map +1 -1
  37. package/dist/session.js +202 -4
  38. package/dist/session.js.map +1 -1
  39. package/dist/types.d.ts +238 -14
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +9 -11
  42. package/dist/types.js.map +1 -1
  43. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  44. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  45. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  46. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  47. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  48. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  49. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  50. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  51. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  52. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  53. package/dist/wasm/_framework/System.Console.wasm +0 -0
  54. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  55. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  56. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  57. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  58. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  59. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  60. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  61. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  62. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  63. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  64. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  65. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  66. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  67. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  68. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  69. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  70. package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
  71. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  72. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  73. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  74. package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
  75. package/dist/wasm/_framework/System.Threading.wasm +0 -0
  76. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  77. package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
  78. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  79. package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
  80. package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
  81. package/dist/wasm/_framework/System.wasm +0 -0
  82. package/dist/wasm/_framework/dotnet.boot.js +41 -41
  83. package/dist/wasm/_framework/dotnet.js +1 -1
  84. package/dist/wasm/_framework/dotnet.js.map +1 -1
  85. package/dist/wasm/_framework/dotnet.native.js +3 -3
  86. package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
  87. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  88. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  89. package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
  90. package/dist/wasm/index.html +2 -1
  91. package/package.json +17 -4
package/dist/editor.js CHANGED
@@ -17,6 +17,8 @@
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";
21
+ import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
20
22
  const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
21
23
  function fontWeightIsBold(w) {
22
24
  if (w === "bold" || w === "bolder")
@@ -30,8 +32,9 @@ function escapeInlineMarkdown(text) {
30
32
  }
31
33
  function collectInlineSegments(node, out) {
32
34
  node.childNodes.forEach((child) => {
33
- // Skip generated list-marker spans — they aren't part of the paragraph's content.
34
- if (child.nodeType === 1 && child.hasAttribute?.("data-list-marker"))
35
+ // Skip converter-generated chrome (list markers, note citation markers, note backrefs) —
36
+ // it isn't part of the paragraph's content and must never be committed as text.
37
+ if (isGeneratedChrome(child))
35
38
  return;
36
39
  if (child.nodeType === 3 /* TEXT_NODE */) {
37
40
  const text = child.textContent ?? "";
@@ -111,11 +114,27 @@ export function serializeInlineMarkdown(block) {
111
114
  function isListBlock(block) {
112
115
  return !!block.querySelector(":scope > [data-list-marker]");
113
116
  }
114
- /** True if `node` is, or is inside, a generated list-marker span (not editable content). */
117
+ /**
118
+ * Inline chrome the CONVERTER generates that is not part of a paragraph's run text: list
119
+ * number/bullet markers, footnote/endnote citation markers
120
+ * (`<a class="footnote-ref"><sup>1</sup></a>`), and the note backrefs (`↩`).
121
+ *
122
+ * The session's run text contains none of it. A citation is a zero-width
123
+ * `w:footnoteReference` — the displayed number is computed by the renderer from document order —
124
+ * so every character of chrome the editor fails to exclude shifts its content-offset space away
125
+ * from the session's. Each omission has its own failure mode: excluded from offsets but not from
126
+ * serialization and the display number gets COMMITTED as literal text (destroying the citation
127
+ * run); left editable and the user can delete a marker outright, orphaning the note.
128
+ */
129
+ const GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"]';
130
+ function isGeneratedChrome(node) {
131
+ return node?.nodeType === 1 && !!node.matches?.(GENERATED_CHROME_SELECTOR);
132
+ }
133
+ /** True if `node` is, or is inside, generated chrome (not editable content). */
115
134
  function isInMarker(node) {
116
135
  let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
117
136
  while (el) {
118
- if (el.hasAttribute && el.hasAttribute("data-list-marker"))
137
+ if (isGeneratedChrome(el))
119
138
  return true;
120
139
  el = el.parentElement;
121
140
  }
@@ -221,11 +240,36 @@ function blockContentText(block) {
221
240
  * trimmed length keeps the split offset consistent with what was committed.
222
241
  */
223
242
  function trimmedSplitOffset(block, domOffset) {
224
- const content = blockContentText(block);
225
- const leading = content.length - content.replace(/^\s+/, "").length;
226
- const trimmedLen = content.trim().length;
243
+ const { leading, trimmedLen } = trimBounds(block);
227
244
  return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
228
245
  }
246
+ /** How far `block`'s DOM content text is offset from, and longer than, its committed form. */
247
+ function trimBounds(block) {
248
+ const content = blockContentText(block);
249
+ return {
250
+ leading: content.length - content.replace(/^\s+/, "").length,
251
+ trimmedLen: content.trim().length,
252
+ };
253
+ }
254
+ /**
255
+ * Map a DOM content SPAN into the run-text space the session holds after a commit — the span
256
+ * analogue of {@link trimmedSplitOffset}, and for the same reason.
257
+ *
258
+ * A block rendered with edge whitespace produces a span longer than the text the commit stores:
259
+ * an empty header/footer story renders as a lone NBSP placeholder, so typing into it leaves a
260
+ * trailing NBSP that `serializeInlineMarkdown(...).trim()` removes (JS `trim()` treats U+00A0 as
261
+ * whitespace). "Select all, then Bold" then asks `ApplyFormat` for [0, len+1) — one past the
262
+ * committed end — and the op is REJECTED with OffsetOutOfRange, so the format silently does
263
+ * nothing. The demo's format buttons preventDefault on mousedown to keep the selection alive,
264
+ * which is exactly the path that computes the span before `syncBlock` commits, so this is the
265
+ * ordinary case rather than an edge one.
266
+ */
267
+ function trimmedSpan(block, span) {
268
+ const { leading, trimmedLen } = trimBounds(block);
269
+ const start = Math.max(0, Math.min(span.start - Math.min(span.start, leading), trimmedLen));
270
+ const end = Math.max(start, Math.min(span.start + span.length - leading, trimmedLen));
271
+ return { start, length: end - start };
272
+ }
229
273
  /**
230
274
  * DOM (node, offset) for content offset `offset` within `el` — the same content-offset
231
275
  * space as contentOffsetOf (marker text and injected bidi marks excluded). Clamps past-end
@@ -314,7 +358,10 @@ function selectionSpanIn(block) {
314
358
  return null;
315
359
  const start = contentOffsetOf(block, range.startContainer, range.startOffset);
316
360
  const end = contentOffsetOf(block, range.endContainer, range.endOffset);
317
- return { start: Math.min(start, end), length: Math.abs(end - start) };
361
+ // Normalized into the committed run-text space: every consumer feeds this straight to a
362
+ // DocxSession op, which rejects a span that overshoots the committed length.
363
+ const span = trimmedSpan(block, { start: Math.min(start, end), length: Math.abs(end - start) });
364
+ return span.length > 0 ? span : null;
318
365
  }
319
366
  /** Restore a content-text selection spanning [start, start+length) within `el` (skips markers). */
320
367
  function selectRange(el, start, length) {
@@ -396,7 +443,10 @@ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
396
443
  bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
397
444
  /* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
398
445
  false, 0, "annot-",
399
- /* renderFootnotesAndEndnotes */ false, /* renderHeadersAndFooters */ paginated,
446
+ // Footnotes/endnotes ON: they are document content, and the editor makes the rendered note
447
+ // paragraphs editable. Must stay in step with DocxSessionOps.RenderHtml (the remount path),
448
+ // whose output has to match this first paint byte-for-byte.
449
+ /* renderFootnotesAndEndnotes */ true, /* renderHeadersAndFooters */ paginated,
400
450
  false, true, true, false, null, /* stampAnchors */ true,
401
451
  ];
402
452
  }
@@ -422,6 +472,11 @@ export class DocxEditor {
422
472
  * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
423
473
  */
424
474
  this.lastSelection = null;
475
+ /** The docked header/footer bands, when `options.headerFooter` is on. */
476
+ this.region = null;
477
+ /** Why the last reconcile() fell back to a full remount (null = it patched). For
478
+ * diagnostics/specs; not part of the public API. */
479
+ this.lastReconcileFallback = null;
425
480
  /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
426
481
  this.onSelectionChange = () => {
427
482
  if (this.closed)
@@ -452,13 +507,46 @@ export class DocxEditor {
452
507
  if (typeof document !== "undefined")
453
508
  document.addEventListener("selectionchange", this.onSelectionChange);
454
509
  }
455
- /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor. */
510
+ /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
511
+ * Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
512
+ * body edit root by design — also register. The fence still rejects other editors on the page. */
456
513
  editableBlockOf(node) {
457
514
  if (!node)
458
515
  return null;
459
516
  const start = node.nodeType === 1 ? node : node.parentElement;
460
517
  const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
461
- return block && this.editRoot.contains(block) ? block : null;
518
+ return block && this.container.contains(block) ? block : null;
519
+ }
520
+ /**
521
+ * The root owning `el`'s sibling block list: its header/footer band's story container, else the
522
+ * body edit root. Keeps a multi-block selection from spanning a band and the body, whose block
523
+ * lists belong to different OOXML parts.
524
+ */
525
+ ownerRoot(el) {
526
+ return this.region?.blockRootOf(el) ?? this.editRoot;
527
+ }
528
+ /** True when `el` is a header/footer band block rather than a body block. */
529
+ isBandBlock(el) {
530
+ return !!this.region?.contains(el);
531
+ }
532
+ /**
533
+ * Repaint after an edit to `block` that would otherwise remount the whole document: a band
534
+ * repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
535
+ * user's place in it — untouched; a body edit reconciles incrementally. `forceRemount` is
536
+ * for ops whose repaint provably needs whole-document context the reconciler cannot see:
537
+ * list membership/level changes (sibling numbering shifts without sibling XML changing)
538
+ * and border-div regrouping (HR insert, clearBorders).
539
+ */
540
+ refreshAfter(block, focusIndex, caretAtEnd = false, forceRemount = false) {
541
+ const band = this.region?.bandOf(block);
542
+ if (band) {
543
+ this.region.refresh(this.region.whichOf(band));
544
+ return;
545
+ }
546
+ if (forceRemount)
547
+ this.remount(focusIndex, caretAtEnd);
548
+ else
549
+ this.reconcile(focusIndex, caretAtEnd);
462
550
  }
463
551
  /** Open a document, render it into `container`, and wire up editing. */
464
552
  static open(container, bytes, exports, options = {}) {
@@ -468,20 +556,26 @@ export class DocxEditor {
468
556
  editable: options.editable ?? true,
469
557
  paginated: options.paginated ?? false,
470
558
  scale: options.scale ?? 1,
559
+ headerFooter: options.headerFooter ?? false,
471
560
  onEdit: options.onEdit,
472
561
  };
473
- // persistAnchorIds=true keeps PtOpenXml:Unid attributes in Save() output, so a remount's
474
- // full re-render keeps the SAME unids the live session uses (a content change like becoming
475
- // a list otherwise re-derives a fresh unid, leaving the block unwired). The cost is that
476
- // saved bytes carry the Unid attributes (Word ignores them).
477
- const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
562
+ // NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
563
+ // projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
564
+ // attributes no renderer reads. Only the remount's re-render needs id stability across a
565
+ // save/re-render hop, and it asks for that per call via SaveWithAnchorIds.
566
+ // emitMarkdownPatch off: the editor re-renders from HTML, never from markdown patches,
567
+ // so paying a whole-document re-projection per op would be dead weight.
568
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"emitMarkdownPatch":false}');
478
569
  const editor = new DocxEditor(container, exports, handle, opts);
479
570
  editor.refreshAnchorMap();
571
+ if (opts.headerFooter)
572
+ editor.createRegion();
480
573
  const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
481
574
  if (opts.paginated)
482
575
  editor.mountPaginated(fullHtml);
483
576
  else
484
577
  editor.mountHtml(fullHtml);
578
+ editor.syncRegionToBody();
485
579
  return editor;
486
580
  }
487
581
  /**
@@ -527,28 +621,126 @@ export class DocxEditor {
527
621
  if (this.closed)
528
622
  throw new Error("DocxEditor is closed");
529
623
  }
530
- /** Rebuild unid → full-anchor-id from the live session projection. */
624
+ /**
625
+ * Rebuild unid → full-anchor-id from the live session projection.
626
+ *
627
+ * Unids are CONTENT-ADDRESSED, so blocks with identical content in DIFFERENT parts collide —
628
+ * e.g. a document with empty default/first/even header stories has one unid for several
629
+ * header parts. A collision must resolve to the BODY entry: body blocks carry only
630
+ * `data-anchor` (the bare unid) and have nothing else to resolve through, whereas a
631
+ * header/footer band block carries its full anchor in `data-hf-anchor` and is resolved from
632
+ * that (see `anchorIdOf`). Letting a non-body scope win here would silently redirect a body
633
+ * edit into a header part.
634
+ */
531
635
  refreshAnchorMap() {
532
- const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
636
+ const bridge = this.exports.DocxSessionBridge;
637
+ // ListAnchors returns the same {anchorIndex} object WITHOUT the markdown payload —
638
+ // marshaling the full projection (a couple hundred KB on a real document) made
639
+ // this refresh the single biggest term of every incremental repaint.
640
+ const raw = typeof bridge.ListAnchors === "function"
641
+ ? bridge.ListAnchors(this.handle)
642
+ : bridge.Project(this.handle);
643
+ const proj = JSON.parse(raw);
533
644
  this.unidToFullId.clear();
645
+ const bodyOwned = new Set();
534
646
  for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
647
+ const isBody = target.scope === "body";
648
+ if (!isBody && bodyOwned.has(target.unid))
649
+ continue;
535
650
  this.unidToFullId.set(target.unid, fullId);
651
+ if (isBody)
652
+ bodyOwned.add(target.unid);
536
653
  }
537
654
  }
655
+ /**
656
+ * The full `kind:scope:unid` anchor for a rendered block. A header/footer band block carries
657
+ * its own — the unid map cannot disambiguate one, since several parts' story paragraphs can
658
+ * share a content-addressed unid (a real Word document with empty default/first/even stories
659
+ * does exactly that, and a unid-keyed lookup would land the edit in the wrong header part).
660
+ */
661
+ anchorIdOf(el) {
662
+ const stamped = el.getAttribute("data-hf-anchor");
663
+ if (stamped)
664
+ return stamped;
665
+ const unid = el.getAttribute("data-anchor");
666
+ return unid ? this.unidToFullId.get(unid) : undefined;
667
+ }
668
+ /** Build the header/footer region (called once, before the first mount). */
669
+ createRegion() {
670
+ this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, { cssPrefix: this.options.cssPrefix, fabricateClasses: this.options.fabricateClasses }, {
671
+ wireBlock: (el) => this.wireBlock(el),
672
+ refreshAnchorMap: () => this.refreshAnchorMap(),
673
+ });
674
+ }
675
+ /**
676
+ * Point the bands at the section governing the current focus (or the first body block).
677
+ * Called after every mount, and on focus of a body block, so a multi-section document shows
678
+ * the stories that actually apply where the caret is.
679
+ */
680
+ syncRegionToBody(fromBlock) {
681
+ if (!this.region)
682
+ return;
683
+ const block = fromBlock ?? this.editableList()[0];
684
+ const unid = block?.getAttribute("data-anchor");
685
+ const id = unid ? this.unidToFullId.get(unid) : undefined;
686
+ this.region.syncToBody(id ?? null);
687
+ }
688
+ /**
689
+ * Insert the bands around `bodyRoot` and make `bodyRoot` the edit root. Band blocks must stay
690
+ * OUT of the edit root: `editableList()`/`blockIndex()` enumerate it to compute remount focus
691
+ * indices, and band blocks in that list would shift every index.
692
+ */
693
+ dockBands(bodyRoot) {
694
+ if (!this.region)
695
+ return;
696
+ bodyRoot.before(this.region.headerBand);
697
+ bodyRoot.after(this.region.footerBand);
698
+ this.region.refreshAll();
699
+ }
538
700
  /** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
539
701
  mountHtml(fullHtml) {
540
702
  const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
541
703
  const styles = Array.from(parsed.querySelectorAll("style"))
542
704
  .map((s) => s.outerHTML)
543
705
  .join("");
544
- this.container.innerHTML = styles + parsed.body.innerHTML;
545
- this.editRoot = this.container;
706
+ if (!this.region) {
707
+ this.container.innerHTML = styles + parsed.body.innerHTML;
708
+ this.editRoot = this.container;
709
+ if (this.options.editable)
710
+ this.wireBlocks(this.container);
711
+ this.stampPlanState();
712
+ return;
713
+ }
714
+ // With bands docked, the body flow needs its own wrapper to be the edit root.
715
+ this.container.innerHTML = styles;
716
+ const flow = document.createElement("div");
717
+ flow.className = "docx-body-flow";
718
+ flow.innerHTML = parsed.body.innerHTML;
719
+ this.container.appendChild(flow);
720
+ this.editRoot = flow;
546
721
  if (this.options.editable)
547
- this.wireBlocks(this.container);
722
+ this.wireBlocks(flow);
723
+ this.stampPlanState();
724
+ this.dockBands(flow);
548
725
  }
549
726
  /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
550
727
  mountPaginated(fullHtml) {
551
- paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
728
+ // With bands docked, pagination writes into its own wrapper so the bands can sit outside the
729
+ // page stack (and so pagination's innerHTML reset can never eat them).
730
+ let target = this.container;
731
+ if (this.region) {
732
+ this.container.innerHTML = "";
733
+ target = document.createElement("div");
734
+ target.className = "docx-body-flow";
735
+ this.container.appendChild(target);
736
+ }
737
+ // Fragmented paragraphs intentionally have only one addressable head and
738
+ // are therefore unsuitable for the editor's one-block editing model.
739
+ paginateHtml(fullHtml, target, {
740
+ scale: this.options.scale,
741
+ cssPrefix: "page-",
742
+ fragmentParagraphs: false,
743
+ });
552
744
  // pagination.ts measures the hidden #pagination-staging subtree ONCE, then flows CLONES of its
553
745
  // blocks into the visible page boxes. Leaving staging in the live DOM is a trap: every
554
746
  // data-anchor exists twice (staging + page-box copy), so document.querySelector('[data-anchor]')
@@ -557,10 +749,14 @@ export class DocxEditor {
557
749
  // is a transient measurement scaffold; drop it so the page-box copies are the single source of
558
750
  // truth. A remount (setPaginated, list/undo edits) rebuilds staging fresh from the live session.
559
751
  this.container.querySelector("#pagination-staging, .page-staging")?.remove();
560
- const pageRoot = this.container.querySelector("#pagination-container") ?? this.container;
752
+ const pageRoot = target.querySelector("#pagination-container") ?? target;
561
753
  this.editRoot = pageRoot;
562
754
  if (this.options.editable)
563
755
  this.wireBlocks(pageRoot);
756
+ // The page boxes render their own (read-only) header/footer margins; the editable bands dock
757
+ // around the page stack, so there is still exactly one addressable node per story paragraph.
758
+ if (this.region)
759
+ this.dockBands(target);
564
760
  }
565
761
  wireBlocks(root) {
566
762
  root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
@@ -573,18 +769,34 @@ export class DocxEditor {
573
769
  // table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
574
770
  // keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
575
771
  // table structure. Anything the projection does not index (unstamped content) stays read-only.
576
- if (!unid || !this.unidToFullId.has(unid))
772
+ // A band block is authoritative via its stamped `data-hf-anchor` even when the unid map
773
+ // resolves that unid to a different part (content-addressed unids collide across parts).
774
+ if (!unid || !this.anchorIdOf(el))
577
775
  return;
578
776
  el.setAttribute("contenteditable", "true");
579
- // Generated list markers (number/bullet + suffix) are not editable content — keep the
580
- // caret out of them so offsets stay aligned with the paragraph's run text.
581
- el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
777
+ // Generated chrome (list number/bullet, footnote/endnote citation markers, note backrefs) is
778
+ // not editable content — keep the caret out so offsets stay aligned with the run text, and so
779
+ // a citation marker can't be deleted directly (which would orphan its note definition).
780
+ el.querySelectorAll(GENERATED_CHROME_SELECTOR)
781
+ .forEach((m) => m.setAttribute("contenteditable", "false"));
582
782
  // Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
583
783
  // matching the session's flat run-text offset space.
584
784
  el.dataset.committedText = blockContentText(el);
585
- el.addEventListener("focus", () => { this.activeBlock = el; });
785
+ el.addEventListener("focus", () => {
786
+ this.activeBlock = el;
787
+ // Follow the caret's section so a cover-page-plus-body document shows the stories that
788
+ // actually apply. Focusing a BAND block must not re-sync: it has no governing section of
789
+ // its own, and re-resolving would clobber the user's kind selection.
790
+ if (this.region && !this.isBandBlock(el))
791
+ this.syncRegionToBody(el);
792
+ });
586
793
  el.addEventListener("blur", () => this.commitBlock(el));
587
794
  el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
795
+ // A band block re-rendered by an incremental swap is a fresh DOM node; re-adopt it (with the
796
+ // anchor its caller already stamped) so the band chrome can still address it.
797
+ const stamped = el.getAttribute("data-hf-anchor");
798
+ if (stamped && this.region?.contains(el))
799
+ this.region.adoptBlock(el, stamped);
588
800
  }
589
801
  /**
590
802
  * Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
@@ -619,7 +831,7 @@ export class DocxEditor {
619
831
  const unid = el.getAttribute("data-anchor");
620
832
  if (!unid)
621
833
  return;
622
- const fullId = this.unidToFullId.get(unid);
834
+ const fullId = this.anchorIdOf(el);
623
835
  if (!fullId)
624
836
  return;
625
837
  const result = this.commitTextChange(el, fullId);
@@ -651,12 +863,22 @@ export class DocxEditor {
651
863
  const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
652
864
  if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
653
865
  const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
866
+ const inBand = this.isBandBlock(el);
654
867
  if (fresh && this.replaceNode(el, fresh)) {
655
- this.unidToFullId.delete(unid);
656
- this.unidToFullId.set(newUnid, newAnchor);
868
+ // Band blocks resolve through `data-hf-anchor`; their unid can collide with another
869
+ // part's, so writing it into the map would corrupt that entry (see anchorIdOf).
870
+ if (inBand) {
871
+ this.region.adoptBlock(fresh, newAnchor);
872
+ }
873
+ else {
874
+ this.unidToFullId.delete(unid);
875
+ this.unidToFullId.set(newUnid, newAnchor);
876
+ }
657
877
  this.wireBlock(fresh);
658
878
  if (this.activeBlock === el)
659
879
  this.activeBlock = fresh; // keep ribbon target valid
880
+ // The throwaway render numbers citation markers from 1 — repair in place.
881
+ this.maybeRenumberNotes(fresh);
660
882
  }
661
883
  }
662
884
  this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
@@ -748,7 +970,7 @@ export class DocxEditor {
748
970
  const unid = el.getAttribute("data-anchor");
749
971
  if (rawOffset == null || !unid)
750
972
  return;
751
- let fullId = this.unidToFullId.get(unid);
973
+ let fullId = this.anchorIdOf(el);
752
974
  if (!fullId)
753
975
  return;
754
976
  // The session commits trimmed text, so map the DOM caret offset into the trimmed run-text
@@ -770,7 +992,7 @@ export class DocxEditor {
770
992
  // document re-render so numbering continues / border <div>s regroup correctly. An in-place node
771
993
  // swap would leave the new paragraph inside the old border div (the rule's line under its text).
772
994
  if (this.affectsList(res) || wrappedInBorder) {
773
- this.remount(idx + 1, false);
995
+ this.refreshAfter(el, idx + 1, false);
774
996
  this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
775
997
  return;
776
998
  }
@@ -780,11 +1002,19 @@ export class DocxEditor {
780
1002
  return;
781
1003
  // el is the focused block — replaceNode guards the re-entrant blur→commit and tolerates a
782
1004
  // node detached mid-focus-transfer; replacing with both new blocks at once keeps them adjacent.
1005
+ const inBand = this.isBandBlock(el);
783
1006
  if (!this.replaceNode(el, firstEl, secondEl))
784
1007
  return;
785
- this.unidToFullId.delete(unid);
786
- this.unidToFullId.set(first.unid, first.id);
787
- this.unidToFullId.set(second.unid, second.id);
1008
+ // Band blocks resolve through `data-hf-anchor` (their unids can collide across parts).
1009
+ if (inBand) {
1010
+ this.region.adoptBlock(firstEl, first.id);
1011
+ this.region.adoptBlock(secondEl, second.id);
1012
+ }
1013
+ else {
1014
+ this.unidToFullId.delete(unid);
1015
+ this.unidToFullId.set(first.unid, first.id);
1016
+ this.unidToFullId.set(second.unid, second.id);
1017
+ }
788
1018
  this.wireBlock(firstEl);
789
1019
  this.wireBlock(secondEl);
790
1020
  placeCaretAtOffset(secondEl, 0);
@@ -796,8 +1026,8 @@ export class DocxEditor {
796
1026
  const thisUnid = el.getAttribute("data-anchor");
797
1027
  if (!prevUnid || !thisUnid)
798
1028
  return;
799
- let prevId = this.unidToFullId.get(prevUnid);
800
- let thisId = this.unidToFullId.get(thisUnid);
1029
+ let prevId = this.anchorIdOf(prev);
1030
+ let thisId = this.anchorIdOf(el);
801
1031
  if (!prevId || !thisId)
802
1032
  return;
803
1033
  const prevIdx = this.blockIndex(prev); // capture before the op
@@ -816,7 +1046,7 @@ export class DocxEditor {
816
1046
  // Merging list items renumbers the list, and merging across a border <div> boundary changes the
817
1047
  // border grouping — both need a whole-document re-render (caret at the merge boundary).
818
1048
  if (this.affectsList(res) || wrappedInBorder) {
819
- this.remount(prevIdx, true);
1049
+ this.refreshAfter(el, prevIdx, true);
820
1050
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
821
1051
  return;
822
1052
  }
@@ -824,12 +1054,19 @@ export class DocxEditor {
824
1054
  if (!mergedEl)
825
1055
  return;
826
1056
  // prev may be focused — replaceNode guards re-entrancy and tolerates a detached node.
1057
+ const inBand = this.isBandBlock(prev);
827
1058
  if (!this.replaceNode(prev, mergedEl))
828
1059
  return;
829
1060
  el.remove();
830
- this.unidToFullId.delete(prevUnid);
831
- this.unidToFullId.delete(thisUnid);
832
- this.unidToFullId.set(merged.unid, merged.id);
1061
+ // Band blocks resolve through `data-hf-anchor` (their unids can collide across parts).
1062
+ if (inBand) {
1063
+ this.region.adoptBlock(mergedEl, merged.id);
1064
+ }
1065
+ else {
1066
+ this.unidToFullId.delete(prevUnid);
1067
+ this.unidToFullId.delete(thisUnid);
1068
+ this.unidToFullId.set(merged.unid, merged.id);
1069
+ }
833
1070
  this.wireBlock(mergedEl);
834
1071
  placeCaretAtOffset(mergedEl, caret);
835
1072
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
@@ -896,9 +1133,9 @@ export class DocxEditor {
896
1133
  return null;
897
1134
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
898
1135
  }
899
- /** The editable block immediately before `el` in document order, or null. */
1136
+ /** The editable block immediately before `el` within its own root, or null. */
900
1137
  previousEditable(el) {
901
- const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
1138
+ const all = Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]'));
902
1139
  const i = all.indexOf(el);
903
1140
  return i > 0 ? all[i - 1] : null;
904
1141
  }
@@ -918,7 +1155,10 @@ export class DocxEditor {
918
1155
  * A collapsed or single-block selection yields just the active block. */
919
1156
  selectedBlocks() {
920
1157
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
921
- const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
1158
+ // Enumerate the ACTIVE block's own root — a band's story container, else the body edit root —
1159
+ // so a selection can never span a band and the body (different OOXML parts).
1160
+ const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
1161
+ const all = Array.from(root.querySelectorAll('[data-anchor][contenteditable="true"]'));
922
1162
  if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
923
1163
  const range = sel.getRangeAt(0);
924
1164
  const hit = all.filter((b) => {
@@ -948,16 +1188,18 @@ export class DocxEditor {
948
1188
  const hasEnd = block.contains(range.endContainer);
949
1189
  if (hasStart && hasEnd)
950
1190
  return selectionSpanIn(block);
1191
+ // Partial/whole slices are normalized into the committed run-text space for the same reason
1192
+ // selectionSpanIn is (see trimmedSpan): a session op rejects a span past the committed end.
951
1193
  const contentLen = blockContentText(block).length;
952
1194
  if (hasStart) {
953
1195
  const start = contentOffsetOf(block, range.startContainer, range.startOffset);
954
- return { start, length: Math.max(0, contentLen - start) };
1196
+ return trimmedSpan(block, { start, length: Math.max(0, contentLen - start) });
955
1197
  }
956
1198
  if (hasEnd) {
957
1199
  const end = contentOffsetOf(block, range.endContainer, range.endOffset);
958
- return { start: 0, length: end };
1200
+ return trimmedSpan(block, { start: 0, length: end });
959
1201
  }
960
- return { start: 0, length: contentLen }; // fully-spanned middle block
1202
+ return trimmedSpan(block, { start: 0, length: contentLen }); // fully-spanned middle block
961
1203
  }
962
1204
  /** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
963
1205
  * the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
@@ -1005,7 +1247,7 @@ export class DocxEditor {
1005
1247
  return {
1006
1248
  block: b,
1007
1249
  unid,
1008
- fullId: unid ? this.unidToFullId.get(unid) : undefined,
1250
+ fullId: this.anchorIdOf(b),
1009
1251
  span: this.blockSpanForSelection(b),
1010
1252
  res: null,
1011
1253
  };
@@ -1029,7 +1271,7 @@ export class DocxEditor {
1029
1271
  // need a reflow — keep the (pre-existing) full remount there until M4 lands a scoped
1030
1272
  // re-paginate. Continuous mode reconciles incrementally.
1031
1273
  if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
1032
- this.remount();
1274
+ this.refreshAfter(edited[0].block, -1, false);
1033
1275
  return;
1034
1276
  }
1035
1277
  const swapped = [];
@@ -1081,7 +1323,7 @@ export class DocxEditor {
1081
1323
  const unid = block.getAttribute("data-anchor");
1082
1324
  if (!unid)
1083
1325
  return;
1084
- let fullId = this.unidToFullId.get(unid);
1326
+ let fullId = this.anchorIdOf(block);
1085
1327
  if (!fullId)
1086
1328
  return;
1087
1329
  const span = selectionSpanIn(block);
@@ -1095,7 +1337,7 @@ export class DocxEditor {
1095
1337
  if (!res.success)
1096
1338
  return;
1097
1339
  if (this.affectsList(res)) {
1098
- this.remount(this.blockIndex(block), false);
1340
+ this.refreshAfter(block, this.blockIndex(block), false);
1099
1341
  return;
1100
1342
  }
1101
1343
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1119,7 +1361,7 @@ export class DocxEditor {
1119
1361
  const unid = block.getAttribute("data-anchor");
1120
1362
  if (!unid)
1121
1363
  return;
1122
- let fullId = this.unidToFullId.get(unid);
1364
+ let fullId = this.anchorIdOf(block);
1123
1365
  if (!fullId)
1124
1366
  return;
1125
1367
  // Use the live selection; if the font-size combobox stole focus and collapsed it, fall back to
@@ -1132,7 +1374,7 @@ export class DocxEditor {
1132
1374
  if (!res.success)
1133
1375
  return;
1134
1376
  if (this.affectsList(res)) {
1135
- this.remount(this.blockIndex(block), false);
1377
+ this.refreshAfter(block, this.blockIndex(block), false);
1136
1378
  return;
1137
1379
  }
1138
1380
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1158,7 +1400,7 @@ export class DocxEditor {
1158
1400
  const unid = block.getAttribute("data-anchor");
1159
1401
  if (!unid)
1160
1402
  return;
1161
- let fullId = this.unidToFullId.get(unid);
1403
+ let fullId = this.anchorIdOf(block);
1162
1404
  if (!fullId)
1163
1405
  return;
1164
1406
  let span = selectionSpanIn(block);
@@ -1169,7 +1411,7 @@ export class DocxEditor {
1169
1411
  if (!res.success)
1170
1412
  return;
1171
1413
  if (this.affectsList(res)) {
1172
- this.remount(this.blockIndex(block), false);
1414
+ this.refreshAfter(block, this.blockIndex(block), false);
1173
1415
  return;
1174
1416
  }
1175
1417
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1194,7 +1436,7 @@ export class DocxEditor {
1194
1436
  const unid = block.getAttribute("data-anchor");
1195
1437
  if (!unid)
1196
1438
  return;
1197
- let fullId = this.unidToFullId.get(unid);
1439
+ let fullId = this.anchorIdOf(block);
1198
1440
  if (!fullId)
1199
1441
  return;
1200
1442
  const idx = this.blockIndex(block);
@@ -1202,9 +1444,10 @@ export class DocxEditor {
1202
1444
  const res = this.parseEdit(this.exports.DocxSessionBridge.InsertHorizontalRule(this.handle, fullId, position === "above" ? "before" : "after", JSON.stringify({ style, size: weight, color: "auto" })));
1203
1445
  if (!res.success)
1204
1446
  return;
1205
- // remount from the active block's index re-renders the new rule whether it landed just
1206
- // above (at idx) or just below (at idx+1) the active block.
1207
- this.remount(idx, false);
1447
+ // Remount from the active block's index re-renders the new rule whether it landed just
1448
+ // above (at idx) or just below (at idx+1) the active block. Forced: a rule is a bordered
1449
+ // paragraph, and border-div grouping is whole-document render context.
1450
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
1208
1451
  }
1209
1452
  /**
1210
1453
  * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
@@ -1218,7 +1461,7 @@ export class DocxEditor {
1218
1461
  const unid = block.getAttribute("data-anchor");
1219
1462
  if (!unid)
1220
1463
  return;
1221
- let fullId = this.unidToFullId.get(unid);
1464
+ let fullId = this.anchorIdOf(block);
1222
1465
  if (!fullId)
1223
1466
  return;
1224
1467
  const idx = this.blockIndex(block);
@@ -1230,7 +1473,7 @@ export class DocxEditor {
1230
1473
  const res = this.parseEdit(this.exports.DocxSessionBridge.InsertTable(this.handle, fullId, emptyHere ? "before" : "after", rows, cols, options ? JSON.stringify(options) : ""));
1231
1474
  if (!res.success)
1232
1475
  return;
1233
- this.remount(idx, false);
1476
+ this.refreshAfter(block, idx, false);
1234
1477
  }
1235
1478
  // ─── Table row / column editing (active block must be inside a table cell) ──────────
1236
1479
  /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
@@ -1241,7 +1484,7 @@ export class DocxEditor {
1241
1484
  const unid = block.getAttribute("data-anchor");
1242
1485
  if (!unid)
1243
1486
  return;
1244
- let fullId = this.unidToFullId.get(unid);
1487
+ let fullId = this.anchorIdOf(block);
1245
1488
  if (!fullId)
1246
1489
  return;
1247
1490
  const idx = this.blockIndex(block);
@@ -1249,7 +1492,7 @@ export class DocxEditor {
1249
1492
  const res = this.parseEdit(run(fullId));
1250
1493
  if (!res.success)
1251
1494
  return;
1252
- this.remount(idx, false);
1495
+ this.refreshAfter(block, idx, false);
1253
1496
  }
1254
1497
  /** Insert a row above/below the active cell's row. No-op outside a table. */
1255
1498
  insertTableRow(where) {
@@ -1288,7 +1531,7 @@ export class DocxEditor {
1288
1531
  const unid = block.getAttribute("data-anchor");
1289
1532
  if (!unid)
1290
1533
  return;
1291
- let fullId = this.unidToFullId.get(unid);
1534
+ let fullId = this.anchorIdOf(block);
1292
1535
  if (!fullId)
1293
1536
  return;
1294
1537
  const idx = this.blockIndex(block);
@@ -1297,8 +1540,8 @@ export class DocxEditor {
1297
1540
  if (!res.success)
1298
1541
  return;
1299
1542
  // A level change ripples through the whole list's numbering — re-render with full document
1300
- // context (a single-block render can't compute nested numbering), keeping the caret in place.
1301
- this.remount(idx, false);
1543
+ // context (sibling numbers shift without sibling XML changing), keeping the caret in place.
1544
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
1302
1545
  }
1303
1546
  /** Toggle (or set) page-break-before on the active block. */
1304
1547
  pageBreakBefore(value = true) {
@@ -1315,7 +1558,7 @@ export class DocxEditor {
1315
1558
  const unid = block.getAttribute("data-anchor");
1316
1559
  if (!unid)
1317
1560
  return;
1318
- let fullId = this.unidToFullId.get(unid);
1561
+ let fullId = this.anchorIdOf(block);
1319
1562
  if (!fullId)
1320
1563
  return;
1321
1564
  let membership = null;
@@ -1331,8 +1574,8 @@ export class DocxEditor {
1331
1574
  if (!res.success)
1332
1575
  return;
1333
1576
  // Numbering continuation across the list needs whole-document context — re-render fully
1334
- // (a single-block render would show every numbered item as "1.").
1335
- this.remount(idx, false);
1577
+ // (sibling numbers shift without sibling XML changing).
1578
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
1336
1579
  }
1337
1580
  /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
1338
1581
  * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
@@ -1358,14 +1601,56 @@ export class DocxEditor {
1358
1601
  const unid = block.getAttribute("data-anchor");
1359
1602
  if (!unid)
1360
1603
  return;
1361
- const fullId = this.unidToFullId.get(unid);
1604
+ const fullId = this.anchorIdOf(block);
1362
1605
  if (!fullId)
1363
1606
  return;
1364
1607
  const idx = this.blockIndex(block);
1365
1608
  const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
1366
1609
  if (!res.success)
1367
1610
  return;
1368
- this.remount(Math.max(0, idx - 1), true);
1611
+ this.refreshAfter(block, Math.max(0, idx - 1), true);
1612
+ }
1613
+ /**
1614
+ * Cite a new footnote from the caret position in the active body block. The note definition is
1615
+ * created (writing the whole Word scaffold — part, reserved separator notes, settings
1616
+ * declaration, styles — on a document that has none yet) and its body renders as ordinary
1617
+ * editable `data-anchor` blocks in the notes section, so editing it afterwards needs no new op.
1618
+ *
1619
+ * Body blocks only: Word disallows a note reference inside a header/footer story or inside
1620
+ * another note, and the session rejects those with `AnchorWrongKind`. Remounts, because a new
1621
+ * note renumbers the citations after it and can add a whole part.
1622
+ */
1623
+ insertFootnote(markdown = "New footnote.") {
1624
+ this.insertNote("footnote", markdown);
1625
+ }
1626
+ /** Cite a new endnote from the caret — see {@link insertFootnote}; writes the endnotes part. */
1627
+ insertEndnote(markdown = "New endnote.") {
1628
+ this.insertNote("endnote", markdown);
1629
+ }
1630
+ insertNote(kind, markdown) {
1631
+ const block = this.activeBlock;
1632
+ if (this.closed || !block)
1633
+ return;
1634
+ // A note reference is legal only in the main story: not in a header/footer band, and not
1635
+ // inside an existing note's body (both render as editable blocks here).
1636
+ if (this.isBandBlock(block) || block.closest(".footnotes, .endnotes"))
1637
+ return;
1638
+ let fullId = this.anchorIdOf(block);
1639
+ if (!fullId)
1640
+ return;
1641
+ const idx = this.blockIndex(block);
1642
+ // Offset first: syncBlock re-renders the block and would drop the live selection.
1643
+ const raw = caretOffsetIn(block);
1644
+ fullId = this.syncBlock(block, fullId);
1645
+ const offset = trimmedSplitOffset(block, raw ?? (block.textContent ?? "").length);
1646
+ const bridge = this.exports.DocxSessionBridge;
1647
+ const call = kind === "footnote" ? bridge.InsertFootnote : bridge.InsertEndnote;
1648
+ if (!call)
1649
+ return; // bridge predates note authoring
1650
+ const res = this.parseEdit(call.call(bridge, this.handle, fullId, offset, markdown));
1651
+ if (!res.success)
1652
+ return;
1653
+ this.reconcile(idx, false);
1369
1654
  }
1370
1655
  applyParagraphFormat(op) {
1371
1656
  const block = this.activeBlock;
@@ -1380,7 +1665,7 @@ export class DocxEditor {
1380
1665
  const unid = block.getAttribute("data-anchor");
1381
1666
  if (!unid)
1382
1667
  return;
1383
- let fullId = this.unidToFullId.get(unid);
1668
+ let fullId = this.anchorIdOf(block);
1384
1669
  if (!fullId)
1385
1670
  return;
1386
1671
  const idx = this.blockIndex(block);
@@ -1391,7 +1676,7 @@ export class DocxEditor {
1391
1676
  // A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
1392
1677
  // it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
1393
1678
  if (this.affectsList(res) || op.clearBorders) {
1394
- this.remount(idx, false);
1679
+ this.refreshAfter(block, idx, false, true);
1395
1680
  return;
1396
1681
  }
1397
1682
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
@@ -1409,7 +1694,7 @@ export class DocxEditor {
1409
1694
  const unid = block.getAttribute("data-anchor");
1410
1695
  if (!unid)
1411
1696
  return;
1412
- let fullId = this.unidToFullId.get(unid);
1697
+ let fullId = this.anchorIdOf(block);
1413
1698
  if (!fullId)
1414
1699
  return;
1415
1700
  const idx = this.blockIndex(block);
@@ -1418,24 +1703,82 @@ export class DocxEditor {
1418
1703
  if (!res.success)
1419
1704
  return;
1420
1705
  if (this.affectsList(res)) {
1421
- this.remount(idx, false);
1706
+ this.refreshAfter(block, idx, false, true);
1422
1707
  return;
1423
1708
  }
1424
1709
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
1425
1710
  }
1426
- /** Undo the last edit (re-renders the document). */
1711
+ /** Undo the last edit (incremental repaint; falls back to a full re-render). */
1427
1712
  undo() {
1428
1713
  if (this.closed)
1429
1714
  return;
1430
1715
  if (this.exports.DocxSessionBridge.Undo(this.handle))
1431
- this.remount();
1716
+ this.reconcile();
1432
1717
  }
1433
- /** Redo the last undone edit (re-renders the document). */
1718
+ /** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
1434
1719
  redo() {
1435
1720
  if (this.closed)
1436
1721
  return;
1437
1722
  if (this.exports.DocxSessionBridge.Redo(this.handle))
1438
- this.remount();
1723
+ this.reconcile();
1724
+ }
1725
+ // ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
1726
+ /**
1727
+ * Select which story kind a band edits (`"default"` / `"first"` / `"even"`). A kind with no
1728
+ * existing part is created empty, so the band always presents something editable. `"first"`
1729
+ * sets the section's `w:titlePg`; `"even"` sets the document-global `w:evenAndOddHeaders`
1730
+ * (which also governs footers — the band surfaces that caveat inline).
1731
+ */
1732
+ setHeaderFooterKind(which, kind) {
1733
+ this.assertOpen();
1734
+ this.region?.setKind(which, kind);
1735
+ }
1736
+ /** The story kind a band is currently editing, or null when the region is off. */
1737
+ headerFooterKind(which) {
1738
+ return this.region?.kindOf(which) ?? null;
1739
+ }
1740
+ /**
1741
+ * Append a page-number field to the focused header/footer story paragraph (falling back to the
1742
+ * band's last paragraph — Word's convention). No-op outside a band.
1743
+ */
1744
+ insertPageNumber(field = "currentPage") {
1745
+ this.assertOpen();
1746
+ if (!this.region)
1747
+ return;
1748
+ const block = this.activeBlock;
1749
+ const band = block ? this.region.bandOf(block) : null;
1750
+ const anchorId = block?.getAttribute("data-hf-anchor");
1751
+ if (band && anchorId) {
1752
+ this.region.insertPageNumber(this.region.whichOf(band), anchorId, field);
1753
+ return;
1754
+ }
1755
+ // No band block focused — target the footer, where page numbers overwhelmingly live.
1756
+ this.region.insertPageNumberInBand("footer", field);
1757
+ }
1758
+ /**
1759
+ * Set the page numbering of the section the bands describe (`w:pgNumType`) — Word's *Format Page
1760
+ * Numbers…*: `start` restarts numbering at that number, `format` chooses `1, 2, 3` vs
1761
+ * `i, ii, iii` etc. Omitted fields are left unchanged. Requires the header/footer region
1762
+ * (`{ headerFooter: true }`); a no-op otherwise.
1763
+ *
1764
+ * Inserted page-number fields are plain, so they render through this. The editor's own view still
1765
+ * shows each field's cached result — Word recomputes on open — but `{ paginated: true }`
1766
+ * substitutes the real per-page number and so reflects the change immediately.
1767
+ */
1768
+ setPageNumbering(op) {
1769
+ this.assertOpen();
1770
+ this.region?.setPageNumbering(op);
1771
+ }
1772
+ /** Remove the section's page-numbering start/format: it reverts to continuing the previous
1773
+ * section's numbering in Word's default `1, 2, 3`. */
1774
+ clearPageNumbering() {
1775
+ this.assertOpen();
1776
+ this.region?.clearPageNumbering();
1777
+ }
1778
+ /** This section's page numbering as the document currently states it — `{}` when the section
1779
+ * sets neither (it continues the previous section in the default format). */
1780
+ pageNumbering() {
1781
+ return this.region?.pageNumbering() ?? {};
1439
1782
  }
1440
1783
  /** Which inline formats the current selection carries — for ribbon button highlighting. */
1441
1784
  queryFormatState() {
@@ -1452,17 +1795,27 @@ export class DocxEditor {
1452
1795
  }
1453
1796
  /** Re-render one block from the live session by EditResult ref, swapping it in place. */
1454
1797
  swapBlock(oldEl, oldUnid, ref) {
1455
- const anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
1798
+ const inBand = this.isBandBlock(oldEl);
1799
+ const anchorId = ref?.id ?? this.anchorIdOf(oldEl);
1456
1800
  const newUnid = ref?.unid ?? oldUnid;
1457
1801
  if (!anchorId)
1458
1802
  return null;
1459
1803
  const fresh = this.renderInto(anchorId);
1460
1804
  if (!fresh || !this.replaceNode(oldEl, fresh))
1461
1805
  return null;
1462
- this.unidToFullId.delete(oldUnid);
1463
- this.unidToFullId.set(newUnid, anchorId);
1806
+ // A band block resolves through its stamped `data-hf-anchor`, never the unid map — and its
1807
+ // unid can collide with another part's, so writing it here would corrupt that entry.
1808
+ if (!inBand) {
1809
+ this.unidToFullId.delete(oldUnid);
1810
+ this.unidToFullId.set(newUnid, anchorId);
1811
+ }
1812
+ // Stamp BEFORE wiring so wireBlock's own resolution sees the authoritative id.
1813
+ if (inBand)
1814
+ this.region.adoptBlock(fresh, anchorId);
1464
1815
  this.wireBlock(fresh);
1465
1816
  this.activeBlock = fresh;
1817
+ // The throwaway render numbers citation markers from 1 — repair in place.
1818
+ this.maybeRenumberNotes(fresh);
1466
1819
  this.options.onEdit?.({ anchorId, unid: newUnid });
1467
1820
  return fresh;
1468
1821
  }
@@ -1480,15 +1833,21 @@ export class DocxEditor {
1480
1833
  if (html.charCodeAt(0) !== 0x7b /* not an error object */)
1481
1834
  return html;
1482
1835
  }
1483
- const bytes = bridge.Save(this.handle);
1836
+ // Fallback only (no RenderHtml on this bridge, or it errored). These bytes are re-rendered and
1837
+ // discarded, and the re-render has to resolve to the SAME anchors the live session holds — a
1838
+ // content change re-derives a block's content-hashed unid, which would leave it unwired. So ask
1839
+ // for the Unid-bearing save here, and here only; DocxEditor.save() stays clean.
1840
+ const bytes = typeof bridge.SaveWithAnchorIds === "function"
1841
+ ? bridge.SaveWithAnchorIds(this.handle)
1842
+ : bridge.Save(this.handle);
1484
1843
  return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
1485
1844
  }
1486
- /** Editable blocks in document order. */
1845
+ /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
1487
1846
  editableList() {
1488
1847
  return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
1489
1848
  }
1490
1849
  blockIndex(el) {
1491
- return this.editableList().indexOf(el);
1850
+ return Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')).indexOf(el);
1492
1851
  }
1493
1852
  /**
1494
1853
  * True when an edit produced or touched a list item (kind "li"). List markers and
@@ -1498,6 +1857,426 @@ export class DocxEditor {
1498
1857
  affectsList(res) {
1499
1858
  return [...(res.modified ?? []), ...(res.created ?? [])].some((r) => r.kind === "li");
1500
1859
  }
1860
+ // ─── Incremental structural reconcile ─────────────────────────────────
1861
+ //
1862
+ // After a structural op (insert table/row/col, footnote, delete block, undo/redo)
1863
+ // the DOM is patched from a unit-sequence diff against the session's render plan
1864
+ // instead of remounting the whole document (~3 s of full-document conversion on a
1865
+ // 350-block file). Full remount remains the universal FALLBACK: any ambiguity,
1866
+ // unsupported bridge, paginated mode, list-membership change, or thrown error
1867
+ // lands there — correctness never depends on the diff being right.
1868
+ /** True when the bridge carries the reconcile trio and the mode allows patching. */
1869
+ canReconcile() {
1870
+ const b = this.exports.DocxSessionBridge;
1871
+ return (!this.options.paginated &&
1872
+ typeof b.ListBlocks === "function" &&
1873
+ typeof b.RenderBlocksHtml === "function" &&
1874
+ typeof b.ListNotes === "function");
1875
+ }
1876
+ /** The body's top-level unit nodes in document order: `[data-anchor]` elements not
1877
+ * nested in another unit (cell paragraphs collapse into their table) and not in the
1878
+ * notes sections. */
1879
+ bodyUnitNodes() {
1880
+ const all = Array.from(this.editRoot.querySelectorAll("[data-anchor]"));
1881
+ return all.filter((el) => {
1882
+ if (el.closest("section.footnotes, section.endnotes"))
1883
+ return false;
1884
+ const ancestor = el.parentElement?.closest("[data-anchor]");
1885
+ return !(ancestor && this.editRoot.contains(ancestor));
1886
+ });
1887
+ }
1888
+ /** The DOM diff token for a body unit node (see editor-reconcile.tokenOf). */
1889
+ static domTokenOf(el) {
1890
+ const unid = el.getAttribute("data-anchor") ?? "";
1891
+ const sig = el.getAttribute("data-render-sig");
1892
+ return sig ? `${unid}|${sig}` : unid;
1893
+ }
1894
+ /** The kind a body unit node would have in the plan (only 'li'/'tbl' matter to the
1895
+ * remount guard). */
1896
+ static domKindOf(el) {
1897
+ if (el.tagName === "TABLE")
1898
+ return "tbl";
1899
+ return el.querySelector(":scope > [data-list-marker]") ? "li" : "p";
1900
+ }
1901
+ static listMarkerText(el) {
1902
+ const m = el?.querySelector(":scope > [data-list-marker]");
1903
+ return m ? m.textContent : null;
1904
+ }
1905
+ /**
1906
+ * Incrementally patch the DOM from the session's render plan; falls back to
1907
+ * {@link remount} whenever it cannot prove the patch correct. Same focus contract
1908
+ * as remount.
1909
+ */
1910
+ reconcile(focusIndex = -1, caretAtEnd = false) {
1911
+ if (!this.canReconcile()) {
1912
+ this.remount(focusIndex, caretAtEnd);
1913
+ return;
1914
+ }
1915
+ try {
1916
+ if (!this.reconcileCore()) {
1917
+ this.remount(focusIndex, caretAtEnd);
1918
+ return;
1919
+ }
1920
+ }
1921
+ catch (err) {
1922
+ this.lastReconcileFallback = `threw: ${err instanceof Error ? err.message : String(err)}`;
1923
+ this.remount(focusIndex, caretAtEnd);
1924
+ return;
1925
+ }
1926
+ if (focusIndex >= 0) {
1927
+ const blocks = this.editableList();
1928
+ const target = blocks[Math.min(focusIndex, blocks.length - 1)];
1929
+ if (target) {
1930
+ this.activeBlock = target;
1931
+ placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
1932
+ }
1933
+ }
1934
+ this.syncRegionToBody(this.activeBlock ?? undefined);
1935
+ }
1936
+ /** The patch itself. Returns false to request the remount fallback. */
1937
+ reconcileCore() {
1938
+ const bridge = this.exports.DocxSessionBridge;
1939
+ // Refresh the unid → anchor map FIRST: wiring freshly rendered nodes (wireBlock)
1940
+ // resolves through it, and the map must reflect the post-op session.
1941
+ this.refreshAnchorMap();
1942
+ const plan = JSON.parse(bridge.ListBlocks(this.handle));
1943
+ if (plan.error)
1944
+ return this.bail(`plan error: ${plan.error}`);
1945
+ const oldNodes = this.bodyUnitNodes();
1946
+ const oldTokens = oldNodes.map(DocxEditor.domTokenOf);
1947
+ const oldKinds = oldNodes.map(DocxEditor.domKindOf);
1948
+ const bodyDiff = diffUnits(oldTokens, plan.body);
1949
+ if (needsRemount(bodyDiff, plan.body, oldKinds))
1950
+ return this.bail("needsRemount (li change or churn)");
1951
+ const fnState = this.notesDiff("footnotes", plan.footnotes);
1952
+ const enState = this.notesDiff("endnotes", plan.endnotes);
1953
+ if (fnState === null || enState === null)
1954
+ return this.bail("notes container unstampable/missing");
1955
+ // One batch render for everything that needs fresh HTML.
1956
+ const addedBodyIds = bodyDiff.added.map((j) => plan.body[j].id);
1957
+ const addedNoteIds = fnState.diff.added
1958
+ .map((j) => plan.footnotes[j].id)
1959
+ .concat(enState.diff.added.map((j) => plan.endnotes[j].id));
1960
+ const allIds = addedBodyIds.concat(addedNoteIds);
1961
+ let rendered = {};
1962
+ if (allIds.length > 0) {
1963
+ rendered = JSON.parse(bridge.RenderBlocksHtml(this.handle, JSON.stringify(allIds), this.options.cssPrefix, this.options.fabricateClasses));
1964
+ if (rendered.error)
1965
+ return this.bail(`render error: ${rendered.error}`);
1966
+ for (const id of allIds)
1967
+ if (!rendered[id])
1968
+ return this.bail(`unrenderable: ${id}`);
1969
+ }
1970
+ // A substituted list item may only swap in place if its rendered marker matches
1971
+ // the old node's — a marker change (level/membership/numbering) means sibling
1972
+ // numbers shifted too, which only a remount repaints.
1973
+ const parse = (html) => {
1974
+ const el = new DOMParser().parseFromString(html, "text/html").body
1975
+ .firstElementChild;
1976
+ // Per-block converter output carries the XHTML xmlns; a full render only has it
1977
+ // on the document root, so drop it to keep reconciled DOM ≡ remounted DOM.
1978
+ el?.removeAttribute("xmlns");
1979
+ return el;
1980
+ };
1981
+ const freshBody = new Map();
1982
+ for (const j of bodyDiff.added) {
1983
+ const el = parse(rendered[plan.body[j].id]);
1984
+ if (!el)
1985
+ return this.bail(`unparseable render: ${plan.body[j].id}`);
1986
+ freshBody.set(j, el);
1987
+ }
1988
+ for (const { oldIndex, newIndex } of bodyDiff.substituted) {
1989
+ const freshRoot = freshBody.get(newIndex);
1990
+ const oldMarker = DocxEditor.listMarkerText(oldNodes[oldIndex]);
1991
+ const newMarker = DocxEditor.listMarkerText(freshRoot ? DocxEditor.anchorElOf(freshRoot) : null);
1992
+ if (oldMarker !== newMarker)
1993
+ return this.bail("substituted li marker drift");
1994
+ }
1995
+ if (!this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody))
1996
+ return this.bail("applyBodyDiff bail");
1997
+ this.applyNotesDiff("footnotes", plan.footnotes, fnState, rendered);
1998
+ this.applyNotesDiff("endnotes", plan.endnotes, enState, rendered);
1999
+ // Note chrome (marker sup text / hrefs / li values) is position-derived and NOT
2000
+ // covered by the unit diff — renumber whenever notes changed or any fresh body
2001
+ // node carries a citation marker.
2002
+ const freshHasMarker = [...freshBody.values()].some((el) => el.querySelector("a.footnote-ref, a.endnote-ref"));
2003
+ if (fnState.diff.added.length + fnState.diff.removed.length > 0 || freshHasMarker)
2004
+ this.renumberNoteChrome("footnote");
2005
+ if (enState.diff.added.length + enState.diff.removed.length > 0 || freshHasMarker)
2006
+ this.renumberNoteChrome("endnote");
2007
+ this.lastReconcileFallback = null;
2008
+ return true;
2009
+ }
2010
+ bail(reason) {
2011
+ this.lastReconcileFallback = reason;
2012
+ return false;
2013
+ }
2014
+ /** The generated single-child wrapper chain around a unit node (a table's alignment
2015
+ * <div>). Climbs while the parent is an anchor-less DIV whose ONLY element child is
2016
+ * the current node — never a section div (multi-child) or the edit root. */
2017
+ unitWrapperOf(el) {
2018
+ let n = el;
2019
+ while (n.parentElement &&
2020
+ n.parentElement !== this.editRoot &&
2021
+ n.parentElement.tagName === "DIV" &&
2022
+ !n.parentElement.hasAttribute("data-anchor") &&
2023
+ n.parentElement.childElementCount === 1) {
2024
+ n = n.parentElement;
2025
+ }
2026
+ return n;
2027
+ }
2028
+ /** The `[data-anchor]` element of a fresh render root (the root itself for a leaf
2029
+ * block, its descendant for a wrapper-shaped render like a table's align div). */
2030
+ static anchorElOf(root) {
2031
+ return root.hasAttribute("data-anchor")
2032
+ ? root
2033
+ : root.querySelector("[data-anchor]");
2034
+ }
2035
+ /** Insert/remove/swap body unit nodes per the diff. Returns false to bail (parent
2036
+ * ambiguity, order violation, wrapper semantics) — the session is already correct,
2037
+ * so bailing just means a full repaint. */
2038
+ applyBodyDiff(oldNodes, units, diff, fresh) {
2039
+ // Kept nodes must appear in increasing old order (no move support in v1).
2040
+ let lastOld = -1;
2041
+ for (let j = 0; j < units.length; j++) {
2042
+ const oi = diff.keep.get(j);
2043
+ if (oi === undefined)
2044
+ continue;
2045
+ if (oi < lastOld)
2046
+ return false;
2047
+ lastOld = oi;
2048
+ }
2049
+ // In-place substitutions first: replace at WRAPPER level so a table swaps with its
2050
+ // alignment div. A LEAF render replacing a wrapped node would break the wrapper's
2051
+ // semantics (border <div> grouping) — that is remount territory.
2052
+ const subOldByNew = new Map(diff.substituted.map((s) => [s.newIndex, s.oldIndex]));
2053
+ for (const [nj, oi] of subOldByNew) {
2054
+ const freshRoot = fresh.get(nj);
2055
+ const oldWrapper = this.unitWrapperOf(oldNodes[oi]);
2056
+ if (!freshRoot.hasAttribute("data-anchor")) {
2057
+ oldWrapper.replaceWith(freshRoot); // wrapper-shaped render (table) ⇄ wrapper
2058
+ }
2059
+ else if (oldWrapper === oldNodes[oi]) {
2060
+ oldNodes[oi].replaceWith(freshRoot);
2061
+ }
2062
+ else {
2063
+ return false; // leaf render into a wrapped slot — border-div semantics, remount
2064
+ }
2065
+ this.wireUnit(freshRoot, units[nj]);
2066
+ }
2067
+ // Pure inserts against kept/substituted neighbors (at wrapper level).
2068
+ const pureAdded = diff.added.filter((j) => !subOldByNew.has(j));
2069
+ const pureRemoved = diff.removed.filter((i) => !diff.substituted.some((s) => s.oldIndex === i));
2070
+ const nodeAt = (j) => {
2071
+ const oi = diff.keep.get(j);
2072
+ if (oi !== undefined)
2073
+ return oldNodes[oi];
2074
+ if (subOldByNew.has(j))
2075
+ return fresh.get(j);
2076
+ const f = fresh.get(j);
2077
+ return f && f.isConnected ? f : null;
2078
+ };
2079
+ for (const j of pureAdded) {
2080
+ const el = fresh.get(j);
2081
+ let prev = null;
2082
+ for (let k = j - 1; k >= 0 && !prev; k--)
2083
+ prev = nodeAt(k);
2084
+ let next = null;
2085
+ for (let k = j + 1; k < units.length && !next; k++) {
2086
+ const oi = diff.keep.get(k);
2087
+ if (oi !== undefined)
2088
+ next = oldNodes[oi];
2089
+ else if (subOldByNew.has(k))
2090
+ next = fresh.get(k);
2091
+ }
2092
+ const prevW = prev ? this.unitWrapperOf(prev) : null;
2093
+ const nextW = next ? this.unitWrapperOf(next) : null;
2094
+ if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
2095
+ return false;
2096
+ if (prevW)
2097
+ prevW.after(el);
2098
+ else if (nextW)
2099
+ nextW.before(el);
2100
+ else
2101
+ return false; // empty container — nowhere provably correct to insert
2102
+ this.wireUnit(el, units[j]);
2103
+ }
2104
+ // Pure removals last, taking now-empty generated wrappers with them.
2105
+ for (const i of pureRemoved) {
2106
+ const wrapper = this.unitWrapperOf(oldNodes[i]);
2107
+ wrapper.remove();
2108
+ }
2109
+ return true;
2110
+ }
2111
+ /** Wire a freshly rendered unit root (and its nested blocks) and stamp the unit's
2112
+ * content signature on its `[data-anchor]` element — the element the next
2113
+ * reconcile's DOM walk reads tokens from. */
2114
+ wireUnit(root, unit) {
2115
+ const anchorEl = DocxEditor.anchorElOf(root);
2116
+ if (anchorEl && unit.sig)
2117
+ anchorEl.setAttribute("data-render-sig", unit.sig);
2118
+ if (anchorEl)
2119
+ this.wireBlock(anchorEl);
2120
+ root.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
2121
+ }
2122
+ /** Old-sequence diff state for one notes section. `null` requests remount (DOM not
2123
+ * stampable/consistent). */
2124
+ notesDiff(sectionClass, units) {
2125
+ const ol = this.editRoot.querySelector(`section.${sectionClass} > ol`);
2126
+ const lis = ol ? Array.from(ol.children).filter((c) => c.tagName === "LI") : [];
2127
+ if (units.length === 0 && lis.length === 0)
2128
+ return { lis, diff: diffUnits([], []) };
2129
+ // A document that gains its FIRST note has no section to patch — remount builds it.
2130
+ if (!ol)
2131
+ return null;
2132
+ const tokens = [];
2133
+ for (const li of lis) {
2134
+ const unid = li.getAttribute("data-note-anchor");
2135
+ if (!unid)
2136
+ return null; // unstamped DOM (older mount) — remount restamps
2137
+ const sig = li.getAttribute("data-render-sig");
2138
+ tokens.push(sig ? `${unid}|${sig}` : unid);
2139
+ }
2140
+ return { lis, diff: diffUnits(tokens, units) };
2141
+ }
2142
+ /** Apply a notes-section diff: rebuild the `<ol>`'s li list, preserving kept nodes. */
2143
+ applyNotesDiff(sectionClass, units, state, rendered) {
2144
+ if (state.diff.added.length === 0 && state.diff.removed.length === 0)
2145
+ return;
2146
+ // Removing the LAST note removes the whole section — a full render emits no
2147
+ // section for a document without notes, and equivalence with remount is the pin.
2148
+ if (units.length === 0) {
2149
+ this.editRoot.querySelector(`section.${sectionClass}`)?.remove();
2150
+ return;
2151
+ }
2152
+ const ol = this.editRoot.querySelector(`section.${sectionClass} > ol`);
2153
+ const prefix = sectionClass === "footnotes" ? "fn" : "en";
2154
+ const nodes = [];
2155
+ for (let j = 0; j < units.length; j++) {
2156
+ const oi = state.diff.keep.get(j);
2157
+ if (oi !== undefined) {
2158
+ nodes.push(state.lis[oi]);
2159
+ continue;
2160
+ }
2161
+ nodes.push(this.buildNoteLi(prefix, units[j], rendered[units[j].id]));
2162
+ }
2163
+ ol.replaceChildren(...nodes);
2164
+ }
2165
+ /** Build a notes-section `<li>` for a freshly rendered note — replicating the
2166
+ * converter's chrome (id/value are re-stamped by the renumber pass; the backref
2167
+ * goes inside the last paragraph, matching RenderFootnoteItem). */
2168
+ buildNoteLi(prefix, unit, html) {
2169
+ const li = document.createElement("li");
2170
+ li.setAttribute("data-note-anchor", unidOf(unit.id));
2171
+ if (unit.sig)
2172
+ li.setAttribute("data-render-sig", unit.sig);
2173
+ li.innerHTML = html;
2174
+ const paras = li.querySelectorAll(":scope > p");
2175
+ const last = paras[paras.length - 1];
2176
+ if (last) {
2177
+ const backref = document.createElement("a");
2178
+ backref.setAttribute("class", `${prefix}-backref`);
2179
+ backref.setAttribute("contenteditable", "false");
2180
+ backref.textContent = "↩";
2181
+ last.append(" ", backref);
2182
+ }
2183
+ li.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
2184
+ return li;
2185
+ }
2186
+ /**
2187
+ * Rewrite position-derived note chrome from the session's citation-ordered note
2188
+ * list: the k-th marker in document order IS note k (ids ascend in reference
2189
+ * order), so marker sup text, hrefs/ids, li ids/values and backref hrefs are all
2190
+ * re-derived positionally. Pure attribute/text patching of generated chrome.
2191
+ */
2192
+ renumberNoteChrome(kind) {
2193
+ const bridge = this.exports.DocxSessionBridge;
2194
+ if (typeof bridge.ListNotes !== "function")
2195
+ return;
2196
+ const prefix = kind === "footnote" ? "fn" : "en";
2197
+ let notes;
2198
+ try {
2199
+ notes = JSON.parse(bridge.ListNotes(this.handle, kind === "endnote"));
2200
+ }
2201
+ catch {
2202
+ return;
2203
+ }
2204
+ if (!Array.isArray(notes))
2205
+ return;
2206
+ const markers = Array.from(this.editRoot.querySelectorAll(`a.${kind}-ref`)).filter((a) => !a.closest("section.footnotes, section.endnotes"));
2207
+ markers.forEach((a, k) => {
2208
+ const n = notes[k];
2209
+ if (!n)
2210
+ return;
2211
+ a.setAttribute("href", `#${prefix}-${n.id}`);
2212
+ a.id = `${prefix}-ref-${n.id}`;
2213
+ if (kind === "footnote")
2214
+ a.setAttribute("data-footnote-id", n.id);
2215
+ const sup = a.querySelector("sup");
2216
+ if (sup)
2217
+ sup.textContent = String(n.ordinal);
2218
+ });
2219
+ // Match list items by their stamped note anchor, NOT position: the section can
2220
+ // hold rendered-but-never-cited notes (Word's continuationNotice) that ListNotes
2221
+ // — a citation walk — does not list; positional pairing would relabel them.
2222
+ const byUnid = new Map(notes.map((n) => [unidOf(n.defAnchorId), n]));
2223
+ const lis = this.editRoot.querySelectorAll(`section.${kind}s > ol > li`);
2224
+ lis.forEach((li) => {
2225
+ const unid = li.getAttribute("data-note-anchor");
2226
+ const n = unid ? byUnid.get(unid) : undefined;
2227
+ if (!n)
2228
+ return;
2229
+ li.id = `${prefix}-${n.id}`;
2230
+ li.setAttribute("value", String(n.ordinal));
2231
+ li.querySelectorAll(`a.${prefix}-backref`).forEach((b) => b.setAttribute("href", `#${prefix}-ref-${n.id}`));
2232
+ });
2233
+ }
2234
+ /** After an incremental block swap, stale marker chrome in the swapped node (the
2235
+ * throwaway render numbers citations from 1) is repaired in place. */
2236
+ maybeRenumberNotes(fresh) {
2237
+ if (fresh.querySelector("a.footnote-ref"))
2238
+ this.renumberNoteChrome("footnote");
2239
+ if (fresh.querySelector("a.endnote-ref"))
2240
+ this.renumberNoteChrome("endnote");
2241
+ }
2242
+ /** Stamp the DOM state the reconciler diffs against: container signatures on body
2243
+ * tables and `data-note-anchor` + signature on notes-section items. Called after
2244
+ * every full mount; reconcile stamps its own insertions. */
2245
+ stampPlanState() {
2246
+ const bridge = this.exports.DocxSessionBridge;
2247
+ if (typeof bridge.ListBlocks !== "function")
2248
+ return;
2249
+ try {
2250
+ const plan = JSON.parse(bridge.ListBlocks(this.handle));
2251
+ if (plan.error)
2252
+ return;
2253
+ // Positional pairing: a fresh full mount renders exactly the plan's units in
2254
+ // order (verified invariant). On any mismatch, leave unstamped — an unstamped
2255
+ // unit just diffs as changed and re-renders once.
2256
+ const nodes = this.bodyUnitNodes();
2257
+ if (nodes.length === plan.body.length) {
2258
+ nodes.forEach((el, k) => {
2259
+ if (plan.body[k].sig && el.getAttribute("data-anchor") === unidOf(plan.body[k].id))
2260
+ el.setAttribute("data-render-sig", plan.body[k].sig);
2261
+ });
2262
+ }
2263
+ const stampNotes = (sectionClass, units) => {
2264
+ const lis = this.editRoot.querySelectorAll(`section.${sectionClass} > ol > li`);
2265
+ if (lis.length !== units.length)
2266
+ return; // inconsistent — leave unstamped (reconcile will remount)
2267
+ lis.forEach((li, k) => {
2268
+ li.setAttribute("data-note-anchor", unidOf(units[k].id));
2269
+ if (units[k].sig)
2270
+ li.setAttribute("data-render-sig", units[k].sig);
2271
+ });
2272
+ };
2273
+ stampNotes("footnotes", plan.footnotes);
2274
+ stampNotes("endnotes", plan.endnotes);
2275
+ }
2276
+ catch {
2277
+ /* stamping is best-effort; unstamped DOM just falls back to remount */
2278
+ }
2279
+ }
1501
2280
  /**
1502
2281
  * Full re-render from current session state (after undo/redo, and after list edits where
1503
2282
  * single-block rendering can't compute numbering). Optionally focus the editable block at
@@ -1520,6 +2299,9 @@ export class DocxEditor {
1520
2299
  placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
1521
2300
  }
1522
2301
  }
2302
+ // A remount rebuilds the body from the live session; re-resolve the section so undo/redo of a
2303
+ // section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
2304
+ this.syncRegionToBody(this.activeBlock ?? undefined);
1523
2305
  }
1524
2306
  }
1525
2307
  //# sourceMappingURL=editor.js.map