docxodus 7.1.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +65 -24
  2. package/dist/docxodus.worker.js +2 -2
  3. package/dist/docxodus.worker.js.map +1 -1
  4. package/dist/editor-headerfooter.d.ts +166 -0
  5. package/dist/editor-headerfooter.d.ts.map +1 -0
  6. package/dist/editor-headerfooter.js +530 -0
  7. package/dist/editor-headerfooter.js.map +1 -0
  8. package/dist/editor-reconcile.d.ts +75 -0
  9. package/dist/editor-reconcile.d.ts.map +1 -0
  10. package/dist/editor-reconcile.js +125 -0
  11. package/dist/editor-reconcile.js.map +1 -0
  12. package/dist/editor.bundle.js +1861 -110
  13. package/dist/editor.d.ts +200 -6
  14. package/dist/editor.d.ts.map +1 -1
  15. package/dist/editor.js +868 -86
  16. package/dist/editor.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/page-number-format.d.ts +22 -0
  22. package/dist/page-number-format.d.ts.map +1 -0
  23. package/dist/page-number-format.js +60 -0
  24. package/dist/page-number-format.js.map +1 -0
  25. package/dist/pagination.bundle.js +635 -37
  26. package/dist/pagination.d.ts +119 -0
  27. package/dist/pagination.d.ts.map +1 -1
  28. package/dist/pagination.js +706 -52
  29. package/dist/pagination.js.map +1 -1
  30. package/dist/react.d.ts +3 -1
  31. package/dist/react.d.ts.map +1 -1
  32. package/dist/react.js +6 -4
  33. package/dist/react.js.map +1 -1
  34. package/dist/session.bundle.js +241 -4
  35. package/dist/session.d.ts +160 -6
  36. package/dist/session.d.ts.map +1 -1
  37. package/dist/session.js +202 -4
  38. package/dist/session.js.map +1 -1
  39. package/dist/types.d.ts +238 -14
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +9 -11
  42. package/dist/types.js.map +1 -1
  43. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  44. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  45. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  46. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  47. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  48. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  49. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  50. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  51. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  52. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  53. package/dist/wasm/_framework/System.Console.wasm +0 -0
  54. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  55. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  56. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  57. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  58. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  59. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  60. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  61. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  62. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  63. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  64. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  65. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  66. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  67. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  68. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  69. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  70. package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
  71. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  72. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  73. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  74. package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
  75. package/dist/wasm/_framework/System.Threading.wasm +0 -0
  76. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  77. package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
  78. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  79. package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
  80. package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
  81. package/dist/wasm/_framework/System.wasm +0 -0
  82. package/dist/wasm/_framework/dotnet.boot.js +41 -41
  83. package/dist/wasm/_framework/dotnet.js +1 -1
  84. package/dist/wasm/_framework/dotnet.js.map +1 -1
  85. package/dist/wasm/_framework/dotnet.native.js +3 -3
  86. package/dist/wasm/_framework/dotnet.native.js.symbols +2102 -2101
  87. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  88. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  89. package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
  90. package/dist/wasm/index.html +2 -1
  91. package/package.json +17 -4
