import Layout from "./layout.js";
import EventEmitter from "event-emitter";
/**
* Represents a single page in a paginated document.
* Handles rendering, layout, overflow detection, and DOM interactions.
*
* @class
*/
class Page {
/**
* Creates an instance of Page.
*
* @param {HTMLElement} pagesArea - The container element for all pages.
* @param {HTMLTemplateElement} pageTemplate - Template for creating new pages.
* @param {boolean} blank - Indicates if this is a blank page.
* @param {Object} hooks - Hook functions for custom behavior.
* @param {Object} options - Additional layout or rendering options.
*/
constructor(pagesArea, pageTemplate, blank, hooks, options) {
this.pagesArea = pagesArea;
this.pageTemplate = pageTemplate;
this.blank = blank;
this.width = undefined;
this.height = undefined;
this.hooks = hooks;
this.settings = options || {};
}
/**
* Creates a new page element from the template and inserts it into the DOM.
*
* @param {HTMLTemplateElement} template - The template to use for page creation.
* @param {HTMLElement} [after] - Optional reference element to insert after.
* @returns {HTMLElement} The newly created page element.
*/
create(template, after) {
let clone = document.importNode(this.pageTemplate.content, true);
let page, index;
if (after) {
this.pagesArea.insertBefore(clone, after.nextElementSibling);
index = Array.prototype.indexOf.call(
this.pagesArea.children,
after.nextElementSibling,
);
page = this.pagesArea.children[index];
} else {
this.pagesArea.appendChild(clone);
page = this.pagesArea.lastChild;
}
let pagebox = page.querySelector(".pagedjs_pagebox");
let area = page.querySelector(".pagedjs_page_content");
let footnotesArea = page.querySelector(".pagedjs_footnote_area");
let size = area.getBoundingClientRect();
area.style.columnWidth = Math.round(size.width) + "px";
area.style.columnGap =
"calc(var(--pagedjs-margin-right) + var(--pagedjs-margin-left) + var(--pagedjs-bleed-right) + var(--pagedjs-bleed-left) + var(--pagedjs-column-gap-offset))";
this.width = Math.round(size.width);
this.height = Math.round(size.height);
this.element = page;
this.pagebox = pagebox;
this.area = area;
this.footnotesArea = footnotesArea;
return page;
}
/**
* Creates a wrapper element inside the page's content area.
*
* @returns {HTMLElement} The wrapper element.
*/
createWrapper() {
let wrapper = document.createElement("div");
this.area.appendChild(wrapper);
this.wrapper = wrapper;
return wrapper;
}
/**
* Sets the page index and updates relevant attributes and classes.
*
* @param {number} pgnum - The page index number (0-based).
*/
index(pgnum) {
this.position = pgnum;
let page = this.element;
let index = pgnum + 1;
let id = `page-${index}`;
this.id = id;
page.dataset.pageNumber = index;
page.setAttribute("id", id);
if (this.name) {
page.classList.add("pagedjs_" + this.name + "_page");
}
if (this.blank) {
page.classList.add("pagedjs_blank_page");
}
if (pgnum === 0) {
page.classList.add("pagedjs_first_page");
}
if (pgnum % 2 !== 1) {
page.classList.remove("pagedjs_left_page");
page.classList.add("pagedjs_right_page");
} else {
page.classList.remove("pagedjs_right_page");
page.classList.add("pagedjs_left_page");
}
}
/**
* Performs layout rendering of content within the page.
*
* @async
* @param {DocumentFragment} contents - The contents to render.
* @param {Object} breakToken - The token where content rendering should resume.
* @param {Page} [prevPage] - The previous page, used for continuity.
* @returns {Promise<Object|undefined>} A new breakToken if content overflowed, or undefined.
*/
async layout(contents, breakToken, prevPage) {
this.clear();
this.startToken = breakToken;
this.layoutMethod = new Layout(this.area, this.hooks, this.settings);
let renderResult = await this.layoutMethod.renderTo(
this.wrapper,
contents,
breakToken,
prevPage,
);
let newBreakToken = renderResult.breakToken;
if (breakToken && newBreakToken && breakToken.equals(newBreakToken)) {
return;
}
this.addListeners(contents);
this.endToken = newBreakToken;
return newBreakToken;
}
/**
* Appends content to the existing layout using the current layout method.
*
* @async
* @param {DocumentFragment} contents - The contents to append.
* @param {Object} breakToken - The token to continue rendering from.
* @returns {Promise<Object>} A new breakToken after rendering.
*/
async append(contents, breakToken) {
if (!this.layoutMethod) {
return this.layout(contents, breakToken);
}
let renderResult = await this.layoutMethod.renderTo(
this.wrapper,
contents,
breakToken,
);
let newBreakToken = renderResult.breakToken;
this.endToken = newBreakToken;
return newBreakToken;
}
/**
* Finds a DOM element by its `data-ref` attribute in a list of elements.
*
* @param {string} ref - The reference string to look for.
* @param {HTMLElement[]} entries - A list of elements to search.
* @returns {HTMLElement|undefined} The matching element, if found.
*/
getByParent(ref, entries) {
for (let i = 0; i < entries.length; i++) {
if (entries[i].dataset.ref === ref) {
return entries[i];
}
}
}
/**
* Registers a callback to run when content overflows the page.
*
* @param {Function} func - The overflow callback function.
*/
onOverflow(func) {
this._onOverflow = func;
}
/**
* Registers a callback to run when content underflows the page.
*
* @param {Function} func - The underflow callback function.
*/
onUnderflow(func) {
this._onUnderflow = func;
}
/**
* Clears the wrapper and listeners, resetting the layout state.
*/
clear() {
this.removeListeners();
this.wrapper && this.wrapper.remove();
this.createWrapper();
}
/**
* Adds event listeners for scroll and resize to monitor overflows.
*
* @param {DocumentFragment} contents - The content being rendered (used in resize checks).
* @returns {boolean} True if listeners were added.
*/
addListeners(contents) {
if (typeof ResizeObserver !== "undefined") {
this.addResizeObserver(contents);
} else {
this._checkOverflowAfterResize = this.checkOverflowAfterResize.bind(
this,
contents,
);
this.element.addEventListener(
"overflow",
this._checkOverflowAfterResize,
false,
);
this.element.addEventListener(
"underflow",
this._checkOverflowAfterResize,
false,
);
}
this._onScroll = () => {
if (this.listening) {
this.element.scrollLeft = 0;
}
};
this.element.addEventListener("scroll", this._onScroll);
this.listening = true;
return true;
}
/**
* Removes event listeners related to overflow and resizing.
*/
removeListeners() {
this.listening = false;
if (typeof ResizeObserver !== "undefined" && this.ro) {
this.ro.disconnect();
} else if (this.element) {
this.element.removeEventListener(
"overflow",
this._checkOverflowAfterResize,
false,
);
this.element.removeEventListener(
"underflow",
this._checkOverflowAfterResize,
false,
);
}
this.element && this.element.removeEventListener("scroll", this._onScroll);
}
/**
* Adds a ResizeObserver to monitor wrapper size changes.
*
* @param {DocumentFragment} contents - The contents being observed for overflow changes.
*/
addResizeObserver(contents) {
let wrapper = this.wrapper;
let prevHeight = wrapper.getBoundingClientRect().height;
this.ro = new ResizeObserver((entries) => {
if (!this.listening) return;
requestAnimationFrame(() => {
for (let entry of entries) {
const cr = entry.contentRect;
if (cr.height > prevHeight) {
this.checkOverflowAfterResize(contents);
prevHeight = wrapper.getBoundingClientRect().height;
} else if (cr.height < prevHeight) {
this.checkUnderflowAfterResize(contents);
prevHeight = cr.height;
}
}
});
});
this.ro.observe(wrapper);
}
/**
* Checks if the page content has overflowed after a resize.
*
* @param {DocumentFragment} contents - The content being checked.
*/
checkOverflowAfterResize(contents) {
if (!this.listening || !this.layoutMethod) return;
let newBreakToken = this.layoutMethod.findBreakToken(
this.wrapper,
contents,
undefined,
this.startToken,
);
if (newBreakToken) {
this.endToken = newBreakToken;
this._onOverflow && this._onOverflow(newBreakToken);
}
}
/**
* Checks if the page content has underflowed (e.g., content was removed).
*
* @param {DocumentFragment} contents - The content being checked.
*/
checkUnderflowAfterResize(contents) {
if (!this.listening || !this.layoutMethod) return;
let endToken = this.layoutMethod.findEndToken(this.wrapper, contents);
if (endToken) {
this._onUnderflow && this._onUnderflow(endToken);
}
}
/**
* Cleans up the page, removing all DOM elements and listeners.
*/
destroy() {
this.removeListeners();
this.element.remove();
this.element = undefined;
this.wrapper = undefined;
}
}
// Add event emitter capabilities
EventEmitter(Page.prototype);
export default Page;