docxodus 10.0.0 → 12.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 (113) hide show
  1. package/README.md +7 -5
  2. package/dist/docxodus.worker.js +6 -38
  3. package/dist/docxodus.worker.js.map +1 -1
  4. package/dist/editor-comments.d.ts +129 -0
  5. package/dist/editor-comments.d.ts.map +1 -0
  6. package/dist/editor-comments.js +805 -0
  7. package/dist/editor-comments.js.map +1 -0
  8. package/dist/editor-headerfooter.d.ts +125 -85
  9. package/dist/editor-headerfooter.d.ts.map +1 -1
  10. package/dist/editor-headerfooter.js +572 -306
  11. package/dist/editor-headerfooter.js.map +1 -1
  12. package/dist/editor-image-patch.d.ts +38 -0
  13. package/dist/editor-image-patch.d.ts.map +1 -0
  14. package/dist/editor-image-patch.js +101 -0
  15. package/dist/editor-image-patch.js.map +1 -0
  16. package/dist/editor.bundle.js +3815 -778
  17. package/dist/editor.d.ts +237 -5
  18. package/dist/editor.d.ts.map +1 -1
  19. package/dist/editor.js +1063 -112
  20. package/dist/editor.js.map +1 -1
  21. package/dist/embed.bundle.js +3911 -849
  22. package/dist/embed.d.ts +1 -1
  23. package/dist/embed.d.ts.map +1 -1
  24. package/dist/embed.iife.js +3899 -837
  25. package/dist/embed.js +10 -1
  26. package/dist/embed.js.map +1 -1
  27. package/dist/export-assets.json +40 -46
  28. package/dist/export-browser.bundle.js +27 -3
  29. package/dist/index.d.ts +28 -64
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +68 -134
  32. package/dist/index.js.map +1 -1
  33. package/dist/pagination.bundle.js +25 -0
  34. package/dist/pagination.d.ts +6 -0
  35. package/dist/pagination.d.ts.map +1 -1
  36. package/dist/pagination.js +19 -0
  37. package/dist/pagination.js.map +1 -1
  38. package/dist/react.d.ts +4 -4
  39. package/dist/react.d.ts.map +1 -1
  40. package/dist/react.js.map +1 -1
  41. package/dist/ribbon-chrome.d.ts +20 -4
  42. package/dist/ribbon-chrome.d.ts.map +1 -1
  43. package/dist/ribbon-chrome.js +575 -184
  44. package/dist/ribbon-chrome.js.map +1 -1
  45. package/dist/ribbon.d.ts +5 -4
  46. package/dist/ribbon.d.ts.map +1 -1
  47. package/dist/ribbon.js +978 -129
  48. package/dist/ribbon.js.map +1 -1
  49. package/dist/session.bundle.js +100 -3
  50. package/dist/session.d.ts +60 -4
  51. package/dist/session.d.ts.map +1 -1
  52. package/dist/session.js +75 -3
  53. package/dist/session.js.map +1 -1
  54. package/dist/types.d.ts +194 -227
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js +9 -75
  57. package/dist/types.js.map +1 -1
  58. package/dist/viewport.d.ts +7 -0
  59. package/dist/viewport.d.ts.map +1 -1
  60. package/dist/viewport.js +12 -0
  61. package/dist/viewport.js.map +1 -1
  62. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
  63. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm.br +0 -0
  64. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
  65. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm.br +0 -0
  66. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  67. package/dist/wasm/_framework/Docxodus.wasm.br +0 -0
  68. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  69. package/dist/wasm/_framework/DocxodusWasm.wasm.br +0 -0
  70. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  71. package/dist/wasm/_framework/System.Collections.Concurrent.wasm.br +0 -0
  72. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  73. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm.br +0 -0
  74. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  75. package/dist/wasm/_framework/System.IO.Compression.wasm.br +0 -0
  76. package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
  77. package/dist/wasm/_framework/System.IO.Packaging.wasm.br +0 -0
  78. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  79. package/dist/wasm/_framework/System.IO.Pipelines.wasm.br +0 -0
  80. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  81. package/dist/wasm/_framework/System.Linq.wasm.br +0 -0
  82. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  83. package/dist/wasm/_framework/System.Private.CoreLib.wasm.br +0 -0
  84. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  85. package/dist/wasm/_framework/System.Private.Uri.wasm.br +0 -0
  86. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  87. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm.br +0 -0
  88. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  89. package/dist/wasm/_framework/System.Private.Xml.wasm.br +0 -0
  90. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  91. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm.br +0 -0
  92. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  93. package/dist/wasm/_framework/System.Security.Cryptography.wasm.br +0 -0
  94. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  95. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm.br +0 -0
  96. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  97. package/dist/wasm/_framework/System.Text.Json.wasm.br +0 -0
  98. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  99. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm.br +0 -0
  100. package/dist/wasm/_framework/dotnet.boot.js +31 -37
  101. package/dist/wasm/_framework/dotnet.boot.js.br +0 -0
  102. package/dist/wasm/_framework/dotnet.native.js +39 -3
  103. package/dist/wasm/_framework/dotnet.native.js.br +0 -0
  104. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  105. package/dist/wasm/_framework/dotnet.native.wasm.br +0 -0
  106. package/dist/worker-proxy.bundle.js +2 -3
  107. package/dist/worker-proxy.d.ts +2 -2
  108. package/dist/worker-proxy.d.ts.map +1 -1
  109. package/dist/worker-proxy.js +1 -2
  110. package/dist/worker-proxy.js.map +1 -1
  111. package/package.json +3 -3
  112. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  113. package/dist/wasm/_framework/System.Diagnostics.Process.wasm.br +0 -0
package/dist/editor.js CHANGED
@@ -19,12 +19,14 @@
19
19
  import { paginateHtml } from "./pagination.js";
20
20
  import { DocumentViewport } from "./viewport.js";
21
21
  import { HeaderFooterRegion } from "./editor-headerfooter.js";
22
+ import { CommentGutter, COMMENT_GUTTER_CSS } from "./editor-comments.js";
22
23
  import { draggable, dropTargetForElements, monitorForElements, } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
23
24
  import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
24
25
  import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
25
26
  import { autoScrollForElements, autoScrollWindowForElements, } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
26
27
  import { TrackedChangeMode } from "./types.js";
27
28
  import { diffUnits, needsRemount, unidOf } from "./editor-reconcile.js";
29
+ import { imageOnlyDelta, patchImageAttributes } from "./editor-image-patch.js";
28
30
  const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
29
31
  const BLOCK_DRAG_TYPE = "docxodus-block";
30
32
  const blockDragStyledDocuments = new WeakSet();
@@ -134,6 +136,13 @@ function collectInlineSegments(node, out) {
134
136
  // it isn't part of the paragraph's content and must never be committed as text.
135
137
  if (isGeneratedChrome(child))
136
138
  return;
139
+ if (isField(child)) {
140
+ // A field serializes as its cached result (the session's own text for it).
141
+ const text = fieldText(child);
142
+ if (text)
143
+ out.push({ text, bold: false, italic: false, href: null });
144
+ return;
145
+ }
137
146
  if (child.nodeType === 3 /* TEXT_NODE */) {
138
147
  const text = child.textContent ?? "";
139
148
  if (!text)
@@ -206,7 +215,42 @@ export function serializeInlineMarkdown(block) {
206
215
  merged.push({ ...s });
207
216
  }
208
217
  }
209
- return escapeLeadingBlockMarkers(merged.map(segToMarkdown).join("").trim());
218
+ // Leading whitespace is never content (a rendered empty paragraph carries a placeholder
219
+ // space, and typing lands after it). Trailing whitespace usually is not either — the
220
+ // browser's bogus trailing <br> serializes as a hard break — EXCEPT the single space a user
221
+ // types before a page-number field goes in: "Page " + PAGE must not commit as "Page". The
222
+ // engine writes it with xml:space="preserve"; `trimBounds` counts it the same way.
223
+ const md = merged.map(segToMarkdown).join("").replace(/^\s+/, "").replace(/\s+$/, "");
224
+ return escapeLeadingBlockMarkers(md + (keepsTrailingSpace(block) ? " " : ""));
225
+ }
226
+ /**
227
+ * Whether a block's content text ends in a space the commit should keep: a plain or no-break
228
+ * space (contenteditable turns a typed trailing space into U+00A0) after some non-whitespace
229
+ * text. A whitespace-only block (the placeholder an empty paragraph renders as) keeps nothing.
230
+ */
231
+ function keepsTrailingSpace(block) {
232
+ const text = blockContentText(block);
233
+ if (text.trim().length === 0)
234
+ return false;
235
+ // An ordinary trailing space can only have come from the document (the converter renders
236
+ // it from a preserved w:t), so it is content. A trailing NBSP is ambiguous: the browser
237
+ // writes one for a typed space, but the placeholder an empty paragraph renders as is one
238
+ // too, and typing at the start of such a paragraph leaves it dangling at the end. Only the
239
+ // typed one counts, which `wireBlock` records from the input event.
240
+ if (/ $/.test(text))
241
+ return true;
242
+ return /\u00a0$/.test(text) && block.dataset.typedTrailingSpace === "1";
243
+ }
244
+ /** Record whether the last input left a typed space at the end of the block \u2014 see
245
+ * {@link keepsTrailingSpace}. */
246
+ function noteTypedSpace(block, event) {
247
+ const input = event;
248
+ const typedSpace = input.inputType === "insertText" && typeof input.data === "string" && /[ \u00a0]$/.test(input.data);
249
+ const atEnd = typedSpace && caretOffsetIn(block) === blockContentText(block).length;
250
+ if (atEnd)
251
+ block.dataset.typedTrailingSpace = "1";
252
+ else
253
+ delete block.dataset.typedTrailingSpace;
210
254
  }
211
255
  /** True if `block` renders as a list item (has a generated marker as its first child). */