@@ -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) {
@@ -84,6 +120,7 @@ var DocxodusEditor = (() => {
84
120
  this.cssPrefix = options.cssPrefix ?? "page-";
85
121
  this.showPageNumbers = options.showPageNumbers ?? true;
86
122
  this.pageGap = options.pageGap ?? 20;
123
+ this.fragmentParagraphs = options.fragmentParagraphs ?? false;
87
124
  this.hfRegistry = /* @__PURE__ */ new Map();
88
125
  this.footnoteRegistry = /* @__PURE__ */ new Map();
89
126
  }
@@ -100,6 +137,7 @@ var DocxodusEditor = (() => {
100
137
  const sections = this.stagingElement.querySelectorAll(
101
138
  "[data-section-index]"
102
139
  );
140
+ this.pageNumbering = this.parsePageNumbering(sections);
103
141
  const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
104
142
  for (const section of sectionsToProcess) {
105
143
  const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
@@ -115,8 +153,62 @@ var DocxodusEditor = (() => {
115
153
  pageNumber += sectionPages.length;
116
154
  }
117
155
  this.stagingElement.style.display = "none";
156
+ this.substitutePageNumberFields(pages.length);
118
157
  return { totalPages: pages.length, pages };
119
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
+ }
120
212
  /**
121
213
  * Measures all content blocks in a section.
122
214
  */
@@ -150,6 +242,393 @@ var DocxodusEditor = (() => {
150
242
  }
151
243
  return blocks;
152
244
  }
245
+ /**
246
+ * Measures one element in the same hidden staging context used for the source blocks.
247
+ * This is intentionally DOM-based: table row heights cannot be inferred from individual
248
+ * rows because wrapping and collapsed borders change the height of a fragment.
249
+ */
250
+ measureElement(element, dims) {
251
+ const measurementHost = document.createElement("div");
252
+ measurementHost.style.position = "absolute";
253
+ measurementHost.style.visibility = "hidden";
254
+ measurementHost.style.left = "-9999px";
255
+ measurementHost.style.width = `${dims.contentWidth}pt`;
256
+ const measuredElement = element.cloneNode(true);
257
+ measurementHost.appendChild(measuredElement);
258
+ this.stagingElement.appendChild(measurementHost);
259
+ const rect = measuredElement.getBoundingClientRect();
260
+ const style = window.getComputedStyle(measuredElement);
261
+ const measured = {
262
+ element,
263
+ heightPt: pxToPt(rect.height),
264
+ marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
265
+ marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
266
+ keepWithNext: element.dataset.keepWithNext === "true",
267
+ keepLines: element.dataset.keepLines === "true",
268
+ pageBreakBefore: element.dataset.pageBreakBefore === "true",
269
+ isPageBreak: element.dataset.pageBreak === "true" || element.classList.contains(`${this.cssPrefix}break`)
270
+ };
271
+ this.stagingElement.removeChild(measurementHost);
272
+ return measured;
273
+ }
274
+ /**
275
+ * Returns the contiguous keep-with-next chain beginning at a block.
276
+ *
277
+ * A hard page break or a page-break-before directive is stronger than a
278
+ * keep-with-next directive, so it terminates the chain. The caller only
279
+ * keeps a chain together when the whole chain can fit on a fresh page.
280
+ */
281
+ getKeepWithNextChain(blocks, startIndex) {
282
+ const firstBlock = blocks[startIndex];
283
+ if (!firstBlock) return [];
284
+ const chain = [firstBlock];
285
+ let lastIndex = startIndex;
286
+ while (blocks[lastIndex].keepWithNext) {
287
+ const nextBlock = blocks[lastIndex + 1];
288
+ if (!nextBlock || nextBlock.isPageBreak || nextBlock.pageBreakBefore) {
289
+ break;
290
+ }
291
+ chain.push(nextBlock);
292
+ lastIndex++;
293
+ }
294
+ return chain;
295
+ }
296
+ /**
297
+ * Measures the visible body height of a keep-with-next chain using the same
298
+ * collapsed-margin rules as normal block placement. The trailing margin is
299
+ * intentionally excluded, matching the individual block fit check.
300
+ */
301
+ measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
302
+ const firstBlock = chain[0];
303
+ if (!firstBlock) return 0;
304
+ const firstMarginTop = isFirstOnPage ? firstBlock.marginTopPt : Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
305
+ let bodyHeight = firstMarginTop + firstBlock.heightPt;
306
+ for (let index = 1; index < chain.length; index++) {
307
+ const previousBlock = chain[index - 1];
308
+ const block = chain[index];
309
+ bodyHeight += Math.max(previousBlock.marginBottomPt, block.marginTopPt) + block.heightPt;
310
+ }
311
+ return bodyHeight;
312
+ }
313
+ /**
314
+ * Finds footnote references introduced by a sequence of blocks, preserving
315
+ * document order and excluding references already assigned to the page.
316
+ */
317
+ collectNewFootnoteIds(blocks, existingFootnoteIds) {
318
+ const knownIds = new Set(existingFootnoteIds);
319
+ const newIds = [];
320
+ for (const block of blocks) {
321
+ for (const id of this.extractFootnoteRefs(block.element)) {
322
+ if (!knownIds.has(id)) {
323
+ knownIds.add(id);
324
+ newIds.push(id);
325
+ }
326
+ }
327
+ }
328
+ return newIds;
329
+ }
330
+ /**
331
+ * The shortest body available to a section's first, default, or even page.
332
+ * A row fragment must fit every variant, otherwise a later header/footer could
333
+ * send it through the oversized-block fallback again.
334
+ */
335
+ smallestEffectiveContentHeight(dims, sectionIndex) {
336
+ return Math.min(
337
+ this.getEffectiveHeights(dims, sectionIndex, 1, 1).contentHeight,
338
+ this.getEffectiveHeights(dims, sectionIndex, 2, 1).contentHeight,
339
+ this.getEffectiveHeights(dims, sectionIndex, 2, 2).contentHeight
340
+ );
341
+ }
342
+ /**
343
+ * Builds a clone of a simple table wrapper containing a contiguous run of rows.
344
+ * Complex table features are deliberately rejected by the caller: a split across
345
+ * merged cells, nested tables, or footnotes cannot be made correct by cloning rows.
346
+ */
347
+ createSimpleTableFragment(wrapper, table, body, rows, retainAnchor) {
348
+ const wrapperClone = wrapper.cloneNode(false);
349
+ const tableClone = table.cloneNode(false);
350
+ for (const child of Array.from(table.children)) {
351
+ if (child !== body) {
352
+ tableClone.appendChild(child.cloneNode(true));
353
+ }
354
+ }
355
+ const bodyClone = body.cloneNode(false);
356
+ for (const row of rows) {
357
+ bodyClone.appendChild(row.cloneNode(true));
358
+ }
359
+ tableClone.appendChild(bodyClone);
360
+ if (!retainAnchor) {
361
+ wrapperClone.removeAttribute("data-anchor");
362
+ tableClone.removeAttribute("data-anchor");
363
+ }
364
+ wrapperClone.appendChild(tableClone);
365
+ return wrapperClone;
366
+ }
367
+ /**
368
+ * Splits an oversized, ordinary table at row boundaries. This only participates
369
+ * in the existing oversized-block fallback; unsupported tables keep the previous
370
+ * overflow behavior rather than risking broken table semantics.
371
+ */
372
+ trySplitSimpleOversizedTable(block, dims, sectionIndex) {
373
+ const wrapper = block.element;
374
+ if (wrapper.tagName !== "DIV" || wrapper.children.length !== 1 || block.keepWithNext || block.keepLines || block.pageBreakBefore || block.isPageBreak) {
375
+ return null;
376
+ }
377
+ const table = wrapper.firstElementChild;
378
+ if (!(table instanceof HTMLTableElement)) {
379
+ return null;
380
+ }
381
+ const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
382
+ if (!body || table.tHead || table.tFoot || body.rows.length < 2 || Array.from(table.children).some((child) => child !== body && child.tagName !== "COLGROUP") || table.querySelector("table, [rowspan], [colspan], [data-footnote-id]") || wrapper.querySelector("[data-footnote-id]")) {
383
+ return null;
384
+ }
385
+ const rows = Array.from(body.rows);
386
+ const minimumContentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
387
+ const maximumFragmentHeight = minimumContentHeight - block.marginTopPt - block.marginBottomPt;
388
+ if (maximumFragmentHeight <= 0) {
389
+ return null;
390
+ }
391
+ const groups = [];
392
+ let start = 0;
393
+ while (start < rows.length) {
394
+ let end = start;
395
+ while (end < rows.length) {
396
+ const candidate = this.createSimpleTableFragment(
397
+ wrapper,
398
+ table,
399
+ body,
400
+ rows.slice(start, end + 1),
401
+ start === 0
402
+ );
403
+ const measured = this.measureElement(candidate, dims);
404
+ if (measured.heightPt > maximumFragmentHeight) {
405
+ break;
406
+ }
407
+ end++;
408
+ }
409
+ if (end === start) {
410
+ return null;
411
+ }
412
+ groups.push(rows.slice(start, end));
413
+ start = end;
414
+ }
415
+ if (groups.length < 2) {
416
+ return null;
417
+ }
418
+ const fragments = [];
419
+ for (let index = 0; index < groups.length; index++) {
420
+ const isFirst = index === 0;
421
+ const isLast = index === groups.length - 1;
422
+ const fragment = this.createSimpleTableFragment(
423
+ wrapper,
424
+ table,
425
+ body,
426
+ groups[index],
427
+ isFirst
428
+ );
429
+ if (!isFirst) {
430
+ fragment.style.setProperty("margin-top", "0", "important");
431
+ }
432
+ if (!isLast) {
433
+ fragment.style.setProperty("margin-bottom", "0", "important");
434
+ }
435
+ const measured = this.measureElement(fragment, dims);
436
+ if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt > minimumContentHeight) {
437
+ return null;
438
+ }
439
+ fragments.push({
440
+ ...measured,
441
+ keepWithNext: false,
442
+ keepLines: false,
443
+ pageBreakBefore: false,
444
+ isPageBreak: false
445
+ });
446
+ }
447
+ return fragments;
448
+ }
449
+ /**
450
+ * A DOM endpoint that can finish a paragraph fragment. Endpoints are chosen
451
+ * after whitespace or at a run boundary so the paginator never deliberately
452
+ * cuts through an ordinary word merely to fill a little more of a page.
453
+ */
454
+ paragraphFragmentEndpoints(paragraph) {
455
+ const endpoints = [];
456
+ const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
457
+ let textNode;
458
+ while (textNode = walker.nextNode()) {
459
+ const text = textNode.data;
460
+ if (text.length === 0) continue;
461
+ const whitespace = /\s+/g;
462
+ let match;
463
+ while ((match = whitespace.exec(text)) !== null) {
464
+ endpoints.push({ node: textNode, offset: match.index + match[0].length });
465
+ }
466
+ if (endpoints.length === 0 || endpoints[endpoints.length - 1].node !== textNode || endpoints[endpoints.length - 1].offset !== text.length) {
467
+ endpoints.push({ node: textNode, offset: text.length });
468
+ }
469
+ }
470
+ return endpoints;
471
+ }
472
+ /**
473
+ * Whether a range contains visible text after ignoring bidi/zero-width marks.
474
+ * Paragraph fragmentation deliberately excludes non-textual descendants, so
475
+ * this is enough to reject empty head or tail fragments.
476
+ */
477
+ hasVisibleFragmentText(fragment) {
478
+ return (fragment.textContent || "").replace(/[\u200B-\u200F\uFEFF]/g, "").trim().length > 0;
479
+ }
480
+ /**
481
+ * Phase one only handles text-like paragraph descendants. Objects, explicit
482
+ * line breaks, list markers, notes, and out-of-flow/inline-block content all
483
+ * require their own line-layout rules and retain the established whole-block
484
+ * fallback instead of risking broken content or duplicate anchors.
485
+ */
486
+ canFragmentParagraph(block) {
487
+ if (!this.fragmentParagraphs || block.element.tagName !== "P") {
488
+ return false;
489
+ }
490
+ const paragraph = block.element;
491
+ if (block.keepWithNext || block.keepLines || block.pageBreakBefore || block.isPageBreak || paragraph.dataset.widowControl === "true" || paragraph.hasAttribute("contenteditable")) {
492
+ return false;
493
+ }
494
+ const unsupportedDescendants = [
495
+ "br",
496
+ "img",
497
+ "picture",
498
+ "svg",
499
+ "math",
500
+ "canvas",
501
+ "video",
502
+ "audio",
503
+ "iframe",
504
+ "object",
505
+ "embed",
506
+ "input",
507
+ "button",
508
+ "select",
509
+ "textarea",
510
+ "table",
511
+ "ol",
512
+ "ul",
513
+ "li",
514
+ "dl",
515
+ "div",
516
+ "p",
517
+ "section",
518
+ "article",
519
+ "aside",
520
+ "figure",
521
+ "fieldset",
522
+ "details",
523
+ "[data-footnote-id]",
524
+ "[data-list-marker]",
525
+ "[data-anchor]",
526
+ "[id]",
527
+ "[contenteditable]"
528
+ ].join(", ");
529
+ if (paragraph.querySelector(unsupportedDescendants)) {
530
+ return false;
531
+ }
532
+ const paragraphStyle = window.getComputedStyle(paragraph);
533
+ if (paragraphStyle.display !== "block" || paragraphStyle.position !== "static" || paragraphStyle.float !== "none" || paragraphStyle.whiteSpace !== "normal" || paragraphStyle.breakBefore !== "auto" || paragraphStyle.breakAfter !== "auto" || paragraphStyle.breakInside === "avoid" || paragraphStyle.pageBreakBefore !== "auto" || paragraphStyle.pageBreakAfter !== "auto" || paragraphStyle.pageBreakInside === "avoid") {
534
+ return false;
535
+ }
536
+ for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
537
+ const style = window.getComputedStyle(descendant);
538
+ if (style.display !== "inline" || style.position !== "static" || style.float !== "none" || style.whiteSpace !== "normal") {
539
+ return false;
540
+ }
541
+ }
542
+ return true;
543
+ }
544
+ /**
545
+ * Builds one range-cloned paragraph fragment. Only the leading fragment keeps
546
+ * the source paragraph's addressability; continuations must not duplicate an
547
+ * id/data-anchor in the rendered document.
548
+ */
549
+ createParagraphFragment(paragraph, range, retainSourceIdentity, isFinalFragment) {
550
+ const fragment = paragraph.cloneNode(false);
551
+ const contents = range.cloneContents();
552
+ fragment.appendChild(contents);
553
+ if (!retainSourceIdentity) {
554
+ fragment.removeAttribute("id");
555
+ fragment.removeAttribute("data-anchor");
556
+ fragment.style.setProperty("margin-top", "0", "important");
557
+ fragment.style.setProperty("text-indent", "0", "important");
558
+ }
559
+ if (!isFinalFragment) {
560
+ fragment.style.setProperty("margin-bottom", "0", "important");
561
+ }
562
+ return fragment;
563
+ }
564
+ /**
565
+ * Splits a simple paragraph at the largest DOM Range endpoint that fits the
566
+ * currently available body space. The caller then processes the tail normally,
567
+ * allowing it to fragment again on later pages when necessary.
568
+ */
569
+ tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
570
+ if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
571
+ return null;
572
+ }
573
+ const paragraph = block.element;
574
+ const endpoints = this.paragraphFragmentEndpoints(paragraph);
575
+ if (endpoints.length < 2) {
576
+ return null;
577
+ }
578
+ let low = 0;
579
+ let high = endpoints.length - 2;
580
+ let best = null;
581
+ while (low <= high) {
582
+ const middle = Math.floor((low + high) / 2);
583
+ const endpoint = endpoints[middle];
584
+ const headRange = document.createRange();
585
+ headRange.setStart(paragraph, 0);
586
+ headRange.setEnd(endpoint.node, endpoint.offset);
587
+ const headContents = headRange.cloneContents();
588
+ if (!this.hasVisibleFragmentText(headContents)) {
589
+ low = middle + 1;
590
+ continue;
591
+ }
592
+ const head = this.createParagraphFragment(paragraph, headRange, true, false);
593
+ const measured = this.measureElement(head, dims);
594
+ if (effectiveMarginTopPt + measured.heightPt <= availableHeightPt) {
595
+ best = { endpoint, element: head, measured };
596
+ low = middle + 1;
597
+ } else {
598
+ high = middle - 1;
599
+ }
600
+ }
601
+ if (!best) {
602
+ return null;
603
+ }
604
+ const tailRange = document.createRange();
605
+ tailRange.setStart(best.endpoint.node, best.endpoint.offset);
606
+ tailRange.setEnd(paragraph, paragraph.childNodes.length);
607
+ const tailContents = tailRange.cloneContents();
608
+ if (!this.hasVisibleFragmentText(tailContents)) {
609
+ return null;
610
+ }
611
+ const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
612
+ const tailMeasured = this.measureElement(tail, dims);
613
+ return [
614
+ {
615
+ ...best.measured,
616
+ element: best.element,
617
+ keepWithNext: false,
618
+ keepLines: false,
619
+ pageBreakBefore: false,
620
+ isPageBreak: false
621
+ },
622
+ {
623
+ ...tailMeasured,
624
+ element: tail,
625
+ keepWithNext: false,
626
+ keepLines: false,
627
+ pageBreakBefore: false,
628
+ isPageBreak: false
629
+ }
630
+ ];
631
+ }
153
632
  /**
154
633
  * Parses the header/footer registry from the staging element.
155
634
  * Also measures heights during parsing for lazy-loading compatibility.
@@ -253,6 +732,7 @@ var DocxodusEditor = (() => {
253
732
  measureContainer.style.visibility = "hidden";
254
733
  measureContainer.style.width = `${contentWidth}pt`;
255
734
  measureContainer.style.left = "-9999px";
735
+ measureContainer.className = this.cssPrefix + "footnotes";
256
736
  const hr = document.createElement("hr");
257
737
  measureContainer.appendChild(hr);
258
738
  if (hasContinuation) {
@@ -287,6 +767,7 @@ var DocxodusEditor = (() => {
287
767
  measureContainer.style.visibility = "hidden";
288
768
  measureContainer.style.width = `${contentWidth}pt`;
289
769
  measureContainer.style.left = "-9999px";
770
+ measureContainer.className = this.cssPrefix + "footnotes";
290
771
  const hr = document.createElement("hr");
291
772
  measureContainer.appendChild(hr);
292
773
  for (const el of continuation.remainingElements) {
@@ -305,11 +786,17 @@ var DocxodusEditor = (() => {
305
786
  splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
306
787
  const footnoteContent = footnoteElement.querySelector(".footnote-content");
307
788
  if (!footnoteContent) {
308
- return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
789
+ return {
790
+ fits: Array.from(footnoteElement.children).map((el) => el.cloneNode(true)),
791
+ overflow: []
792
+ };
309
793
  }
310
794
  const children = Array.from(footnoteContent.children);
311
795
  if (children.length <= 1) {
312
- return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
796
+ return {
797
+ fits: children.map((el) => el.cloneNode(true)),
798
+ overflow: []
799
+ };
313
800
  }
314
801
  const fits = [];
315
802
  const overflow = [];
@@ -319,6 +806,7 @@ var DocxodusEditor = (() => {
319
806
  hrMeasure.style.visibility = "hidden";
320
807
  hrMeasure.style.width = `${contentWidth}pt`;
321
808
  hrMeasure.style.left = "-9999px";
809
+ hrMeasure.className = this.cssPrefix + "footnotes";
322
810
  const hr = document.createElement("hr");
323
811
  hrMeasure.appendChild(hr);
324
812
  this.stagingElement.appendChild(hrMeasure);
@@ -333,6 +821,7 @@ var DocxodusEditor = (() => {
333
821
  measureContainer.style.visibility = "hidden";
334
822
  measureContainer.style.width = `${contentWidth}pt`;
335
823
  measureContainer.style.left = "-9999px";
824
+ measureContainer.className = this.cssPrefix + "footnotes";
336
825
  measureContainer.appendChild(child.cloneNode(true));
337
826
  this.stagingElement.appendChild(measureContainer);
338
827
  const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
@@ -360,6 +849,7 @@ var DocxodusEditor = (() => {
360
849
  measureContainer.style.visibility = "hidden";
361
850
  measureContainer.style.width = `${contentWidth}pt`;
362
851
  measureContainer.style.left = "-9999px";
852
+ measureContainer.className = this.cssPrefix + "footnotes";
363
853
  measureContainer.appendChild(footnote.cloneNode(true));
364
854
  this.stagingElement.appendChild(measureContainer);
365
855
  const rect = measureContainer.getBoundingClientRect();
@@ -543,12 +1033,14 @@ var DocxodusEditor = (() => {
543
1033
  let currentFootnoteHeight = 0;
544
1034
  let currentContinuation = this.pendingFootnoteContinuation;
545
1035
  let nextPageContinuation = null;
1036
+ let deferredFootnoteIds = [];
546
1037
  let currentPartialFootnotes = [];
547
1038
  if (currentContinuation && currentContinuation.remainingElements.length > 0) {
548
1039
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
549
1040
  }
550
1041
  const finishPage = () => {
551
- if (currentContent.length === 0 && !currentContinuation) return;
1042
+ const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
1043
+ if (currentContent.length === 0 && !hasCurrentContinuation) return;
552
1044
  const page = this.createPage(
553
1045
  dims,
554
1046
  pageNumber,
@@ -572,15 +1064,25 @@ var DocxodusEditor = (() => {
572
1064
  currentPartialFootnotes = [];
573
1065
  currentContinuation = nextPageContinuation;
574
1066
  nextPageContinuation = null;
1067
+ if (deferredFootnoteIds.length > 0) {
1068
+ currentFootnoteIds = [...deferredFootnoteIds];
1069
+ deferredFootnoteIds = [];
1070
+ }
575
1071
  if (currentContinuation && currentContinuation.remainingElements.length > 0) {
576
1072
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
577
1073
  } else {
578
1074
  currentFootnoteHeight = 0;
579
1075
  }
1076
+ if (currentFootnoteIds.length > 0) {
1077
+ currentFootnoteHeight += this.measureFootnotesHeight(
1078
+ currentFootnoteIds,
1079
+ dims.contentWidth,
1080
+ null
1081
+ );
1082
+ }
580
1083
  };
581
1084
  for (let i = 0; i < blocks.length; i++) {
582
1085
  const block = blocks[i];
583
- const nextBlock = blocks[i + 1];
584
1086
  if (block.isPageBreak) {
585
1087
  finishPage();
586
1088
  continue;
@@ -588,8 +1090,59 @@ var DocxodusEditor = (() => {
588
1090
  if (block.pageBreakBefore && currentContent.length > 0) {
589
1091
  finishPage();
590
1092
  }
591
- const blockFootnoteIds = this.extractFootnoteRefs(block.element);
592
- const newFootnoteIds = blockFootnoteIds.filter((id) => !currentFootnoteIds.includes(id));
1093
+ const previousBlock = blocks[i - 1];
1094
+ const startsKeepChain = !previousBlock || !previousBlock.keepWithNext || previousBlock.isPageBreak || block.pageBreakBefore;
1095
+ const pageHasOccupiedSpace = currentContent.length > 0 || currentFootnoteHeight > 0;
1096
+ if (pageHasOccupiedSpace && block.keepWithNext && startsKeepChain) {
1097
+ const keepChain = this.getKeepWithNextChain(blocks, i);
1098
+ if (keepChain.length > 1) {
1099
+ const newChainFootnoteIds = this.collectNewFootnoteIds(
1100
+ keepChain,
1101
+ currentFootnoteIds
1102
+ );
1103
+ let additionalChainFootnoteHeight = 0;
1104
+ if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
1105
+ const totalChainFootnoteHeight = this.measureFootnotesHeight(
1106
+ [...currentFootnoteIds, ...newChainFootnoteIds],
1107
+ dims.contentWidth,
1108
+ currentContinuation
1109
+ );
1110
+ additionalChainFootnoteHeight = Math.max(
1111
+ 0,
1112
+ totalChainFootnoteHeight - currentFootnoteHeight
1113
+ );
1114
+ }
1115
+ const currentChainHeight = this.measureKeepWithNextChainBodyHeight(
1116
+ keepChain,
1117
+ prevMarginBottomPt,
1118
+ currentContent.length === 0
1119
+ ) + additionalChainFootnoteHeight;
1120
+ const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
1121
+ if (currentChainHeight > currentAvailableHeight) {
1122
+ const nextPageHeights = this.getEffectiveHeights(
1123
+ dims,
1124
+ sectionIndex,
1125
+ pageInSection + 1,
1126
+ pageNumber + 1
1127
+ );
1128
+ const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(
1129
+ keepChain,
1130
+ 0,
1131
+ true
1132
+ );
1133
+ const freshChainFootnoteHeight = this.measureFootnotesHeight(
1134
+ newChainFootnoteIds,
1135
+ dims.contentWidth,
1136
+ nextPageContinuation
1137
+ );
1138
+ if (freshChainBodyHeight + freshChainFootnoteHeight <= nextPageHeights.contentHeight) {
1139
+ finishPage();
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
1145
+ const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
593
1146
  let additionalFootnoteHeight = 0;
594
1147
  if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
595
1148
  const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
@@ -606,15 +1159,23 @@ var DocxodusEditor = (() => {
606
1159
  effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt;
607
1160
  }
608
1161
  const blockSpace = effectiveMarginTop + block.heightPt + additionalFootnoteHeight;
609
- let neededHeight = blockSpace;
610
- if (block.keepWithNext && nextBlock && !nextBlock.isPageBreak) {
611
- const collapsedMargin = Math.max(block.marginBottomPt, nextBlock.marginTopPt);
612
- neededHeight = effectiveMarginTop + block.heightPt + collapsedMargin + nextBlock.heightPt + additionalFootnoteHeight;
613
- }
614
1162
  const effectiveRemainingHeight = remainingHeight - currentFootnoteHeight;
615
1163
  const bodyContentUsed = effectiveContentHeight - remainingHeight;
616
1164
  const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
617
1165
  const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
1166
+ if (blockSpace > effectiveRemainingHeight) {
1167
+ const paragraphFragments = this.tryFragmentParagraph(
1168
+ block,
1169
+ dims,
1170
+ effectiveRemainingHeight,
1171
+ effectiveMarginTop
1172
+ );
1173
+ if (paragraphFragments) {
1174
+ blocks.splice(i, 1, ...paragraphFragments);
1175
+ i--;
1176
+ continue;
1177
+ }
1178
+ }
618
1179
  if (blockSpace <= effectiveRemainingHeight) {
619
1180
  currentContent.push(block.element.cloneNode(true));
620
1181
  remainingHeight -= effectiveMarginTop + block.heightPt + block.marginBottomPt;
@@ -656,28 +1217,18 @@ var DocxodusEditor = (() => {
656
1217
  footnoteId,
657
1218
  fittingElements: fits
658
1219
  });
659
- nextPageContinuation = {
660
- footnoteId,
661
- remainingElements: overflow
662
- };
1220
+ if (overflow.length > 0) {
1221
+ nextPageContinuation = {
1222
+ footnoteId,
1223
+ remainingElements: overflow
1224
+ };
1225
+ }
663
1226
  currentFootnoteHeight = availableForFootnotes;
664
1227
  } else {
665
- nextPageContinuation = {
666
- footnoteId,
667
- remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *")).map((el) => el.cloneNode(true))
668
- };
669
- if (nextPageContinuation.remainingElements.length === 0) {
670
- nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
671
- }
1228
+ deferredFootnoteIds.push(footnoteId);
672
1229
  }
673
1230
  } else {
674
- nextPageContinuation = {
675
- footnoteId,
676
- remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *")).map((el) => el.cloneNode(true))
677
- };
678
- if (nextPageContinuation.remainingElements.length === 0) {
679
- nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
680
- }
1231
+ deferredFootnoteIds.push(footnoteId);
681
1232
  }
682
1233
  }
683
1234
  }
@@ -692,30 +1243,39 @@ var DocxodusEditor = (() => {
692
1243
  currentFootnoteHeight = expandedFootnoteSpace;
693
1244
  } else {
694
1245
  finishPage();
695
- const newPageFootnoteHeight = blockFootnoteIds.length > 0 ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
1246
+ const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
696
1247
  const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
697
1248
  currentContent.push(block.element.cloneNode(true));
698
1249
  remainingHeight = effectiveContentHeight - newPageSpace;
699
1250
  prevMarginBottomPt = block.marginBottomPt;
700
- currentFootnoteIds = [...blockFootnoteIds];
1251
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
701
1252
  currentFootnoteHeight = newPageFootnoteHeight;
702
1253
  }
703
1254
  } else {
704
1255
  finishPage();
705
- const newPageFootnoteHeight = blockFootnoteIds.length > 0 ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
1256
+ const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
706
1257
  const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
707
1258
  currentContent.push(block.element.cloneNode(true));
708
1259
  remainingHeight = effectiveContentHeight - newPageSpace;
709
1260
  prevMarginBottomPt = block.marginBottomPt;
710
- currentFootnoteIds = [...blockFootnoteIds];
1261
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
711
1262
  currentFootnoteHeight = newPageFootnoteHeight;
712
1263
  }
713
1264
  } else {
1265
+ const tableFragments = this.trySplitSimpleOversizedTable(block, dims, sectionIndex);
1266
+ if (tableFragments) {
1267
+ if (currentContent.length > 0 || currentContinuation) {
1268
+ finishPage();
1269
+ }
1270
+ blocks.splice(i, 1, ...tableFragments);
1271
+ i--;
1272
+ continue;
1273
+ }
714
1274
  if (currentContent.length > 0) {
715
1275
  finishPage();
716
1276
  }
717
1277
  currentContent.push(block.element.cloneNode(true));
718
- currentFootnoteIds = [...blockFootnoteIds];
1278
+ currentFootnoteIds = [...currentFootnoteIds, ...allBlockFootnoteIds];
719
1279
  finishPage();
720
1280
  }
721
1281
  }
@@ -723,6 +1283,27 @@ var DocxodusEditor = (() => {
723
1283
  this.pendingFootnoteContinuation = nextPageContinuation;
724
1284
  return pages;
725
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
+ }
726
1307
  /**
727
1308
  * Creates a page container element.
728
1309
  */
