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.
@@ -3166,6 +3166,28 @@ var Docxodus = (() => {
3166
3166
  }
3167
3167
  while (i < n) removed.push(i++);
3168
3168
  while (j < m) added.push(j++);
3169
+ const bareUnid = (token) => {
3170
+ const bar = token.indexOf("|");
3171
+ return bar < 0 ? token : token.slice(0, bar);
3172
+ };
3173
+ const substituted = [];
3174
+ const usedRemoved = /* @__PURE__ */ new Set();
3175
+ const removedByUnid = /* @__PURE__ */ new Map();
3176
+ for (const oi of removed) {
3177
+ const u = bareUnid(oldUnids[oi]);
3178
+ (removedByUnid.get(u) ?? removedByUnid.set(u, []).get(u)).push(oi);
3179
+ }
3180
+ const unmatchedAdded = [];
3181
+ for (const nj of added) {
3182
+ const candidates = removedByUnid.get(bareUnid(newUnids[nj]));
3183
+ const oi = candidates?.find((i2) => !usedRemoved.has(i2));
3184
+ if (oi !== void 0) {
3185
+ substituted.push({ oldIndex: oi, newIndex: nj });
3186
+ usedRemoved.add(oi);
3187
+ } else {
3188
+ unmatchedAdded.push(nj);
3189
+ }
3190
+ }
3169
3191
  const keptBeforeOld = (oi) => {
3170
3192
  let c = 0;
3171
3193
  for (const v of keep.values()) if (v < oi) c++;
@@ -3176,9 +3198,7 @@ var Docxodus = (() => {
3176
3198
  for (const k of keep.keys()) if (k < nj) c++;
3177
3199
  return c;
3178
3200
  };
3179
- const substituted = [];
3180
- const usedRemoved = /* @__PURE__ */ new Set();
3181
- for (const nj of added) {
3201
+ for (const nj of unmatchedAdded) {
3182
3202
  const target = keptBeforeNew(nj);
3183
3203
  for (const oi of removed) {
3184
3204
  if (usedRemoved.has(oi)) continue;
@@ -3382,6 +3402,33 @@ var Docxodus = (() => {
3382
3402
  if (lastText) return { node: lastText, offset: (lastText.textContent ?? "").length };
3383
3403
  return { node: el, offset: el.childNodes.length };
3384
3404
  }
3405
+ function caretPointFromClient(doc, clientX, clientY) {
3406
+ const caretDoc = doc;
3407
+ const position = caretDoc.caretPositionFromPoint?.(clientX, clientY);
3408
+ if (position) return { node: position.offsetNode, offset: position.offset };
3409
+ const range = caretDoc.caretRangeFromPoint?.(clientX, clientY);
3410
+ return range ? { node: range.startContainer, offset: range.startOffset } : null;
3411
+ }
3412
+ function setSelectionBetween(anchor, focus) {
3413
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
3414
+ if (!sel || !anchor.node.isConnected || !focus.node.isConnected) return false;
3415
+ try {
3416
+ const probe = document.createRange();
3417
+ probe.setStart(anchor.node, anchor.offset);
3418
+ probe.collapse(true);
3419
+ const focusIsBefore = probe.comparePoint(focus.node, focus.offset) < 0;
3420
+ const range = document.createRange();
3421
+ const start = focusIsBefore ? focus : anchor;
3422
+ const end = focusIsBefore ? anchor : focus;
3423
+ range.setStart(start.node, start.offset);
3424
+ range.setEnd(end.node, end.offset);
3425
+ sel.removeAllRanges();
3426
+ sel.addRange(range);
3427
+ return true;
3428
+ } catch {
3429
+ return false;
3430
+ }
3431
+ }
3385
3432
  function placeCaretAtOffset(el, offset) {
3386
3433
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
3387
3434
  if (!sel) return;
@@ -3547,6 +3594,15 @@ var Docxodus = (() => {
3547
3594
  * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
3548
3595
  */
3549
3596
  this.lastSelection = null;
3597
+ /**
3598
+ * Stable bookmark for a selection spanning independent editable blocks. Native controls such as
3599
+ * a font-size combobox can take focus and collapse the live DOM selection; block ids + content
3600
+ * offsets let the command restore that same range before applying. This is the multi-block
3601
+ * counterpart to lastSelection above.
3602
+ */
3603
+ this.lastCrossBlockSelection = null;
3604
+ /** State for the mouse-selection bridge between independent contenteditable block hosts. */
3605
+ this.dragSelection = null;
3550
3606
  /** The docked header/footer bands, when `options.headerFooter` is on. */
3551
3607
  this.region = null;
3552
3608
  /** Why the last reconcile() fell back to a full remount (null = it patched). For
@@ -3558,24 +3614,92 @@ var Docxodus = (() => {
3558
3614
  const sel = typeof window !== "undefined" ? window.getSelection() : null;
3559
3615
  if (!sel || sel.rangeCount === 0) return;
3560
3616
  const range = sel.getRangeAt(0);
3561
- const block = this.editableBlockOf(range.commonAncestorContainer);
3617
+ const startBlock = this.editableBlockOf(range.startContainer);
3618
+ const endBlock = this.editableBlockOf(range.endContainer);
3619
+ const block = startBlock ?? endBlock;
3562
3620
  if (!block) return;
3563
- const unid = block.getAttribute("data-anchor");
3564
- if (!unid) return;
3565
3621
  if (range.collapsed) {
3566
3622
  this.lastSelection = null;
3623
+ this.lastCrossBlockSelection = null;
3624
+ return;
3625
+ }
3626
+ if (startBlock && endBlock && startBlock !== endBlock && this.ownerRoot(startBlock) === this.ownerRoot(endBlock)) {
3627
+ const startUnid = startBlock.getAttribute("data-anchor");
3628
+ const endUnid = endBlock.getAttribute("data-anchor");
3629
+ if (startUnid && endUnid) {
3630
+ this.lastSelection = null;
3631
+ this.lastCrossBlockSelection = {
3632
+ startUnid,
3633
+ startOffset: contentOffsetOf(startBlock, range.startContainer, range.startOffset),
3634
+ endUnid,
3635
+ endOffset: contentOffsetOf(endBlock, range.endContainer, range.endOffset)
3636
+ };
3637
+ }
3567
3638
  return;
3568
3639
  }
3640
+ const unid = block.getAttribute("data-anchor");
3641
+ if (!unid) return;
3642
+ this.lastCrossBlockSelection = null;
3569
3643
  const span = selectionSpanIn(block);
3570
3644
  if (span) this.lastSelection = { unid, span };
3571
3645
  };
3646
+ /** Start tracking a normal primary-button text drag inside one editable block. */
3647
+ this.onMouseDown = (event) => {
3648
+ if (this.closed || !this.options.editable || event.button !== 0) return;
3649
+ const target = event.target instanceof Node ? event.target : null;
3650
+ const origin = this.editableBlockOf(target);
3651
+ if (!origin || isInMarker(target)) return;
3652
+ const anchor = caretPointFromClient(document, event.clientX, event.clientY);
3653
+ if (!anchor || this.editableBlockOf(anchor.node) !== origin) return;
3654
+ this.dragSelection = { anchor, origin, crossedBlockBoundary: false };
3655
+ };
3656
+ /**
3657
+ * Browsers fence native mouse selection at a contenteditable host boundary. Once a drag reaches
3658
+ * another block in the same OOXML story, take over just that gesture and create the cross-block
3659
+ * Selection the editor's existing multi-block command path consumes. Intra-block selection stays
3660
+ * entirely native, and separate hosts remain intact for safe per-anchor commits.
3661
+ */
3662
+ this.onMouseMove = (event) => {
3663
+ const drag = this.dragSelection;
3664
+ if (!drag) return;
3665
+ if ((event.buttons & 1) === 0) {
3666
+ this.dragSelection = null;
3667
+ return;
3668
+ }
3669
+ const focus = caretPointFromClient(document, event.clientX, event.clientY);
3670
+ if (!focus || isInMarker(focus.node)) return;
3671
+ const focusBlock = this.editableBlockOf(focus.node);
3672
+ if (!focusBlock || this.ownerRoot(focusBlock) !== this.ownerRoot(drag.origin)) return;
3673
+ if (focusBlock !== drag.origin) drag.crossedBlockBoundary = true;
3674
+ if (!drag.crossedBlockBoundary) return;
3675
+ event.preventDefault();
3676
+ setSelectionBetween(drag.anchor, focus);
3677
+ };
3678
+ /** Commit the final cross-block endpoint before releasing the gesture state. */
3679
+ this.onMouseUp = (event) => {
3680
+ const drag = this.dragSelection;
3681
+ if (!drag) return;
3682
+ if (drag.crossedBlockBoundary) {
3683
+ const focus = caretPointFromClient(document, event.clientX, event.clientY);
3684
+ const focusBlock = focus ? this.editableBlockOf(focus.node) : null;
3685
+ if (focus && focusBlock && this.ownerRoot(focusBlock) === this.ownerRoot(drag.origin)) {
3686
+ event.preventDefault();
3687
+ setSelectionBetween(drag.anchor, focus);
3688
+ }
3689
+ }
3690
+ this.dragSelection = null;
3691
+ };
3572
3692
  this.container = container;
3573
3693
  this.exports = exports;
3574
3694
  this.handle = handle;
3575
3695
  this.options = options;
3576
3696
  this.editRoot = container;
3577
- if (typeof document !== "undefined")
3697
+ if (typeof document !== "undefined") {
3578
3698
  document.addEventListener("selectionchange", this.onSelectionChange);
3699
+ document.addEventListener("mousedown", this.onMouseDown, true);
3700
+ document.addEventListener("mousemove", this.onMouseMove, true);
3701
+ document.addEventListener("mouseup", this.onMouseUp, true);
3702
+ }
3579
3703
  }
3580
3704
  /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor.
3581
3705
  * Fenced by `container`, not `editRoot`, so header/footer band blocks — which live outside the
@@ -3655,8 +3779,13 @@ var Docxodus = (() => {
3655
3779
  close() {
3656
3780
  if (this.closed) return;
3657
3781
  this.closed = true;
3658
- if (typeof document !== "undefined")
3782
+ if (typeof document !== "undefined") {
3659
3783
  document.removeEventListener("selectionchange", this.onSelectionChange);
3784
+ document.removeEventListener("mousedown", this.onMouseDown, true);
3785
+ document.removeEventListener("mousemove", this.onMouseMove, true);
3786
+ document.removeEventListener("mouseup", this.onMouseUp, true);
3787
+ }
3788
+ this.dragSelection = null;
3660
3789
  this.exports.DocxSessionBridge.CloseSession(this.handle);
3661
3790
  }
3662
3791
  /**
@@ -3964,8 +4093,7 @@ var Docxodus = (() => {
3964
4093
  this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
3965
4094
  return;
3966
4095
  }
3967
- const firstEl = this.renderInto(first.id);
3968
- const secondEl = this.renderInto(second.id);
4096
+ const [firstEl, secondEl] = this.renderTwo(first.id, second.id);
3969
4097
  if (!firstEl || !secondEl) return;
3970
4098
  const inBand = this.isBandBlock(el);
3971
4099
  if (!this.replaceNode(el, firstEl, secondEl)) return;
@@ -4078,6 +4206,34 @@ var Docxodus = (() => {
4078
4206
  if (html.charCodeAt(0) === 123) return null;
4079
4207
  return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
4080
4208
  }
4209
+ /** Render two blocks in ONE batched bridge call when the bundle carries RenderBlocksHtml —
4210
+ * the per-render shell/converter setup is paid once instead of twice, which matters on the
4211
+ * Enter path (split renders both halves synchronously under the keystroke). Falls back to
4212
+ * two per-block renders on older bundles. Output is renderInto-identical per block (both
4213
+ * routes share the same extraction). */
4214
+ renderTwo(a, b) {
4215
+ const bridge = this.exports.DocxSessionBridge;
4216
+ if (typeof bridge.RenderBlocksHtml === "function") {
4217
+ try {
4218
+ const map = JSON.parse(
4219
+ bridge.RenderBlocksHtml(
4220
+ this.handle,
4221
+ JSON.stringify([a, b]),
4222
+ this.options.cssPrefix,
4223
+ this.options.fabricateClasses
4224
+ )
4225
+ );
4226
+ if (!map.error) {
4227
+ const parse = (h) => h ? new DOMParser().parseFromString(h, "text/html").body.firstElementChild : null;
4228
+ const fa = parse(map[a]);
4229
+ const fb = parse(map[b]);
4230
+ if (fa && fb) return [fa, fb];
4231
+ }
4232
+ } catch {
4233
+ }
4234
+ }
4235
+ return [this.renderInto(a), this.renderInto(b)];
4236
+ }
4081
4237
  /** The editable block immediately before `el` within its own root, or null. */
4082
4238
  previousEditable(el) {
4083
4239
  const all = Array.from(
@@ -4095,12 +4251,34 @@ var Docxodus = (() => {
4095
4251
  }
4096
4252
  // ─── M5: formatting commands (ribbon) ────────────────────────────────
4097
4253
  // ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
4254
+ /**
4255
+ * Restore a cross-block selection after a native toolbar control took focus. The bookmark uses
4256
+ * stable anchor ids and content offsets, so it also survives incremental block swaps.
4257
+ */
4258
+ restoreCrossBlockSelection() {
4259
+ const bookmark = this.lastCrossBlockSelection;
4260
+ const active = this.activeBlock;
4261
+ if (!bookmark || !active) return false;
4262
+ const all = Array.from(
4263
+ this.ownerRoot(active).querySelectorAll('[data-anchor][contenteditable="true"]')
4264
+ );
4265
+ const first = all.find((block) => block.getAttribute("data-anchor") === bookmark.startUnid);
4266
+ const last = all.find((block) => block.getAttribute("data-anchor") === bookmark.endUnid);
4267
+ if (!first || !last || first === last || all.indexOf(first) > all.indexOf(last)) return false;
4268
+ const start = contentPositionIn(first, bookmark.startOffset);
4269
+ const end = contentPositionIn(last, bookmark.endOffset);
4270
+ return setSelectionBetween(start, end);
4271
+ }
4098
4272
  /** Editable blocks the current selection covers, in document order. Uses Range.comparePoint
4099
4273
  * (robust to a selection boundary that normalized onto a wrapper element rather than a block
4100
4274
  * or text node — Range.intersectsNode misses the end block at a `(block, childCount)` boundary).
4101
4275
  * A collapsed or single-block selection yields just the active block. */
4102
4276
  selectedBlocks() {
4103
- const sel = typeof window !== "undefined" ? window.getSelection() : null;
4277
+ let sel = typeof window !== "undefined" ? window.getSelection() : null;
4278
+ if ((!sel || sel.rangeCount === 0 || sel.isCollapsed) && this.lastCrossBlockSelection) {
4279
+ this.restoreCrossBlockSelection();
4280
+ sel = typeof window !== "undefined" ? window.getSelection() : null;
4281
+ }
4104
4282
  const root = this.activeBlock ? this.ownerRoot(this.activeBlock) : this.editRoot;
4105
4283
  const all = Array.from(
4106
4284
  root.querySelectorAll('[data-anchor][contenteditable="true"]')
@@ -4879,7 +5057,8 @@ var Docxodus = (() => {
4879
5057
  );
4880
5058
  if (oldMarker !== newMarker) return this.bail("substituted li marker drift");
4881
5059
  }
4882
- if (!this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody)) return this.bail("applyBodyDiff bail");
5060
+ const bodyApply = this.applyBodyDiff(oldNodes, plan.body, bodyDiff, freshBody);
5061
+ if (bodyApply !== true) return this.bail(`applyBodyDiff bail: ${bodyApply}`);
4883
5062
  this.applyNotesDiff("footnotes", plan.footnotes, fnState, rendered);
4884
5063
  this.applyNotesDiff("endnotes", plan.endnotes, enState, rendered);
4885
5064
  const freshHasMarker = [...freshBody.values()].some(
@@ -4911,15 +5090,15 @@ var Docxodus = (() => {
4911
5090
  static anchorElOf(root) {
4912
5091
  return root.hasAttribute("data-anchor") ? root : root.querySelector("[data-anchor]");
4913
5092
  }
4914
- /** Insert/remove/swap body unit nodes per the diff. Returns false to bail (parent
4915
- * ambiguity, order violation, wrapper semantics) — the session is already correct,
4916
- * so bailing just means a full repaint. */
5093
+ /** Insert/remove/swap body unit nodes per the diff. Returns `true` on success or a
5094
+ * bail-reason string (parent ambiguity, order violation, wrapper semantics) — the
5095
+ * session is already correct, so bailing just means a full repaint. */
4917
5096
  applyBodyDiff(oldNodes, units, diff, fresh) {
4918
5097
  let lastOld = -1;
4919
5098
  for (let j = 0; j < units.length; j++) {
4920
5099
  const oi = diff.keep.get(j);
4921
5100
  if (oi === void 0) continue;
4922
- if (oi < lastOld) return false;
5101
+ if (oi < lastOld) return "kept order violation";
4923
5102
  lastOld = oi;
4924
5103
  }
4925
5104
  const subOldByNew = new Map(diff.substituted.map((s) => [s.newIndex, s.oldIndex]));
@@ -4930,8 +5109,10 @@ var Docxodus = (() => {
4930
5109
  oldWrapper.replaceWith(freshRoot);
4931
5110
  } else if (oldWrapper === oldNodes[oi]) {
4932
5111
  oldNodes[oi].replaceWith(freshRoot);
5112
+ } else if (!oldNodes[oi].hasAttribute("data-render-sig") && unidOf(units[nj].id) === oldNodes[oi].getAttribute("data-anchor")) {
5113
+ oldNodes[oi].replaceWith(freshRoot);
4933
5114
  } else {
4934
- return false;
5115
+ return `leaf render into wrapped slot (unit ${units[nj].id.slice(0, 24)})`;
4935
5116
  }
4936
5117
  this.wireUnit(freshRoot, units[nj]);
4937
5118
  }
@@ -4958,10 +5139,11 @@ var Docxodus = (() => {
4958
5139
  }
4959
5140
  const prevW = prev ? this.unitWrapperOf(prev) : null;
4960
5141
  const nextW = next ? this.unitWrapperOf(next) : null;
4961
- if (prevW && nextW && prevW.parentElement !== nextW.parentElement) return false;
5142
+ if (prevW && nextW && prevW.parentElement !== nextW.parentElement)
5143
+ return "insert neighbors in different parents";
4962
5144
  if (prevW) prevW.after(el);
4963
5145
  else if (nextW) nextW.before(el);
4964
- else return false;
5146
+ else return "insert with no anchored neighbor";
4965
5147
  this.wireUnit(el, units[j]);
4966
5148
  }
4967
5149
  for (const i of pureRemoved) {
package/dist/embed.js 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
  * ```
Binary file
@@ -1,7 +1,7 @@
1
1
  export const config = /*json-start*/{
2
2
  "mainAssemblyName": "DocxodusWasm.dll",
3
3
  "resources": {
4
- "hash": "sha256-0YKGRpKROHQqrAqN7ta7jD/WmUW9+BcGipg1KvXvNDQ=",
4
+ "hash": "sha256-GBQnb6vThRMtbemeUukvnAtr1XgfjxrFyRC52FIkYGQ=",
5
5
  "jsModuleNative": [
6
6
  {
7
7
  "name": "dotnet.native.js"
@@ -44,12 +44,12 @@ export const config = /*json-start*/{
44
44
  {
45
45
  "virtualPath": "Docxodus.wasm",
46
46
  "name": "Docxodus.wasm",
47
- "hash": "sha256-cN94nBDf42F1xu7qcpkJz7Ozpa43xw8NWXZb78wusL4="
47
+ "hash": "sha256-y+NTXvpEmYtq4hVsSsO7lyntCmhzyFu5yowHDCSG7+4="
48
48
  },
49
49
  {
50
50
  "virtualPath": "DocxodusWasm.wasm",
51
51
  "name": "DocxodusWasm.wasm",
52
- "hash": "sha256-5+eeT1emrJVwTujZEDENAM/nJGerKcJWg/QQICGBweU="
52
+ "hash": "sha256-aPqvmsFeG0JZT8yM9J53L9Sk8YREZVqdtmSsUHgHrCI="
53
53
  },
54
54
  {
55
55
  "virtualPath": "System.Collections.Concurrent.wasm",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docxodus",
3
- "version": "9.1.0",
3
+ "version": "9.1.1",
4
4
  "description": "Render, edit, diff and project Word .docx files in the browser — DOCX to HTML, a block editor, Word-grade redlines with tracked changes, and anchor-addressed markdown for LLM agents. WebAssembly, no server.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,7 +50,7 @@
50
50
  "build:embed-bundle": "esbuild src/embed.ts --bundle --format=esm --outfile=dist/embed.bundle.js && esbuild src/embed.ts --bundle --format=iife --global-name=Docxodus --outfile=dist/embed.iife.js",
51
51
  "build:worker-bundle": "esbuild src/docxodus.worker.ts --bundle --format=esm --outfile=dist/docxodus.worker.js && esbuild src/worker-proxy.ts --bundle --format=esm --outfile=dist/worker-proxy.bundle.js",
52
52
  "build": "npm run build:wasm && npm run build:ts && npm run build:pagination-bundle && npm run build:session-bundle && npm run build:editor-bundle && npm run build:embed-bundle && npm run build:worker-bundle",
53
- "pretest": "npm run typecheck && cp tests/test-harness.html dist/wasm/ && cp tests/worker-test-harness.html dist/wasm/ && cp tests/profiling-harness.html dist/wasm/ && cp examples/editor.html dist/wasm/ && cp examples/embed.html dist/wasm/ && cp ../docs/demo/player.html dist/wasm/player.html && cp ../docs/demo/index.html dist/wasm/demo-index.html && cp ../TestFiles/HC031-Complicated-Document.docx dist/wasm/demo-sample.docx && cp dist/pagination.bundle.js dist/wasm/ && cp dist/session.bundle.js dist/wasm/ && cp dist/editor.bundle.js dist/wasm/ && cp dist/embed.bundle.js dist/wasm/ && cp dist/docxodus.worker.js dist/wasm/ && cp dist/worker-proxy.bundle.js dist/wasm/worker-proxy.js",
53
+ "pretest": "npm run typecheck && cp tests/test-harness.html dist/wasm/ && cp tests/worker-test-harness.html dist/wasm/ && cp tests/profiling-harness.html dist/wasm/ && cp examples/editor.html dist/wasm/ && cp examples/embed.html dist/wasm/ && cp ../docs/demo/player.html dist/wasm/player.html && cp ../docs/demo/index.html dist/wasm/demo-index.html && cp ../docs/demo/docxodus-demo-guide.docx dist/wasm/docxodus-demo-guide.docx && cp dist/pagination.bundle.js dist/wasm/ && cp dist/session.bundle.js dist/wasm/ && cp dist/editor.bundle.js dist/wasm/ && cp dist/embed.bundle.js dist/wasm/ && cp dist/docxodus.worker.js dist/wasm/ && cp dist/worker-proxy.bundle.js dist/wasm/worker-proxy.js",
54
54
  "test": "playwright test",
55
55
  "test:ui": "playwright test --ui",
56
56
  "demo": "npm run build && npm run demo:serve",