docxodus 7.0.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/docxodus.worker.js +2 -2
  2. package/dist/docxodus.worker.js.map +1 -1
  3. package/dist/editor-headerfooter.d.ts +130 -0
  4. package/dist/editor-headerfooter.d.ts.map +1 -0
  5. package/dist/editor-headerfooter.js +414 -0
  6. package/dist/editor-headerfooter.js.map +1 -0
  7. package/dist/editor.bundle.js +1075 -81
  8. package/dist/editor.d.ts +82 -4
  9. package/dist/editor.d.ts.map +1 -1
  10. package/dist/editor.js +304 -63
  11. package/dist/editor.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/pagination.bundle.js +480 -19
  16. package/dist/pagination.d.ts +81 -0
  17. package/dist/pagination.d.ts.map +1 -1
  18. package/dist/pagination.js +528 -26
  19. package/dist/pagination.js.map +1 -1
  20. package/dist/react.d.ts +3 -1
  21. package/dist/react.d.ts.map +1 -1
  22. package/dist/react.js +6 -4
  23. package/dist/react.js.map +1 -1
  24. package/dist/session.bundle.js +41 -0
  25. package/dist/session.d.ts +33 -1
  26. package/dist/session.d.ts.map +1 -1
  27. package/dist/session.js +41 -0
  28. package/dist/session.js.map +1 -1
  29. package/dist/types.d.ts +59 -10
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/types.js +9 -11
  32. package/dist/types.js.map +1 -1
  33. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  34. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  35. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  36. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  37. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  38. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  39. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  40. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  41. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  42. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  43. package/dist/wasm/_framework/System.Console.wasm +0 -0
  44. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  45. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  46. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  47. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  48. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  49. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  50. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  51. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  52. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  53. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  54. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  55. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  56. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  57. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  58. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  59. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  60. package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
  61. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  62. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  63. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  64. package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
  65. package/dist/wasm/_framework/System.Threading.wasm +0 -0
  66. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  67. package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
  68. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  69. package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
  70. package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
  71. package/dist/wasm/_framework/System.wasm +0 -0
  72. package/dist/wasm/_framework/dotnet.boot.js +41 -41
  73. package/dist/wasm/_framework/dotnet.js +1 -1
  74. package/dist/wasm/_framework/dotnet.js.map +1 -1
  75. package/dist/wasm/_framework/dotnet.native.js +3 -3
  76. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  77. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  78. package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
  79. package/dist/wasm/index.html +2 -1
  80. package/package.json +1 -1