@@ -748,6 +1329,7 @@ var DocxodusEditor = (() => {
748
1329
  pageBox.style.contain = "layout paint";
749
1330
  pageBox.dataset.pageNumber = String(pageNumber);
750
1331
  pageBox.dataset.sectionIndex = String(sectionIndex);
1332
+ pageBox.dataset.pageInSection = String(pageInSection);
751
1333
  const effectiveHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
752
1334
  const headerSource = this.selectHeader(sectionIndex, pageInSection, pageNumber);
753
1335
  if (headerSource) {
@@ -765,7 +1347,9 @@ var DocxodusEditor = (() => {
765
1347
  headerDiv.style.justifyContent = "flex-end";
766
1348
  headerDiv.style.paddingBottom = "4pt";
767
1349
  for (const child of Array.from(headerSource.childNodes)) {
768
- headerDiv.appendChild(child.cloneNode(true));
1350
+ const clonedheaderDiv = child.cloneNode(true);
1351
+ if (clonedheaderDiv.nodeType === 1) this.makeClonedStoryInert(clonedheaderDiv);
1352
+ headerDiv.appendChild(clonedheaderDiv);
769
1353
  }
770
1354
  pageBox.appendChild(headerDiv);
771
1355
  }
@@ -803,7 +1387,9 @@ var DocxodusEditor = (() => {
803
1387
  footerDiv.style.justifyContent = "flex-start";
804
1388
  footerDiv.style.paddingTop = "4pt";
805
1389
  for (const child of Array.from(footerSource.childNodes)) {
806
- footerDiv.appendChild(child.cloneNode(true));
1390
+ const clonedfooterDiv = child.cloneNode(true);
1391
+ if (clonedfooterDiv.nodeType === 1) this.makeClonedStoryInert(clonedfooterDiv);
1392
+ footerDiv.appendChild(clonedfooterDiv);
807
1393
  }
808
1394
  pageBox.appendChild(footerDiv);
809
1395
  }
@@ -814,6 +1400,13 @@ var DocxodusEditor = (() => {
814
1400
  pageBox.appendChild(pageNum);
815
1401
  }
816
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
+ }
817
1410
  return {
818
1411
  pageNumber,
819
1412
  sectionIndex,
@@ -839,10 +1432,533 @@ var DocxodusEditor = (() => {
839
1432
  if (!pageContainer) {
840
1433
  throw new Error("Pagination container element not found");
841
1434
  }
842
- const engine = new PaginationEngine(staging, pageContainer, options);
1435
+ const engine = new PaginationEngine(staging, pageContainer, {
1436
+ ...options,
1437
+ fragmentParagraphs: options.fragmentParagraphs ?? true
1438
+ });
843
1439
  return engine.paginate();
844
1440
  }
845
1441
 
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
+ ];
1451
+ var KIND_LABELS = [
1452
+ { value: "default", label: "Default" },
1453
+ { value: "first", label: "First page" },
1454
+ { value: "even", label: "Even pages" }
1455
+ ];
1456
+ var EVEN_WARNING = "w:evenAndOddHeaders is document-global and governs footers too \u2014 even pages stop inheriting the Default stories, so a section with only a Default footer shows no footer at all on even pages.";
1457
+ var FIRST_WARNING = "w:titlePg makes page 1 use its own first-page header AND footer instead of the Default ones, so an empty first-page footer leaves page 1 with no footer.";
1458
+ var STORY_BLOCK_KINDS = /* @__PURE__ */ new Set(["p", "h", "li"]);
1459
+ var HeaderFooterRegion = class {
1460
+ constructor(bridge, handle, options, callbacks) {
1461
+ /** The body anchor whose section the bands currently describe (needed to seed a story). */
1462
+ this.bodyAnchorId = null;
1463
+ this.sectionInfo = null;
1464
+ /** Memoized body anchor → governing sectionUnid, so repeat focus costs no bridge call. */
1465
+ this.sectionOfAnchor = /* @__PURE__ */ new Map();
1466
+ this.kinds = {
1467
+ header: "default",
1468
+ footer: "default"
1469
+ };
1470
+ this.bridge = bridge;
1471
+ this.handle = handle;
1472
+ this.options = options;
1473
+ this.callbacks = callbacks;
1474
+ this.headerBand = this.buildBand("header");
1475
+ this.footerBand = this.buildBand("footer");
1476
+ }
1477
+ // ─── public surface ──────────────────────────────────────────────────
1478
+ /**
1479
+ * Point the bands at the section governing `bodyAnchorId` and repaint if it changed.
1480
+ * Cheap to call on every focus change: the anchor → section lookup is memoized, and an
1481
+ * unchanged `sectionUnid` returns without touching the DOM (so it never clobbers the
1482
+ * user's kind selection).
1483
+ */
1484
+ syncToBody(bodyAnchorId) {
1485
+ if (!bodyAnchorId) return;
1486
+ const cached = this.sectionOfAnchor.get(bodyAnchorId);
1487
+ if (cached !== void 0 && cached === this.sectionInfo?.sectionUnid) {
1488
+ this.bodyAnchorId = bodyAnchorId;
1489
+ return;
1490
+ }
1491
+ const info = this.readSectionInfo(bodyAnchorId);
1492
+ if (!info) return;
1493
+ this.sectionOfAnchor.set(bodyAnchorId, info.sectionUnid);
1494
+ const changed = info.sectionUnid !== this.sectionInfo?.sectionUnid;
1495
+ this.bodyAnchorId = bodyAnchorId;
1496
+ this.sectionInfo = info;
1497
+ if (changed) this.refreshAll();
1498
+ }
1499
+ /** Repaint one band from the live session. */
1500
+ refresh(which) {
1501
+ this.sectionInfo = this.bodyAnchorId ? this.readSectionInfo(this.bodyAnchorId) : null;
1502
+ this.renderBand(which);
1503
+ }
1504
+ /** Repaint both bands from the live session (after a remount, undo/redo, or section change). */
1505
+ refreshAll() {
1506
+ this.renderBand("header");
1507
+ this.renderBand("footer");
1508
+ }
1509
+ /** Select (and, if absent, create) the story kind a band edits, and make it render. */
1510
+ setKind(which, kind) {
1511
+ this.kinds[which] = kind;
1512
+ const select = this.bandFor(which).querySelector("[data-hf-kind]");
1513
+ if (select && select.value !== kind) select.value = kind;
1514
+ if (!this.partUriFor(which, kind)) this.seedStory(which, kind);
1515
+ if (this.bodyAnchorId && kind !== "default") {
1516
+ this.bridge.EnsureHeaderFooterVisible(this.handle, this.bodyAnchorId, kind);
1517
+ }
1518
+ this.refresh(which);
1519
+ }
1520
+ /** The kind a band is currently editing. */
1521
+ kindOf(which) {
1522
+ return this.kinds[which];
1523
+ }
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. */
1528
+ insertPageNumber(which, anchorId, field) {
1529
+ const res = parseResult(this.bridge.InsertPageNumberField(this.handle, anchorId, field, ""));
1530
+ if (!res.success) return false;
1531
+ this.refresh(which);
1532
+ return true;
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
+ }
1581
+ /** Insert a page number into a band's own target paragraph (focused, else last). Seeds the
1582
+ * story first if the band's selected kind has none, so the command is never a silent no-op. */
1583
+ insertPageNumberInBand(which, field) {
1584
+ if (!this.partUriFor(which, this.kinds[which])) {
1585
+ this.seedStory(which, this.kinds[which]);
1586
+ this.renderBand(which);
1587
+ }
1588
+ const target = this.pageNumberTarget(which);
1589
+ return target ? this.insertPageNumber(which, target, field) : false;
1590
+ }
1591
+ /** The band element containing `node`, or null. */
1592
+ bandOf(node) {
1593
+ const el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
1594
+ const band = el?.closest("[data-hf-band]") ?? null;
1595
+ return band === this.headerBand || band === this.footerBand ? band : null;
1596
+ }
1597
+ /** The block-list root (a band's story container) owning `node`, or null. */
1598
+ blockRootOf(node) {
1599
+ const band = this.bandOf(node);
1600
+ return band ? band.querySelector("[data-hf-body]") : null;
1601
+ }
1602
+ /** True when `node` is inside either band (including its chrome). */
1603
+ contains(node) {
1604
+ return this.bandOf(node) !== null;
1605
+ }
1606
+ /** `"header"` / `"footer"` for a band element produced by this region. */
1607
+ whichOf(band) {
1608
+ return band === this.footerBand ? "footer" : "header";
1609
+ }
1610
+ // ─── section + anchor resolution ─────────────────────────────────────
1611
+ readSectionInfo(bodyAnchorId) {
1612
+ try {
1613
+ const raw = this.bridge.GetSectionInfo(this.handle, bodyAnchorId);
1614
+ const parsed = JSON.parse(raw);
1615
+ return parsed && typeof parsed.sectionUnid === "string" ? parsed : null;
1616
+ } catch {
1617
+ return null;
1618
+ }
1619
+ }
1620
+ refsFor(which) {
1621
+ const info = this.sectionInfo;
1622
+ if (!info) return [];
1623
+ return (which === "header" ? info.headerRefs : info.footerRefs) ?? [];
1624
+ }
1625
+ /** The reference supplying `kind` for this band — possibly inherited from an earlier section. */
1626
+ refFor(which, kind) {
1627
+ return this.refsFor(which).find((r) => r.kind === kind) ?? null;
1628
+ }
1629
+ /** URI of the part supplying `kind` for this band, or null when the story doesn't exist. */
1630
+ partUriFor(which, kind) {
1631
+ return this.refFor(which, kind)?.partUri ?? null;
1632
+ }
1633
+ /** Full anchor ids of the story paragraphs held in `partUri`, in document order. */
1634
+ storyAnchors(partUri) {
1635
+ let index;
1636
+ try {
1637
+ index = JSON.parse(this.bridge.Project(this.handle)).anchorIndex;
1638
+ } catch {
1639
+ return [];
1640
+ }
1641
+ return Object.entries(index).filter(([, t]) => t.partUri === partUri && STORY_BLOCK_KINDS.has(t.kind)).map(([id]) => id);
1642
+ }
1643
+ /**
1644
+ * Create an empty story for `kind`. Seeding happens the moment a kind is selected rather than
1645
+ * lazily on first keystroke: an absent story renders no contenteditable element, so there
1646
+ * would be nothing to click into.
1647
+ */
1648
+ seedStory(which, kind) {
1649
+ if (!this.bodyAnchorId) return;
1650
+ const set = which === "header" ? this.bridge.SetHeaderText : this.bridge.SetFooterText;
1651
+ const res = parseResult(set(this.handle, this.bodyAnchorId, kind, ""));
1652
+ if (!res.success) return;
1653
+ this.callbacks.refreshAnchorMap();
1654
+ this.sectionInfo = this.readSectionInfo(this.bodyAnchorId);
1655
+ }
1656
+ // ─── rendering ───────────────────────────────────────────────────────
1657
+ bandFor(which) {
1658
+ return which === "header" ? this.headerBand : this.footerBand;
1659
+ }
1660
+ buildBand(which) {
1661
+ const band = document.createElement("div");
1662
+ band.className = "docx-hf-band";
1663
+ band.setAttribute("data-hf-band", which);
1664
+ const chrome = document.createElement("div");
1665
+ chrome.className = "docx-hf-chrome";
1666
+ const label = document.createElement("span");
1667
+ label.className = "docx-hf-label";
1668
+ label.textContent = which === "header" ? "Header" : "Footer";
1669
+ chrome.appendChild(label);
1670
+ const kindSelect = document.createElement("select");
1671
+ kindSelect.setAttribute("data-hf-kind", "");
1672
+ kindSelect.title = "Which header/footer story to edit";
1673
+ for (const k of KIND_LABELS) {
1674
+ const opt = document.createElement("option");
1675
+ opt.value = k.value;
1676
+ opt.textContent = k.label;
1677
+ kindSelect.appendChild(opt);
1678
+ }
1679
+ kindSelect.addEventListener(
1680
+ "change",
1681
+ () => this.setKind(which, kindSelect.value)
1682
+ );
1683
+ chrome.appendChild(kindSelect);
1684
+ const pageNum = document.createElement("select");
1685
+ pageNum.setAttribute("data-hf-pagenum", "");
1686
+ pageNum.title = "Insert a page-number field into the focused story paragraph";
1687
+ for (const o of [
1688
+ { value: "", label: "Page \u2116\u2026" },
1689
+ { value: "currentPage", label: "Page number" },
1690
+ { value: "totalPages", label: "Total pages" }
1691
+ ]) {
1692
+ const opt = document.createElement("option");
1693
+ opt.value = o.value;
1694
+ opt.textContent = o.label;
1695
+ pageNum.appendChild(opt);
1696
+ }
1697
+ pageNum.addEventListener("change", () => {
1698
+ const field = pageNum.value;
1699
+ pageNum.value = "";
1700
+ if (field !== "currentPage" && field !== "totalPages") return;
1701
+ const target = this.pageNumberTarget(which);
1702
+ if (target) this.insertPageNumber(which, target, field);
1703
+ });
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);
1740
+ const inheritedNote = document.createElement("span");
1741
+ inheritedNote.className = "docx-hf-inherited";
1742
+ inheritedNote.setAttribute("data-hf-inherited-note", "");
1743
+ inheritedNote.hidden = true;
1744
+ chrome.appendChild(inheritedNote);
1745
+ band.appendChild(chrome);
1746
+ const warning = document.createElement("div");
1747
+ warning.className = "docx-hf-warning";
1748
+ warning.setAttribute("data-hf-warning", "");
1749
+ warning.hidden = true;
1750
+ band.appendChild(warning);
1751
+ const body = document.createElement("div");
1752
+ body.className = "docx-hf-body";
1753
+ body.setAttribute("data-hf-body", "");
1754
+ band.appendChild(body);
1755
+ return band;
1756
+ }
1757
+ /**
1758
+ * Which story paragraph a page-number insert targets: the last one the user focused in this
1759
+ * band if it is still attached, else the band's last paragraph (Word's convention — the page
1760
+ * number goes at the end of the footer line).
1761
+ */
1762
+ pageNumberTarget(which) {
1763
+ const body = this.bandFor(which).querySelector("[data-hf-body]");
1764
+ if (!body) return null;
1765
+ const focused = body.querySelector('[data-hf-focused="true"][data-hf-anchor]');
1766
+ const blocks = Array.from(body.querySelectorAll("[data-hf-anchor]"));
1767
+ const target = focused ?? blocks[blocks.length - 1];
1768
+ return target?.getAttribute("data-hf-anchor") ?? null;
1769
+ }
1770
+ renderBand(which) {
1771
+ const band = this.bandFor(which);
1772
+ const body = band.querySelector("[data-hf-body]");
1773
+ if (!body) return;
1774
+ const kind = this.kinds[which];
1775
+ const select = band.querySelector("[data-hf-kind]");
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
+ }
1784
+ const inherited = this.refFor(which, kind)?.inherited === true;
1785
+ band.toggleAttribute("data-hf-inherited", inherited);
1786
+ const note = band.querySelector("[data-hf-inherited-note]");
1787
+ if (note) {
1788
+ note.hidden = !inherited;
1789
+ note.textContent = inherited ? "inherited from an earlier section" : "";
1790
+ }
1791
+ this.renderKindWarning(which);
1792
+ body.innerHTML = "";
1793
+ const partUri = this.partUriFor(which, kind);
1794
+ if (!partUri) {
1795
+ body.appendChild(this.placeholder(which, kind));
1796
+ band.setAttribute("data-hf-empty", "true");
1797
+ return;
1798
+ }
1799
+ band.removeAttribute("data-hf-empty");
1800
+ const anchors = this.storyAnchors(partUri);
1801
+ if (anchors.length === 0) {
1802
+ body.appendChild(this.placeholder(which, kind));
1803
+ return;
1804
+ }
1805
+ for (const anchorId of anchors) {
1806
+ const el = this.renderStoryBlock(anchorId);
1807
+ if (!el) continue;
1808
+ body.appendChild(el);
1809
+ this.adoptBlock(el, anchorId);
1810
+ this.callbacks.wireBlock(el);
1811
+ }
1812
+ }
1813
+ /**
1814
+ * Mark a story paragraph as belonging to this region. The full anchor id is stamped alongside
1815
+ * `data-anchor` (which carries only the bare unid, matching the body's convention) so band
1816
+ * chrome can address the block without going through the editor's unid map. Called on first
1817
+ * render AND after an incremental swap, which replaces the DOM node.
1818
+ */
1819
+ adoptBlock(el, anchorId) {
1820
+ const body = this.blockRootOf(el);
1821
+ if (!body) return;
1822
+ el.setAttribute("data-hf-anchor", anchorId);
1823
+ if (el.dataset.hfAdopted === "true") return;
1824
+ el.dataset.hfAdopted = "true";
1825
+ el.addEventListener("focus", () => {
1826
+ body.querySelectorAll("[data-hf-focused]").forEach((sib) => sib.removeAttribute("data-hf-focused"));
1827
+ el.setAttribute("data-hf-focused", "true");
1828
+ });
1829
+ }
1830
+ renderStoryBlock(anchorId) {
1831
+ const html = this.bridge.RenderBlockHtml(
1832
+ this.handle,
1833
+ anchorId,
1834
+ this.options.cssPrefix,
1835
+ this.options.fabricateClasses
1836
+ );
1837
+ if (html.charCodeAt(0) === 123) return null;
1838
+ return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1839
+ }
1840
+ placeholder(which, kind) {
1841
+ const el = document.createElement("div");
1842
+ el.className = "docx-hf-placeholder";
1843
+ el.setAttribute("data-hf-placeholder", "");
1844
+ const label = KIND_LABELS.find((k) => k.value === kind)?.label ?? kind;
1845
+ el.textContent = `No ${label.toLowerCase()} ${which} \u2014 pick a kind to create one.`;
1846
+ return el;
1847
+ }
1848
+ /**
1849
+ * Surface the caveat that comes with the selected kind. Turning on first/even means those pages
1850
+ * stop using the Default stories entirely, which bites hardest on the OTHER band: enabling an
1851
+ * even header with no even footer leaves even pages footer-less, and enabling a first-page
1852
+ * header with an empty first-page footer leaves page 1 footer-less. The note is shown whenever
1853
+ * first/even is selected (the behavior change is real either way); the fix button appears only
1854
+ * when the counterpart story is missing entirely, which is what a user almost always wants next.
1855
+ */
1856
+ renderKindWarning(which) {
1857
+ const band = this.bandFor(which);
1858
+ const warning = band.querySelector("[data-hf-warning]");
1859
+ if (!warning) return;
1860
+ const kind = this.kinds[which];
1861
+ if (kind === "default") {
1862
+ warning.hidden = true;
1863
+ warning.textContent = "";
1864
+ return;
1865
+ }
1866
+ const other = which === "header" ? "footer" : "header";
1867
+ warning.hidden = false;
1868
+ warning.textContent = `${kind === "even" ? EVEN_WARNING : FIRST_WARNING} `;
1869
+ if (this.partUriFor(other, kind)) return;
1870
+ const fix = document.createElement("button");
1871
+ fix.type = "button";
1872
+ fix.setAttribute("data-hf-fix-counterpart", "");
1873
+ fix.textContent = `Also create a ${kind === "even" ? "matching even" : "first-page"} ${other}`;
1874
+ fix.addEventListener("click", () => {
1875
+ this.setKind(other, kind);
1876
+ this.renderKindWarning(which);
1877
+ });
1878
+ warning.appendChild(fix);
1879
+ }
1880
+ };
1881
+ function parseResult(json) {
1882
+ try {
1883
+ return JSON.parse(json);
1884
+ } catch {
1885
+ return { success: false };
1886
+ }
1887
+ }
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
+
846
1962
  // src/editor.ts
847
1963
  var EDITABLE_TAGS = /* @__PURE__ */ new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
848
1964
  function fontWeightIsBold(w) {
@@ -855,7 +1971,7 @@ var DocxodusEditor = (() => {
855
1971
  }
856
1972
  function collectInlineSegments(node, out) {
857
1973
  node.childNodes.forEach((child) => {
858
- if (child.nodeType === 1 && child.hasAttribute?.("data-list-marker")) return;
1974
+ if (isGeneratedChrome(child)) return;
859
1975
  if (child.nodeType === 3) {
860
1976
  const text = child.textContent ?? "";
861
1977
  if (!text) return;
@@ -909,10 +2025,14 @@ var DocxodusEditor = (() => {
909
2025
  function isListBlock(block) {
910
2026
  return !!block.querySelector(":scope > [data-list-marker]");
911
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
+ }
912
2032
  function isInMarker(node) {
913
2033
  let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
914
2034
  while (el) {
915
- if (el.hasAttribute && el.hasAttribute("data-list-marker")) return true;
2035
+ if (isGeneratedChrome(el)) return true;
916
2036
  el = el.parentElement;
917
2037
  }
918
2038
  return false;
@@ -976,11 +2096,22 @@ var DocxodusEditor = (() => {
976
2096
  return out;
977
2097
  }
978
2098
  function trimmedSplitOffset(block, domOffset) {
979
- const content = blockContentText(block);
980
- const leading = content.length - content.replace(/^\s+/, "").length;
981
- const trimmedLen = content.trim().length;
2099
+ const { leading, trimmedLen } = trimBounds(block);
982
2100
  return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
983
2101
  }
2102
+ function trimBounds(block) {
2103
+ const content = blockContentText(block);
2104
+ return {
2105
+ leading: content.length - content.replace(/^\s+/, "").length,
2106
+ trimmedLen: content.trim().length
2107
+ };
2108
+ }
2109
+ function trimmedSpan(block, span) {
2110
+ const { leading, trimmedLen } = trimBounds(block);
2111
+ const start = Math.max(0, Math.min(span.start - Math.min(span.start, leading), trimmedLen));
2112
+ const end = Math.max(start, Math.min(span.start + span.length - leading, trimmedLen));
2113
+ return { start, length: end - start };
2114
+ }
984
2115
  function contentPositionIn(el, offset) {
985
2116
  let remaining = offset;
986
2117
  let result = null;
@@ -1047,7 +2178,8 @@ var DocxodusEditor = (() => {
1047
2178
  if (!block.contains(range.startContainer) || !block.contains(range.endContainer)) return null;
1048
2179
  const start = contentOffsetOf(block, range.startContainer, range.startOffset);
1049
2180
  const end = contentOffsetOf(block, range.endContainer, range.endOffset);
1050
- return { start: Math.min(start, end), length: Math.abs(end - start) };
2181
+ const span = trimmedSpan(block, { start: Math.min(start, end), length: Math.abs(end - start) });
2182
+ return span.length > 0 ? span : null;
1051
2183
  }
1052
2184
  function selectRange(el, start, length) {
1053
2185
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
@@ -1132,8 +2264,11 @@ var DocxodusEditor = (() => {
1132
2264
  false,
1133
2265
  0,
1134
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.
1135
2270
  /* renderFootnotesAndEndnotes */
1136
- false,
2271
+ true,
1137
2272
  /* renderHeadersAndFooters */
1138
2273
  paginated,
1139
2274
  false,
@@ -1167,6 +2302,11 @@ var DocxodusEditor = (() => {
1167
2302
  * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
1168
2303
  */
1169
2304
  this.lastSelection = null;
2305
+ /** The docked header/footer bands, when `options.headerFooter` is on. */
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;
1170
2310
  /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
1171
2311
  this.onSelectionChange = () => {
1172
2312
  if (this.closed) return;
@@ -1192,12 +2332,43 @@ var DocxodusEditor = (() => {
1192
2332
  if (typeof document !== "undefined")
1193
2333
  document.addEventListener("selectionchange", this.onSelectionChange);
1194
2334
  }
1195
- /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor. */
2335
+ /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
2336
+ * Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
2337
+ * body edit root by design — also register. The fence still rejects other editors on the page. */
1196
2338
  editableBlockOf(node) {
1197
2339
  if (!node) return null;
1198
2340
  const start = node.nodeType === 1 ? node : node.parentElement;
1199
2341
  const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
1200
- return block && this.editRoot.contains(block) ? block : null;
2342
+ return block && this.container.contains(block) ? block : null;
2343
+ }
2344
+ /**
2345
+ * The root owning `el`'s sibling block list: its header/footer band's story container, else the
2346
+ * body edit root. Keeps a multi-block selection from spanning a band and the body, whose block
2347
+ * lists belong to different OOXML parts.
2348
+ */
2349
+ ownerRoot(el) {
2350
+ return this.region?.blockRootOf(el) ?? this.editRoot;
2351
+ }
2352
+ /** True when `el` is a header/footer band block rather than a body block. */
2353
+ isBandBlock(el) {
2354
+ return !!this.region?.contains(el);
2355
+ }
2356
+ /**
2357
+ * Repaint after an edit to `block` that would otherwise remount the whole document: a band
2358
+ * repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
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).
2363
+ */
2364
+ refreshAfter(block, focusIndex, caretAtEnd = false, forceRemount = false) {
2365
+ const band = this.region?.bandOf(block);
2366
+ if (band) {
2367
+ this.region.refresh(this.region.whichOf(band));
2368
+ return;
2369
+ }
2370
+ if (forceRemount) this.remount(focusIndex, caretAtEnd);
2371
+ else this.reconcile(focusIndex, caretAtEnd);
1201
2372
  }
1202
2373
  /** Open a document, render it into `container`, and wire up editing. */
1203
2374
  static open(container, bytes, exports, options = {}) {
@@ -1207,16 +2378,19 @@ var DocxodusEditor = (() => {
1207
2378
  editable: options.editable ?? true,
1208
2379
  paginated: options.paginated ?? false,
1209
2380
  scale: options.scale ?? 1,
2381
+ headerFooter: options.headerFooter ?? false,
1210
2382
  onEdit: options.onEdit
1211
2383
  };
1212
- const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
2384
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"emitMarkdownPatch":false}');
1213
2385
  const editor = new _DocxEditor(container, exports, handle, opts);
1214
2386
  editor.refreshAnchorMap();
2387
+ if (opts.headerFooter) editor.createRegion();
1215
2388
  const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(
1216
2389
  ...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale)
1217
2390
  );
1218
2391
  if (opts.paginated) editor.mountPaginated(fullHtml);
1219
2392
  else editor.mountHtml(fullHtml);
2393
+ editor.syncRegionToBody();
1220
2394
  return editor;
1221
2395
  }
1222
2396
  /**
@@ -1259,29 +2433,117 @@ var DocxodusEditor = (() => {
1259
2433
  assertOpen() {
1260
2434
  if (this.closed) throw new Error("DocxEditor is closed");
1261
2435
  }
1262
- /** Rebuild unid → full-anchor-id from the live session projection. */
2436
+ /**
2437
+ * Rebuild unid → full-anchor-id from the live session projection.
2438
+ *
2439
+ * Unids are CONTENT-ADDRESSED, so blocks with identical content in DIFFERENT parts collide —
2440
+ * e.g. a document with empty default/first/even header stories has one unid for several
2441
+ * header parts. A collision must resolve to the BODY entry: body blocks carry only
2442
+ * `data-anchor` (the bare unid) and have nothing else to resolve through, whereas a
2443
+ * header/footer band block carries its full anchor in `data-hf-anchor` and is resolved from
2444
+ * that (see `anchorIdOf`). Letting a non-body scope win here would silently redirect a body
2445
+ * edit into a header part.
2446
+ */
1263
2447
  refreshAnchorMap() {
1264
- 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);
1265
2451
  this.unidToFullId.clear();
2452
+ const bodyOwned = /* @__PURE__ */ new Set();
1266
2453
  for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
2454
+ const isBody = target.scope === "body";
2455
+ if (!isBody && bodyOwned.has(target.unid)) continue;
1267
2456
  this.unidToFullId.set(target.unid, fullId);
2457
+ if (isBody) bodyOwned.add(target.unid);
1268
2458
  }
1269
2459
  }
2460
+ /**
2461
+ * The full `kind:scope:unid` anchor for a rendered block. A header/footer band block carries
2462
+ * its own — the unid map cannot disambiguate one, since several parts' story paragraphs can
2463
+ * share a content-addressed unid (a real Word document with empty default/first/even stories
2464
+ * does exactly that, and a unid-keyed lookup would land the edit in the wrong header part).
2465
+ */
2466
+ anchorIdOf(el) {
2467
+ const stamped = el.getAttribute("data-hf-anchor");
2468
+ if (stamped) return stamped;
2469
+ const unid = el.getAttribute("data-anchor");
2470
+ return unid ? this.unidToFullId.get(unid) : void 0;
2471
+ }
2472
+ /** Build the header/footer region (called once, before the first mount). */
2473
+ createRegion() {
2474
+ this.region = new HeaderFooterRegion(
2475
+ this.exports.DocxSessionBridge,
2476
+ this.handle,
2477
+ { cssPrefix: this.options.cssPrefix, fabricateClasses: this.options.fabricateClasses },
2478
+ {
2479
+ wireBlock: (el) => this.wireBlock(el),
2480
+ refreshAnchorMap: () => this.refreshAnchorMap()
2481
+ }
2482
+ );
2483
+ }
2484
+ /**
2485
+ * Point the bands at the section governing the current focus (or the first body block).
2486
+ * Called after every mount, and on focus of a body block, so a multi-section document shows
2487
+ * the stories that actually apply where the caret is.
2488
+ */
2489
+ syncRegionToBody(fromBlock) {
2490
+ if (!this.region) return;
2491
+ const block = fromBlock ?? this.editableList()[0];
2492
+ const unid = block?.getAttribute("data-anchor");
2493
+ const id = unid ? this.unidToFullId.get(unid) : void 0;
2494
+ this.region.syncToBody(id ?? null);
2495
+ }
2496
+ /**
2497
+ * Insert the bands around `bodyRoot` and make `bodyRoot` the edit root. Band blocks must stay
2498
+ * OUT of the edit root: `editableList()`/`blockIndex()` enumerate it to compute remount focus
2499
+ * indices, and band blocks in that list would shift every index.
2500
+ */
2501
+ dockBands(bodyRoot) {
2502
+ if (!this.region) return;
2503
+ bodyRoot.before(this.region.headerBand);
2504
+ bodyRoot.after(this.region.footerBand);
2505
+ this.region.refreshAll();
2506
+ }
1270
2507
  /** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
1271
2508
  mountHtml(fullHtml) {
1272
2509
  const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
1273
2510
  const styles = Array.from(parsed.querySelectorAll("style")).map((s) => s.outerHTML).join("");
1274
- this.container.innerHTML = styles + parsed.body.innerHTML;
1275
- this.editRoot = this.container;
1276
- if (this.options.editable) this.wireBlocks(this.container);
2511
+ if (!this.region) {
2512
+ this.container.innerHTML = styles + parsed.body.innerHTML;
2513
+ this.editRoot = this.container;
2514
+ if (this.options.editable) this.wireBlocks(this.container);
2515
+ this.stampPlanState();
2516
+ return;
2517
+ }
2518
+ this.container.innerHTML = styles;
2519
+ const flow = document.createElement("div");
2520
+ flow.className = "docx-body-flow";
2521
+ flow.innerHTML = parsed.body.innerHTML;
2522
+ this.container.appendChild(flow);
2523
+ this.editRoot = flow;
2524
+ if (this.options.editable) this.wireBlocks(flow);
2525
+ this.stampPlanState();
2526
+ this.dockBands(flow);
1277
2527
  }
1278
2528
  /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
1279
2529
  mountPaginated(fullHtml) {
1280
- paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
2530
+ let target = this.container;
2531
+ if (this.region) {
2532
+ this.container.innerHTML = "";
2533
+ target = document.createElement("div");
2534
+ target.className = "docx-body-flow";
2535
+ this.container.appendChild(target);
2536
+ }
2537
+ paginateHtml(fullHtml, target, {
2538
+ scale: this.options.scale,
2539
+ cssPrefix: "page-",
2540
+ fragmentParagraphs: false
2541
+ });
1281
2542
  this.container.querySelector("#pagination-staging, .page-staging")?.remove();
1282
- const pageRoot = this.container.querySelector("#pagination-container") ?? this.container;
2543
+ const pageRoot = target.querySelector("#pagination-container") ?? target;
1283
2544
  this.editRoot = pageRoot;
1284
2545
  if (this.options.editable) this.wireBlocks(pageRoot);
2546
+ if (this.region) this.dockBands(target);
1285
2547
  }
1286
2548
  wireBlocks(root) {
1287
2549
  root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
@@ -1289,15 +2551,18 @@ var DocxodusEditor = (() => {
1289
2551
  wireBlock(el) {
1290
2552
  if (!EDITABLE_TAGS.has(el.tagName)) return;
1291
2553
  const unid = el.getAttribute("data-anchor");
1292
- if (!unid || !this.unidToFullId.has(unid)) return;
2554
+ if (!unid || !this.anchorIdOf(el)) return;
1293
2555
  el.setAttribute("contenteditable", "true");
1294
- el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
2556
+ el.querySelectorAll(GENERATED_CHROME_SELECTOR).forEach((m) => m.setAttribute("contenteditable", "false"));
1295
2557
  el.dataset.committedText = blockContentText(el);
1296
2558
  el.addEventListener("focus", () => {
1297
2559
  this.activeBlock = el;
2560
+ if (this.region && !this.isBandBlock(el)) this.syncRegionToBody(el);
1298
2561
  });
1299
2562
  el.addEventListener("blur", () => this.commitBlock(el));
1300
2563
  el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
2564
+ const stamped = el.getAttribute("data-hf-anchor");
2565
+ if (stamped && this.region?.contains(el)) this.region.adoptBlock(el, stamped);
1301
2566
  }
1302
2567
  /**
1303
2568
  * Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
@@ -1327,7 +2592,7 @@ var DocxodusEditor = (() => {
1327
2592
  if (this.closed || this.replacing) return;
1328
2593
  const unid = el.getAttribute("data-anchor");
1329
2594
  if (!unid) return;
1330
- const fullId = this.unidToFullId.get(unid);
2595
+ const fullId = this.anchorIdOf(el);
1331
2596
  if (!fullId) return;
1332
2597
  const result = this.commitTextChange(el, fullId);
1333
2598
  if (!result) return;
@@ -1354,11 +2619,17 @@ var DocxodusEditor = (() => {
1354
2619
  );
1355
2620
  if (html.charCodeAt(0) !== 123) {
1356
2621
  const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
2622
+ const inBand = this.isBandBlock(el);
1357
2623
  if (fresh && this.replaceNode(el, fresh)) {
1358
- this.unidToFullId.delete(unid);
1359
- this.unidToFullId.set(newUnid, newAnchor);
2624
+ if (inBand) {
2625
+ this.region.adoptBlock(fresh, newAnchor);
2626
+ } else {
2627
+ this.unidToFullId.delete(unid);
2628
+ this.unidToFullId.set(newUnid, newAnchor);
2629
+ }
1360
2630
  this.wireBlock(fresh);
1361
2631
  if (this.activeBlock === el) this.activeBlock = fresh;
2632
+ this.maybeRenumberNotes(fresh);
1362
2633
  }
1363
2634
  }
1364
2635
  this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
@@ -1432,7 +2703,7 @@ var DocxodusEditor = (() => {
1432
2703
  const rawOffset = caretOffsetIn(el);
1433
2704
  const unid = el.getAttribute("data-anchor");
1434
2705
  if (rawOffset == null || !unid) return;
1435
- let fullId = this.unidToFullId.get(unid);
2706
+ let fullId = this.anchorIdOf(el);
1436
2707
  if (!fullId) return;
1437
2708
  const offset = trimmedSplitOffset(el, rawOffset);
1438
2709
  const idx = this.blockIndex(el);
@@ -1444,17 +2715,23 @@ var DocxodusEditor = (() => {
1444
2715
  const second = res.created?.[0];
1445
2716
  if (!first || !second) return;
1446
2717
  if (this.affectsList(res) || wrappedInBorder) {
1447
- this.remount(idx + 1, false);
2718
+ this.refreshAfter(el, idx + 1, false);
1448
2719
  this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
1449
2720
  return;
1450
2721
  }
1451
2722
  const firstEl = this.renderInto(first.id);
1452
2723
  const secondEl = this.renderInto(second.id);
1453
2724
  if (!firstEl || !secondEl) return;
2725
+ const inBand = this.isBandBlock(el);
1454
2726
  if (!this.replaceNode(el, firstEl, secondEl)) return;
1455
- this.unidToFullId.delete(unid);
1456
- this.unidToFullId.set(first.unid, first.id);
1457
- this.unidToFullId.set(second.unid, second.id);
2727
+ if (inBand) {
2728
+ this.region.adoptBlock(firstEl, first.id);
2729
+ this.region.adoptBlock(secondEl, second.id);
2730
+ } else {
2731
+ this.unidToFullId.delete(unid);
2732
+ this.unidToFullId.set(first.unid, first.id);
2733
+ this.unidToFullId.set(second.unid, second.id);
2734
+ }
1458
2735
  this.wireBlock(firstEl);
1459
2736
  this.wireBlock(secondEl);
1460
2737
  placeCaretAtOffset(secondEl, 0);
@@ -1465,8 +2742,8 @@ var DocxodusEditor = (() => {
1465
2742
  const prevUnid = prev.getAttribute("data-anchor");
1466
2743
  const thisUnid = el.getAttribute("data-anchor");
1467
2744
  if (!prevUnid || !thisUnid) return;
1468
- let prevId = this.unidToFullId.get(prevUnid);
1469
- let thisId = this.unidToFullId.get(thisUnid);
2745
+ let prevId = this.anchorIdOf(prev);
2746
+ let thisId = this.anchorIdOf(el);
1470
2747
  if (!prevId || !thisId) return;
1471
2748
  const prevIdx = this.blockIndex(prev);
1472
2749
  const wrappedInBorder = inBorderWrapper(prev) || inBorderWrapper(el);
@@ -1478,17 +2755,22 @@ var DocxodusEditor = (() => {
1478
2755
  const merged = res.modified?.[0];
1479
2756
  if (!merged) return;
1480
2757
  if (this.affectsList(res) || wrappedInBorder) {
1481
- this.remount(prevIdx, true);
2758
+ this.refreshAfter(el, prevIdx, true);
1482
2759
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
1483
2760
  return;
1484
2761
  }
1485
2762
  const mergedEl = this.renderInto(merged.id);
1486
2763
  if (!mergedEl) return;
2764
+ const inBand = this.isBandBlock(prev);
1487
2765
  if (!this.replaceNode(prev, mergedEl)) return;
1488
2766
  el.remove();
1489
- this.unidToFullId.delete(prevUnid);
1490
- this.unidToFullId.delete(thisUnid);
1491
- this.unidToFullId.set(merged.unid, merged.id);
2767
+ if (inBand) {
2768
+ this.region.adoptBlock(mergedEl, merged.id);
2769
+ } else {
2770
+ this.unidToFullId.delete(prevUnid);
2771
+ this.unidToFullId.delete(thisUnid);
2772
+ this.unidToFullId.set(merged.unid, merged.id);
2773
+ }
1492
2774
  this.wireBlock(mergedEl);
1493
2775
  placeCaretAtOffset(mergedEl, caret);
1494
2776
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
@@ -1551,10 +2833,10 @@ var DocxodusEditor = (() => {
1551
2833
  if (html.charCodeAt(0) === 123) return null;
1552
2834
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1553
2835
  }
1554
- /** The editable block immediately before `el` in document order, or null. */
2836
+ /** The editable block immediately before `el` within its own root, or null. */
1555
2837
  previousEditable(el) {
1556
2838
  const all = Array.from(
1557
- this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')
2839
+ this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')
1558
2840
  );
1559
2841
  const i = all.indexOf(el);
1560
2842
  return i > 0 ? all[i - 1] : null;
@@ -1574,8 +2856,9 @@ var DocxodusEditor = (() => {
1574
2856
  * A collapsed or single-block selection yields just the active block. */
1575
2857
  selectedBlocks() {
1576
2858
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
2859
+ const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
1577
2860
  const all = Array.from(
1578
- this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')
2861
+ root.querySelectorAll('[data-anchor][contenteditable="true"]')
1579
2862
  );
1580
2863
  if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
1581
2864
  const range = sel.getRangeAt(0);
@@ -1605,13 +2888,13 @@ var DocxodusEditor = (() => {
1605
2888
  const contentLen = blockContentText(block).length;
1606
2889
  if (hasStart) {
1607
2890
  const start = contentOffsetOf(block, range.startContainer, range.startOffset);
1608
- return { start, length: Math.max(0, contentLen - start) };
2891
+ return trimmedSpan(block, { start, length: Math.max(0, contentLen - start) });
1609
2892
  }
1610
2893
  if (hasEnd) {
1611
2894
  const end = contentOffsetOf(block, range.endContainer, range.endOffset);
1612
- return { start: 0, length: end };
2895
+ return trimmedSpan(block, { start: 0, length: end });
1613
2896
  }
1614
- return { start: 0, length: contentLen };
2897
+ return trimmedSpan(block, { start: 0, length: contentLen });
1615
2898
  }
1616
2899
  /** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
1617
2900
  * the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
@@ -1658,7 +2941,7 @@ var DocxodusEditor = (() => {
1658
2941
  return {
1659
2942
  block: b,
1660
2943
  unid,
1661
- fullId: unid ? this.unidToFullId.get(unid) : void 0,
2944
+ fullId: this.anchorIdOf(b),
1662
2945
  span: this.blockSpanForSelection(b),
1663
2946
  res: null
1664
2947
  };
@@ -1678,7 +2961,7 @@ var DocxodusEditor = (() => {
1678
2961
  const edited = targets.filter((t) => t.res && t.unid);
1679
2962
  if (edited.length === 0) return;
1680
2963
  if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
1681
- this.remount();
2964
+ this.refreshAfter(edited[0].block, -1, false);
1682
2965
  return;
1683
2966
  }
1684
2967
  const swapped = [];
@@ -1721,7 +3004,7 @@ var DocxodusEditor = (() => {
1721
3004
  }
1722
3005
  const unid = block.getAttribute("data-anchor");
1723
3006
  if (!unid) return;
1724
- let fullId = this.unidToFullId.get(unid);
3007
+ let fullId = this.anchorIdOf(block);
1725
3008
  if (!fullId) return;
1726
3009
  const span = selectionSpanIn(block);
1727
3010
  const on = value ?? !selectionHasFormat(key, block);
@@ -1737,7 +3020,7 @@ var DocxodusEditor = (() => {
1737
3020
  );
1738
3021
  if (!res.success) return;
1739
3022
  if (this.affectsList(res)) {
1740
- this.remount(this.blockIndex(block), false);
3023
+ this.refreshAfter(block, this.blockIndex(block), false);
1741
3024
  return;
1742
3025
  }
1743
3026
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1756,7 +3039,7 @@ var DocxodusEditor = (() => {
1756
3039
  if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontSizePts: pts })) return;
1757
3040
  const unid = block.getAttribute("data-anchor");
1758
3041
  if (!unid) return;
1759
- let fullId = this.unidToFullId.get(unid);
3042
+ let fullId = this.anchorIdOf(block);
1760
3043
  if (!fullId) return;
1761
3044
  let span = selectionSpanIn(block);
1762
3045
  if (!span && this.lastSelection && this.lastSelection.unid === unid) span = this.lastSelection.span;
@@ -1771,7 +3054,7 @@ var DocxodusEditor = (() => {
1771
3054
  );
1772
3055
  if (!res.success) return;
1773
3056
  if (this.affectsList(res)) {
1774
- this.remount(this.blockIndex(block), false);
3057
+ this.refreshAfter(block, this.blockIndex(block), false);
1775
3058
  return;
1776
3059
  }
1777
3060
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1792,7 +3075,7 @@ var DocxodusEditor = (() => {
1792
3075
  if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontFamily: name })) return;
1793
3076
  const unid = block.getAttribute("data-anchor");
1794
3077
  if (!unid) return;
1795
- let fullId = this.unidToFullId.get(unid);
3078
+ let fullId = this.anchorIdOf(block);
1796
3079
  if (!fullId) return;
1797
3080
  let span = selectionSpanIn(block);
1798
3081
  if (!span && this.lastSelection && this.lastSelection.unid === unid) span = this.lastSelection.span;
@@ -1807,7 +3090,7 @@ var DocxodusEditor = (() => {
1807
3090
  );
1808
3091
  if (!res.success) return;
1809
3092
  if (this.affectsList(res)) {
1810
- this.remount(this.blockIndex(block), false);
3093
+ this.refreshAfter(block, this.blockIndex(block), false);
1811
3094
  return;
1812
3095
  }
1813
3096
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1828,7 +3111,7 @@ var DocxodusEditor = (() => {
1828
3111
  if (this.closed || !block) return;
1829
3112
  const unid = block.getAttribute("data-anchor");
1830
3113
  if (!unid) return;
1831
- let fullId = this.unidToFullId.get(unid);
3114
+ let fullId = this.anchorIdOf(block);
1832
3115
  if (!fullId) return;
1833
3116
  const idx = this.blockIndex(block);
1834
3117
  fullId = this.syncBlock(block, fullId);
@@ -1841,7 +3124,13 @@ var DocxodusEditor = (() => {
1841
3124
  )
1842
3125
  );
1843
3126
  if (!res.success) return;
1844
- this.remount(idx, false);
3127
+ this.refreshAfter(
3128
+ block,
3129
+ idx,
3130
+ false,
3131
+ /* forceRemount */
3132
+ true
3133
+ );
1845
3134
  }
1846
3135
  /**
1847
3136
  * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
@@ -1853,7 +3142,7 @@ var DocxodusEditor = (() => {
1853
3142
  if (this.closed || !block) return;
1854
3143
  const unid = block.getAttribute("data-anchor");
1855
3144
  if (!unid) return;
1856
- let fullId = this.unidToFullId.get(unid);
3145
+ let fullId = this.anchorIdOf(block);
1857
3146
  if (!fullId) return;
1858
3147
  const idx = this.blockIndex(block);
1859
3148
  fullId = this.syncBlock(block, fullId);
@@ -1869,7 +3158,7 @@ var DocxodusEditor = (() => {
1869
3158
  )
1870
3159
  );
1871
3160
  if (!res.success) return;
1872
- this.remount(idx, false);
3161
+ this.refreshAfter(block, idx, false);
1873
3162
  }
1874
3163
  // ─── Table row / column editing (active block must be inside a table cell) ──────────
1875
3164
  /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
@@ -1878,13 +3167,13 @@ var DocxodusEditor = (() => {
1878
3167
  if (this.closed || !block || !block.closest("table")) return;
1879
3168
  const unid = block.getAttribute("data-anchor");
1880
3169
  if (!unid) return;
1881
- let fullId = this.unidToFullId.get(unid);
3170
+ let fullId = this.anchorIdOf(block);
1882
3171
  if (!fullId) return;
1883
3172
  const idx = this.blockIndex(block);
1884
3173
  fullId = this.syncBlock(block, fullId);
1885
3174
  const res = this.parseEdit(run(fullId));
1886
3175
  if (!res.success) return;
1887
- this.remount(idx, false);
3176
+ this.refreshAfter(block, idx, false);
1888
3177
  }
1889
3178
  /** Insert a row above/below the active cell's row. No-op outside a table. */
1890
3179
  insertTableRow(where) {
@@ -1925,13 +3214,19 @@ var DocxodusEditor = (() => {
1925
3214
  if (this.closed || !block) return;
1926
3215
  const unid = block.getAttribute("data-anchor");
1927
3216
  if (!unid) return;
1928
- let fullId = this.unidToFullId.get(unid);
3217
+ let fullId = this.anchorIdOf(block);
1929
3218
  if (!fullId) return;
1930
3219
  const idx = this.blockIndex(block);
1931
3220
  fullId = this.syncBlock(block, fullId);
1932
3221
  const res = this.parseEdit(this.exports.DocxSessionBridge.SetListLevel(this.handle, fullId, delta));
1933
3222
  if (!res.success) return;
1934
- this.remount(idx, false);
3223
+ this.refreshAfter(
3224
+ block,
3225
+ idx,
3226
+ false,
3227
+ /* forceRemount */
3228
+ true
3229
+ );
1935
3230
  }
1936
3231
  /** Toggle (or set) page-break-before on the active block. */
1937
3232
  pageBreakBefore(value = true) {
@@ -1946,7 +3241,7 @@ var DocxodusEditor = (() => {
1946
3241
  if (this.closed || !block) return;
1947
3242
  const unid = block.getAttribute("data-anchor");
1948
3243
  if (!unid) return;
1949
- let fullId = this.unidToFullId.get(unid);
3244
+ let fullId = this.anchorIdOf(block);
1950
3245
  if (!fullId) return;
1951
3246
  let membership = null;
1952
3247
  try {
@@ -1960,7 +3255,13 @@ var DocxodusEditor = (() => {
1960
3255
  this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, isThisKind ? "none" : kind)
1961
3256
  );
1962
3257
  if (!res.success) return;
1963
- this.remount(idx, false);
3258
+ this.refreshAfter(
3259
+ block,
3260
+ idx,
3261
+ false,
3262
+ /* forceRemount */
3263
+ true
3264
+ );
1964
3265
  }
1965
3266
  /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
1966
3267
  * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
@@ -1982,12 +3283,46 @@ var DocxodusEditor = (() => {
1982
3283
  if (this.editableList().length <= 1) return;
1983
3284
  const unid = block.getAttribute("data-anchor");
1984
3285
  if (!unid) return;
1985
- const fullId = this.unidToFullId.get(unid);
3286
+ const fullId = this.anchorIdOf(block);
1986
3287
  if (!fullId) return;
1987
3288
  const idx = this.blockIndex(block);
1988
3289
  const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
1989
3290
  if (!res.success) return;
1990
- this.remount(Math.max(0, idx - 1), true);
3291
+ this.refreshAfter(block, Math.max(0, idx - 1), true);
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);
1991
3326
  }
1992
3327
  applyParagraphFormat(op) {
1993
3328
  const block = this.activeBlock;
@@ -2002,7 +3337,7 @@ var DocxodusEditor = (() => {
2002
3337
  return;
2003
3338
  const unid = block.getAttribute("data-anchor");
2004
3339
  if (!unid) return;
2005
- let fullId = this.unidToFullId.get(unid);
3340
+ let fullId = this.anchorIdOf(block);
2006
3341
  if (!fullId) return;
2007
3342
  const idx = this.blockIndex(block);
2008
3343
  fullId = this.syncBlock(block, fullId);
@@ -2011,7 +3346,7 @@ var DocxodusEditor = (() => {
2011
3346
  );
2012
3347
  if (!res.success) return;
2013
3348
  if (this.affectsList(res) || op.clearBorders) {
2014
- this.remount(idx, false);
3349
+ this.refreshAfter(block, idx, false, true);
2015
3350
  return;
2016
3351
  }
2017
3352
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
@@ -2029,27 +3364,83 @@ var DocxodusEditor = (() => {
2029
3364
  return;
2030
3365
  const unid = block.getAttribute("data-anchor");
2031
3366
  if (!unid) return;
2032
- let fullId = this.unidToFullId.get(unid);
3367
+ let fullId = this.anchorIdOf(block);
2033
3368
  if (!fullId) return;
2034
3369
  const idx = this.blockIndex(block);
2035
3370
  fullId = this.syncBlock(block, fullId);
2036
3371
  const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, fullId, styleId));
2037
3372
  if (!res.success) return;
2038
3373
  if (this.affectsList(res)) {
2039
- this.remount(idx, false);
3374
+ this.refreshAfter(block, idx, false, true);
2040
3375
  return;
2041
3376
  }
2042
3377
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
2043
3378
  }
2044
- /** Undo the last edit (re-renders the document). */
3379
+ /** Undo the last edit (incremental repaint; falls back to a full re-render). */
2045
3380
  undo() {
2046
3381
  if (this.closed) return;
2047
- if (this.exports.DocxSessionBridge.Undo(this.handle)) this.remount();
3382
+ if (this.exports.DocxSessionBridge.Undo(this.handle)) this.reconcile();
2048
3383
  }
2049
- /** Redo the last undone edit (re-renders the document). */
3384
+ /** Redo the last undone edit (incremental repaint; falls back to a full re-render). */
2050
3385
  redo() {
2051
3386
  if (this.closed) return;
2052
- if (this.exports.DocxSessionBridge.Redo(this.handle)) this.remount();
3387
+ if (this.exports.DocxSessionBridge.Redo(this.handle)) this.reconcile();
3388
+ }
3389
+ // ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
3390
+ /**
3391
+ * Select which story kind a band edits (`"default"` / `"first"` / `"even"`). A kind with no
3392
+ * existing part is created empty, so the band always presents something editable. `"first"`
3393
+ * sets the section's `w:titlePg`; `"even"` sets the document-global `w:evenAndOddHeaders`
3394
+ * (which also governs footers — the band surfaces that caveat inline).
3395
+ */
3396
+ setHeaderFooterKind(which, kind) {
3397
+ this.assertOpen();
3398
+ this.region?.setKind(which, kind);
3399
+ }
3400
+ /** The story kind a band is currently editing, or null when the region is off. */
3401
+ headerFooterKind(which) {
3402
+ return this.region?.kindOf(which) ?? null;
3403
+ }
3404
+ /**
3405
+ * Append a page-number field to the focused header/footer story paragraph (falling back to the
3406
+ * band's last paragraph — Word's convention). No-op outside a band.
3407
+ */
3408
+ insertPageNumber(field = "currentPage") {
3409
+ this.assertOpen();
3410
+ if (!this.region) return;
3411
+ const block = this.activeBlock;
3412
+ const band = block ? this.region.bandOf(block) : null;
3413
+ const anchorId = block?.getAttribute("data-hf-anchor");
3414
+ if (band && anchorId) {
3415
+ this.region.insertPageNumber(this.region.whichOf(band), anchorId, field);
3416
+ return;
3417
+ }
3418
+ this.region.insertPageNumberInBand("footer", field);
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() ?? {};
2053
3444
  }
2054
3445
  /** Which inline formats the current selection carries — for ribbon button highlighting. */
2055
3446
  queryFormatState() {
@@ -2066,15 +3457,20 @@ var DocxodusEditor = (() => {
2066
3457
  }
2067
3458
  /** Re-render one block from the live session by EditResult ref, swapping it in place. */
2068
3459
  swapBlock(oldEl, oldUnid, ref) {
2069
- const anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
3460
+ const inBand = this.isBandBlock(oldEl);
3461
+ const anchorId = ref?.id ?? this.anchorIdOf(oldEl);
2070
3462
  const newUnid = ref?.unid ?? oldUnid;
2071
3463
  if (!anchorId) return null;
2072
3464
  const fresh = this.renderInto(anchorId);
2073
3465
  if (!fresh || !this.replaceNode(oldEl, fresh)) return null;
2074
- this.unidToFullId.delete(oldUnid);
2075
- this.unidToFullId.set(newUnid, anchorId);
3466
+ if (!inBand) {
3467
+ this.unidToFullId.delete(oldUnid);
3468
+ this.unidToFullId.set(newUnid, anchorId);
3469
+ }
3470
+ if (inBand) this.region.adoptBlock(fresh, anchorId);
2076
3471
  this.wireBlock(fresh);
2077
3472
  this.activeBlock = fresh;
3473
+ this.maybeRenumberNotes(fresh);
2078
3474
  this.options.onEdit?.({ anchorId, unid: newUnid });
2079
3475
  return fresh;
2080
3476
  }
@@ -2097,17 +3493,19 @@ var DocxodusEditor = (() => {
2097
3493
  );
2098
3494
  if (html.charCodeAt(0) !== 123) return html;
2099
3495
  }
2100
- const bytes = bridge.Save(this.handle);
3496
+ const bytes = typeof bridge.SaveWithAnchorIds === "function" ? bridge.SaveWithAnchorIds(this.handle) : bridge.Save(this.handle);
2101
3497
  return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(
2102
3498
  ...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale)
2103
3499
  );
2104
3500
  }
2105
- /** Editable blocks in document order. */
3501
+ /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
2106
3502
  editableList() {
2107
3503
  return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
2108
3504
  }
2109
3505
  blockIndex(el) {
2110
- return this.editableList().indexOf(el);
3506
+ return Array.from(
3507
+ this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')
3508
+ ).indexOf(el);
2111
3509
  }
2112
3510
  /**
2113
3511
  * True when an edit produced or touched a list item (kind "li"). List markers and
@@ -2117,6 +3515,358 @@ var DocxodusEditor = (() => {
2117
3515
  affectsList(res) {
2118
3516
  return [...res.modified ?? [], ...res.created ?? []].some((r) => r.kind === "li");
2119
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
+ }
2120
3870
  /**
2121
3871
  * Full re-render from current session state (after undo/redo, and after list edits where
2122
3872
  * single-block rendering can't compute numbering). Optionally focus the editable block at
@@ -2137,6 +3887,7 @@ var DocxodusEditor = (() => {
2137
3887
  placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
2138
3888
  }
2139
3889
  }
3890
+ this.syncRegionToBody(this.activeBlock ?? void 0);
2140
3891
  }
2141
3892
  };
2142
3893
  return __toCommonJS(editor_exports);