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.
- package/dist/docxodus.worker.js +13 -6
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor.bundle.js +2143 -0
- package/dist/editor.d.ts +336 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +1525 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.d.ts +129 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +336 -8
- package/dist/index.js.map +1 -1
- package/dist/session.bundle.js +67 -0
- package/dist/session.d.ts +37 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +59 -0
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +296 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +67 -0
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
- package/dist/wasm/_framework/System.Console.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Memory.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
- package/dist/wasm/_framework/System.wasm +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +279 -0
- package/dist/wasm/_framework/dotnet.js +2 -2
- package/dist/wasm/_framework/dotnet.js.map +1 -1
- package/dist/wasm/_framework/dotnet.native.js +5628 -3
- package/dist/wasm/_framework/dotnet.native.js.symbols +5530 -3599
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.runtime.js +1 -1
- package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
- package/package.json +7 -4
- package/dist/wasm/_framework/System.Diagnostics.DiagnosticSource.wasm +0 -0
- package/dist/wasm/_framework/blazor.boot.json +0 -66
- 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
|
+
})();
|