@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 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
+ });
@@ -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 };
@@ -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
+ }