docxodus 6.3.0 → 7.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 (74) hide show
  1. package/dist/docxodus.worker.js +13 -6
  2. package/dist/docxodus.worker.js.map +1 -1
  3. package/dist/editor.bundle.js +2143 -0
  4. package/dist/editor.d.ts +336 -0
  5. package/dist/editor.d.ts.map +1 -0
  6. package/dist/editor.js +1525 -0
  7. package/dist/editor.js.map +1 -0
  8. package/dist/index.d.ts +130 -5
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +336 -8
  11. package/dist/index.js.map +1 -1
  12. package/dist/session.bundle.js +674 -0
  13. package/dist/session.d.ts +72 -2
  14. package/dist/session.d.ts.map +1 -1
  15. package/dist/session.js +104 -0
  16. package/dist/session.js.map +1 -1
  17. package/dist/types.d.ts +326 -5
  18. package/dist/types.d.ts.map +1 -1
  19. package/dist/types.js +67 -0
  20. package/dist/types.js.map +1 -1
  21. package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
  22. package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
  23. package/dist/wasm/_framework/Docxodus.wasm +0 -0
  24. package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
  25. package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
  26. package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
  27. package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
  28. package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
  29. package/dist/wasm/_framework/System.Collections.wasm +0 -0
  30. package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
  31. package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
  32. package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
  33. package/dist/wasm/_framework/System.Console.wasm +0 -0
  34. package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
  35. package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
  36. package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
  37. package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
  38. package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
  39. package/dist/wasm/_framework/System.Linq.wasm +0 -0
  40. package/dist/wasm/_framework/System.Memory.wasm +0 -0
  41. package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
  42. package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
  43. package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
  44. package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
  45. package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
  46. package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
  47. package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
  48. package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
  49. package/dist/wasm/_framework/System.Runtime.wasm +0 -0
  50. package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
  51. package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
  52. package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
  53. package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
  54. package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
  55. package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
  56. package/dist/wasm/_framework/System.Threading.wasm +0 -0
  57. package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
  58. package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
  59. package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
  60. package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
  61. package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
  62. package/dist/wasm/_framework/System.wasm +0 -0
  63. package/dist/wasm/_framework/dotnet.boot.js +279 -0
  64. package/dist/wasm/_framework/dotnet.js +2 -2
  65. package/dist/wasm/_framework/dotnet.js.map +1 -1
  66. package/dist/wasm/_framework/dotnet.native.js +5628 -3
  67. package/dist/wasm/_framework/dotnet.native.js.symbols +5530 -3599
  68. package/dist/wasm/_framework/dotnet.native.wasm +0 -0
  69. package/dist/wasm/_framework/dotnet.runtime.js +1 -1
  70. package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
  71. package/package.json +8 -4
  72. package/dist/wasm/_framework/System.Diagnostics.DiagnosticSource.wasm +0 -0
  73. package/dist/wasm/_framework/blazor.boot.json +0 -66
  74. package/dist/wasm/_framework/supportFiles/0_runtimeconfig.bin +0 -1