212
256
  function isListBlock(block) {
@@ -245,10 +289,37 @@ function ensureListMarkerSeparator(block) {
245
289
  * serialization and the display number gets COMMITTED as literal text (destroying the citation
246
290
  * run); left editable and the user can delete a marker outright, orphaning the note.
247
291
  */
248
- const GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"]';
292
+ const GENERATED_CHROME_SELECTOR = '[data-list-marker], a.footnote-ref, a.endnote-ref, a[class$="-backref"], a.comment-marker';
249
293
  function isGeneratedChrome(node) {
250
294
  return node?.nodeType === 1 && !!node.matches?.(GENERATED_CHROME_SELECTOR);
251
295
  }
296
+ /**
297
+ * A rendered field — PAGE / NUMPAGES in a running story, stamped `[data-field]` by the editor
298
+ * render profile. Fields are ATOMIC for editing (`contenteditable=false`, the caret lands on
299
+ * either side), and their content length is the field's CACHED result — the text the session
300
+ * holds in the result run — even after the page view substituted the per-page number into the
301
+ * DOM. Counting the substituted text instead would shift every offset after the field and make
302
+ * the commit diff rewrite the field runs.
303
+ */
304
+ function fieldOf(node) {
305
+ const el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
306
+ return el?.closest?.("[data-field]") ?? null;
307
+ }
308
+ function isField(node) {
309
+ return node.nodeType === 1 && node.hasAttribute("data-field");
310
+ }
311
+ /** The text a field counts for: the cached result the session holds, else what it shows. */
312
+ function fieldText(field) {
313
+ return field.dataset.fieldCached ?? field.textContent ?? "";
314
+ }
315
+ /** Freeze a field's rendered result as its cached text (once) and keep the caret out of it. */
316
+ function adoptFields(block) {
317
+ for (const field of Array.from(block.querySelectorAll("[data-field]"))) {
318
+ if (field.dataset.fieldCached === undefined)
319
+ field.dataset.fieldCached = field.textContent ?? "";
320
+ field.setAttribute("contenteditable", "false");
321
+ }
322
+ }
252
323
  /** True if `node` is, or is inside, generated chrome (not editable content). */
253
324
  function isInMarker(node) {
254
325
  let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
@@ -297,6 +368,16 @@ function contentOffsetOf(block, container, offset) {
297
368
  const walk = (node) => {
298
369
  if (done)
299
370
  return;
371
+ if (isField(node)) {
372
+ // Atomic: a point inside a field resolves to the field's end; the field counts its
373
+ // cached text either way.
374
+ const inside = node === container || node.contains(container);
375
+ if (!inside || offset > 0 || node !== container)
376
+ count += stripBidi(fieldText(node)).length;
377
+ if (inside)
378
+ done = true;
379
+ return;
380
+ }
300
381
  if (node.nodeType === 3 /* TEXT_NODE */) {
301
382
  if (node === container) {
302
383
  if (!isInMarker(node))
@@ -337,7 +418,10 @@ function caretOffsetIn(block) {
337
418
  function blockContentText(block) {
338
419
  let out = "";
339
420
  const walk = (node) => {
340
- if (node.nodeType === 3 /* TEXT_NODE */) {
421
+ if (isField(node)) {
422
+ out += stripBidi(fieldText(node));
423
+ }
424
+ else if (node.nodeType === 3 /* TEXT_NODE */) {
341
425
  if (!isInMarker(node))
342
426
  out += stripBidi(node.textContent ?? "");
343
427
  }
@@ -367,7 +451,9 @@ function trimBounds(block) {
367
451
  const content = blockContentText(block);
368
452
  return {
369
453
  leading: content.length - content.replace(/^\s+/, "").length,
370
- trimmedLen: content.trim().length,
454
+ // Mirrors serializeInlineMarkdown: leading and trailing whitespace dropped, plus the one
455
+ // trailing space a commit keeps.
456
+ trimmedLen: content.trim().length + (keepsTrailingSpace(block) ? 1 : 0),
371
457
  };
372
458
  }
373
459
  /**
@@ -402,6 +488,19 @@ function contentPositionIn(el, offset) {
402
488
  const walk = (node) => {
403
489
  if (result)
404
490
  return;
491
+ if (isField(node)) {
492
+ // Never land inside a field: an offset at its start sits before it, anything up to its
493
+ // cached length sits after it.
494
+ const len = stripBidi(fieldText(node)).length;
495
+ const parent = node.parentNode;
496
+ if (parent && remaining <= len) {
497
+ const index = Array.prototype.indexOf.call(parent.childNodes, node);
498
+ result = { node: parent, offset: index + (remaining > 0 ? 1 : 0) };
499
+ return;
500
+ }
501
+ remaining -= len;
502
+ return;
503
+ }
405
504
  if (node.nodeType === 3 /* TEXT_NODE */) {
406
505
  if (isInMarker(node))
407
506
  return;
@@ -470,40 +569,23 @@ function setSelectionBetween(anchor, focus) {
470
569
  /** Place the caret at content offset `offset` within `el`, skipping marker text. */
471
570
  function placeCaretAtOffset(el, offset) {
472
571
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
473
- if (!sel)
572
+ // A remount can detach the block between a caller's lookup and this call; addRange on a
573
+ // detached node throws "the given range isn't in document".
574
+ if (!sel || !el.isConnected)
474
575
  return;
475
576
  el.focus();
476
577
  const range = document.createRange();
477
- let remaining = offset;
478
- let placed = false;
479
- const walk = (node) => {
480
- if (placed)
481
- return;
482
- if (node.nodeType === 3 /* TEXT_NODE */) {
483
- if (isInMarker(node))
484
- return; // never land the caret in the marker
485
- const raw = node.textContent ?? "";
486
- const len = stripBidi(raw).length; // content length excludes injected bidi marks
487
- if (remaining <= len) {
488
- range.setStart(node, domOffsetForContentOffset(raw, remaining));
489
- placed = true;
490
- }
491
- else {
492
- remaining -= len;
493
- }
494
- }
495
- else {
496
- node.childNodes.forEach(walk);
497
- }
498
- };
499
- walk(el);
500
- if (!placed) {
578
+ // contentPositionIn clamps a past-end offset to the end of the last text node, and never
579
+ // lands inside marker chrome or a field — the same rules every other offset helper uses.
580
+ const pos = contentPositionIn(el, offset);
581
+ try {
582
+ range.setStart(pos.node, pos.offset);
583
+ }
584
+ catch {
501
585
  range.selectNodeContents(el);
502
586
  range.collapse(false);
503
587
  }
504
- else {
505
- range.collapse(true);
506
- }
588
+ range.collapse(true);
507
589
  sel.removeAllRanges();
508
590
  sel.addRange(range);
509
591
  }
@@ -534,36 +616,17 @@ function selectRange(el, start, length) {
534
616
  return;
535
617
  el.focus();
536
618
  const range = document.createRange();
537
- const end = start + length;
538
- let pos = 0;
539
- let startSet = false;
540
- const walk = (node) => {
541
- for (const child of Array.from(node.childNodes)) {
542
- if (child.nodeType === 3 /* TEXT_NODE */) {
543
- if (isInMarker(child))
544
- continue; // marker text isn't part of the content offset space
545
- const raw = child.textContent ?? "";
546
- const len = stripBidi(raw).length; // content length excludes injected bidi marks
547
- if (!startSet && pos + len >= start) {
548
- range.setStart(child, domOffsetForContentOffset(raw, start - pos));
549
- startSet = true;
550
- }
551
- if (startSet && pos + len >= end) {
552
- range.setEnd(child, domOffsetForContentOffset(raw, end - pos));
553
- return true;
554
- }
555
- pos += len;
556
- }
557
- else if (walk(child)) {
558
- return true;
559
- }
560
- }
561
- return false;
562
- };
563
- if (walk(el) || startSet) {
564
- sel.removeAllRanges();
565
- sel.addRange(range);
619
+ const from = contentPositionIn(el, start);
620
+ const to = contentPositionIn(el, start + length);
621
+ try {
622
+ range.setStart(from.node, from.offset);
623
+ range.setEnd(to.node, to.offset);
566
624
  }
625
+ catch {
626
+ return;
627
+ }
628
+ sel.removeAllRanges();
629
+ sel.addRange(range);
567
630
  }
568
631
  /**
569
632
  * True when `el`'s immediate parent is a paragraph-border `<div>` the full render wrapped it in
@@ -599,9 +662,12 @@ function selectionHasFormat(key, fallback) {
599
662
  }
600
663
  }
601
664
  /** Build the full ConvertDocxToHtmlComplete arg list (stampAnchors = last arg). */
602
- function completeArgs(bytes, cssPrefix, fabricate, paginated, scale, renderTrackedChanges) {
665
+ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale, renderTrackedChanges, comments) {
603
666
  return [
604
- bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
667
+ // Comment mode 1 = Inline: the commented runs are wrapped in highlight spans and the
668
+ // reference becomes an (editor-hidden) marker, which is what the comment gutter positions
669
+ // its bubbles against. -1 renders no comment markup at all.
670
+ bytes, "Document", cssPrefix, fabricate, "", comments ? 1 : -1, "comment-",
605
671
  /* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
606
672
  false, 0, "annot-",
607
673
  // Footnotes/endnotes ON: they are document content, and the editor makes the rendered note
@@ -669,6 +735,10 @@ export class DocxEditor {
669
735
  this.dragSelection = null;
670
736
  /** The docked header/footer bands, when `options.headerFooter` is on. */
671
737
  this.region = null;
738
+ /** The comment gutter, when `options.comments` is on. */
739
+ this.gutter = null;
740
+ /** Story host the caret is in (page view / band), published for chrome via `onStoryChange`. */
741
+ this.activeStory = null;
672
742
  /** Why the last reconcile() fell back to a full remount (null = it patched). For
673
743
  * diagnostics/specs; not part of the public API. */
674
744
  this.lastReconcileFallback = null;
@@ -878,8 +948,12 @@ export class DocxEditor {
878
948
  blockDrag: options.blockDrag ?? false,
879
949
  trackedChanges: options.trackedChanges ?? TrackedChangeMode.Accept,
880
950
  revisionAuthor: options.revisionAuthor ?? "docxodus",
951
+ comments: options.comments ?? true,
952
+ commentAuthor: options.commentAuthor ?? options.revisionAuthor ?? "Reviewer",
881
953
  onEdit: options.onEdit,
882
954
  onMove: options.onMove,
955
+ onStoryChange: options.onStoryChange,
956
+ onCommentsChange: options.onCommentsChange,
883
957
  };
884
958
  // NOT persistAnchorIds: that setting applies to every Save on the session, so it put the
885
959
  // projector's Unid bookkeeping into the bytes the USER downloads — ~6x the file size for
@@ -896,13 +970,18 @@ export class DocxEditor {
896
970
  editor.refreshAnchorMap();
897
971
  if (opts.headerFooter)
898
972
  editor.createRegion();
899
- const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale, opts.trackedChanges === TrackedChangeMode.RenderInline));
973
+ // First paint goes through the session-attached editor render when the bundle has it, so
974
+ // the comment markup (and everything else in the profile) is exactly what a remount will
975
+ // produce; older bundles take the bytes path with the same profile.
976
+ const fullHtml = editor.renderFullHtml(bytes);
900
977
  if (opts.paginated)
901
978
  editor.mountPaginated(fullHtml);
902
979
  else
903
980
  editor.mountHtml(fullHtml);
904
981
  editor.syncRegionToBody();
905
982
  editor.setupBlockDrag();
983
+ if (opts.comments)
984
+ editor.createGutter();
906
985
  return editor;
907
986
  }
908
987
  /**
@@ -931,6 +1010,9 @@ export class DocxEditor {
931
1010
  }
932
1011
  this.clearDragSelection();
933
1012
  this.teardownBlockDrag();
1013
+ this.gutter?.dispose();
1014
+ this.gutter = null;
1015
+ this.region?.detachPages();
934
1016
  this.viewport.dispose();
935
1017
  this.exports.DocxSessionBridge.CloseSession(this.handle);
936
1018
  }
@@ -1635,11 +1717,36 @@ export class DocxEditor {
1635
1717
  }
1636
1718
  /** Build the header/footer region (called once, before the first mount). */
1637
1719
  createRegion() {
1638
- this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, { cssPrefix: this.options.cssPrefix, fabricateClasses: this.options.fabricateClasses }, {
1720
+ this.region = new HeaderFooterRegion(this.exports.DocxSessionBridge, this.handle, {
1639
1721
  wireBlock: (el) => this.wireBlock(el),
1722
+ renderBlock: (anchorId) => this.renderInto(anchorId),
1640
1723
  refreshAnchorMap: () => this.refreshAnchorMap(),
1724
+ bodyAnchorIdOf: (unid) => this.unidToFullId.get(unid),
1725
+ remount: () => {
1726
+ if (!this.closed)
1727
+ this.remount();
1728
+ },
1729
+ onActiveChange: (_host, which) => {
1730
+ this.activeStory = which;
1731
+ this.options.onStoryChange?.(which);
1732
+ },
1641
1733
  });
1642
1734
  }
1735
+ /** Build the comment gutter over the container (called once, after the first mount). */
1736
+ createGutter() {
1737
+ ensureCommentGutterStyles(this.container.ownerDocument);
1738
+ this.gutter = new CommentGutter({
1739
+ container: this.container,
1740
+ commentAuthor: this.options.commentAuthor,
1741
+ listComments: () => this.listComments(),
1742
+ addComment: (markdown, author, target) => this.addComment(markdown, author, target),
1743
+ addCommentReply: (parent, markdown, author) => this.addCommentReply(parent, markdown, author),
1744
+ updateComment: (anchorId, markdown) => this.updateComment(anchorId, markdown),
1745
+ removeComment: (anchorId) => this.removeComment(anchorId),
1746
+ setCommentResolved: (anchorId, resolved) => this.setCommentResolved(anchorId, resolved),
1747
+ commentTarget: () => this.commentTarget(),
1748
+ }, { onChange: (info) => this.options.onCommentsChange?.(info) });
1749
+ }
1643
1750
  /**
1644
1751
  * Point the bands at the section governing the current focus (or the first body block).
1645
1752
  * Called after every mount, and on focus of a body block, so a multi-section document shows
@@ -1674,11 +1781,13 @@ export class DocxEditor {
1674
1781
  * and the scaled page, which are different boxes.
1675
1782
  */
1676
1783
  mountHtml(fullHtml) {
1784
+ this.region?.detachPages();
1677
1785
  const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
1678
1786
  const styles = Array.from(parsed.querySelectorAll("style"))
1679
1787
  .map((s) => s.outerHTML)
1680
1788
  .join("");
1681
1789
  this.container.innerHTML = styles;
1790
+ this.readoptGutter();
1682
1791
  const flow = document.createElement("div");
1683
1792
  flow.className = "docx-body-flow";
1684
1793
  flow.innerHTML = parsed.body.innerHTML;
@@ -1693,15 +1802,14 @@ export class DocxEditor {
1693
1802
  }
1694
1803
  /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
1695
1804
  mountPaginated(fullHtml) {
1696
- // With bands docked, pagination writes into its own wrapper so the bands can sit outside the
1697
- // page stack (and so pagination's innerHTML reset can never eat them).
1698
- let target = this.container;
1699
- if (this.region) {
1700
- this.container.innerHTML = "";
1701
- target = document.createElement("div");
1702
- target.className = "docx-body-flow";
1703
- this.container.appendChild(target);
1704
- }
1805
+ this.region?.detachPages();
1806
+ // Pagination writes into its own wrapper so the container can also hold the comment gutter
1807
+ // (and so pagination's innerHTML reset can never eat it).
1808
+ this.container.innerHTML = "";
1809
+ this.readoptGutter();
1810
+ const target = document.createElement("div");
1811
+ target.className = "docx-body-flow";
1812
+ this.container.appendChild(target);
1705
1813
  // Fragmented paragraphs intentionally have only one addressable head and
1706
1814
  // are therefore unsuitable for the editor's one-block editing model.
1707
1815
  paginateHtml(fullHtml, target, {
@@ -1721,10 +1829,11 @@ export class DocxEditor {
1721
1829
  this.editRoot = pageRoot;
1722
1830
  if (this.options.editable)
1723
1831
  this.wireBlocks(pageRoot);
1724
- // The page boxes render their own (read-only) header/footer margins; the editable bands dock
1725
- // around the page stack, so there is still exactly one addressable node per story paragraph.
1832
+ // The page boxes render their own header/footer margins as inert clones; the region turns
1833
+ // the clicked one into the live story in place (Word's edit-in-the-margin), so there is
1834
+ // still exactly one addressable node per story paragraph at any time.
1726
1835
  if (this.region)
1727
- this.dockBands(target);
1836
+ this.region.attachPages(pageRoot);
1728
1837
  // Page boxes already carry the section's dimensions, so the viewport contributes only the
1729
1838
  // fit zoom — a full page is far wider than a phone, and clipping it is not an option.
1730
1839
  this.viewport.attach(pageRoot, false);
@@ -1756,6 +1865,9 @@ export class DocxEditor {
1756
1865
  // a citation marker can't be deleted directly (which would orphan its note definition).
1757
1866
  el.querySelectorAll(GENERATED_CHROME_SELECTOR)
1758
1867
  .forEach((m) => m.setAttribute("contenteditable", "false"));
1868
+ // Page-number fields are atomic, and remember their cached result so per-page substitution
1869
+ // in the page view never shifts the offset space (see `fieldText`).
1870
+ adoptFields(el);
1759
1871
  // Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
1760
1872
  // matching the session's flat run-text offset space.
1761
1873
  el.dataset.committedText = blockContentText(el);
@@ -1766,8 +1878,10 @@ export class DocxEditor {
1766
1878
  // its own, and re-resolving would clobber the user's kind selection.
1767
1879
  if (this.region && !this.isBandBlock(el))
1768
1880
  this.syncRegionToBody(el);
1881
+ this.region?.noteFocus(el);
1769
1882
  });
1770
1883
  el.addEventListener("blur", () => this.commitBlock(el));
1884
+ el.addEventListener("input", (ev) => noteTypedSpace(el, ev));
1771
1885
  el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
1772
1886
  // A band block re-rendered by an incremental swap is a fresh DOM node; re-adopt it (with the
1773
1887
  // anchor its caller already stamped) so the band chrome can still address it.
@@ -1856,6 +1970,8 @@ export class DocxEditor {
1856
1970
  this.activeBlock = fresh; // keep ribbon target valid
1857
1971
  // The throwaway render numbers citation markers from 1 — repair in place.
1858
1972
  this.maybeRenumberNotes(fresh);
1973
+ if (inBand)
1974
+ this.region.afterStoryEdit(fresh);
1859
1975
  }
1860
1976
  }
1861
1977
  this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
@@ -2036,6 +2152,8 @@ export class DocxEditor {
2036
2152
  }
2037
2153
  this.wireBlock(firstEl);
2038
2154
  this.wireBlock(secondEl);
2155
+ if (inBand)
2156
+ this.region.afterStoryEdit(secondEl);
2039
2157
  placeCaretAtOffset(secondEl, 0);
2040
2158
  this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
2041
2159
  }
@@ -2087,6 +2205,8 @@ export class DocxEditor {
2087
2205
  this.unidToFullId.set(merged.unid, merged.id);
2088
2206
  }
2089
2207
  this.wireBlock(mergedEl);
2208
+ if (inBand)
2209
+ this.region.afterStoryEdit(mergedEl);
2090
2210
  placeCaretAtOffset(mergedEl, caret);
2091
2211
  this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
2092
2212
  }
@@ -2104,11 +2224,20 @@ export class DocxEditor {
2104
2224
  // but wireBlock may have stored textContent before this Task 2 change, and the bidi test
2105
2225
  // explicitly stores textContent). Using stripBidi keeps the baseline consistent with the
2106
2226
  // session's offset space regardless of how committedText was stored.
2107
- const old = stripBidi(el.dataset.committedText ?? "");
2108
- const next = blockContentText(el);
2227
+ // contenteditable keeps a typed trailing space as U+00A0 so it stays visible; the document
2228
+ // wants an ordinary space there (Word's own), and the two are the same length, so the
2229
+ // offset space is unchanged. Both sides are normalized the same way, so an untouched
2230
+ // placeholder (the NBSP an empty paragraph renders as) never reads as an edit \u2014 a blur that
2231
+ // committed "" over a paragraph another op just filled would wipe that op's work.
2232
+ const old = stripBidi(el.dataset.committedText ?? "").replace(/\u00a0$/, " ");
2233
+ const next = blockContentText(el).replace(/\u00a0$/, " ");
2109
2234
  if (old === next)
2110
2235
  return null;
2111
- if (old.trim().length === 0) {
2236
+ // A whitespace-only baseline means the paragraph was (or rendered as) empty, so the typed
2237
+ // text replaces it wholesale — unless it holds a field whose cached result is empty (a PAGE
2238
+ // field in a tool-generated footer): that is content, and the span path below keeps it
2239
+ // while inserting the typed text beside it.
2240
+ if (old.trim().length === 0 && !el.querySelector("[data-field]")) {
2112
2241
  return this.parseEdit(this.exports.DocxSessionBridge.ReplaceText(this.handle, fullId, serializeInlineMarkdown(el)));
2113
2242
  }
2114
2243
  const minLen = Math.min(old.length, next.length);
@@ -2121,10 +2250,16 @@ export class DocxEditor {
2121
2250
  let start = p;
2122
2251
  let len = old.length - p - s;
2123
2252
  let middle = next.slice(p, next.length - s);
2124
- // A pure insertion is a zero-length span, which resolves to no runs and is rejected. Anchor a
2125
- // neighbor char so the span is non-empty and the inserted text inherits an adjacent run's rPr
2126
- // (the LEFT run when there is one, matching contenteditable; the first run at the very start).
2127
2253
  if (len === 0) {
2254
+ // A pure insertion. The engine inserts a NEW run at a run boundary (and steps outside a
2255
+ // field's chrome, so typing after "Page X of Y" never lands inside the NUMPAGES result);
2256
+ // an offset inside a run's text is not a boundary and is refused, as is the op on an
2257
+ // older engine build. Then anchor a neighbour char so the span is non-empty and the
2258
+ // inserted text inherits an adjacent run's rPr (the LEFT run when there is one, matching
2259
+ // contenteditable; the first run at the very start).
2260
+ const inserted = this.parseEdit(this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, start, 0, middle));
2261
+ if (inserted.success)
2262
+ return inserted;
2128
2263
  if (start > 0) {
2129
2264
  start -= 1;
2130
2265
  len = 1;
@@ -2157,6 +2292,9 @@ export class DocxEditor {
2157
2292
  }
2158
2293
  renderBlockHtml(anchorId) {
2159
2294
  const bridge = this.exports.DocxSessionBridge;
2295
+ if (typeof bridge.RenderEditorBlockHtml === "function") {
2296
+ return bridge.RenderEditorBlockHtml(this.handle, anchorId, this.editorRenderProfile());
2297
+ }
2160
2298
  if (typeof bridge.RenderBlockHtmlForReview === "function") {
2161
2299
  return bridge.RenderBlockHtmlForReview(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges);
2162
2300
  }
@@ -2168,6 +2306,18 @@ export class DocxEditor {
2168
2306
  ? bridge.ListRenderedBlocks(this.handle, this.renderTrackedChanges)
2169
2307
  : bridge.ListBlocks(this.handle);
2170
2308
  }
2309
+ /** Batch-render `idsJson` anchors through the richest endpoint the bundle carries; the JSON
2310
+ * maps each anchor to its HTML (null when it failed to resolve), or carries `error`. */
2311
+ renderBlocksJson(idsJson) {
2312
+ const bridge = this.exports.DocxSessionBridge;
2313
+ if (typeof bridge.RenderEditorBlocksHtml === "function") {
2314
+ return bridge.RenderEditorBlocksHtml(this.handle, idsJson, this.editorRenderProfile());
2315
+ }
2316
+ if (typeof bridge.RenderBlocksHtmlForReview === "function") {
2317
+ return bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges);
2318
+ }
2319
+ return bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
2320
+ }
2171
2321
  /** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
2172
2322
  * the per-render shell/converter setup is paid once instead of twice, which matters on the
2173
2323
  * Enter path (split renders both halves synchronously under the keystroke). Falls back to
@@ -2178,9 +2328,7 @@ export class DocxEditor {
2178
2328
  if (typeof bridge.RenderBlocksHtml === "function") {
2179
2329
  try {
2180
2330
  const idsJson = JSON.stringify([a, b]);
2181
- const json = typeof bridge.RenderBlocksHtmlForReview === "function"
2182
- ? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
2183
- : bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
2331
+ const json = this.renderBlocksJson(idsJson);
2184
2332
  const map = JSON.parse(json);
2185
2333
  if (!map.error) {
2186
2334
  const parse = (h) => h
@@ -2208,8 +2356,10 @@ export class DocxEditor {
2208
2356
  parseEdit(json) {
2209
2357
  try {
2210
2358
  const result = JSON.parse(json);
2211
- if (result.success)
2359
+ if (result.success) {
2212
2360
  this.invalidateBlockMoveTargets();
2361
+ this.gutter?.schedule();
2362
+ }
2213
2363
  return result;
2214
2364
  }
2215
2365
  catch {
@@ -2762,24 +2912,131 @@ export class DocxEditor {
2762
2912
  * annotation type legal review runs on, finally authorable from the shipped surface
2763
2913
  * (issue #580).
2764
2914
  */
2765
- addComment(markdown = "New comment.", author = "Reviewer") {
2766
- const block = this.activeBlock;
2915
+ addComment(markdown = "New comment.", author = this.options.commentAuthor, target) {
2916
+ let block = target?.block.isConnected ? target.block : this.activeBlock;
2917
+ let span = target ? target.span : null;
2918
+ if (target && !target.block.isConnected) {
2919
+ // The draft's block was swapped since it was captured — the click that posts a draft
2920
+ // blurs and commits the paragraph. Find the same paragraph by anchor rather than taking
2921
+ // whatever block is active now, and keep the span only while the text it was measured
2922
+ // on is unchanged; otherwise comment the whole paragraph rather than the wrong characters.
2923
+ const live = target.anchor === undefined ? null : this.blockByAnchor(target.anchor);
2924
+ if (live)
2925
+ block = live;
2926
+ if (!live || target.text === undefined || blockContentText(live) !== target.text)
2927
+ span = null;
2928
+ }
2767
2929
  if (this.closed || !block)
2768
- return;
2930
+ return null;
2769
2931
  const bridge = this.exports.DocxSessionBridge;
2770
2932
  if (!bridge.AddComment)
2771
- return; // bridge predates comment authoring
2933
+ return null; // bridge predates comment authoring
2772
2934
  let fullId = this.anchorIdOf(block);
2773
2935
  if (!fullId)
2774
- return;
2936
+ return null;
2775
2937
  const idx = this.blockIndex(block);
2776
- // Span first: syncBlock re-renders the block and would drop the live selection.
2777
- const span = selectionSpanIn(block);
2938
+ // Span first: syncBlock re-renders the block and would drop the live selection. A target
2939
+ // captured earlier (the gutter's draft bubble) wins over whatever the selection is now.
2940
+ if (!target)
2941
+ span = selectionSpanIn(block);
2778
2942
  fullId = this.syncBlock(block, fullId);
2779
2943
  const res = this.parseEdit(bridge.AddComment(this.handle, fullId, span ? JSON.stringify(span) : "", author, "", "", markdown));
2780
2944
  if (!res.success)
2781
- return;
2782
- this.reconcile(idx, false);
2945
+ return null;
2946
+ const created = res.created?.find((a) => a.kind === "cmt")?.id ?? null;
2947
+ // Comment markup lands inside the host paragraph, so it re-renders like any text edit; the
2948
+ // gutter then picks the new highlight up on its next layout.
2949
+ if (this.isBandBlock(block))
2950
+ this.refreshAfter(block, idx, false);
2951
+ else
2952
+ this.reconcile(idx, false);
2953
+ const entry = created ? this.listComments().find((c) => c.anchorId === created) ?? null : null;
2954
+ return entry;
2955
+ }
2956
+ /** Reply to a thread root (or any comment) as a native Word reply (`commentsExtended`). */
2957
+ addCommentReply(parentAnchorId, markdown, author = this.options.commentAuthor) {
2958
+ if (this.closed)
2959
+ return false;
2960
+ const bridge = this.exports.DocxSessionBridge;
2961
+ if (!bridge.AddCommentReply)
2962
+ return false;
2963
+ const res = this.parseEdit(bridge.AddCommentReply(this.handle, parentAnchorId, author, "", "", markdown));
2964
+ if (!res.success)
2965
+ return false;
2966
+ // A reply adds a reference run beside the parent's, so the host paragraph re-renders.
2967
+ this.reconcile();
2968
+ return true;
2969
+ }
2970
+ /** Replace a comment's body text; author/date are preserved. */
2971
+ updateComment(commentAnchorId, markdown) {
2972
+ if (this.closed)
2973
+ return false;
2974
+ const bridge = this.exports.DocxSessionBridge;
2975
+ if (!bridge.UpdateComment)
2976
+ return false;
2977
+ return this.parseEdit(bridge.UpdateComment(this.handle, commentAnchorId, markdown)).success;
2978
+ }
2979
+ /** Delete a comment: the definition and its range markers everywhere. */
2980
+ removeComment(commentAnchorId) {
2981
+ if (this.closed)
2982
+ return false;
2983
+ const bridge = this.exports.DocxSessionBridge;
2984
+ if (!bridge.RemoveComment)
2985
+ return false;
2986
+ const res = this.parseEdit(bridge.RemoveComment(this.handle, commentAnchorId));
2987
+ if (!res.success)
2988
+ return false;
2989
+ this.reconcile();
2990
+ return true;
2991
+ }
2992
+ /** What "New comment" would comment on right now: the active block and the selection in it. */
2993
+ commentTarget() {
2994
+ const block = this.activeBlock;
2995
+ if (this.closed || !block || !block.isConnected)
2996
+ return null;
2997
+ let span = selectionSpanIn(block);
2998
+ const unid = block.getAttribute("data-anchor");
2999
+ if (!span && this.lastSelection && this.lastSelection.unid === unid)
3000
+ span = this.lastSelection.span;
3001
+ return { block, span, anchor: this.anchorIdOf(block), text: blockContentText(block) };
3002
+ }
3003
+ /** The live block for a full anchor id, or null when no mounted block resolves to it. */
3004
+ blockByAnchor(anchor) {
3005
+ for (const el of Array.from(this.container.querySelectorAll("[data-anchor]"))) {
3006
+ if (this.anchorIdOf(el) === anchor)
3007
+ return el;
3008
+ }
3009
+ return null;
3010
+ }
3011
+ /** Open a draft comment bubble beside the selection (Word's "New Comment"). */
3012
+ beginComment() {
3013
+ return this.gutter?.beginDraft() ?? false;
3014
+ }
3015
+ cancelComment() {
3016
+ this.gutter?.cancelDraft();
3017
+ }
3018
+ /** Activate a thread by `cmt` anchor id or numeric comment id (null clears). */
3019
+ activateComment(id) {
3020
+ this.gutter?.setActive(id, { scrollBubble: true, scrollAnchor: true });
3021
+ }
3022
+ /** Step to the next (+1) / previous (−1) thread in document order. */
3023
+ stepComment(direction) {
3024
+ return this.gutter?.step(direction) ?? null;
3025
+ }
3026
+ /** The active thread root's anchor id, or null. */
3027
+ get activeComment() {
3028
+ return this.gutter?.active ?? null;
3029
+ }
3030
+ /** Show or hide the comment gutter (the markup stays in the document). */
3031
+ showComments(visible) {
3032
+ this.gutter?.setVisible(visible);
3033
+ }
3034
+ get commentsVisible() {
3035
+ return this.gutter?.isVisible ?? false;
3036
+ }
3037
+ /** Force the gutter to lay out now (tests). */
3038
+ layoutComments() {
3039
+ this.gutter?.layout();
2783
3040
  }
2784
3041
  /** The document's native comment threads (session truth), for review UIs — flat entries
2785
3042
  * with `parentAnchorId` linking replies and `resolved` carrying thread state. */
@@ -2801,11 +3058,11 @@ export class DocxEditor {
2801
3058
  * needed — a review UI re-reads {@link listComments} for the new state. */
2802
3059
  setCommentResolved(commentAnchorId, resolved) {
2803
3060
  if (this.closed)
2804
- return;
3061
+ return false;
2805
3062
  const bridge = this.exports.DocxSessionBridge;
2806
3063
  if (!bridge.SetCommentResolved)
2807
- return;
2808
- this.parseEdit(bridge.SetCommentResolved(this.handle, commentAnchorId, resolved));
3064
+ return false;
3065
+ return this.parseEdit(bridge.SetCommentResolved(this.handle, commentAnchorId, resolved)).success;
2809
3066
  }
2810
3067
  applyParagraphFormat(op) {
2811
3068
  const block = this.activeBlock;
@@ -2907,8 +3164,11 @@ export class DocxEditor {
2907
3164
  const block = this.activeBlock;
2908
3165
  const band = block ? this.region.bandOf(block) : null;
2909
3166
  const anchorId = block?.getAttribute("data-hf-anchor");
2910
- if (band && anchorId) {
2911
- this.region.insertPageNumber(this.region.whichOf(band), anchorId, field);
3167
+ if (block && band && anchorId) {
3168
+ // Flush uncommitted typing first: the field appends to the SESSION paragraph, and the
3169
+ // story repaint that follows would otherwise blur-commit stale DOM text over it.
3170
+ const synced = this.syncBlock(block, anchorId);
3171
+ this.region.insertPageNumber(this.region.whichOf(band), synced, field);
2912
3172
  return;
2913
3173
  }
2914
3174
  // No band block focused — target the footer, where page numbers overwhelmingly live.
@@ -2939,6 +3199,629 @@ export class DocxEditor {
2939
3199
  pageNumbering() {
2940
3200
  return this.region?.pageNumbering() ?? {};
2941
3201
  }
3202
+ // ─── Font group ──────────────────────────────────────────────────────
3203
+ /**
3204
+ * Apply one inline `FormatOp` to the selection: a sub-range of the active block, the whole
3205
+ * block when the caret is collapsed, or every block of a multi-block selection. The last real
3206
+ * selection is used when a focus-stealing control (a colour picker) collapsed the live one.
3207
+ */
3208
+ applyInlineFormat(op) {
3209
+ const block = this.activeBlock;
3210
+ if (this.closed || !block)
3211
+ return;
3212
+ const blocks = this.selectedBlocks();
3213
+ if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, op))
3214
+ return;
3215
+ const unid = block.getAttribute("data-anchor");
3216
+ if (!unid)
3217
+ return;
3218
+ let fullId = this.anchorIdOf(block);
3219
+ if (!fullId)
3220
+ return;
3221
+ let span = selectionSpanIn(block);
3222
+ if (!span && this.lastSelection && this.lastSelection.unid === unid)
3223
+ span = this.lastSelection.span;
3224
+ fullId = this.syncBlock(block, fullId);
3225
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify(op)));
3226
+ if (!res.success)
3227
+ return;
3228
+ if (this.affectsList(res)) {
3229
+ this.refreshAfter(block, this.blockIndex(block), false);
3230
+ return;
3231
+ }
3232
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
3233
+ if (fresh && span)
3234
+ selectRange(fresh, span.start, span.length);
3235
+ else
3236
+ fresh?.focus();
3237
+ }
3238
+ /** Font colour as a hex triplet (with or without '#'); `""` clears the explicit colour. */
3239
+ setFontColor(hex) {
3240
+ this.applyInlineFormat({ color: hex.replace(/^#/, "").toUpperCase() });
3241
+ }
3242
+ /** Word highlight colour name (`"yellow"`, `"green"`, …); `""` removes the highlight. */
3243
+ setHighlight(name) {
3244
+ this.applyInlineFormat({ highlight: name });
3245
+ }
3246
+ setAllCaps(on) {
3247
+ this.applyInlineFormat({ caps: on });
3248
+ }
3249
+ setSmallCaps(on) {
3250
+ this.applyInlineFormat({ smallCaps: on });
3251
+ }
3252
+ /** Word's "Clear All Formatting": drop every direct run property on the selection. */
3253
+ clearFormatting() {
3254
+ this.applyInlineFormat({
3255
+ bold: false, italic: false, underline: false, strike: false, code: false, color: "",
3256
+ highlight: "", vertAlign: "", fontSizePts: 0, fontFamily: "", runStyle: "",
3257
+ caps: false, smallCaps: false,
3258
+ });
3259
+ }
3260
+ /** The caret's rendered font size in points (from computed style), or null. */
3261
+ fontSizeAtCaret() {
3262
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
3263
+ let el = this.activeBlock;
3264
+ if (sel && sel.rangeCount > 0) {
3265
+ const n = sel.getRangeAt(0).startContainer;
3266
+ const candidate = n.nodeType === 3 ? n.parentElement : n;
3267
+ if (candidate && this.container.contains(candidate))
3268
+ el = candidate;
3269
+ }
3270
+ if (!el || typeof getComputedStyle !== "function")
3271
+ return null;
3272
+ const px = parseFloat(getComputedStyle(el).fontSize);
3273
+ return px > 0 ? Math.round(px * 0.75 * 2) / 2 : null;
3274
+ }
3275
+ /** Grow (+) or shrink (−) the selection's font size by `delta` points (Word's A↑ / A↓). */
3276
+ adjustFontSize(delta) {
3277
+ const size = this.fontSizeAtCaret();
3278
+ if (size == null)
3279
+ return;
3280
+ this.setFontSize(Math.max(1, Math.round((size + delta) * 2) / 2));
3281
+ }
3282
+ // ─── Paragraph group ─────────────────────────────────────────────────
3283
+ /** Line spacing as a multiple of single (1, 1.15, 1.5, 2 …) — `w:spacing/@w:line` under `auto`. */
3284
+ setLineSpacing(multiple) {
3285
+ this.applyParagraphFormat({ lineSpacing: Math.round(multiple * 240), lineSpacingRule: "auto" });
3286
+ }
3287
+ /** Space before/after the paragraph, in POINTS (Word's Paragraph dialog units). */
3288
+ setParagraphSpacing(op) {
3289
+ const payload = {};
3290
+ if (op.beforePt != null)
3291
+ payload.spacingBefore = Math.max(0, Math.round(op.beforePt * 20));
3292
+ if (op.afterPt != null)
3293
+ payload.spacingAfter = Math.max(0, Math.round(op.afterPt * 20));
3294
+ if (Object.keys(payload).length === 0)
3295
+ return;
3296
+ this.applyParagraphFormat(payload);
3297
+ }
3298
+ /** First-line indent in twips (0 = none); removes any hanging indent. */
3299
+ setFirstLineIndent(twips) {
3300
+ this.applyParagraphFormat({ firstLineIndent: Math.max(0, twips) });
3301
+ }
3302
+ /** Hanging indent in twips (0 = none); removes any first-line indent. */
3303
+ setHangingIndent(twips) {
3304
+ this.applyParagraphFormat({ hangingIndent: Math.max(0, twips) });
3305
+ }
3306
+ /**
3307
+ * Make the active block (or every selected block) a list item of `kind` — the full Word
3308
+ * numbering gallery, not just bullets/decimal — or a plain paragraph with `"none"`.
3309
+ */
3310
+ setListFormat(kind) {
3311
+ const block = this.activeBlock;
3312
+ if (this.closed || !block)
3313
+ return;
3314
+ const blocks = this.selectedBlocks();
3315
+ if (blocks.length > 1 &&
3316
+ this.applyParagraphOpAcrossBlocks(blocks, (id) => this.exports.DocxSessionBridge.ApplyListFormat(this.handle, id, kind), true))
3317
+ return;
3318
+ const unid = block.getAttribute("data-anchor");
3319
+ if (!unid)
3320
+ return;
3321
+ let fullId = this.anchorIdOf(block);
3322
+ if (!fullId)
3323
+ return;
3324
+ const idx = this.blockIndex(block);
3325
+ fullId = this.syncBlock(block, fullId);
3326
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, kind));
3327
+ if (!res.success)
3328
+ return;
3329
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
3330
+ }
3331
+ /** The active block's list format (`"bullet"`, `"decimal"`, …), or null when it is not a list item. */
3332
+ listFormatAtCaret() {
3333
+ const block = this.activeBlock;
3334
+ if (this.closed || !block || !isListBlock(block))
3335
+ return null;
3336
+ const fullId = this.anchorIdOf(block);
3337
+ if (!fullId)
3338
+ return null;
3339
+ try {
3340
+ const membership = JSON.parse(this.exports.DocxSessionBridge.GetListMembership(this.handle, fullId));
3341
+ return membership?.format ?? null;
3342
+ }
3343
+ catch {
3344
+ return null;
3345
+ }
3346
+ }
3347
+ /** Paragraph formatting (direct + effective) of the active block, for ribbon state. */
3348
+ paragraphFormatting() {
3349
+ const block = this.activeBlock;
3350
+ const bridge = this.exports.DocxSessionBridge;
3351
+ if (this.closed || !block || typeof bridge.GetFormatting !== "function")
3352
+ return null;
3353
+ const fullId = this.anchorIdOf(block);
3354
+ if (!fullId)
3355
+ return null;
3356
+ try {
3357
+ const parsed = JSON.parse(bridge.GetFormatting(this.handle, fullId));
3358
+ return parsed && typeof parsed === "object" && "effectiveParagraph" in parsed ? parsed : null;
3359
+ }
3360
+ catch {
3361
+ return null;
3362
+ }
3363
+ }
3364
+ // ─── Links, images, references ───────────────────────────────────────
3365
+ /** Wrap the selection in a hyperlink (external URL, or an internal bookmark name). */
3366
+ insertHyperlink(target, kind = "external") {
3367
+ const block = this.activeBlock;
3368
+ const bridge = this.exports.DocxSessionBridge;
3369
+ if (this.closed || !block || typeof bridge.AddHyperlink !== "function")
3370
+ return false;
3371
+ const unid = block.getAttribute("data-anchor");
3372
+ if (!unid)
3373
+ return false;
3374
+ let fullId = this.anchorIdOf(block);
3375
+ if (!fullId)
3376
+ return false;
3377
+ let span = selectionSpanIn(block);
3378
+ if (!span && this.lastSelection && this.lastSelection.unid === unid)
3379
+ span = this.lastSelection.span;
3380
+ if (!span)
3381
+ return false; // Word needs a range to link
3382
+ fullId = this.syncBlock(block, fullId);
3383
+ const res = this.parseEdit(bridge.AddHyperlink(this.handle, fullId, span.start, span.length, kind, target));
3384
+ if (!res.success)
3385
+ return false;
3386
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
3387
+ if (fresh)
3388
+ selectRange(fresh, span.start, span.length);
3389
+ return true;
3390
+ }
3391
+ /** The hyperlink the caret (or selection start) sits in, or null. */
3392
+ hyperlinkAtCaret() {
3393
+ const block = this.activeBlock;
3394
+ const bridge = this.exports.DocxSessionBridge;
3395
+ if (this.closed || !block || typeof bridge.ListHyperlinks !== "function")
3396
+ return null;
3397
+ const fullId = this.anchorIdOf(block);
3398
+ if (!fullId)
3399
+ return null;
3400
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
3401
+ let offset = 0;
3402
+ if (sel && sel.rangeCount > 0 && block.contains(sel.getRangeAt(0).startContainer)) {
3403
+ const r = sel.getRangeAt(0);
3404
+ offset = contentOffsetOf(block, r.startContainer, r.startOffset);
3405
+ }
3406
+ try {
3407
+ const links = JSON.parse(bridge.ListHyperlinks(this.handle, 63));
3408
+ return (links.find((l) => l.anchorId === fullId && offset >= l.span.start && offset <= l.span.start + l.span.length) ?? null);
3409
+ }
3410
+ catch {
3411
+ return null;
3412
+ }
3413
+ }
3414
+ /** Remove the hyperlink at the caret, keeping its text. */
3415
+ removeHyperlink() {
3416
+ const link = this.hyperlinkAtCaret();
3417
+ const bridge = this.exports.DocxSessionBridge;
3418
+ const block = this.activeBlock;
3419
+ if (!link || !block || typeof bridge.RemoveHyperlink !== "function")
3420
+ return false;
3421
+ const unid = block.getAttribute("data-anchor");
3422
+ const res = this.parseEdit(bridge.RemoveHyperlink(this.handle, link.id));
3423
+ if (!res.success)
3424
+ return false;
3425
+ if (unid)
3426
+ this.swapBlock(block, unid, res.modified?.[0]);
3427
+ return true;
3428
+ }
3429
+ /** Insert an inline image (base64 bytes) at the caret in the active body block. */
3430
+ insertImage(imageBase64, options = {}) {
3431
+ const block = this.activeBlock;
3432
+ const bridge = this.exports.DocxSessionBridge;
3433
+ if (this.closed || !block || typeof bridge.InsertImage !== "function")
3434
+ return false;
3435
+ let fullId = this.anchorIdOf(block);
3436
+ if (!fullId)
3437
+ return false;
3438
+ const idx = this.blockIndex(block);
3439
+ const raw = caretOffsetIn(block);
3440
+ fullId = this.syncBlock(block, fullId);
3441
+ const offset = trimmedSplitOffset(block, raw ?? (block.textContent ?? "").length);
3442
+ const res = this.parseEdit(bridge.InsertImage(this.handle, fullId, offset, imageBase64, JSON.stringify(options)));
3443
+ if (!res.success)
3444
+ return false;
3445
+ // Image parts are not in the block-render shell, so a single-block swap would drop the
3446
+ // picture; the full render is the only path that shows it.
3447
+ this.refreshAfter(block, idx, true, /* forceRemount */ true);
3448
+ return true;
3449
+ }
3450
+ /** Read a picked file and insert it as an inline image. */
3451
+ async insertImageFile(file, options = {}) {
3452
+ const bytes = new Uint8Array(await file.arrayBuffer());
3453
+ let binary = "";
3454
+ const chunk = 0x8000;
3455
+ for (let i = 0; i < bytes.length; i += chunk) {
3456
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
3457
+ }
3458
+ return this.insertImage(btoa(binary), options);
3459
+ }
3460
+ /** Insert a table of contents (a real TOC field) before the active block. */
3461
+ insertTableOfContents(options = {}) {
3462
+ const block = this.activeBlock;
3463
+ const bridge = this.exports.DocxSessionBridge;
3464
+ if (this.closed || !block || typeof bridge.InsertTableOfContents !== "function")
3465
+ return false;
3466
+ if (this.isBandBlock(block) || block.closest("table, .footnotes, .endnotes"))
3467
+ return false;
3468
+ let fullId = this.anchorIdOf(block);
3469
+ if (!fullId)
3470
+ return false;
3471
+ const idx = this.blockIndex(block);
3472
+ fullId = this.syncBlock(block, fullId);
3473
+ const res = this.parseEdit(bridge.InsertTableOfContents(this.handle, fullId, "before", JSON.stringify(options)));
3474
+ if (!res.success)
3475
+ return false;
3476
+ this.refreshAfter(block, idx, false, /* forceRemount */ true);
3477
+ return true;
3478
+ }
3479
+ // ─── Table group (extended) ──────────────────────────────────────────
3480
+ /** Merge the active cell with `rowSpan`×`colSpan` neighbours (down and right). */
3481
+ mergeCells(rowSpan, colSpan) {
3482
+ const bridge = this.exports.DocxSessionBridge;
3483
+ if (typeof bridge.MergeCells !== "function")
3484
+ return;
3485
+ this.tableEdit((a) => bridge.MergeCells(this.handle, a, rowSpan, colSpan, ""));
3486
+ }
3487
+ /** Split a merged cell back into its grid cells. */
3488
+ unmergeCells() {
3489
+ const bridge = this.exports.DocxSessionBridge;
3490
+ if (typeof bridge.UnmergeCells !== "function")
3491
+ return;
3492
+ this.tableEdit((a) => bridge.UnmergeCells(this.handle, a));
3493
+ }
3494
+ /** Set (or with `style: "none"` remove) the borders of the active cell's table. */
3495
+ setTableBorders(spec) {
3496
+ const bridge = this.exports.DocxSessionBridge;
3497
+ if (typeof bridge.SetTableBorders !== "function")
3498
+ return;
3499
+ this.tableEdit((a) => bridge.SetTableBorders(this.handle, a, JSON.stringify(spec)));
3500
+ }
3501
+ /** Shade the active cell (or its row/column/table) with a hex fill; `""` clears. */
3502
+ setCellShading(fill, scope = "cell") {
3503
+ const bridge = this.exports.DocxSessionBridge;
3504
+ if (typeof bridge.SetCellShading !== "function")
3505
+ return;
3506
+ this.tableEdit((a) => bridge.SetCellShading(this.handle, a, fill.replace(/^#/, ""), scope));
3507
+ }
3508
+ /** Repeat the active cell's row at the top of every page the table spans. */
3509
+ setRepeatHeaderRow(repeat) {
3510
+ const bridge = this.exports.DocxSessionBridge;
3511
+ if (typeof bridge.SetRepeatHeaderRow !== "function")
3512
+ return;
3513
+ this.tableEdit((a) => bridge.SetRepeatHeaderRow(this.handle, a, repeat));
3514
+ }
3515
+ /** Delete the whole table the caret is in. */
3516
+ deleteTable() {
3517
+ const block = this.activeBlock;
3518
+ const table = block?.closest("table");
3519
+ if (this.closed || !block || !table)
3520
+ return;
3521
+ const tableId = this.anchorIdOf(table);
3522
+ if (!tableId)
3523
+ return;
3524
+ const idx = this.blockIndex(block);
3525
+ const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, tableId));
3526
+ if (!res.success)
3527
+ return;
3528
+ this.refreshAfter(block, Math.max(0, idx - 1), true);
3529
+ }
3530
+ // ─── Review group ────────────────────────────────────────────────────
3531
+ /** How edits are being recorded right now. */
3532
+ get trackedChanges() {
3533
+ return this.options.trackedChanges;
3534
+ }
3535
+ /**
3536
+ * Switch tracked-changes mode mid-session (Word's "Track Changes" toggle). Undo history and
3537
+ * edits survive; the document re-renders so revisions show (or stop showing) inline.
3538
+ */
3539
+ setTrackedChanges(mode) {
3540
+ this.assertOpen();
3541
+ if (this.options.trackedChanges === mode)
3542
+ return;
3543
+ const bridge = this.exports.DocxSessionBridge;
3544
+ if (typeof bridge.SetTrackedChanges !== "function")
3545
+ return;
3546
+ bridge.SetTrackedChanges(this.handle, mode);
3547
+ this.options.trackedChanges = mode;
3548
+ this.remount();
3549
+ }
3550
+ setRevisionAuthor(author) {
3551
+ this.assertOpen();
3552
+ this.options.revisionAuthor = author;
3553
+ this.exports.DocxSessionBridge.SetRevisionAuthor?.(this.handle, author);
3554
+ }
3555
+ /** Every tracked revision in the document, from the session's registry. */
3556
+ listRevisions() {
3557
+ const bridge = this.exports.DocxSessionBridge;
3558
+ if (this.closed || typeof bridge.ListRevisions !== "function")
3559
+ return [];
3560
+ try {
3561
+ const parsed = JSON.parse(bridge.ListRevisions(this.handle));
3562
+ return Array.isArray(parsed) ? parsed : [];
3563
+ }
3564
+ catch {
3565
+ return [];
3566
+ }
3567
+ }
3568
+ acceptRevision(revisionId) {
3569
+ return this.resolveRevision("AcceptRevision", revisionId);
3570
+ }
3571
+ rejectRevision(revisionId) {
3572
+ return this.resolveRevision("RejectRevision", revisionId);
3573
+ }
3574
+ acceptAllRevisions() {
3575
+ return this.resolveRevision("AcceptAllRevisions");
3576
+ }
3577
+ rejectAllRevisions() {
3578
+ return this.resolveRevision("RejectAllRevisions");
3579
+ }
3580
+ resolveRevision(op, revisionId) {
3581
+ const bridge = this.exports.DocxSessionBridge;
3582
+ if (this.closed)
3583
+ return false;
3584
+ const idx = this.activeBlock ? this.blockIndex(this.activeBlock) : -1;
3585
+ let json;
3586
+ if (op === "AcceptRevision" || op === "RejectRevision") {
3587
+ const fn = bridge[op];
3588
+ if (typeof fn !== "function" || !revisionId)
3589
+ return false;
3590
+ json = fn(this.handle, revisionId);
3591
+ }
3592
+ else {
3593
+ const fn = bridge[op];
3594
+ if (typeof fn !== "function")
3595
+ return false;
3596
+ json = fn(this.handle);
3597
+ }
3598
+ const res = this.parseEdit(json);
3599
+ if (!res.success)
3600
+ return false;
3601
+ // Resolution rewrites markup across the document; the reconciler proves what it can.
3602
+ this.reconcile(idx, false);
3603
+ return true;
3604
+ }
3605
+ /** Rendered revision marks in document order — what Previous/Next step through. */
3606
+ revisionElements() {
3607
+ return Array.from(this.container.querySelectorAll('ins[class*="-ins"], del[class*="-del"], ins[class*="move-to"], del[class*="move-from"], ' +
3608
+ 'tr[class*="row-ins"], tr[class*="row-del"]')).filter((el) => !el.closest("#pagination-staging, .docx-comment-gutter"));
3609
+ }
3610
+ /** The registry entry a rendered revision mark belongs to (matched by block, then by text). */
3611
+ revisionAt(el) {
3612
+ const block = el.closest("[data-anchor]");
3613
+ const unid = block?.getAttribute("data-anchor");
3614
+ const candidates = this.listRevisions().filter((r) => r.resolutionStatus === "supported");
3615
+ if (!unid)
3616
+ return candidates[0] ?? null;
3617
+ const inBlock = candidates.filter((r) => r.anchorId?.endsWith(unid) || r.affectedAnchors?.some((a) => a.unid === unid));
3618
+ if (inBlock.length === 0)
3619
+ return null;
3620
+ const text = (el.textContent ?? "").trim();
3621
+ return inBlock.find((r) => text && r.text.trim() === text) ?? inBlock[0];
3622
+ }
3623
+ // ─── Find & replace ──────────────────────────────────────────────────
3624
+ /** Every occurrence of `query` in the editable blocks, in document order. */
3625
+ find(query, options = {}) {
3626
+ if (!query)
3627
+ return [];
3628
+ const needle = options.matchCase ? query : query.toLowerCase();
3629
+ const blocks = this.editableList().concat(Array.from(this.container.querySelectorAll('[data-hf-band] [data-anchor][contenteditable="true"]')));
3630
+ const out = [];
3631
+ for (const block of blocks) {
3632
+ const text = blockContentText(block);
3633
+ const hay = options.matchCase ? text : text.toLowerCase();
3634
+ let from = 0;
3635
+ for (;;) {
3636
+ const at = hay.indexOf(needle, from);
3637
+ if (at < 0)
3638
+ break;
3639
+ out.push({ block, start: at, length: query.length });
3640
+ from = at + Math.max(1, query.length);
3641
+ }
3642
+ }
3643
+ return out;
3644
+ }
3645
+ /** Select a match and scroll it into view. */
3646
+ selectMatch(match) {
3647
+ if (!match.block.isConnected)
3648
+ return;
3649
+ this.activeBlock = match.block;
3650
+ selectRange(match.block, match.start, match.length);
3651
+ match.block.scrollIntoView({ block: "center", behavior: "smooth" });
3652
+ }
3653
+ /** Replace one match's text (formatting of the surrounding run is inherited). */
3654
+ replaceMatch(match, replacement) {
3655
+ const block = match.block;
3656
+ if (this.closed || !block.isConnected)
3657
+ return false;
3658
+ const unid = block.getAttribute("data-anchor");
3659
+ if (!unid)
3660
+ return false;
3661
+ let fullId = this.anchorIdOf(block);
3662
+ if (!fullId)
3663
+ return false;
3664
+ fullId = this.syncBlock(block, fullId);
3665
+ const span = trimmedSpan(block, { start: match.start, length: match.length });
3666
+ if (span.length === 0)
3667
+ return false;
3668
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, span.start, span.length, replacement));
3669
+ if (!res.success)
3670
+ return false;
3671
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
3672
+ if (fresh)
3673
+ selectRange(fresh, span.start, replacement.length);
3674
+ return true;
3675
+ }
3676
+ /** Replace every occurrence; returns how many were replaced. */
3677
+ replaceAll(query, replacement, options = {}) {
3678
+ if (this.closed || !query)
3679
+ return 0;
3680
+ const matches = this.find(query, options);
3681
+ let count = 0;
3682
+ // Group by block and replace from the END so earlier offsets stay valid.
3683
+ const byBlock = new Map();
3684
+ for (const m of matches) {
3685
+ const list = byBlock.get(m.block) ?? [];
3686
+ list.push(m);
3687
+ byBlock.set(m.block, list);
3688
+ }
3689
+ for (const [block, list] of byBlock) {
3690
+ let current = block;
3691
+ for (const m of list.slice().sort((a, b) => b.start - a.start)) {
3692
+ if (!current.isConnected)
3693
+ break;
3694
+ const before = current;
3695
+ if (this.replaceMatch({ block: current, start: m.start, length: m.length }, replacement)) {
3696
+ count++;
3697
+ // swapBlock replaced the node; keep following it.
3698
+ current = this.activeBlock && this.activeBlock !== before ? this.activeBlock : current;
3699
+ }
3700
+ }
3701
+ }
3702
+ return count;
3703
+ }
3704
+ // ─── View ────────────────────────────────────────────────────────────
3705
+ /** Set the author-pinned zoom (1 = 100%). Fit-to-width still caps it on narrow hosts. */
3706
+ setZoom(scale) {
3707
+ this.assertOpen();
3708
+ this.viewport.setScale(scale);
3709
+ this.gutter?.schedule();
3710
+ }
3711
+ /** The zoom the user asked for (what a zoom control shows), before fit-to-width caps it. */
3712
+ get requestedZoom() {
3713
+ return this.viewport.requestedScale;
3714
+ }
3715
+ /** Word count over the body's editable text. */
3716
+ wordCount() {
3717
+ let count = 0;
3718
+ for (const block of this.editableList()) {
3719
+ // Notes are not body words in either view (the paginator's per-page note block carries
3720
+ // the "page-footnotes" class; the continuous view's sections are ".footnotes"/".endnotes").
3721
+ if (block.closest('.footnotes, .endnotes, [class$="-footnotes"], [class$="-endnotes"]'))
3722
+ continue;
3723
+ const text = blockContentText(block);
3724
+ const words = text.match(/[\p{L}\p{N}][\p{L}\p{N}'’.-]*/gu);
3725
+ count += words ? words.length : 0;
3726
+ }
3727
+ return count;
3728
+ }
3729
+ /** In page view, the active block's page and the page total; null in continuous view. */
3730
+ pageInfo() {
3731
+ if (!this.options.paginated)
3732
+ return null;
3733
+ const boxes = this.editRoot.querySelectorAll(".page-box:not([data-section-filler])");
3734
+ const total = boxes.length;
3735
+ const box = this.activeBlock?.closest(".page-box");
3736
+ const page = box ? parseInt(box.dataset.pageNumber || "1", 10) : 1;
3737
+ return { page, total };
3738
+ }
3739
+ // ─── Layout: section page setup ──────────────────────────────────────
3740
+ /** The section governing the active block (or the first body block). */
3741
+ sectionInfo() {
3742
+ if (this.closed)
3743
+ return null;
3744
+ const block = this.activeBlock && !this.isBandBlock(this.activeBlock) ? this.activeBlock : this.editableList()[0];
3745
+ const unid = block?.getAttribute("data-anchor");
3746
+ const fullId = unid ? this.unidToFullId.get(unid) : undefined;
3747
+ if (!fullId)
3748
+ return null;
3749
+ try {
3750
+ const parsed = JSON.parse(this.exports.DocxSessionBridge.GetSectionInfo(this.handle, fullId));
3751
+ return parsed && typeof parsed.sectionUnid === "string" ? parsed : null;
3752
+ }
3753
+ catch {
3754
+ return null;
3755
+ }
3756
+ }
3757
+ /** Word's Page Setup on the section holding the caret: size, orientation, margins. */
3758
+ setPageSetup(op) {
3759
+ this.assertOpen();
3760
+ const bridge = this.exports.DocxSessionBridge;
3761
+ if (typeof bridge.SetPageSetup !== "function")
3762
+ return false;
3763
+ const info = this.sectionInfo();
3764
+ if (!info)
3765
+ return false;
3766
+ const res = this.parseEdit(bridge.SetPageSetup(this.handle, info.anchorId, JSON.stringify(op)));
3767
+ if (!res.success)
3768
+ return false;
3769
+ // Geometry is whole-document context (sheet width, page boxes).
3770
+ this.remount(this.activeBlock ? this.blockIndex(this.activeBlock) : -1, false);
3771
+ return true;
3772
+ }
3773
+ // ─── Header & footer (extended) ──────────────────────────────────────
3774
+ /** Word's "Different first page" (`first`) / "Different odd & even pages" (`even`). */
3775
+ setHeaderFooterKindEnabled(kind, enabled) {
3776
+ this.assertOpen();
3777
+ return this.region?.setKindEnabled(kind, enabled) ?? false;
3778
+ }
3779
+ headerFooterKindEnabled(kind) {
3780
+ return this.region?.kindEnabled(kind) ?? false;
3781
+ }
3782
+ /** "First Page Header", "Footer", … for the story a band (or the active page area) shows. */
3783
+ headerFooterStoryLabel(which) {
3784
+ return this.region?.storyLabel(which) ?? (which === "header" ? "Header" : "Footer");
3785
+ }
3786
+ /** Put the caret in the header or footer (Word's "Go to Header / Go to Footer"). */
3787
+ goToHeaderFooter(which) {
3788
+ this.assertOpen();
3789
+ return this.region?.focusStory(which, this.activeBlock) ?? false;
3790
+ }
3791
+ /** Leave the header/footer and put the caret back in the body. */
3792
+ closeHeaderFooter() {
3793
+ this.assertOpen();
3794
+ this.region?.close();
3795
+ const body = this.editableList();
3796
+ const target = this.activeBlock && !this.isBandBlock(this.activeBlock) && this.activeBlock.isConnected
3797
+ ? this.activeBlock
3798
+ : body[0];
3799
+ if (target)
3800
+ placeCaretAtOffset(target, 0);
3801
+ }
3802
+ /** Which story the caret is in, or null in the body. */
3803
+ get activeStoryKind() {
3804
+ return this.activeStory;
3805
+ }
3806
+ // ─── Introspection for chrome ────────────────────────────────────────
3807
+ /** The document's style definitions (for a styles gallery); empty on older bundles. */
3808
+ styles() {
3809
+ const bridge = this.exports.DocxSessionBridge;
3810
+ if (this.closed || typeof bridge.ListStyles !== "function")
3811
+ return [];
3812
+ try {
3813
+ const parsed = JSON.parse(bridge.ListStyles(this.handle));
3814
+ return Array.isArray(parsed) ? parsed : [];
3815
+ }
3816
+ catch {
3817
+ return [];
3818
+ }
3819
+ }
3820
+ /** The active block's paragraph style id (from the render), or null. */
3821
+ styleAtCaret() {
3822
+ const info = this.paragraphFormatting();
3823
+ return info?.effectiveParagraph.styleId ?? info?.directParagraph.styleId ?? null;
3824
+ }
2942
3825
  /** Which inline formats the current selection carries — for ribbon button highlighting. */
2943
3826
  queryFormatState() {
2944
3827
  const block = this.activeBlock ?? this.editRoot;
@@ -2975,6 +3858,8 @@ export class DocxEditor {
2975
3858
  this.activeBlock = fresh;
2976
3859
  // The throwaway render numbers citation markers from 1 — repair in place.
2977
3860
  this.maybeRenumberNotes(fresh);
3861
+ if (inBand)
3862
+ this.region.afterStoryEdit(fresh);
2978
3863
  this.options.onEdit?.({ anchorId, unid: newUnid });
2979
3864
  return fresh;
2980
3865
  }
@@ -2985,8 +3870,39 @@ export class DocxEditor {
2985
3870
  * Save + ConvertDocxToHtmlComplete for older WASM bundles. Both paths use
2986
3871
  * the same option profile, so the rendered HTML is identical.
2987
3872
  */
2988
- renderFullHtml() {
3873
+ /** The editor's render profile, as the comment-aware bridge endpoints take it. */
3874
+ editorRenderProfile() {
3875
+ return JSON.stringify({
3876
+ cssPrefix: this.options.cssPrefix,
3877
+ fabricateClasses: this.options.fabricateClasses,
3878
+ paginated: this.options.paginated,
3879
+ scale: this.options.scale,
3880
+ renderTrackedChanges: this.renderTrackedChanges,
3881
+ comments: this.options.comments,
3882
+ });
3883
+ }
3884
+ /**
3885
+ * Full-document HTML from the live session. Prefers the session-attached
3886
+ * `RenderEditorHtml` (comment-aware) or `RenderHtml` bridge — the saved bytes never cross the
3887
+ * JS/WASM boundary — and falls back to `ConvertDocxToHtmlComplete` over `bytes` (the opened
3888
+ * document on first paint, else a fresh save) for older WASM bundles. Every path uses the
3889
+ * same option profile, so the rendered HTML is identical.
3890
+ */
3891
+ renderFullHtml(bytes) {
2989
3892
  const bridge = this.exports.DocxSessionBridge;
3893
+ if (typeof bridge.RenderEditorHtml === "function") {
3894
+ const html = bridge.RenderEditorHtml(this.handle, this.editorRenderProfile());
3895
+ if (html.charCodeAt(0) !== 0x7b)
3896
+ return html;
3897
+ }
3898
+ if (this.options.comments) {
3899
+ // Only the bytes path can render comment markup on a bundle without RenderEditorHtml.
3900
+ const source = bytes ??
3901
+ (typeof bridge.SaveWithAnchorIds === "function"
3902
+ ? bridge.SaveWithAnchorIds(this.handle)
3903
+ : bridge.Save(this.handle));
3904
+ return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(source, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges, true));
3905
+ }
2990
3906
  if (typeof bridge.RenderHtmlForReview === "function") {
2991
3907
  const html = bridge.RenderHtmlForReview(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges);
2992
3908
  if (html.charCodeAt(0) !== 0x7b)
@@ -3001,14 +3917,17 @@ export class DocxEditor {
3001
3917
  // discarded, and the re-render has to resolve to the SAME anchors the live session holds — a
3002
3918
  // content change re-derives a block's content-hashed unid, which would leave it unwired. So ask
3003
3919
  // for the Unid-bearing save here, and here only; DocxEditor.save() stays clean.
3004
- const bytes = typeof bridge.SaveWithAnchorIds === "function"
3005
- ? bridge.SaveWithAnchorIds(this.handle)
3006
- : bridge.Save(this.handle);
3007
- return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges));
3008
- }
3009
- /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). */
3920
+ const source = bytes ??
3921
+ (typeof bridge.SaveWithAnchorIds === "function"
3922
+ ? bridge.SaveWithAnchorIds(this.handle)
3923
+ : bridge.Save(this.handle));
3924
+ return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(source, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale, this.renderTrackedChanges, false));
3925
+ }
3926
+ /** Editable BODY blocks in document order (band blocks are enumerated by `ownerRoot`). In page
3927
+ * view a live story sits INSIDE a page box, so it is excluded here explicitly — a remount
3928
+ * rebuilds the pages without it, and a focus index that counted it would land one block off. */
3010
3929
  editableList() {
3011
- return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
3930
+ return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')).filter((el) => !el.closest("[data-hf-band]"));
3012
3931
  }
3013
3932
  blockIndex(el) {
3014
3933
  return Array.from(this.ownerRoot(el).querySelectorAll('[data-anchor][contenteditable="true"]')).indexOf(el);
@@ -3096,6 +4015,7 @@ export class DocxEditor {
3096
4015
  }
3097
4016
  }
3098
4017
  this.syncRegionToBody(this.activeBlock ?? undefined);
4018
+ this.gutter?.schedule();
3099
4019
  }
3100
4020
  /** The patch itself. Returns false to request the remount fallback. */
3101
4021
  reconcileCore() {
@@ -3132,9 +4052,7 @@ export class DocxEditor {
3132
4052
  let rendered = {};
3133
4053
  if (allIds.length > 0) {
3134
4054
  const idsJson = JSON.stringify(allIds);
3135
- const renderedJson = typeof bridge.RenderBlocksHtmlForReview === "function"
3136
- ? bridge.RenderBlocksHtmlForReview(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses, this.renderTrackedChanges)
3137
- : bridge.RenderBlocksHtml(this.handle, idsJson, this.options.cssPrefix, this.options.fabricateClasses);
4055
+ const renderedJson = this.renderBlocksJson(idsJson);
3138
4056
  rendered = JSON.parse(renderedJson);
3139
4057
  if (rendered.error)
3140
4058
  return this.bail(`render error: ${rendered.error}`);
@@ -3220,6 +4138,24 @@ export class DocxEditor {
3220
4138
  for (const [nj, oi] of subOldByNew) {
3221
4139
  const freshRoot = fresh.get(nj);
3222
4140
  const oldWrapper = this.unitWrapperOf(oldNodes[oi]);
4141
+ // A render that differs from the live node ONLY in <img> attributes (a host replacing
4142
+ // an image's media through the session — the arcade's Doom cartridge does it every
4143
+ // frame) patches those attributes on the elements already on screen instead of
4144
+ // swapping nodes. Firefox and WebKit paint a freshly inserted <img> as an empty box
4145
+ // until its data URI is decoded, so a per-frame swap strobes white between frames;
4146
+ // an in-place src change keeps the previous bitmap up until the new one is ready.
4147
+ // The live node stays wired, so only its signature stamp needs refreshing. Any
4148
+ // other difference (or a border-wrapper change) falls through to the swap below.
4149
+ const leaf = freshRoot.hasAttribute("data-anchor");
4150
+ const imagePairs = imageOnlyDelta(leaf ? oldNodes[oi] : oldWrapper, freshRoot);
4151
+ if (imagePairs) {
4152
+ for (const [live, next] of imagePairs)
4153
+ patchImageAttributes(live, next);
4154
+ const sig = units[nj].sig;
4155
+ if (sig)
4156
+ oldNodes[oi].setAttribute("data-render-sig", sig);
4157
+ continue;
4158
+ }
3223
4159
  if (!freshRoot.hasAttribute("data-anchor")) {
3224
4160
  oldWrapper.replaceWith(freshRoot); // wrapper-shaped render (table) ⇄ wrapper
3225
4161
  }
@@ -3546,6 +4482,21 @@ export class DocxEditor {
3546
4482
  // section-affecting edit (or a pagination toggle) leaves the bands describing the right one.
3547
4483
  this.syncRegionToBody(this.activeBlock ?? undefined);
3548
4484
  this.setupBlockDrag();
4485
+ this.gutter?.schedule();
4486
+ }
4487
+ /** Re-append the gutter after a mount emptied the container (mounts replace `innerHTML`). */
4488
+ readoptGutter() {
4489
+ this.gutter?.reattach();
3549
4490
  }
3550
4491
  }
4492
+ const commentGutterStyledDocuments = new WeakSet();
4493
+ function ensureCommentGutterStyles(doc) {
4494
+ if (commentGutterStyledDocuments.has(doc))
4495
+ return;
4496
+ commentGutterStyledDocuments.add(doc);
4497
+ const style = doc.createElement("style");
4498
+ style.dataset.docxodusCommentGutter = "true";
4499
+ style.textContent = COMMENT_GUTTER_CSS;
4500
+ (doc.head ?? doc.documentElement).appendChild(style);
4501
+ }
3551
4502
  //# sourceMappingURL=editor.js.map