docxodus 8.0.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 (46) hide show
  1. package/README.md +65 -24
  2. package/dist/editor-headerfooter.d.ts +39 -3
  3. package/dist/editor-headerfooter.d.ts.map +1 -1
  4. package/dist/editor-headerfooter.js +118 -2
  5. package/dist/editor-headerfooter.js.map +1 -1
  6. package/dist/editor-reconcile.d.ts +75 -0
  7. package/dist/editor-reconcile.d.ts.map +1 -0
  8. package/dist/editor-reconcile.js +125 -0
  9. package/dist/editor-reconcile.js.map +1 -0
  10. package/dist/editor.bundle.js +799 -42
  11. package/dist/editor.d.ts +121 -5
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +572 -31
  14. package/dist/editor.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/page-number-format.d.ts +22 -0
  19. package/dist/page-number-format.d.ts.map +1 -0
  20. package/dist/page-number-format.js +60 -0
  21. package/dist/page-number-format.js.map +1 -0
  22. package/dist/pagination.bundle.js +158 -21
  23. package/dist/pagination.d.ts +38 -0
  24. package/dist/pagination.d.ts.map +1 -1
  25. package/dist/pagination.js +181 -29
  26. package/dist/pagination.js.map +1 -1
  27. package/dist/session.bundle.js +226 -4
  28. package/dist/session.d.ts +147 -6
  29. package/dist/session.d.ts.map +1 -1
  30. package/dist/session.js +187 -4
  31. package/dist/session.js.map +1 -1
  32. package/dist/types.d.ts +191 -4
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  36. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  37. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  38. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  39. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  40. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  41. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  42. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  43. package/dist/wasm/_framework/dotnet.boot.js +10 -10
  44. package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
  45. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  46. package/package.json +17 -4
package/dist/editor.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { paginateHtml } from "./pagination.js";
20
20
  import { HeaderFooterRegion } from "./editor-headerfooter.js";
21
+ import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
21
22
  const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
