@kedataindo/docflow-layout-engine 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +416 -0
- package/dist/index.d.cts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +386 -0
- package/package.json +37 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
PAGE_SIZES: () => PAGE_SIZES,
|
|
24
|
+
PageBreaker: () => PageBreaker,
|
|
25
|
+
PageLayout: () => PageLayout,
|
|
26
|
+
getPageSize: () => getPageSize
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/types.ts
|
|
31
|
+
var PAGE_SIZES = [
|
|
32
|
+
{ id: "a4", name: "A4", pageWidth: 794, pageHeight: 1123 },
|
|
33
|
+
{ id: "f4", name: "F4", pageWidth: 816, pageHeight: 1204 },
|
|
34
|
+
{ id: "letter", name: "Letter", pageWidth: 816, pageHeight: 1056 },
|
|
35
|
+
{ id: "legal", name: "Legal", pageWidth: 816, pageHeight: 1344 },
|
|
36
|
+
{ id: "a5", name: "A5", pageWidth: 559, pageHeight: 794 }
|
|
37
|
+
];
|
|
38
|
+
function getPageSize(id) {
|
|
39
|
+
return PAGE_SIZES.find((size) => size.id === id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/PageBreaker.ts
|
|
43
|
+
var PageBreaker = class {
|
|
44
|
+
constructor(measureTextBlock = () => null) {
|
|
45
|
+
this.measureTextBlock = measureTextBlock;
|
|
46
|
+
}
|
|
47
|
+
measureTextBlock;
|
|
48
|
+
computePages(blocks, pageHeight, maxCharsPerPage) {
|
|
49
|
+
const pages = [];
|
|
50
|
+
let currentBlocks = [];
|
|
51
|
+
let currentFrom = 0;
|
|
52
|
+
let currentTo = 0;
|
|
53
|
+
let usedHeight = 0;
|
|
54
|
+
let i = 0;
|
|
55
|
+
let block = blocks[0];
|
|
56
|
+
while (block) {
|
|
57
|
+
const blockHeight = block.bottom - block.top;
|
|
58
|
+
if (!block.canSplit && blockHeight > pageHeight) {
|
|
59
|
+
block = { ...block, pageOverflow: true };
|
|
60
|
+
}
|
|
61
|
+
if (currentBlocks.length === 0) {
|
|
62
|
+
currentFrom = block.from;
|
|
63
|
+
}
|
|
64
|
+
if (block.forceBreak && currentBlocks.length > 0) {
|
|
65
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
66
|
+
currentBlocks = [];
|
|
67
|
+
usedHeight = 0;
|
|
68
|
+
currentFrom = block.from;
|
|
69
|
+
currentTo = block.from;
|
|
70
|
+
}
|
|
71
|
+
if (block.pageOverflow) {
|
|
72
|
+
if (currentBlocks.length > 0) {
|
|
73
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
74
|
+
currentBlocks = [];
|
|
75
|
+
usedHeight = 0;
|
|
76
|
+
}
|
|
77
|
+
currentBlocks = [block];
|
|
78
|
+
currentFrom = block.from;
|
|
79
|
+
currentTo = block.to;
|
|
80
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
81
|
+
currentBlocks = [];
|
|
82
|
+
usedHeight = 0;
|
|
83
|
+
i++;
|
|
84
|
+
block = blocks[i];
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let blockSpan;
|
|
88
|
+
if (currentBlocks.length === 0) {
|
|
89
|
+
const marginTop = block.marginTop ?? 0;
|
|
90
|
+
const marginBottom = block.marginBottom ?? 0;
|
|
91
|
+
blockSpan = block.bottom - block.top + marginTop + marginBottom;
|
|
92
|
+
} else {
|
|
93
|
+
const prevBlock = currentBlocks[currentBlocks.length - 1];
|
|
94
|
+
const gap = Math.max(0, block.top - prevBlock.bottom);
|
|
95
|
+
blockSpan = gap + (block.bottom - block.top) + (block.marginBottom ?? 0);
|
|
96
|
+
}
|
|
97
|
+
const fitsInPage = usedHeight + blockSpan <= pageHeight;
|
|
98
|
+
let exceedsCharLimit = false;
|
|
99
|
+
if (maxCharsPerPage && fitsInPage) {
|
|
100
|
+
const charsWithBlock = currentTo - currentFrom + (block.to - block.from);
|
|
101
|
+
exceedsCharLimit = charsWithBlock > maxCharsPerPage;
|
|
102
|
+
}
|
|
103
|
+
if (fitsInPage && !exceedsCharLimit) {
|
|
104
|
+
currentBlocks.push(block);
|
|
105
|
+
currentTo = block.to;
|
|
106
|
+
usedHeight += blockSpan;
|
|
107
|
+
i++;
|
|
108
|
+
block = blocks[i];
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (block.canSplit) {
|
|
112
|
+
const remaining = pageHeight - usedHeight;
|
|
113
|
+
const split = this.splitBlock(block, remaining);
|
|
114
|
+
if (split) {
|
|
115
|
+
currentBlocks.push(split.current);
|
|
116
|
+
currentTo = split.current.to;
|
|
117
|
+
pages.push({
|
|
118
|
+
from: currentFrom,
|
|
119
|
+
to: currentTo,
|
|
120
|
+
blocks: currentBlocks
|
|
121
|
+
});
|
|
122
|
+
currentBlocks = [];
|
|
123
|
+
usedHeight = 0;
|
|
124
|
+
block = split.next;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (currentBlocks.length > 0) {
|
|
129
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
130
|
+
currentBlocks = [];
|
|
131
|
+
usedHeight = 0;
|
|
132
|
+
}
|
|
133
|
+
currentBlocks.push(block);
|
|
134
|
+
currentFrom = block.from;
|
|
135
|
+
currentTo = block.to;
|
|
136
|
+
usedHeight = block.bottom - block.top + (block.marginTop ?? 0) + (block.marginBottom ?? 0);
|
|
137
|
+
i++;
|
|
138
|
+
block = blocks[i];
|
|
139
|
+
}
|
|
140
|
+
if (currentBlocks.length > 0) {
|
|
141
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
142
|
+
}
|
|
143
|
+
return pages;
|
|
144
|
+
}
|
|
145
|
+
splitBlock(block, availableHeight) {
|
|
146
|
+
const totalChars = block.to - block.from;
|
|
147
|
+
if (totalChars <= 0 || availableHeight <= 0) return null;
|
|
148
|
+
if (totalChars < 10 || availableHeight < 20) return null;
|
|
149
|
+
let lo = 0;
|
|
150
|
+
let hi = totalChars;
|
|
151
|
+
let best = -1;
|
|
152
|
+
while (lo <= hi) {
|
|
153
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
154
|
+
const height = this.measureTextBlock(block, mid);
|
|
155
|
+
if (height === null) return null;
|
|
156
|
+
if (height <= availableHeight) {
|
|
157
|
+
best = mid;
|
|
158
|
+
lo = mid + 1;
|
|
159
|
+
} else {
|
|
160
|
+
hi = mid - 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (best <= 0 || best >= totalChars - 3) return null;
|
|
164
|
+
const currentHeight = this.measureTextBlock(block, best);
|
|
165
|
+
if (currentHeight === null) return null;
|
|
166
|
+
const splitOffset = block.from + best;
|
|
167
|
+
const current = {
|
|
168
|
+
...block,
|
|
169
|
+
to: splitOffset,
|
|
170
|
+
bottom: block.top + currentHeight,
|
|
171
|
+
splitOffset
|
|
172
|
+
};
|
|
173
|
+
const next = {
|
|
174
|
+
...block,
|
|
175
|
+
from: splitOffset,
|
|
176
|
+
top: block.top + currentHeight,
|
|
177
|
+
splitOffset: void 0
|
|
178
|
+
};
|
|
179
|
+
return { current, next };
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// src/PageLayout.ts
|
|
184
|
+
var PageLayout = class {
|
|
185
|
+
element;
|
|
186
|
+
editorView;
|
|
187
|
+
options;
|
|
188
|
+
breaker;
|
|
189
|
+
debounceMs;
|
|
190
|
+
shadowRoot = null;
|
|
191
|
+
resizeObserver = null;
|
|
192
|
+
debounceTimer = null;
|
|
193
|
+
lastDocHash = "";
|
|
194
|
+
lastShadowHash = "";
|
|
195
|
+
lastSize = { width: 0, height: 0 };
|
|
196
|
+
lastResult = null;
|
|
197
|
+
pendingResolvers = [];
|
|
198
|
+
constructor(editor, options, debounceMs = 150) {
|
|
199
|
+
if (editor instanceof HTMLElement) {
|
|
200
|
+
this.element = editor;
|
|
201
|
+
} else {
|
|
202
|
+
this.element = editor.dom;
|
|
203
|
+
this.editorView = editor.view;
|
|
204
|
+
}
|
|
205
|
+
this.options = {
|
|
206
|
+
pageHeight: options.pageHeight,
|
|
207
|
+
pageWidth: options.pageWidth ?? 0,
|
|
208
|
+
margins: options.margins ?? { top: 0, bottom: 0, left: 0, right: 0 },
|
|
209
|
+
maxCharsPerPage: options.maxCharsPerPage ?? 0
|
|
210
|
+
};
|
|
211
|
+
this.debounceMs = debounceMs;
|
|
212
|
+
this.breaker = new PageBreaker(
|
|
213
|
+
(block, offset) => this.measureTextOffset(block, offset)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
measureBlocks(root) {
|
|
217
|
+
const container = root ?? this.element;
|
|
218
|
+
const containerRect = container.getBoundingClientRect();
|
|
219
|
+
const blocks = [];
|
|
220
|
+
let fallbackPos = 0;
|
|
221
|
+
for (const child of Array.from(container.children)) {
|
|
222
|
+
if (!(child instanceof HTMLElement)) continue;
|
|
223
|
+
const nodeType = child.getAttribute("data-node-type") ?? child.tagName.toLowerCase();
|
|
224
|
+
let pmFrom = parseInt(child.getAttribute("data-from") ?? "-1", 10);
|
|
225
|
+
let pmTo = parseInt(child.getAttribute("data-to") ?? "-1", 10);
|
|
226
|
+
if ((pmFrom < 0 || pmTo < pmFrom) && this.editorView) {
|
|
227
|
+
try {
|
|
228
|
+
pmFrom = this.editorView.posAtDOM(child, 0);
|
|
229
|
+
pmTo = this.editorView.posAtDOM(child, child.childNodes.length);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (pmFrom < 0 || pmTo < pmFrom) {
|
|
234
|
+
pmFrom = fallbackPos;
|
|
235
|
+
const textLen = child.textContent?.length ?? 0;
|
|
236
|
+
pmTo = fallbackPos + textLen + 2;
|
|
237
|
+
fallbackPos = pmTo;
|
|
238
|
+
}
|
|
239
|
+
const rect = child.getBoundingClientRect();
|
|
240
|
+
const canSplit = this.isTextBlock(nodeType);
|
|
241
|
+
const forceBreak = child.getAttribute("data-page-break") === "true" || nodeType === "pageBreak";
|
|
242
|
+
const style = getComputedStyle(child);
|
|
243
|
+
const marginTop = parseFloat(style.marginTop) || 0;
|
|
244
|
+
const marginBottom = parseFloat(style.marginBottom) || 0;
|
|
245
|
+
blocks.push({
|
|
246
|
+
nodeType,
|
|
247
|
+
from: pmFrom,
|
|
248
|
+
to: pmTo,
|
|
249
|
+
top: rect.top - containerRect.top,
|
|
250
|
+
bottom: rect.bottom - containerRect.top,
|
|
251
|
+
rect,
|
|
252
|
+
canSplit,
|
|
253
|
+
forceBreak,
|
|
254
|
+
marginTop,
|
|
255
|
+
marginBottom
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return blocks;
|
|
259
|
+
}
|
|
260
|
+
layout(immediate = false) {
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
this.pendingResolvers.push(resolve);
|
|
263
|
+
if (immediate || this.debounceMs <= 0) {
|
|
264
|
+
this.runLayout();
|
|
265
|
+
} else {
|
|
266
|
+
this.scheduleLayout();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
observe(container) {
|
|
271
|
+
this.resizeObserver?.disconnect();
|
|
272
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
273
|
+
this.layout();
|
|
274
|
+
});
|
|
275
|
+
this.resizeObserver.observe(container);
|
|
276
|
+
}
|
|
277
|
+
destroy() {
|
|
278
|
+
if (this.debounceTimer) {
|
|
279
|
+
clearTimeout(this.debounceTimer);
|
|
280
|
+
this.debounceTimer = null;
|
|
281
|
+
}
|
|
282
|
+
this.pendingResolvers = [];
|
|
283
|
+
this.resizeObserver?.disconnect();
|
|
284
|
+
this.resizeObserver = null;
|
|
285
|
+
if (this.shadowRoot?.parentNode) {
|
|
286
|
+
this.shadowRoot.parentNode.removeChild(this.shadowRoot);
|
|
287
|
+
}
|
|
288
|
+
this.shadowRoot = null;
|
|
289
|
+
this.lastResult = null;
|
|
290
|
+
this.lastDocHash = "";
|
|
291
|
+
this.lastShadowHash = "";
|
|
292
|
+
}
|
|
293
|
+
scheduleLayout() {
|
|
294
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
295
|
+
this.debounceTimer = setTimeout(() => {
|
|
296
|
+
this.runLayout();
|
|
297
|
+
}, this.debounceMs);
|
|
298
|
+
}
|
|
299
|
+
runLayout() {
|
|
300
|
+
if (this.debounceTimer) {
|
|
301
|
+
clearTimeout(this.debounceTimer);
|
|
302
|
+
this.debounceTimer = null;
|
|
303
|
+
}
|
|
304
|
+
const docHash = this.computeDocHash();
|
|
305
|
+
const size = this.getContainerSize();
|
|
306
|
+
if (this.lastResult && this.lastDocHash === docHash && this.lastSize.width === size.width && this.lastSize.height === size.height) {
|
|
307
|
+
this.resolveAll(this.lastResult);
|
|
308
|
+
return this.lastResult;
|
|
309
|
+
}
|
|
310
|
+
this.prepareShadowLayout();
|
|
311
|
+
const root = this.element;
|
|
312
|
+
const blocks = this.measureBlocks(root);
|
|
313
|
+
const availableHeight = this.availableHeight();
|
|
314
|
+
const pages = this.breaker.computePages(blocks, availableHeight, this.options.maxCharsPerPage);
|
|
315
|
+
const result = { pages, invalidatedAt: Date.now() };
|
|
316
|
+
this.lastResult = result;
|
|
317
|
+
this.lastDocHash = docHash;
|
|
318
|
+
this.lastSize = size;
|
|
319
|
+
this.resolveAll(result);
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
resolveAll(result) {
|
|
323
|
+
for (const resolve of this.pendingResolvers) {
|
|
324
|
+
resolve(result);
|
|
325
|
+
}
|
|
326
|
+
this.pendingResolvers = [];
|
|
327
|
+
}
|
|
328
|
+
computeDocHash() {
|
|
329
|
+
let hash = "";
|
|
330
|
+
for (const child of Array.from(this.element.children)) {
|
|
331
|
+
if (child instanceof HTMLElement) {
|
|
332
|
+
hash += child.tagName + (child.getAttribute("data-from") ?? "") + (child.getAttribute("data-to") ?? "") + (child.getAttribute("data-node-type") ?? "") + (child.textContent ?? "");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return hash;
|
|
336
|
+
}
|
|
337
|
+
getContainerSize() {
|
|
338
|
+
const rect = this.element.getBoundingClientRect();
|
|
339
|
+
return { width: rect.width, height: rect.height };
|
|
340
|
+
}
|
|
341
|
+
availableHeight() {
|
|
342
|
+
const { top, bottom } = this.options.margins;
|
|
343
|
+
return this.options.pageHeight - top - bottom;
|
|
344
|
+
}
|
|
345
|
+
prepareShadowLayout() {
|
|
346
|
+
if (!this.shadowRoot) {
|
|
347
|
+
const shadow = document.createElement("div");
|
|
348
|
+
shadow.setAttribute("data-layout-shadow", "true");
|
|
349
|
+
shadow.style.position = "absolute";
|
|
350
|
+
shadow.style.top = "-9999px";
|
|
351
|
+
shadow.style.left = "-9999px";
|
|
352
|
+
shadow.style.visibility = "hidden";
|
|
353
|
+
shadow.style.pointerEvents = "none";
|
|
354
|
+
shadow.style.overflow = "hidden";
|
|
355
|
+
if (this.options.pageWidth > 0) {
|
|
356
|
+
shadow.style.width = `${this.options.pageWidth}px`;
|
|
357
|
+
}
|
|
358
|
+
shadow.className = this.element.className || "tiptap ProseMirror";
|
|
359
|
+
document.body.appendChild(shadow);
|
|
360
|
+
this.shadowRoot = shadow;
|
|
361
|
+
}
|
|
362
|
+
const currentHash = this.computeDocHash();
|
|
363
|
+
if (this.lastShadowHash !== currentHash) {
|
|
364
|
+
this.shadowRoot.innerHTML = "";
|
|
365
|
+
for (const child of Array.from(this.element.children)) {
|
|
366
|
+
this.shadowRoot.appendChild(child.cloneNode(true));
|
|
367
|
+
}
|
|
368
|
+
const elements = Array.from(this.shadowRoot.getElementsByTagName("*"));
|
|
369
|
+
elements.forEach((el) => {
|
|
370
|
+
el.style.removeProperty("margin-top");
|
|
371
|
+
});
|
|
372
|
+
this.lastShadowHash = currentHash;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
isTextBlock(nodeType) {
|
|
376
|
+
return ["paragraph", "heading", "list", "blockquote", "codeBlock"].includes(
|
|
377
|
+
nodeType
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
measureTextOffset(block, offset) {
|
|
381
|
+
if (offset <= 0) return 0;
|
|
382
|
+
const root = this.shadowRoot ?? this.element;
|
|
383
|
+
const el = root.querySelector(`[data-from="${block.from}"]`);
|
|
384
|
+
if (!el) return null;
|
|
385
|
+
const start = this.findTextNodeAtOffset(el, 0);
|
|
386
|
+
const end = this.findTextNodeAtOffset(el, offset);
|
|
387
|
+
if (!start || !end) return null;
|
|
388
|
+
const range = document.createRange();
|
|
389
|
+
range.setStart(start.node, start.offset);
|
|
390
|
+
range.setEnd(end.node, end.offset);
|
|
391
|
+
const rects = range.getClientRects();
|
|
392
|
+
if (!rects.length) return null;
|
|
393
|
+
return rects[rects.length - 1].bottom - rects[0].top;
|
|
394
|
+
}
|
|
395
|
+
findTextNodeAtOffset(root, offset) {
|
|
396
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
397
|
+
let remaining = offset;
|
|
398
|
+
let node = walker.nextNode();
|
|
399
|
+
while (node) {
|
|
400
|
+
const length = node.textContent?.length ?? 0;
|
|
401
|
+
if (remaining <= length) {
|
|
402
|
+
return { node, offset: remaining };
|
|
403
|
+
}
|
|
404
|
+
remaining -= length;
|
|
405
|
+
node = walker.nextNode();
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
411
|
+
0 && (module.exports = {
|
|
412
|
+
PAGE_SIZES,
|
|
413
|
+
PageBreaker,
|
|
414
|
+
PageLayout,
|
|
415
|
+
getPageSize
|
|
416
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
interface BlockInfo {
|
|
2
|
+
nodeType: string;
|
|
3
|
+
from: number;
|
|
4
|
+
to: number;
|
|
5
|
+
top: number;
|
|
6
|
+
bottom: number;
|
|
7
|
+
rect?: DOMRect;
|
|
8
|
+
canSplit: boolean;
|
|
9
|
+
forceBreak?: boolean;
|
|
10
|
+
splitOffset?: number;
|
|
11
|
+
/** True when this block's height exceeds the available page height.
|
|
12
|
+
* The renderer should apply overflow-hidden at the page level to clip it. */
|
|
13
|
+
pageOverflow?: boolean;
|
|
14
|
+
/** CSS margin-top in pixels (computed style) */
|
|
15
|
+
marginTop?: number;
|
|
16
|
+
/** CSS margin-bottom in pixels (computed style) */
|
|
17
|
+
marginBottom?: number;
|
|
18
|
+
}
|
|
19
|
+
interface Page {
|
|
20
|
+
from: number;
|
|
21
|
+
to: number;
|
|
22
|
+
blocks: BlockInfo[];
|
|
23
|
+
}
|
|
24
|
+
interface LayoutOptions {
|
|
25
|
+
pageHeight: number;
|
|
26
|
+
pageWidth?: number;
|
|
27
|
+
margins?: {
|
|
28
|
+
top: number;
|
|
29
|
+
bottom: number;
|
|
30
|
+
left: number;
|
|
31
|
+
right: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Maximum characters per page, used as a secondary cutoff alongside the
|
|
35
|
+
* height-based limit. Computed dynamically from font metrics, margins,
|
|
36
|
+
* and page dimensions so it adapts to font-size / orientation changes.
|
|
37
|
+
*/
|
|
38
|
+
maxCharsPerPage?: number;
|
|
39
|
+
}
|
|
40
|
+
interface LayoutResult {
|
|
41
|
+
pages: Page[];
|
|
42
|
+
invalidatedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
interface PageSize {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
pageWidth: number;
|
|
48
|
+
pageHeight: number;
|
|
49
|
+
}
|
|
50
|
+
declare const PAGE_SIZES: PageSize[];
|
|
51
|
+
declare function getPageSize(id: string): PageSize | undefined;
|
|
52
|
+
|
|
53
|
+
type MeasureTextBlock = (block: BlockInfo, offset: number) => number | null;
|
|
54
|
+
interface SplitResult {
|
|
55
|
+
current: BlockInfo;
|
|
56
|
+
next: BlockInfo;
|
|
57
|
+
}
|
|
58
|
+
declare class PageBreaker {
|
|
59
|
+
private measureTextBlock;
|
|
60
|
+
constructor(measureTextBlock?: MeasureTextBlock);
|
|
61
|
+
computePages(blocks: BlockInfo[], pageHeight: number, maxCharsPerPage?: number): Page[];
|
|
62
|
+
splitBlock(block: BlockInfo, availableHeight: number): SplitResult | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface EditorLike {
|
|
66
|
+
dom: HTMLElement;
|
|
67
|
+
view?: {
|
|
68
|
+
posAtDOM: (node: Node, offset: number) => number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
declare class PageLayout {
|
|
72
|
+
private readonly element;
|
|
73
|
+
private readonly editorView;
|
|
74
|
+
private readonly options;
|
|
75
|
+
private readonly breaker;
|
|
76
|
+
private readonly debounceMs;
|
|
77
|
+
private shadowRoot;
|
|
78
|
+
private resizeObserver;
|
|
79
|
+
private debounceTimer;
|
|
80
|
+
private lastDocHash;
|
|
81
|
+
private lastShadowHash;
|
|
82
|
+
private lastSize;
|
|
83
|
+
private lastResult;
|
|
84
|
+
private pendingResolvers;
|
|
85
|
+
constructor(editor: EditorLike | HTMLElement, options: LayoutOptions, debounceMs?: number);
|
|
86
|
+
measureBlocks(root?: HTMLElement): BlockInfo[];
|
|
87
|
+
layout(immediate?: boolean): Promise<LayoutResult>;
|
|
88
|
+
observe(container: HTMLElement): void;
|
|
89
|
+
destroy(): void;
|
|
90
|
+
private scheduleLayout;
|
|
91
|
+
private runLayout;
|
|
92
|
+
private resolveAll;
|
|
93
|
+
private computeDocHash;
|
|
94
|
+
private getContainerSize;
|
|
95
|
+
private availableHeight;
|
|
96
|
+
private prepareShadowLayout;
|
|
97
|
+
private isTextBlock;
|
|
98
|
+
private measureTextOffset;
|
|
99
|
+
private findTextNodeAtOffset;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { type BlockInfo, type EditorLike, type LayoutOptions, type LayoutResult, type MeasureTextBlock, PAGE_SIZES, type Page, PageBreaker, PageLayout, type PageSize, type SplitResult, getPageSize };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
interface BlockInfo {
|
|
2
|
+
nodeType: string;
|
|
3
|
+
from: number;
|
|
4
|
+
to: number;
|
|
5
|
+
top: number;
|
|
6
|
+
bottom: number;
|
|
7
|
+
rect?: DOMRect;
|
|
8
|
+
canSplit: boolean;
|
|
9
|
+
forceBreak?: boolean;
|
|
10
|
+
splitOffset?: number;
|
|
11
|
+
/** True when this block's height exceeds the available page height.
|
|
12
|
+
* The renderer should apply overflow-hidden at the page level to clip it. */
|
|
13
|
+
pageOverflow?: boolean;
|
|
14
|
+
/** CSS margin-top in pixels (computed style) */
|
|
15
|
+
marginTop?: number;
|
|
16
|
+
/** CSS margin-bottom in pixels (computed style) */
|
|
17
|
+
marginBottom?: number;
|
|
18
|
+
}
|
|
19
|
+
interface Page {
|
|
20
|
+
from: number;
|
|
21
|
+
to: number;
|
|
22
|
+
blocks: BlockInfo[];
|
|
23
|
+
}
|
|
24
|
+
interface LayoutOptions {
|
|
25
|
+
pageHeight: number;
|
|
26
|
+
pageWidth?: number;
|
|
27
|
+
margins?: {
|
|
28
|
+
top: number;
|
|
29
|
+
bottom: number;
|
|
30
|
+
left: number;
|
|
31
|
+
right: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Maximum characters per page, used as a secondary cutoff alongside the
|
|
35
|
+
* height-based limit. Computed dynamically from font metrics, margins,
|
|
36
|
+
* and page dimensions so it adapts to font-size / orientation changes.
|
|
37
|
+
*/
|
|
38
|
+
maxCharsPerPage?: number;
|
|
39
|
+
}
|
|
40
|
+
interface LayoutResult {
|
|
41
|
+
pages: Page[];
|
|
42
|
+
invalidatedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
interface PageSize {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
pageWidth: number;
|
|
48
|
+
pageHeight: number;
|
|
49
|
+
}
|
|
50
|
+
declare const PAGE_SIZES: PageSize[];
|
|
51
|
+
declare function getPageSize(id: string): PageSize | undefined;
|
|
52
|
+
|
|
53
|
+
type MeasureTextBlock = (block: BlockInfo, offset: number) => number | null;
|
|
54
|
+
interface SplitResult {
|
|
55
|
+
current: BlockInfo;
|
|
56
|
+
next: BlockInfo;
|
|
57
|
+
}
|
|
58
|
+
declare class PageBreaker {
|
|
59
|
+
private measureTextBlock;
|
|
60
|
+
constructor(measureTextBlock?: MeasureTextBlock);
|
|
61
|
+
computePages(blocks: BlockInfo[], pageHeight: number, maxCharsPerPage?: number): Page[];
|
|
62
|
+
splitBlock(block: BlockInfo, availableHeight: number): SplitResult | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface EditorLike {
|
|
66
|
+
dom: HTMLElement;
|
|
67
|
+
view?: {
|
|
68
|
+
posAtDOM: (node: Node, offset: number) => number;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
declare class PageLayout {
|
|
72
|
+
private readonly element;
|
|
73
|
+
private readonly editorView;
|
|
74
|
+
private readonly options;
|
|
75
|
+
private readonly breaker;
|
|
76
|
+
private readonly debounceMs;
|
|
77
|
+
private shadowRoot;
|
|
78
|
+
private resizeObserver;
|
|
79
|
+
private debounceTimer;
|
|
80
|
+
private lastDocHash;
|
|
81
|
+
private lastShadowHash;
|
|
82
|
+
private lastSize;
|
|
83
|
+
private lastResult;
|
|
84
|
+
private pendingResolvers;
|
|
85
|
+
constructor(editor: EditorLike | HTMLElement, options: LayoutOptions, debounceMs?: number);
|
|
86
|
+
measureBlocks(root?: HTMLElement): BlockInfo[];
|
|
87
|
+
layout(immediate?: boolean): Promise<LayoutResult>;
|
|
88
|
+
observe(container: HTMLElement): void;
|
|
89
|
+
destroy(): void;
|
|
90
|
+
private scheduleLayout;
|
|
91
|
+
private runLayout;
|
|
92
|
+
private resolveAll;
|
|
93
|
+
private computeDocHash;
|
|
94
|
+
private getContainerSize;
|
|
95
|
+
private availableHeight;
|
|
96
|
+
private prepareShadowLayout;
|
|
97
|
+
private isTextBlock;
|
|
98
|
+
private measureTextOffset;
|
|
99
|
+
private findTextNodeAtOffset;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { type BlockInfo, type EditorLike, type LayoutOptions, type LayoutResult, type MeasureTextBlock, PAGE_SIZES, type Page, PageBreaker, PageLayout, type PageSize, type SplitResult, getPageSize };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var PAGE_SIZES = [
|
|
3
|
+
{ id: "a4", name: "A4", pageWidth: 794, pageHeight: 1123 },
|
|
4
|
+
{ id: "f4", name: "F4", pageWidth: 816, pageHeight: 1204 },
|
|
5
|
+
{ id: "letter", name: "Letter", pageWidth: 816, pageHeight: 1056 },
|
|
6
|
+
{ id: "legal", name: "Legal", pageWidth: 816, pageHeight: 1344 },
|
|
7
|
+
{ id: "a5", name: "A5", pageWidth: 559, pageHeight: 794 }
|
|
8
|
+
];
|
|
9
|
+
function getPageSize(id) {
|
|
10
|
+
return PAGE_SIZES.find((size) => size.id === id);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/PageBreaker.ts
|
|
14
|
+
var PageBreaker = class {
|
|
15
|
+
constructor(measureTextBlock = () => null) {
|
|
16
|
+
this.measureTextBlock = measureTextBlock;
|
|
17
|
+
}
|
|
18
|
+
measureTextBlock;
|
|
19
|
+
computePages(blocks, pageHeight, maxCharsPerPage) {
|
|
20
|
+
const pages = [];
|
|
21
|
+
let currentBlocks = [];
|
|
22
|
+
let currentFrom = 0;
|
|
23
|
+
let currentTo = 0;
|
|
24
|
+
let usedHeight = 0;
|
|
25
|
+
let i = 0;
|
|
26
|
+
let block = blocks[0];
|
|
27
|
+
while (block) {
|
|
28
|
+
const blockHeight = block.bottom - block.top;
|
|
29
|
+
if (!block.canSplit && blockHeight > pageHeight) {
|
|
30
|
+
block = { ...block, pageOverflow: true };
|
|
31
|
+
}
|
|
32
|
+
if (currentBlocks.length === 0) {
|
|
33
|
+
currentFrom = block.from;
|
|
34
|
+
}
|
|
35
|
+
if (block.forceBreak && currentBlocks.length > 0) {
|
|
36
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
37
|
+
currentBlocks = [];
|
|
38
|
+
usedHeight = 0;
|
|
39
|
+
currentFrom = block.from;
|
|
40
|
+
currentTo = block.from;
|
|
41
|
+
}
|
|
42
|
+
if (block.pageOverflow) {
|
|
43
|
+
if (currentBlocks.length > 0) {
|
|
44
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
45
|
+
currentBlocks = [];
|
|
46
|
+
usedHeight = 0;
|
|
47
|
+
}
|
|
48
|
+
currentBlocks = [block];
|
|
49
|
+
currentFrom = block.from;
|
|
50
|
+
currentTo = block.to;
|
|
51
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
52
|
+
currentBlocks = [];
|
|
53
|
+
usedHeight = 0;
|
|
54
|
+
i++;
|
|
55
|
+
block = blocks[i];
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
let blockSpan;
|
|
59
|
+
if (currentBlocks.length === 0) {
|
|
60
|
+
const marginTop = block.marginTop ?? 0;
|
|
61
|
+
const marginBottom = block.marginBottom ?? 0;
|
|
62
|
+
blockSpan = block.bottom - block.top + marginTop + marginBottom;
|
|
63
|
+
} else {
|
|
64
|
+
const prevBlock = currentBlocks[currentBlocks.length - 1];
|
|
65
|
+
const gap = Math.max(0, block.top - prevBlock.bottom);
|
|
66
|
+
blockSpan = gap + (block.bottom - block.top) + (block.marginBottom ?? 0);
|
|
67
|
+
}
|
|
68
|
+
const fitsInPage = usedHeight + blockSpan <= pageHeight;
|
|
69
|
+
let exceedsCharLimit = false;
|
|
70
|
+
if (maxCharsPerPage && fitsInPage) {
|
|
71
|
+
const charsWithBlock = currentTo - currentFrom + (block.to - block.from);
|
|
72
|
+
exceedsCharLimit = charsWithBlock > maxCharsPerPage;
|
|
73
|
+
}
|
|
74
|
+
if (fitsInPage && !exceedsCharLimit) {
|
|
75
|
+
currentBlocks.push(block);
|
|
76
|
+
currentTo = block.to;
|
|
77
|
+
usedHeight += blockSpan;
|
|
78
|
+
i++;
|
|
79
|
+
block = blocks[i];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (block.canSplit) {
|
|
83
|
+
const remaining = pageHeight - usedHeight;
|
|
84
|
+
const split = this.splitBlock(block, remaining);
|
|
85
|
+
if (split) {
|
|
86
|
+
currentBlocks.push(split.current);
|
|
87
|
+
currentTo = split.current.to;
|
|
88
|
+
pages.push({
|
|
89
|
+
from: currentFrom,
|
|
90
|
+
to: currentTo,
|
|
91
|
+
blocks: currentBlocks
|
|
92
|
+
});
|
|
93
|
+
currentBlocks = [];
|
|
94
|
+
usedHeight = 0;
|
|
95
|
+
block = split.next;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (currentBlocks.length > 0) {
|
|
100
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
101
|
+
currentBlocks = [];
|
|
102
|
+
usedHeight = 0;
|
|
103
|
+
}
|
|
104
|
+
currentBlocks.push(block);
|
|
105
|
+
currentFrom = block.from;
|
|
106
|
+
currentTo = block.to;
|
|
107
|
+
usedHeight = block.bottom - block.top + (block.marginTop ?? 0) + (block.marginBottom ?? 0);
|
|
108
|
+
i++;
|
|
109
|
+
block = blocks[i];
|
|
110
|
+
}
|
|
111
|
+
if (currentBlocks.length > 0) {
|
|
112
|
+
pages.push({ from: currentFrom, to: currentTo, blocks: currentBlocks });
|
|
113
|
+
}
|
|
114
|
+
return pages;
|
|
115
|
+
}
|
|
116
|
+
splitBlock(block, availableHeight) {
|
|
117
|
+
const totalChars = block.to - block.from;
|
|
118
|
+
if (totalChars <= 0 || availableHeight <= 0) return null;
|
|
119
|
+
if (totalChars < 10 || availableHeight < 20) return null;
|
|
120
|
+
let lo = 0;
|
|
121
|
+
let hi = totalChars;
|
|
122
|
+
let best = -1;
|
|
123
|
+
while (lo <= hi) {
|
|
124
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
125
|
+
const height = this.measureTextBlock(block, mid);
|
|
126
|
+
if (height === null) return null;
|
|
127
|
+
if (height <= availableHeight) {
|
|
128
|
+
best = mid;
|
|
129
|
+
lo = mid + 1;
|
|
130
|
+
} else {
|
|
131
|
+
hi = mid - 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (best <= 0 || best >= totalChars - 3) return null;
|
|
135
|
+
const currentHeight = this.measureTextBlock(block, best);
|
|
136
|
+
if (currentHeight === null) return null;
|
|
137
|
+
const splitOffset = block.from + best;
|
|
138
|
+
const current = {
|
|
139
|
+
...block,
|
|
140
|
+
to: splitOffset,
|
|
141
|
+
bottom: block.top + currentHeight,
|
|
142
|
+
splitOffset
|
|
143
|
+
};
|
|
144
|
+
const next = {
|
|
145
|
+
...block,
|
|
146
|
+
from: splitOffset,
|
|
147
|
+
top: block.top + currentHeight,
|
|
148
|
+
splitOffset: void 0
|
|
149
|
+
};
|
|
150
|
+
return { current, next };
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// src/PageLayout.ts
|
|
155
|
+
var PageLayout = class {
|
|
156
|
+
element;
|
|
157
|
+
editorView;
|
|
158
|
+
options;
|
|
159
|
+
breaker;
|
|
160
|
+
debounceMs;
|
|
161
|
+
shadowRoot = null;
|
|
162
|
+
resizeObserver = null;
|
|
163
|
+
debounceTimer = null;
|
|
164
|
+
lastDocHash = "";
|
|
165
|
+
lastShadowHash = "";
|
|
166
|
+
lastSize = { width: 0, height: 0 };
|
|
167
|
+
lastResult = null;
|
|
168
|
+
pendingResolvers = [];
|
|
169
|
+
constructor(editor, options, debounceMs = 150) {
|
|
170
|
+
if (editor instanceof HTMLElement) {
|
|
171
|
+
this.element = editor;
|
|
172
|
+
} else {
|
|
173
|
+
this.element = editor.dom;
|
|
174
|
+
this.editorView = editor.view;
|
|
175
|
+
}
|
|
176
|
+
this.options = {
|
|
177
|
+
pageHeight: options.pageHeight,
|
|
178
|
+
pageWidth: options.pageWidth ?? 0,
|
|
179
|
+
margins: options.margins ?? { top: 0, bottom: 0, left: 0, right: 0 },
|
|
180
|
+
maxCharsPerPage: options.maxCharsPerPage ?? 0
|
|
181
|
+
};
|
|
182
|
+
this.debounceMs = debounceMs;
|
|
183
|
+
this.breaker = new PageBreaker(
|
|
184
|
+
(block, offset) => this.measureTextOffset(block, offset)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
measureBlocks(root) {
|
|
188
|
+
const container = root ?? this.element;
|
|
189
|
+
const containerRect = container.getBoundingClientRect();
|
|
190
|
+
const blocks = [];
|
|
191
|
+
let fallbackPos = 0;
|
|
192
|
+
for (const child of Array.from(container.children)) {
|
|
193
|
+
if (!(child instanceof HTMLElement)) continue;
|
|
194
|
+
const nodeType = child.getAttribute("data-node-type") ?? child.tagName.toLowerCase();
|
|
195
|
+
let pmFrom = parseInt(child.getAttribute("data-from") ?? "-1", 10);
|
|
196
|
+
let pmTo = parseInt(child.getAttribute("data-to") ?? "-1", 10);
|
|
197
|
+
if ((pmFrom < 0 || pmTo < pmFrom) && this.editorView) {
|
|
198
|
+
try {
|
|
199
|
+
pmFrom = this.editorView.posAtDOM(child, 0);
|
|
200
|
+
pmTo = this.editorView.posAtDOM(child, child.childNodes.length);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (pmFrom < 0 || pmTo < pmFrom) {
|
|
205
|
+
pmFrom = fallbackPos;
|
|
206
|
+
const textLen = child.textContent?.length ?? 0;
|
|
207
|
+
pmTo = fallbackPos + textLen + 2;
|
|
208
|
+
fallbackPos = pmTo;
|
|
209
|
+
}
|
|
210
|
+
const rect = child.getBoundingClientRect();
|
|
211
|
+
const canSplit = this.isTextBlock(nodeType);
|
|
212
|
+
const forceBreak = child.getAttribute("data-page-break") === "true" || nodeType === "pageBreak";
|
|
213
|
+
const style = getComputedStyle(child);
|
|
214
|
+
const marginTop = parseFloat(style.marginTop) || 0;
|
|
215
|
+
const marginBottom = parseFloat(style.marginBottom) || 0;
|
|
216
|
+
blocks.push({
|
|
217
|
+
nodeType,
|
|
218
|
+
from: pmFrom,
|
|
219
|
+
to: pmTo,
|
|
220
|
+
top: rect.top - containerRect.top,
|
|
221
|
+
bottom: rect.bottom - containerRect.top,
|
|
222
|
+
rect,
|
|
223
|
+
canSplit,
|
|
224
|
+
forceBreak,
|
|
225
|
+
marginTop,
|
|
226
|
+
marginBottom
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return blocks;
|
|
230
|
+
}
|
|
231
|
+
layout(immediate = false) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
this.pendingResolvers.push(resolve);
|
|
234
|
+
if (immediate || this.debounceMs <= 0) {
|
|
235
|
+
this.runLayout();
|
|
236
|
+
} else {
|
|
237
|
+
this.scheduleLayout();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
observe(container) {
|
|
242
|
+
this.resizeObserver?.disconnect();
|
|
243
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
244
|
+
this.layout();
|
|
245
|
+
});
|
|
246
|
+
this.resizeObserver.observe(container);
|
|
247
|
+
}
|
|
248
|
+
destroy() {
|
|
249
|
+
if (this.debounceTimer) {
|
|
250
|
+
clearTimeout(this.debounceTimer);
|
|
251
|
+
this.debounceTimer = null;
|
|
252
|
+
}
|
|
253
|
+
this.pendingResolvers = [];
|
|
254
|
+
this.resizeObserver?.disconnect();
|
|
255
|
+
this.resizeObserver = null;
|
|
256
|
+
if (this.shadowRoot?.parentNode) {
|
|
257
|
+
this.shadowRoot.parentNode.removeChild(this.shadowRoot);
|
|
258
|
+
}
|
|
259
|
+
this.shadowRoot = null;
|
|
260
|
+
this.lastResult = null;
|
|
261
|
+
this.lastDocHash = "";
|
|
262
|
+
this.lastShadowHash = "";
|
|
263
|
+
}
|
|
264
|
+
scheduleLayout() {
|
|
265
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
266
|
+
this.debounceTimer = setTimeout(() => {
|
|
267
|
+
this.runLayout();
|
|
268
|
+
}, this.debounceMs);
|
|
269
|
+
}
|
|
270
|
+
runLayout() {
|
|
271
|
+
if (this.debounceTimer) {
|
|
272
|
+
clearTimeout(this.debounceTimer);
|
|
273
|
+
this.debounceTimer = null;
|
|
274
|
+
}
|
|
275
|
+
const docHash = this.computeDocHash();
|
|
276
|
+
const size = this.getContainerSize();
|
|
277
|
+
if (this.lastResult && this.lastDocHash === docHash && this.lastSize.width === size.width && this.lastSize.height === size.height) {
|
|
278
|
+
this.resolveAll(this.lastResult);
|
|
279
|
+
return this.lastResult;
|
|
280
|
+
}
|
|
281
|
+
this.prepareShadowLayout();
|
|
282
|
+
const root = this.element;
|
|
283
|
+
const blocks = this.measureBlocks(root);
|
|
284
|
+
const availableHeight = this.availableHeight();
|
|
285
|
+
const pages = this.breaker.computePages(blocks, availableHeight, this.options.maxCharsPerPage);
|
|
286
|
+
const result = { pages, invalidatedAt: Date.now() };
|
|
287
|
+
this.lastResult = result;
|
|
288
|
+
this.lastDocHash = docHash;
|
|
289
|
+
this.lastSize = size;
|
|
290
|
+
this.resolveAll(result);
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
resolveAll(result) {
|
|
294
|
+
for (const resolve of this.pendingResolvers) {
|
|
295
|
+
resolve(result);
|
|
296
|
+
}
|
|
297
|
+
this.pendingResolvers = [];
|
|
298
|
+
}
|
|
299
|
+
computeDocHash() {
|
|
300
|
+
let hash = "";
|
|
301
|
+
for (const child of Array.from(this.element.children)) {
|
|
302
|
+
if (child instanceof HTMLElement) {
|
|
303
|
+
hash += child.tagName + (child.getAttribute("data-from") ?? "") + (child.getAttribute("data-to") ?? "") + (child.getAttribute("data-node-type") ?? "") + (child.textContent ?? "");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return hash;
|
|
307
|
+
}
|
|
308
|
+
getContainerSize() {
|
|
309
|
+
const rect = this.element.getBoundingClientRect();
|
|
310
|
+
return { width: rect.width, height: rect.height };
|
|
311
|
+
}
|
|
312
|
+
availableHeight() {
|
|
313
|
+
const { top, bottom } = this.options.margins;
|
|
314
|
+
return this.options.pageHeight - top - bottom;
|
|
315
|
+
}
|
|
316
|
+
prepareShadowLayout() {
|
|
317
|
+
if (!this.shadowRoot) {
|
|
318
|
+
const shadow = document.createElement("div");
|
|
319
|
+
shadow.setAttribute("data-layout-shadow", "true");
|
|
320
|
+
shadow.style.position = "absolute";
|
|
321
|
+
shadow.style.top = "-9999px";
|
|
322
|
+
shadow.style.left = "-9999px";
|
|
323
|
+
shadow.style.visibility = "hidden";
|
|
324
|
+
shadow.style.pointerEvents = "none";
|
|
325
|
+
shadow.style.overflow = "hidden";
|
|
326
|
+
if (this.options.pageWidth > 0) {
|
|
327
|
+
shadow.style.width = `${this.options.pageWidth}px`;
|
|
328
|
+
}
|
|
329
|
+
shadow.className = this.element.className || "tiptap ProseMirror";
|
|
330
|
+
document.body.appendChild(shadow);
|
|
331
|
+
this.shadowRoot = shadow;
|
|
332
|
+
}
|
|
333
|
+
const currentHash = this.computeDocHash();
|
|
334
|
+
if (this.lastShadowHash !== currentHash) {
|
|
335
|
+
this.shadowRoot.innerHTML = "";
|
|
336
|
+
for (const child of Array.from(this.element.children)) {
|
|
337
|
+
this.shadowRoot.appendChild(child.cloneNode(true));
|
|
338
|
+
}
|
|
339
|
+
const elements = Array.from(this.shadowRoot.getElementsByTagName("*"));
|
|
340
|
+
elements.forEach((el) => {
|
|
341
|
+
el.style.removeProperty("margin-top");
|
|
342
|
+
});
|
|
343
|
+
this.lastShadowHash = currentHash;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
isTextBlock(nodeType) {
|
|
347
|
+
return ["paragraph", "heading", "list", "blockquote", "codeBlock"].includes(
|
|
348
|
+
nodeType
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
measureTextOffset(block, offset) {
|
|
352
|
+
if (offset <= 0) return 0;
|
|
353
|
+
const root = this.shadowRoot ?? this.element;
|
|
354
|
+
const el = root.querySelector(`[data-from="${block.from}"]`);
|
|
355
|
+
if (!el) return null;
|
|
356
|
+
const start = this.findTextNodeAtOffset(el, 0);
|
|
357
|
+
const end = this.findTextNodeAtOffset(el, offset);
|
|
358
|
+
if (!start || !end) return null;
|
|
359
|
+
const range = document.createRange();
|
|
360
|
+
range.setStart(start.node, start.offset);
|
|
361
|
+
range.setEnd(end.node, end.offset);
|
|
362
|
+
const rects = range.getClientRects();
|
|
363
|
+
if (!rects.length) return null;
|
|
364
|
+
return rects[rects.length - 1].bottom - rects[0].top;
|
|
365
|
+
}
|
|
366
|
+
findTextNodeAtOffset(root, offset) {
|
|
367
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
368
|
+
let remaining = offset;
|
|
369
|
+
let node = walker.nextNode();
|
|
370
|
+
while (node) {
|
|
371
|
+
const length = node.textContent?.length ?? 0;
|
|
372
|
+
if (remaining <= length) {
|
|
373
|
+
return { node, offset: remaining };
|
|
374
|
+
}
|
|
375
|
+
remaining -= length;
|
|
376
|
+
node = walker.nextNode();
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
export {
|
|
382
|
+
PAGE_SIZES,
|
|
383
|
+
PageBreaker,
|
|
384
|
+
PageLayout,
|
|
385
|
+
getPageSize
|
|
386
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kedataindo/docflow-layout-engine",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"registry": "https://registry.npmjs.org",
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@tiptap/pm": "^2.11.0",
|
|
24
|
+
"@kedataindo/docflow-core": "0.0.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"happy-dom": "^20.10.6",
|
|
28
|
+
"tsup": "^8.3.0",
|
|
29
|
+
"typescript": "^5.6.3",
|
|
30
|
+
"vitest": "^2.1.3"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test:unit": "vitest run"
|
|
36
|
+
}
|
|
37
|
+
}
|