docxodus 9.1.0 → 9.1.1

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.
@@ -3045,6 +3045,28 @@ function diffUnits(oldUnids, newUnits) {
3045
3045
  }
3046
3046
  while (i < n) removed.push(i++);
3047
3047
  while (j < m) added.push(j++);
3048
+ const bareUnid = (token) => {
3049
+ const bar = token.indexOf("|");
3050
+ return bar < 0 ? token : token.slice(0, bar);
3051
+ };
3052
+ const substituted = [];
3053
+ const usedRemoved = /* @__PURE__ */ new Set();
3054
+ const removedByUnid = /* @__PURE__ */ new Map();
3055
+ for (const oi of removed) {
3056
+ const u = bareUnid(oldUnids[oi]);
3057
+ (removedByUnid.get(u) ?? removedByUnid.set(u, []).get(u)).push(oi);
3058
+ }
3059
+ const unmatchedAdded = [];
3060
+ for (const nj of added) {
3061
+ const candidates = removedByUnid.get(bareUnid(newUnids[nj]));
3062
+ const oi = candidates?.find((i2) => !usedRemoved.has(i2));
3063
+ if (oi !== void 0) {
3064
+ substituted.push({ oldIndex: oi, newIndex: nj });
3065
+ usedRemoved.add(oi);
3066
+ } else {
3067
+ unmatchedAdded.push(nj);
3068
+ }
3069
+ }
3048
3070
  const keptBeforeOld = (oi) => {
3049
3071
  let c = 0;
3050
3072
  for (const v of keep.values()) if (v < oi) c++;
@@ -3055,9 +3077,7 @@ function diffUnits(oldUnids, newUnits) {
3055
3077
  for (const k of keep.keys()) if (k < nj) c++;
3056
3078
  return c;
3057
3079
  };
3058
- const substituted = [];
3059
- const usedRemoved = /* @__PURE__ */ new Set();
3060
- for (const nj of added) {
3080
+ for (const nj of unmatchedAdded) {
3061
3081
  const target = keptBeforeNew(nj);
3062
3082
  for (const oi of removed) {
3063
3083
  if (usedRemoved.has(oi)) continue;
@@ -3261,6 +3281,33 @@ function contentPositionIn(el, offset) {
3261
3281
  if (lastText) return { node: lastText, offset: (lastText.textContent ?? "").length };
3262
3282
  return { node: el, offset: el.childNodes.length };
3263
3283
  }
3284
+ function caretPointFromClient(doc, clientX, clientY) {
3285
+ const caretDoc = doc;
3286
+ const position = caretDoc.caretPositionFromPoint?.(clientX, clientY);
3287
+ if (position) return { node: position.offsetNode, offset: position.offset };
3288
+ const range = caretDoc.caretRangeFromPoint?.(clientX, clientY);
3289
+ return range ? { node: range.startContainer, offset: range.startOffset } : null;
3290
+ }
3291
+ function setSelectionBetween(anchor, focus) {
3292
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
3293
+ if (!sel || !anchor.node.isConnected || !focus.node.isConnected) return false;
3294
+ try {
3295
+ const probe = document.createRange();
3296
+ probe.setStart(anchor.node, anchor.offset);
3297
+ probe.collapse(true);
3298
+ const focusIsBefore = probe.comparePoint(focus.node, focus.offset) < 0;
3299
+ const range = document.createRange();
3300
+ const start = focusIsBefore ? focus : anchor;
3301
+ const end = focusIsBefore ? anchor : focus;
3302
+ range.setStart(start.node, start.offset);
3303
+ range.setEnd(end.node, end.offset);
3304
+ sel.removeAllRanges();
3305
+ sel.addRange(range);
3306
+ return true;
3307
+ } catch {
3308
+ return false;
3309
+ }
3310
+ }
3264
3311
  function placeCaretAtOffset(el, offset) {
3265
3312
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
3266
3313
  if (!sel) return;
@@ -3426,6 +3473,15 @@ var DocxEditor = class _DocxEditor {
3426
3473
  * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
3427
3474
  */
3428
3475
  this.lastSelection = null;
3476
+ /**
3477
+ * Stable bookmark for a selection spanning independent editable blocks. Native controls such as
3478
+ * a font-size combobox can take focus and collapse the live DOM selection; block ids + content
3479
+ * offsets let the command restore that same range before applying. This is the multi-block
3480
+ * counterpart to lastSelection above.
3481
+ */
3482
+ this.lastCrossBlockSelection = null;
3483
+ /** State for the mouse-selection bridge between independent contenteditable block hosts. */
3484
+ this.dragSelection = null;
3429
3485
  /** The docked header/footer bands, when `options.headerFooter` is on. */
3430
3486
  this.region = null;
3431
3487
  /** Why the last reconcile() fell back to a full remount (null = it patched). For
@@ -3437,24 +3493,92 @@ var DocxEditor = class _DocxEditor {
3437
3493
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
3438
3494
  if (!sel || sel.rangeCount === 0) return;
3439
3495
  const range = sel.getRangeAt(0);
3440
- const block = this.editableBlockOf(range.commonAncestorContainer);
3496
+ const startBlock = this.editableBlockOf(range.startContainer);
3497
+ const endBlock = this.editableBlockOf(range.endContainer);
3498
+ const block = startBlock ?? endBlock;
3441
3499
  if (!block) return;
3442
- const unid = block.getAttribute("data-anchor");
3443
- if (!unid) return;
3444
3500
  if (range.collapsed) {
3445
3501
  this.lastSelection = null;
3502
+ this.lastCrossBlockSelection = null;
3503
+ return;
3504
+ }
3505
+ if (startBlock && endBlock && startBlock !== endBlock && this.ownerRoot(startBlock) === this.ownerRoot(endBlock)) {
3506
+ const startUnid = startBlock.getAttribute("data-anchor");
3507
+ const endUnid = endBlock.getAttribute("data-anchor");
3508
+ if (startUnid && endUnid) {
3509
+ this.lastSelection = null;
3510
+ this.lastCrossBlockSelection = {
3511
+ startUnid,
3512
+ startOffset: contentOffsetOf(startBlock, range.startContainer, range.startOffset),
3513
+ endUnid,
3514
+ endOffset: contentOffsetOf(endBlock, range.endContainer, range.endOffset)
3515
+ };
3516
+ }
3446
3517
  return;
3447
3518
  }
3519
+ const unid = block.getAttribute("data-anchor");
3520
+ if (!unid) return;
3521
+ this.lastCrossBlockSelection = null;
3448
3522
  const span = selectionSpanIn(block);
3449
3523
  if (span) this.lastSelection = { unid, span };
3450
3524
  };
3525
+ /** Start tracking a normal primary-button text drag inside one editable block. */
3526
+ this.onMouseDown = (event) => {
3527
+ if (this.closed || !this.options.editable || event.button !== 0) return;
3528
+ const target = event.target instanceof Node ? event.target : null;
3529
+ const origin = this.editableBlockOf(target);
3530
+ if (!origin || isInMarker(target)) return;
3531
+ const anchor = caretPointFromClient(document, event.clientX, event.clientY);
3532
+ if (!anchor || this.editableBlockOf(anchor.node) !== origin) return;
3533
+ this.dragSelection = { anchor, origin, crossedBlockBoundary: false };
3534
+ };
3535
+ /**
3536
+ * Browsers fence native mouse selection at a contenteditable host boundary. Once a drag reaches
3537
+ * another block in the same OOXML story, take over just that gesture and create the cross-block
3538
+ * Selection the editor's existing multi-block command path consumes. Intra-block selection stays
3539
+ * entirely native, and separate hosts remain intact for safe per-anchor commits.
3540
+ */
3541
+ this.onMouseMove = (event) => {
3542
+ const drag = this.dragSelection;
3543
+ if (!drag) return;
3544
+ if ((event.buttons & 1) === 0) {
3545
+ this.dragSelection = null;
3546
+ return;
3547
+ }
3548
+ const focus = caretPointFromClient(document, event.clientX, event.clientY);
3549
+ if (!focus || isInMarker(focus.node)) return;
3550
+ const focusBlock = this.editableBlockOf(focus.node);
3551
+ if (!focusBlock || this.ownerRoot(focusBlock) !== this.ownerRoot(drag.origin)) return;
3552
+ if (focusBlock !== drag.origin) drag.crossedBlockBoundary = true;
3553
+ if (!drag.crossedBlockBoundary) return;
3554
+ event.preventDefault();
3555
+ setSelectionBetween(drag.anchor, focus);
3556
+ };
3557
+ /** Commit the final cross-block endpoint before releasing the gesture state. */
3558
+ this.onMouseUp = (event) => {
3559
+ const drag = this.dragSelection;
3560
+ if (!drag) return;
3561
+ if (drag.crossedBlockBoundary) {
3562
+ const focus = caretPointFromClient(document, event.clientX, event.clientY);
3563
+ const focusBlock = focus ? this.editableBlockOf(focus.node) : null;
3564
+ if (focus && focusBlock && this.ownerRoot(focusBlock) === this.ownerRoot(drag.origin)) {
3565
+ event.preventDefault();
3566
+ setSelectionBetween(drag.anchor, focus);
3567
+ }
3568
+ }
3569
+ this.dragSelection = null;
3570
+ };
3451
3571
  this.container = container;
3452
3572
  this.exports = exports;
3453
3573
  this.handle = handle;
3454
3574
  this.options = options;
3455
3575
  this.editRoot = container;
3456
- if (typeof document !== "undefined")
3576
+ if (typeof document !== "undefined") {
3457
3577
  document.addEventListener("selectionchange", this.onSelectionChange);
3578
+ document.addEventListener("mousedown", this.onMouseDown, true);
3579
+ document.addEventListener("mousemove", this.onMouseMove, true);
3580
+ document.addEventListener("mouseup", this.onMouseUp, true);
3581
+ }
3458
3582
  }
3459
3583
  /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
3460
3584
  * Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
@@ -3534,8 +3658,13 @@ var DocxEditor = class _DocxEditor {
3534
3658
  close() {
3535
3659
  if (this.closed) return;
3536
3660
  this.closed = true;
3537
- if (typeof document !== "undefined")
3661
+ if (typeof document !== "undefined") {
3538
3662
  document.removeEventListener("selectionchange", this.onSelectionChange);
3663
+ document.removeEventListener("mousedown", this.onMouseDown, true);
3664
+ document.removeEventListener("mousemove", this.onMouseMove, true);
3665
+ document.removeEventListener("mouseup", this.onMouseUp, true);
3666
+ }
3667
+ this.dragSelection = null;
3539
3668
  this.exports.DocxSessionBridge.CloseSession(this.handle);
3540
3669
  }
3541
3670
  /**
@@ -3843,8 +3972,7 @@ var DocxEditor = class _DocxEditor {
3843
3972
  this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
3844
3973
  return;
3845
3974
  }
3846
- const firstEl = this.renderInto(first.id);
3847
- const secondEl = this.renderInto(second.id);
3975
+ const [firstEl, secondEl] = this.renderTwo(first.id, second.id);
3848
3976
  if (!firstEl || !secondEl) return;
3849
3977
  const inBand = this.isBandBlock(el);
3850
3978
  if (!this.replaceNode(el, firstEl, secondEl)) return;
@@ -3957,6 +4085,34 @@ var DocxEditor = class _DocxEditor {
3957
4085
  if (html.charCodeAt(0) === 123) return null;
3958
4086
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
3959
4087
  }
4088
+ /** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
4089
+ * the per-render shell/converter setup is paid once instead of twice, which matters on the
4090
+ * Enter path (split renders both halves synchronously under the keystroke). Falls back to
4091
+ * two per-block renders on older bundles. Output is renderInto-identical per block (both
4092
+ * routes share the same extraction). */
4093
+ renderTwo(a, b) {
4094
+ const bridge = this.exports.DocxSessionBridge;
4095
+ if (typeof bridge.RenderBlocksHtml === "function") {
4096
+ try {
4097
+ const map = JSON.parse(
4098
+ bridge.RenderBlocksHtml(
4099
+ this.handle,
4100
+ JSON.stringify([a, b]),
4101
+ this.options.cssPrefix,
4102
+ this.options.fabricateClasses
4103
+ )
4104
+ );
4105
+ if (!map.error) {
4106
+ const parse = (h) => h ? new DOMParser().parseFromString(h, "text/html").body.firstElementChild : null;
4107
+ const fa = parse(map[a]);
4108
+ const fb = parse(map[b]);
4109
+ if (fa && fb) return [fa, fb];
4110
+ }
4111
+ } catch {
4112
+ }
4113
+ }
4114
+ return [this.renderInto(a), this.renderInto(b)];
4115
+ }
3960
4116
  /** The editable block immediately before `el` within its own root, or null. */
3961
4117
  previousEditable(el) {
3962
4118
  const all = Array.from(
@@ -3974,12 +4130,34 @@ var DocxEditor = class _DocxEditor {
3974
4130
  }
3975
4131
  // ─── M5: formatting commands (ribbon) ────────────────────────────────
3976
4132
  // ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
4133
+ /**
4134
+ * Restore a cross-block selection after a native toolbar control took focus. The bookmark uses
4135
+ * stable anchor ids and content offsets, so it also survives incremental block swaps.
4136
+ */
4137
+ restoreCrossBlockSelection() {
4138
+ const bookmark = this.lastCrossBlockSelection;
4139
+ const active = this.activeBlock;
4140
+ if (!bookmark || !active) return false;
4141
+ const all = Array.from(
4142
+ this.ownerRoot(active).querySelectorAll('[data-anchor][contenteditable="true"]')
4143
+ );
4144
+ const first = all.find((block) => block.getAttribute("data-anchor") === bookmark.startUnid);
4145
+ const last = all.find((block) => block.getAttribute("data-anchor") === bookmark.endUnid);
4146
+ if (!first || !last || first === last || all.indexOf(first) > all.indexOf(last)) return false;
4147
+ const start = contentPositionIn(first, bookmark.startOffset);
4148
+ const end = contentPositionIn(last, bookmark.endOffset);
4149
+ return setSelectionBetween(start, end);
4150
+ }
3977
4151
  /** Editable blocks the current selection covers, in document order. Uses Range.comparePoint
3978
4152
  * (robust to a selection boundary that normalized onto a wrapper element rather than a block
3979
4153
  * or text node — Range.intersectsNode misses the end block at a `(block, childCount)` boundary).
3980
4154
  * A collapsed or single-block selection yields just the active block. */
3981
4155
  selectedBlocks() {
3982
- const sel = typeof window !== "undefined" ? window.getSelection() : null;
4156
+ let sel = typeof window !== "undefined" ? window.getSelection() : null;
4157
+ if ((!sel || sel.rangeCount === 0 || sel.isCollapsed) && this.lastCrossBlockSelection) {
4158
+ this.restoreCrossBlockSelection();
4159
+ sel = typeof window !== "undefined" ? window.getSelection() : null;
4160
+ }
3983
4161
  const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
3984
4162
  const all = Array.from(
3985
4163
  root.querySelectorAll('[data-anchor][contenteditable="true"]')
@@ -4758,7 +4936,8 @@ var DocxEditor = class _DocxEditor {
4758
4936
  );
4759
4937
  if (oldMarker !== newMarker) return this.bail("substituted li marker drift");
4760
4938
  }
4761
- if (!this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody)) return this.bail("applyBodyDiff bail");
4939
+ const bodyApply = this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody);
4940
+ if (bodyApply !== true) return this.bail(`applyBodyDiff bail: ${bodyApply}`);
4762
4941
  this.applyNotesDiff("footnotes", plan.footnotes, fnState, rendered);
4763
4942
  this.applyNotesDiff("endnotes", plan.endnotes, enState, rendered);
4764
4943
  const freshHasMarker = [...freshBody.values()].some(
@@ -4790,15 +4969,15 @@ var DocxEditor = class _DocxEditor {
4790
4969
  static anchorElOf(root) {
4791
4970
  return root.hasAttribute("data-anchor") ? root : root.querySelector("[data-anchor]");
4792
4971
  }
4793
- /** Insert/remove/swap body unit nodes per the diff. Returns false to bail (parent
4794
- * ambiguity, order violation, wrapper semantics) — the session is already correct,
4795
- * so bailing just means a full repaint. */
4972
+ /** Insert/remove/swap body unit nodes per the diff. Returns `true` on success or a
4973
+ * bail-reason string (parent ambiguity, order violation, wrapper semantics) — the
4974
+ * session is already correct, so bailing just means a full repaint. */
4796
4975
  applyBodyDiff(oldNodes, units, diff, fresh) {
4797
4976
  let lastOld = -1;
4798
4977
  for (let j = 0; j < units.length; j++) {
4799
4978
  const oi = diff.keep.get(j);
4800
4979
  if (oi === void 0) continue;
4801
- if (oi < lastOld) return false;
4980
+ if (oi < lastOld) return "kept order violation";
4802
4981
  lastOld = oi;
4803
4982
  }
4804
4983
  const subOldByNew = new Map(diff.substituted.map((s) => [s.newIndex, s.oldIndex]));
@@ -4809,8 +4988,10 @@ var DocxEditor = class _DocxEditor {
4809
4988
  oldWrapper.replaceWith(freshRoot);
4810
4989
  } else if (oldWrapper === oldNodes[oi]) {
4811
4990
  oldNodes[oi].replaceWith(freshRoot);
4991
+ } else if (!oldNodes[oi].hasAttribute("data-render-sig") && unidOf(units[nj].id) === oldNodes[oi].getAttribute("data-anchor")) {
4992
+ oldNodes[oi].replaceWith(freshRoot);
4812
4993
  } else {
4813
- return false;
4994
+ return `leaf render into wrapped slot (unit ${units[nj].id.slice(0, 24)})`;
4814
4995
  }
4815
4996
  this.wireUnit(freshRoot, units[nj]);
4816
4997
  }
@@ -4837,10 +5018,11 @@ var DocxEditor = class _DocxEditor {
4837
5018
  }
4838
5019
  const prevW = prev ? this.unitWrapperOf(prev) : null;
4839
5020
  const nextW = next ? this.unitWrapperOf(next) : null;
4840
- if (prevW && nextW && prevW.parentElement !== nextW.parentElement) return false;
5021
+ if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
5022
+ return "insert neighbors in different parents";
4841
5023
  if (prevW) prevW.after(el);
4842
5024
  else if (nextW) nextW.before(el);
4843
- else return false;
5025
+ else return "insert with no anchored neighbor";
4844
5026
  this.wireUnit(el, units[j]);
4845
5027
  }
4846
5028
  for (const i of pureRemoved) {
package/dist/embed.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * ```html
8
8
  * <div id="doc"></div>
9
9
  * <script type="module">
10
- * import { createViewer } from "https://cdn.jsdelivr.net/npm/docxodus@9.1.0/dist/embed.bundle.js";
10
+ * import { createViewer } from "https://cdn.jsdelivr.net/npm/docxodus@9.1.1/dist/embed.bundle.js";
11
11
  * await createViewer("#doc", "./contract.docx");
12
12
  * </script>
13
13
  * ```