package/dist/editor.js ADDED
@@ -0,0 +1,1525 @@
1
+ /**
2
+ * DocxEditor — a framework-agnostic, in-browser DOCX block editor.
3
+ *
4
+ * Architecture (see docs/architecture/ir_editor_feasibility.md, "Option B"):
5
+ * - model-of-record: a live DocxSession in WASM (lossless save);
6
+ * - rendering: WmlToHtmlConverter HTML (faithful) stamped with data-anchor;
7
+ * - editing: each block is contenteditable; on commit, the edit goes through
8
+ * DocxSession by anchor, then ONLY that block is re-rendered from the live
9
+ * session (session-attached RenderBlockHtml) and patched into the DOM.
10
+ *
11
+ * The IR/anchor system is the addressing spine; the live OOXML is the truth.
12
+ * This is the pure-TypeScript core; a React wrapper can sit on top.
13
+ *
14
+ * MVP scope: per-block, commit-on-blur editing of paragraphs/headings. An edited
15
+ * block's content is replaced from its plain text (inline formatting within an
16
+ * edited block is not preserved — a documented MVP limit); UNTOUCHED blocks keep
17
+ * full fidelity, and save() is lossless for them.
18
+ */
19
+ import { paginateHtml } from "./pagination.js";
20
+ const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
21
+ function fontWeightIsBold(w) {
22
+ if (w === "bold" || w === "bolder")
23
+ return true;
24
+ const n = parseInt(w, 10);
25
+ return !Number.isNaN(n) && n >= 600;
26
+ }
27
+ function escapeInlineMarkdown(text) {
28
+ // Escape the markdown the projector subset is sensitive to; keep it minimal.
29
+ return text.replace(/([\\`*_[\]])/g, "\\$1");
30
+ }
31
+ function collectInlineSegments(node, out) {
32
+ node.childNodes.forEach((child) => {
33
+ // Skip generated list-marker spans — they aren't part of the paragraph's content.
34
+ if (child.nodeType === 1 && child.hasAttribute?.("data-list-marker"))
35
+ return;
36
+ if (child.nodeType === 3 /* TEXT_NODE */) {
37
+ const text = child.textContent ?? "";
38
+ if (!text)
39
+ return;
40
+ const parent = child.parentElement;
41
+ let bold = false;
42
+ let italic = false;
43
+ let href = null;
44
+ if (parent && typeof getComputedStyle === "function") {
45
+ const cs = getComputedStyle(parent);
46
+ bold = fontWeightIsBold(cs.fontWeight);
47
+ italic = cs.fontStyle === "italic" || cs.fontStyle === "oblique";
48
+ const a = parent.closest("a");
49
+ href = a ? a.getAttribute("href") : null;
50
+ }
51
+ out.push({ text, bold, italic, href });
52
+ }
53
+ else if (child.nodeType === 1 /* ELEMENT_NODE */) {
54
+ const el = child;
55
+ if (el.tagName === "BR") {
56
+ out.push({ text: "\n", bold: false, italic: false, href: null });
57
+ return;
58
+ }
59
+ collectInlineSegments(el, out);
60
+ }
61
+ });
62
+ }
63
+ function segToMarkdown(seg) {
64
+ // A <br> segment is a hard line break → the canonical GFM " \n", which the
65
+ // DocxSession markdown parser turns into a real w:br (Word's intra-paragraph
66
+ // line break) instead of a literal newline in w:t.
67
+ if (seg.text === "\n")
68
+ return " \n";
69
+ let md = escapeInlineMarkdown(seg.text).replace(/[ \t]*\n/g, " \n");
70
+ if (/\S/.test(seg.text)) {
71
+ // Don't wrap pure whitespace — `** **` is not valid emphasis.
72
+ if (seg.bold && seg.italic)
73
+ md = `***${md}***`;
74
+ else if (seg.bold)
75
+ md = `**${md}**`;
76
+ else if (seg.italic)
77
+ md = `*${md}*`;
78
+ }
79
+ if (seg.href)
80
+ md = `[${md}](${seg.href})`;
81
+ return md;
82
+ }
83
+ /**
84
+ * Serialize a block's inline content to the projector's markdown subset, preserving
85
+ * bold / italic / links (emphasis detected via computed style). Used so an edit keeps
86
+ * the block's formatting instead of flattening it to plain text. Formatting the markdown
87
+ * subset cannot express (font size/color) is still dropped on an edited block.
88
+ */
89
+ export function serializeInlineMarkdown(block) {
90
+ const segs = [];
91
+ collectInlineSegments(block, segs);
92
+ // Merge adjacent segments with identical formatting to avoid `**a****b**`.
93
+ const merged = [];
94
+ for (const s of segs) {
95
+ const prev = merged[merged.length - 1];
96
+ if (prev &&
97
+ prev.text !== "\n" &&
98
+ s.text !== "\n" &&
99
+ prev.bold === s.bold &&
100
+ prev.italic === s.italic &&
101
+ prev.href === s.href) {
102
+ prev.text += s.text;
103
+ }
104
+ else {
105
+ merged.push({ ...s });
106
+ }
107
+ }
108
+ return merged.map(segToMarkdown).join("").trim();
109
+ }
110
+ /** True if `block` renders as a list item (has a generated marker as its first child). */
111
+ function isListBlock(block) {
112
+ return !!block.querySelector(":scope > [data-list-marker]");
113
+ }
114
+ /** True if `node` is, or is inside, a generated list-marker span (not editable content). */
115
+ function isInMarker(node) {
116
+ let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
117
+ while (el) {
118
+ if (el.hasAttribute && el.hasAttribute("data-list-marker"))
119
+ return true;
120
+ el = el.parentElement;
121
+ }
122
+ return false;
123
+ }
124
+ /**
125
+ * Unicode bidi formatting marks the HTML converter injects to preserve visual order: LRM/RLM/ALM,
126
+ * the embedding/override controls, and the isolates (see WmlToHtmlConverter — a paragraph/run gets
127
+ * a leading U+200E or U+200F). They are presentation-only — NOT part of the paragraph's run text the
128
+ * session holds — so the editor must exclude them from its content-offset space, the same way it
129
+ * excludes generated list markers. Otherwise every caret offset is shifted by the leading mark and a
130
+ * caret at end-of-line overshoots the session's text length, so SplitParagraph/ApplyFormat reject the
131
+ * offset (symptom: Enter at the end of a Google-Docs-exported paragraph is silently dropped).
132
+ */
133
+ // LRM, RLM, ALM; the embedding/override controls (LRE RLE PDF LRO RLO); the isolates (LRI RLI FSI PDI).
134
+ const BIDI_MARK_CLASS = "\u200E\u200F\u061C\u202A-\u202E\u2066-\u2069";
135
+ const BIDI_MARKS_RE_G = new RegExp(`[${BIDI_MARK_CLASS}]`, "g");
136
+ const BIDI_MARK_RE = new RegExp(`[${BIDI_MARK_CLASS}]`);
137
+ function stripBidi(s) {
138
+ return s.replace(BIDI_MARKS_RE_G, "");
139
+ }
140
+ /** Raw string index in `s` for content offset `n` (content = chars excluding bidi marks). */
141
+ function domOffsetForContentOffset(s, n) {
142
+ let content = 0;
143
+ for (let i = 0; i < s.length; i++) {
144
+ if (content >= n)
145
+ return i;
146
+ if (!BIDI_MARK_RE.test(s[i]))
147
+ content++;
148
+ }
149
+ return s.length;
150
+ }
151
+ /**
152
+ * Content-text offset of (container, offset) within `block`, EXCLUDING generated list-marker
153
+ * text and injected bidi marks. This is the offset DocxSession ops expect (the paragraph's run
154
+ * text, not the rendered number/bullet or bidi marks the converter injects).
155
+ */
156
+ function contentOffsetOf(block, container, offset) {
157
+ let count = 0;
158
+ let done = false;
159
+ const walk = (node) => {
160
+ if (done)
161
+ return;
162
+ if (node.nodeType === 3 /* TEXT_NODE */) {
163
+ if (node === container) {
164
+ if (!isInMarker(node))
165
+ count += stripBidi((node.textContent ?? "").slice(0, offset)).length;
166
+ done = true;
167
+ return;
168
+ }
169
+ if (!isInMarker(node))
170
+ count += stripBidi(node.textContent ?? "").length;
171
+ }
172
+ else {
173
+ if (node === container) {
174
+ // Element container: `offset` is a child index — count content up to that child.
175
+ const kids = Array.from(node.childNodes);
176
+ for (let i = 0; i < offset && i < kids.length; i++)
177
+ walk(kids[i]);
178
+ done = true;
179
+ return;
180
+ }
181
+ node.childNodes.forEach(walk);
182
+ }
183
+ };
184
+ walk(block);
185
+ return count;
186
+ }
187
+ /** Content-text offset of the collapsed caret within `block` (excludes markers), or null. */
188
+ function caretOffsetIn(block) {
189
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
190
+ if (!sel || sel.rangeCount === 0)
191
+ return null;
192
+ const range = sel.getRangeAt(0);
193
+ if (!block.contains(range.startContainer))
194
+ return null;
195
+ return contentOffsetOf(block, range.startContainer, range.startOffset);
196
+ }
197
+ /** Visible content text of `block`, excluding generated list-marker text (the same content
198
+ * caretOffsetIn/contentOffsetOf count). */
199
+ function blockContentText(block) {
200
+ let out = "";
201
+ const walk = (node) => {
202
+ if (node.nodeType === 3 /* TEXT_NODE */) {
203
+ if (!isInMarker(node))
204
+ out += stripBidi(node.textContent ?? "");
205
+ }
206
+ else {
207
+ node.childNodes.forEach(walk);
208
+ }
209
+ };
210
+ walk(block);
211
+ return out;
212
+ }
213
+ /**
214
+ * Map a DOM caret offset (from caretOffsetIn) into the run-text offset the session holds after a
215
+ * commit. commitBlock/syncBlock commit `serializeInlineMarkdown(el)`, which `.trim()`s leading and
216
+ * trailing whitespace, so the session's paragraph text is shorter than the DOM text whenever the
217
+ * block has edge whitespace — e.g. a blank document renders its empty paragraph with a placeholder
218
+ * space, and typing lands after it. Without this adjustment the caret offset overshoots the
219
+ * committed length, SplitParagraph returns OffsetOutOfRange, and splitAtCaret silently drops the
220
+ * Enter (no new paragraph). Subtracting the leading whitespace before the caret and clamping to the
221
+ * trimmed length keeps the split offset consistent with what was committed.
222
+ */
223
+ function trimmedSplitOffset(block, domOffset) {
224
+ const content = blockContentText(block);
225
+ const leading = content.length - content.replace(/^\s+/, "").length;
226
+ const trimmedLen = content.trim().length;
227
+ return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
228
+ }
229
+ /**
230
+ * DOM (node, offset) for content offset `offset` within `el` — the same content-offset
231
+ * space as contentOffsetOf (marker text and injected bidi marks excluded). Clamps past-end
232
+ * offsets to the end of the last text node (or the element itself when it has none), so a
233
+ * caller can always build a Range from the result.
234
+ */
235
+ function contentPositionIn(el, offset) {
236
+ let remaining = offset;
237
+ let result = null;
238
+ let lastText = null;
239
+ const walk = (node) => {
240
+ if (result)
241
+ return;
242
+ if (node.nodeType === 3 /* TEXT_NODE */) {
243
+ if (isInMarker(node))
244
+ return;
245
+ const raw = node.textContent ?? "";
246
+ const len = stripBidi(raw).length;
247
+ lastText = node;
248
+ if (remaining <= len) {
249
+ result = { node, offset: domOffsetForContentOffset(raw, remaining) };
250
+ return;
251
+ }
252
+ remaining -= len;
253
+ }
254
+ else {
255
+ node.childNodes.forEach(walk);
256
+ }
257
+ };
258
+ walk(el);
259
+ if (result)
260
+ return result;
261
+ if (lastText)
262
+ return { node: lastText, offset: (lastText.textContent ?? "").length };
263
+ return { node: el, offset: el.childNodes.length };
264
+ }
265
+ /** Place the caret at content offset `offset` within `el`, skipping marker text. */
266
+ function placeCaretAtOffset(el, offset) {
267
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
268
+ if (!sel)
269
+ return;
270
+ el.focus();
271
+ const range = document.createRange();
272
+ let remaining = offset;
273
+ let placed = false;
274
+ const walk = (node) => {
275
+ if (placed)
276
+ return;
277
+ if (node.nodeType === 3 /* TEXT_NODE */) {
278
+ if (isInMarker(node))
279
+ return; // never land the caret in the marker
280
+ const raw = node.textContent ?? "";
281
+ const len = stripBidi(raw).length; // content length excludes injected bidi marks
282
+ if (remaining <= len) {
283
+ range.setStart(node, domOffsetForContentOffset(raw, remaining));
284
+ placed = true;
285
+ }
286
+ else {
287
+ remaining -= len;
288
+ }
289
+ }
290
+ else {
291
+ node.childNodes.forEach(walk);
292
+ }
293
+ };
294
+ walk(el);
295
+ if (!placed) {
296
+ range.selectNodeContents(el);
297
+ range.collapse(false);
298
+ }
299
+ else {
300
+ range.collapse(true);
301
+ }
302
+ sel.removeAllRanges();
303
+ sel.addRange(range);
304
+ }
305
+ /** The selection's content-text {start,length} within `block` (excludes markers), or null. */
306
+ function selectionSpanIn(block) {
307
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
308
+ if (!sel || sel.rangeCount === 0)
309
+ return null;
310
+ const range = sel.getRangeAt(0);
311
+ if (range.collapsed)
312
+ return null;
313
+ if (!block.contains(range.startContainer) || !block.contains(range.endContainer))
314
+ return null;
315
+ const start = contentOffsetOf(block, range.startContainer, range.startOffset);
316
+ const end = contentOffsetOf(block, range.endContainer, range.endOffset);
317
+ return { start: Math.min(start, end), length: Math.abs(end - start) };
318
+ }
319
+ /** Restore a content-text selection spanning [start, start+length) within `el` (skips markers). */
320
+ function selectRange(el, start, length) {
321
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
322
+ // The block may have been swapped out of the document by a re-render before this runs
323
+ // (e.g. a focus-stealing toolbar control firing twice). addRange on a detached range
324
+ // throws "the given range isn't in document" — skip rather than warn.
325
+ if (!sel || !el.isConnected)
326
+ return;
327
+ el.focus();
328
+ const range = document.createRange();
329
+ const end = start + length;
330
+ let pos = 0;
331
+ let startSet = false;
332
+ const walk = (node) => {
333
+ for (const child of Array.from(node.childNodes)) {
334
+ if (child.nodeType === 3 /* TEXT_NODE */) {
335
+ if (isInMarker(child))
336
+ continue; // marker text isn't part of the content offset space
337
+ const raw = child.textContent ?? "";
338
+ const len = stripBidi(raw).length; // content length excludes injected bidi marks
339
+ if (!startSet && pos + len >= start) {
340
+ range.setStart(child, domOffsetForContentOffset(raw, start - pos));
341
+ startSet = true;
342
+ }
343
+ if (startSet && pos + len >= end) {
344
+ range.setEnd(child, domOffsetForContentOffset(raw, end - pos));
345
+ return true;
346
+ }
347
+ pos += len;
348
+ }
349
+ else if (walk(child)) {
350
+ return true;
351
+ }
352
+ }
353
+ return false;
354
+ };
355
+ if (walk(el) || startSet) {
356
+ sel.removeAllRanges();
357
+ sel.addRange(range);
358
+ }
359
+ }
360
+ /**
361
+ * True when `el`'s immediate parent is a paragraph-border `<div>` the full render wrapped it in
362
+ * (CreateBorderDivs groups visibly-bordered paragraphs into a div). The body wrapper div has no
363
+ * border. Splitting/merging such a block must re-render the whole document so the converter can
364
+ * re-group the border boxes — an in-place node swap would leave the new (often borderless)
365
+ * paragraph stranded inside the stale border div, drawing the rule's line under its text.
366
+ */
367
+ function inBorderWrapper(el) {
368
+ const style = el.parentElement?.getAttribute("style") ?? "";
369
+ return /border-(top|bottom|left|right):\s*(?!none)[^;]+/i.test(style);
370
+ }
371
+ /** Whether the current selection's start already carries `key`, read from computed style. */
372
+ function selectionHasFormat(key, fallback) {
373
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
374
+ let el = fallback;
375
+ if (sel && sel.rangeCount > 0) {
376
+ const n = sel.getRangeAt(0).startContainer;
377
+ el = n.nodeType === 3 ? n.parentElement : n;
378
+ }
379
+ if (!el || typeof getComputedStyle !== "function")
380
+ return false;
381
+ const cs = getComputedStyle(el);
382
+ switch (key) {
383
+ case "bold": return fontWeightIsBold(cs.fontWeight);
384
+ case "italic": return cs.fontStyle === "italic" || cs.fontStyle === "oblique";
385
+ case "underline": return cs.textDecorationLine.includes("underline");
386
+ case "strike": return cs.textDecorationLine.includes("line-through");
387
+ case "code": return /mono|courier|consolas/i.test(cs.fontFamily);
388
+ case "superscript": return cs.verticalAlign === "super" || !!el.closest("sup");
389
+ case "subscript": return cs.verticalAlign === "sub" || !!el.closest("sub");
390
+ default: return false;
391
+ }
392
+ }
393
+ /** Build the full ConvertDocxToHtmlComplete arg list (stampAnchors = last arg). */
394
+ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
395
+ return [
396
+ bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
397
+ /* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
398
+ false, 0, "annot-",
399
+ /* renderFootnotesAndEndnotes */ false, /* renderHeadersAndFooters */ paginated,
400
+ false, true, true, false, null, /* stampAnchors */ true,
401
+ ];
402
+ }
403
+ export class DocxEditor {
404
+ constructor(container, exports, handle, options) {
405
+ /** Map a block's current bare unid → its full kind:scope:unid (DocxSession anchor). */
406
+ this.unidToFullId = new Map();
407
+ /** The most recently focused editable block — the target for ribbon/format commands. */
408
+ this.activeBlock = null;
409
+ this.closed = false;
410
+ /**
411
+ * Re-entrancy guard for node replacement. Replacing a contenteditable block that still holds
412
+ * focus removes the focused node, which fires a SYNCHRONOUS `blur` → re-enters commitBlock; the
413
+ * interleaved second replaceWith then throws NotFoundError ("node ... no longer a child") and the
414
+ * structural edit (split/merge/format) is lost. While this flag is set, commitBlock no-ops.
415
+ */
416
+ this.replacing = false;
417
+ /**
418
+ * The last real (non-collapsed) text selection inside an editable block. A toolbar control that
419
+ * must take focus to be used — the font-size combobox — blurs the block and collapses the live
420
+ * selection, so without this an operation triggered from such a control could only target the
421
+ * whole paragraph (S-1 smoke-test finding 3). Refreshed whenever a non-empty selection sits in a
422
+ * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
423
+ */
424
+ this.lastSelection = null;
425
+ /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
426
+ this.onSelectionChange = () => {
427
+ if (this.closed)
428
+ return;
429
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
430
+ if (!sel || sel.rangeCount === 0)
431
+ return; // no selection info — keep the cache as-is
432
+ const range = sel.getRangeAt(0);
433
+ const block = this.editableBlockOf(range.commonAncestorContainer);
434
+ if (!block)
435
+ return; // selection is outside the editor (e.g. a toolbar field) — keep the cache
436
+ const unid = block.getAttribute("data-anchor");
437
+ if (!unid)
438
+ return;
439
+ if (range.collapsed) {
440
+ this.lastSelection = null; // an explicit caret in a block — drop any stale selection
441
+ return;
442
+ }
443
+ const span = selectionSpanIn(block);
444
+ if (span)
445
+ this.lastSelection = { unid, span };
446
+ };
447
+ this.container = container;
448
+ this.exports = exports;
449
+ this.handle = handle;
450
+ this.options = options;
451
+ this.editRoot = container;
452
+ if (typeof document !== "undefined")
453
+ document.addEventListener("selectionchange", this.onSelectionChange);
454
+ }
455
+ /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor. */
456
+ editableBlockOf(node) {
457
+ if (!node)
458
+ return null;
459
+ const start = node.nodeType === 1 ? node : node.parentElement;
460
+ const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
461
+ return block && this.editRoot.contains(block) ? block : null;
462
+ }
463
+ /** Open a document, render it into `container`, and wire up editing. */
464
+ static open(container, bytes, exports, options = {}) {
465
+ const opts = {
466
+ cssPrefix: options.cssPrefix ?? "docx-",
467
+ fabricateClasses: options.fabricateClasses ?? false,
468
+ editable: options.editable ?? true,
469
+ paginated: options.paginated ?? false,
470
+ scale: options.scale ?? 1,
471
+ onEdit: options.onEdit,
472
+ };
473
+ // persistAnchorIds=true keeps PtOpenXml:Unid attributes in Save() output, so a remount's
474
+ // full re-render keeps the SAME unids the live session uses (a content change like becoming
475
+ // a list otherwise re-derives a fresh unid, leaving the block unwired). The cost is that
476
+ // saved bytes carry the Unid attributes (Word ignores them).
477
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
478
+ const editor = new DocxEditor(container, exports, handle, opts);
479
+ editor.refreshAnchorMap();
480
+ const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
481
+ if (opts.paginated)
482
+ editor.mountPaginated(fullHtml);
483
+ else
484
+ editor.mountHtml(fullHtml);
485
+ return editor;
486
+ }
487
+ /**
488
+ * Open a fresh, blank document (a "New document" — single empty paragraph, Normal style,
489
+ * US-Letter section) and wire up editing. The seed bytes come from the WASM bridge so the
490
+ * result opens cleanly in Word too.
491
+ */
492
+ static openBlank(container, exports, options = {}) {
493
+ return DocxEditor.open(container, exports.DocxSessionBridge.CreateBlankDocx(), exports, options);
494
+ }
495
+ /** Lossless DOCX bytes reflecting all edits. */
496
+ save() {
497
+ this.assertOpen();
498
+ return this.exports.DocxSessionBridge.Save(this.handle);
499
+ }
500
+ /** Release the underlying WASM session. The editor is unusable afterward. */
501
+ close() {
502
+ if (this.closed)
503
+ return;
504
+ this.closed = true;
505
+ if (typeof document !== "undefined")
506
+ document.removeEventListener("selectionchange", this.onSelectionChange);
507
+ this.exports.DocxSessionBridge.CloseSession(this.handle);
508
+ }
509
+ /**
510
+ * Switch between continuous and paginated rendering WITHOUT losing edits. Re-renders from the
511
+ * LIVE session, so every committed edit (and the undo/redo history) survives the toggle — unlike
512
+ * re-opening the original bytes, which silently discards session edits. No-op if already `value`.
513
+ */
514
+ setPaginated(value) {
515
+ this.assertOpen();
516
+ if (this.options.paginated === value)
517
+ return;
518
+ this.options.paginated = value;
519
+ this.remount();
520
+ }
521
+ /** The editor's current DOM (for inspection/tests). */
522
+ get root() {
523
+ return this.container;
524
+ }
525
+ // ─── internals ───────────────────────────────────────────────────────
526
+ assertOpen() {
527
+ if (this.closed)
528
+ throw new Error("DocxEditor is closed");
529
+ }
530
+ /** Rebuild unid → full-anchor-id from the live session projection. */
531
+ refreshAnchorMap() {
532
+ const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
533
+ this.unidToFullId.clear();
534
+ for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
535
+ this.unidToFullId.set(target.unid, fullId);
536
+ }
537
+ }
538
+ /** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
539
+ mountHtml(fullHtml) {
540
+ const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
541
+ const styles = Array.from(parsed.querySelectorAll("style"))
542
+ .map((s) => s.outerHTML)
543
+ .join("");
544
+ this.container.innerHTML = styles + parsed.body.innerHTML;
545
+ this.editRoot = this.container;
546
+ if (this.options.editable)
547
+ this.wireBlocks(this.container);
548
+ }
549
+ /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
550
+ mountPaginated(fullHtml) {
551
+ paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
552
+ // pagination.ts measures the hidden #pagination-staging subtree ONCE, then flows CLONES of its
553
+ // blocks into the visible page boxes. Leaving staging in the live DOM is a trap: every
554
+ // data-anchor exists twice (staging + page-box copy), so document.querySelector('[data-anchor]')
555
+ // is ambiguous (hits the hidden copy first), and the staging copy goes stale because edits land
556
+ // only on the page-box copy — a future reflow-from-staging would silently revert them. Staging
557
+ // is a transient measurement scaffold; drop it so the page-box copies are the single source of
558
+ // truth. A remount (setPaginated, list/undo edits) rebuilds staging fresh from the live session.
559
+ this.container.querySelector("#pagination-staging, .page-staging")?.remove();
560
+ const pageRoot = this.container.querySelector("#pagination-container") ?? this.container;
561
+ this.editRoot = pageRoot;
562
+ if (this.options.editable)
563
+ this.wireBlocks(pageRoot);
564
+ }
565
+ wireBlocks(root) {
566
+ root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
567
+ }
568
+ wireBlock(el) {
569
+ if (!EDITABLE_TAGS.has(el.tagName))
570
+ return;
571
+ const unid = el.getAttribute("data-anchor");
572
+ // Only blocks the markdown projection addresses are editable via the text path. This INCLUDES
573
+ // table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
574
+ // keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
575
+ // table structure. Anything the projection does not index (unstamped content) stays read-only.
576
+ if (!unid || !this.unidToFullId.has(unid))
577
+ return;
578
+ el.setAttribute("contenteditable", "true");
579
+ // Generated list markers (number/bullet + suffix) are not editable content — keep the
580
+ // caret out of them so offsets stay aligned with the paragraph's run text.
581
+ el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
582
+ // Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
583
+ // matching the session's flat run-text offset space.
584
+ el.dataset.committedText = blockContentText(el);
585
+ el.addEventListener("focus", () => { this.activeBlock = el; });
586
+ el.addEventListener("blur", () => this.commitBlock(el));
587
+ el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
588
+ }
589
+ /**
590
+ * Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
591
+ * block fires (see `replacing`), AND tolerating the case where a synchronous blur during focus
592
+ * transfer detaches `oldEl` between the caller's checks and here. `replaceWith` then throws
593
+ * NotFoundError ("node … no longer a child … moved in a blur event handler") — `isConnected`
594
+ * alone doesn't catch this race. The session is already updated, so a skipped/failed visual swap
595
+ * leaves correct content (the typed DOM); the next commit or remount reconciles it. This is why
596
+ * the catch is silent rather than rethrowing — there's no lost data, only a deferred re-render.
597
+ * Returns true if the swap happened.
598
+ */
599
+ replaceNode(oldEl, ...newNodes) {
600
+ const prev = this.replacing;
601
+ this.replacing = true;
602
+ try {
603
+ if (!oldEl.parentNode)
604
+ return false;
605
+ oldEl.replaceWith(...newNodes);
606
+ return true;
607
+ }
608
+ catch {
609
+ return false;
610
+ }
611
+ finally {
612
+ this.replacing = prev;
613
+ }
614
+ }
615
+ /** Commit a block edit on blur: diff → run-preserving session op → re-render only this block. */
616
+ commitBlock(el) {
617
+ if (this.closed || this.replacing)
618
+ return;
619
+ const unid = el.getAttribute("data-anchor");
620
+ if (!unid)
621
+ return;
622
+ const fullId = this.unidToFullId.get(unid);
623
+ if (!fullId)
624
+ return;
625
+ const result = this.commitTextChange(el, fullId);
626
+ if (!result)
627
+ return; // no change
628
+ if (!result.success) {
629
+ // Session unchanged — re-render this block from truth to discard the rejected DOM edit.
630
+ const fresh = this.renderInto(fullId);
631
+ if (fresh && this.replaceNode(el, fresh)) {
632
+ this.wireBlock(fresh);
633
+ if (this.activeBlock === el)
634
+ this.activeBlock = fresh;
635
+ }
636
+ return;
637
+ }
638
+ const newAnchor = result.modified?.[0]?.id ?? fullId;
639
+ const newUnid = result.modified?.[0]?.unid ?? unid;
640
+ // List items: do NOT re-render on a text commit. Re-rendering replaces the node *during* the
641
+ // blur, cancelling the browser's in-flight focus transfer when the user clicks straight to
642
+ // another bullet; numbering also needs whole-document context a single-block render lacks. The
643
+ // DOM already shows what the user typed with the correct marker — sync the baseline only.
644
+ if (el.querySelector(":scope > [data-list-marker]")) {
645
+ el.dataset.committedText = blockContentText(el);
646
+ this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
647
+ return;
648
+ }
649
+ // Plain block: re-render ONLY this block from the live session for canonical HTML. Swapping the
650
+ // just-blurred node here is safe (verified — focus stays on the newly-clicked block).
651
+ const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
652
+ if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
653
+ const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
654
+ if (fresh && this.replaceNode(el, fresh)) {
655
+ this.unidToFullId.delete(unid);
656
+ this.unidToFullId.set(newUnid, newAnchor);
657
+ this.wireBlock(fresh);
658
+ if (this.activeBlock === el)
659
+ this.activeBlock = fresh; // keep ribbon target valid
660
+ }
661
+ }
662
+ this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
663
+ }
664
+ // ─── M2: structural editing ──────────────────────────────────────────
665
+ onKeydown(el, ev) {
666
+ if (this.closed)
667
+ return;
668
+ // Common formatting / history shortcuts.
669
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
670
+ const k = ev.key.toLowerCase();
671
+ const fmt = { b: "bold", i: "italic", u: "underline" };
672
+ if (fmt[k]) {
673
+ ev.preventDefault();
674
+ this.format(fmt[k]);
675
+ return;
676
+ }
677
+ if (k === "z") {
678
+ ev.preventDefault();
679
+ ev.shiftKey ? this.redo() : this.undo();
680
+ return;
681
+ }
682
+ if (k === "y") {
683
+ ev.preventDefault();
684
+ this.redo();
685
+ return;
686
+ }
687
+ }
688
+ // Inside a table cell, structural ops that change the TABLE GRID (cross-cell merge,
689
+ // list-nest, focus-jumping Tab) stay INERT — the single-block model can't give them
690
+ // whole-table context. Tab is swallowed (no focus escape / literal tab); Backspace at
691
+ // the cell's start does not merge across the cell boundary (mid-text Backspace still
692
+ // deletes normally). Enter, however, splits the cell paragraph into two paragraphs
693
+ // WITHIN the same cell — the engine keeps the new w:p in the w:tc, the grid is
694
+ // unchanged, so it's safe (it's how a cell holds stacked lines: value over a smaller
695
+ // label, multi-line addresses). (GAP3.)
696
+ const inTableCell = !!el.closest("table");
697
+ // Tab / Shift+Tab on a list item nests / un-nests it (changes list level).
698
+ if (ev.key === "Tab") {
699
+ if (inTableCell) {
700
+ ev.preventDefault();
701
+ return;
702
+ }
703
+ if (isListBlock(el)) {
704
+ ev.preventDefault();
705
+ this.activeBlock = el;
706
+ this.setListLevel(ev.shiftKey ? -1 : 1);
707
+ return;
708
+ }
709
+ }
710
+ // Shift+Enter inserts an intra-paragraph line break (a real w:br on commit),
711
+ // not a paragraph split. Deterministic across browsers and allowed in cells
712
+ // (a line break changes no table structure).
713
+ if (ev.key === "Enter" && ev.shiftKey && !ev.isComposing) {
714
+ ev.preventDefault();
715
+ this.insertLineBreakAtCaret();
716
+ return;
717
+ }
718
+ if (ev.key === "Enter" && !ev.shiftKey && !ev.isComposing) {
719
+ ev.preventDefault();
720
+ // Splits at the caret — in a cell this stacks a second paragraph within the same
721
+ // w:tc (grid unchanged); in the body it splits the paragraph as before.
722
+ this.splitAtCaret(el);
723
+ }
724
+ else if (ev.key === "Backspace") {
725
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
726
+ if (sel && sel.isCollapsed && caretOffsetIn(el) === 0 && !inTableCell) {
727
+ const prev = this.previousEditable(el);
728
+ if (prev) {
729
+ ev.preventDefault();
730
+ this.mergeWithPrevious(prev, el);
731
+ }
732
+ }
733
+ }
734
+ }
735
+ /** Shift+Enter: insert an intra-paragraph line break at the caret. Delegates to the
736
+ * native `insertLineBreak` command, which inserts a <br> AND positions the caret
737
+ * after it correctly (handling the browser's bogus trailing-<br> rule) so typing
738
+ * continues on the new line. Commits (on blur) as a w:br via the " \n" hard break
739
+ * the serializer emits for a <br>. */
740
+ insertLineBreakAtCaret() {
741
+ if (typeof document !== "undefined" && typeof document.execCommand === "function") {
742
+ document.execCommand("insertLineBreak");
743
+ }
744
+ }
745
+ /** Enter: split the block at the caret into two paragraphs. */
746
+ splitAtCaret(el) {
747
+ const rawOffset = caretOffsetIn(el);
748
+ const unid = el.getAttribute("data-anchor");
749
+ if (rawOffset == null || !unid)
750
+ return;
751
+ let fullId = this.unidToFullId.get(unid);
752
+ if (!fullId)
753
+ return;
754
+ // The session commits trimmed text, so map the DOM caret offset into the trimmed run-text
755
+ // offset (else an overshoot — e.g. a placeholder leading space — is rejected and Enter is lost).
756
+ const offset = trimmedSplitOffset(el, rawOffset);
757
+ const idx = this.blockIndex(el); // capture before the op (for list remount focus)
758
+ // Whether this paragraph is rendered inside a border <div> — captured before the DOM mutates.
759
+ const wrappedInBorder = inBorderWrapper(el);
760
+ fullId = this.syncBlock(el, fullId); // flush any uncommitted typing first
761
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SplitParagraph(this.handle, fullId, offset));
762
+ if (!res.success)
763
+ return;
764
+ const first = res.modified?.[0];
765
+ const second = res.created?.[0];
766
+ if (!first || !second)
767
+ return;
768
+ // Splitting a list item makes a continuing list item, and splitting a bordered paragraph (e.g.
769
+ // a horizontal rule) yields a new paragraph whose border status differs — both need a whole-
770
+ // document re-render so numbering continues / border <div>s regroup correctly. An in-place node
771
+ // swap would leave the new paragraph inside the old border div (the rule's line under its text).
772
+ if (this.affectsList(res) || wrappedInBorder) {
773
+ this.remount(idx + 1, false);
774
+ this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
775
+ return;
776
+ }
777
+ const firstEl = this.renderInto(first.id);
778
+ const secondEl = this.renderInto(second.id);
779
+ if (!firstEl || !secondEl)
780
+ return;
781
+ // el is the focused block — replaceNode guards the re-entrant blur→commit and tolerates a
782
+ // node detached mid-focus-transfer; replacing with both new blocks at once keeps them adjacent.
783
+ if (!this.replaceNode(el, firstEl, secondEl))
784
+ return;
785
+ this.unidToFullId.delete(unid);
786
+ this.unidToFullId.set(first.unid, first.id);
787
+ this.unidToFullId.set(second.unid, second.id);
788
+ this.wireBlock(firstEl);
789
+ this.wireBlock(secondEl);
790
+ placeCaretAtOffset(secondEl, 0);
791
+ this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
792
+ }
793
+ /** Backspace at block start: merge this block into the previous one. */
794
+ mergeWithPrevious(prev, el) {
795
+ const prevUnid = prev.getAttribute("data-anchor");
796
+ const thisUnid = el.getAttribute("data-anchor");
797
+ if (!prevUnid || !thisUnid)
798
+ return;
799
+ let prevId = this.unidToFullId.get(prevUnid);
800
+ let thisId = this.unidToFullId.get(thisUnid);
801
+ if (!prevId || !thisId)
802
+ return;
803
+ const prevIdx = this.blockIndex(prev); // capture before the op
804
+ // Either side rendered inside a border <div> means the merge changes border grouping — captured
805
+ // before the DOM mutates so the post-merge branch can force a full re-render.
806
+ const wrappedInBorder = inBorderWrapper(prev) || inBorderWrapper(el);
807
+ prevId = this.syncBlock(prev, prevId);
808
+ thisId = this.syncBlock(el, thisId);
809
+ const caret = (prev.textContent ?? "").length; // merge boundary
810
+ const res = this.parseEdit(this.exports.DocxSessionBridge.MergeParagraphs(this.handle, prevId, thisId));
811
+ if (!res.success)
812
+ return;
813
+ const merged = res.modified?.[0];
814
+ if (!merged)
815
+ return;
816
+ // Merging list items renumbers the list, and merging across a border <div> boundary changes the
817
+ // border grouping — both need a whole-document re-render (caret at the merge boundary).
818
+ if (this.affectsList(res) || wrappedInBorder) {
819
+ this.remount(prevIdx, true);
820
+ this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
821
+ return;
822
+ }
823
+ const mergedEl = this.renderInto(merged.id);
824
+ if (!mergedEl)
825
+ return;
826
+ // prev may be focused — replaceNode guards re-entrancy and tolerates a detached node.
827
+ if (!this.replaceNode(prev, mergedEl))
828
+ return;
829
+ el.remove();
830
+ this.unidToFullId.delete(prevUnid);
831
+ this.unidToFullId.delete(thisUnid);
832
+ this.unidToFullId.set(merged.unid, merged.id);
833
+ this.wireBlock(mergedEl);
834
+ placeCaretAtOffset(mergedEl, caret);
835
+ this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
836
+ }
837
+ /**
838
+ * Apply the block's pending text change to the session with full inline-formatting fidelity.
839
+ * Diffs the committed content text (markers + bidi excluded) against the current content text
840
+ * and rewrites only the changed span via ReplaceTextAtSpan — every untouched run keeps its exact
841
+ * rPr, and typed text inherits the boundary run's formatting. Returns the parsed EditResult, or
842
+ * null when there is no change. Empty/whitespace-only baselines (e.g. the placeholder space the
843
+ * converter renders for an empty paragraph, whose DOM text doesn't line up with the session's
844
+ * empty run text) are rebuilt via ReplaceText — there is no inline formatting to preserve there.
845
+ */
846
+ commitTextChange(el, fullId) {
847
+ // `old` mirrors the session's flat run-text: strip bidi marks (blockContentText strips them,
848
+ // but wireBlock may have stored textContent before this Task 2 change, and the bidi test
849
+ // explicitly stores textContent). Using stripBidi keeps the baseline consistent with the
850
+ // session's offset space regardless of how committedText was stored.
851
+ const old = stripBidi(el.dataset.committedText ?? "");
852
+ const next = blockContentText(el);
853
+ if (old === next)
854
+ return null;
855
+ if (old.trim().length === 0) {
856
+ return this.parseEdit(this.exports.DocxSessionBridge.ReplaceText(this.handle, fullId, serializeInlineMarkdown(el)));
857
+ }
858
+ const minLen = Math.min(old.length, next.length);
859
+ let p = 0;
860
+ while (p < minLen && old[p] === next[p])
861
+ p++;
862
+ let s = 0;
863
+ while (s < minLen - p && old[old.length - 1 - s] === next[next.length - 1 - s])
864
+ s++;
865
+ let start = p;
866
+ let len = old.length - p - s;
867
+ let middle = next.slice(p, next.length - s);
868
+ // A pure insertion is a zero-length span, which resolves to no runs and is rejected. Anchor a
869
+ // neighbor char so the span is non-empty and the inserted text inherits an adjacent run's rPr
870
+ // (the LEFT run when there is one, matching contenteditable; the first run at the very start).
871
+ if (len === 0) {
872
+ if (start > 0) {
873
+ start -= 1;
874
+ len = 1;
875
+ middle = old[start] + middle;
876
+ }
877
+ else {
878
+ len = 1;
879
+ middle = middle + old[0];
880
+ }
881
+ }
882
+ return this.parseEdit(this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, start, len, middle));
883
+ }
884
+ /** Flush a block's current (uncommitted) text to the session; returns the live full id. */
885
+ syncBlock(el, fullId) {
886
+ const result = this.commitTextChange(el, fullId);
887
+ if (!result || !result.success)
888
+ return fullId;
889
+ el.dataset.committedText = blockContentText(el);
890
+ return result.modified?.[0]?.id ?? fullId;
891
+ }
892
+ /** Render a block by anchor and parse it into a detached element (null on error). */
893
+ renderInto(anchorId) {
894
+ const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
895
+ if (html.charCodeAt(0) === 0x7b /* error object */)
896
+ return null;
897
+ return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
898
+ }
899
+ /** The editable block immediately before `el` in document order, or null. */
900
+ previousEditable(el) {
901
+ const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
902
+ const i = all.indexOf(el);
903
+ return i > 0 ? all[i - 1] : null;
904
+ }
905
+ parseEdit(json) {
906
+ try {
907
+ return JSON.parse(json);
908
+ }
909
+ catch {
910
+ return { success: false };
911
+ }
912
+ }
913
+ // ─── M5: formatting commands (ribbon) ────────────────────────────────
914
+ // ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
915
+ /** Editable blocks the current selection covers, in document order. Uses Range.comparePoint
916
+ * (robust to a selection boundary that normalized onto a wrapper element rather than a block
917
+ * or text node — Range.intersectsNode misses the end block at a `(block, childCount)` boundary).
918
+ * A collapsed or single-block selection yields just the active block. */
919
+ selectedBlocks() {
920
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
921
+ const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
922
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
923
+ const range = sel.getRangeAt(0);
924
+ const hit = all.filter((b) => {
925
+ try {
926
+ const startsAfterEnd = range.comparePoint(b, 0) > 0; // block begins after the selection ends
927
+ const endsBeforeStart = range.comparePoint(b, b.childNodes.length) < 0; // block ends before it starts
928
+ return !startsAfterEnd && !endsBeforeStart;
929
+ }
930
+ catch {
931
+ return false;
932
+ }
933
+ });
934
+ if (hit.length > 1)
935
+ return hit;
936
+ }
937
+ return this.activeBlock ? [this.activeBlock] : [];
938
+ }
939
+ /** The selection's span within `block`, clipped to the block (for inline ops across blocks):
940
+ * the first block runs selection-start→end-of-block, middle blocks are whole, the last block
941
+ * runs start-of-block→selection-end. Returns null for a whole-block apply. */
942
+ blockSpanForSelection(block) {
943
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
944
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed)
945
+ return null;
946
+ const range = sel.getRangeAt(0);
947
+ const hasStart = block.contains(range.startContainer);
948
+ const hasEnd = block.contains(range.endContainer);
949
+ if (hasStart && hasEnd)
950
+ return selectionSpanIn(block);
951
+ const contentLen = blockContentText(block).length;
952
+ if (hasStart) {
953
+ const start = contentOffsetOf(block, range.startContainer, range.startOffset);
954
+ return { start, length: Math.max(0, contentLen - start) };
955
+ }
956
+ if (hasEnd) {
957
+ const end = contentOffsetOf(block, range.endContainer, range.endOffset);
958
+ return { start: 0, length: end };
959
+ }
960
+ return { start: 0, length: contentLen }; // fully-spanned middle block
961
+ }
962
+ /** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
963
+ * the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
964
+ * document convert, seconds on a large doc, where per-block swaps are ~10 ms each).
965
+ * Returns false (caller falls back to the single-block path) for a 1-block selection. */
966
+ applyInlineOpAcrossBlocks(blocks, op) {
967
+ if (blocks.length <= 1)
968
+ return false;
969
+ const targets = this.multiBlockTargets(blocks);
970
+ for (const t of targets) {
971
+ if (!t.fullId || (t.span && t.span.length === 0))
972
+ continue;
973
+ const synced = this.syncBlock(t.block, t.fullId);
974
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, synced, t.span ? JSON.stringify(t.span) : "", JSON.stringify(op)));
975
+ if (res.success)
976
+ t.res = res;
977
+ }
978
+ this.finishMultiBlockOp(targets, false);
979
+ return true;
980
+ }
981
+ /** Apply a whole-block (paragraph-level) op to each selected block, then reconcile the DOM
982
+ * incrementally ({@link finishMultiBlockOp}). `forceRemount` is for ops whose rendering
983
+ * needs whole-document context (e.g. border-div regrouping after clearBorders). Returns
984
+ * false for a 1-block selection (caller uses the single-block path). */
985
+ applyParagraphOpAcrossBlocks(blocks, run, forceRemount = false) {
986
+ if (blocks.length <= 1)
987
+ return false;
988
+ const targets = this.multiBlockTargets(blocks);
989
+ for (const t of targets) {
990
+ if (!t.fullId)
991
+ continue;
992
+ const synced = this.syncBlock(t.block, t.fullId);
993
+ const res = this.parseEdit(run(synced));
994
+ if (res.success)
995
+ t.res = res;
996
+ }
997
+ this.finishMultiBlockOp(targets, forceRemount);
998
+ return true;
999
+ }
1000
+ /** Snapshot each selected block's identity + selection slice BEFORE any session op runs
1001
+ * (ops never mutate the DOM, so spans captured here stay valid until the swap phase). */
1002
+ multiBlockTargets(blocks) {
1003
+ return blocks.map((b) => {
1004
+ const unid = b.getAttribute("data-anchor");
1005
+ return {
1006
+ block: b,
1007
+ unid,
1008
+ fullId: unid ? this.unidToFullId.get(unid) : undefined,
1009
+ span: this.blockSpanForSelection(b),
1010
+ res: null,
1011
+ };
1012
+ });
1013
+ }
1014
+ /**
1015
+ * Reconcile the DOM after a multi-block op. Fidelity-identical to the single-block path by
1016
+ * construction: each edited block is swapped for its own session-attached single-block
1017
+ * render — exactly what format()/setFontSize()/applyParagraphFormat() do for one block —
1018
+ * so a multi-block apply is N single-block applies, not one whole-document re-render
1019
+ * (which froze the UI for the full-document convert time on every ribbon action). Falls
1020
+ * back to ONE full remount when the op touched a list item (numbering continuation needs
1021
+ * whole-document context) or the caller forced it. Restores the cross-block selection so
1022
+ * consecutive ribbon actions (center, then bold) keep targeting the same range.
1023
+ */
1024
+ finishMultiBlockOp(targets, forceRemount) {
1025
+ const edited = targets.filter((t) => t.res && t.unid);
1026
+ if (edited.length === 0)
1027
+ return;
1028
+ // Paginated mode: a multi-block format change can alter block heights, and page boxes
1029
+ // need a reflow — keep the (pre-existing) full remount there until M4 lands a scoped
1030
+ // re-paginate. Continuous mode reconciles incrementally.
1031
+ if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
1032
+ this.remount();
1033
+ return;
1034
+ }
1035
+ const swapped = [];
1036
+ for (const t of edited) {
1037
+ const fresh = this.swapBlock(t.block, t.unid, t.res.modified?.[0]);
1038
+ swapped.push({ el: fresh ?? t.block, span: t.span });
1039
+ }
1040
+ const first = swapped[0];
1041
+ const last = swapped[swapped.length - 1];
1042
+ if (!first.el.isConnected || !last.el.isConnected)
1043
+ return;
1044
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1045
+ if (!sel)
1046
+ return;
1047
+ const startOff = first.span?.start ?? 0;
1048
+ const endOff = last.span ? last.span.start + last.span.length : blockContentText(last.el).length;
1049
+ const startPos = contentPositionIn(first.el, startOff);
1050
+ const endPos = contentPositionIn(last.el, endOff);
1051
+ try {
1052
+ const range = document.createRange();
1053
+ range.setStart(startPos.node, startPos.offset);
1054
+ range.setEnd(endPos.node, endPos.offset);
1055
+ sel.removeAllRanges();
1056
+ sel.addRange(range);
1057
+ }
1058
+ catch {
1059
+ /* selection restore is best-effort — the edits themselves are already committed */
1060
+ }
1061
+ }
1062
+ /**
1063
+ * Toggle (or set) an inline format on the current selection in the active block.
1064
+ * A selection spanning multiple blocks applies to each. With no selection, applies to
1065
+ * the whole paragraph. Routes through DocxSession (`ApplyFormat`) so it is lossless and
1066
+ * supports underline/strike, not just markdown.
1067
+ */
1068
+ format(key, value) {
1069
+ const block = this.activeBlock;
1070
+ if (this.closed || !block)
1071
+ return;
1072
+ const blocks = this.selectedBlocks();
1073
+ if (blocks.length > 1) {
1074
+ const on0 = value ?? !selectionHasFormat(key, blocks[0]);
1075
+ const multiOp = key === "superscript" || key === "subscript"
1076
+ ? { vertAlign: on0 ? key : "" }
1077
+ : { [key]: on0 };
1078
+ if (this.applyInlineOpAcrossBlocks(blocks, multiOp))
1079
+ return;
1080
+ }
1081
+ const unid = block.getAttribute("data-anchor");
1082
+ if (!unid)
1083
+ return;
1084
+ let fullId = this.unidToFullId.get(unid);
1085
+ if (!fullId)
1086
+ return;
1087
+ const span = selectionSpanIn(block);
1088
+ const on = value ?? !selectionHasFormat(key, block);
1089
+ // Super/subscript map to the single-valued w:vertAlign; the rest are boolean toggles.
1090
+ const op = key === "superscript" || key === "subscript"
1091
+ ? { vertAlign: on ? key : "" }
1092
+ : { [key]: on };
1093
+ fullId = this.syncBlock(block, fullId); // don't clobber uncommitted typing
1094
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify(op)));
1095
+ if (!res.success)
1096
+ return;
1097
+ if (this.affectsList(res)) {
1098
+ this.remount(this.blockIndex(block), false);
1099
+ return;
1100
+ }
1101
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
1102
+ if (fresh && span)
1103
+ selectRange(fresh, span.start, span.length);
1104
+ else
1105
+ fresh?.focus();
1106
+ }
1107
+ /**
1108
+ * Set the font size (in points) of the current selection in the active block; with no
1109
+ * selection, applies to the whole paragraph. `pts <= 0` clears the explicit size. Routes
1110
+ * through DocxSession `ApplyFormat` (`w:sz`), so it is lossless and survives save.
1111
+ */
1112
+ setFontSize(pts) {
1113
+ const block = this.activeBlock;
1114
+ if (this.closed || !block)
1115
+ return;
1116
+ const blocks = this.selectedBlocks();
1117
+ if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontSizePts: pts }))
1118
+ return;
1119
+ const unid = block.getAttribute("data-anchor");
1120
+ if (!unid)
1121
+ return;
1122
+ let fullId = this.unidToFullId.get(unid);
1123
+ if (!fullId)
1124
+ return;
1125
+ // Use the live selection; if the font-size combobox stole focus and collapsed it, fall back to
1126
+ // the last real selection cached for this block so a sub-range still sizes (finding 3).
1127
+ let span = selectionSpanIn(block);
1128
+ if (!span && this.lastSelection && this.lastSelection.unid === unid)
1129
+ span = this.lastSelection.span;
1130
+ fullId = this.syncBlock(block, fullId);
1131
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify({ fontSizePts: pts })));
1132
+ if (!res.success)
1133
+ return;
1134
+ if (this.affectsList(res)) {
1135
+ this.remount(this.blockIndex(block), false);
1136
+ return;
1137
+ }
1138
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
1139
+ if (fresh && span)
1140
+ selectRange(fresh, span.start, span.length);
1141
+ else
1142
+ fresh?.focus();
1143
+ }
1144
+ /**
1145
+ * Set the font family of the current selection in the active block; with no selection,
1146
+ * applies to the whole paragraph. `""` clears the explicit font (inherits the style/default).
1147
+ * Routes through DocxSession `ApplyFormat` (`w:rFonts`), so it is lossless and survives save.
1148
+ * Multi-block + last-selection plumbing matches {@link setFontSize} (a focus-stealing font
1149
+ * dropdown still applies to the real sub-range).
1150
+ */
1151
+ setFontFamily(name) {
1152
+ const block = this.activeBlock;
1153
+ if (this.closed || !block)
1154
+ return;
1155
+ const blocks = this.selectedBlocks();
1156
+ if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontFamily: name }))
1157
+ return;
1158
+ const unid = block.getAttribute("data-anchor");
1159
+ if (!unid)
1160
+ return;
1161
+ let fullId = this.unidToFullId.get(unid);
1162
+ if (!fullId)
1163
+ return;
1164
+ let span = selectionSpanIn(block);
1165
+ if (!span && this.lastSelection && this.lastSelection.unid === unid)
1166
+ span = this.lastSelection.span;
1167
+ fullId = this.syncBlock(block, fullId);
1168
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify({ fontFamily: name })));
1169
+ if (!res.success)
1170
+ return;
1171
+ if (this.affectsList(res)) {
1172
+ this.remount(this.blockIndex(block), false);
1173
+ return;
1174
+ }
1175
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
1176
+ if (fresh && span)
1177
+ selectRange(fresh, span.start, span.length);
1178
+ else
1179
+ fresh?.focus();
1180
+ }
1181
+ /** Set paragraph alignment (left/center/right/justify) on the active block. */
1182
+ setAlignment(alignment) {
1183
+ this.applyParagraphFormat({ alignment });
1184
+ }
1185
+ /**
1186
+ * Insert an S-1-style horizontal rule (an empty paragraph with a bottom border) after the
1187
+ * active block. `weight` is the rule thickness in eighths of a point (default 12 ≈ 1.5pt).
1188
+ * Re-renders fully (a new block needs whole-document context to lay out).
1189
+ */
1190
+ insertHorizontalRule(weight = 12, style = "single", position = "below") {
1191
+ const block = this.activeBlock;
1192
+ if (this.closed || !block)
1193
+ return;
1194
+ const unid = block.getAttribute("data-anchor");
1195
+ if (!unid)
1196
+ return;
1197
+ let fullId = this.unidToFullId.get(unid);
1198
+ if (!fullId)
1199
+ return;
1200
+ const idx = this.blockIndex(block);
1201
+ fullId = this.syncBlock(block, fullId);
1202
+ const res = this.parseEdit(this.exports.DocxSessionBridge.InsertHorizontalRule(this.handle, fullId, position === "above" ? "before" : "after", JSON.stringify({ style, size: weight, color: "auto" })));
1203
+ if (!res.success)
1204
+ return;
1205
+ // remount from the active block's index re-renders the new rule whether it landed just
1206
+ // above (at idx) or just below (at idx+1) the active block.
1207
+ this.remount(idx, false);
1208
+ }
1209
+ /**
1210
+ * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
1211
+ * markdown) seeds the cells, `options.borderless` makes an invisible layout table, and
1212
+ * `options.cellAlignment` aligns every cell. Re-renders fully (tables need document context).
1213
+ */
1214
+ insertTable(rows, cols, options) {
1215
+ const block = this.activeBlock;
1216
+ if (this.closed || !block)
1217
+ return;
1218
+ const unid = block.getAttribute("data-anchor");
1219
+ if (!unid)
1220
+ return;
1221
+ let fullId = this.unidToFullId.get(unid);
1222
+ if (!fullId)
1223
+ return;
1224
+ const idx = this.blockIndex(block);
1225
+ fullId = this.syncBlock(block, fullId);
1226
+ // If the caret is on an empty paragraph (not a table cell), insert the table BEFORE it so the
1227
+ // empty paragraph becomes the editable line BELOW the table — no stray blank line above it, and
1228
+ // a reachable paragraph below (S-1 smoke-test findings 2 + 4). Otherwise insert after.
1229
+ const emptyHere = !block.closest("table") && blockContentText(block).replace(/[\s ]+/g, "").length === 0;
1230
+ const res = this.parseEdit(this.exports.DocxSessionBridge.InsertTable(this.handle, fullId, emptyHere ? "before" : "after", rows, cols, options ? JSON.stringify(options) : ""));
1231
+ if (!res.success)
1232
+ return;
1233
+ this.remount(idx, false);
1234
+ }
1235
+ // ─── Table row / column editing (active block must be inside a table cell) ──────────
1236
+ /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
1237
+ tableEdit(run) {
1238
+ const block = this.activeBlock;
1239
+ if (this.closed || !block || !block.closest("table"))
1240
+ return;
1241
+ const unid = block.getAttribute("data-anchor");
1242
+ if (!unid)
1243
+ return;
1244
+ let fullId = this.unidToFullId.get(unid);
1245
+ if (!fullId)
1246
+ return;
1247
+ const idx = this.blockIndex(block);
1248
+ fullId = this.syncBlock(block, fullId); // flush uncommitted cell text first
1249
+ const res = this.parseEdit(run(fullId));
1250
+ if (!res.success)
1251
+ return;
1252
+ this.remount(idx, false);
1253
+ }
1254
+ /** Insert a row above/below the active cell's row. No-op outside a table. */
1255
+ insertTableRow(where) {
1256
+ this.tableEdit((a) => this.exports.DocxSessionBridge.InsertTableRow(this.handle, a, where === "above" ? "before" : "after"));
1257
+ }
1258
+ /** Insert a column left/right of the active cell's column. No-op outside a table. */
1259
+ insertTableColumn(where) {
1260
+ this.tableEdit((a) => this.exports.DocxSessionBridge.InsertTableColumn(this.handle, a, where === "left" ? "before" : "after"));
1261
+ }
1262
+ /** Delete the active cell's row (deleting the last row removes the table). No-op outside a table. */
1263
+ deleteTableRow() {
1264
+ this.tableEdit((a) => this.exports.DocxSessionBridge.DeleteTableRow(this.handle, a));
1265
+ }
1266
+ /** Delete the active cell's column (deleting the last column removes the table). No-op outside a table. */
1267
+ deleteTableColumn() {
1268
+ this.tableEdit((a) => this.exports.DocxSessionBridge.DeleteTableColumn(this.handle, a));
1269
+ }
1270
+ /**
1271
+ * Indent/outdent the active block. On a LIST item this changes the list NESTING LEVEL
1272
+ * (`SetListLevel`) so numbering nests (e.g. 1, 2 → a sub-level) rather than the item just
1273
+ * shifting sideways with flat numbering. On a plain paragraph it adjusts the left indent by
1274
+ * `deltaTwips` (default ±720 = 0.5"), clamped at 0.
1275
+ */
1276
+ indent(deltaTwips = 720) {
1277
+ if (this.activeBlock && isListBlock(this.activeBlock)) {
1278
+ this.setListLevel(deltaTwips >= 0 ? 1 : -1);
1279
+ return;
1280
+ }
1281
+ this.applyParagraphFormat({ indentDelta: deltaTwips });
1282
+ }
1283
+ /** Change the active list item's nesting level by `delta` (+1 deeper, −1 shallower). */
1284
+ setListLevel(delta) {
1285
+ const block = this.activeBlock;
1286
+ if (this.closed || !block)
1287
+ return;
1288
+ const unid = block.getAttribute("data-anchor");
1289
+ if (!unid)
1290
+ return;
1291
+ let fullId = this.unidToFullId.get(unid);
1292
+ if (!fullId)
1293
+ return;
1294
+ const idx = this.blockIndex(block);
1295
+ fullId = this.syncBlock(block, fullId);
1296
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SetListLevel(this.handle, fullId, delta));
1297
+ if (!res.success)
1298
+ return;
1299
+ // A level change ripples through the whole list's numbering — re-render with full document
1300
+ // context (a single-block render can't compute nested numbering), keeping the caret in place.
1301
+ this.remount(idx, false);
1302
+ }
1303
+ /** Toggle (or set) page-break-before on the active block. */
1304
+ pageBreakBefore(value = true) {
1305
+ this.applyParagraphFormat({ pageBreakBefore: value });
1306
+ }
1307
+ /**
1308
+ * Toggle the active block between a bullet/numbered list item and a plain paragraph.
1309
+ * Clicking the same kind it already is removes the list; any other state applies the kind.
1310
+ */
1311
+ toggleList(kind) {
1312
+ const block = this.activeBlock;
1313
+ if (this.closed || !block)
1314
+ return;
1315
+ const unid = block.getAttribute("data-anchor");
1316
+ if (!unid)
1317
+ return;
1318
+ let fullId = this.unidToFullId.get(unid);
1319
+ if (!fullId)
1320
+ return;
1321
+ let membership = null;
1322
+ try {
1323
+ membership = JSON.parse(this.exports.DocxSessionBridge.GetListMembership(this.handle, fullId));
1324
+ }
1325
+ catch { /* treat as not-a-list */ }
1326
+ const isThisKind = !!membership && typeof membership.format === "string" &&
1327
+ membership.format.toLowerCase().startsWith(kind === "bullet" ? "bullet" : "decimal");
1328
+ const idx = this.blockIndex(block); // capture before the op
1329
+ fullId = this.syncBlock(block, fullId);
1330
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, isThisKind ? "none" : kind));
1331
+ if (!res.success)
1332
+ return;
1333
+ // Numbering continuation across the list needs whole-document context — re-render fully
1334
+ // (a single-block render would show every numbered item as "1.").
1335
+ this.remount(idx, false);
1336
+ }
1337
+ /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
1338
+ * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
1339
+ * this surfaces it on the editor so an HR border is removable (S-1 smoke-test finding 1b). */
1340
+ clearParagraphBorders() {
1341
+ this.applyParagraphFormat({ clearBorders: true });
1342
+ }
1343
+ /**
1344
+ * Delete the active block (e.g. a stray empty paragraph left above/below a table). Routes
1345
+ * through DocxSession `DeleteBlock` + re-render, focusing the previous block. No-op when the
1346
+ * caret is inside a table (remove cells via the table toolbar's delete row/column instead) and
1347
+ * no-op when it is the only editable block (don't empty the document). Closes the S-1
1348
+ * smoke-test "no block-delete affordance" gap.
1349
+ */
1350
+ deleteBlock() {
1351
+ const block = this.activeBlock;
1352
+ if (this.closed || !block)
1353
+ return;
1354
+ if (block.closest("table"))
1355
+ return; // cells are removed via the table toolbar, not here
1356
+ if (this.editableList().length <= 1)
1357
+ return; // never delete the last editable block
1358
+ const unid = block.getAttribute("data-anchor");
1359
+ if (!unid)
1360
+ return;
1361
+ const fullId = this.unidToFullId.get(unid);
1362
+ if (!fullId)
1363
+ return;
1364
+ const idx = this.blockIndex(block);
1365
+ const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
1366
+ if (!res.success)
1367
+ return;
1368
+ this.remount(Math.max(0, idx - 1), true);
1369
+ }
1370
+ applyParagraphFormat(op) {
1371
+ const block = this.activeBlock;
1372
+ if (this.closed || !block)
1373
+ return;
1374
+ const blocks = this.selectedBlocks();
1375
+ if (blocks.length > 1 &&
1376
+ this.applyParagraphOpAcrossBlocks(blocks, (id) => this.exports.DocxSessionBridge.SetParagraphFormat(this.handle, id, JSON.stringify(op)),
1377
+ // A border change regroups the wrapping border <div>s — whole-document context.
1378
+ !!op.clearBorders))
1379
+ return;
1380
+ const unid = block.getAttribute("data-anchor");
1381
+ if (!unid)
1382
+ return;
1383
+ let fullId = this.unidToFullId.get(unid);
1384
+ if (!fullId)
1385
+ return;
1386
+ const idx = this.blockIndex(block);
1387
+ fullId = this.syncBlock(block, fullId);
1388
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphFormat(this.handle, fullId, JSON.stringify(op)));
1389
+ if (!res.success)
1390
+ return;
1391
+ // A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
1392
+ // it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
1393
+ if (this.affectsList(res) || op.clearBorders) {
1394
+ this.remount(idx, false);
1395
+ return;
1396
+ }
1397
+ this.swapBlock(block, unid, res.modified?.[0])?.focus();
1398
+ }
1399
+ /** Set the paragraph style of the active block — or of every block in a multi-block selection
1400
+ * (e.g. "Heading1", "Heading2", "Normal"). */
1401
+ setParagraphStyle(styleId) {
1402
+ const block = this.activeBlock;
1403
+ if (this.closed || !block)
1404
+ return;
1405
+ const blocks = this.selectedBlocks();
1406
+ if (blocks.length > 1 &&
1407
+ this.applyParagraphOpAcrossBlocks(blocks, (id) => this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, id, styleId)))
1408
+ return;
1409
+ const unid = block.getAttribute("data-anchor");
1410
+ if (!unid)
1411
+ return;
1412
+ let fullId = this.unidToFullId.get(unid);
1413
+ if (!fullId)
1414
+ return;
1415
+ const idx = this.blockIndex(block);
1416
+ fullId = this.syncBlock(block, fullId);
1417
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, fullId, styleId));
1418
+ if (!res.success)
1419
+ return;
1420
+ if (this.affectsList(res)) {
1421
+ this.remount(idx, false);
1422
+ return;
1423
+ }
1424
+ this.swapBlock(block, unid, res.modified?.[0])?.focus();
1425
+ }
1426
+ /** Undo the last edit (re-renders the document). */
1427
+ undo() {
1428
+ if (this.closed)
1429
+ return;
1430
+ if (this.exports.DocxSessionBridge.Undo(this.handle))
1431
+ this.remount();
1432
+ }
1433
+ /** Redo the last undone edit (re-renders the document). */
1434
+ redo() {
1435
+ if (this.closed)
1436
+ return;
1437
+ if (this.exports.DocxSessionBridge.Redo(this.handle))
1438
+ this.remount();
1439
+ }
1440
+ /** Which inline formats the current selection carries — for ribbon button highlighting. */
1441
+ queryFormatState() {
1442
+ const block = this.activeBlock ?? this.editRoot;
1443
+ return {
1444
+ bold: selectionHasFormat("bold", block),
1445
+ italic: selectionHasFormat("italic", block),
1446
+ underline: selectionHasFormat("underline", block),
1447
+ strike: selectionHasFormat("strike", block),
1448
+ code: selectionHasFormat("code", block),
1449
+ superscript: selectionHasFormat("superscript", block),
1450
+ subscript: selectionHasFormat("subscript", block),
1451
+ };
1452
+ }
1453
+ /** Re-render one block from the live session by EditResult ref, swapping it in place. */
1454
+ swapBlock(oldEl, oldUnid, ref) {
1455
+ const anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
1456
+ const newUnid = ref?.unid ?? oldUnid;
1457
+ if (!anchorId)
1458
+ return null;
1459
+ const fresh = this.renderInto(anchorId);
1460
+ if (!fresh || !this.replaceNode(oldEl, fresh))
1461
+ return null;
1462
+ this.unidToFullId.delete(oldUnid);
1463
+ this.unidToFullId.set(newUnid, anchorId);
1464
+ this.wireBlock(fresh);
1465
+ this.activeBlock = fresh;
1466
+ this.options.onEdit?.({ anchorId, unid: newUnid });
1467
+ return fresh;
1468
+ }
1469
+ /**
1470
+ * Full-document HTML from the live session. Prefers the session-attached
1471
+ * `RenderHtml` bridge — the saved bytes never cross the JS/WASM boundary
1472
+ * (two multi-MB copies per remount on a large doc) — and falls back to
1473
+ * Save + ConvertDocxToHtmlComplete for older WASM bundles. Both paths use
1474
+ * the same option profile, so the rendered HTML is identical.
1475
+ */
1476
+ renderFullHtml() {
1477
+ const bridge = this.exports.DocxSessionBridge;
1478
+ if (typeof bridge.RenderHtml === "function") {
1479
+ const html = bridge.RenderHtml(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale);
1480
+ if (html.charCodeAt(0) !== 0x7b /* not an error object */)
1481
+ return html;
1482
+ }
1483
+ const bytes = bridge.Save(this.handle);
1484
+ return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
1485
+ }
1486
+ /** Editable blocks in document order. */
1487
+ editableList() {
1488
+ return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
1489
+ }
1490
+ blockIndex(el) {
1491
+ return this.editableList().indexOf(el);
1492
+ }
1493
+ /**
1494
+ * True when an edit produced or touched a list item (kind "li"). List markers and
1495
+ * numbering CONTINUATION need whole-document context, which a single-block render lacks
1496
+ * (every item would render as "1."), so such edits re-render the whole document.
1497
+ */
1498
+ affectsList(res) {
1499
+ return [...(res.modified ?? []), ...(res.created ?? [])].some((r) => r.kind === "li");
1500
+ }
1501
+ /**
1502
+ * Full re-render from current session state (after undo/redo, and after list edits where
1503
+ * single-block rendering can't compute numbering). Optionally focus the editable block at
1504
+ * `focusIndex` (caret at start, or end if `caretAtEnd`) — addressed by index because a
1505
+ * block's content-hashed unid changes across the save/reproject a remount performs.
1506
+ */
1507
+ remount(focusIndex = -1, caretAtEnd = false) {
1508
+ this.refreshAnchorMap();
1509
+ const fullHtml = this.renderFullHtml();
1510
+ this.activeBlock = null;
1511
+ if (this.options.paginated)
1512
+ this.mountPaginated(fullHtml);
1513
+ else
1514
+ this.mountHtml(fullHtml);
1515
+ if (focusIndex >= 0) {
1516
+ const blocks = this.editableList();
1517
+ const target = blocks[Math.min(focusIndex, blocks.length - 1)];
1518
+ if (target) {
1519
+ this.activeBlock = target;
1520
+ placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
1521
+ }
1522
+ }
1523
+ }
1524
+ }
1525
+ //# sourceMappingURL=editor.js.map