22
23
  function fontWeightIsBold(w) {
23
24
  if (w === "bold" || w === "bolder")
@@ -31,8 +32,9 @@ function escapeInlineMarkdown(text) {
31
32
  }
32
33
  function collectInlineSegments(node, out) {
33
34
  node.childNodes.forEach((child) => {
34
- // Skip generated list-marker spans — they aren't part of the paragraph's content.
35
- 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))
36
38
  return;
37
39
  if (child.nodeType === 3 /* TEXT_NODE */) {
38
40
  const text = child.textContent ?? "";
@@ -112,11 +114,27 @@ export function serializeInlineMarkdown(block) {
112
114
  function isListBlock(block) {
113
115
  return !!block.querySelector(":scope > [data-list-marker]");
114
116
  }
115
- /** 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). */
116
134
  function isInMarker(node) {
117
135
  let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
118
136
  while (el) {
119
- if (el.hasAttribute && el.hasAttribute("data-list-marker"))
137
+ if (isGeneratedChrome(el))
120
138
  return true;
121
139
  el = el.parentElement;
122
140
  }
@@ -425,7 +443,10 @@ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
425
443
  bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
426
444
  /* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
427
445
  false, 0, "annot-",
428
- /* 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,
429
450
  false, true, true, false, null, /* stampAnchors */ true,
430
451
  ];
431
452
  }
@@ -453,6 +474,9 @@ export class DocxEditor {
453
474
  this.lastSelection = null;
454
475
  /** The docked header/footer bands, when `options.headerFooter` is on. */
455
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;
456
480
  /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
457
481
  this.onSelectionChange = () => {
458
482
  if (this.closed)
@@ -508,15 +532,21 @@ export class DocxEditor {
508
532
  /**
509
533
  * Repaint after an edit to `block` that would otherwise remount the whole document: a band
510
534
  * repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
511
- * user's place in it — untouched.
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).
512
539
  */
513
- refreshAfter(block, focusIndex, caretAtEnd = false) {
540
+ refreshAfter(block, focusIndex, caretAtEnd = false, forceRemount = false) {
514
541
  const band = this.region?.bandOf(block);
515
542
  if (band) {
516
543
  this.region.refresh(this.region.whichOf(band));
517
544
  return;
518
545
  }
519
- this.remount(focusIndex, caretAtEnd);
546
+ if (forceRemount)
547
+ this.remount(focusIndex, caretAtEnd);
548
+ else
549
+ this.reconcile(focusIndex, caretAtEnd);
520
550
  }
521
551
  /** Open a document, render it into `container`, and wire up editing. */
522
552
  static open(container, bytes, exports, options = {}) {
@@ -529,11 +559,13 @@ export class DocxEditor {
529
559
  headerFooter: options.headerFooter ?? false,
530
560
  onEdit: options.onEdit,
531
561
  };
532
- // persistAnchorIds=true keeps PtOpenXml:Unid attributes in Save() output, so a remount's
533
- // full re-render keeps the SAME unids the live session uses (a content change like becoming
534
- // a list otherwise re-derives a fresh unid, leaving the block unwired). The cost is that
535
- // saved bytes carry the Unid attributes (Word ignores them).
536
- 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}');
537
569
  const editor = new DocxEditor(container, exports, handle, opts);
538
570
  editor.refreshAnchorMap();
539
571
  if (opts.headerFooter)
@@ -601,7 +633,14 @@ export class DocxEditor {
601
633
  * edit into a header part.
602
634
  */
603
635
  refreshAnchorMap() {
604
- 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);
605
644
  this.unidToFullId.clear();
606
645
  const bodyOwned = new Set();
607
646
  for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
@@ -669,6 +708,7 @@ export class DocxEditor {
669
708
  this.editRoot = this.container;
670
709
  if (this.options.editable)
671
710
  this.wireBlocks(this.container);
711
+ this.stampPlanState();
672
712
  return;
673
713
  }
674
714
  // With bands docked, the body flow needs its own wrapper to be the edit root.
@@ -680,6 +720,7 @@ export class DocxEditor {
680
720
  this.editRoot = flow;
681
721
  if (this.options.editable)
682
722
  this.wireBlocks(flow);
723
+ this.stampPlanState();
683
724
  this.dockBands(flow);
684
725
  }
685
726
  /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
@@ -733,9 +774,11 @@ export class DocxEditor {
733
774
  if (!unid || !this.anchorIdOf(el))
734
775
  return;
735
776
  el.setAttribute("contenteditable", "true");
736
- // Generated list markers (number/bullet + suffix) are not editable content — keep the
737
- // caret out of them so offsets stay aligned with the paragraph's run text.
738
- 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"));
739
782
  // Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
740
783
  // matching the session's flat run-text offset space.
741
784
  el.dataset.committedText = blockContentText(el);
@@ -834,6 +877,8 @@ export class DocxEditor {
834
877
  this.wireBlock(fresh);
835
878
  if (this.activeBlock === el)
836
879
  this.activeBlock = fresh; // keep ribbon target valid
880
+ // The throwaway render numbers citation markers from 1 — repair in place.
881
+ this.maybeRenumberNotes(fresh);
837
882
  }
838
883
  }
839
884
  this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
@@ -1399,9 +1444,10 @@ export class DocxEditor {
1399
1444
  const res = this.parseEdit(this.exports.DocxSessionBridge.InsertHorizontalRule(this.handle, fullId, position === "above" ? "before" : "after", JSON.stringify({ style, size: weight, color: "auto" })));
1400
1445
  if (!res.success)
1401
1446
  return;
1402
- // remount from the active block's index re-renders the new rule whether it landed just
1403
- // above (at idx) or just below (at idx+1) the active block.
1404
- this.refreshAfter(block, 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);
1405
1451
  }
1406
1452
  /**
1407
1453
  * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
@@ -1494,8 +1540,8 @@ export class DocxEditor {
1494
1540
  if (!res.success)
1495
1541
  return;
1496
1542
  // A level change ripples through the whole list's numbering — re-render with full document
1497
- // context (a single-block render can't compute nested numbering), keeping the caret in place.
1498
- this.refreshAfter(block, idx, false);
1543
+ // context (sibling numbers shift without sibling XML changing), keeping the caret in place.
1544
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
1499
1545
  }
1500
1546
  /** Toggle (or set) page-break-before on the active block. */
1501
1547
  pageBreakBefore(value = true) {
@@ -1528,8 +1574,8 @@ export class DocxEditor {
1528
1574
  if (!res.success)
1529
1575
  return;
1530
1576
  // Numbering continuation across the list needs whole-document context — re-render fully
1531
- // (a single-block render would show every numbered item as "1.").
1532
- this.refreshAfter(block, idx, false);
1577
+ // (sibling numbers shift without sibling XML changing).
1578
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
1533
1579
  }
1534
1580
  /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
1535
1581
  * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
@@ -1564,6 +1610,48 @@ export class DocxEditor {
1564
1610
  return;
1565
1611
  this.refreshAfter(block, Math.max(0, idx - 1), true);
1566
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);
1654
+ }
1567
1655
  applyParagraphFormat(op) {
1568
1656
  const block = this.activeBlock;
1569
1657
  if (this.closed || !block)
@@ -1588,7 +1676,7 @@ export class DocxEditor {
1588
1676
  // A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
1589
1677
  // it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
1590
1678
  if (this.affectsList(res) || op.clearBorders) {
1591
- this.refreshAfter(block, idx, false);
1679
+ this.refreshAfter(block, idx, false, true);
1592
1680
  return;
1593
1681
  }
1594
1682
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
@@ -1615,24 +1703,24 @@ export class DocxEditor {
1615
1703
  if (!res.success)
1616
1704
  return;
1617
1705
  if (this.affectsList(res)) {
1618
- this.refreshAfter(block, idx, false);
1706
+ this.refreshAfter(block, idx, false, true);
1619
1707
  return;
1620
1708
  }
1621
1709
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
1622
1710
  }
1623
- /** Undo the last edit (re-renders the document). */
1711
+ /** Undo the last edit (incremental repaint; falls back to a full re-render). */
1624
1712
  undo() {
1625
1713
  if (this.closed)
1626
1714
  return;
1627
1715
  if (this.exports.DocxSessionBridge.Undo(this.handle))
1628
- this.remount();
1716
+ this.reconcile();
1629
1717
  }
1630
- /** Redo the last undone edit (re-renders the document). */
1718
+ /** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
1631
1719
  redo() {
1632
1720
  if (this.closed)
1633
1721
  return;
1634
1722
  if (this.exports.DocxSessionBridge.Redo(this.handle))
1635
- this.remount();
1723
+ this.reconcile();
1636
1724
  }
1637
1725
  // ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
1638
1726
  /**
@@ -1667,6 +1755,31 @@ export class DocxEditor {
1667
1755
  // No band block focused — target the footer, where page numbers overwhelmingly live.
1668
1756
  this.region.insertPageNumberInBand("footer", field);
1669
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() ?? {};
1782
+ }
1670
1783
  /** Which inline formats the current selection carries — for ribbon button highlighting. */
1671
1784
  queryFormatState() {
1672
1785
  const block = this.activeBlock ?? this.editRoot;
@@ -1701,6 +1814,8 @@ export class DocxEditor {
1701
1814
  this.region.adoptBlock(fresh, anchorId);
1702
1815
  this.wireBlock(fresh);
1703
1816
  this.activeBlock = fresh;
1817
+ // The throwaway render numbers citation markers from 1 — repair in place.
1818
+ this.maybeRenumberNotes(fresh);
1704
1819
  this.options.onEdit?.({ anchorId, unid: newUnid });
1705
1820
  return fresh;
1706
1821
  }
@@ -1718,7 +1833,13 @@ export class DocxEditor {
1718
1833
  if (html.charCodeAt(0) !== 0x7b /* not an error object */)
1719
1834
  return html;
1720
1835
  }
1721
- 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);
1722
1843
  return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
1723
1844
  }
1724
1845
  /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
@@ -1736,6 +1857,426 @@ export class DocxEditor {
1736
1857
  affectsList(res) {
1737
1858
  return [...(res.modified ?? []), ...(res.created ?? [])].some((r) => r.kind === "li");
1738
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
+ }
1739
2280
  /**
1740
2281
  * Full re-render from current session state (after undo/redo, and after list edits where
1741
2282
  * single-block rendering can't compute numbering). Optionally focus the editable block at