@@ -84,6 +84,7 @@ var DocxodusEditor = (() => {
84
84
  this.cssPrefix = options.cssPrefix ?? "page-";
85
85
  this.showPageNumbers = options.showPageNumbers ?? true;
86
86
  this.pageGap = options.pageGap ?? 20;
87
+ this.fragmentParagraphs = options.fragmentParagraphs ?? false;
87
88
  this.hfRegistry = /* @__PURE__ */ new Map();
88
89
  this.footnoteRegistry = /* @__PURE__ */ new Map();
89
90
  }
@@ -150,6 +151,393 @@ var DocxodusEditor = (() => {
150
151
  }
151
152
  return blocks;
152
153
  }
154
+ /**
155
+ * Measures one element in the same hidden staging context used for the source blocks.
156
+ * This is intentionally DOM-based: table row heights cannot be inferred from individual
157
+ * rows because wrapping and collapsed borders change the height of a fragment.
158
+ */
159
+ measureElement(element, dims) {
160
+ const measurementHost = document.createElement("div");
161
+ measurementHost.style.position = "absolute";
162
+ measurementHost.style.visibility = "hidden";
163
+ measurementHost.style.left = "-9999px";
164
+ measurementHost.style.width = `${dims.contentWidth}pt`;
165
+ const measuredElement = element.cloneNode(true);
166
+ measurementHost.appendChild(measuredElement);
167
+ this.stagingElement.appendChild(measurementHost);
168
+ const rect = measuredElement.getBoundingClientRect();
169
+ const style = window.getComputedStyle(measuredElement);
170
+ const measured = {
171
+ element,
172
+ heightPt: pxToPt(rect.height),
173
+ marginTopPt: pxToPt(parseFloat(style.marginTop) || 0),
174
+ marginBottomPt: pxToPt(parseFloat(style.marginBottom) || 0),
175
+ keepWithNext: element.dataset.keepWithNext === "true",
176
+ keepLines: element.dataset.keepLines === "true",
177
+ pageBreakBefore: element.dataset.pageBreakBefore === "true",
178
+ isPageBreak: element.dataset.pageBreak === "true" || element.classList.contains(`${this.cssPrefix}break`)
179
+ };
180
+ this.stagingElement.removeChild(measurementHost);
181
+ return measured;
182
+ }
183
+ /**
184
+ * Returns the contiguous keep-with-next chain beginning at a block.
185
+ *
186
+ * A hard page break or a page-break-before directive is stronger than a
187
+ * keep-with-next directive, so it terminates the chain. The caller only
188
+ * keeps a chain together when the whole chain can fit on a fresh page.
189
+ */
190
+ getKeepWithNextChain(blocks, startIndex) {
191
+ const firstBlock = blocks[startIndex];
192
+ if (!firstBlock) return [];
193
+ const chain = [firstBlock];
194
+ let lastIndex = startIndex;
195
+ while (blocks[lastIndex].keepWithNext) {
196
+ const nextBlock = blocks[lastIndex + 1];
197
+ if (!nextBlock || nextBlock.isPageBreak || nextBlock.pageBreakBefore) {
198
+ break;
199
+ }
200
+ chain.push(nextBlock);
201
+ lastIndex++;
202
+ }
203
+ return chain;
204
+ }
205
+ /**
206
+ * Measures the visible body height of a keep-with-next chain using the same
207
+ * collapsed-margin rules as normal block placement. The trailing margin is
208
+ * intentionally excluded, matching the individual block fit check.
209
+ */
210
+ measureKeepWithNextChainBodyHeight(chain, previousMarginBottomPt, isFirstOnPage) {
211
+ const firstBlock = chain[0];
212
+ if (!firstBlock) return 0;
213
+ const firstMarginTop = isFirstOnPage ? firstBlock.marginTopPt : Math.max(firstBlock.marginTopPt, previousMarginBottomPt) - previousMarginBottomPt;
214
+ let bodyHeight = firstMarginTop + firstBlock.heightPt;
215
+ for (let index = 1; index < chain.length; index++) {
216
+ const previousBlock = chain[index - 1];
217
+ const block = chain[index];
218
+ bodyHeight += Math.max(previousBlock.marginBottomPt, block.marginTopPt) + block.heightPt;
219
+ }
220
+ return bodyHeight;
221
+ }
222
+ /**
223
+ * Finds footnote references introduced by a sequence of blocks, preserving
224
+ * document order and excluding references already assigned to the page.
225
+ */
226
+ collectNewFootnoteIds(blocks, existingFootnoteIds) {
227
+ const knownIds = new Set(existingFootnoteIds);
228
+ const newIds = [];
229
+ for (const block of blocks) {
230
+ for (const id of this.extractFootnoteRefs(block.element)) {
231
+ if (!knownIds.has(id)) {
232
+ knownIds.add(id);
233
+ newIds.push(id);
234
+ }
235
+ }
236
+ }
237
+ return newIds;
238
+ }
239
+ /**
240
+ * The shortest body available to a section's first, default, or even page.
241
+ * A row fragment must fit every variant, otherwise a later header/footer could
242
+ * send it through the oversized-block fallback again.
243
+ */
244
+ smallestEffectiveContentHeight(dims, sectionIndex) {
245
+ return Math.min(
246
+ this.getEffectiveHeights(dims, sectionIndex, 1, 1).contentHeight,
247
+ this.getEffectiveHeights(dims, sectionIndex, 2, 1).contentHeight,
248
+ this.getEffectiveHeights(dims, sectionIndex, 2, 2).contentHeight
249
+ );
250
+ }
251
+ /**
252
+ * Builds a clone of a simple table wrapper containing a contiguous run of rows.
253
+ * Complex table features are deliberately rejected by the caller: a split across
254
+ * merged cells, nested tables, or footnotes cannot be made correct by cloning rows.
255
+ */
256
+ createSimpleTableFragment(wrapper, table, body, rows, retainAnchor) {
257
+ const wrapperClone = wrapper.cloneNode(false);
258
+ const tableClone = table.cloneNode(false);
259
+ for (const child of Array.from(table.children)) {
260
+ if (child !== body) {
261
+ tableClone.appendChild(child.cloneNode(true));
262
+ }
263
+ }
264
+ const bodyClone = body.cloneNode(false);
265
+ for (const row of rows) {
266
+ bodyClone.appendChild(row.cloneNode(true));
267
+ }
268
+ tableClone.appendChild(bodyClone);
269
+ if (!retainAnchor) {
270
+ wrapperClone.removeAttribute("data-anchor");
271
+ tableClone.removeAttribute("data-anchor");
272
+ }
273
+ wrapperClone.appendChild(tableClone);
274
+ return wrapperClone;
275
+ }
276
+ /**
277
+ * Splits an oversized, ordinary table at row boundaries. This only participates
278
+ * in the existing oversized-block fallback; unsupported tables keep the previous
279
+ * overflow behavior rather than risking broken table semantics.
280
+ */
281
+ trySplitSimpleOversizedTable(block, dims, sectionIndex) {
282
+ const wrapper = block.element;
283
+ if (wrapper.tagName !== "DIV" || wrapper.children.length !== 1 || block.keepWithNext || block.keepLines || block.pageBreakBefore || block.isPageBreak) {
284
+ return null;
285
+ }
286
+ const table = wrapper.firstElementChild;
287
+ if (!(table instanceof HTMLTableElement)) {
288
+ return null;
289
+ }
290
+ const body = table.tBodies.length === 1 ? table.tBodies[0] : null;
291
+ 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]")) {
292
+ return null;
293
+ }
294
+ const rows = Array.from(body.rows);
295
+ const minimumContentHeight = this.smallestEffectiveContentHeight(dims, sectionIndex);
296
+ const maximumFragmentHeight = minimumContentHeight - block.marginTopPt - block.marginBottomPt;
297
+ if (maximumFragmentHeight <= 0) {
298
+ return null;
299
+ }
300
+ const groups = [];
301
+ let start = 0;
302
+ while (start < rows.length) {
303
+ let end = start;
304
+ while (end < rows.length) {
305
+ const candidate = this.createSimpleTableFragment(
306
+ wrapper,
307
+ table,
308
+ body,
309
+ rows.slice(start, end + 1),
310
+ start === 0
311
+ );
312
+ const measured = this.measureElement(candidate, dims);
313
+ if (measured.heightPt > maximumFragmentHeight) {
314
+ break;
315
+ }
316
+ end++;
317
+ }
318
+ if (end === start) {
319
+ return null;
320
+ }
321
+ groups.push(rows.slice(start, end));
322
+ start = end;
323
+ }
324
+ if (groups.length < 2) {
325
+ return null;
326
+ }
327
+ const fragments = [];
328
+ for (let index = 0; index < groups.length; index++) {
329
+ const isFirst = index === 0;
330
+ const isLast = index === groups.length - 1;
331
+ const fragment = this.createSimpleTableFragment(
332
+ wrapper,
333
+ table,
334
+ body,
335
+ groups[index],
336
+ isFirst
337
+ );
338
+ if (!isFirst) {
339
+ fragment.style.setProperty("margin-top", "0", "important");
340
+ }
341
+ if (!isLast) {
342
+ fragment.style.setProperty("margin-bottom", "0", "important");
343
+ }
344
+ const measured = this.measureElement(fragment, dims);
345
+ if (measured.heightPt + measured.marginTopPt + measured.marginBottomPt > minimumContentHeight) {
346
+ return null;
347
+ }
348
+ fragments.push({
349
+ ...measured,
350
+ keepWithNext: false,
351
+ keepLines: false,
352
+ pageBreakBefore: false,
353
+ isPageBreak: false
354
+ });
355
+ }
356
+ return fragments;
357
+ }
358
+ /**
359
+ * A DOM endpoint that can finish a paragraph fragment. Endpoints are chosen
360
+ * after whitespace or at a run boundary so the paginator never deliberately
361
+ * cuts through an ordinary word merely to fill a little more of a page.
362
+ */
363
+ paragraphFragmentEndpoints(paragraph) {
364
+ const endpoints = [];
365
+ const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
366
+ let textNode;
367
+ while (textNode = walker.nextNode()) {
368
+ const text = textNode.data;
369
+ if (text.length === 0) continue;
370
+ const whitespace = /\s+/g;
371
+ let match;
372
+ while ((match = whitespace.exec(text)) !== null) {
373
+ endpoints.push({ node: textNode, offset: match.index + match[0].length });
374
+ }
375
+ if (endpoints.length === 0 || endpoints[endpoints.length - 1].node !== textNode || endpoints[endpoints.length - 1].offset !== text.length) {
376
+ endpoints.push({ node: textNode, offset: text.length });
377
+ }
378
+ }
379
+ return endpoints;
380
+ }
381
+ /**
382
+ * Whether a range contains visible text after ignoring bidi/zero-width marks.
383
+ * Paragraph fragmentation deliberately excludes non-textual descendants, so
384
+ * this is enough to reject empty head or tail fragments.
385
+ */
386
+ hasVisibleFragmentText(fragment) {
387
+ return (fragment.textContent || "").replace(/[\u200B-\u200F\uFEFF]/g, "").trim().length > 0;
388
+ }
389
+ /**
390
+ * Phase one only handles text-like paragraph descendants. Objects, explicit
391
+ * line breaks, list markers, notes, and out-of-flow/inline-block content all
392
+ * require their own line-layout rules and retain the established whole-block
393
+ * fallback instead of risking broken content or duplicate anchors.
394
+ */
395
+ canFragmentParagraph(block) {
396
+ if (!this.fragmentParagraphs || block.element.tagName !== "P") {
397
+ return false;
398
+ }
399
+ const paragraph = block.element;
400
+ if (block.keepWithNext || block.keepLines || block.pageBreakBefore || block.isPageBreak || paragraph.dataset.widowControl === "true" || paragraph.hasAttribute("contenteditable")) {
401
+ return false;
402
+ }
403
+ const unsupportedDescendants = [
404
+ "br",
405
+ "img",
406
+ "picture",
407
+ "svg",
408
+ "math",
409
+ "canvas",
410
+ "video",
411
+ "audio",
412
+ "iframe",
413
+ "object",
414
+ "embed",
415
+ "input",
416
+ "button",
417
+ "select",
418
+ "textarea",
419
+ "table",
420
+ "ol",
421
+ "ul",
422
+ "li",
423
+ "dl",
424
+ "div",
425
+ "p",
426
+ "section",
427
+ "article",
428
+ "aside",
429
+ "figure",
430
+ "fieldset",
431
+ "details",
432
+ "[data-footnote-id]",
433
+ "[data-list-marker]",
434
+ "[data-anchor]",
435
+ "[id]",
436
+ "[contenteditable]"
437
+ ].join(", ");
438
+ if (paragraph.querySelector(unsupportedDescendants)) {
439
+ return false;
440
+ }
441
+ const paragraphStyle = window.getComputedStyle(paragraph);
442
+ 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") {
443
+ return false;
444
+ }
445
+ for (const descendant of Array.from(paragraph.querySelectorAll("*"))) {
446
+ const style = window.getComputedStyle(descendant);
447
+ if (style.display !== "inline" || style.position !== "static" || style.float !== "none" || style.whiteSpace !== "normal") {
448
+ return false;
449
+ }
450
+ }
451
+ return true;
452
+ }
453
+ /**
454
+ * Builds one range-cloned paragraph fragment. Only the leading fragment keeps
455
+ * the source paragraph's addressability; continuations must not duplicate an
456
+ * id/data-anchor in the rendered document.
457
+ */
458
+ createParagraphFragment(paragraph, range, retainSourceIdentity, isFinalFragment) {
459
+ const fragment = paragraph.cloneNode(false);
460
+ const contents = range.cloneContents();
461
+ fragment.appendChild(contents);
462
+ if (!retainSourceIdentity) {
463
+ fragment.removeAttribute("id");
464
+ fragment.removeAttribute("data-anchor");
465
+ fragment.style.setProperty("margin-top", "0", "important");
466
+ fragment.style.setProperty("text-indent", "0", "important");
467
+ }
468
+ if (!isFinalFragment) {
469
+ fragment.style.setProperty("margin-bottom", "0", "important");
470
+ }
471
+ return fragment;
472
+ }
473
+ /**
474
+ * Splits a simple paragraph at the largest DOM Range endpoint that fits the
475
+ * currently available body space. The caller then processes the tail normally,
476
+ * allowing it to fragment again on later pages when necessary.
477
+ */
478
+ tryFragmentParagraph(block, dims, availableHeightPt, effectiveMarginTopPt) {
479
+ if (!this.canFragmentParagraph(block) || availableHeightPt <= effectiveMarginTopPt) {
480
+ return null;
481
+ }
482
+ const paragraph = block.element;
483
+ const endpoints = this.paragraphFragmentEndpoints(paragraph);
484
+ if (endpoints.length < 2) {
485
+ return null;
486
+ }
487
+ let low = 0;
488
+ let high = endpoints.length - 2;
489
+ let best = null;
490
+ while (low <= high) {
491
+ const middle = Math.floor((low + high) / 2);
492
+ const endpoint = endpoints[middle];
493
+ const headRange = document.createRange();
494
+ headRange.setStart(paragraph, 0);
495
+ headRange.setEnd(endpoint.node, endpoint.offset);
496
+ const headContents = headRange.cloneContents();
497
+ if (!this.hasVisibleFragmentText(headContents)) {
498
+ low = middle + 1;
499
+ continue;
500
+ }
501
+ const head = this.createParagraphFragment(paragraph, headRange, true, false);
502
+ const measured = this.measureElement(head, dims);
503
+ if (effectiveMarginTopPt + measured.heightPt <= availableHeightPt) {
504
+ best = { endpoint, element: head, measured };
505
+ low = middle + 1;
506
+ } else {
507
+ high = middle - 1;
508
+ }
509
+ }
510
+ if (!best) {
511
+ return null;
512
+ }
513
+ const tailRange = document.createRange();
514
+ tailRange.setStart(best.endpoint.node, best.endpoint.offset);
515
+ tailRange.setEnd(paragraph, paragraph.childNodes.length);
516
+ const tailContents = tailRange.cloneContents();
517
+ if (!this.hasVisibleFragmentText(tailContents)) {
518
+ return null;
519
+ }
520
+ const tail = this.createParagraphFragment(paragraph, tailRange, false, true);
521
+ const tailMeasured = this.measureElement(tail, dims);
522
+ return [
523
+ {
524
+ ...best.measured,
525
+ element: best.element,
526
+ keepWithNext: false,
527
+ keepLines: false,
528
+ pageBreakBefore: false,
529
+ isPageBreak: false
530
+ },
531
+ {
532
+ ...tailMeasured,
533
+ element: tail,
534
+ keepWithNext: false,
535
+ keepLines: false,
536
+ pageBreakBefore: false,
537
+ isPageBreak: false
538
+ }
539
+ ];
540
+ }
153
541
  /**
154
542
  * Parses the header/footer registry from the staging element.
155
543
  * Also measures heights during parsing for lazy-loading compatibility.
@@ -548,7 +936,8 @@ var DocxodusEditor = (() => {
548
936
  currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
549
937
  }
550
938
  const finishPage = () => {
551
- if (currentContent.length === 0 && !currentContinuation) return;
939
+ const hasCurrentContinuation = (currentContinuation?.remainingElements.length ?? 0) > 0;
940
+ if (currentContent.length === 0 && !hasCurrentContinuation) return;
552
941
  const page = this.createPage(
553
942
  dims,
554
943
  pageNumber,
@@ -580,7 +969,6 @@ var DocxodusEditor = (() => {
580
969
  };
581
970
  for (let i = 0; i < blocks.length; i++) {
582
971
  const block = blocks[i];
583
- const nextBlock = blocks[i + 1];
584
972
  if (block.isPageBreak) {
585
973
  finishPage();
586
974
  continue;
@@ -588,8 +976,59 @@ var DocxodusEditor = (() => {
588
976
  if (block.pageBreakBefore && currentContent.length > 0) {
589
977
  finishPage();
590
978
  }
591
- const blockFootnoteIds = this.extractFootnoteRefs(block.element);
592
- const newFootnoteIds = blockFootnoteIds.filter((id) => !currentFootnoteIds.includes(id));
979
+ const previousBlock = blocks[i - 1];
980
+ const startsKeepChain = !previousBlock || !previousBlock.keepWithNext || previousBlock.isPageBreak || block.pageBreakBefore;
981
+ const pageHasOccupiedSpace = currentContent.length > 0 || currentFootnoteHeight > 0;
982
+ if (pageHasOccupiedSpace && block.keepWithNext && startsKeepChain) {
983
+ const keepChain = this.getKeepWithNextChain(blocks, i);
984
+ if (keepChain.length > 1) {
985
+ const newChainFootnoteIds = this.collectNewFootnoteIds(
986
+ keepChain,
987
+ currentFootnoteIds
988
+ );
989
+ let additionalChainFootnoteHeight = 0;
990
+ if (newChainFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
991
+ const totalChainFootnoteHeight = this.measureFootnotesHeight(
992
+ [...currentFootnoteIds, ...newChainFootnoteIds],
993
+ dims.contentWidth,
994
+ currentContinuation
995
+ );
996
+ additionalChainFootnoteHeight = Math.max(
997
+ 0,
998
+ totalChainFootnoteHeight - currentFootnoteHeight
999
+ );
1000
+ }
1001
+ const currentChainHeight = this.measureKeepWithNextChainBodyHeight(
1002
+ keepChain,
1003
+ prevMarginBottomPt,
1004
+ currentContent.length === 0
1005
+ ) + additionalChainFootnoteHeight;
1006
+ const currentAvailableHeight = remainingHeight - currentFootnoteHeight;
1007
+ if (currentChainHeight > currentAvailableHeight) {
1008
+ const nextPageHeights = this.getEffectiveHeights(
1009
+ dims,
1010
+ sectionIndex,
1011
+ pageInSection + 1,
1012
+ pageNumber + 1
1013
+ );
1014
+ const freshChainBodyHeight = this.measureKeepWithNextChainBodyHeight(
1015
+ keepChain,
1016
+ 0,
1017
+ true
1018
+ );
1019
+ const freshChainFootnoteHeight = this.measureFootnotesHeight(
1020
+ newChainFootnoteIds,
1021
+ dims.contentWidth,
1022
+ nextPageContinuation
1023
+ );
1024
+ if (freshChainBodyHeight + freshChainFootnoteHeight <= nextPageHeights.contentHeight) {
1025
+ finishPage();
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ const allBlockFootnoteIds = this.extractFootnoteRefs(block.element);
1031
+ const newFootnoteIds = this.collectNewFootnoteIds([block], currentFootnoteIds);
593
1032
  let additionalFootnoteHeight = 0;
594
1033
  if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
595
1034
  const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
@@ -606,15 +1045,23 @@ var DocxodusEditor = (() => {
606
1045
  effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt;
607
1046
  }
608
1047
  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
1048
  const effectiveRemainingHeight = remainingHeight - currentFootnoteHeight;
615
1049
  const bodyContentUsed = effectiveContentHeight - remainingHeight;
616
1050
  const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
617
1051
  const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
1052
+ if (blockSpace > effectiveRemainingHeight) {
1053
+ const paragraphFragments = this.tryFragmentParagraph(
1054
+ block,
1055
+ dims,
1056
+ effectiveRemainingHeight,
1057
+ effectiveMarginTop
1058
+ );
1059
+ if (paragraphFragments) {
1060
+ blocks.splice(i, 1, ...paragraphFragments);
1061
+ i--;
1062
+ continue;
1063
+ }
1064
+ }
618
1065
  if (blockSpace <= effectiveRemainingHeight) {
619
1066
  currentContent.push(block.element.cloneNode(true));
620
1067
  remainingHeight -= effectiveMarginTop + block.heightPt + block.marginBottomPt;
@@ -656,10 +1103,12 @@ var DocxodusEditor = (() => {
656
1103
  footnoteId,
657
1104
  fittingElements: fits
658
1105
  });
659
- nextPageContinuation = {
660
- footnoteId,
661
- remainingElements: overflow
662
- };
1106
+ if (overflow.length > 0) {
1107
+ nextPageContinuation = {
1108
+ footnoteId,
1109
+ remainingElements: overflow
1110
+ };
1111
+ }
663
1112
  currentFootnoteHeight = availableForFootnotes;
664
1113
  } else {
665
1114
  nextPageContinuation = {
@@ -692,30 +1141,39 @@ var DocxodusEditor = (() => {
692
1141
  currentFootnoteHeight = expandedFootnoteSpace;
693
1142
  } else {
694
1143
  finishPage();
695
- const newPageFootnoteHeight = blockFootnoteIds.length > 0 ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
1144
+ const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
696
1145
  const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
697
1146
  currentContent.push(block.element.cloneNode(true));
698
1147
  remainingHeight = effectiveContentHeight - newPageSpace;
699
1148
  prevMarginBottomPt = block.marginBottomPt;
700
- currentFootnoteIds = [...blockFootnoteIds];
1149
+ currentFootnoteIds = [...allBlockFootnoteIds];
701
1150
  currentFootnoteHeight = newPageFootnoteHeight;
702
1151
  }
703
1152
  } else {
704
1153
  finishPage();
705
- const newPageFootnoteHeight = blockFootnoteIds.length > 0 ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
1154
+ const newPageFootnoteHeight = allBlockFootnoteIds.length > 0 ? this.measureFootnotesHeight(allBlockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
706
1155
  const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
707
1156
  currentContent.push(block.element.cloneNode(true));
708
1157
  remainingHeight = effectiveContentHeight - newPageSpace;
709
1158
  prevMarginBottomPt = block.marginBottomPt;
710
- currentFootnoteIds = [...blockFootnoteIds];
1159
+ currentFootnoteIds = [...allBlockFootnoteIds];
711
1160
  currentFootnoteHeight = newPageFootnoteHeight;
712
1161
  }
713
1162
  } else {
1163
+ const tableFragments = this.trySplitSimpleOversizedTable(block, dims, sectionIndex);
1164
+ if (tableFragments) {
1165
+ if (currentContent.length > 0 || currentContinuation) {
1166
+ finishPage();
1167
+ }
1168
+ blocks.splice(i, 1, ...tableFragments);
1169
+ i--;
1170
+ continue;
1171
+ }
714
1172
  if (currentContent.length > 0) {
715
1173
  finishPage();
716
1174
  }
717
1175
  currentContent.push(block.element.cloneNode(true));
718
- currentFootnoteIds = [...blockFootnoteIds];
1176
+ currentFootnoteIds = [...allBlockFootnoteIds];
719
1177
  finishPage();
720
1178
  }
721
1179
  }
@@ -839,10 +1297,360 @@ var DocxodusEditor = (() => {
839
1297
  if (!pageContainer) {
840
1298
  throw new Error("Pagination container element not found");
841
1299
  }
842
- const engine = new PaginationEngine(staging, pageContainer, options);
1300
+ const engine = new PaginationEngine(staging, pageContainer, {
1301
+ ...options,
1302
+ fragmentParagraphs: options.fragmentParagraphs ?? true
1303
+ });
843
1304
  return engine.paginate();
844
1305
  }
845
1306
 
1307
+ // src/editor-headerfooter.ts
1308
+ var KIND_LABELS = [
1309
+ { value: "default", label: "Default" },
1310
+ { value: "first", label: "First page" },
1311
+ { value: "even", label: "Even pages" }
1312
+ ];
1313
+ 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.";
1314
+ 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.";
1315
+ var STORY_BLOCK_KINDS = /* @__PURE__ */ new Set(["p", "h", "li"]);
1316
+ var HeaderFooterRegion = class {
1317
+ constructor(bridge, handle, options, callbacks) {
1318
+ /** The body anchor whose section the bands currently describe (needed to seed a story). */
1319
+ this.bodyAnchorId = null;
1320
+ this.sectionInfo = null;
1321
+ /** Memoized body anchor → governing sectionUnid, so repeat focus costs no bridge call. */
1322
+ this.sectionOfAnchor = /* @__PURE__ */ new Map();
1323
+ this.kinds = {
1324
+ header: "default",
1325
+ footer: "default"
1326
+ };
1327
+ this.bridge = bridge;
1328
+ this.handle = handle;
1329
+ this.options = options;
1330
+ this.callbacks = callbacks;
1331
+ this.headerBand = this.buildBand("header");
1332
+ this.footerBand = this.buildBand("footer");
1333
+ }
1334
+ // ─── public surface ──────────────────────────────────────────────────
1335
+ /**
1336
+ * Point the bands at the section governing `bodyAnchorId` and repaint if it changed.
1337
+ * Cheap to call on every focus change: the anchor → section lookup is memoized, and an
1338
+ * unchanged `sectionUnid` returns without touching the DOM (so it never clobbers the
1339
+ * user's kind selection).
1340
+ */
1341
+ syncToBody(bodyAnchorId) {
1342
+ if (!bodyAnchorId) return;
1343
+ const cached = this.sectionOfAnchor.get(bodyAnchorId);
1344
+ if (cached !== void 0 && cached === this.sectionInfo?.sectionUnid) {
1345
+ this.bodyAnchorId = bodyAnchorId;
1346
+ return;
1347
+ }
1348
+ const info = this.readSectionInfo(bodyAnchorId);
1349
+ if (!info) return;
1350
+ this.sectionOfAnchor.set(bodyAnchorId, info.sectionUnid);
1351
+ const changed = info.sectionUnid !== this.sectionInfo?.sectionUnid;
1352
+ this.bodyAnchorId = bodyAnchorId;
1353
+ this.sectionInfo = info;
1354
+ if (changed) this.refreshAll();
1355
+ }
1356
+ /** Repaint one band from the live session. */
1357
+ refresh(which) {
1358
+ this.sectionInfo = this.bodyAnchorId ? this.readSectionInfo(this.bodyAnchorId) : null;
1359
+ this.renderBand(which);
1360
+ }
1361
+ /** Repaint both bands from the live session (after a remount, undo/redo, or section change). */
1362
+ refreshAll() {
1363
+ this.renderBand("header");
1364
+ this.renderBand("footer");
1365
+ }
1366
+ /** Select (and, if absent, create) the story kind a band edits, and make it render. */
1367
+ setKind(which, kind) {
1368
+ this.kinds[which] = kind;
1369
+ const select = this.bandFor(which).querySelector("[data-hf-kind]");
1370
+ if (select && select.value !== kind) select.value = kind;
1371
+ if (!this.partUriFor(which, kind)) this.seedStory(which, kind);
1372
+ if (this.bodyAnchorId && kind !== "default") {
1373
+ this.bridge.EnsureHeaderFooterVisible(this.handle, this.bodyAnchorId, kind);
1374
+ }
1375
+ this.refresh(which);
1376
+ }
1377
+ /** The kind a band is currently editing. */
1378
+ kindOf(which) {
1379
+ return this.kinds[which];
1380
+ }
1381
+ /** Append a PAGE / NUMPAGES field to the story paragraph addressed by `anchorId`. */
1382
+ insertPageNumber(which, anchorId, field) {
1383
+ const res = parseResult(this.bridge.InsertPageNumberField(this.handle, anchorId, field));
1384
+ if (!res.success) return false;
1385
+ this.refresh(which);
1386
+ return true;
1387
+ }
1388
+ /** Insert a page number into a band's own target paragraph (focused, else last). Seeds the
1389
+ * story first if the band's selected kind has none, so the command is never a silent no-op. */
1390
+ insertPageNumberInBand(which, field) {
1391
+ if (!this.partUriFor(which, this.kinds[which])) {
1392
+ this.seedStory(which, this.kinds[which]);
1393
+ this.renderBand(which);
1394
+ }
1395
+ const target = this.pageNumberTarget(which);
1396
+ return target ? this.insertPageNumber(which, target, field) : false;
1397
+ }
1398
+ /** The band element containing `node`, or null. */
1399
+ bandOf(node) {
1400
+ const el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
1401
+ const band = el?.closest("[data-hf-band]") ?? null;
1402
+ return band === this.headerBand || band === this.footerBand ? band : null;
1403
+ }
1404
+ /** The block-list root (a band's story container) owning `node`, or null. */
1405
+ blockRootOf(node) {
1406
+ const band = this.bandOf(node);
1407
+ return band ? band.querySelector("[data-hf-body]") : null;
1408
+ }
1409
+ /** True when `node` is inside either band (including its chrome). */
1410
+ contains(node) {
1411
+ return this.bandOf(node) !== null;
1412
+ }
1413
+ /** `"header"` / `"footer"` for a band element produced by this region. */
1414
+ whichOf(band) {
1415
+ return band === this.footerBand ? "footer" : "header";
1416
+ }
1417
+ // ─── section + anchor resolution ─────────────────────────────────────
1418
+ readSectionInfo(bodyAnchorId) {
1419
+ try {
1420
+ const raw = this.bridge.GetSectionInfo(this.handle, bodyAnchorId);
1421
+ const parsed = JSON.parse(raw);
1422
+ return parsed && typeof parsed.sectionUnid === "string" ? parsed : null;
1423
+ } catch {
1424
+ return null;
1425
+ }
1426
+ }
1427
+ refsFor(which) {
1428
+ const info = this.sectionInfo;
1429
+ if (!info) return [];
1430
+ return (which === "header" ? info.headerRefs : info.footerRefs) ?? [];
1431
+ }
1432
+ /** The reference supplying `kind` for this band — possibly inherited from an earlier section. */
1433
+ refFor(which, kind) {
1434
+ return this.refsFor(which).find((r) => r.kind === kind) ?? null;
1435
+ }
1436
+ /** URI of the part supplying `kind` for this band, or null when the story doesn't exist. */
1437
+ partUriFor(which, kind) {
1438
+ return this.refFor(which, kind)?.partUri ?? null;
1439
+ }
1440
+ /** Full anchor ids of the story paragraphs held in `partUri`, in document order. */
1441
+ storyAnchors(partUri) {
1442
+ let index;
1443
+ try {
1444
+ index = JSON.parse(this.bridge.Project(this.handle)).anchorIndex;
1445
+ } catch {
1446
+ return [];
1447
+ }
1448
+ return Object.entries(index).filter(([, t]) => t.partUri === partUri && STORY_BLOCK_KINDS.has(t.kind)).map(([id]) => id);
1449
+ }
1450
+ /**
1451
+ * Create an empty story for `kind`. Seeding happens the moment a kind is selected rather than
1452
+ * lazily on first keystroke: an absent story renders no contenteditable element, so there
1453
+ * would be nothing to click into.
1454
+ */
1455
+ seedStory(which, kind) {
1456
+ if (!this.bodyAnchorId) return;
1457
+ const set = which === "header" ? this.bridge.SetHeaderText : this.bridge.SetFooterText;
1458
+ const res = parseResult(set(this.handle, this.bodyAnchorId, kind, ""));
1459
+ if (!res.success) return;
1460
+ this.callbacks.refreshAnchorMap();
1461
+ this.sectionInfo = this.readSectionInfo(this.bodyAnchorId);
1462
+ }
1463
+ // ─── rendering ───────────────────────────────────────────────────────
1464
+ bandFor(which) {
1465
+ return which === "header" ? this.headerBand : this.footerBand;
1466
+ }
1467
+ buildBand(which) {
1468
+ const band = document.createElement("div");
1469
+ band.className = "docx-hf-band";
1470
+ band.setAttribute("data-hf-band", which);
1471
+ const chrome = document.createElement("div");
1472
+ chrome.className = "docx-hf-chrome";
1473
+ const label = document.createElement("span");
1474
+ label.className = "docx-hf-label";
1475
+ label.textContent = which === "header" ? "Header" : "Footer";
1476
+ chrome.appendChild(label);
1477
+ const kindSelect = document.createElement("select");
1478
+ kindSelect.setAttribute("data-hf-kind", "");
1479
+ kindSelect.title = "Which header/footer story to edit";
1480
+ for (const k of KIND_LABELS) {
1481
+ const opt = document.createElement("option");
1482
+ opt.value = k.value;
1483
+ opt.textContent = k.label;
1484
+ kindSelect.appendChild(opt);
1485
+ }
1486
+ kindSelect.addEventListener(
1487
+ "change",
1488
+ () => this.setKind(which, kindSelect.value)
1489
+ );
1490
+ chrome.appendChild(kindSelect);
1491
+ const pageNum = document.createElement("select");
1492
+ pageNum.setAttribute("data-hf-pagenum", "");
1493
+ pageNum.title = "Insert a page-number field into the focused story paragraph";
1494
+ for (const o of [
1495
+ { value: "", label: "Page \u2116\u2026" },
1496
+ { value: "currentPage", label: "Page number" },
1497
+ { value: "totalPages", label: "Total pages" }
1498
+ ]) {
1499
+ const opt = document.createElement("option");
1500
+ opt.value = o.value;
1501
+ opt.textContent = o.label;
1502
+ pageNum.appendChild(opt);
1503
+ }
1504
+ pageNum.addEventListener("change", () => {
1505
+ const field = pageNum.value;
1506
+ pageNum.value = "";
1507
+ if (field !== "currentPage" && field !== "totalPages") return;
1508
+ const target = this.pageNumberTarget(which);
1509
+ if (target) this.insertPageNumber(which, target, field);
1510
+ });
1511
+ chrome.appendChild(pageNum);
1512
+ const inheritedNote = document.createElement("span");
1513
+ inheritedNote.className = "docx-hf-inherited";
1514
+ inheritedNote.setAttribute("data-hf-inherited-note", "");
1515
+ inheritedNote.hidden = true;
1516
+ chrome.appendChild(inheritedNote);
1517
+ band.appendChild(chrome);
1518
+ const warning = document.createElement("div");
1519
+ warning.className = "docx-hf-warning";
1520
+ warning.setAttribute("data-hf-warning", "");
1521
+ warning.hidden = true;
1522
+ band.appendChild(warning);
1523
+ const body = document.createElement("div");
1524
+ body.className = "docx-hf-body";
1525
+ body.setAttribute("data-hf-body", "");
1526
+ band.appendChild(body);
1527
+ return band;
1528
+ }
1529
+ /**
1530
+ * Which story paragraph a page-number insert targets: the last one the user focused in this
1531
+ * band if it is still attached, else the band's last paragraph (Word's convention — the page
1532
+ * number goes at the end of the footer line).
1533
+ */
1534
+ pageNumberTarget(which) {
1535
+ const body = this.bandFor(which).querySelector("[data-hf-body]");
1536
+ if (!body) return null;
1537
+ const focused = body.querySelector('[data-hf-focused="true"][data-hf-anchor]');
1538
+ const blocks = Array.from(body.querySelectorAll("[data-hf-anchor]"));
1539
+ const target = focused ?? blocks[blocks.length - 1];
1540
+ return target?.getAttribute("data-hf-anchor") ?? null;
1541
+ }
1542
+ renderBand(which) {
1543
+ const band = this.bandFor(which);
1544
+ const body = band.querySelector("[data-hf-body]");
1545
+ if (!body) return;
1546
+ const kind = this.kinds[which];
1547
+ const select = band.querySelector("[data-hf-kind]");
1548
+ if (select && select.value !== kind) select.value = kind;
1549
+ const inherited = this.refFor(which, kind)?.inherited === true;
1550
+ band.toggleAttribute("data-hf-inherited", inherited);
1551
+ const note = band.querySelector("[data-hf-inherited-note]");
1552
+ if (note) {
1553
+ note.hidden = !inherited;
1554
+ note.textContent = inherited ? "inherited from an earlier section" : "";
1555
+ }
1556
+ this.renderKindWarning(which);
1557
+ body.innerHTML = "";
1558
+ const partUri = this.partUriFor(which, kind);
1559
+ if (!partUri) {
1560
+ body.appendChild(this.placeholder(which, kind));
1561
+ band.setAttribute("data-hf-empty", "true");
1562
+ return;
1563
+ }
1564
+ band.removeAttribute("data-hf-empty");
1565
+ const anchors = this.storyAnchors(partUri);
1566
+ if (anchors.length === 0) {
1567
+ body.appendChild(this.placeholder(which, kind));
1568
+ return;
1569
+ }
1570
+ for (const anchorId of anchors) {
1571
+ const el = this.renderStoryBlock(anchorId);
1572
+ if (!el) continue;
1573
+ body.appendChild(el);
1574
+ this.adoptBlock(el, anchorId);
1575
+ this.callbacks.wireBlock(el);
1576
+ }
1577
+ }
1578
+ /**
1579
+ * Mark a story paragraph as belonging to this region. The full anchor id is stamped alongside
1580
+ * `data-anchor` (which carries only the bare unid, matching the body's convention) so band
1581
+ * chrome can address the block without going through the editor's unid map. Called on first
1582
+ * render AND after an incremental swap, which replaces the DOM node.
1583
+ */
1584
+ adoptBlock(el, anchorId) {
1585
+ const body = this.blockRootOf(el);
1586
+ if (!body) return;
1587
+ el.setAttribute("data-hf-anchor", anchorId);
1588
+ if (el.dataset.hfAdopted === "true") return;
1589
+ el.dataset.hfAdopted = "true";
1590
+ el.addEventListener("focus", () => {
1591
+ body.querySelectorAll("[data-hf-focused]").forEach((sib) => sib.removeAttribute("data-hf-focused"));
1592
+ el.setAttribute("data-hf-focused", "true");
1593
+ });
1594
+ }
1595
+ renderStoryBlock(anchorId) {
1596
+ const html = this.bridge.RenderBlockHtml(
1597
+ this.handle,
1598
+ anchorId,
1599
+ this.options.cssPrefix,
1600
+ this.options.fabricateClasses
1601
+ );
1602
+ if (html.charCodeAt(0) === 123) return null;
1603
+ return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1604
+ }
1605
+ placeholder(which, kind) {
1606
+ const el = document.createElement("div");
1607
+ el.className = "docx-hf-placeholder";
1608
+ el.setAttribute("data-hf-placeholder", "");
1609
+ const label = KIND_LABELS.find((k) => k.value === kind)?.label ?? kind;
1610
+ el.textContent = `No ${label.toLowerCase()} ${which} \u2014 pick a kind to create one.`;
1611
+ return el;
1612
+ }
1613
+ /**
1614
+ * Surface the caveat that comes with the selected kind. Turning on first/even means those pages
1615
+ * stop using the Default stories entirely, which bites hardest on the OTHER band: enabling an
1616
+ * even header with no even footer leaves even pages footer-less, and enabling a first-page
1617
+ * header with an empty first-page footer leaves page 1 footer-less. The note is shown whenever
1618
+ * first/even is selected (the behavior change is real either way); the fix button appears only
1619
+ * when the counterpart story is missing entirely, which is what a user almost always wants next.
1620
+ */
1621
+ renderKindWarning(which) {
1622
+ const band = this.bandFor(which);
1623
+ const warning = band.querySelector("[data-hf-warning]");
1624
+ if (!warning) return;
1625
+ const kind = this.kinds[which];
1626
+ if (kind === "default") {
1627
+ warning.hidden = true;
1628
+ warning.textContent = "";
1629
+ return;
1630
+ }
1631
+ const other = which === "header" ? "footer" : "header";
1632
+ warning.hidden = false;
1633
+ warning.textContent = `${kind === "even" ? EVEN_WARNING : FIRST_WARNING} `;
1634
+ if (this.partUriFor(other, kind)) return;
1635
+ const fix = document.createElement("button");
1636
+ fix.type = "button";
1637
+ fix.setAttribute("data-hf-fix-counterpart", "");
1638
+ fix.textContent = `Also create a ${kind === "even" ? "matching even" : "first-page"} ${other}`;
1639
+ fix.addEventListener("click", () => {
1640
+ this.setKind(other, kind);
1641
+ this.renderKindWarning(which);
1642
+ });
1643
+ warning.appendChild(fix);
1644
+ }
1645
+ };
1646
+ function parseResult(json) {
1647
+ try {
1648
+ return JSON.parse(json);
1649
+ } catch {
1650
+ return { success: false };
1651
+ }
1652
+ }
1653
+
846
1654
  // src/editor.ts
