@kedataindo/docflow-layout-engine 0.0.1 → 0.0.2

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 CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  PAGE_SIZES: () => PAGE_SIZES,
24
24
  PageBreaker: () => PageBreaker,
25
25
  PageLayout: () => PageLayout,
26
+ VirtualPageOverlay: () => VirtualPageOverlay,
26
27
  getPageSize: () => getPageSize
27
28
  });
28
29
  module.exports = __toCommonJS(index_exports);
@@ -191,6 +192,7 @@ var PageLayout = class {
191
192
  resizeObserver = null;
192
193
  debounceTimer = null;
193
194
  lastDocHash = "";
195
+ lastBlockHashes = /* @__PURE__ */ new Map();
194
196
  lastShadowHash = "";
195
197
  lastSize = { width: 0, height: 0 };
196
198
  lastResult = null;
@@ -288,6 +290,7 @@ var PageLayout = class {
288
290
  this.shadowRoot = null;
289
291
  this.lastResult = null;
290
292
  this.lastDocHash = "";
293
+ this.lastBlockHashes = /* @__PURE__ */ new Map();
291
294
  this.lastShadowHash = "";
292
295
  }
293
296
  scheduleLayout() {
@@ -301,20 +304,56 @@ var PageLayout = class {
301
304
  clearTimeout(this.debounceTimer);
302
305
  this.debounceTimer = null;
303
306
  }
304
- const docHash = this.computeDocHash();
305
307
  const size = this.getContainerSize();
306
- if (this.lastResult && this.lastDocHash === docHash && this.lastSize.width === size.width && this.lastSize.height === size.height) {
308
+ const containerChanged = this.lastSize.width !== size.width || this.lastSize.height !== size.height;
309
+ const docHash = this.computeDocHash();
310
+ if (this.lastResult && !containerChanged && this.lastDocHash === docHash) {
307
311
  this.resolveAll(this.lastResult);
308
312
  return this.lastResult;
309
313
  }
310
314
  this.prepareShadowLayout();
311
- const root = this.element;
312
- const blocks = this.measureBlocks(root);
315
+ const blocks = this.measureBlocks(this.element);
313
316
  const availableHeight = this.availableHeight();
314
- const pages = this.breaker.computePages(blocks, availableHeight, this.options.maxCharsPerPage);
317
+ const newBlockHashes = this.computeBlockHashes();
318
+ const allPages = this.breaker.computePages(blocks, availableHeight, this.options.maxCharsPerPage);
319
+ let pages;
320
+ if (this.lastResult && !containerChanged && this.lastBlockHashes.size > 0) {
321
+ let firstChangedBlockIdx = -1;
322
+ for (let i = 0; i < blocks.length; i++) {
323
+ if (newBlockHashes.get(i) !== this.lastBlockHashes.get(i)) {
324
+ firstChangedBlockIdx = i;
325
+ break;
326
+ }
327
+ }
328
+ if (firstChangedBlockIdx === -1 && newBlockHashes.size !== this.lastBlockHashes.size) {
329
+ firstChangedBlockIdx = Math.min(newBlockHashes.size, this.lastBlockHashes.size);
330
+ }
331
+ if (firstChangedBlockIdx >= 0) {
332
+ const firstChangedFrom = blocks[firstChangedBlockIdx]?.from ?? 0;
333
+ const oldPages = this.lastResult.pages;
334
+ let affectedPageIdx = oldPages.length;
335
+ for (let p = 0; p < oldPages.length; p++) {
336
+ const lastBlock = oldPages[p].blocks[oldPages[p].blocks.length - 1];
337
+ if (lastBlock && lastBlock.to >= firstChangedFrom) {
338
+ affectedPageIdx = p;
339
+ break;
340
+ }
341
+ }
342
+ if (affectedPageIdx > 0 && affectedPageIdx < allPages.length && oldPages[affectedPageIdx - 1]?.to === allPages[affectedPageIdx - 1]?.to) {
343
+ pages = [...oldPages.slice(0, affectedPageIdx), ...allPages.slice(affectedPageIdx)];
344
+ } else {
345
+ pages = allPages;
346
+ }
347
+ } else {
348
+ pages = allPages;
349
+ }
350
+ } else {
351
+ pages = allPages;
352
+ }
315
353
  const result = { pages, invalidatedAt: Date.now() };
316
354
  this.lastResult = result;
317
355
  this.lastDocHash = docHash;
356
+ this.lastBlockHashes = newBlockHashes;
318
357
  this.lastSize = size;
319
358
  this.resolveAll(result);
320
359
  return result;
@@ -334,6 +373,18 @@ var PageLayout = class {
334
373
  }
335
374
  return hash;
336
375
  }
376
+ /** Compute per-block hashes for incremental recalculation. */
377
+ computeBlockHashes() {
378
+ const map = /* @__PURE__ */ new Map();
379
+ let idx = 0;
380
+ for (const child of Array.from(this.element.children)) {
381
+ if (child instanceof HTMLElement) {
382
+ map.set(idx, child.tagName + (child.getAttribute("data-from") ?? "") + (child.getAttribute("data-to") ?? "") + (child.getAttribute("data-node-type") ?? "") + (child.textContent ?? ""));
383
+ }
384
+ idx++;
385
+ }
386
+ return map;
387
+ }
337
388
  getContainerSize() {
338
389
  const rect = this.element.getBoundingClientRect();
339
390
  return { width: rect.width, height: rect.height };
@@ -407,10 +458,136 @@ var PageLayout = class {
407
458
  return null;
408
459
  }
409
460
  };
461
+
462
+ // src/VirtualPageOverlay.ts
463
+ var VirtualPageOverlay = class {
464
+ pageLayout;
465
+ config;
466
+ scrollEl;
467
+ editorEl;
468
+ bufferPages;
469
+ lastResult = null;
470
+ lastVisible = [];
471
+ lastScrollTop = -1;
472
+ scrollHandler = null;
473
+ rafId = 0;
474
+ constructor(editor, config, scrollEl, bufferPages = 2) {
475
+ const ps = typeof config.pageSize === "string" ? getPageSize(config.pageSize) ?? { id: "a4", name: "A4", pageWidth: 794, pageHeight: 1123 } : config.pageSize;
476
+ this.config = {
477
+ pageSize: ps,
478
+ margins: config.margins ?? { top: 20, bottom: 20, left: 50, right: 50 },
479
+ pageGap: config.pageGap ?? 50,
480
+ headerLeft: config.headerLeft ?? "",
481
+ headerRight: config.headerRight ?? "",
482
+ footerLeft: config.footerLeft ?? "",
483
+ footerRight: config.footerRight ?? ""
484
+ };
485
+ this.scrollEl = scrollEl;
486
+ this.bufferPages = bufferPages;
487
+ this.editorEl = editor instanceof HTMLElement ? editor : editor.dom;
488
+ this.pageLayout = new PageLayout(editor, {
489
+ pageHeight: ps.pageHeight,
490
+ pageWidth: ps.pageWidth,
491
+ margins: this.config.margins
492
+ }, 150);
493
+ }
494
+ /** Run or re-run layout measurement. Resolves with current page data. */
495
+ async layout() {
496
+ const result = await this.pageLayout.layout();
497
+ this.lastResult = result;
498
+ return this.computeData();
499
+ }
500
+ /** Start observing scroll events to track visible pages. */
501
+ observe() {
502
+ if (this.scrollHandler) return;
503
+ this.scrollHandler = () => this.onScroll();
504
+ this.scrollEl.addEventListener("scroll", this.scrollHandler, { passive: true });
505
+ this.updateVisible();
506
+ }
507
+ /** Stop observing scroll events. */
508
+ disconnect() {
509
+ if (this.scrollHandler) {
510
+ this.scrollEl.removeEventListener("scroll", this.scrollHandler);
511
+ this.scrollHandler = null;
512
+ }
513
+ if (this.rafId) {
514
+ cancelAnimationFrame(this.rafId);
515
+ this.rafId = 0;
516
+ }
517
+ this.pageLayout.destroy();
518
+ }
519
+ /** Get current page overlay data synchronously (cached). */
520
+ getData() {
521
+ return {
522
+ totalPages: this.lastResult?.pages.length ?? 1,
523
+ visiblePages: this.lastVisible,
524
+ config: this.config
525
+ };
526
+ }
527
+ /** Update configuration (page size, margins, etc.) — triggers re-layout. */
528
+ async updateConfig(config) {
529
+ if (config.pageSize) {
530
+ const ps = typeof config.pageSize === "string" ? getPageSize(config.pageSize) ?? this.config.pageSize : config.pageSize;
531
+ this.config.pageSize = ps;
532
+ this.pageLayout.destroy();
533
+ this.pageLayout = new PageLayout(this.editorEl, {
534
+ pageHeight: ps.pageHeight,
535
+ pageWidth: ps.pageWidth,
536
+ margins: this.config.margins
537
+ }, 150);
538
+ }
539
+ if (config.margins) {
540
+ this.config.margins = { ...this.config.margins, ...config.margins };
541
+ }
542
+ if (config.pageGap !== void 0) this.config.pageGap = config.pageGap;
543
+ if (config.headerLeft !== void 0) this.config.headerLeft = config.headerLeft;
544
+ if (config.headerRight !== void 0) this.config.headerRight = config.headerRight;
545
+ if (config.footerLeft !== void 0) this.config.footerLeft = config.footerLeft;
546
+ if (config.footerRight !== void 0) this.config.footerRight = config.footerRight;
547
+ return this.layout();
548
+ }
549
+ // ── Private ────────────────────────────────────────────────────────────
550
+ onScroll() {
551
+ if (this.rafId) return;
552
+ this.rafId = requestAnimationFrame(() => {
553
+ this.rafId = 0;
554
+ this.updateVisible();
555
+ });
556
+ }
557
+ async updateVisible() {
558
+ const st = this.scrollEl.scrollTop;
559
+ if (st === this.lastScrollTop) return;
560
+ this.lastScrollTop = st;
561
+ if (!this.lastResult) {
562
+ await this.layout();
563
+ }
564
+ this.computeData();
565
+ }
566
+ computeData() {
567
+ const pages = this.lastResult?.pages ?? [];
568
+ const totalPages = pages.length || 1;
569
+ const ph = this.config.pageSize.pageHeight + this.config.pageGap;
570
+ const viewportTop = this.scrollEl.scrollTop;
571
+ const viewportBottom = viewportTop + this.scrollEl.clientHeight;
572
+ const visible = [];
573
+ const startPage = Math.max(0, Math.floor(viewportTop / ph) - this.bufferPages);
574
+ const endPage = Math.min(totalPages, Math.ceil(viewportBottom / ph) + this.bufferPages);
575
+ for (let i = startPage; i < endPage; i++) {
576
+ visible.push({
577
+ index: i,
578
+ top: i * ph,
579
+ height: ph
580
+ });
581
+ }
582
+ this.lastVisible = visible;
583
+ return { totalPages, visiblePages: visible, config: this.config };
584
+ }
585
+ };
410
586
  // Annotate the CommonJS export names for ESM import in node:
411
587
  0 && (module.exports = {
412
588
  PAGE_SIZES,
413
589
  PageBreaker,
414
590
  PageLayout,
591
+ VirtualPageOverlay,
415
592
  getPageSize
416
593
  });
package/dist/index.d.cts CHANGED
@@ -78,6 +78,7 @@ declare class PageLayout {
78
78
  private resizeObserver;
79
79
  private debounceTimer;
80
80
  private lastDocHash;
81
+ private lastBlockHashes;
81
82
  private lastShadowHash;
82
83
  private lastSize;
83
84
  private lastResult;
@@ -91,6 +92,8 @@ declare class PageLayout {
91
92
  private runLayout;
92
93
  private resolveAll;
93
94
  private computeDocHash;
95
+ /** Compute per-block hashes for incremental recalculation. */
96
+ private computeBlockHashes;
94
97
  private getContainerSize;
95
98
  private availableHeight;
96
99
  private prepareShadowLayout;
@@ -99,4 +102,69 @@ declare class PageLayout {
99
102
  private findTextNodeAtOffset;
100
103
  }
101
104
 
102
- export { type BlockInfo, type EditorLike, type LayoutOptions, type LayoutResult, type MeasureTextBlock, PAGE_SIZES, type Page, PageBreaker, PageLayout, type PageSize, type SplitResult, getPageSize };
105
+ interface PageOverlayConfig {
106
+ pageSize: PageSize | string;
107
+ margins?: {
108
+ top: number;
109
+ bottom: number;
110
+ left: number;
111
+ right: number;
112
+ };
113
+ pageGap?: number;
114
+ headerLeft?: string;
115
+ headerRight?: string;
116
+ footerLeft?: string;
117
+ footerRight?: string;
118
+ }
119
+ interface VisiblePage {
120
+ index: number;
121
+ top: number;
122
+ height: number;
123
+ }
124
+ interface PageOverlayData {
125
+ totalPages: number;
126
+ visiblePages: VisiblePage[];
127
+ config: Required<PageOverlayConfig> & {
128
+ pageSize: PageSize;
129
+ };
130
+ }
131
+ /**
132
+ * Manages virtualized page overlays for a ProseMirror editor.
133
+ *
134
+ * Instead of creating DOM nodes for ALL pages (like tiptap-pagination-plus),
135
+ * this only tracks which pages are currently visible in the viewport and
136
+ * provides the data needed to render a lightweight overlay layer.
137
+ *
138
+ * Usage:
139
+ * const overlay = new VirtualPageOverlay(editor, config, scrollEl, bufferPages?)
140
+ * overlay.layout().then(data => render(data))
141
+ * // On scroll: overlay.updateVisible().then(data => render(data))
142
+ */
143
+ declare class VirtualPageOverlay {
144
+ pageLayout: PageLayout;
145
+ private config;
146
+ private scrollEl;
147
+ private editorEl;
148
+ private bufferPages;
149
+ private lastResult;
150
+ private lastVisible;
151
+ private lastScrollTop;
152
+ private scrollHandler;
153
+ private rafId;
154
+ constructor(editor: EditorLike | HTMLElement, config: PageOverlayConfig, scrollEl: HTMLElement, bufferPages?: number);
155
+ /** Run or re-run layout measurement. Resolves with current page data. */
156
+ layout(): Promise<PageOverlayData>;
157
+ /** Start observing scroll events to track visible pages. */
158
+ observe(): void;
159
+ /** Stop observing scroll events. */
160
+ disconnect(): void;
161
+ /** Get current page overlay data synchronously (cached). */
162
+ getData(): PageOverlayData;
163
+ /** Update configuration (page size, margins, etc.) — triggers re-layout. */
164
+ updateConfig(config: Partial<PageOverlayConfig>): Promise<PageOverlayData>;
165
+ private onScroll;
166
+ private updateVisible;
167
+ private computeData;
168
+ }
169
+
170
+ export { type BlockInfo, type EditorLike, type LayoutOptions, type LayoutResult, type MeasureTextBlock, PAGE_SIZES, type Page, PageBreaker, PageLayout, type PageOverlayConfig, type PageOverlayData, type PageSize, type SplitResult, VirtualPageOverlay, type VisiblePage, getPageSize };
package/dist/index.d.ts CHANGED
@@ -78,6 +78,7 @@ declare class PageLayout {
78
78
  private resizeObserver;
79
79
  private debounceTimer;
80
80
  private lastDocHash;
81
+ private lastBlockHashes;
81
82
  private lastShadowHash;
82
83
  private lastSize;
83
84
  private lastResult;
@@ -91,6 +92,8 @@ declare class PageLayout {
91
92
  private runLayout;
92
93
  private resolveAll;
93
94
  private computeDocHash;
95
+ /** Compute per-block hashes for incremental recalculation. */
96
+ private computeBlockHashes;
94
97
  private getContainerSize;
95
98
  private availableHeight;
96
99
  private prepareShadowLayout;
@@ -99,4 +102,69 @@ declare class PageLayout {
99
102
  private findTextNodeAtOffset;
100
103
  }
101
104
 
102
- export { type BlockInfo, type EditorLike, type LayoutOptions, type LayoutResult, type MeasureTextBlock, PAGE_SIZES, type Page, PageBreaker, PageLayout, type PageSize, type SplitResult, getPageSize };
105
+ interface PageOverlayConfig {
106
+ pageSize: PageSize | string;
107
+ margins?: {
108
+ top: number;
109
+ bottom: number;
110
+ left: number;
111
+ right: number;
112
+ };
113
+ pageGap?: number;
114
+ headerLeft?: string;
115
+ headerRight?: string;
116
+ footerLeft?: string;
117
+ footerRight?: string;
118
+ }
119
+ interface VisiblePage {
120
+ index: number;
121
+ top: number;
122
+ height: number;
123
+ }
124
+ interface PageOverlayData {
125
+ totalPages: number;
126
+ visiblePages: VisiblePage[];
127
+ config: Required<PageOverlayConfig> & {
128
+ pageSize: PageSize;
129
+ };
130
+ }
131
+ /**
132
+ * Manages virtualized page overlays for a ProseMirror editor.
133
+ *
134
+ * Instead of creating DOM nodes for ALL pages (like tiptap-pagination-plus),
135
+ * this only tracks which pages are currently visible in the viewport and
136
+ * provides the data needed to render a lightweight overlay layer.
137
+ *
138
+ * Usage:
139
+ * const overlay = new VirtualPageOverlay(editor, config, scrollEl, bufferPages?)
140
+ * overlay.layout().then(data => render(data))
141
+ * // On scroll: overlay.updateVisible().then(data => render(data))
142
+ */
143
+ declare class VirtualPageOverlay {
144
+ pageLayout: PageLayout;
145
+ private config;
146
+ private scrollEl;
147
+ private editorEl;
148
+ private bufferPages;
149
+ private lastResult;
150
+ private lastVisible;
151
+ private lastScrollTop;
152
+ private scrollHandler;
153
+ private rafId;
154
+ constructor(editor: EditorLike | HTMLElement, config: PageOverlayConfig, scrollEl: HTMLElement, bufferPages?: number);
155
+ /** Run or re-run layout measurement. Resolves with current page data. */
156
+ layout(): Promise<PageOverlayData>;
157
+ /** Start observing scroll events to track visible pages. */
158
+ observe(): void;
159
+ /** Stop observing scroll events. */
160
+ disconnect(): void;
161
+ /** Get current page overlay data synchronously (cached). */
162
+ getData(): PageOverlayData;
163
+ /** Update configuration (page size, margins, etc.) — triggers re-layout. */
164
+ updateConfig(config: Partial<PageOverlayConfig>): Promise<PageOverlayData>;
165
+ private onScroll;
166
+ private updateVisible;
167
+ private computeData;
168
+ }
169
+
170
+ export { type BlockInfo, type EditorLike, type LayoutOptions, type LayoutResult, type MeasureTextBlock, PAGE_SIZES, type Page, PageBreaker, PageLayout, type PageOverlayConfig, type PageOverlayData, type PageSize, type SplitResult, VirtualPageOverlay, type VisiblePage, getPageSize };
package/dist/index.js CHANGED
@@ -162,6 +162,7 @@ var PageLayout = class {
162
162
  resizeObserver = null;
163
163
  debounceTimer = null;
164
164
  lastDocHash = "";
165
+ lastBlockHashes = /* @__PURE__ */ new Map();
165
166
  lastShadowHash = "";
166
167
  lastSize = { width: 0, height: 0 };
167
168
  lastResult = null;
@@ -259,6 +260,7 @@ var PageLayout = class {
259
260
  this.shadowRoot = null;
260
261
  this.lastResult = null;
261
262
  this.lastDocHash = "";
263
+ this.lastBlockHashes = /* @__PURE__ */ new Map();
262
264
  this.lastShadowHash = "";
263
265
  }
264
266
  scheduleLayout() {
@@ -272,20 +274,56 @@ var PageLayout = class {
272
274
  clearTimeout(this.debounceTimer);
273
275
  this.debounceTimer = null;
274
276
  }
275
- const docHash = this.computeDocHash();
276
277
  const size = this.getContainerSize();
277
- if (this.lastResult && this.lastDocHash === docHash && this.lastSize.width === size.width && this.lastSize.height === size.height) {
278
+ const containerChanged = this.lastSize.width !== size.width || this.lastSize.height !== size.height;
279
+ const docHash = this.computeDocHash();
280
+ if (this.lastResult && !containerChanged && this.lastDocHash === docHash) {
278
281
  this.resolveAll(this.lastResult);
279
282
  return this.lastResult;
280
283
  }
281
284
  this.prepareShadowLayout();
282
- const root = this.element;
283
- const blocks = this.measureBlocks(root);
285
+ const blocks = this.measureBlocks(this.element);
284
286
  const availableHeight = this.availableHeight();
285
- const pages = this.breaker.computePages(blocks, availableHeight, this.options.maxCharsPerPage);
287
+ const newBlockHashes = this.computeBlockHashes();
288
+ const allPages = this.breaker.computePages(blocks, availableHeight, this.options.maxCharsPerPage);
289
+ let pages;
290
+ if (this.lastResult && !containerChanged && this.lastBlockHashes.size > 0) {
291
+ let firstChangedBlockIdx = -1;
292
+ for (let i = 0; i < blocks.length; i++) {
293
+ if (newBlockHashes.get(i) !== this.lastBlockHashes.get(i)) {
294
+ firstChangedBlockIdx = i;
295
+ break;
296
+ }
297
+ }
298
+ if (firstChangedBlockIdx === -1 && newBlockHashes.size !== this.lastBlockHashes.size) {
299
+ firstChangedBlockIdx = Math.min(newBlockHashes.size, this.lastBlockHashes.size);
300
+ }
301
+ if (firstChangedBlockIdx >= 0) {
302
+ const firstChangedFrom = blocks[firstChangedBlockIdx]?.from ?? 0;
303
+ const oldPages = this.lastResult.pages;
304
+ let affectedPageIdx = oldPages.length;
305
+ for (let p = 0; p < oldPages.length; p++) {
306
+ const lastBlock = oldPages[p].blocks[oldPages[p].blocks.length - 1];
307
+ if (lastBlock && lastBlock.to >= firstChangedFrom) {
308
+ affectedPageIdx = p;
309
+ break;
310
+ }
311
+ }
312
+ if (affectedPageIdx > 0 && affectedPageIdx < allPages.length && oldPages[affectedPageIdx - 1]?.to === allPages[affectedPageIdx - 1]?.to) {
313
+ pages = [...oldPages.slice(0, affectedPageIdx), ...allPages.slice(affectedPageIdx)];
314
+ } else {
315
+ pages = allPages;
316
+ }
317
+ } else {
318
+ pages = allPages;
319
+ }
320
+ } else {
321
+ pages = allPages;
322
+ }
286
323
  const result = { pages, invalidatedAt: Date.now() };
287
324
  this.lastResult = result;
288
325
  this.lastDocHash = docHash;
326
+ this.lastBlockHashes = newBlockHashes;
289
327
  this.lastSize = size;
290
328
  this.resolveAll(result);
291
329
  return result;
@@ -305,6 +343,18 @@ var PageLayout = class {
305
343
  }
306
344
  return hash;
307
345
  }
346
+ /** Compute per-block hashes for incremental recalculation. */
347
+ computeBlockHashes() {
348
+ const map = /* @__PURE__ */ new Map();
349
+ let idx = 0;
350
+ for (const child of Array.from(this.element.children)) {
351
+ if (child instanceof HTMLElement) {
352
+ map.set(idx, child.tagName + (child.getAttribute("data-from") ?? "") + (child.getAttribute("data-to") ?? "") + (child.getAttribute("data-node-type") ?? "") + (child.textContent ?? ""));
353
+ }
354
+ idx++;
355
+ }
356
+ return map;
357
+ }
308
358
  getContainerSize() {
309
359
  const rect = this.element.getBoundingClientRect();
310
360
  return { width: rect.width, height: rect.height };
@@ -378,9 +428,135 @@ var PageLayout = class {
378
428
  return null;
379
429
  }
380
430
  };
431
+
432
+ // src/VirtualPageOverlay.ts
433
+ var VirtualPageOverlay = class {
434
+ pageLayout;
435
+ config;
436
+ scrollEl;
437
+ editorEl;
438
+ bufferPages;
439
+ lastResult = null;
440
+ lastVisible = [];
441
+ lastScrollTop = -1;
442
+ scrollHandler = null;
443
+ rafId = 0;
444
+ constructor(editor, config, scrollEl, bufferPages = 2) {
445
+ const ps = typeof config.pageSize === "string" ? getPageSize(config.pageSize) ?? { id: "a4", name: "A4", pageWidth: 794, pageHeight: 1123 } : config.pageSize;
446
+ this.config = {
447
+ pageSize: ps,
448
+ margins: config.margins ?? { top: 20, bottom: 20, left: 50, right: 50 },
449
+ pageGap: config.pageGap ?? 50,
450
+ headerLeft: config.headerLeft ?? "",
451
+ headerRight: config.headerRight ?? "",
452
+ footerLeft: config.footerLeft ?? "",
453
+ footerRight: config.footerRight ?? ""
454
+ };
455
+ this.scrollEl = scrollEl;
456
+ this.bufferPages = bufferPages;
457
+ this.editorEl = editor instanceof HTMLElement ? editor : editor.dom;
458
+ this.pageLayout = new PageLayout(editor, {
459
+ pageHeight: ps.pageHeight,
460
+ pageWidth: ps.pageWidth,
461
+ margins: this.config.margins
462
+ }, 150);
463
+ }
464
+ /** Run or re-run layout measurement. Resolves with current page data. */
465
+ async layout() {
466
+ const result = await this.pageLayout.layout();
467
+ this.lastResult = result;
468
+ return this.computeData();
469
+ }
470
+ /** Start observing scroll events to track visible pages. */
471
+ observe() {
472
+ if (this.scrollHandler) return;
473
+ this.scrollHandler = () => this.onScroll();
474
+ this.scrollEl.addEventListener("scroll", this.scrollHandler, { passive: true });
475
+ this.updateVisible();
476
+ }
477
+ /** Stop observing scroll events. */
478
+ disconnect() {
479
+ if (this.scrollHandler) {
480
+ this.scrollEl.removeEventListener("scroll", this.scrollHandler);
481
+ this.scrollHandler = null;
482
+ }
483
+ if (this.rafId) {
484
+ cancelAnimationFrame(this.rafId);
485
+ this.rafId = 0;
486
+ }
487
+ this.pageLayout.destroy();
488
+ }
489
+ /** Get current page overlay data synchronously (cached). */
490
+ getData() {
491
+ return {
492
+ totalPages: this.lastResult?.pages.length ?? 1,
493
+ visiblePages: this.lastVisible,
494
+ config: this.config
495
+ };
496
+ }
497
+ /** Update configuration (page size, margins, etc.) — triggers re-layout. */
498
+ async updateConfig(config) {
499
+ if (config.pageSize) {
500
+ const ps = typeof config.pageSize === "string" ? getPageSize(config.pageSize) ?? this.config.pageSize : config.pageSize;
501
+ this.config.pageSize = ps;
502
+ this.pageLayout.destroy();
503
+ this.pageLayout = new PageLayout(this.editorEl, {
504
+ pageHeight: ps.pageHeight,
505
+ pageWidth: ps.pageWidth,
506
+ margins: this.config.margins
507
+ }, 150);
508
+ }
509
+ if (config.margins) {
510
+ this.config.margins = { ...this.config.margins, ...config.margins };
511
+ }
512
+ if (config.pageGap !== void 0) this.config.pageGap = config.pageGap;
513
+ if (config.headerLeft !== void 0) this.config.headerLeft = config.headerLeft;
514
+ if (config.headerRight !== void 0) this.config.headerRight = config.headerRight;
515
+ if (config.footerLeft !== void 0) this.config.footerLeft = config.footerLeft;
516
+ if (config.footerRight !== void 0) this.config.footerRight = config.footerRight;
517
+ return this.layout();
518
+ }
519
+ // ── Private ────────────────────────────────────────────────────────────
520
+ onScroll() {
521
+ if (this.rafId) return;
522
+ this.rafId = requestAnimationFrame(() => {
523
+ this.rafId = 0;
524
+ this.updateVisible();
525
+ });
526
+ }
527
+ async updateVisible() {
528
+ const st = this.scrollEl.scrollTop;
529
+ if (st === this.lastScrollTop) return;
530
+ this.lastScrollTop = st;
531
+ if (!this.lastResult) {
532
+ await this.layout();
533
+ }
534
+ this.computeData();
535
+ }
536
+ computeData() {
537
+ const pages = this.lastResult?.pages ?? [];
538
+ const totalPages = pages.length || 1;
539
+ const ph = this.config.pageSize.pageHeight + this.config.pageGap;
540
+ const viewportTop = this.scrollEl.scrollTop;
541
+ const viewportBottom = viewportTop + this.scrollEl.clientHeight;
542
+ const visible = [];
543
+ const startPage = Math.max(0, Math.floor(viewportTop / ph) - this.bufferPages);
544
+ const endPage = Math.min(totalPages, Math.ceil(viewportBottom / ph) + this.bufferPages);
545
+ for (let i = startPage; i < endPage; i++) {
546
+ visible.push({
547
+ index: i,
548
+ top: i * ph,
549
+ height: ph
550
+ });
551
+ }
552
+ this.lastVisible = visible;
553
+ return { totalPages, visiblePages: visible, config: this.config };
554
+ }
555
+ };
381
556
  export {
382
557
  PAGE_SIZES,
383
558
  PageBreaker,
384
559
  PageLayout,
560
+ VirtualPageOverlay,
385
561
  getPageSize
386
562
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@kedataindo/docflow-layout-engine",
3
- "version": "0.0.1",
3
+ "license": "UNLICENSED",
4
+ "version": "0.0.2",
4
5
  "type": "module",
5
6
  "main": "./dist/index.js",
6
7
  "module": "./dist/index.js",
@@ -19,19 +20,19 @@
19
20
  "files": [
20
21
  "dist"
21
22
  ],
23
+ "scripts": {
24
+ "build": "tsup src/index.ts --format esm,cjs --dts",
25
+ "typecheck": "tsc --noEmit",
26
+ "test:unit": "vitest run"
27
+ },
22
28
  "dependencies": {
23
29
  "@tiptap/pm": "^2.11.0",
24
- "@kedataindo/docflow-core": "0.0.2"
30
+ "@kedataindo/docflow-core": "0.0.3"
25
31
  },
26
32
  "devDependencies": {
27
33
  "happy-dom": "^20.10.6",
28
34
  "tsup": "^8.3.0",
29
35
  "typescript": "^5.6.3",
30
36
  "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
  }
37
- }
38
+ }