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
@@ -25,6 +25,40 @@ var DocxodusEditor = (() => {
25
25
  serializeInlineMarkdown: () => serializeInlineMarkdown
26
26
  });
27
27
 
28
+ // src/page-number-format.ts
29
+ var ROMAN_ONES = ["", "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix"];
30
+ var ROMAN_TENS = ["", "x", "xx", "xxx", "xl", "l", "lx", "lxx", "lxxx", "xc"];
31
+ var ROMAN_HUNDREDS = ["", "c", "cc", "ccc", "cd", "d", "dc", "dcc", "dccc", "cm"];
32
+ var ROMAN_THOUSANDS = ["", "m", "mm", "mmm"];
33
+ function toRoman(value) {
34
+ if (value <= 0 || value >= 4e3) return String(value);
35
+ return ROMAN_THOUSANDS[Math.floor(value / 1e3)] + ROMAN_HUNDREDS[Math.floor(value % 1e3 / 100)] + ROMAN_TENS[Math.floor(value % 100 / 10)] + ROMAN_ONES[value % 10];
36
+ }
37
+ function toLetter(value) {
38
+ if (value <= 0) return String(value);
39
+ const wrapped = value % 780 === 0 ? 780 : value % 780;
40
+ const repeats = Math.floor((wrapped - 1) / 26) + 1;
41
+ return "abcdefghijklmnopqrstuvwxyz".charAt((wrapped - 1) % 26).repeat(repeats);
42
+ }
43
+ var RENDERERS = {
44
+ // ST_NumberFormat tokens (w:pgNumType/@w:fmt, w:numFmt).
45
+ decimal: (v) => String(v),
46
+ lowerRoman: toRoman,
47
+ upperRoman: (v) => toRoman(v).toUpperCase(),
48
+ lowerLetter: toLetter,
49
+ upperLetter: (v) => toLetter(v).toUpperCase(),
50
+ // Field `\*` general-formatting switch arguments.
51
+ Arabic: (v) => String(v),
52
+ roman: toRoman,
53
+ ROMAN: (v) => toRoman(v).toUpperCase(),
54
+ alphabetic: toLetter,
55
+ ALPHABETIC: (v) => toLetter(v).toUpperCase()
56
+ };
57
+ function formatPageNumber(value, format) {
58
+ const renderer = format ? RENDERERS[format] : void 0;
59
+ return (renderer ?? RENDERERS.decimal)(value);
60
+ }
61
+
28
62
  // src/pagination.ts
29
63
  var DEFAULT_PAGE_WIDTH = 612;
30
64
  var DEFAULT_PAGE_HEIGHT = 792;
