docxodus 6.4.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 +129 -4
  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 +67 -0
  13. package/dist/session.d.ts +37 -1
  14. package/dist/session.d.ts.map +1 -1
  15. package/dist/session.js +59 -0
  16. package/dist/session.js.map +1 -1
  17. package/dist/types.d.ts +296 -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 +7 -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
@@ -0,0 +1,2143 @@
1
+ "use strict";
2
+ var DocxodusEditor = (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/editor.ts
22
+ var editor_exports = {};
23
+ __export(editor_exports, {
24
+ DocxEditor: () => DocxEditor,
25
+ serializeInlineMarkdown: () => serializeInlineMarkdown
26
+ });
27
+
28
+ // src/pagination.ts
29
+ var DEFAULT_PAGE_WIDTH = 612;
30
+ var DEFAULT_PAGE_HEIGHT = 792;
31
+ var DEFAULT_MARGIN = 72;
32
+ var MAX_FOOTNOTE_AREA_RATIO = 0.6;
33
+ var MIN_BODY_CONTENT_HEIGHT = 72;
34
+ function pxToPt(px) {
35
+ return px * 0.75;
36
+ }
37
+ function ptToPx(pt) {
38
+ return pt / 0.75;
39
+ }
40
+ var DEFAULT_HEADER_FOOTER_HEIGHT = 36;
41
+ function parseDimensions(section) {
42
+ const pageWidth = parseFloat(section.dataset.pageWidth || "") || DEFAULT_PAGE_WIDTH;
43
+ const pageHeight = parseFloat(section.dataset.pageHeight || "") || DEFAULT_PAGE_HEIGHT;
44
+ const contentWidth = parseFloat(section.dataset.contentWidth || "") || pageWidth - 2 * DEFAULT_MARGIN;
45
+ const contentHeight = parseFloat(section.dataset.contentHeight || "") || pageHeight - 2 * DEFAULT_MARGIN;
46
+ const marginTop = parseFloat(section.dataset.marginTop || "") || DEFAULT_MARGIN;
47
+ const marginRight = parseFloat(section.dataset.marginRight || "") || DEFAULT_MARGIN;
48
+ const marginBottom = parseFloat(section.dataset.marginBottom || "") || DEFAULT_MARGIN;
49
+ const marginLeft = parseFloat(section.dataset.marginLeft || "") || DEFAULT_MARGIN;
50
+ const headerHeight = parseFloat(section.dataset.headerHeight || "") || DEFAULT_HEADER_FOOTER_HEIGHT;
51
+ const footerHeight = parseFloat(section.dataset.footerHeight || "") || DEFAULT_HEADER_FOOTER_HEIGHT;
52
+ return {
53
+ pageWidth,
54
+ pageHeight,
55
+ contentWidth,
56
+ contentHeight,
57
+ marginTop,
58
+ marginRight,
59
+ marginBottom,
60
+ marginLeft,
61
+ headerHeight,
62
+ footerHeight
63
+ };
64
+ }
65
+ var PaginationEngine = class {
66
+ /**
67
+ * Creates a new pagination engine.
68
+ *
69
+ * @param staging - The staging element or its ID containing the content to paginate
70
+ * @param container - The container element or its ID where pages will be rendered
71
+ * @param options - Pagination options
72
+ */
73
+ constructor(staging, container, options = {}) {
74
+ this.pendingFootnoteContinuation = null;
75
+ this.stagingElement = typeof staging === "string" ? document.getElementById(staging) : staging;
76
+ this.containerElement = typeof container === "string" ? document.getElementById(container) : container;
77
+ if (!this.stagingElement) {
78
+ throw new Error("Staging element not found");
79
+ }
80
+ if (!this.containerElement) {
81
+ throw new Error("Container element not found");
82
+ }
83
+ this.scale = options.scale ?? 1;
84
+ this.cssPrefix = options.cssPrefix ?? "page-";
85
+ this.showPageNumbers = options.showPageNumbers ?? true;
86
+ this.pageGap = options.pageGap ?? 20;
87
+ this.hfRegistry = /* @__PURE__ */ new Map();
88
+ this.footnoteRegistry = /* @__PURE__ */ new Map();
89
+ }
90
+ /**
91
+ * Runs the pagination process.
92
+ *
93
+ * @returns PaginationResult with page information
94
+ */
95
+ paginate() {
96
+ const pages = [];
97
+ let pageNumber = 1;
98
+ this.hfRegistry = this.parseHeaderFooterRegistry();
99
+ this.footnoteRegistry = this.parseFootnoteRegistry();
100
+ const sections = this.stagingElement.querySelectorAll(
101
+ "[data-section-index]"
102
+ );
103
+ const sectionsToProcess = sections.length > 0 ? Array.from(sections) : [this.stagingElement];
104
+ for (const section of sectionsToProcess) {
105
+ const sectionIndex = parseInt(section.dataset.sectionIndex || "0", 10);
106
+ const dims = parseDimensions(section);
107
+ this.stagingElement.style.visibility = "hidden";
108
+ this.stagingElement.style.position = "absolute";
109
+ this.stagingElement.style.left = "-9999px";
110
+ this.stagingElement.style.display = "block";
111
+ section.style.width = `${dims.contentWidth}pt`;
112
+ const blocks = this.measureBlocks(section, dims);
113
+ const sectionPages = this.flowToPages(blocks, dims, pageNumber, sectionIndex);
114
+ pages.push(...sectionPages);
115
+ pageNumber += sectionPages.length;
116
+ }
117
+ this.stagingElement.style.display = "none";
118
+ return { totalPages: pages.length, pages };
119
+ }
120
+ /**
121
+ * Measures all content blocks in a section.
122
+ */
123
+ measureBlocks(section, dims) {
124
+ const blocks = [];
125
+ const children = Array.from(section.children);
126
+ for (const child of children) {
127
+ if (child.dataset.sectionIndex !== void 0) {
128
+ const nestedBlocks = this.measureBlocks(child, dims);
129
+ blocks.push(...nestedBlocks);
130
+ continue;
131
+ }
132
+ const rect = child.getBoundingClientRect();
133
+ const style = window.getComputedStyle(child);
134
+ const marginTopPx = parseFloat(style.marginTop) || 0;
135
+ const marginBottomPx = parseFloat(style.marginBottom) || 0;
136
+ const heightPt = pxToPt(rect.height);
137
+ const marginTopPt = pxToPt(marginTopPx);
138
+ const marginBottomPt = pxToPt(marginBottomPx);
139
+ const isPageBreak = child.dataset.pageBreak === "true" || child.classList.contains(`${this.cssPrefix}break`);
140
+ blocks.push({
141
+ element: child,
142
+ heightPt,
143
+ marginTopPt,
144
+ marginBottomPt,
145
+ keepWithNext: child.dataset.keepWithNext === "true",
146
+ keepLines: child.dataset.keepLines === "true",
147
+ pageBreakBefore: child.dataset.pageBreakBefore === "true",
148
+ isPageBreak
149
+ });
150
+ }
151
+ return blocks;
152
+ }
153
+ /**
154
+ * Parses the header/footer registry from the staging element.
155
+ * Also measures heights during parsing for lazy-loading compatibility.
156
+ */
157
+ parseHeaderFooterRegistry() {
158
+ const registry = /* @__PURE__ */ new Map();
159
+ const registryEl = this.stagingElement.querySelector("#pagination-hf-registry");
160
+ if (!registryEl) return registry;
161
+ const sectionWidths = /* @__PURE__ */ new Map();
162
+ const sections = Array.from(this.stagingElement.querySelectorAll("[data-section-index]"));
163
+ for (const section of sections) {
164
+ const idx = parseInt(section.dataset.sectionIndex || "0", 10);
165
+ const contentWidth = parseFloat(section.dataset.contentWidth || "") || DEFAULT_PAGE_WIDTH - 2 * DEFAULT_MARGIN;
166
+ sectionWidths.set(idx, contentWidth);
167
+ }
168
+ const defaultContentWidth = sectionWidths.get(0) || DEFAULT_PAGE_WIDTH - 2 * DEFAULT_MARGIN;
169
+ const entries = Array.from(registryEl.querySelectorAll("[data-section][data-hf-type]"));
170
+ for (const entry of entries) {
171
+ const sectionIndex = parseInt(entry.dataset.section || "0", 10);
172
+ const hfType = entry.dataset.hfType;
173
+ if (!registry.has(sectionIndex)) {
174
+ registry.set(sectionIndex, {});
175
+ }
176
+ const section = registry.get(sectionIndex);
177
+ const content = entry.cloneNode(true);
178
+ const contentWidth = sectionWidths.get(sectionIndex) || defaultContentWidth;
179
+ const measuredHeight = this.measureHeaderFooterHeight(content, contentWidth);
180
+ switch (hfType) {
181
+ case "header-default":
182
+ section.headerDefault = content;
183
+ section.headerDefaultHeight = measuredHeight;
184
+ break;
185
+ case "header-first":
186
+ section.headerFirst = content;
187
+ section.headerFirstHeight = measuredHeight;
188
+ break;
189
+ case "header-even":
190
+ section.headerEven = content;
191
+ section.headerEvenHeight = measuredHeight;
192
+ break;
193
+ case "footer-default":
194
+ section.footerDefault = content;
195
+ section.footerDefaultHeight = measuredHeight;
196
+ break;
197
+ case "footer-first":
198
+ section.footerFirst = content;
199
+ section.footerFirstHeight = measuredHeight;
200
+ break;
201
+ case "footer-even":
202
+ section.footerEven = content;
203
+ section.footerEvenHeight = measuredHeight;
204
+ break;
205
+ }
206
+ }
207
+ return registry;
208
+ }
209
+ /**
210
+ * Parses the footnote registry from the staging element.
211
+ */
212
+ parseFootnoteRegistry() {
213
+ const registry = /* @__PURE__ */ new Map();
214
+ const registryEl = this.stagingElement.querySelector("#pagination-footnote-registry");
215
+ if (!registryEl) return registry;
216
+ const entries = Array.from(registryEl.querySelectorAll("[data-footnote-id]"));
217
+ for (const entry of entries) {
218
+ const footnoteId = entry.dataset.footnoteId;
219
+ if (footnoteId) {
220
+ registry.set(footnoteId, entry.cloneNode(true));
221
+ }
222
+ }
223
+ return registry;
224
+ }
225
+ /**
226
+ * Extracts footnote reference IDs from an element.
227
+ */
228
+ extractFootnoteRefs(element) {
229
+ const refs = element.querySelectorAll("[data-footnote-id]");
230
+ const ids = [];
231
+ for (const ref of Array.from(refs)) {
232
+ const id = ref.dataset.footnoteId;
233
+ if (id && !ids.includes(id)) {
234
+ ids.push(id);
235
+ }
236
+ }
237
+ return ids;
238
+ }
239
+ /**
240
+ * Measures the height of footnotes for given IDs (in points).
241
+ * Creates a temporary container to measure the footnotes.
242
+ * @param footnoteIds - IDs of footnotes to measure
243
+ * @param contentWidth - Width for measurement
244
+ * @param continuation - Optional continuation content to include first
245
+ */
246
+ measureFootnotesHeight(footnoteIds, contentWidth, continuation) {
247
+ const hasContinuation = continuation && continuation.remainingElements.length > 0;
248
+ if (footnoteIds.length === 0 && !hasContinuation || this.footnoteRegistry.size === 0) {
249
+ return 0;
250
+ }
251
+ const measureContainer = document.createElement("div");
252
+ measureContainer.style.position = "absolute";
253
+ measureContainer.style.visibility = "hidden";
254
+ measureContainer.style.width = `${contentWidth}pt`;
255
+ measureContainer.style.left = "-9999px";
256
+ const hr = document.createElement("hr");
257
+ measureContainer.appendChild(hr);
258
+ if (hasContinuation) {
259
+ const contWrapper = document.createElement("div");
260
+ contWrapper.className = "footnote-continuation";
261
+ for (const el of continuation.remainingElements) {
262
+ contWrapper.appendChild(el.cloneNode(true));
263
+ }
264
+ measureContainer.appendChild(contWrapper);
265
+ }
266
+ for (const id of footnoteIds) {
267
+ const footnote = this.footnoteRegistry.get(id);
268
+ if (footnote) {
269
+ measureContainer.appendChild(footnote.cloneNode(true));
270
+ }
271
+ }
272
+ this.stagingElement.appendChild(measureContainer);
273
+ const rect = measureContainer.getBoundingClientRect();
274
+ const heightPt = pxToPt(rect.height);
275
+ this.stagingElement.removeChild(measureContainer);
276
+ return heightPt;
277
+ }
278
+ /**
279
+ * Measures the height of just the continuation content (in points).
280
+ */
281
+ measureContinuationHeight(continuation, contentWidth) {
282
+ if (!continuation || continuation.remainingElements.length === 0) {
283
+ return 0;
284
+ }
285
+ const measureContainer = document.createElement("div");
286
+ measureContainer.style.position = "absolute";
287
+ measureContainer.style.visibility = "hidden";
288
+ measureContainer.style.width = `${contentWidth}pt`;
289
+ measureContainer.style.left = "-9999px";
290
+ const hr = document.createElement("hr");
291
+ measureContainer.appendChild(hr);
292
+ for (const el of continuation.remainingElements) {
293
+ measureContainer.appendChild(el.cloneNode(true));
294
+ }
295
+ this.stagingElement.appendChild(measureContainer);
296
+ const rect = measureContainer.getBoundingClientRect();
297
+ const heightPt = pxToPt(rect.height);
298
+ this.stagingElement.removeChild(measureContainer);
299
+ return heightPt;
300
+ }
301
+ /**
302
+ * Splits a footnote element into parts that fit within the available height.
303
+ * Returns the elements that fit and the elements that need to continue.
304
+ */
305
+ splitFootnoteToFit(footnoteElement, availableHeightPt, contentWidth) {
306
+ const footnoteContent = footnoteElement.querySelector(".footnote-content");
307
+ if (!footnoteContent) {
308
+ return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
309
+ }
310
+ const children = Array.from(footnoteContent.children);
311
+ if (children.length <= 1) {
312
+ return { fits: [footnoteElement.cloneNode(true)], overflow: [] };
313
+ }
314
+ const fits = [];
315
+ const overflow = [];
316
+ let currentHeight = 0;
317
+ const hrMeasure = document.createElement("div");
318
+ hrMeasure.style.position = "absolute";
319
+ hrMeasure.style.visibility = "hidden";
320
+ hrMeasure.style.width = `${contentWidth}pt`;
321
+ hrMeasure.style.left = "-9999px";
322
+ const hr = document.createElement("hr");
323
+ hrMeasure.appendChild(hr);
324
+ this.stagingElement.appendChild(hrMeasure);
325
+ const hrHeight = pxToPt(hrMeasure.getBoundingClientRect().height);
326
+ this.stagingElement.removeChild(hrMeasure);
327
+ currentHeight = hrHeight;
328
+ const footnoteNumber = footnoteElement.querySelector(".footnote-number");
329
+ for (let i = 0; i < children.length; i++) {
330
+ const child = children[i];
331
+ const measureContainer = document.createElement("div");
332
+ measureContainer.style.position = "absolute";
333
+ measureContainer.style.visibility = "hidden";
334
+ measureContainer.style.width = `${contentWidth}pt`;
335
+ measureContainer.style.left = "-9999px";
336
+ measureContainer.appendChild(child.cloneNode(true));
337
+ this.stagingElement.appendChild(measureContainer);
338
+ const childHeight = pxToPt(measureContainer.getBoundingClientRect().height);
339
+ this.stagingElement.removeChild(measureContainer);
340
+ if (currentHeight + childHeight <= availableHeightPt) {
341
+ fits.push(child.cloneNode(true));
342
+ currentHeight += childHeight;
343
+ } else {
344
+ for (let j = i; j < children.length; j++) {
345
+ overflow.push(children[j].cloneNode(true));
346
+ }
347
+ break;
348
+ }
349
+ }
350
+ return { fits, overflow };
351
+ }
352
+ /**
353
+ * Measures a single footnote's height.
354
+ */
355
+ measureSingleFootnoteHeight(footnoteId, contentWidth) {
356
+ const footnote = this.footnoteRegistry.get(footnoteId);
357
+ if (!footnote) return 0;
358
+ const measureContainer = document.createElement("div");
359
+ measureContainer.style.position = "absolute";
360
+ measureContainer.style.visibility = "hidden";
361
+ measureContainer.style.width = `${contentWidth}pt`;
362
+ measureContainer.style.left = "-9999px";
363
+ measureContainer.appendChild(footnote.cloneNode(true));
364
+ this.stagingElement.appendChild(measureContainer);
365
+ const rect = measureContainer.getBoundingClientRect();
366
+ const heightPt = pxToPt(rect.height);
367
+ this.stagingElement.removeChild(measureContainer);
368
+ return heightPt;
369
+ }
370
+ /**
371
+ * Adds footnotes to a page container, including continuation content.
372
+ */
373
+ addPageFootnotes(pageBox, footnoteIds, dims, footnoteHeight, continuation, partialFootnotes) {
374
+ const hasContinuation = continuation && continuation.remainingElements.length > 0;
375
+ if (footnoteIds.length === 0 && !hasContinuation) {
376
+ return;
377
+ }
378
+ if (this.footnoteRegistry.size === 0 && !hasContinuation) {
379
+ return;
380
+ }
381
+ const partialFootnoteIds = new Set(partialFootnotes?.map((p) => p.footnoteId) || []);
382
+ const maxFootnoteHeight = Math.min(
383
+ footnoteHeight,
384
+ dims.contentHeight * MAX_FOOTNOTE_AREA_RATIO
385
+ );
386
+ const footnotesDiv = document.createElement("div");
387
+ footnotesDiv.className = `${this.cssPrefix}footnotes`;
388
+ footnotesDiv.style.position = "absolute";
389
+ footnotesDiv.style.bottom = `${dims.marginBottom}pt`;
390
+ footnotesDiv.style.left = `${dims.marginLeft}pt`;
391
+ footnotesDiv.style.width = `${dims.contentWidth}pt`;
392
+ footnotesDiv.style.boxSizing = "border-box";
393
+ footnotesDiv.style.maxHeight = `${maxFootnoteHeight}pt`;
394
+ footnotesDiv.style.overflow = "hidden";
395
+ const hr = document.createElement("hr");
396
+ footnotesDiv.appendChild(hr);
397
+ if (hasContinuation) {
398
+ const contWrapper = document.createElement("div");
399
+ contWrapper.className = "footnote-continuation";
400
+ for (const el of continuation.remainingElements) {
401
+ contWrapper.appendChild(el.cloneNode(true));
402
+ }
403
+ footnotesDiv.appendChild(contWrapper);
404
+ }
405
+ for (const id of footnoteIds) {
406
+ const partial = partialFootnotes?.find((p) => p.footnoteId === id);
407
+ if (partial) {
408
+ const footnote = this.footnoteRegistry.get(id);
409
+ if (footnote) {
410
+ const partialDiv = document.createElement("div");
411
+ partialDiv.className = "footnote-item";
412
+ partialDiv.dataset.footnoteId = id;
413
+ const numberSpan = footnote.querySelector(".footnote-number");
414
+ if (numberSpan) {
415
+ partialDiv.appendChild(numberSpan.cloneNode(true));
416
+ }
417
+ const contentSpan = document.createElement("span");
418
+ contentSpan.className = "footnote-content";
419
+ for (const el of partial.fittingElements) {
420
+ contentSpan.appendChild(el.cloneNode(true));
421
+ }
422
+ partialDiv.appendChild(contentSpan);
423
+ footnotesDiv.appendChild(partialDiv);
424
+ }
425
+ } else {
426
+ const footnote = this.footnoteRegistry.get(id);
427
+ if (footnote) {
428
+ footnotesDiv.appendChild(footnote.cloneNode(true));
429
+ }
430
+ }
431
+ }
432
+ pageBox.appendChild(footnotesDiv);
433
+ }
434
+ /**
435
+ * Selects the appropriate header for a page based on section, page position, and page number.
436
+ */
437
+ selectHeader(sectionIndex, pageInSection, globalPageNumber) {
438
+ const sectionHf = this.hfRegistry.get(sectionIndex);
439
+ if (!sectionHf) return void 0;
440
+ if (pageInSection === 1 && sectionHf.headerFirst) {
441
+ return sectionHf.headerFirst;
442
+ }
443
+ if (globalPageNumber % 2 === 0 && sectionHf.headerEven) {
444
+ return sectionHf.headerEven;
445
+ }
446
+ return sectionHf.headerDefault;
447
+ }
448
+ /**
449
+ * Selects the appropriate footer for a page based on section, page position, and page number.
450
+ */
451
+ selectFooter(sectionIndex, pageInSection, globalPageNumber) {
452
+ const sectionHf = this.hfRegistry.get(sectionIndex);
453
+ if (!sectionHf) return void 0;
454
+ if (pageInSection === 1 && sectionHf.footerFirst) {
455
+ return sectionHf.footerFirst;
456
+ }
457
+ if (globalPageNumber % 2 === 0 && sectionHf.footerEven) {
458
+ return sectionHf.footerEven;
459
+ }
460
+ return sectionHf.footerDefault;
461
+ }
462
+ /**
463
+ * Computes effective header, footer, and content heights for a specific page position.
464
+ * Uses pre-measured header/footer heights from the registry.
465
+ * This method is deterministic - same inputs always produce same outputs.
466
+ * This enables lazy loading compatibility since available height can be computed
467
+ * for any page position without knowing the page's content.
468
+ */
469
+ getEffectiveHeights(dims, sectionIndex, pageInSection, globalPageNumber) {
470
+ const sectionHf = this.hfRegistry.get(sectionIndex);
471
+ let headerHeight = dims.marginTop;
472
+ if (sectionHf) {
473
+ let measuredHeaderHeight;
474
+ if (pageInSection === 1 && sectionHf.headerFirstHeight != null) {
475
+ measuredHeaderHeight = sectionHf.headerFirstHeight;
476
+ } else if (globalPageNumber % 2 === 0 && sectionHf.headerEvenHeight != null) {
477
+ measuredHeaderHeight = sectionHf.headerEvenHeight;
478
+ } else if (sectionHf.headerDefaultHeight != null) {
479
+ measuredHeaderHeight = sectionHf.headerDefaultHeight;
480
+ }
481
+ if (measuredHeaderHeight != null) {
482
+ headerHeight = Math.max(dims.marginTop, measuredHeaderHeight);
483
+ }
484
+ }
485
+ let footerHeight = dims.marginBottom;
486
+ if (sectionHf) {
487
+ let measuredFooterHeight;
488
+ if (pageInSection === 1 && sectionHf.footerFirstHeight != null) {
489
+ measuredFooterHeight = sectionHf.footerFirstHeight;
490
+ } else if (globalPageNumber % 2 === 0 && sectionHf.footerEvenHeight != null) {
491
+ measuredFooterHeight = sectionHf.footerEvenHeight;
492
+ } else if (sectionHf.footerDefaultHeight != null) {
493
+ measuredFooterHeight = sectionHf.footerDefaultHeight;
494
+ }
495
+ if (measuredFooterHeight != null) {
496
+ footerHeight = Math.max(dims.marginBottom, measuredFooterHeight);
497
+ }
498
+ }
499
+ const headerExpansion = headerHeight - dims.marginTop;
500
+ const footerExpansion = footerHeight - dims.marginBottom;
501
+ const contentHeight = dims.contentHeight - headerExpansion - footerExpansion;
502
+ return { headerHeight, footerHeight, contentHeight };
503
+ }
504
+ /**
505
+ * Measures the content height of a header or footer element.
506
+ * This is needed because headers/footers can contain more content than fits in the margin area.
507
+ */
508
+ measureHeaderFooterHeight(source, contentWidth) {
509
+ const measureContainer = document.createElement("div");
510
+ measureContainer.style.position = "absolute";
511
+ measureContainer.style.visibility = "hidden";
512
+ measureContainer.style.width = `${contentWidth}pt`;
513
+ measureContainer.style.left = "-9999px";
514
+ measureContainer.style.paddingBottom = "4pt";
515
+ for (const child of Array.from(source.childNodes)) {
516
+ measureContainer.appendChild(child.cloneNode(true));
517
+ }
518
+ this.stagingElement.appendChild(measureContainer);
519
+ const rect = measureContainer.getBoundingClientRect();
520
+ const heightPt = pxToPt(rect.height);
521
+ this.stagingElement.removeChild(measureContainer);
522
+ return heightPt;
523
+ }
524
+ /**
525
+ * Flows measured blocks into page containers.
526
+ * Implements a single-pass, forward-only algorithm that is compatible with future lazy loading.
527
+ * Supports footnote continuation - long footnotes can split across pages.
528
+ */
529
+ flowToPages(blocks, dims, startPageNumber, sectionIndex) {
530
+ const pages = [];
531
+ let currentContent = [];
532
+ let pageNumber = startPageNumber;
533
+ let pageInSection = 1;
534
+ let { contentHeight: effectiveContentHeight } = this.getEffectiveHeights(
535
+ dims,
536
+ sectionIndex,
537
+ pageInSection,
538
+ pageNumber
539
+ );
540
+ let remainingHeight = effectiveContentHeight;
541
+ let prevMarginBottomPt = 0;
542
+ let currentFootnoteIds = [];
543
+ let currentFootnoteHeight = 0;
544
+ let currentContinuation = this.pendingFootnoteContinuation;
545
+ let nextPageContinuation = null;
546
+ let currentPartialFootnotes = [];
547
+ if (currentContinuation && currentContinuation.remainingElements.length > 0) {
548
+ currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
549
+ }
550
+ const finishPage = () => {
551
+ if (currentContent.length === 0 && !currentContinuation) return;
552
+ const page = this.createPage(
553
+ dims,
554
+ pageNumber,
555
+ sectionIndex,
556
+ currentContent,
557
+ pageInSection,
558
+ currentFootnoteIds,
559
+ currentFootnoteHeight,
560
+ currentContinuation,
561
+ currentPartialFootnotes.length > 0 ? currentPartialFootnotes : void 0
562
+ );
563
+ pages.push(page);
564
+ pageNumber++;
565
+ pageInSection++;
566
+ currentContent = [];
567
+ const newHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
568
+ effectiveContentHeight = newHeights.contentHeight;
569
+ remainingHeight = effectiveContentHeight;
570
+ prevMarginBottomPt = 0;
571
+ currentFootnoteIds = [];
572
+ currentPartialFootnotes = [];
573
+ currentContinuation = nextPageContinuation;
574
+ nextPageContinuation = null;
575
+ if (currentContinuation && currentContinuation.remainingElements.length > 0) {
576
+ currentFootnoteHeight = this.measureContinuationHeight(currentContinuation, dims.contentWidth);
577
+ } else {
578
+ currentFootnoteHeight = 0;
579
+ }
580
+ };
581
+ for (let i = 0; i < blocks.length; i++) {
582
+ const block = blocks[i];
583
+ const nextBlock = blocks[i + 1];
584
+ if (block.isPageBreak) {
585
+ finishPage();
586
+ continue;
587
+ }
588
+ if (block.pageBreakBefore && currentContent.length > 0) {
589
+ finishPage();
590
+ }
591
+ const blockFootnoteIds = this.extractFootnoteRefs(block.element);
592
+ const newFootnoteIds = blockFootnoteIds.filter((id) => !currentFootnoteIds.includes(id));
593
+ let additionalFootnoteHeight = 0;
594
+ if (newFootnoteIds.length > 0 && this.footnoteRegistry.size > 0) {
595
+ const combinedFootnoteIds = [...currentFootnoteIds, ...newFootnoteIds];
596
+ const totalFootnoteHeight = this.measureFootnotesHeight(
597
+ combinedFootnoteIds,
598
+ dims.contentWidth,
599
+ currentContinuation
600
+ );
601
+ additionalFootnoteHeight = totalFootnoteHeight - currentFootnoteHeight;
602
+ }
603
+ const isFirstOnPage = currentContent.length === 0;
604
+ let effectiveMarginTop = block.marginTopPt;
605
+ if (!isFirstOnPage) {
606
+ effectiveMarginTop = Math.max(block.marginTopPt, prevMarginBottomPt) - prevMarginBottomPt;
607
+ }
608
+ const blockSpace = effectiveMarginTop + block.heightPt + additionalFootnoteHeight;
609
+ let neededHeight = blockSpace;
610
+ if (block.keepWithNext && nextBlock && !nextBlock.isPageBreak) {
611
+ const collapsedMargin = Math.max(block.marginBottomPt, nextBlock.marginTopPt);
612
+ neededHeight = effectiveMarginTop + block.heightPt + collapsedMargin + nextBlock.heightPt + additionalFootnoteHeight;
613
+ }
614
+ const effectiveRemainingHeight = remainingHeight - currentFootnoteHeight;
615
+ const bodyContentUsed = effectiveContentHeight - remainingHeight;
616
+ const maxFootnoteArea = effectiveContentHeight * MAX_FOOTNOTE_AREA_RATIO;
617
+ const maxFootnoteExpansion = Math.max(0, maxFootnoteArea - currentFootnoteHeight);
618
+ if (blockSpace <= effectiveRemainingHeight) {
619
+ currentContent.push(block.element.cloneNode(true));
620
+ remainingHeight -= effectiveMarginTop + block.heightPt + block.marginBottomPt;
621
+ prevMarginBottomPt = block.marginBottomPt;
622
+ if (newFootnoteIds.length > 0) {
623
+ currentFootnoteIds.push(...newFootnoteIds);
624
+ currentFootnoteHeight += additionalFootnoteHeight;
625
+ }
626
+ } else if (block.heightPt + block.marginTopPt <= effectiveContentHeight) {
627
+ const blockSpaceWithoutFootnotes = effectiveMarginTop + block.heightPt;
628
+ const minBodySpaceNeeded = bodyContentUsed + blockSpaceWithoutFootnotes + MIN_BODY_CONTENT_HEIGHT;
629
+ const canExpandFootnotes = minBodySpaceNeeded <= effectiveContentHeight;
630
+ if (newFootnoteIds.length > 0 && blockSpaceWithoutFootnotes <= effectiveRemainingHeight) {
631
+ currentContent.push(block.element.cloneNode(true));
632
+ remainingHeight -= effectiveMarginTop + block.heightPt + block.marginBottomPt;
633
+ prevMarginBottomPt = block.marginBottomPt;
634
+ const availableForFootnotes = Math.min(
635
+ maxFootnoteArea,
636
+ effectiveContentHeight - bodyContentUsed - blockSpaceWithoutFootnotes
637
+ );
638
+ for (const footnoteId of newFootnoteIds) {
639
+ const footnote = this.footnoteRegistry.get(footnoteId);
640
+ if (!footnote) continue;
641
+ const footnoteHeight = this.measureSingleFootnoteHeight(footnoteId, dims.contentWidth);
642
+ const spaceLeftForFootnotes = availableForFootnotes - currentFootnoteHeight;
643
+ if (footnoteHeight <= spaceLeftForFootnotes) {
644
+ currentFootnoteIds.push(footnoteId);
645
+ currentFootnoteHeight += footnoteHeight;
646
+ } else {
647
+ if (spaceLeftForFootnotes > 20) {
648
+ const { fits, overflow } = this.splitFootnoteToFit(
649
+ footnote,
650
+ spaceLeftForFootnotes,
651
+ dims.contentWidth
652
+ );
653
+ if (fits.length > 0) {
654
+ currentFootnoteIds.push(footnoteId);
655
+ currentPartialFootnotes.push({
656
+ footnoteId,
657
+ fittingElements: fits
658
+ });
659
+ nextPageContinuation = {
660
+ footnoteId,
661
+ remainingElements: overflow
662
+ };
663
+ currentFootnoteHeight = availableForFootnotes;
664
+ } else {
665
+ nextPageContinuation = {
666
+ footnoteId,
667
+ remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *")).map((el) => el.cloneNode(true))
668
+ };
669
+ if (nextPageContinuation.remainingElements.length === 0) {
670
+ nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
671
+ }
672
+ }
673
+ } else {
674
+ nextPageContinuation = {
675
+ footnoteId,
676
+ remainingElements: Array.from(footnote.querySelectorAll(".footnote-content > *")).map((el) => el.cloneNode(true))
677
+ };
678
+ if (nextPageContinuation.remainingElements.length === 0) {
679
+ nextPageContinuation.remainingElements = [footnote.cloneNode(true)];
680
+ }
681
+ }
682
+ }
683
+ }
684
+ } else if (canExpandFootnotes && newFootnoteIds.length > 0) {
685
+ const expandedFootnoteSpace = Math.min(maxFootnoteArea, additionalFootnoteHeight + currentFootnoteHeight);
686
+ const bodySpaceAfterExpansion = effectiveContentHeight - expandedFootnoteSpace;
687
+ if (blockSpaceWithoutFootnotes <= bodySpaceAfterExpansion - bodyContentUsed) {
688
+ currentContent.push(block.element.cloneNode(true));
689
+ remainingHeight = bodySpaceAfterExpansion - bodyContentUsed - blockSpaceWithoutFootnotes;
690
+ prevMarginBottomPt = block.marginBottomPt;
691
+ currentFootnoteIds.push(...newFootnoteIds);
692
+ currentFootnoteHeight = expandedFootnoteSpace;
693
+ } else {
694
+ finishPage();
695
+ const newPageFootnoteHeight = blockFootnoteIds.length > 0 ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
696
+ const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
697
+ currentContent.push(block.element.cloneNode(true));
698
+ remainingHeight = effectiveContentHeight - newPageSpace;
699
+ prevMarginBottomPt = block.marginBottomPt;
700
+ currentFootnoteIds = [...blockFootnoteIds];
701
+ currentFootnoteHeight = newPageFootnoteHeight;
702
+ }
703
+ } else {
704
+ finishPage();
705
+ const newPageFootnoteHeight = blockFootnoteIds.length > 0 ? this.measureFootnotesHeight(blockFootnoteIds, dims.contentWidth, currentContinuation) : currentContinuation ? this.measureContinuationHeight(currentContinuation, dims.contentWidth) : 0;
706
+ const newPageSpace = block.marginTopPt + block.heightPt + block.marginBottomPt;
707
+ currentContent.push(block.element.cloneNode(true));
708
+ remainingHeight = effectiveContentHeight - newPageSpace;
709
+ prevMarginBottomPt = block.marginBottomPt;
710
+ currentFootnoteIds = [...blockFootnoteIds];
711
+ currentFootnoteHeight = newPageFootnoteHeight;
712
+ }
713
+ } else {
714
+ if (currentContent.length > 0) {
715
+ finishPage();
716
+ }
717
+ currentContent.push(block.element.cloneNode(true));
718
+ currentFootnoteIds = [...blockFootnoteIds];
719
+ finishPage();
720
+ }
721
+ }
722
+ finishPage();
723
+ this.pendingFootnoteContinuation = nextPageContinuation;
724
+ return pages;
725
+ }
726
+ /**
727
+ * Creates a page container element.
728
+ */
729
+ createPage(dims, pageNumber, sectionIndex, content, pageInSection, footnoteIds = [], footnoteHeight = 0, continuation, partialFootnotes) {
730
+ const pageBox = document.createElement("div");
731
+ pageBox.className = `${this.cssPrefix}box`;
732
+ pageBox.style.width = `${dims.pageWidth}pt`;
733
+ pageBox.style.height = `${dims.pageHeight}pt`;
734
+ pageBox.style.overflow = "hidden";
735
+ pageBox.style.position = "relative";
736
+ if (this.scale !== 1) {
737
+ pageBox.style.zoom = String(this.scale);
738
+ pageBox.style.transform = `scale(${this.scale})`;
739
+ pageBox.style.transformOrigin = "top left";
740
+ const heightReductionPt = dims.pageHeight * (1 - this.scale);
741
+ const widthReductionPt = dims.pageWidth * (1 - this.scale);
742
+ const heightReductionPx = ptToPx(heightReductionPt);
743
+ const widthReductionPx = ptToPx(widthReductionPt);
744
+ pageBox.style.marginRight = `-${widthReductionPx}px`;
745
+ pageBox.style.marginBottom = `${this.pageGap - heightReductionPx}px`;
746
+ }
747
+ pageBox.style.willChange = "transform";
748
+ pageBox.style.contain = "layout paint";
749
+ pageBox.dataset.pageNumber = String(pageNumber);
750
+ pageBox.dataset.sectionIndex = String(sectionIndex);
751
+ const effectiveHeights = this.getEffectiveHeights(dims, sectionIndex, pageInSection, pageNumber);
752
+ const headerSource = this.selectHeader(sectionIndex, pageInSection, pageNumber);
753
+ if (headerSource) {
754
+ const headerDiv = document.createElement("div");
755
+ headerDiv.className = `${this.cssPrefix}header`;
756
+ headerDiv.style.position = "absolute";
757
+ headerDiv.style.top = "0";
758
+ headerDiv.style.left = `${dims.marginLeft}pt`;
759
+ headerDiv.style.width = `${dims.contentWidth}pt`;
760
+ headerDiv.style.height = `${effectiveHeights.headerHeight}pt`;
761
+ headerDiv.style.overflow = "hidden";
762
+ headerDiv.style.boxSizing = "border-box";
763
+ headerDiv.style.display = "flex";
764
+ headerDiv.style.flexDirection = "column";
765
+ headerDiv.style.justifyContent = "flex-end";
766
+ headerDiv.style.paddingBottom = "4pt";
767
+ for (const child of Array.from(headerSource.childNodes)) {
768
+ headerDiv.appendChild(child.cloneNode(true));
769
+ }
770
+ pageBox.appendChild(headerDiv);
771
+ }
772
+ const contentAreaTop = effectiveHeights.headerHeight;
773
+ const contentAreaHeight = effectiveHeights.contentHeight;
774
+ const contentArea = document.createElement("div");
775
+ contentArea.className = `${this.cssPrefix}content`;
776
+ contentArea.style.position = "absolute";
777
+ contentArea.style.top = `${contentAreaTop}pt`;
778
+ contentArea.style.left = `${dims.marginLeft}pt`;
779
+ contentArea.style.width = `${dims.contentWidth}pt`;
780
+ contentArea.style.height = `${contentAreaHeight}pt`;
781
+ contentArea.style.overflow = "hidden";
782
+ for (const el of content) {
783
+ contentArea.appendChild(el);
784
+ }
785
+ pageBox.appendChild(contentArea);
786
+ const hasContinuation = continuation && continuation.remainingElements.length > 0;
787
+ if (footnoteIds.length > 0 || hasContinuation) {
788
+ this.addPageFootnotes(pageBox, footnoteIds, dims, footnoteHeight, continuation, partialFootnotes);
789
+ }
790
+ const footerSource = this.selectFooter(sectionIndex, pageInSection, pageNumber);
791
+ if (footerSource) {
792
+ const footerDiv = document.createElement("div");
793
+ footerDiv.className = `${this.cssPrefix}footer`;
794
+ footerDiv.style.position = "absolute";
795
+ footerDiv.style.bottom = "0";
796
+ footerDiv.style.left = `${dims.marginLeft}pt`;
797
+ footerDiv.style.width = `${dims.contentWidth}pt`;
798
+ footerDiv.style.height = `${effectiveHeights.footerHeight}pt`;
799
+ footerDiv.style.overflow = "hidden";
800
+ footerDiv.style.boxSizing = "border-box";
801
+ footerDiv.style.display = "flex";
802
+ footerDiv.style.flexDirection = "column";
803
+ footerDiv.style.justifyContent = "flex-start";
804
+ footerDiv.style.paddingTop = "4pt";
805
+ for (const child of Array.from(footerSource.childNodes)) {
806
+ footerDiv.appendChild(child.cloneNode(true));
807
+ }
808
+ pageBox.appendChild(footerDiv);
809
+ }
810
+ if (this.showPageNumbers) {
811
+ const pageNum = document.createElement("div");
812
+ pageNum.className = `${this.cssPrefix}number`;
813
+ pageNum.textContent = String(pageNumber);
814
+ pageBox.appendChild(pageNum);
815
+ }
816
+ this.containerElement.appendChild(pageBox);
817
+ return {
818
+ pageNumber,
819
+ sectionIndex,
820
+ dimensions: dims,
821
+ element: pageBox
822
+ };
823
+ }
824
+ };
825
+ function paginateHtml(html, container, options = {}) {
826
+ const containerEl = typeof container === "string" ? document.getElementById(container) : container;
827
+ if (!containerEl) {
828
+ throw new Error("Container element not found");
829
+ }
830
+ containerEl.innerHTML = html;
831
+ const cssPrefix = options.cssPrefix ?? "page-";
832
+ const staging = containerEl.querySelector("#pagination-staging") || containerEl.querySelector(`.${cssPrefix}staging`);
833
+ const pageContainer = containerEl.querySelector("#pagination-container") || containerEl.querySelector(`.${cssPrefix}container`);
834
+ if (!staging) {
835
+ throw new Error(
836
+ "Pagination staging element not found. Make sure the HTML was generated with PaginationMode.Paginated"
837
+ );
838
+ }
839
+ if (!pageContainer) {
840
+ throw new Error("Pagination container element not found");
841
+ }
842
+ const engine = new PaginationEngine(staging, pageContainer, options);
843
+ return engine.paginate();
844
+ }
845
+
846
+ // src/editor.ts
847
+ var EDITABLE_TAGS = /* @__PURE__ */ new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
848
+ function fontWeightIsBold(w) {
849
+ if (w === "bold" || w === "bolder") return true;
850
+ const n = parseInt(w, 10);
851
+ return !Number.isNaN(n) && n >= 600;
852
+ }
853
+ function escapeInlineMarkdown(text) {
854
+ return text.replace(/([\\`*_[\]])/g, "\\$1");
855
+ }
856
+ function collectInlineSegments(node, out) {
857
+ node.childNodes.forEach((child) => {
858
+ if (child.nodeType === 1 && child.hasAttribute?.("data-list-marker")) return;
859
+ if (child.nodeType === 3) {
860
+ const text = child.textContent ?? "";
861
+ if (!text) return;
862
+ const parent = child.parentElement;
863
+ let bold = false;
864
+ let italic = false;
865
+ let href = null;
866
+ if (parent && typeof getComputedStyle === "function") {
867
+ const cs = getComputedStyle(parent);
868
+ bold = fontWeightIsBold(cs.fontWeight);
869
+ italic = cs.fontStyle === "italic" || cs.fontStyle === "oblique";
870
+ const a = parent.closest("a");
871
+ href = a ? a.getAttribute("href") : null;
872
+ }
873
+ out.push({ text, bold, italic, href });
874
+ } else if (child.nodeType === 1) {
875
+ const el = child;
876
+ if (el.tagName === "BR") {
877
+ out.push({ text: "\n", bold: false, italic: false, href: null });
878
+ return;
879
+ }
880
+ collectInlineSegments(el, out);
881
+ }
882
+ });
883
+ }
884
+ function segToMarkdown(seg) {
885
+ if (seg.text === "\n") return " \n";
886
+ let md = escapeInlineMarkdown(seg.text).replace(/[ \t]*\n/g, " \n");
887
+ if (/\S/.test(seg.text)) {
888
+ if (seg.bold && seg.italic) md = `***${md}***`;
889
+ else if (seg.bold) md = `**${md}**`;
890
+ else if (seg.italic) md = `*${md}*`;
891
+ }
892
+ if (seg.href) md = `[${md}](${seg.href})`;
893
+ return md;
894
+ }
895
+ function serializeInlineMarkdown(block) {
896
+ const segs = [];
897
+ collectInlineSegments(block, segs);
898
+ const merged = [];
899
+ for (const s of segs) {
900
+ const prev = merged[merged.length - 1];
901
+ if (prev && prev.text !== "\n" && s.text !== "\n" && prev.bold === s.bold && prev.italic === s.italic && prev.href === s.href) {
902
+ prev.text += s.text;
903
+ } else {
904
+ merged.push({ ...s });
905
+ }
906
+ }
907
+ return merged.map(segToMarkdown).join("").trim();
908
+ }
909
+ function isListBlock(block) {
910
+ return !!block.querySelector(":scope > [data-list-marker]");
911
+ }
912
+ function isInMarker(node) {
913
+ let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
914
+ while (el) {
915
+ if (el.hasAttribute && el.hasAttribute("data-list-marker")) return true;
916
+ el = el.parentElement;
917
+ }
918
+ return false;
919
+ }
920
+ var BIDI_MARK_CLASS = "\u200E\u200F\u061C\u202A-\u202E\u2066-\u2069";
921
+ var BIDI_MARKS_RE_G = new RegExp(`[${BIDI_MARK_CLASS}]`, "g");
922
+ var BIDI_MARK_RE = new RegExp(`[${BIDI_MARK_CLASS}]`);
923
+ function stripBidi(s) {
924
+ return s.replace(BIDI_MARKS_RE_G, "");
925
+ }
926
+ function domOffsetForContentOffset(s, n) {
927
+ let content = 0;
928
+ for (let i = 0; i < s.length; i++) {
929
+ if (content >= n) return i;
930
+ if (!BIDI_MARK_RE.test(s[i])) content++;
931
+ }
932
+ return s.length;
933
+ }
934
+ function contentOffsetOf(block, container, offset) {
935
+ let count = 0;
936
+ let done = false;
937
+ const walk = (node) => {
938
+ if (done) return;
939
+ if (node.nodeType === 3) {
940
+ if (node === container) {
941
+ if (!isInMarker(node)) count += stripBidi((node.textContent ?? "").slice(0, offset)).length;
942
+ done = true;
943
+ return;
944
+ }
945
+ if (!isInMarker(node)) count += stripBidi(node.textContent ?? "").length;
946
+ } else {
947
+ if (node === container) {
948
+ const kids = Array.from(node.childNodes);
949
+ for (let i = 0; i < offset && i < kids.length; i++) walk(kids[i]);
950
+ done = true;
951
+ return;
952
+ }
953
+ node.childNodes.forEach(walk);
954
+ }
955
+ };
956
+ walk(block);
957
+ return count;
958
+ }
959
+ function caretOffsetIn(block) {
960
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
961
+ if (!sel || sel.rangeCount === 0) return null;
962
+ const range = sel.getRangeAt(0);
963
+ if (!block.contains(range.startContainer)) return null;
964
+ return contentOffsetOf(block, range.startContainer, range.startOffset);
965
+ }
966
+ function blockContentText(block) {
967
+ let out = "";
968
+ const walk = (node) => {
969
+ if (node.nodeType === 3) {
970
+ if (!isInMarker(node)) out += stripBidi(node.textContent ?? "");
971
+ } else {
972
+ node.childNodes.forEach(walk);
973
+ }
974
+ };
975
+ walk(block);
976
+ return out;
977
+ }
978
+ function trimmedSplitOffset(block, domOffset) {
979
+ const content = blockContentText(block);
980
+ const leading = content.length - content.replace(/^\s+/, "").length;
981
+ const trimmedLen = content.trim().length;
982
+ return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
983
+ }
984
+ function contentPositionIn(el, offset) {
985
+ let remaining = offset;
986
+ let result = null;
987
+ let lastText = null;
988
+ const walk = (node) => {
989
+ if (result) return;
990
+ if (node.nodeType === 3) {
991
+ if (isInMarker(node)) return;
992
+ const raw = node.textContent ?? "";
993
+ const len = stripBidi(raw).length;
994
+ lastText = node;
995
+ if (remaining <= len) {
996
+ result = { node, offset: domOffsetForContentOffset(raw, remaining) };
997
+ return;
998
+ }
999
+ remaining -= len;
1000
+ } else {
1001
+ node.childNodes.forEach(walk);
1002
+ }
1003
+ };
1004
+ walk(el);
1005
+ if (result) return result;
1006
+ if (lastText) return { node: lastText, offset: (lastText.textContent ?? "").length };
1007
+ return { node: el, offset: el.childNodes.length };
1008
+ }
1009
+ function placeCaretAtOffset(el, offset) {
1010
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1011
+ if (!sel) return;
1012
+ el.focus();
1013
+ const range = document.createRange();
1014
+ let remaining = offset;
1015
+ let placed = false;
1016
+ const walk = (node) => {
1017
+ if (placed) return;
1018
+ if (node.nodeType === 3) {
1019
+ if (isInMarker(node)) return;
1020
+ const raw = node.textContent ?? "";
1021
+ const len = stripBidi(raw).length;
1022
+ if (remaining <= len) {
1023
+ range.setStart(node, domOffsetForContentOffset(raw, remaining));
1024
+ placed = true;
1025
+ } else {
1026
+ remaining -= len;
1027
+ }
1028
+ } else {
1029
+ node.childNodes.forEach(walk);
1030
+ }
1031
+ };
1032
+ walk(el);
1033
+ if (!placed) {
1034
+ range.selectNodeContents(el);
1035
+ range.collapse(false);
1036
+ } else {
1037
+ range.collapse(true);
1038
+ }
1039
+ sel.removeAllRanges();
1040
+ sel.addRange(range);
1041
+ }
1042
+ function selectionSpanIn(block) {
1043
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1044
+ if (!sel || sel.rangeCount === 0) return null;
1045
+ const range = sel.getRangeAt(0);
1046
+ if (range.collapsed) return null;
1047
+ if (!block.contains(range.startContainer) || !block.contains(range.endContainer)) return null;
1048
+ const start = contentOffsetOf(block, range.startContainer, range.startOffset);
1049
+ const end = contentOffsetOf(block, range.endContainer, range.endOffset);
1050
+ return { start: Math.min(start, end), length: Math.abs(end - start) };
1051
+ }
1052
+ function selectRange(el, start, length) {
1053
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1054
+ if (!sel || !el.isConnected) return;
1055
+ el.focus();
1056
+ const range = document.createRange();
1057
+ const end = start + length;
1058
+ let pos = 0;
1059
+ let startSet = false;
1060
+ const walk = (node) => {
1061
+ for (const child of Array.from(node.childNodes)) {
1062
+ if (child.nodeType === 3) {
1063
+ if (isInMarker(child)) continue;
1064
+ const raw = child.textContent ?? "";
1065
+ const len = stripBidi(raw).length;
1066
+ if (!startSet && pos + len >= start) {
1067
+ range.setStart(child, domOffsetForContentOffset(raw, start - pos));
1068
+ startSet = true;
1069
+ }
1070
+ if (startSet && pos + len >= end) {
1071
+ range.setEnd(child, domOffsetForContentOffset(raw, end - pos));
1072
+ return true;
1073
+ }
1074
+ pos += len;
1075
+ } else if (walk(child)) {
1076
+ return true;
1077
+ }
1078
+ }
1079
+ return false;
1080
+ };
1081
+ if (walk(el) || startSet) {
1082
+ sel.removeAllRanges();
1083
+ sel.addRange(range);
1084
+ }
1085
+ }
1086
+ function inBorderWrapper(el) {
1087
+ const style = el.parentElement?.getAttribute("style") ?? "";
1088
+ return /border-(top|bottom|left|right):\s*(?!none)[^;]+/i.test(style);
1089
+ }
1090
+ function selectionHasFormat(key, fallback) {
1091
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1092
+ let el = fallback;
1093
+ if (sel && sel.rangeCount > 0) {
1094
+ const n = sel.getRangeAt(0).startContainer;
1095
+ el = n.nodeType === 3 ? n.parentElement : n;
1096
+ }
1097
+ if (!el || typeof getComputedStyle !== "function") return false;
1098
+ const cs = getComputedStyle(el);
1099
+ switch (key) {
1100
+ case "bold":
1101
+ return fontWeightIsBold(cs.fontWeight);
1102
+ case "italic":
1103
+ return cs.fontStyle === "italic" || cs.fontStyle === "oblique";
1104
+ case "underline":
1105
+ return cs.textDecorationLine.includes("underline");
1106
+ case "strike":
1107
+ return cs.textDecorationLine.includes("line-through");
1108
+ case "code":
1109
+ return /mono|courier|consolas/i.test(cs.fontFamily);
1110
+ case "superscript":
1111
+ return cs.verticalAlign === "super" || !!el.closest("sup");
1112
+ case "subscript":
1113
+ return cs.verticalAlign === "sub" || !!el.closest("sub");
1114
+ default:
1115
+ return false;
1116
+ }
1117
+ }
1118
+ function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
1119
+ return [
1120
+ bytes,
1121
+ "Document",
1122
+ cssPrefix,
1123
+ fabricate,
1124
+ "",
1125
+ -1,
1126
+ "comment-",
1127
+ /* paginationMode */
1128
+ paginated ? 1 : 0,
1129
+ /* paginationScale */
1130
+ scale,
1131
+ "page-",
1132
+ false,
1133
+ 0,
1134
+ "annot-",
1135
+ /* renderFootnotesAndEndnotes */
1136
+ false,
1137
+ /* renderHeadersAndFooters */
1138
+ paginated,
1139
+ false,
1140
+ true,
1141
+ true,
1142
+ false,
1143
+ null,
1144
+ /* stampAnchors */
1145
+ true
1146
+ ];
1147
+ }
1148
+ var DocxEditor = class _DocxEditor {
1149
+ constructor(container, exports, handle, options) {
1150
+ /** Map a block's current bare unid → its full kind:scope:unid (DocxSession anchor). */
1151
+ this.unidToFullId = /* @__PURE__ */ new Map();
1152
+ /** The most recently focused editable block — the target for ribbon/format commands. */
1153
+ this.activeBlock = null;
1154
+ this.closed = false;
1155
+ /**
1156
+ * Re-entrancy guard for node replacement. Replacing a contenteditable block that still holds
1157
+ * focus removes the focused node, which fires a SYNCHRONOUS `blur` → re-enters commitBlock; the
1158
+ * interleaved second replaceWith then throws NotFoundError ("node ... no longer a child") and the
1159
+ * structural edit (split/merge/format) is lost. While this flag is set, commitBlock no-ops.
1160
+ */
1161
+ this.replacing = false;
1162
+ /**
1163
+ * The last real (non-collapsed) text selection inside an editable block. A toolbar control that
1164
+ * must take focus to be used — the font-size combobox — blurs the block and collapses the live
1165
+ * selection, so without this an operation triggered from such a control could only target the
1166
+ * whole paragraph (S-1 smoke-test finding 3). Refreshed whenever a non-empty selection sits in a
1167
+ * block, and cleared when a caret is collapsed inside a block (so it never goes stale).
1168
+ */
1169
+ this.lastSelection = null;
1170
+ /** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
1171
+ this.onSelectionChange = () => {
1172
+ if (this.closed) return;
1173
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1174
+ if (!sel || sel.rangeCount === 0) return;
1175
+ const range = sel.getRangeAt(0);
1176
+ const block = this.editableBlockOf(range.commonAncestorContainer);
1177
+ if (!block) return;
1178
+ const unid = block.getAttribute("data-anchor");
1179
+ if (!unid) return;
1180
+ if (range.collapsed) {
1181
+ this.lastSelection = null;
1182
+ return;
1183
+ }
1184
+ const span = selectionSpanIn(block);
1185
+ if (span) this.lastSelection = { unid, span };
1186
+ };
1187
+ this.container = container;
1188
+ this.exports = exports;
1189
+ this.handle = handle;
1190
+ this.options = options;
1191
+ this.editRoot = container;
1192
+ if (typeof document !== "undefined")
1193
+ document.addEventListener("selectionchange", this.onSelectionChange);
1194
+ }
1195
+ /** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor. */
1196
+ editableBlockOf(node) {
1197
+ if (!node) return null;
1198
+ const start = node.nodeType === 1 ? node : node.parentElement;
1199
+ const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
1200
+ return block && this.editRoot.contains(block) ? block : null;
1201
+ }
1202
+ /** Open a document, render it into `container`, and wire up editing. */
1203
+ static open(container, bytes, exports, options = {}) {
1204
+ const opts = {
1205
+ cssPrefix: options.cssPrefix ?? "docx-",
1206
+ fabricateClasses: options.fabricateClasses ?? false,
1207
+ editable: options.editable ?? true,
1208
+ paginated: options.paginated ?? false,
1209
+ scale: options.scale ?? 1,
1210
+ onEdit: options.onEdit
1211
+ };
1212
+ const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
1213
+ const editor = new _DocxEditor(container, exports, handle, opts);
1214
+ editor.refreshAnchorMap();
1215
+ const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(
1216
+ ...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale)
1217
+ );
1218
+ if (opts.paginated) editor.mountPaginated(fullHtml);
1219
+ else editor.mountHtml(fullHtml);
1220
+ return editor;
1221
+ }
1222
+ /**
1223
+ * Open a fresh, blank document (a "New document" — single empty paragraph, Normal style,
1224
+ * US-Letter section) and wire up editing. The seed bytes come from the WASM bridge so the
1225
+ * result opens cleanly in Word too.
1226
+ */
1227
+ static openBlank(container, exports, options = {}) {
1228
+ return _DocxEditor.open(container, exports.DocxSessionBridge.CreateBlankDocx(), exports, options);
1229
+ }
1230
+ /** Lossless DOCX bytes reflecting all edits. */
1231
+ save() {
1232
+ this.assertOpen();
1233
+ return this.exports.DocxSessionBridge.Save(this.handle);
1234
+ }
1235
+ /** Release the underlying WASM session. The editor is unusable afterward. */
1236
+ close() {
1237
+ if (this.closed) return;
1238
+ this.closed = true;
1239
+ if (typeof document !== "undefined")
1240
+ document.removeEventListener("selectionchange", this.onSelectionChange);
1241
+ this.exports.DocxSessionBridge.CloseSession(this.handle);
1242
+ }
1243
+ /**
1244
+ * Switch between continuous and paginated rendering WITHOUT losing edits. Re-renders from the
1245
+ * LIVE session, so every committed edit (and the undo/redo history) survives the toggle — unlike
1246
+ * re-opening the original bytes, which silently discards session edits. No-op if already `value`.
1247
+ */
1248
+ setPaginated(value) {
1249
+ this.assertOpen();
1250
+ if (this.options.paginated === value) return;
1251
+ this.options.paginated = value;
1252
+ this.remount();
1253
+ }
1254
+ /** The editor's current DOM (for inspection/tests). */
1255
+ get root() {
1256
+ return this.container;
1257
+ }
1258
+ // ─── internals ───────────────────────────────────────────────────────
1259
+ assertOpen() {
1260
+ if (this.closed) throw new Error("DocxEditor is closed");
1261
+ }
1262
+ /** Rebuild unid → full-anchor-id from the live session projection. */
1263
+ refreshAnchorMap() {
1264
+ const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
1265
+ this.unidToFullId.clear();
1266
+ for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
1267
+ this.unidToFullId.set(target.unid, fullId);
1268
+ }
1269
+ }
1270
+ /** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
1271
+ mountHtml(fullHtml) {
1272
+ const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
1273
+ const styles = Array.from(parsed.querySelectorAll("style")).map((s) => s.outerHTML).join("");
1274
+ this.container.innerHTML = styles + parsed.body.innerHTML;
1275
+ this.editRoot = this.container;
1276
+ if (this.options.editable) this.wireBlocks(this.container);
1277
+ }
1278
+ /** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
1279
+ mountPaginated(fullHtml) {
1280
+ paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
1281
+ this.container.querySelector("#pagination-staging, .page-staging")?.remove();
1282
+ const pageRoot = this.container.querySelector("#pagination-container") ?? this.container;
1283
+ this.editRoot = pageRoot;
1284
+ if (this.options.editable) this.wireBlocks(pageRoot);
1285
+ }
1286
+ wireBlocks(root) {
1287
+ root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
1288
+ }
1289
+ wireBlock(el) {
1290
+ if (!EDITABLE_TAGS.has(el.tagName)) return;
1291
+ const unid = el.getAttribute("data-anchor");
1292
+ if (!unid || !this.unidToFullId.has(unid)) return;
1293
+ el.setAttribute("contenteditable", "true");
1294
+ el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
1295
+ el.dataset.committedText = blockContentText(el);
1296
+ el.addEventListener("focus", () => {
1297
+ this.activeBlock = el;
1298
+ });
1299
+ el.addEventListener("blur", () => this.commitBlock(el));
1300
+ el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
1301
+ }
1302
+ /**
1303
+ * Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
1304
+ * block fires (see `replacing`), AND tolerating the case where a synchronous blur during focus
1305
+ * transfer detaches `oldEl` between the caller's checks and here. `replaceWith` then throws
1306
+ * NotFoundError ("node … no longer a child … moved in a blur event handler") — `isConnected`
1307
+ * alone doesn't catch this race. The session is already updated, so a skipped/failed visual swap
1308
+ * leaves correct content (the typed DOM); the next commit or remount reconciles it. This is why
1309
+ * the catch is silent rather than rethrowing — there's no lost data, only a deferred re-render.
1310
+ * Returns true if the swap happened.
1311
+ */
1312
+ replaceNode(oldEl, ...newNodes) {
1313
+ const prev = this.replacing;
1314
+ this.replacing = true;
1315
+ try {
1316
+ if (!oldEl.parentNode) return false;
1317
+ oldEl.replaceWith(...newNodes);
1318
+ return true;
1319
+ } catch {
1320
+ return false;
1321
+ } finally {
1322
+ this.replacing = prev;
1323
+ }
1324
+ }
1325
+ /** Commit a block edit on blur: diff → run-preserving session op → re-render only this block. */
1326
+ commitBlock(el) {
1327
+ if (this.closed || this.replacing) return;
1328
+ const unid = el.getAttribute("data-anchor");
1329
+ if (!unid) return;
1330
+ const fullId = this.unidToFullId.get(unid);
1331
+ if (!fullId) return;
1332
+ const result = this.commitTextChange(el, fullId);
1333
+ if (!result) return;
1334
+ if (!result.success) {
1335
+ const fresh = this.renderInto(fullId);
1336
+ if (fresh && this.replaceNode(el, fresh)) {
1337
+ this.wireBlock(fresh);
1338
+ if (this.activeBlock === el) this.activeBlock = fresh;
1339
+ }
1340
+ return;
1341
+ }
1342
+ const newAnchor = result.modified?.[0]?.id ?? fullId;
1343
+ const newUnid = result.modified?.[0]?.unid ?? unid;
1344
+ if (el.querySelector(":scope > [data-list-marker]")) {
1345
+ el.dataset.committedText = blockContentText(el);
1346
+ this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
1347
+ return;
1348
+ }
1349
+ const html = this.exports.DocxSessionBridge.RenderBlockHtml(
1350
+ this.handle,
1351
+ newAnchor,
1352
+ this.options.cssPrefix,
1353
+ this.options.fabricateClasses
1354
+ );
1355
+ if (html.charCodeAt(0) !== 123) {
1356
+ const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1357
+ if (fresh && this.replaceNode(el, fresh)) {
1358
+ this.unidToFullId.delete(unid);
1359
+ this.unidToFullId.set(newUnid, newAnchor);
1360
+ this.wireBlock(fresh);
1361
+ if (this.activeBlock === el) this.activeBlock = fresh;
1362
+ }
1363
+ }
1364
+ this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
1365
+ }
1366
+ // ─── M2: structural editing ──────────────────────────────────────────
1367
+ onKeydown(el, ev) {
1368
+ if (this.closed) return;
1369
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
1370
+ const k = ev.key.toLowerCase();
1371
+ const fmt = { b: "bold", i: "italic", u: "underline" };
1372
+ if (fmt[k]) {
1373
+ ev.preventDefault();
1374
+ this.format(fmt[k]);
1375
+ return;
1376
+ }
1377
+ if (k === "z") {
1378
+ ev.preventDefault();
1379
+ ev.shiftKey ? this.redo() : this.undo();
1380
+ return;
1381
+ }
1382
+ if (k === "y") {
1383
+ ev.preventDefault();
1384
+ this.redo();
1385
+ return;
1386
+ }
1387
+ }
1388
+ const inTableCell = !!el.closest("table");
1389
+ if (ev.key === "Tab") {
1390
+ if (inTableCell) {
1391
+ ev.preventDefault();
1392
+ return;
1393
+ }
1394
+ if (isListBlock(el)) {
1395
+ ev.preventDefault();
1396
+ this.activeBlock = el;
1397
+ this.setListLevel(ev.shiftKey ? -1 : 1);
1398
+ return;
1399
+ }
1400
+ }
1401
+ if (ev.key === "Enter" && ev.shiftKey && !ev.isComposing) {
1402
+ ev.preventDefault();
1403
+ this.insertLineBreakAtCaret();
1404
+ return;
1405
+ }
1406
+ if (ev.key === "Enter" && !ev.shiftKey && !ev.isComposing) {
1407
+ ev.preventDefault();
1408
+ this.splitAtCaret(el);
1409
+ } else if (ev.key === "Backspace") {
1410
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1411
+ if (sel && sel.isCollapsed && caretOffsetIn(el) === 0 && !inTableCell) {
1412
+ const prev = this.previousEditable(el);
1413
+ if (prev) {
1414
+ ev.preventDefault();
1415
+ this.mergeWithPrevious(prev, el);
1416
+ }
1417
+ }
1418
+ }
1419
+ }
1420
+ /** Shift+Enter: insert an intra-paragraph line break at the caret. Delegates to the
1421
+ * native `insertLineBreak` command, which inserts a <br> AND positions the caret
1422
+ * after it correctly (handling the browser's bogus trailing-<br> rule) so typing
1423
+ * continues on the new line. Commits (on blur) as a w:br via the " \n" hard break
1424
+ * the serializer emits for a <br>. */
1425
+ insertLineBreakAtCaret() {
1426
+ if (typeof document !== "undefined" && typeof document.execCommand === "function") {
1427
+ document.execCommand("insertLineBreak");
1428
+ }
1429
+ }
1430
+ /** Enter: split the block at the caret into two paragraphs. */
1431
+ splitAtCaret(el) {
1432
+ const rawOffset = caretOffsetIn(el);
1433
+ const unid = el.getAttribute("data-anchor");
1434
+ if (rawOffset == null || !unid) return;
1435
+ let fullId = this.unidToFullId.get(unid);
1436
+ if (!fullId) return;
1437
+ const offset = trimmedSplitOffset(el, rawOffset);
1438
+ const idx = this.blockIndex(el);
1439
+ const wrappedInBorder = inBorderWrapper(el);
1440
+ fullId = this.syncBlock(el, fullId);
1441
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SplitParagraph(this.handle, fullId, offset));
1442
+ if (!res.success) return;
1443
+ const first = res.modified?.[0];
1444
+ const second = res.created?.[0];
1445
+ if (!first || !second) return;
1446
+ if (this.affectsList(res) || wrappedInBorder) {
1447
+ this.remount(idx + 1, false);
1448
+ this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
1449
+ return;
1450
+ }
1451
+ const firstEl = this.renderInto(first.id);
1452
+ const secondEl = this.renderInto(second.id);
1453
+ if (!firstEl || !secondEl) return;
1454
+ if (!this.replaceNode(el, firstEl, secondEl)) return;
1455
+ this.unidToFullId.delete(unid);
1456
+ this.unidToFullId.set(first.unid, first.id);
1457
+ this.unidToFullId.set(second.unid, second.id);
1458
+ this.wireBlock(firstEl);
1459
+ this.wireBlock(secondEl);
1460
+ placeCaretAtOffset(secondEl, 0);
1461
+ this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
1462
+ }
1463
+ /** Backspace at block start: merge this block into the previous one. */
1464
+ mergeWithPrevious(prev, el) {
1465
+ const prevUnid = prev.getAttribute("data-anchor");
1466
+ const thisUnid = el.getAttribute("data-anchor");
1467
+ if (!prevUnid || !thisUnid) return;
1468
+ let prevId = this.unidToFullId.get(prevUnid);
1469
+ let thisId = this.unidToFullId.get(thisUnid);
1470
+ if (!prevId || !thisId) return;
1471
+ const prevIdx = this.blockIndex(prev);
1472
+ const wrappedInBorder = inBorderWrapper(prev) || inBorderWrapper(el);
1473
+ prevId = this.syncBlock(prev, prevId);
1474
+ thisId = this.syncBlock(el, thisId);
1475
+ const caret = (prev.textContent ?? "").length;
1476
+ const res = this.parseEdit(this.exports.DocxSessionBridge.MergeParagraphs(this.handle, prevId, thisId));
1477
+ if (!res.success) return;
1478
+ const merged = res.modified?.[0];
1479
+ if (!merged) return;
1480
+ if (this.affectsList(res) || wrappedInBorder) {
1481
+ this.remount(prevIdx, true);
1482
+ this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
1483
+ return;
1484
+ }
1485
+ const mergedEl = this.renderInto(merged.id);
1486
+ if (!mergedEl) return;
1487
+ if (!this.replaceNode(prev, mergedEl)) return;
1488
+ el.remove();
1489
+ this.unidToFullId.delete(prevUnid);
1490
+ this.unidToFullId.delete(thisUnid);
1491
+ this.unidToFullId.set(merged.unid, merged.id);
1492
+ this.wireBlock(mergedEl);
1493
+ placeCaretAtOffset(mergedEl, caret);
1494
+ this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
1495
+ }
1496
+ /**
1497
+ * Apply the block's pending text change to the session with full inline-formatting fidelity.
1498
+ * Diffs the committed content text (markers + bidi excluded) against the current content text
1499
+ * and rewrites only the changed span via ReplaceTextAtSpan — every untouched run keeps its exact
1500
+ * rPr, and typed text inherits the boundary run's formatting. Returns the parsed EditResult, or
1501
+ * null when there is no change. Empty/whitespace-only baselines (e.g. the placeholder space the
1502
+ * converter renders for an empty paragraph, whose DOM text doesn't line up with the session's
1503
+ * empty run text) are rebuilt via ReplaceText — there is no inline formatting to preserve there.
1504
+ */
1505
+ commitTextChange(el, fullId) {
1506
+ const old = stripBidi(el.dataset.committedText ?? "");
1507
+ const next = blockContentText(el);
1508
+ if (old === next) return null;
1509
+ if (old.trim().length === 0) {
1510
+ return this.parseEdit(
1511
+ this.exports.DocxSessionBridge.ReplaceText(this.handle, fullId, serializeInlineMarkdown(el))
1512
+ );
1513
+ }
1514
+ const minLen = Math.min(old.length, next.length);
1515
+ let p = 0;
1516
+ while (p < minLen && old[p] === next[p]) p++;
1517
+ let s = 0;
1518
+ while (s < minLen - p && old[old.length - 1 - s] === next[next.length - 1 - s]) s++;
1519
+ let start = p;
1520
+ let len = old.length - p - s;
1521
+ let middle = next.slice(p, next.length - s);
1522
+ if (len === 0) {
1523
+ if (start > 0) {
1524
+ start -= 1;
1525
+ len = 1;
1526
+ middle = old[start] + middle;
1527
+ } else {
1528
+ len = 1;
1529
+ middle = middle + old[0];
1530
+ }
1531
+ }
1532
+ return this.parseEdit(
1533
+ this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, start, len, middle)
1534
+ );
1535
+ }
1536
+ /** Flush a block's current (uncommitted) text to the session; returns the live full id. */
1537
+ syncBlock(el, fullId) {
1538
+ const result = this.commitTextChange(el, fullId);
1539
+ if (!result || !result.success) return fullId;
1540
+ el.dataset.committedText = blockContentText(el);
1541
+ return result.modified?.[0]?.id ?? fullId;
1542
+ }
1543
+ /** Render a block by anchor and parse it into a detached element (null on error). */
1544
+ renderInto(anchorId) {
1545
+ const html = this.exports.DocxSessionBridge.RenderBlockHtml(
1546
+ this.handle,
1547
+ anchorId,
1548
+ this.options.cssPrefix,
1549
+ this.options.fabricateClasses
1550
+ );
1551
+ if (html.charCodeAt(0) === 123) return null;
1552
+ return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
1553
+ }
1554
+ /** The editable block immediately before `el` in document order, or null. */
1555
+ previousEditable(el) {
1556
+ const all = Array.from(
1557
+ this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')
1558
+ );
1559
+ const i = all.indexOf(el);
1560
+ return i > 0 ? all[i - 1] : null;
1561
+ }
1562
+ parseEdit(json) {
1563
+ try {
1564
+ return JSON.parse(json);
1565
+ } catch {
1566
+ return { success: false };
1567
+ }
1568
+ }
1569
+ // ─── M5: formatting commands (ribbon) ────────────────────────────────
1570
+ // ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
1571
+ /** Editable blocks the current selection covers, in document order. Uses Range.comparePoint
1572
+ * (robust to a selection boundary that normalized onto a wrapper element rather than a block
1573
+ * or text node — Range.intersectsNode misses the end block at a `(block, childCount)` boundary).
1574
+ * A collapsed or single-block selection yields just the active block. */
1575
+ selectedBlocks() {
1576
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1577
+ const all = Array.from(
1578
+ this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]')
1579
+ );
1580
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
1581
+ const range = sel.getRangeAt(0);
1582
+ const hit = all.filter((b) => {
1583
+ try {
1584
+ const startsAfterEnd = range.comparePoint(b, 0) > 0;
1585
+ const endsBeforeStart = range.comparePoint(b, b.childNodes.length) < 0;
1586
+ return !startsAfterEnd && !endsBeforeStart;
1587
+ } catch {
1588
+ return false;
1589
+ }
1590
+ });
1591
+ if (hit.length > 1) return hit;
1592
+ }
1593
+ return this.activeBlock ? [this.activeBlock] : [];
1594
+ }
1595
+ /** The selection's span within `block`, clipped to the block (for inline ops across blocks):
1596
+ * the first block runs selection-start→end-of-block, middle blocks are whole, the last block
1597
+ * runs start-of-block→selection-end. Returns null for a whole-block apply. */
1598
+ blockSpanForSelection(block) {
1599
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1600
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
1601
+ const range = sel.getRangeAt(0);
1602
+ const hasStart = block.contains(range.startContainer);
1603
+ const hasEnd = block.contains(range.endContainer);
1604
+ if (hasStart && hasEnd) return selectionSpanIn(block);
1605
+ const contentLen = blockContentText(block).length;
1606
+ if (hasStart) {
1607
+ const start = contentOffsetOf(block, range.startContainer, range.startOffset);
1608
+ return { start, length: Math.max(0, contentLen - start) };
1609
+ }
1610
+ if (hasEnd) {
1611
+ const end = contentOffsetOf(block, range.endContainer, range.endOffset);
1612
+ return { start: 0, length: end };
1613
+ }
1614
+ return { start: 0, length: contentLen };
1615
+ }
1616
+ /** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
1617
+ * the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
1618
+ * document convert, seconds on a large doc, where per-block swaps are ~10 ms each).
1619
+ * Returns false (caller falls back to the single-block path) for a 1-block selection. */
1620
+ applyInlineOpAcrossBlocks(blocks, op) {
1621
+ if (blocks.length <= 1) return false;
1622
+ const targets = this.multiBlockTargets(blocks);
1623
+ for (const t of targets) {
1624
+ if (!t.fullId || t.span && t.span.length === 0) continue;
1625
+ const synced = this.syncBlock(t.block, t.fullId);
1626
+ const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(
1627
+ this.handle,
1628
+ synced,
1629
+ t.span ? JSON.stringify(t.span) : "",
1630
+ JSON.stringify(op)
1631
+ ));
1632
+ if (res.success) t.res = res;
1633
+ }
1634
+ this.finishMultiBlockOp(targets, false);
1635
+ return true;
1636
+ }
1637
+ /** Apply a whole-block (paragraph-level) op to each selected block, then reconcile the DOM
1638
+ * incrementally ({@link finishMultiBlockOp}). `forceRemount` is for ops whose rendering
1639
+ * needs whole-document context (e.g. border-div regrouping after clearBorders). Returns
1640
+ * false for a 1-block selection (caller uses the single-block path). */
1641
+ applyParagraphOpAcrossBlocks(blocks, run, forceRemount = false) {
1642
+ if (blocks.length <= 1) return false;
1643
+ const targets = this.multiBlockTargets(blocks);
1644
+ for (const t of targets) {
1645
+ if (!t.fullId) continue;
1646
+ const synced = this.syncBlock(t.block, t.fullId);
1647
+ const res = this.parseEdit(run(synced));
1648
+ if (res.success) t.res = res;
1649
+ }
1650
+ this.finishMultiBlockOp(targets, forceRemount);
1651
+ return true;
1652
+ }
1653
+ /** Snapshot each selected block's identity + selection slice BEFORE any session op runs
1654
+ * (ops never mutate the DOM, so spans captured here stay valid until the swap phase). */
1655
+ multiBlockTargets(blocks) {
1656
+ return blocks.map((b) => {
1657
+ const unid = b.getAttribute("data-anchor");
1658
+ return {
1659
+ block: b,
1660
+ unid,
1661
+ fullId: unid ? this.unidToFullId.get(unid) : void 0,
1662
+ span: this.blockSpanForSelection(b),
1663
+ res: null
1664
+ };
1665
+ });
1666
+ }
1667
+ /**
1668
+ * Reconcile the DOM after a multi-block op. Fidelity-identical to the single-block path by
1669
+ * construction: each edited block is swapped for its own session-attached single-block
1670
+ * render — exactly what format()/setFontSize()/applyParagraphFormat() do for one block —
1671
+ * so a multi-block apply is N single-block applies, not one whole-document re-render
1672
+ * (which froze the UI for the full-document convert time on every ribbon action). Falls
1673
+ * back to ONE full remount when the op touched a list item (numbering continuation needs
1674
+ * whole-document context) or the caller forced it. Restores the cross-block selection so
1675
+ * consecutive ribbon actions (center, then bold) keep targeting the same range.
1676
+ */
1677
+ finishMultiBlockOp(targets, forceRemount) {
1678
+ const edited = targets.filter((t) => t.res && t.unid);
1679
+ if (edited.length === 0) return;
1680
+ if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
1681
+ this.remount();
1682
+ return;
1683
+ }
1684
+ const swapped = [];
1685
+ for (const t of edited) {
1686
+ const fresh = this.swapBlock(t.block, t.unid, t.res.modified?.[0]);
1687
+ swapped.push({ el: fresh ?? t.block, span: t.span });
1688
+ }
1689
+ const first = swapped[0];
1690
+ const last = swapped[swapped.length - 1];
1691
+ if (!first.el.isConnected || !last.el.isConnected) return;
1692
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
1693
+ if (!sel) return;
1694
+ const startOff = first.span?.start ?? 0;
1695
+ const endOff = last.span ? last.span.start + last.span.length : blockContentText(last.el).length;
1696
+ const startPos = contentPositionIn(first.el, startOff);
1697
+ const endPos = contentPositionIn(last.el, endOff);
1698
+ try {
1699
+ const range = document.createRange();
1700
+ range.setStart(startPos.node, startPos.offset);
1701
+ range.setEnd(endPos.node, endPos.offset);
1702
+ sel.removeAllRanges();
1703
+ sel.addRange(range);
1704
+ } catch {
1705
+ }
1706
+ }
1707
+ /**
1708
+ * Toggle (or set) an inline format on the current selection in the active block.
1709
+ * A selection spanning multiple blocks applies to each. With no selection, applies to
1710
+ * the whole paragraph. Routes through DocxSession (`ApplyFormat`) so it is lossless and
1711
+ * supports underline/strike, not just markdown.
1712
+ */
1713
+ format(key, value) {
1714
+ const block = this.activeBlock;
1715
+ if (this.closed || !block) return;
1716
+ const blocks = this.selectedBlocks();
1717
+ if (blocks.length > 1) {
1718
+ const on0 = value ?? !selectionHasFormat(key, blocks[0]);
1719
+ const multiOp = key === "superscript" || key === "subscript" ? { vertAlign: on0 ? key : "" } : { [key]: on0 };
1720
+ if (this.applyInlineOpAcrossBlocks(blocks, multiOp)) return;
1721
+ }
1722
+ const unid = block.getAttribute("data-anchor");
1723
+ if (!unid) return;
1724
+ let fullId = this.unidToFullId.get(unid);
1725
+ if (!fullId) return;
1726
+ const span = selectionSpanIn(block);
1727
+ const on = value ?? !selectionHasFormat(key, block);
1728
+ const op = key === "superscript" || key === "subscript" ? { vertAlign: on ? key : "" } : { [key]: on };
1729
+ fullId = this.syncBlock(block, fullId);
1730
+ const res = this.parseEdit(
1731
+ this.exports.DocxSessionBridge.ApplyFormat(
1732
+ this.handle,
1733
+ fullId,
1734
+ span ? JSON.stringify(span) : "",
1735
+ JSON.stringify(op)
1736
+ )
1737
+ );
1738
+ if (!res.success) return;
1739
+ if (this.affectsList(res)) {
1740
+ this.remount(this.blockIndex(block), false);
1741
+ return;
1742
+ }
1743
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
1744
+ if (fresh && span) selectRange(fresh, span.start, span.length);
1745
+ else fresh?.focus();
1746
+ }
1747
+ /**
1748
+ * Set the font size (in points) of the current selection in the active block; with no
1749
+ * selection, applies to the whole paragraph. `pts <= 0` clears the explicit size. Routes
1750
+ * through DocxSession `ApplyFormat` (`w:sz`), so it is lossless and survives save.
1751
+ */
1752
+ setFontSize(pts) {
1753
+ const block = this.activeBlock;
1754
+ if (this.closed || !block) return;
1755
+ const blocks = this.selectedBlocks();
1756
+ if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontSizePts: pts })) return;
1757
+ const unid = block.getAttribute("data-anchor");
1758
+ if (!unid) return;
1759
+ let fullId = this.unidToFullId.get(unid);
1760
+ if (!fullId) return;
1761
+ let span = selectionSpanIn(block);
1762
+ if (!span && this.lastSelection && this.lastSelection.unid === unid) span = this.lastSelection.span;
1763
+ fullId = this.syncBlock(block, fullId);
1764
+ const res = this.parseEdit(
1765
+ this.exports.DocxSessionBridge.ApplyFormat(
1766
+ this.handle,
1767
+ fullId,
1768
+ span ? JSON.stringify(span) : "",
1769
+ JSON.stringify({ fontSizePts: pts })
1770
+ )
1771
+ );
1772
+ if (!res.success) return;
1773
+ if (this.affectsList(res)) {
1774
+ this.remount(this.blockIndex(block), false);
1775
+ return;
1776
+ }
1777
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
1778
+ if (fresh && span) selectRange(fresh, span.start, span.length);
1779
+ else fresh?.focus();
1780
+ }
1781
+ /**
1782
+ * Set the font family of the current selection in the active block; with no selection,
1783
+ * applies to the whole paragraph. `""` clears the explicit font (inherits the style/default).
1784
+ * Routes through DocxSession `ApplyFormat` (`w:rFonts`), so it is lossless and survives save.
1785
+ * Multi-block + last-selection plumbing matches {@link setFontSize} (a focus-stealing font
1786
+ * dropdown still applies to the real sub-range).
1787
+ */
1788
+ setFontFamily(name) {
1789
+ const block = this.activeBlock;
1790
+ if (this.closed || !block) return;
1791
+ const blocks = this.selectedBlocks();
1792
+ if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontFamily: name })) return;
1793
+ const unid = block.getAttribute("data-anchor");
1794
+ if (!unid) return;
1795
+ let fullId = this.unidToFullId.get(unid);
1796
+ if (!fullId) return;
1797
+ let span = selectionSpanIn(block);
1798
+ if (!span && this.lastSelection && this.lastSelection.unid === unid) span = this.lastSelection.span;
1799
+ fullId = this.syncBlock(block, fullId);
1800
+ const res = this.parseEdit(
1801
+ this.exports.DocxSessionBridge.ApplyFormat(
1802
+ this.handle,
1803
+ fullId,
1804
+ span ? JSON.stringify(span) : "",
1805
+ JSON.stringify({ fontFamily: name })
1806
+ )
1807
+ );
1808
+ if (!res.success) return;
1809
+ if (this.affectsList(res)) {
1810
+ this.remount(this.blockIndex(block), false);
1811
+ return;
1812
+ }
1813
+ const fresh = this.swapBlock(block, unid, res.modified?.[0]);
1814
+ if (fresh && span) selectRange(fresh, span.start, span.length);
1815
+ else fresh?.focus();
1816
+ }
1817
+ /** Set paragraph alignment (left/center/right/justify) on the active block. */
1818
+ setAlignment(alignment) {
1819
+ this.applyParagraphFormat({ alignment });
1820
+ }
1821
+ /**
1822
+ * Insert an S-1-style horizontal rule (an empty paragraph with a bottom border) after the
1823
+ * active block. `weight` is the rule thickness in eighths of a point (default 12 ≈ 1.5pt).
1824
+ * Re-renders fully (a new block needs whole-document context to lay out).
1825
+ */
1826
+ insertHorizontalRule(weight = 12, style = "single", position = "below") {
1827
+ const block = this.activeBlock;
1828
+ if (this.closed || !block) return;
1829
+ const unid = block.getAttribute("data-anchor");
1830
+ if (!unid) return;
1831
+ let fullId = this.unidToFullId.get(unid);
1832
+ if (!fullId) return;
1833
+ const idx = this.blockIndex(block);
1834
+ fullId = this.syncBlock(block, fullId);
1835
+ const res = this.parseEdit(
1836
+ this.exports.DocxSessionBridge.InsertHorizontalRule(
1837
+ this.handle,
1838
+ fullId,
1839
+ position === "above" ? "before" : "after",
1840
+ JSON.stringify({ style, size: weight, color: "auto" })
1841
+ )
1842
+ );
1843
+ if (!res.success) return;
1844
+ this.remount(idx, false);
1845
+ }
1846
+ /**
1847
+ * Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
1848
+ * markdown) seeds the cells, `options.borderless` makes an invisible layout table, and
1849
+ * `options.cellAlignment` aligns every cell. Re-renders fully (tables need document context).
1850
+ */
1851
+ insertTable(rows, cols, options) {
1852
+ const block = this.activeBlock;
1853
+ if (this.closed || !block) return;
1854
+ const unid = block.getAttribute("data-anchor");
1855
+ if (!unid) return;
1856
+ let fullId = this.unidToFullId.get(unid);
1857
+ if (!fullId) return;
1858
+ const idx = this.blockIndex(block);
1859
+ fullId = this.syncBlock(block, fullId);
1860
+ const emptyHere = !block.closest("table") && blockContentText(block).replace(/[\s ]+/g, "").length === 0;
1861
+ const res = this.parseEdit(
1862
+ this.exports.DocxSessionBridge.InsertTable(
1863
+ this.handle,
1864
+ fullId,
1865
+ emptyHere ? "before" : "after",
1866
+ rows,
1867
+ cols,
1868
+ options ? JSON.stringify(options) : ""
1869
+ )
1870
+ );
1871
+ if (!res.success) return;
1872
+ this.remount(idx, false);
1873
+ }
1874
+ // ─── Table row / column editing (active block must be inside a table cell) ──────────
1875
+ /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
1876
+ tableEdit(run) {
1877
+ const block = this.activeBlock;
1878
+ if (this.closed || !block || !block.closest("table")) return;
1879
+ const unid = block.getAttribute("data-anchor");
1880
+ if (!unid) return;
1881
+ let fullId = this.unidToFullId.get(unid);
1882
+ if (!fullId) return;
1883
+ const idx = this.blockIndex(block);
1884
+ fullId = this.syncBlock(block, fullId);
1885
+ const res = this.parseEdit(run(fullId));
1886
+ if (!res.success) return;
1887
+ this.remount(idx, false);
1888
+ }
1889
+ /** Insert a row above/below the active cell's row. No-op outside a table. */
1890
+ insertTableRow(where) {
1891
+ this.tableEdit(
1892
+ (a) => this.exports.DocxSessionBridge.InsertTableRow(this.handle, a, where === "above" ? "before" : "after")
1893
+ );
1894
+ }
1895
+ /** Insert a column left/right of the active cell's column. No-op outside a table. */
1896
+ insertTableColumn(where) {
1897
+ this.tableEdit(
1898
+ (a) => this.exports.DocxSessionBridge.InsertTableColumn(this.handle, a, where === "left" ? "before" : "after")
1899
+ );
1900
+ }
1901
+ /** Delete the active cell's row (deleting the last row removes the table). No-op outside a table. */
1902
+ deleteTableRow() {
1903
+ this.tableEdit((a) => this.exports.DocxSessionBridge.DeleteTableRow(this.handle, a));
1904
+ }
1905
+ /** Delete the active cell's column (deleting the last column removes the table). No-op outside a table. */
1906
+ deleteTableColumn() {
1907
+ this.tableEdit((a) => this.exports.DocxSessionBridge.DeleteTableColumn(this.handle, a));
1908
+ }
1909
+ /**
1910
+ * Indent/outdent the active block. On a LIST item this changes the list NESTING LEVEL
1911
+ * (`SetListLevel`) so numbering nests (e.g. 1, 2 → a sub-level) rather than the item just
1912
+ * shifting sideways with flat numbering. On a plain paragraph it adjusts the left indent by
1913
+ * `deltaTwips` (default ±720 = 0.5"), clamped at 0.
1914
+ */
1915
+ indent(deltaTwips = 720) {
1916
+ if (this.activeBlock && isListBlock(this.activeBlock)) {
1917
+ this.setListLevel(deltaTwips >= 0 ? 1 : -1);
1918
+ return;
1919
+ }
1920
+ this.applyParagraphFormat({ indentDelta: deltaTwips });
1921
+ }
1922
+ /** Change the active list item's nesting level by `delta` (+1 deeper, −1 shallower). */
1923
+ setListLevel(delta) {
1924
+ const block = this.activeBlock;
1925
+ if (this.closed || !block) return;
1926
+ const unid = block.getAttribute("data-anchor");
1927
+ if (!unid) return;
1928
+ let fullId = this.unidToFullId.get(unid);
1929
+ if (!fullId) return;
1930
+ const idx = this.blockIndex(block);
1931
+ fullId = this.syncBlock(block, fullId);
1932
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SetListLevel(this.handle, fullId, delta));
1933
+ if (!res.success) return;
1934
+ this.remount(idx, false);
1935
+ }
1936
+ /** Toggle (or set) page-break-before on the active block. */
1937
+ pageBreakBefore(value = true) {
1938
+ this.applyParagraphFormat({ pageBreakBefore: value });
1939
+ }
1940
+ /**
1941
+ * Toggle the active block between a bullet/numbered list item and a plain paragraph.
1942
+ * Clicking the same kind it already is removes the list; any other state applies the kind.
1943
+ */
1944
+ toggleList(kind) {
1945
+ const block = this.activeBlock;
1946
+ if (this.closed || !block) return;
1947
+ const unid = block.getAttribute("data-anchor");
1948
+ if (!unid) return;
1949
+ let fullId = this.unidToFullId.get(unid);
1950
+ if (!fullId) return;
1951
+ let membership = null;
1952
+ try {
1953
+ membership = JSON.parse(this.exports.DocxSessionBridge.GetListMembership(this.handle, fullId));
1954
+ } catch {
1955
+ }
1956
+ const isThisKind = !!membership && typeof membership.format === "string" && membership.format.toLowerCase().startsWith(kind === "bullet" ? "bullet" : "decimal");
1957
+ const idx = this.blockIndex(block);
1958
+ fullId = this.syncBlock(block, fullId);
1959
+ const res = this.parseEdit(
1960
+ this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, isThisKind ? "none" : kind)
1961
+ );
1962
+ if (!res.success) return;
1963
+ this.remount(idx, false);
1964
+ }
1965
+ /** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
1966
+ * or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
1967
+ * this surfaces it on the editor so an HR border is removable (S-1 smoke-test finding 1b). */
1968
+ clearParagraphBorders() {
1969
+ this.applyParagraphFormat({ clearBorders: true });
1970
+ }
1971
+ /**
1972
+ * Delete the active block (e.g. a stray empty paragraph left above/below a table). Routes
1973
+ * through DocxSession `DeleteBlock` + re-render, focusing the previous block. No-op when the
1974
+ * caret is inside a table (remove cells via the table toolbar's delete row/column instead) and
1975
+ * no-op when it is the only editable block (don't empty the document). Closes the S-1
1976
+ * smoke-test "no block-delete affordance" gap.
1977
+ */
1978
+ deleteBlock() {
1979
+ const block = this.activeBlock;
1980
+ if (this.closed || !block) return;
1981
+ if (block.closest("table")) return;
1982
+ if (this.editableList().length <= 1) return;
1983
+ const unid = block.getAttribute("data-anchor");
1984
+ if (!unid) return;
1985
+ const fullId = this.unidToFullId.get(unid);
1986
+ if (!fullId) return;
1987
+ const idx = this.blockIndex(block);
1988
+ const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
1989
+ if (!res.success) return;
1990
+ this.remount(Math.max(0, idx - 1), true);
1991
+ }
1992
+ applyParagraphFormat(op) {
1993
+ const block = this.activeBlock;
1994
+ if (this.closed || !block) return;
1995
+ const blocks = this.selectedBlocks();
1996
+ if (blocks.length > 1 && this.applyParagraphOpAcrossBlocks(
1997
+ blocks,
1998
+ (id) => this.exports.DocxSessionBridge.SetParagraphFormat(this.handle, id, JSON.stringify(op)),
1999
+ // A border change regroups the wrapping border <div>s — whole-document context.
2000
+ !!op.clearBorders
2001
+ ))
2002
+ return;
2003
+ const unid = block.getAttribute("data-anchor");
2004
+ if (!unid) return;
2005
+ let fullId = this.unidToFullId.get(unid);
2006
+ if (!fullId) return;
2007
+ const idx = this.blockIndex(block);
2008
+ fullId = this.syncBlock(block, fullId);
2009
+ const res = this.parseEdit(
2010
+ this.exports.DocxSessionBridge.SetParagraphFormat(this.handle, fullId, JSON.stringify(op))
2011
+ );
2012
+ if (!res.success) return;
2013
+ if (this.affectsList(res) || op.clearBorders) {
2014
+ this.remount(idx, false);
2015
+ return;
2016
+ }
2017
+ this.swapBlock(block, unid, res.modified?.[0])?.focus();
2018
+ }
2019
+ /** Set the paragraph style of the active block — or of every block in a multi-block selection
2020
+ * (e.g. "Heading1", "Heading2", "Normal"). */
2021
+ setParagraphStyle(styleId) {
2022
+ const block = this.activeBlock;
2023
+ if (this.closed || !block) return;
2024
+ const blocks = this.selectedBlocks();
2025
+ if (blocks.length > 1 && this.applyParagraphOpAcrossBlocks(
2026
+ blocks,
2027
+ (id) => this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, id, styleId)
2028
+ ))
2029
+ return;
2030
+ const unid = block.getAttribute("data-anchor");
2031
+ if (!unid) return;
2032
+ let fullId = this.unidToFullId.get(unid);
2033
+ if (!fullId) return;
2034
+ const idx = this.blockIndex(block);
2035
+ fullId = this.syncBlock(block, fullId);
2036
+ const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, fullId, styleId));
2037
+ if (!res.success) return;
2038
+ if (this.affectsList(res)) {
2039
+ this.remount(idx, false);
2040
+ return;
2041
+ }
2042
+ this.swapBlock(block, unid, res.modified?.[0])?.focus();
2043
+ }
2044
+ /** Undo the last edit (re-renders the document). */
2045
+ undo() {
2046
+ if (this.closed) return;
2047
+ if (this.exports.DocxSessionBridge.Undo(this.handle)) this.remount();
2048
+ }
2049
+ /** Redo the last undone edit (re-renders the document). */
2050
+ redo() {
2051
+ if (this.closed) return;
2052
+ if (this.exports.DocxSessionBridge.Redo(this.handle)) this.remount();
2053
+ }
2054
+ /** Which inline formats the current selection carries — for ribbon button highlighting. */
2055
+ queryFormatState() {
2056
+ const block = this.activeBlock ?? this.editRoot;
2057
+ return {
2058
+ bold: selectionHasFormat("bold", block),
2059
+ italic: selectionHasFormat("italic", block),
2060
+ underline: selectionHasFormat("underline", block),
2061
+ strike: selectionHasFormat("strike", block),
2062
+ code: selectionHasFormat("code", block),
2063
+ superscript: selectionHasFormat("superscript", block),
2064
+ subscript: selectionHasFormat("subscript", block)
2065
+ };
2066
+ }
2067
+ /** Re-render one block from the live session by EditResult ref, swapping it in place. */
2068
+ swapBlock(oldEl, oldUnid, ref) {
2069
+ const anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
2070
+ const newUnid = ref?.unid ?? oldUnid;
2071
+ if (!anchorId) return null;
2072
+ const fresh = this.renderInto(anchorId);
2073
+ if (!fresh || !this.replaceNode(oldEl, fresh)) return null;
2074
+ this.unidToFullId.delete(oldUnid);
2075
+ this.unidToFullId.set(newUnid, anchorId);
2076
+ this.wireBlock(fresh);
2077
+ this.activeBlock = fresh;
2078
+ this.options.onEdit?.({ anchorId, unid: newUnid });
2079
+ return fresh;
2080
+ }
2081
+ /**
2082
+ * Full-document HTML from the live session. Prefers the session-attached
2083
+ * `RenderHtml` bridge — the saved bytes never cross the JS/WASM boundary
2084
+ * (two multi-MB copies per remount on a large doc) — and falls back to
2085
+ * Save + ConvertDocxToHtmlComplete for older WASM bundles. Both paths use
2086
+ * the same option profile, so the rendered HTML is identical.
2087
+ */
2088
+ renderFullHtml() {
2089
+ const bridge = this.exports.DocxSessionBridge;
2090
+ if (typeof bridge.RenderHtml === "function") {
2091
+ const html = bridge.RenderHtml(
2092
+ this.handle,
2093
+ this.options.cssPrefix,
2094
+ this.options.fabricateClasses,
2095
+ this.options.paginated,
2096
+ this.options.scale
2097
+ );
2098
+ if (html.charCodeAt(0) !== 123) return html;
2099
+ }
2100
+ const bytes = bridge.Save(this.handle);
2101
+ return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(
2102
+ ...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale)
2103
+ );
2104
+ }
2105
+ /** Editable blocks in document order. */
2106
+ editableList() {
2107
+ return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
2108
+ }
2109
+ blockIndex(el) {
2110
+ return this.editableList().indexOf(el);
2111
+ }
2112
+ /**
2113
+ * True when an edit produced or touched a list item (kind "li"). List markers and
2114
+ * numbering CONTINUATION need whole-document context, which a single-block render lacks
2115
+ * (every item would render as "1."), so such edits re-render the whole document.
2116
+ */
2117
+ affectsList(res) {
2118
+ return [...res.modified ?? [], ...res.created ?? []].some((r) => r.kind === "li");
2119
+ }
2120
+ /**
2121
+ * Full re-render from current session state (after undo/redo, and after list edits where
2122
+ * single-block rendering can't compute numbering). Optionally focus the editable block at
2123
+ * `focusIndex` (caret at start, or end if `caretAtEnd`) — addressed by index because a
2124
+ * block's content-hashed unid changes across the save/reproject a remount performs.
2125
+ */
2126
+ remount(focusIndex = -1, caretAtEnd = false) {
2127
+ this.refreshAnchorMap();
2128
+ const fullHtml = this.renderFullHtml();
2129
+ this.activeBlock = null;
2130
+ if (this.options.paginated) this.mountPaginated(fullHtml);
2131
+ else this.mountHtml(fullHtml);
2132
+ if (focusIndex >= 0) {
2133
+ const blocks = this.editableList();
2134
+ const target = blocks[Math.min(focusIndex, blocks.length - 1)];
2135
+ if (target) {
2136
+ this.activeBlock = target;
2137
+ placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
2138
+ }
2139
+ }
2140
+ }
2141
+ };
2142
+ return __toCommonJS(editor_exports);
2143
+ })();