847
1655
  var EDITABLE_TAGS = /* @__PURE__ */ new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
848
1656
  function fontWeightIsBold(w) {
@@ -976,11 +1784,22 @@ var DocxodusEditor = (() => {
976
1784
  return out;
977
1785
  }
978
1786
  function trimmedSplitOffset(block, domOffset) {
979
- const content = blockContentText(block);
980
- const leading = content.length - content.replace(/^\s+/, "").length;
981
- const trimmedLen = content.trim().length;
1787
+ const { leading, trimmedLen } = trimBounds(block);
982
1788
  return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
983
1789
  }
1790
+ function trimBounds(block) {
1791
+ const content = blockContentText(block);
1792
+ return {
1793
+ leading: content.length - content.replace(/^\s+/, "").length,
1794
+ trimmedLen: content.trim().length
1795
+ };
1796
+ }
1797
+ function trimmedSpan(block, span) {
1798
+ const { leading, trimmedLen } = trimBounds(block);
1799
+ const start = Math.max(0, Math.min(span.start - Math.min(span.start, leading), trimmedLen));
1800
+ const end = Math.max(start, Math.min(span.start + span.length - leading, trimmedLen));
1801
+ return { start, length: end - start };
1802
+ }
984
1803
  function contentPositionIn(el, offset) {
985
1804
  let remaining = offset;
986
1805
  let result = null;
@@ -1047,7 +1866,8 @@ var DocxodusEditor = (() => {
1047
1866
  if (!block.contains(range.startContainer) || !block.contains(range.endContainer)) return null;
1048
1867
  const start = contentOffsetOf(block, range.startContainer, range.startOffset);
1049
1868
  const end = contentOffsetOf(block, range.endContainer, range.endOffset);
1050
- return { start: Math.min(start, end), length: Math.abs(end - start) };
1869
+ const span = trimmedSpan(block, { start: Math.min(start, end), length: Math.abs(end - start) });
1870
+ return span.length > 0 ? span : null;
1051
1871
  }
1052
1872
  function selectRange(el, start, length) {
1053
1873
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
@@ -1167,6 +1987,8 @@ var DocxodusEditor = (() => {
1167
1987
  * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
1168
1988
  */
1169
1989
  this.lastSelection = null;
1990
+ /** The docked header/footer bands, when `options.headerFooter` is on. */
1991
+ this.region = null;
1170
1992
  /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
1171
1993
  this.onSelectionChange = () => {
1172
1994
  if (this.closed) return;
@@ -1192,12 +2014,39 @@ var DocxodusEditor = (() => {
1192
2014
  if (typeof document !== "undefined")
1193
2015
  document.addEventListener("selectionchange", this.onSelectionChange);
1194
2016
  }
1195
- /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor. */
2017
+ /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
2018
+ * Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
2019
+ * body edit root by design — also register. The fence still rejects other editors on the page. */
1196
2020
  editableBlockOf(node) {
1197
2021
  if (!node) return null;
1198
2022
  const start = node.nodeType === 1 ? node : node.parentElement;
1199
2023
  const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
1200
- return block && this.editRoot.contains(block) ? block : null;
2024
+ return block && this.container.contains(block) ? block : null;
2025
+ }
2026
+ /**
2027
+ * The root owning `el`'s sibling block list: its header/footer band's story container, else the
2028
+ * body edit root. Keeps a multi-block selection from spanning a band and the body, whose block
2029
+ * lists belong to different OOXML parts.
2030
+ */
2031
+ ownerRoot(el) {
2032
+ return this.region?.blockRootOf(el) ?? this.editRoot;
2033
+ }
2034
+ /** True when `el` is a header/footer band block rather than a body block. */
2035
+ isBandBlock(el) {
2036
+ return !!this.region?.contains(el);
2037
+ }
2038
+ /**
2039
+ * Repaint after an edit to `block` that would otherwise remount the whole document: a band
2040
+ * repaints only itself (a story is one to three paragraphs), leaving the body DOM — and the
2041
+ * user's place in it — untouched.
2042
+ */
2043
+ refreshAfter(block, focusIndex, caretAtEnd = false) {
2044
+ const band = this.region?.bandOf(block);
2045
+ if (band) {
2046
+ this.region.refresh(this.region.whichOf(band));
2047
+ return;
2048
+ }
2049
+ this.remount(focusIndex, caretAtEnd);
1201
2050
  }
1202
2051
  /** Open a document, render it into `container`, and wire up editing. */
1203
2052
  static open(container, bytes, exports, options = {}) {
@@ -1207,16 +2056,19 @@ var DocxodusEditor = (() => {
1207
2056
  editable: options.editable ?? true,
1208
2057
  paginated: options.paginated ?? false,
1209
2058
  scale: options.scale ?? 1,
2059
+ headerFooter: options.headerFooter ?? false,
1210
2060
  onEdit: options.onEdit
1211
2061
  };
1212
2062
  const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
1213
2063
  const editor = new _DocxEditor(container, exports, handle, opts);
1214
2064
  editor.refreshAnchorMap();
2065
+ if (opts.headerFooter) editor.createRegion();
1215
2066
  const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(
1216
2067
  ...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale)
1217
2068
  );
1218
2069
  if (opts.paginated) editor.mountPaginated(fullHtml);
1219
2070
  else editor.mountHtml(fullHtml);
2071
+ editor.syncRegionToBody();
1220
2072
  return editor;
1221
2073
  }
1222
2074
  /**
@@ -1259,29 +2111,113 @@ var DocxodusEditor = (() => {
1259
2111
  assertOpen() {
1260
2112
  if (this.closed) throw new Error("DocxEditor is closed");
1261
2113
  }
1262
- /** Rebuild unid → full-anchor-id from the live session projection. */
2114
+ /**
2115
+ * Rebuild unid → full-anchor-id from the live session projection.
2116
+ *
2117
+ * Unids are CONTENT-ADDRESSED, so blocks with identical content in DIFFERENT parts collide —
2118
+ * e.g. a document with empty default/first/even header stories has one unid for several
2119
+ * header parts. A collision must resolve to the BODY entry: body blocks carry only
2120
+ * `data-anchor` (the bare unid) and have nothing else to resolve through, whereas a
2121
+ * header/footer band block carries its full anchor in `data-hf-anchor` and is resolved from
2122
+ * that (see `anchorIdOf`). Letting a non-body scope win here would silently redirect a body
2123
+ * edit into a header part.
2124
+ */
1263
2125
  refreshAnchorMap() {
1264
2126
  const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
1265
2127
  this.unidToFullId.clear();
2128
+ const bodyOwned = /* @__PURE__ */ new Set();
1266
2129
  for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
2130
+ const isBody = target.scope === "body";
2131
+ if (!isBody && bodyOwned.has(target.unid)) continue;
1267
2132
  this.unidToFullId.set(target.unid, fullId);
2133
+ if (isBody) bodyOwned.add(target.unid);
1268
2134
  }
1269
2135
  }
2136
+ /**
2137
+ * The full `kind:scope:unid` anchor for a rendered block. A header/footer band block carries
2138
+ * its own — the unid map cannot disambiguate one, since several parts' story paragraphs can
2139
+ * share a content-addressed unid (a real Word document with empty default/first/even stories
2140
+ * does exactly that, and a unid-keyed lookup would land the edit in the wrong header part).
2141
+ */
2142
+ anchorIdOf(el) {
2143
+ const stamped = el.getAttribute("data-hf-anchor");
2144
+ if (stamped) return stamped;
2145
+ const unid = el.getAttribute("data-anchor");
2146
+ return unid ? this.unidToFullId.get(unid) : void 0;
2147
+ }
2148
+ /** Build the header/footer region (called once, before the first mount). */
2149
+ createRegion() {
2150
+ this.region = new HeaderFooterRegion(
2151
+ this.exports.DocxSessionBridge,
2152
+ this.handle,
2153
+ { cssPrefix: this.options.cssPrefix, fabricateClasses: this.options.fabricateClasses },
2154
+ {
2155
+ wireBlock: (el) => this.wireBlock(el),
2156
+ refreshAnchorMap: () => this.refreshAnchorMap()
2157
+ }
2158
+ );
2159
+ }
2160
+ /**
2161
+ * Point the bands at the section governing the current focus (or the first body block).
2162
+ * Called after every mount, and on focus of a body block, so a multi-section document shows
2163
+ * the stories that actually apply where the caret is.
2164
+ */
2165
+ syncRegionToBody(fromBlock) {
2166
+ if (!this.region) return;
2167
+ const block = fromBlock ?? this.editableList()[0];
2168
+ const unid = block?.getAttribute("data-anchor");
2169
+ const id = unid ? this.unidToFullId.get(unid) : void 0;
2170
+ this.region.syncToBody(id ?? null);
2171
+ }
2172
+ /**
2173
+ * Insert the bands around `bodyRoot` and make `bodyRoot` the edit root. Band blocks must stay
2174
+ * OUT of the edit root: `editableList()`/`blockIndex()` enumerate it to compute remount focus
2175
+ * indices, and band blocks in that list would shift every index.
2176
+ */
2177
+ dockBands(bodyRoot) {
2178
+ if (!this.region) return;
2179
+ bodyRoot.before(this.region.headerBand);
2180
+ bodyRoot.after(this.region.footerBand);
2181
+ this.region.refreshAll();
2182
+ }
1270
2183
  /** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
1271
2184
  mountHtml(fullHtml) {
1272
2185
  const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
1273
2186
  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);
2187
+ if (!this.region) {
2188
+ this.container.innerHTML = styles + parsed.body.innerHTML;
2189
+ this.editRoot = this.container;
2190
+ if (this.options.editable) this.wireBlocks(this.container);
2191
+ return;
2192
+ }
2193
+ this.container.innerHTML = styles;
2194
+ const flow = document.createElement("div");
2195
+ flow.className = "docx-body-flow";
2196
+ flow.innerHTML = parsed.body.innerHTML;
2197
+ this.container.appendChild(flow);
2198
+ this.editRoot = flow;
2199
+ if (this.options.editable) this.wireBlocks(flow);
2200
+ this.dockBands(flow);
1277
2201
  }
1278
2202
  /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
1279
2203
  mountPaginated(fullHtml) {
1280
- paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
2204
+ let target = this.container;
2205
+ if (this.region) {
2206
+ this.container.innerHTML = "";
2207
+ target = document.createElement("div");
2208
+ target.className = "docx-body-flow";
2209
+ this.container.appendChild(target);
2210
+ }
2211
+ paginateHtml(fullHtml, target, {
2212
+ scale: this.options.scale,
2213
+ cssPrefix: "page-",
2214
+ fragmentParagraphs: false
2215
+ });
1281
2216
  this.container.querySelector("#pagination-staging, .page-staging")?.remove();
1282
- const pageRoot = this.container.querySelector("#pagination-container") ?? this.container;
2217
+ const pageRoot = target.querySelector("#pagination-container") ?? target;
1283
2218
  this.editRoot = pageRoot;
1284
2219
  if (this.options.editable) this.wireBlocks(pageRoot);
2220
+ if (this.region) this.dockBands(target);
1285
2221
  }
1286
2222
  wireBlocks(root) {
1287
2223
  root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
@@ -1289,15 +2225,18 @@ var DocxodusEditor = (() => {
1289
2225
  wireBlock(el) {
1290
2226
  if (!EDITABLE_TAGS.has(el.tagName)) return;
1291
2227
  const unid = el.getAttribute("data-anchor");
1292
- if (!unid || !this.unidToFullId.has(unid)) return;
2228
+ if (!unid || !this.anchorIdOf(el)) return;
1293
2229
  el.setAttribute("contenteditable", "true");
1294
2230
  el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
1295
2231
  el.dataset.committedText = blockContentText(el);
1296
2232
  el.addEventListener("focus", () => {
1297
2233
  this.activeBlock = el;
2234
+ if (this.region && !this.isBandBlock(el)) this.syncRegionToBody(el);
1298
2235
  });
1299
2236
  el.addEventListener("blur", () => this.commitBlock(el));
1300
2237
  el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
2238
+ const stamped = el.getAttribute("data-hf-anchor");
2239
+ if (stamped && this.region?.contains(el)) this.region.adoptBlock(el, stamped);
1301
2240
  }
1302
2241
  /**
1303
2242
  * Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
@@ -1327,7 +2266,7 @@ var DocxodusEditor = (() => {
1327
2266
  if (this.closed || this.replacing) return;
1328
2267
  const unid = el.getAttribute("data-anchor");
1329
2268
  if (!unid) return;
1330
- const fullId = this.unidToFullId.get(unid);
2269
+ const fullId = this.anchorIdOf(el);
1331
2270
  if (!fullId) return;
1332
2271
  const result = this.commitTextChange(el, fullId);
1333
2272
  if (!result) return;
@@ -1354,9 +2293,14 @@ var DocxodusEditor = (() => {
1354
2293
  );
1355
2294
  if (html.charCodeAt(0) !== 123) {
1356
2295
  const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
2296
+ const inBand = this.isBandBlock(el);
1357
2297
  if (fresh && this.replaceNode(el, fresh)) {
1358
- this.unidToFullId.delete(unid);
1359
- this.unidToFullId.set(newUnid, newAnchor);
2298
+ if (inBand) {
2299
+ this.region.adoptBlock(fresh, newAnchor);
2300
+ } else {
2301
+ this.unidToFullId.delete(unid);
2302
+ this.unidToFullId.set(newUnid, newAnchor);
2303
+ }
1360
2304
  this.wireBlock(fresh);
1361
2305
  if (this.activeBlock === el) this.activeBlock = fresh;
1362
2306
  }
@@ -1432,7 +2376,7 @@ var DocxodusEditor = (() => {
1432
2376
  const rawOffset = caretOffsetIn(el);
1433
2377
  const unid = el.getAttribute("data-anchor");
1434
2378
  if (rawOffset == null || !unid) return;
1435
- let fullId = this.unidToFullId.get(unid);
2379
+ let fullId = this.anchorIdOf(el);
1436
2380
  if (!fullId) return;
1437
2381
  const offset = trimmedSplitOffset(el, rawOffset);
1438
2382
  const idx = this.blockIndex(el);
@@ -1444,17 +2388,23 @@ var DocxodusEditor = (() => {
1444
2388
  const second = res.created?.[0];
1445
2389
  if (!first || !second) return;
1446
2390
  if (this.affectsList(res) || wrappedInBorder) {
1447
- this.remount(idx + 1, false);
2391
+ this.refreshAfter(el, idx + 1, false);
1448
2392
  this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
1449
2393
  return;
1450
2394
  }
1451
2395
  const firstEl = this.renderInto(first.id);
1452
2396
  const secondEl = this.renderInto(second.id);
1453
2397
  if (!firstEl || !secondEl) return;
2398
+ const inBand = this.isBandBlock(el);
1454
2399
  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);
2400
+ if (inBand) {
2401
+ this.region.adoptBlock(firstEl, first.id);
2402
+ this.region.adoptBlock(secondEl, second.id);
2403
+ } else {
2404
+ this.unidToFullId.delete(unid);
2405
+ this.unidToFullId.set(first.unid, first.id);
2406
+ this.unidToFullId.set(second.unid, second.id);
2407
+ }
1458
2408
  this.wireBlock(firstEl);
1459
2409
  this.wireBlock(secondEl);
1460
2410
  placeCaretAtOffset(secondEl, 0);
@@ -1465,8 +2415,8 @@ var DocxodusEditor = (() => {
1465
2415
  const prevUnid = prev.getAttribute("data-anchor");
1466
2416
  const thisUnid = el.getAttribute("data-anchor");
1467
2417
  if (!prevUnid || !thisUnid) return;
1468
- let prevId = this.unidToFullId.get(prevUnid);
1469
- let thisId = this.unidToFullId.get(thisUnid);
2418
+ let prevId = this.anchorIdOf(prev);
2419
+ let thisId = this.anchorIdOf(el);
1470
2420
  if (!prevId || !thisId) return;
1471
2421
  const prevIdx = this.blockIndex(prev);
1472
2422
  const wrappedInBorder = inBorderWrapper(prev) || inBorderWrapper(el);
@@ -1478,17 +2428,22 @@ var DocxodusEditor = (() => {
1478
2428
  const merged = res.modified?.[0];
1479
2429
  if (!merged) return;
1480
2430
  if (this.affectsList(res) || wrappedInBorder) {
1481
- this.remount(prevIdx, true);
2431
+ this.refreshAfter(el, prevIdx, true);
1482
2432
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
1483
2433
  return;
1484
2434
  }
1485
2435
  const mergedEl = this.renderInto(merged.id);
1486
2436
  if (!mergedEl) return;
2437
+ const inBand = this.isBandBlock(prev);
1487
2438
  if (!this.replaceNode(prev, mergedEl)) return;
1488
2439
  el.remove();
1489
- this.unidToFullId.delete(prevUnid);
1490
- this.unidToFullId.delete(thisUnid);
1491
- this.unidToFullId.set(merged.unid, merged.id);
2440
+ if (inBand) {
2441
+ this.region.adoptBlock(mergedEl, merged.id);
2442
+ } else {
2443
+ this.unidToFullId.delete(prevUnid);
2444
+ this.unidToFullId.delete(thisUnid);
2445
+ this.unidToFullId.set(merged.unid, merged.id);
2446
+ }
1492
2447
  this.wireBlock(mergedEl);
1493
2448
  placeCaretAtOffset(mergedEl, caret);
1494
2449
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
@@ -1551,10 +2506,10 @@ var DocxodusEditor = (() => {
1551
2506
  if (html.charCodeAt(0) === 123) return null;
1552
2507
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1553
2508
  }
1554
- /** The editable block immediately before `el` in document order, or null. */
2509
+ /** The editable block immediately before `el` within its own root, or null. */
1555
2510
  previousEditable(el) {
1556
2511
  const all = Array.from(
1557
- this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')
2512
+ this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')
1558
2513
  );
1559
2514
  const i = all.indexOf(el);
1560
2515
  return i > 0 ? all[i - 1] : null;
@@ -1574,8 +2529,9 @@ var DocxodusEditor = (() => {
1574
2529
  * A collapsed or single-block selection yields just the active block. */
1575
2530
  selectedBlocks() {
1576
2531
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
2532
+ const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
1577
2533
  const all = Array.from(
1578
- this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')
2534
+ root.querySelectorAll('[data-anchor][contenteditable="true"]')
1579
2535
  );
1580
2536
  if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
1581
2537
  const range = sel.getRangeAt(0);
@@ -1605,13 +2561,13 @@ var DocxodusEditor = (() => {
1605
2561
  const contentLen = blockContentText(block).length;
1606
2562
  if (hasStart) {
1607
2563
  const start = contentOffsetOf(block, range.startContainer, range.startOffset);
1608
- return { start, length: Math.max(0, contentLen - start) };
2564
+ return trimmedSpan(block, { start, length: Math.max(0, contentLen - start) });
1609
2565
  }
1610
2566
  if (hasEnd) {
1611
2567
  const end = contentOffsetOf(block, range.endContainer, range.endOffset);
1612
- return { start: 0, length: end };
2568
+ return trimmedSpan(block, { start: 0, length: end });
1613
2569
  }
1614
- return { start: 0, length: contentLen };
2570
+ return trimmedSpan(block, { start: 0, length: contentLen });
1615
2571
  }
1616
2572
  /** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
1617
2573
  * the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
@@ -1658,7 +2614,7 @@ var DocxodusEditor = (() => {
1658
2614
  return {
1659
2615
  block: b,
1660
2616
  unid,
1661
- fullId: unid ? this.unidToFullId.get(unid) : void 0,
2617
+ fullId: this.anchorIdOf(b),
1662
2618
  span: this.blockSpanForSelection(b),
1663
2619
  res: null
1664
2620
  };
@@ -1678,7 +2634,7 @@ var DocxodusEditor = (() => {
1678
2634
  const edited = targets.filter((t) => t.res && t.unid);
1679
2635
  if (edited.length === 0) return;
1680
2636
  if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
1681
- this.remount();
2637
+ this.refreshAfter(edited[0].block, -1, false);
1682
2638
  return;
1683
2639
  }
1684
2640
  const swapped = [];
@@ -1721,7 +2677,7 @@ var DocxodusEditor = (() => {
1721
2677
  }
1722
2678
  const unid = block.getAttribute("data-anchor");
1723
2679
  if (!unid) return;
1724
- let fullId = this.unidToFullId.get(unid);
2680
+ let fullId = this.anchorIdOf(block);
1725
2681
  if (!fullId) return;
1726
2682
  const span = selectionSpanIn(block);
1727
2683
  const on = value ?? !selectionHasFormat(key, block);
@@ -1737,7 +2693,7 @@ var DocxodusEditor = (() => {
1737
2693
  );
1738
2694
  if (!res.success) return;
1739
2695
  if (this.affectsList(res)) {
1740
- this.remount(this.blockIndex(block), false);
2696
+ this.refreshAfter(block, this.blockIndex(block), false);
1741
2697
  return;
1742
2698
  }
1743
2699
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1756,7 +2712,7 @@ var DocxodusEditor = (() => {
1756
2712
  if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontSizePts: pts })) return;
1757
2713
  const unid = block.getAttribute("data-anchor");
1758
2714
  if (!unid) return;
1759
- let fullId = this.unidToFullId.get(unid);
2715
+ let fullId = this.anchorIdOf(block);
1760
2716
  if (!fullId) return;
1761
2717
  let span = selectionSpanIn(block);
1762
2718
  if (!span && this.lastSelection && this.lastSelection.unid === unid) span = this.lastSelection.span;
@@ -1771,7 +2727,7 @@ var DocxodusEditor = (() => {
1771
2727
  );
1772
2728
  if (!res.success) return;
1773
2729
  if (this.affectsList(res)) {
1774
- this.remount(this.blockIndex(block), false);
2730
+ this.refreshAfter(block, this.blockIndex(block), false);
1775
2731
  return;
1776
2732
  }
1777
2733
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1792,7 +2748,7 @@ var DocxodusEditor = (() => {
1792
2748
  if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontFamily: name })) return;
1793
2749
  const unid = block.getAttribute("data-anchor");
1794
2750
  if (!unid) return;
1795
- let fullId = this.unidToFullId.get(unid);
2751
+ let fullId = this.anchorIdOf(block);
1796
2752
  if (!fullId) return;
1797
2753
  let span = selectionSpanIn(block);
1798
2754
  if (!span && this.lastSelection && this.lastSelection.unid === unid) span = this.lastSelection.span;
@@ -1807,7 +2763,7 @@ var DocxodusEditor = (() => {
1807
2763
  );
1808
2764
  if (!res.success) return;
1809
2765
  if (this.affectsList(res)) {
1810
- this.remount(this.blockIndex(block), false);
2766
+ this.refreshAfter(block, this.blockIndex(block), false);
1811
2767
  return;
1812
2768
  }
1813
2769
  const fresh = this.swapBlock(block, unid, res.modified?.[0]);
@@ -1828,7 +2784,7 @@ var DocxodusEditor = (() => {
1828
2784
  if (this.closed || !block) return;
1829
2785
  const unid = block.getAttribute("data-anchor");
1830
2786
  if (!unid) return;
1831
- let fullId = this.unidToFullId.get(unid);
2787
+ let fullId = this.anchorIdOf(block);
1832
2788
  if (!fullId) return;
1833
2789
  const idx = this.blockIndex(block);
1834
2790
  fullId = this.syncBlock(block, fullId);
@@ -1841,7 +2797,7 @@ var DocxodusEditor = (() => {
1841
2797
  )
1842
2798
  );
1843
2799
  if (!res.success) return;
1844
- this.remount(idx, false);
2800
+ this.refreshAfter(block, idx, false);
1845
2801
  }
1846
2802
  /**
1847
2803
  * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
@@ -1853,7 +2809,7 @@ var DocxodusEditor = (() => {
1853
2809
  if (this.closed || !block) return;
1854
2810
  const unid = block.getAttribute("data-anchor");
1855
2811
  if (!unid) return;
1856
- let fullId = this.unidToFullId.get(unid);
2812
+ let fullId = this.anchorIdOf(block);
1857
2813
  if (!fullId) return;
1858
2814
  const idx = this.blockIndex(block);
1859
2815
  fullId = this.syncBlock(block, fullId);
@@ -1869,7 +2825,7 @@ var DocxodusEditor = (() => {
1869
2825
  )
1870
2826
  );
1871
2827
  if (!res.success) return;
1872
- this.remount(idx, false);
2828
+ this.refreshAfter(block, idx, false);
1873
2829
  }
1874
2830
  // ─── Table row / column editing (active block must be inside a table cell) ──────────
1875
2831
  /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
@@ -1878,13 +2834,13 @@ var DocxodusEditor = (() => {
1878
2834
  if (this.closed || !block || !block.closest("table")) return;
1879
2835
  const unid = block.getAttribute("data-anchor");
1880
2836
  if (!unid) return;
1881
- let fullId = this.unidToFullId.get(unid);
2837
+ let fullId = this.anchorIdOf(block);
1882
2838
  if (!fullId) return;
1883
2839
  const idx = this.blockIndex(block);
1884
2840
  fullId = this.syncBlock(block, fullId);
1885
2841
  const res = this.parseEdit(run(fullId));
1886
2842
  if (!res.success) return;
1887
- this.remount(idx, false);
2843
+ this.refreshAfter(block, idx, false);
1888
2844
  }
1889
2845
  /** Insert a row above/below the active cell's row. No-op outside a table. */
1890
2846
  insertTableRow(where) {
@@ -1925,13 +2881,13 @@ var DocxodusEditor = (() => {
1925
2881
  if (this.closed || !block) return;
1926
2882
  const unid = block.getAttribute("data-anchor");
1927
2883
  if (!unid) return;
1928
- let fullId = this.unidToFullId.get(unid);
2884
+ let fullId = this.anchorIdOf(block);
1929
2885
  if (!fullId) return;
1930
2886
  const idx = this.blockIndex(block);
1931
2887
  fullId = this.syncBlock(block, fullId);
1932
2888
  const res = this.parseEdit(this.exports.DocxSessionBridge.SetListLevel(this.handle, fullId, delta));
1933
2889
  if (!res.success) return;
1934
- this.remount(idx, false);
2890
+ this.refreshAfter(block, idx, false);
1935
2891
  }
1936
2892
  /** Toggle (or set) page-break-before on the active block. */
1937
2893
  pageBreakBefore(value = true) {
@@ -1946,7 +2902,7 @@ var DocxodusEditor = (() => {
1946
2902
  if (this.closed || !block) return;
1947
2903
  const unid = block.getAttribute("data-anchor");
1948
2904
  if (!unid) return;
1949
- let fullId = this.unidToFullId.get(unid);
2905
+ let fullId = this.anchorIdOf(block);
1950
2906
  if (!fullId) return;
1951
2907
  let membership = null;
1952
2908
  try {
@@ -1960,7 +2916,7 @@ var DocxodusEditor = (() => {
1960
2916
  this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, isThisKind ? "none" : kind)
1961
2917
  );
1962
2918
  if (!res.success) return;
1963
- this.remount(idx, false);
2919
+ this.refreshAfter(block, idx, false);
1964
2920
  }
1965
2921
  /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
1966
2922
  * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
@@ -1982,12 +2938,12 @@ var DocxodusEditor = (() => {
1982
2938
  if (this.editableList().length <= 1) return;
1983
2939
  const unid = block.getAttribute("data-anchor");
1984
2940
  if (!unid) return;
1985
- const fullId = this.unidToFullId.get(unid);
2941
+ const fullId = this.anchorIdOf(block);
1986
2942
  if (!fullId) return;
1987
2943
  const idx = this.blockIndex(block);
1988
2944
  const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
1989
2945
  if (!res.success) return;
1990
- this.remount(Math.max(0, idx - 1), true);
2946
+ this.refreshAfter(block, Math.max(0, idx - 1), true);
1991
2947
  }
1992
2948
  applyParagraphFormat(op) {
1993
2949
  const block = this.activeBlock;
@@ -2002,7 +2958,7 @@ var DocxodusEditor = (() => {
2002
2958
  return;
2003
2959
  const unid = block.getAttribute("data-anchor");
2004
2960
  if (!unid) return;
2005
- let fullId = this.unidToFullId.get(unid);
2961
+ let fullId = this.anchorIdOf(block);
2006
2962
  if (!fullId) return;
2007
2963
  const idx = this.blockIndex(block);
2008
2964
  fullId = this.syncBlock(block, fullId);
@@ -2011,7 +2967,7 @@ var DocxodusEditor = (() => {
2011
2967
  );
2012
2968
  if (!res.success) return;
2013
2969
  if (this.affectsList(res) || op.clearBorders) {
2014
- this.remount(idx, false);
2970
+ this.refreshAfter(block, idx, false);
2015
2971
  return;
2016
2972
  }
2017
2973
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
@@ -2029,14 +2985,14 @@ var DocxodusEditor = (() => {
2029
2985
  return;
2030
2986
  const unid = block.getAttribute("data-anchor");
2031
2987
  if (!unid) return;
2032
- let fullId = this.unidToFullId.get(unid);
2988
+ let fullId = this.anchorIdOf(block);
2033
2989
  if (!fullId) return;
2034
2990
  const idx = this.blockIndex(block);
2035
2991
  fullId = this.syncBlock(block, fullId);
2036
2992
  const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, fullId, styleId));
2037
2993
  if (!res.success) return;
2038
2994
  if (this.affectsList(res)) {
2039
- this.remount(idx, false);
2995
+ this.refreshAfter(block, idx, false);
2040
2996
  return;
2041
2997
  }
2042
2998
  this.swapBlock(block, unid, res.modified?.[0])?.focus();
@@ -2051,6 +3007,37 @@ var DocxodusEditor = (() => {
2051
3007
  if (this.closed) return;
2052
3008
  if (this.exports.DocxSessionBridge.Redo(this.handle)) this.remount();
2053
3009
  }
3010
+ // ─── Header/footer region commands (no-ops unless `headerFooter` is on) ───────────────
3011
+ /**
3012
+ * Select which story kind a band edits (`"default"` / `"first"` / `"even"`). A kind with no
3013
+ * existing part is created empty, so the band always presents something editable. `"first"`
3014
+ * sets the section's `w:titlePg`; `"even"` sets the document-global `w:evenAndOddHeaders`
3015
+ * (which also governs footers — the band surfaces that caveat inline).
3016
+ */
3017
+ setHeaderFooterKind(which, kind) {
3018
+ this.assertOpen();
3019
+ this.region?.setKind(which, kind);
3020
+ }
3021
+ /** The story kind a band is currently editing, or null when the region is off. */
3022
+ headerFooterKind(which) {
3023
+ return this.region?.kindOf(which) ?? null;
3024
+ }
3025
+ /**
3026
+ * Append a page-number field to the focused header/footer story paragraph (falling back to the
3027
+ * band's last paragraph — Word's convention). No-op outside a band.
3028
+ */
3029
+ insertPageNumber(field = "currentPage") {
3030
+ this.assertOpen();
3031
+ if (!this.region) return;
3032
+ const block = this.activeBlock;
3033
+ const band = block ? this.region.bandOf(block) : null;
3034
+ const anchorId = block?.getAttribute("data-hf-anchor");
3035
+ if (band && anchorId) {
3036
+ this.region.insertPageNumber(this.region.whichOf(band), anchorId, field);
3037
+ return;
3038
+ }
3039
+ this.region.insertPageNumberInBand("footer", field);
3040
+ }
2054
3041
  /** Which inline formats the current selection carries — for ribbon button highlighting. */
2055
3042
  queryFormatState() {
2056
3043
  const block = this.activeBlock ?? this.editRoot;
@@ -2066,13 +3053,17 @@ var DocxodusEditor = (() => {
2066
3053
  }
2067
3054
  /** Re-render one block from the live session by EditResult ref, swapping it in place. */
2068
3055
  swapBlock(oldEl, oldUnid, ref) {
2069
- const anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
3056
+ const inBand = this.isBandBlock(oldEl);
3057
+ const anchorId = ref?.id ?? this.anchorIdOf(oldEl);
2070
3058
  const newUnid = ref?.unid ?? oldUnid;
2071
3059
  if (!anchorId) return null;
2072
3060
  const fresh = this.renderInto(anchorId);
2073
3061
  if (!fresh || !this.replaceNode(oldEl, fresh)) return null;
2074
- this.unidToFullId.delete(oldUnid);
2075
- this.unidToFullId.set(newUnid, anchorId);
3062
+ if (!inBand) {
3063
+ this.unidToFullId.delete(oldUnid);
3064
+ this.unidToFullId.set(newUnid, anchorId);
3065
+ }
3066
+ if (inBand) this.region.adoptBlock(fresh, anchorId);
2076
3067
  this.wireBlock(fresh);
2077
3068
  this.activeBlock = fresh;
2078
3069
  this.options.onEdit?.({ anchorId, unid: newUnid });
@@ -2102,12 +3093,14 @@ var DocxodusEditor = (() => {
2102
3093
  ...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale)
2103
3094
  );
2104
3095
  }
2105
- /** Editable blocks in document order. */
3096
+ /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
2106
3097
  editableList() {
2107
3098
  return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
2108
3099
  }
2109
3100
  blockIndex(el) {
2110
- return this.editableList().indexOf(el);
3101
+ return Array.from(
3102
+ this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')
3103
+ ).indexOf(el);
2111
3104
  }
2112
3105
  /**
2113
3106
  * True when an edit produced or touched a list item (kind "li"). List markers and
@@ -2137,6 +3130,7 @@ var DocxodusEditor = (() => {
2137
3130
  placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
2138
3131
  }
2139
3132
  }
3133
+ this.syncRegionToBody(this.activeBlock ?? void 0);
2140
3134
  }
2141
3135
  };
2142
3136
  return __toCommonJS(editor_exports);