@@ -72,6 +106,8 @@ var DocxodusEditor = (() => {
72
106
  */
73
107
  constructor(staging, container, options = {}) {
74
108
  this.pendingFootnoteContinuation = null;
109
+ /** Per-section `w:pgNumType` (start / format), read off the section wrappers. */
110
+ this.pageNumbering = /* @__PURE__ */ new Map();
75
111
  this.stagingElement = typeof staging === "string" ? document.getElementById(staging) : staging;
76
112
  this.containerElement = typeof container === "string" ? document.getElementById(container) : container;
77
113
  if (!this.stagingElement) {
@@ -101,6 +137,7 @@ var DocxodusEditor = (() => {
101
137
  const sections = this.stagingElement.querySelectorAll(
102
138
  "[data-section-index]"
103
139
  );
140
+ this.pageNumbering = this.parsePageNumbering(sections);
104
141
  const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
105
142
  for (const section of sectionsToProcess) {
106
143
  const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
@@ -116,8 +153,62 @@ var DocxodusEditor = (() => {
116
153
  pageNumber += sectionPages.length;
117
154
  }
118
155
  this.stagingElement.style.display = "none";
156
+ this.substitutePageNumberFields(pages.length);
119
157
  return { totalPages: pages.length, pages };
120
158
  }
159
+ /** Read each section's `w:pgNumType` off its wrapper (see {@link SectionPageNumbering}). */
160
+ parsePageNumbering(sections) {
161
+ const map = /* @__PURE__ */ new Map();
162
+ for (const section of Array.from(sections)) {
163
+ const index = parseInt(section.dataset.sectionIndex || "0", 10);
164
+ const rawStart = section.dataset.pageNumStart;
165
+ const start = rawStart === void 0 ? void 0 : parseInt(rawStart, 10);
166
+ map.set(index, {
167
+ start: start !== void 0 && Number.isFinite(start) ? start : void 0,
168
+ format: section.dataset.pageNumFmt
169
+ });
170
+ }
171
+ return map;
172
+ }
173
+ /**
174
+ * Fill in the page-number fields inside every page's cloned header/footer.
175
+ *
176
+ * A header/footer is authored once and cloned onto each page, so a PAGE field's single cached
177
+ * result would otherwise show the same number on every page — the whole reason the converter
178
+ * marks these. `data-field-format` (the field's own `\*` switch) wins over the section's format
179
+ * when present, which is exactly how Word resolves the two.
180
+ *
181
+ * Runs after layout because NUMPAGES cannot be known before the last page exists. The
182
+ * substituted text can therefore be marginally wider than the cached result the header was
183
+ * measured with; the header band clips, so the failure mode is a hair of overflow rather than
184
+ * a layout that disagrees with itself.
185
+ *
186
+ * Scoped to the CLONED header/footer regions on purpose. A page-number field in body text is
187
+ * ordinary run content that the editor may make editable, and committing an edited block writes
188
+ * back whatever text the DOM holds — rewriting it here would mean a body field commits a number
189
+ * the document never contained. Body content is also not cloned, so it does not have the problem
190
+ * this method exists to solve.
191
+ */
192
+ substitutePageNumberFields(totalPages) {
193
+ const boxes = this.containerElement.querySelectorAll(`.${this.cssPrefix}box`);
194
+ for (const box of Array.from(boxes)) {
195
+ const markers = box.querySelectorAll(
196
+ `.${this.cssPrefix}header [data-field], .${this.cssPrefix}footer [data-field]`
197
+ );
198
+ if (markers.length === 0) continue;
199
+ const sectionIndex = parseInt(box.dataset.sectionIndex || "0", 10);
200
+ const pageNumber = parseInt(box.dataset.pageNumber || "1", 10);
201
+ const pageInSection = parseInt(box.dataset.pageInSection || "1", 10);
202
+ const numbering = this.pageNumbering.get(sectionIndex) ?? {};
203
+ const displayed = numbering.start !== void 0 ? numbering.start + pageInSection - 1 : pageNumber;
204
+ for (const marker of Array.from(markers)) {
205
+ const kind = marker.dataset.field;
206
+ if (kind !== "PAGE" && kind !== "NUMPAGES") continue;
207
+ const format = marker.dataset.fieldFormat ?? numbering.format;
208
+ marker.textContent = formatPageNumber(kind === "PAGE" ? displayed : totalPages, format);
209
+ }
210
+ }
211
+ }
121
212
  /**
122
213
  * Measures all content blocks in a section.
123
214
  */
@@ -641,6 +732,7 @@ var DocxodusEditor = (() => {
641
732
  measureContainer.style.visibility = "hidden";
642
733
  measureContainer.style.width = `${contentWidth}pt`;
643
734
  measureContainer.style.left = "-9999px";
735
+ measureContainer.className = this.cssPrefix + "footnotes";
644
736
  const hr = document.createElement("hr");
645
737
  measureContainer.appendChild(hr);
646
738
  if (hasContinuation) {
@@ -675,6 +767,7 @@ var DocxodusEditor = (() => {
675
767
  measureContainer.style.visibility = "hidden";
676
768
  measureContainer.style.width = `${contentWidth}pt`;
677
769
  measureContainer.style.left = "-9999px";
770
+ measureContainer.className = this.cssPrefix + "footnotes";
678
771
  const hr = document.createElement("hr");
679
772
  measureContainer.appendChild(hr);
680
773
  for (const el of continuation.remainingElements) {
@@ -693,11 +786,17 @@ var DocxodusEditor = (() => {
693
786
  splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
694
787
  const footnoteContent = footnoteElement.querySelector(".footnote-content");
695
788
  if (!footnoteContent) {
696
- return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
789
+ return {
790
+ fits: Array.from(footnoteElement.children).map((el) => el.cloneNode(true)),
791
+ overflow: []
792
+ };
697
793
  }
698
794
  const children = Array.from(footnoteContent.children);
699
795
  if (children.length <= 1) {
700
- return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
796
+ return {
797
+ fits: children.map((el) => el.cloneNode(true)),
798
+ overflow: []
799
+ };
701
800
  }
702
801
  const fits = [];
703
802
  const overflow = [];
@@ -707,6 +806,7 @@ var DocxodusEditor = (() => {
707
806
  hrMeasure.style.visibility = "hidden";
708
807
  hrMeasure.style.width = `${contentWidth}pt`;
709
808
  hrMeasure.style.left = "-9999px";
809
+ hrMeasure.className = this.cssPrefix + "footnotes";
710
810
  const hr = document.createElement("hr");
711
811
  hrMeasure.appendChild(hr);
712
812
  this.stagingElement.appendChild(hrMeasure);
@@ -721,6 +821,7 @@ var DocxodusEditor = (() => {
721
821
  measureContainer.style.visibility = "hidden";
722
822
  measureContainer.style.width = `${contentWidth}pt`;
723
823
  measureContainer.style.left = "-9999px";
824
+ measureContainer.className = this.cssPrefix + "footnotes";
724
825
  measureContainer.appendChild(child.cloneNode(true));
725
826
  this.stagingElement.appendChild(measureContainer);
726
827
  const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
@@ -748,6 +849,7 @@ var DocxodusEditor = (() => {
748
849
  measureContainer.style.visibility = "hidden";
749
850
  measureContainer.style.width = `${contentWidth}pt`;
750
851
  measureContainer.style.left = "-9999px";
852
+ measureContainer.className = this.cssPrefix + "footnotes";
751
853
  measureContainer.appendChild(footnote.cloneNode(true));
752
854
  this.stagingElement.appendChild(measureContainer);
753
855
  const rect = measureContainer.getBoundingClientRect();
@@ -931,6 +1033,7 @@ var DocxodusEditor = (() => {
931
1033
  let currentFootnoteHeight = 0;
932
1034
  let currentContinuation = this.pendingFootnoteContinuation;
933
1035
  let nextPageContinuation = null;
1036
+ let deferredFootnoteIds = [];
934
1037
  let currentPartialFootnotes = [];
935
1038
  if (currentContinuation && currentContinuation.remainingElements.length > 0) {
936
1039
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
@@ -961,11 +1064,22 @@ var DocxodusEditor = (() => {
961
1064
  currentPartialFootnotes = [];
962
1065
  currentContinuation = nextPageContinuation;
963
1066
  nextPageContinuation = null;
1067
+ if (deferredFootnoteIds.length > 0) {
1068
+ currentFootnoteIds = [...deferredFootnoteIds];
1069
+ deferredFootnoteIds = [];
1070
+ }
964
1071
  if (currentContinuation && currentContinuation.remainingElements.length > 0) {
965
1072
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
966
1073
  } else {
967
1074
  currentFootnoteHeight = 0;
968
1075
  }
1076
+ if (currentFootnoteIds.length > 0) {
1077
+ currentFootnoteHeight += this.measureFootnotesHeight(
1078
+ currentFootnoteIds,
1079
+ dims.contentWidth,
1080
+ null
1081
+ );
1082
+ }
969
1083
  };
970
1084
  for (let i = 0; i < blocks.length; i++) {
971
1085
  const block = blocks[i];
@@ -1111,22 +1225,10 @@ var DocxodusEditor = (() => {
1111
1225
  }
1112
1226
  currentFootnoteHeight = availableForFootnotes;
1113
1227
  } else {
1114
- nextPageContinuation = {
1115
- footnoteId,
1116
- remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *")).map((el) => el.cloneNode(true))
1117
- };
1118
- if (nextPageContinuation.remainingElements.length === 0) {
1119
- nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
1120
- }
1228
+ deferredFootnoteIds.push(footnoteId);
1121
1229
  }
1122
1230
  } else {
1123
- nextPageContinuation = {
1124
- footnoteId,
1125
- remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *")).map((el) => el.cloneNode(true))
1126
- };
1127
- if (nextPageContinuation.remainingElements.length === 0) {
1128
- nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
1129
- }
1231
+ deferredFootnoteIds.push(footnoteId);
1130
1232
  }
1131
1233
  }
1132
1234
  }
@@ -1146,7 +1248,7 @@ var DocxodusEditor = (() => {
1146
1248
  currentContent.push(block.element.cloneNode(true));
1147
1249
  remainingHeight = effectiveContentHeight - newPageSpace;
1148
1250
  prevMarginBottomPt = block.marginBottomPt;
1149
- currentFootnoteIds = [...allBlockFootnoteIds];
1251
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1150
1252
  currentFootnoteHeight = newPageFootnoteHeight;
1151
1253
  }
1152
1254
  } else {
@@ -1156,7 +1258,7 @@ var DocxodusEditor = (() => {
1156
1258
  currentContent.push(block.element.cloneNode(true));
1157
1259
  remainingHeight = effectiveContentHeight - newPageSpace;
1158
1260
  prevMarginBottomPt = block.marginBottomPt;
1159
- currentFootnoteIds = [...allBlockFootnoteIds];
1261
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1160
1262
  currentFootnoteHeight = newPageFootnoteHeight;
1161
1263
  }
1162
1264
  } else {
@@ -1173,7 +1275,7 @@ var DocxodusEditor = (() => {
1173
1275
  finishPage();
1174
1276
  }
1175
1277
  currentContent.push(block.element.cloneNode(true));
1176
- currentFootnoteIds = [...allBlockFootnoteIds];
1278
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
1177
1279
  finishPage();
1178
1280
  }
1179
1281
  }
@@ -1181,6 +1283,27 @@ var DocxodusEditor = (() => {
1181
1283
  this.pendingFootnoteContinuation = nextPageContinuation;
1182
1284
  return pages;
1183
1285
  }
1286
+ /**
1287
+ * Strip block addressing from a header/footer node cloned into a page box.
1288
+ *
1289
+ * A running story is authored ONCE and cloned onto every page, so the clones all carry the same
1290
+ * `data-anchor` — on this document, 42 page boxes claiming one footer paragraph. Left editable,
1291
+ * committing any one of them writes back through that single shared anchor, and the per-page
1292
+ * page-number substitution makes it worse: each clone shows a DIFFERENT number, so a commit
1293
+ * writes that page's number into the story as literal text and destroys the PAGE field.
1294
+ *
1295
+ * Page-box header/footer content is presentation. The docked editing bands
1296
+ * (`editor-headerfooter.ts`) are the addressable affordance, and they exist precisely because a
1297
+ * cloned node cannot be uniquely addressed.
1298
+ */
1299
+ makeClonedStoryInert(root) {
1300
+ const nodes = [root, ...Array.from(root.querySelectorAll("*"))];
1301
+ for (const el of nodes) {
1302
+ el.removeAttribute("data-anchor");
1303
+ el.removeAttribute("data-committed-text");
1304
+ if (el.getAttribute("contenteditable") !== null) el.setAttribute("contenteditable", "false");
1305
+ }
1306
+ }
1184
1307
  /**
1185
1308
  * Creates a page container element.
1186
1309
  */
@@ -1206,6 +1329,7 @@ var DocxodusEditor = (() => {
1206
1329
  pageBox.style.contain = "layout paint";
1207
1330
  pageBox.dataset.pageNumber = String(pageNumber);
1208
1331
  pageBox.dataset.sectionIndex = String(sectionIndex);
1332
+ pageBox.dataset.pageInSection = String(pageInSection);
1209
1333
  const effectiveHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
1210
1334
  const headerSource = this.selectHeader(sectionIndex, pageInSection, pageNumber);
1211
1335
  if (headerSource) {
@@ -1223,7 +1347,9 @@ var DocxodusEditor = (() => {
1223
1347
  headerDiv.style.justifyContent = "flex-end";
1224
1348
  headerDiv.style.paddingBottom = "4pt";
1225
1349
  for (const child of Array.from(headerSource.childNodes)) {
1226
- headerDiv.appendChild(child.cloneNode(true));
1350
+ const clonedheaderDiv = child.cloneNode(true);
1351
+ if (clonedheaderDiv.nodeType === 1) this.makeClonedStoryInert(clonedheaderDiv);
1352
+ headerDiv.appendChild(clonedheaderDiv);
1227
1353
  }
1228
1354
  pageBox.appendChild(headerDiv);
1229
1355
  }
@@ -1261,7 +1387,9 @@ var DocxodusEditor = (() => {
1261
1387
  footerDiv.style.justifyContent = "flex-start";
1262
1388
  footerDiv.style.paddingTop = "4pt";
1263
1389
  for (const child of Array.from(footerSource.childNodes)) {
1264
- footerDiv.appendChild(child.cloneNode(true));
1390
+ const clonedfooterDiv = child.cloneNode(true);
1391
+ if (clonedfooterDiv.nodeType === 1) this.makeClonedStoryInert(clonedfooterDiv);
1392
+ footerDiv.appendChild(clonedfooterDiv);
1265
1393
  }
1266
1394
  pageBox.appendChild(footerDiv);
1267
1395
  }
@@ -1272,6 +1400,13 @@ var DocxodusEditor = (() => {
1272
1400
  pageBox.appendChild(pageNum);
1273
1401
  }
1274
1402
  this.containerElement.appendChild(pageBox);
1403
+ const notesEl = pageBox.querySelector(`.${this.cssPrefix}footnotes`);
1404
+ if (notesEl) {
1405
+ const notesHeightPt = pxToPt(notesEl.getBoundingClientRect().height);
1406
+ if (notesHeightPt > 0) {
1407
+ contentArea.style.height = `${Math.max(0, contentAreaHeight - notesHeightPt)}pt`;
1408
+ }
1409
+ }
1275
1410
  return {
1276
1411
  pageNumber,
1277
1412
  sectionIndex,
@@ -1305,6 +1440,14 @@ var DocxodusEditor = (() => {
1305
1440
  }
1306
1441
 
1307
1442
  // src/editor-headerfooter.ts
1443
+ var PAGE_FORMAT_LABELS = [
1444
+ { value: "", label: "Format\u2026" },
1445
+ { value: "decimal", label: "1, 2, 3" },
1446
+ { value: "lowerLetter", label: "a, b, c" },
1447
+ { value: "upperLetter", label: "A, B, C" },
1448
+ { value: "lowerRoman", label: "i, ii, iii" },
1449
+ { value: "upperRoman", label: "I, II, III" }
1450
+ ];
1308
1451
  var KIND_LABELS = [
1309
1452
  { value: "default", label: "Default" },
1310
1453
  { value: "first", label: "First page" },
@@ -1378,13 +1521,63 @@ var DocxodusEditor = (() => {
1378
1521
  kindOf(which) {
1379
1522
  return this.kinds[which];
1380
1523
  }
1381
- /** Append a PAGE / NUMPAGES field to the story paragraph addressed by `anchorId`. */
1524
+ /** Append a PAGE / NUMPAGES field to the story paragraph addressed by `anchorId`.
1525
+ * Deliberately a PLAIN field (no `\*` switch), exactly as Word inserts one, so it follows the
1526
+ * section's page-number format — see {@link setPageNumbering}. Stamping the section's current
1527
+ * format as a switch here would silently WIN over any later format change. */
1382
1528
  insertPageNumber(which, anchorId, field) {
1383
- const res = parseResult(this.bridge.InsertPageNumberField(this.handle, anchorId, field));
1529
+ const res = parseResult(this.bridge.InsertPageNumberField(this.handle, anchorId, field, ""));
1384
1530
  if (!res.success) return false;
1385
1531
  this.refresh(which);
1386
1532
  return true;
1387
1533
  }
1534
+ /**
1535
+ * Set this section's page numbering (`w:pgNumType`) — Word's *Format Page Numbers…*. Omitted
1536
+ * fields are left alone, so the format and the start are independently settable. Both bands then
1537
+ * repaint, because the values belong to the section rather than to either story.
1538
+ *
1539
+ * The rendered page numbers in the editor do not change: a page-number field's cached result is
1540
+ * what the browser shows, and Word recomputes it on open. Paginated mode substitutes the real
1541
+ * per-page number, so it does reflect the format immediately.
1542
+ */
1543
+ setPageNumbering(op) {
1544
+ if (!this.bodyAnchorId) return false;
1545
+ const res = parseResult(
1546
+ this.bridge.SetPageNumbering(this.handle, this.bodyAnchorId, JSON.stringify(op))
1547
+ );
1548
+ if (!res.success) return false;
1549
+ this.reloadSection();
1550
+ return true;
1551
+ }
1552
+ /** This section's page numbering as the live document states it. Fields are absent, not
1553
+ * defaulted — "continues the previous section" is not the same claim as "starts at 1". */
1554
+ pageNumbering() {
1555
+ return {
1556
+ start: this.sectionInfo?.pageNumberStart,
1557
+ format: this.sectionInfo?.pageNumberFormat
1558
+ };
1559
+ }
1560
+ /** Remove this section's page-numbering start/format — it reverts to continuing the previous
1561
+ * section's numbering in Word's default `1, 2, 3`. */
1562
+ clearPageNumbering() {
1563
+ if (!this.bodyAnchorId) return false;
1564
+ const res = parseResult(this.bridge.ClearPageNumbering(this.handle, this.bodyAnchorId));
1565
+ if (!res.success) return false;
1566
+ this.reloadSection();
1567
+ return true;
1568
+ }
1569
+ /**
1570
+ * Re-read the section from the live document and repaint BOTH bands.
1571
+ *
1572
+ * `refreshAll` only repaints — it deliberately does not re-read, because its callers (remount,
1573
+ * undo/redo) already refreshed the section. A section-property write has no such caller, and
1574
+ * repainting from the stale snapshot would leave the chrome reporting the value the document had
1575
+ * before the edit.
1576
+ */
1577
+ reloadSection() {
1578
+ this.sectionInfo = this.bodyAnchorId ? this.readSectionInfo(this.bodyAnchorId) : null;
1579
+ this.refreshAll();
1580
+ }
1388
1581
  /** Insert a page number into a band's own target paragraph (focused, else last). Seeds the
1389
1582
  * story first if the band's selected kind has none, so the command is never a silent no-op. */
1390
1583
  insertPageNumberInBand(which, field) {
@@ -1509,6 +1702,41 @@ var DocxodusEditor = (() => {
1509
1702
  if (target) this.insertPageNumber(which, target, field);
1510
1703
  });
1511
1704
  chrome.appendChild(pageNum);
1705
+ const pageFmt = document.createElement("select");
1706
+ pageFmt.setAttribute("data-hf-pagefmt", "");
1707
+ pageFmt.title = "Page-number format for this section";
1708
+ for (const o of PAGE_FORMAT_LABELS) {
1709
+ const opt = document.createElement("option");
1710
+ opt.value = o.value;
1711
+ opt.textContent = o.label;
1712
+ pageFmt.appendChild(opt);
1713
+ }
1714
+ pageFmt.addEventListener("change", () => {
1715
+ if (pageFmt.value === "") return;
1716
+ this.setPageNumbering({ format: pageFmt.value });
1717
+ });
1718
+ chrome.appendChild(pageFmt);
1719
+ const pageStart = document.createElement("input");
1720
+ pageStart.setAttribute("data-hf-pagestart", "");
1721
+ pageStart.type = "number";
1722
+ pageStart.min = "0";
1723
+ pageStart.placeholder = "Start at";
1724
+ pageStart.title = "Restart this section's page numbering at this number";
1725
+ const commitStart = () => {
1726
+ const raw = pageStart.value.trim();
1727
+ if (raw === "") return;
1728
+ const start = Number(raw);
1729
+ if (!Number.isInteger(start) || start < 0) return;
1730
+ this.setPageNumbering({ start });
1731
+ };
1732
+ pageStart.addEventListener("blur", commitStart);
1733
+ pageStart.addEventListener("keydown", (e) => {
1734
+ if (e.key === "Enter") {
1735
+ e.preventDefault();
1736
+ commitStart();
1737
+ }
1738
+ });
1739
+ chrome.appendChild(pageStart);
1512
1740
  const inheritedNote = document.createElement("span");
1513
1741
  inheritedNote.className = "docx-hf-inherited";
1514
1742
  inheritedNote.setAttribute("data-hf-inherited-note", "");
@@ -1546,6 +1774,13 @@ var DocxodusEditor = (() => {
1546
1774
  const kind = this.kinds[which];
1547
1775
  const select = band.querySelector("[data-hf-kind]");
1548
1776
  if (select && select.value !== kind) select.value = kind;
1777
+ const pageFmt = band.querySelector("[data-hf-pagefmt]");
1778
+ if (pageFmt) pageFmt.value = this.sectionInfo?.pageNumberFormat ?? "";
1779
+ const pageStart = band.querySelector("[data-hf-pagestart]");
1780
+ if (pageStart && document.activeElement !== pageStart) {
1781
+ const start = this.sectionInfo?.pageNumberStart;
1782
+ pageStart.value = start === void 0 ? "" : String(start);
1783
+ }
1549
1784
  const inherited = this.refFor(which, kind)?.inherited === true;
1550
1785
  band.toggleAttribute("data-hf-inherited", inherited);
1551
1786
  const note = band.querySelector("[data-hf-inherited-note]");
@@ -1651,6 +1886,79 @@ var DocxodusEditor = (() => {
1651
1886
  }
1652
1887
  }
1653
1888
 
1889
+ // src/editor-reconcile.ts
1890
+ function tokenOf(unit) {
1891
+ return unidOf(unit.id) + (unit.sig ? "|" + unit.sig : "");
1892
+ }
1893
+ function unidOf(id) {
1894
+ return id.substring(id.lastIndexOf(":") + 1);
1895
+ }
1896
+ function diffUnits(oldUnids, newUnits) {
1897
+ const n = oldUnids.length;
1898
+ const m = newUnits.length;
1899
+ const newUnids = newUnits.map(tokenOf);
1900
+ const lcs = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
1901
+ for (let i2 = n - 1; i2 >= 0; i2--) {
1902
+ for (let j2 = m - 1; j2 >= 0; j2--) {
1903
+ lcs[i2][j2] = oldUnids[i2] === newUnids[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
1904
+ }
1905
+ }
1906
+ const keep = /* @__PURE__ */ new Map();
1907
+ const removed = [];
1908
+ const added = [];
1909
+ let i = 0;
1910
+ let j = 0;
1911
+ while (i < n && j < m) {
1912
+ if (oldUnids[i] === newUnids[j]) {
1913
+ keep.set(j, i);
1914
+ i++;
1915
+ j++;
1916
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
1917
+ removed.push(i++);
1918
+ } else {
1919
+ added.push(j++);
1920
+ }
1921
+ }
1922
+ while (i < n) removed.push(i++);
1923
+ while (j < m) added.push(j++);
1924
+ const keptBeforeOld = (oi) => {
1925
+ let c = 0;
1926
+ for (const v of keep.values()) if (v < oi) c++;
1927
+ return c;
1928
+ };
1929
+ const keptBeforeNew = (nj) => {
1930
+ let c = 0;
1931
+ for (const k of keep.keys()) if (k < nj) c++;
1932
+ return c;
1933
+ };
1934
+ const substituted = [];
1935
+ const usedRemoved = /* @__PURE__ */ new Set();
1936
+ for (const nj of added) {
1937
+ const target = keptBeforeNew(nj);
1938
+ for (const oi of removed) {
1939
+ if (usedRemoved.has(oi)) continue;
1940
+ if (keptBeforeOld(oi) === target) {
1941
+ substituted.push({ oldIndex: oi, newIndex: nj });
1942
+ usedRemoved.add(oi);
1943
+ break;
1944
+ }
1945
+ }
1946
+ }
1947
+ return { keep, removed, added, substituted };
1948
+ }
1949
+ function needsRemount(diff, newUnits, oldKinds, threshold = 40) {
1950
+ if (diff.added.length + diff.removed.length > threshold) return true;
1951
+ const subNew = new Set(diff.substituted.map((s) => s.newIndex));
1952
+ const subOld = new Set(diff.substituted.map((s) => s.oldIndex));
1953
+ for (const j of diff.added) {
1954
+ if (!subNew.has(j) && newUnits[j]?.kind === "li") return true;
1955
+ }
1956
+ for (const i of diff.removed) {
1957
+ if (!subOld.has(i) && oldKinds[i] === "li") return true;
1958
+ }
1959
+ return false;
1960
+ }
1961
+
1654
1962
  // src/editor.ts
1655
1963
  var EDITABLE_TAGS = /* @__PURE__ */ new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
1656
1964
  function fontWeightIsBold(w) {
@@ -1663,7 +1971,7 @@ var DocxodusEditor = (() => {
1663
1971
  }
1664
1972
  function collectInlineSegments(node, out) {
1665
1973
  node.childNodes.forEach((child) => {
1666
- if (child.nodeType === 1 && child.hasAttribute?.("data-list-marker")) return;
1974
+ if (isGeneratedChrome(child)) return;
1667
1975
  if (child.nodeType === 3) {
1668
1976
  const text = child.textContent ?? "";
1669
1977
  if (!text) return;
@@ -1717,10 +2025,14 @@ var DocxodusEditor = (() => {
1717
2025
  function isListBlock(block) {
1718
2026
  return !!block.querySelector(":scope > [data-list-marker]");
1719
2027
  }
2028
+ var GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"]';
2029
+ function isGeneratedChrome(node) {
2030
+ return node?.nodeType === 1 && !!node.matches?.(GENERATED_CHROME_SELECTOR);
2031
+ }
1720
2032
  function isInMarker(node) {
1721
2033
  let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
1722
2034
  while (el) {
1723
- if (el.hasAttribute && el.hasAttribute("data-list-marker")) return true;
2035
+ if (isGeneratedChrome(el)) return true;
1724
2036
  el = el.parentElement;
1725
2037
  }
1726
2038
  return false;
@@ -1952,8 +2264,11 @@ var DocxodusEditor = (() => {
1952
2264
  false,
1953
2265
  0,
1954
2266
  "annot-",
2267
+ // Footnotes/endnotes ON: they are document content, and the editor makes the rendered note
2268
+ // paragraphs editable. Must stay in step with DocxSessionOps.RenderHtml (the remount path),
2269
+ // whose output has to match this first paint byte-for-byte.
1955
2270
  /* renderFootnotesAndEndnotes */
1956
- false,
2271
+ true,
1957
2272
  /* renderHeadersAndFooters */
1958
2273
  paginated,
1959
2274
  false,
@@ -1989,6 +2304,9 @@ var DocxodusEditor = (() => {
1989
2304
  this.lastSelection = null;
1990
2305
  /** The docked header/footer bands, when `options.headerFooter` is on. */
1991
2306
  this.region = null;
2307
+ /** Why the last reconcile() fell back to a full remount (null = it patched). For
2308
+ * diagnostics/specs; not part of the public API. */
2309
+ this.lastReconcileFallback = null;
1992
2310
  /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
1993
2311
  this.onSelectionChange = () => {
1994
2312
  if (this.closed) return;
@@ -2038,15 +2356,19 @@ var DocxodusEditor = (() => {
2038
2356
  /**
2039
2357
  * Repaint after an edit to `block` that would otherwise remount the whole document: a band
2040
2358
  * repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
2041
- * user's place in it — untouched.
2359
+ * user's place in it — untouched; a body edit reconciles incrementally. `forceRemount` is
2360
+ * for ops whose repaint provably needs whole-document context the reconciler cannot see:
2361
+ * list membership/level changes (sibling numbering shifts without sibling XML changing)
2362
+ * and border-div regrouping (HR insert, clearBorders).
2042
2363
  */
2043
- refreshAfter(block, focusIndex, caretAtEnd = false) {
2364
+ refreshAfter(block, focusIndex, caretAtEnd = false, forceRemount = false) {
2044
2365
  const band = this.region?.bandOf(block);
2045
2366
  if (band) {
2046
2367
  this.region.refresh(this.region.whichOf(band));
2047
2368
  return;
2048
2369
  }
2049
- this.remount(focusIndex, caretAtEnd);
2370
+ if (forceRemount) this.remount(focusIndex, caretAtEnd);
2371
+ else this.reconcile(focusIndex, caretAtEnd);
2050
2372
  }
2051
2373
  /** Open a document, render it into `container`, and wire up editing. */
2052
2374
  static open(container, bytes, exports, options = {}) {
@@ -2059,7 +2381,7 @@ var DocxodusEditor = (() => {
2059
2381
  headerFooter: options.headerFooter ?? false,
2060
2382
  onEdit: options.onEdit
2061
2383
  };
2062
- const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
2384
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"emitMarkdownPatch":false}');
2063
2385
  const editor = new _DocxEditor(container, exports, handle, opts);
2064
2386
  editor.refreshAnchorMap();
2065
2387
  if (opts.headerFooter) editor.createRegion();
@@ -2123,7 +2445,9 @@ var DocxodusEditor = (() => {
2123
2445
  * edit into a header part.
2124
2446
  */
2125
2447
  refreshAnchorMap() {
2126
- const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
2448
+ const bridge = this.exports.DocxSessionBridge;
2449
+ const raw = typeof bridge.ListAnchors === "function" ? bridge.ListAnchors(this.handle) : bridge.Project(this.handle);
2450
+ const proj = JSON.parse(raw);
2127
2451
  this.unidToFullId.clear();
2128
2452
  const bodyOwned = /* @__PURE__ */ new Set();
2129
2453
  for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
@@ -2188,6 +2512,7 @@ var DocxodusEditor = (() => {
2188
2512
  this.container.innerHTML = styles + parsed.body.innerHTML;
2189
2513
  this.editRoot = this.container;
2190
2514
  if (this.options.editable) this.wireBlocks(this.container);
2515
+ this.stampPlanState();
2191
2516
  return;
2192
2517
  }
2193
2518
  this.container.innerHTML = styles;
@@ -2197,6 +2522,7 @@ var DocxodusEditor = (() => {
2197
2522
  this.container.appendChild(flow);
2198
2523
  this.editRoot = flow;
2199
2524
  if (this.options.editable) this.wireBlocks(flow);
2525
+ this.stampPlanState();
2200
2526
  this.dockBands(flow);
2201
2527
  }
2202
2528
  /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
@@ -2227,7 +2553,7 @@ var DocxodusEditor = (() => {
2227
2553
  const unid = el.getAttribute("data-anchor");
2228
2554
  if (!unid || !this.anchorIdOf(el)) return;
2229
2555
  el.setAttribute("contenteditable", "true");
2230
- el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
2556
+ el.querySelectorAll(GENERATED_CHROME_SELECTOR).forEach((m) => m.setAttribute("contenteditable", "false"));
2231
2557
  el.dataset.committedText = blockContentText(el);
2232
2558
  el.addEventListener("focus", () => {
2233
2559
  this.activeBlock = el;
@@ -2303,6 +2629,7 @@ var DocxodusEditor = (() => {
2303
2629
  }
2304
2630
  this.wireBlock(fresh);
2305
2631
  if (this.activeBlock === el) this.activeBlock = fresh;
2632
+ this.maybeRenumberNotes(fresh);
2306
2633
  }
2307
2634
  }
2308
2635
  this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
@@ -2797,7 +3124,13 @@ var DocxodusEditor = (() => {
2797
3124
  )
2798
3125
  );
2799
3126
  if (!res.success) return;
2800
- this.refreshAfter(block, idx, false);
3127
+ this.refreshAfter(
3128
+ block,
3129
+ idx,
3130
+ false,
3131
+ /* forceRemount */
3132
+ true
3133
+ );
2801
3134
  }
2802
3135
  /**
2803
3136
  * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
@@ -2887,7 +3220,13 @@ var DocxodusEditor = (() => {
2887
3220
  fullId = this.syncBlock(block, fullId);
2888
3221
  const res = this.parseEdit(this.exports.DocxSessionBridge.SetListLevel(this.handle, fullId, delta));
2889
3222
  if (!res.success) return;
2890
- this.refreshAfter(block, idx, false);
3223
+ this.refreshAfter(
3224
+ block,
3225
+ idx,
3226
+ false,
3227
+ /* forceRemount */
3228
+ true
3229
+ );
2891
3230
  }
2892
3231
  /** Toggle (or set) page-break-before on the active block. */
2893
3232
  pageBreakBefore(value = true) {
@@ -2916,7 +3255,13 @@ var DocxodusEditor = (() => {
2916
3255
  this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, isThisKind ? "none" : kind)
2917
3256
  );
2918
3257
  if (!res.success) return;
2919
- this.refreshAfter(block, idx, false);
3258
+ this.refreshAfter(
3259
+ block,
3260
+ idx,
3261
+ false,
3262
+ /* forceRemount */
3263
+ true
3264
+ );
2920
3265
  }
2921
3266
  /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
2922
3267
  * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
@@ -2945,6 +3290,40 @@ var DocxodusEditor = (() => {
2945
3290
  if (!res.success) return;
2946
3291
  this.refreshAfter(block, Math.max(0, idx - 1), true);
2947
3292
  }
3293
+ /**
3294
+ * Cite a new footnote from the caret position in the active body block. The note definition is
3295
+ * created (writing the whole Word scaffold — part, reserved separator notes, settings
3296
+ * declaration, styles — on a document that has none yet) and its body renders as ordinary
3297
+ * editable `data-anchor` blocks in the notes section, so editing it afterwards needs no new op.
3298
+ *
3299
+ * Body blocks only: Word disallows a note reference inside a header/footer story or inside
3300
+ * another note, and the session rejects those with `AnchorWrongKind`. Remounts, because a new
3301
+ * note renumbers the citations after it and can add a whole part.
3302
+ */
3303
+ insertFootnote(markdown = "New footnote.") {
3304
+ this.insertNote("footnote", markdown);
3305
+ }
3306
+ /** Cite a new endnote from the caret — see {@link insertFootnote}; writes the endnotes part. */
3307
+ insertEndnote(markdown = "New endnote.") {
3308
+ this.insertNote("endnote", markdown);
3309
+ }
3310
+ insertNote(kind, markdown) {
3311
+ const block = this.activeBlock;
3312
+ if (this.closed || !block) return;
3313
+ if (this.isBandBlock(block) || block.closest(".footnotes, .endnotes")) return;
3314
+ let fullId = this.anchorIdOf(block);
3315
+ if (!fullId) return;
3316
+ const idx = this.blockIndex(block);
3317
+ const raw = caretOffsetIn(block);
3318
+ fullId = this.syncBlock(block, fullId);
3319
+ const offset = trimmedSplitOffset(block, raw ?? (block.textContent ?? "").length);
3320
+ const bridge = this.exports.DocxSessionBridge;
3321
+ const call = kind === "footnote" ? bridge.InsertFootnote : bridge.InsertEndnote;
3322
+ if (!call) return;
3323
+ const res = this.parseEdit(call.call(bridge, this.handle, fullId, offset, markdown));
3324
+ if (!res.success) return;
3325
+ this.reconcile(idx, false);
3326
+ }
2948
3327
  applyParagraphFormat(op) {
2949
3328
  const block = this.activeBlock;
2950
3329
  if (this.closed || !block) return;
@@ -2967,7 +3346,7 @@ var DocxodusEditor = (() => {
2967
3346
  );
2968
3347
  if (!res.success) return;
2969
3348
  if (this.affectsList(res) || op.clearBorders) {
2970
- this.refreshAfter(block, idx, false);
3349
+ this.refreshAfter(block, idx, false, true);
2971
3350
  return;
2972
3351
  }
2973
3352
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
@@ -2992,20 +3371,20 @@ var DocxodusEditor = (() => {
2992
3371
  const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, fullId, styleId));
2993
3372
  if (!res.success) return;
2994
3373
  if (this.affectsList(res)) {
2995
- this.refreshAfter(block, idx, false);
3374
+ this.refreshAfter(block, idx, false, true);
2996
3375
  return;
2997
3376
  }
2998
3377
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
2999
3378
  }
3000
- /** Undo the last edit (re-renders the document). */
3379
+ /** Undo the last edit (incremental repaint; falls back to a full re-render). */
3001
3380
  undo() {
3002
3381
  if (this.closed) return;
3003
- if (this.exports.DocxSessionBridge.Undo(this.handle)) this.remount();
3382
+ if (this.exports.DocxSessionBridge.Undo(this.handle)) this.reconcile();
3004
3383
  }
3005
- /** Redo the last undone edit (re-renders the document). */
3384
+ /** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
3006
3385
  redo() {
3007
3386
  if (this.closed) return;
3008
- if (this.exports.DocxSessionBridge.Redo(this.handle)) this.remount();
3387
+ if (this.exports.DocxSessionBridge.Redo(this.handle)) this.reconcile();
3009
3388
  }
3010
3389
  // ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
3011
3390
  /**
@@ -3038,6 +3417,31 @@ var DocxodusEditor = (() => {
3038
3417
  }
3039
3418
  this.region.insertPageNumberInBand("footer", field);
3040
3419
  }
3420
+ /**
3421
+ * Set the page numbering of the section the bands describe (`w:pgNumType`) — Word's *Format Page
3422
+ * Numbers…*: `start` restarts numbering at that number, `format` chooses `1, 2, 3` vs
3423
+ * `i, ii, iii` etc. Omitted fields are left unchanged. Requires the header/footer region
3424
+ * (`{ headerFooter: true }`); a no-op otherwise.
3425
+ *
3426
+ * Inserted page-number fields are plain, so they render through this. The editor's own view still
3427
+ * shows each field's cached result — Word recomputes on open — but `{ paginated: true }`
3428
+ * substitutes the real per-page number and so reflects the change immediately.
3429
+ */
3430
+ setPageNumbering(op) {
3431
+ this.assertOpen();
3432
+ this.region?.setPageNumbering(op);
3433
+ }
3434
+ /** Remove the section's page-numbering start/format: it reverts to continuing the previous
3435
+ * section's numbering in Word's default `1, 2, 3`. */
3436
+ clearPageNumbering() {
3437
+ this.assertOpen();
3438
+ this.region?.clearPageNumbering();
3439
+ }
3440
+ /** This section's page numbering as the document currently states it — `{}` when the section
3441
+ * sets neither (it continues the previous section in the default format). */
3442
+ pageNumbering() {
3443
+ return this.region?.pageNumbering() ?? {};
3444
+ }
3041
3445
  /** Which inline formats the current selection carries — for ribbon button highlighting. */
3042
3446
  queryFormatState() {
3043
3447
  const block = this.activeBlock ?? this.editRoot;
@@ -3066,6 +3470,7 @@ var DocxodusEditor = (() => {
3066
3470
  if (inBand) this.region.adoptBlock(fresh, anchorId);
3067
3471
  this.wireBlock(fresh);
3068
3472
  this.activeBlock = fresh;
3473
+ this.maybeRenumberNotes(fresh);
3069
3474
  this.options.onEdit?.({ anchorId, unid: newUnid });
3070
3475
  return fresh;
3071
3476
  }
@@ -3088,7 +3493,7 @@ var DocxodusEditor = (() => {
3088
3493
  );
3089
3494
  if (html.charCodeAt(0) !== 123) return html;
3090
3495
  }
3091
- const bytes = bridge.Save(this.handle);
3496
+ const bytes = typeof bridge.SaveWithAnchorIds === "function" ? bridge.SaveWithAnchorIds(this.handle) : bridge.Save(this.handle);
3092
3497
  return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(
3093
3498
  ...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale)
3094
3499
  );
@@ -3110,6 +3515,358 @@ var DocxodusEditor = (() => {
3110
3515
  affectsList(res) {
3111
3516
  return [...res.modified ?? [], ...res.created ?? []].some((r) => r.kind === "li");
3112
3517
  }
3518
+ // ─── Incremental structural reconcile ─────────────────────────────────
3519
+ //
3520
+ // After a structural op (insert table/row/col, footnote, delete block, undo/redo)
3521
+ // the DOM is patched from a unit-sequence diff against the session's render plan
3522
+ // instead of remounting the whole document (~3 s of full-document conversion on a
3523
+ // 350-block file). Full remount remains the universal FALLBACK: any ambiguity,
3524
+ // unsupported bridge, paginated mode, list-membership change, or thrown error
3525
+ // lands there — correctness never depends on the diff being right.
3526
+ /** True when the bridge carries the reconcile trio and the mode allows patching. */
3527
+ canReconcile() {
3528
+ const b = this.exports.DocxSessionBridge;
3529
+ return !this.options.paginated && typeof b.ListBlocks === "function" && typeof b.RenderBlocksHtml === "function" && typeof b.ListNotes === "function";
3530
+ }
3531
+ /** The body's top-level unit nodes in document order: `[data-anchor]` elements not
3532
+ * nested in another unit (cell paragraphs collapse into their table) and not in the
3533
+ * notes sections. */
3534
+ bodyUnitNodes() {
3535
+ const all = Array.from(this.editRoot.querySelectorAll("[data-anchor]"));
3536
+ return all.filter((el) => {
3537
+ if (el.closest("section.footnotes, section.endnotes")) return false;
3538
+ const ancestor = el.parentElement?.closest("[data-anchor]");
3539
+ return !(ancestor && this.editRoot.contains(ancestor));
3540
+ });
3541
+ }
3542
+ /** The DOM diff token for a body unit node (see editor-reconcile.tokenOf). */
3543
+ static domTokenOf(el) {
3544
+ const unid = el.getAttribute("data-anchor") ?? "";
3545
+ const sig = el.getAttribute("data-render-sig");
3546
+ return sig ? `${unid}|${sig}` : unid;
3547
+ }
3548
+ /** The kind a body unit node would have in the plan (only 'li'/'tbl' matter to the
3549
+ * remount guard). */
3550
+ static domKindOf(el) {
3551
+ if (el.tagName === "TABLE") return "tbl";
3552
+ return el.querySelector(":scope > [data-list-marker]") ? "li" : "p";
3553
+ }
3554
+ static listMarkerText(el) {
3555
+ const m = el?.querySelector(":scope > [data-list-marker]");
3556
+ return m ? m.textContent : null;
3557
+ }
3558
+ /**
3559
+ * Incrementally patch the DOM from the session's render plan; falls back to
3560
+ * {@link remount} whenever it cannot prove the patch correct. Same focus contract
3561
+ * as remount.
3562
+ */
3563
+ reconcile(focusIndex = -1, caretAtEnd = false) {
3564
+ if (!this.canReconcile()) {
3565
+ this.remount(focusIndex, caretAtEnd);
3566
+ return;
3567
+ }
3568
+ try {
3569
+ if (!this.reconcileCore()) {
3570
+ this.remount(focusIndex, caretAtEnd);
3571
+ return;
3572
+ }
3573
+ } catch (err) {
3574
+ this.lastReconcileFallback = `threw: ${err instanceof Error ? err.message : String(err)}`;
3575
+ this.remount(focusIndex, caretAtEnd);
3576
+ return;
3577
+ }
3578
+ if (focusIndex >= 0) {
3579
+ const blocks = this.editableList();
3580
+ const target = blocks[Math.min(focusIndex, blocks.length - 1)];
3581
+ if (target) {
3582
+ this.activeBlock = target;
3583
+ placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
3584
+ }
3585
+ }
3586
+ this.syncRegionToBody(this.activeBlock ?? void 0);
3587
+ }
3588
+ /** The patch itself. Returns false to request the remount fallback. */
3589
+ reconcileCore() {
3590
+ const bridge = this.exports.DocxSessionBridge;
3591
+ this.refreshAnchorMap();
3592
+ const plan = JSON.parse(bridge.ListBlocks(this.handle));
3593
+ if (plan.error) return this.bail(`plan error: ${plan.error}`);
3594
+ const oldNodes = this.bodyUnitNodes();
3595
+ const oldTokens = oldNodes.map(_DocxEditor.domTokenOf);
3596
+ const oldKinds = oldNodes.map(_DocxEditor.domKindOf);
3597
+ const bodyDiff = diffUnits(oldTokens, plan.body);
3598
+ if (needsRemount(bodyDiff, plan.body, oldKinds)) return this.bail("needsRemount (li change or churn)");
3599
+ const fnState = this.notesDiff("footnotes", plan.footnotes);
3600
+ const enState = this.notesDiff("endnotes", plan.endnotes);
3601
+ if (fnState === null || enState === null) return this.bail("notes container unstampable/missing");
3602
+ const addedBodyIds = bodyDiff.added.map((j) => plan.body[j].id);
3603
+ const addedNoteIds = fnState.diff.added.map((j) => plan.footnotes[j].id).concat(enState.diff.added.map((j) => plan.endnotes[j].id));
3604
+ const allIds = addedBodyIds.concat(addedNoteIds);
3605
+ let rendered = {};
3606
+ if (allIds.length > 0) {
3607
+ rendered = JSON.parse(
3608
+ bridge.RenderBlocksHtml(
3609
+ this.handle,
3610
+ JSON.stringify(allIds),
3611
+ this.options.cssPrefix,
3612
+ this.options.fabricateClasses
3613
+ )
3614
+ );
3615
+ if (rendered.error) return this.bail(`render error: ${rendered.error}`);
3616
+ for (const id of allIds) if (!rendered[id]) return this.bail(`unrenderable: ${id}`);
3617
+ }
3618
+ const parse = (html) => {
3619
+ const el = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
3620
+ el?.removeAttribute("xmlns");
3621
+ return el;
3622
+ };
3623
+ const freshBody = /* @__PURE__ */ new Map();
3624
+ for (const j of bodyDiff.added) {
3625
+ const el = parse(rendered[plan.body[j].id]);
3626
+ if (!el) return this.bail(`unparseable render: ${plan.body[j].id}`);
3627
+ freshBody.set(j, el);
3628
+ }
3629
+ for (const { oldIndex, newIndex } of bodyDiff.substituted) {
3630
+ const freshRoot = freshBody.get(newIndex);
3631
+ const oldMarker = _DocxEditor.listMarkerText(oldNodes[oldIndex]);
3632
+ const newMarker = _DocxEditor.listMarkerText(
3633
+ freshRoot ? _DocxEditor.anchorElOf(freshRoot) : null
3634
+ );
3635
+ if (oldMarker !== newMarker) return this.bail("substituted li marker drift");
3636
+ }
3637
+ if (!this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody)) return this.bail("applyBodyDiff bail");
3638
+ this.applyNotesDiff("footnotes", plan.footnotes, fnState, rendered);
3639
+ this.applyNotesDiff("endnotes", plan.endnotes, enState, rendered);
3640
+ const freshHasMarker = [...freshBody.values()].some(
3641
+ (el) => el.querySelector("a.footnote-ref, a.endnote-ref")
3642
+ );
3643
+ if (fnState.diff.added.length + fnState.diff.removed.length > 0 || freshHasMarker)
3644
+ this.renumberNoteChrome("footnote");
3645
+ if (enState.diff.added.length + enState.diff.removed.length > 0 || freshHasMarker)
3646
+ this.renumberNoteChrome("endnote");
3647
+ this.lastReconcileFallback = null;
3648
+ return true;
3649
+ }
3650
+ bail(reason) {
3651
+ this.lastReconcileFallback = reason;
3652
+ return false;
3653
+ }
3654
+ /** The generated single-child wrapper chain around a unit node (a table's alignment
3655
+ * <div>). Climbs while the parent is an anchor-less DIV whose ONLY element child is
3656
+ * the current node — never a section div (multi-child) or the edit root. */
3657
+ unitWrapperOf(el) {
3658
+ let n = el;
3659
+ while (n.parentElement && n.parentElement !== this.editRoot && n.parentElement.tagName === "DIV" && !n.parentElement.hasAttribute("data-anchor") && n.parentElement.childElementCount === 1) {
3660
+ n = n.parentElement;
3661
+ }
3662
+ return n;
3663
+ }
3664
+ /** The `[data-anchor]` element of a fresh render root (the root itself for a leaf
3665
+ * block, its descendant for a wrapper-shaped render like a table's align div). */
3666
+ static anchorElOf(root) {
3667
+ return root.hasAttribute("data-anchor") ? root : root.querySelector("[data-anchor]");
3668
+ }
3669
+ /** Insert/remove/swap body unit nodes per the diff. Returns false to bail (parent
3670
+ * ambiguity, order violation, wrapper semantics) — the session is already correct,
3671
+ * so bailing just means a full repaint. */
3672
+ applyBodyDiff(oldNodes, units, diff, fresh) {
3673
+ let lastOld = -1;
3674
+ for (let j = 0; j < units.length; j++) {
3675
+ const oi = diff.keep.get(j);
3676
+ if (oi === void 0) continue;
3677
+ if (oi < lastOld) return false;
3678
+ lastOld = oi;
3679
+ }
3680
+ const subOldByNew = new Map(diff.substituted.map((s) => [s.newIndex, s.oldIndex]));
3681
+ for (const [nj, oi] of subOldByNew) {
3682
+ const freshRoot = fresh.get(nj);
3683
+ const oldWrapper = this.unitWrapperOf(oldNodes[oi]);
3684
+ if (!freshRoot.hasAttribute("data-anchor")) {
3685
+ oldWrapper.replaceWith(freshRoot);
3686
+ } else if (oldWrapper === oldNodes[oi]) {
3687
+ oldNodes[oi].replaceWith(freshRoot);
3688
+ } else {
3689
+ return false;
3690
+ }
3691
+ this.wireUnit(freshRoot, units[nj]);
3692
+ }
3693
+ const pureAdded = diff.added.filter((j) => !subOldByNew.has(j));
3694
+ const pureRemoved = diff.removed.filter(
3695
+ (i) => !diff.substituted.some((s) => s.oldIndex === i)
3696
+ );
3697
+ const nodeAt = (j) => {
3698
+ const oi = diff.keep.get(j);
3699
+ if (oi !== void 0) return oldNodes[oi];
3700
+ if (subOldByNew.has(j)) return fresh.get(j);
3701
+ const f = fresh.get(j);
3702
+ return f && f.isConnected ? f : null;
3703
+ };
3704
+ for (const j of pureAdded) {
3705
+ const el = fresh.get(j);
3706
+ let prev = null;
3707
+ for (let k = j - 1; k >= 0 && !prev; k--) prev = nodeAt(k);
3708
+ let next = null;
3709
+ for (let k = j + 1; k < units.length && !next; k++) {
3710
+ const oi = diff.keep.get(k);
3711
+ if (oi !== void 0) next = oldNodes[oi];
3712
+ else if (subOldByNew.has(k)) next = fresh.get(k);
3713
+ }
3714
+ const prevW = prev ? this.unitWrapperOf(prev) : null;
3715
+ const nextW = next ? this.unitWrapperOf(next) : null;
3716
+ if (prevW && nextW && prevW.parentElement !== nextW.parentElement) return false;
3717
+ if (prevW) prevW.after(el);
3718
+ else if (nextW) nextW.before(el);
3719
+ else return false;
3720
+ this.wireUnit(el, units[j]);
3721
+ }
3722
+ for (const i of pureRemoved) {
3723
+ const wrapper = this.unitWrapperOf(oldNodes[i]);
3724
+ wrapper.remove();
3725
+ }
3726
+ return true;
3727
+ }
3728
+ /** Wire a freshly rendered unit root (and its nested blocks) and stamp the unit's
3729
+ * content signature on its `[data-anchor]` element — the element the next
3730
+ * reconcile's DOM walk reads tokens from. */
3731
+ wireUnit(root, unit) {
3732
+ const anchorEl = _DocxEditor.anchorElOf(root);
3733
+ if (anchorEl && unit.sig) anchorEl.setAttribute("data-render-sig", unit.sig);
3734
+ if (anchorEl) this.wireBlock(anchorEl);
3735
+ root.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
3736
+ }
3737
+ /** Old-sequence diff state for one notes section. `null` requests remount (DOM not
3738
+ * stampable/consistent). */
3739
+ notesDiff(sectionClass, units) {
3740
+ const ol = this.editRoot.querySelector(`section.${sectionClass} > ol`);
3741
+ const lis = ol ? Array.from(ol.children).filter((c) => c.tagName === "LI") : [];
3742
+ if (units.length === 0 && lis.length === 0) return { lis, diff: diffUnits([], []) };
3743
+ if (!ol) return null;
3744
+ const tokens = [];
3745
+ for (const li of lis) {
3746
+ const unid = li.getAttribute("data-note-anchor");
3747
+ if (!unid) return null;
3748
+ const sig = li.getAttribute("data-render-sig");
3749
+ tokens.push(sig ? `${unid}|${sig}` : unid);
3750
+ }
3751
+ return { lis, diff: diffUnits(tokens, units) };
3752
+ }
3753
+ /** Apply a notes-section diff: rebuild the `<ol>`'s li list, preserving kept nodes. */
3754
+ applyNotesDiff(sectionClass, units, state, rendered) {
3755
+ if (state.diff.added.length === 0 && state.diff.removed.length === 0) return;
3756
+ if (units.length === 0) {
3757
+ this.editRoot.querySelector(`section.${sectionClass}`)?.remove();
3758
+ return;
3759
+ }
3760
+ const ol = this.editRoot.querySelector(`section.${sectionClass} > ol`);
3761
+ const prefix = sectionClass === "footnotes" ? "fn" : "en";
3762
+ const nodes = [];
3763
+ for (let j = 0; j < units.length; j++) {
3764
+ const oi = state.diff.keep.get(j);
3765
+ if (oi !== void 0) {
3766
+ nodes.push(state.lis[oi]);
3767
+ continue;
3768
+ }
3769
+ nodes.push(this.buildNoteLi(prefix, units[j], rendered[units[j].id]));
3770
+ }
3771
+ ol.replaceChildren(...nodes);
3772
+ }
3773
+ /** Build a notes-section `<li>` for a freshly rendered note — replicating the
3774
+ * converter's chrome (id/value are re-stamped by the renumber pass; the backref
3775
+ * goes inside the last paragraph, matching RenderFootnoteItem). */
3776
+ buildNoteLi(prefix, unit, html) {
3777
+ const li = document.createElement("li");
3778
+ li.setAttribute("data-note-anchor", unidOf(unit.id));
3779
+ if (unit.sig) li.setAttribute("data-render-sig", unit.sig);
3780
+ li.innerHTML = html;
3781
+ const paras = li.querySelectorAll(":scope > p");
3782
+ const last = paras[paras.length - 1];
3783
+ if (last) {
3784
+ const backref = document.createElement("a");
3785
+ backref.setAttribute("class", `${prefix}-backref`);
3786
+ backref.setAttribute("contenteditable", "false");
3787
+ backref.textContent = "\u21A9";
3788
+ last.append(" ", backref);
3789
+ }
3790
+ li.querySelectorAll("[data-anchor]").forEach((b) => this.wireBlock(b));
3791
+ return li;
3792
+ }
3793
+ /**
3794
+ * Rewrite position-derived note chrome from the session's citation-ordered note
3795
+ * list: the k-th marker in document order IS note k (ids ascend in reference
3796
+ * order), so marker sup text, hrefs/ids, li ids/values and backref hrefs are all
3797
+ * re-derived positionally. Pure attribute/text patching of generated chrome.
3798
+ */
3799
+ renumberNoteChrome(kind) {
3800
+ const bridge = this.exports.DocxSessionBridge;
3801
+ if (typeof bridge.ListNotes !== "function") return;
3802
+ const prefix = kind === "footnote" ? "fn" : "en";
3803
+ let notes;
3804
+ try {
3805
+ notes = JSON.parse(bridge.ListNotes(this.handle, kind === "endnote"));
3806
+ } catch {
3807
+ return;
3808
+ }
3809
+ if (!Array.isArray(notes)) return;
3810
+ const markers = Array.from(
3811
+ this.editRoot.querySelectorAll(`a.${kind}-ref`)
3812
+ ).filter((a) => !a.closest("section.footnotes, section.endnotes"));
3813
+ markers.forEach((a, k) => {
3814
+ const n = notes[k];
3815
+ if (!n) return;
3816
+ a.setAttribute("href", `#${prefix}-${n.id}`);
3817
+ a.id = `${prefix}-ref-${n.id}`;
3818
+ if (kind === "footnote") a.setAttribute("data-footnote-id", n.id);
3819
+ const sup = a.querySelector("sup");
3820
+ if (sup) sup.textContent = String(n.ordinal);
3821
+ });
3822
+ const byUnid = new Map(notes.map((n) => [unidOf(n.defAnchorId), n]));
3823
+ const lis = this.editRoot.querySelectorAll(`section.${kind}s > ol > li`);
3824
+ lis.forEach((li) => {
3825
+ const unid = li.getAttribute("data-note-anchor");
3826
+ const n = unid ? byUnid.get(unid) : void 0;
3827
+ if (!n) return;
3828
+ li.id = `${prefix}-${n.id}`;
3829
+ li.setAttribute("value", String(n.ordinal));
3830
+ li.querySelectorAll(`a.${prefix}-backref`).forEach(
3831
+ (b) => b.setAttribute("href", `#${prefix}-ref-${n.id}`)
3832
+ );
3833
+ });
3834
+ }
3835
+ /** After an incremental block swap, stale marker chrome in the swapped node (the
3836
+ * throwaway render numbers citations from 1) is repaired in place. */
3837
+ maybeRenumberNotes(fresh) {
3838
+ if (fresh.querySelector("a.footnote-ref")) this.renumberNoteChrome("footnote");
3839
+ if (fresh.querySelector("a.endnote-ref")) this.renumberNoteChrome("endnote");
3840
+ }
3841
+ /** Stamp the DOM state the reconciler diffs against: container signatures on body
3842
+ * tables and `data-note-anchor` + signature on notes-section items. Called after
3843
+ * every full mount; reconcile stamps its own insertions. */
3844
+ stampPlanState() {
3845
+ const bridge = this.exports.DocxSessionBridge;
3846
+ if (typeof bridge.ListBlocks !== "function") return;
3847
+ try {
3848
+ const plan = JSON.parse(bridge.ListBlocks(this.handle));
3849
+ if (plan.error) return;
3850
+ const nodes = this.bodyUnitNodes();
3851
+ if (nodes.length === plan.body.length) {
3852
+ nodes.forEach((el, k) => {
3853
+ if (plan.body[k].sig && el.getAttribute("data-anchor") === unidOf(plan.body[k].id))
3854
+ el.setAttribute("data-render-sig", plan.body[k].sig);
3855
+ });
3856
+ }
3857
+ const stampNotes = (sectionClass, units) => {
3858
+ const lis = this.editRoot.querySelectorAll(`section.${sectionClass} > ol > li`);
3859
+ if (lis.length !== units.length) return;
3860
+ lis.forEach((li, k) => {
3861
+ li.setAttribute("data-note-anchor", unidOf(units[k].id));
3862
+ if (units[k].sig) li.setAttribute("data-render-sig", units[k].sig);
3863
+ });
3864
+ };
3865
+ stampNotes("footnotes", plan.footnotes);
3866
+ stampNotes("endnotes", plan.endnotes);
3867
+ } catch {
3868
+ }
3869
+ }
3113
3870
  /**
3114
3871
  * Full re-render from current session state (after undo/redo, and after list edits where
3115
3872
  * single-block rendering can't compute numbering). Optionally focus the editable block at