UNPKG

leaf-flip

Version:

A modern TypeScript library for creating realistic page turn effects

691 lines (684 loc) 22.2 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { FlipPage: () => FlipPage, LeafPage: () => LeafPage, Utils: () => Utils, createFlipPage: () => createFlipPage, createTurnPage: () => createTurnPage }); module.exports = __toCommonJS(index_exports); // src/constants.ts var PI = Math.PI; var A90 = PI / 2; var isTouch = "ontouchstart" in window; var events = isTouch ? { start: "touchstart", move: "touchmove", end: "touchend" } : { start: "mousedown", move: "mousemove", end: "mouseup" }; var displays = ["single", "double"]; var defaultLeafOptions = { page: 1, gradients: true, duration: 600, acceleration: true, display: "double", when: void 0 }; var defaultFlipOptions = { folding: null, corners: "forward", cornerSize: 100, gradients: true, duration: 600, acceleration: true }; // src/flip-page.ts var FlipPage = class { element; options; data; isTurning = false; isDisabled = false; constructor(element, options = {}) { this.element = element; this.options = { ...defaultFlipOptions, ...options }; this.data = { opts: this.options }; this.initialize(); } initialize() { this.setupWrapper(); this.setupEventListeners(); } setupWrapper() { const parent = this.element.parentElement; if (!parent) throw new Error("Element must have a parent"); const wrapper = document.createElement("div"); wrapper.style.position = "absolute"; wrapper.style.zIndex = "0"; parent.appendChild(wrapper); const fwrapper = document.createElement("div"); fwrapper.style.position = "absolute"; fwrapper.style.zIndex = "0"; fwrapper.style.visibility = "hidden"; parent.appendChild(fwrapper); const fpage = document.createElement("div"); fpage.style.cursor = "default"; fwrapper.appendChild(fpage); this.data.wrapper = wrapper; this.data.fwrapper = fwrapper; this.data.fpage = fpage; this.data.parent = parent; if (this.options.gradients) { const shadow = document.createElement("div"); shadow.style.position = "absolute"; shadow.style.top = "0"; shadow.style.left = "0"; shadow.style.width = "100%"; shadow.style.height = "100%"; this.data.shadow = shadow; fpage.appendChild(shadow); } this.resize(); } setupEventListeners() { if (!this.data.fpage) return; this.data.fpage.addEventListener(events.start, this.handleStart.bind(this)); document.addEventListener(events.move, this.handleMove.bind(this)); document.addEventListener(events.end, this.handleEnd.bind(this)); } handleStart(e) { if (this.isDisabled || this.isTurning) return; const point = this.getPoint(e); const corner = this.detectCorner(point); if (corner) { this.data.point = { ...point, corner }; this.isTurning = true; this.element.dispatchEvent(new CustomEvent("flipstart", { detail: { point: this.data.point } })); } } handleMove(e) { if (!this.data.point || !this.isTurning) return; const point = this.getPoint(e); this.updateFlip(point); } handleEnd() { if (!this.data.point || !this.isTurning) return; this.isTurning = false; this.completeFlip(); this.data.point = void 0; this.element.dispatchEvent(new CustomEvent("flipend")); } getPoint(e) { if ("touches" in e) { const touch = e.touches[0]; if (!touch) return { x: 0, y: 0 }; const rect2 = this.element.getBoundingClientRect(); return { x: touch.clientX - rect2.left, y: touch.clientY - rect2.top }; } const rect = this.element.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } detectCorner(point) { const { cornerSize, corners } = this.options; const width = this.element.offsetWidth; const height = this.element.offsetHeight; if (!cornerSize) return null; let allowedCorners; if (typeof corners === "string") { switch (corners) { case "forward": allowedCorners = ["br", "tr"]; break; case "backward": allowedCorners = ["bl", "tl"]; break; case "all": allowedCorners = ["tl", "bl", "tr", "br"]; break; default: allowedCorners = ["br", "tr"]; } } else { allowedCorners = ["br", "tr"]; } if (point.y < cornerSize) { if (point.x < cornerSize && allowedCorners.includes("tl")) return "tl"; if (point.x > width - cornerSize && allowedCorners.includes("tr")) return "tr"; } else if (point.y > height - cornerSize) { if (point.x < cornerSize && allowedCorners.includes("bl")) return "bl"; if (point.x > width - cornerSize && allowedCorners.includes("br")) return "br"; } return null; } updateFlip(point) { if (!this.data.point) return; const width = this.element.offsetWidth; const height = this.element.offsetHeight; const corner = this.data.point.corner; let angle = Math.atan2(point.y - this.data.point.y, point.x - this.data.point.x); if (corner.charAt(0) === "b") angle += PI; const fold = { x: width - Math.cos(angle) * width, y: height - Math.sin(angle) * height }; this.applyTransform(fold, angle); } applyTransform(fold, angle) { if (!this.data.fpage) return; const matrix = [ Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), fold.x, fold.y ]; const transform = this.options.acceleration ? `matrix3d(${matrix.join(",")},0,0,0,0,1)` : `matrix(${matrix.join(",")})`; this.data.fpage.style.transform = transform; if (this.options.gradients && this.data.shadow) { const intensity = 0.5 - Math.abs(angle) / (2 * PI); this.data.shadow.style.background = `linear-gradient(to right, rgba(0,0,0,${0.5 - intensity}), rgba(0,0,0,0) 100%)`; } } completeFlip() { if (!this.data.fpage) return; const duration = this.options.duration || 600; this.data.fpage.style.transition = `transform ${duration}ms ease-out`; this.data.fpage.style.transform = "none"; setTimeout(() => { if (this.data.fpage) { this.data.fpage.style.transition = ""; } }, duration); } resize() { const width = this.element.offsetWidth; const height = this.element.offsetHeight; const size = Math.sqrt(width * width + height * height); if (this.data.wrapper) { this.data.wrapper.style.width = `${size}px`; this.data.wrapper.style.height = `${size}px`; } if (this.data.fwrapper) { this.data.fwrapper.style.width = `${size}px`; this.data.fwrapper.style.height = `${size}px`; } } flip(corner) { if (this.isDisabled || this.isTurning) return; const point = this.getDefaultCornerPoint(corner); if (!point) return; this.data.point = { ...point, corner }; this.isTurning = true; this.updateFlip(point); } getDefaultCornerPoint(corner) { const width = this.element.offsetWidth; const height = this.element.offsetHeight; switch (corner) { case "tl": return { x: 0, y: 0 }; case "tr": return { x: width, y: 0 }; case "bl": return { x: 0, y: height }; case "br": return { x: width, y: height }; default: return null; } } disable(disabled) { this.isDisabled = disabled; } destroy() { if (this.data.fpage) { this.data.fpage.removeEventListener(events.start, this.handleStart.bind(this)); } document.removeEventListener(events.move, this.handleMove.bind(this)); document.removeEventListener(events.end, this.handleEnd.bind(this)); this.data.wrapper?.remove(); this.data.fwrapper?.remove(); } }; // src/utils.ts var Utils = class { static vendor = ""; static has3d; static getVendorPrefix() { if (this.vendor) return this.vendor; const styles = window.getComputedStyle(document.documentElement, ""); const vendors = ["-webkit-", "-moz-", "-ms-", "-o-", ""]; const prefix = Array.from(styles).find( (p) => styles.getPropertyValue(`${p}transform`) !== void 0 ) || ""; this.vendor = prefix; return prefix; } static has3DSupport() { if (this.has3d !== void 0) return this.has3d; const el = document.createElement("div"); document.body.appendChild(el); el.style.transform = "translate3d(1px,1px,1px)"; const has3d = window.getComputedStyle(el).transform.includes("3d"); document.body.removeChild(el); this.has3d = has3d; return has3d; } // Add CSS transform functions static translate(x, y, use3d) { return this.has3DSupport() && use3d ? `translate3d(${x}px, ${y}px, 0px)` : `translate(${x}px, ${y}px)`; } static rotate(degrees) { return `rotate(${degrees}deg)`; } static createPoint2D(x, y) { return { x: Math.round(x * 1e3) / 1e3, y: Math.round(y * 1e3) / 1e3 }; } // Add transform combine helper static transform(...transforms) { return transforms.join(" "); } // Add memoization for expensive calculations static memoizedBezier = /* @__PURE__ */ new Map(); static bezier(p1, p2, p3, p4, t) { const key = `${p1.x},${p1.y},${p2.x},${p2.y},${p3.x},${p3.y},${p4.x},${p4.y},${t}`; if (this.memoizedBezier.has(key)) { return this.memoizedBezier.get(key); } const mum1 = 1 - t; const mum13 = mum1 * mum1 * mum1; const mu3 = t * t * t; const point = this.createPoint2D( mum13 * p1.x + 3 * t * mum1 * mum1 * p2.x + 3 * t * t * mum1 * p3.x + mu3 * p4.x, mum13 * p1.y + 3 * t * mum1 * mum1 * p2.y + 3 * t * t * mum1 * p3.y + mu3 * p4.y ); this.memoizedBezier.set(key, point); return point; } // Add cleanup method for memoization static clearMemoization() { this.memoizedBezier.clear(); } }; // src/leaf-page.ts var pagePositions = { 0: { top: 0, left: 0, right: "auto", bottom: "auto" }, 1: { top: 0, right: 0, left: "auto", bottom: "auto" } }; var LeafPage = class { element; options; data; constructor(element, options = {}) { this.element = element; this.options = { ...defaultLeafOptions, ...options }; this.data = { pageObjs: {}, pages: {}, pageWrap: {}, pagePlace: {}, pageMv: [], totalPages: 0, page: this.options.page || 1, display: this.options.display || "double", disabled: false, done: false }; this.initialize(); } initialize() { Utils.getVendorPrefix(); const has3d = Utils.has3DSupport(); this.element.style.position = "relative"; this.element.style.width = `${this.options.width || this.element.offsetWidth}px`; this.element.style.height = `${this.options.height || this.element.offsetHeight}px`; this.setupEventListeners(); this.setDisplay(this.options.display || "double"); if (has3d && !("ontouchstart" in window) && this.options.acceleration) { this.element.style.transform = Utils.translate(0, 0, true); } const children = Array.from(this.element.children); for (let i = 0; i < children.length; i++) { this.addPage(children[i], i + 1); } this.setPage(this.options.page || 1); this.data.done = true; } setupEventListeners() { this.element.addEventListener(events.start, this.handleStart.bind(this)); document.addEventListener(events.move, this.handleMove.bind(this)); document.addEventListener(events.end, this.handleEnd.bind(this)); } handleStart(e) { if (this.data.disabled) return; const point = this.getEventPoint(e); const corner = this.detectCorner(point); if (corner) { this.startFlip(corner); } } handleMove(e) { if (this.data.disabled || !this.data.pageMv.length) return; const point = this.getEventPoint(e); this.updateFlip(point); } handleEnd() { if (this.data.disabled || !this.data.pageMv.length) return; this.completeFlip(); } getEventPoint(e) { const event = e; const rect = this.element.getBoundingClientRect(); if ("touches" in event) { const touch = event.touches[0]; if (!touch) return { x: 0, y: 0 }; return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; } return { x: event.clientX - rect.left, y: event.clientY - rect.top }; } detectCorner(point) { const width = this.element.offsetWidth; const height = this.element.offsetHeight; const cornerSize = 50; if (point.y < cornerSize) { if (point.x < cornerSize) return "tl"; if (point.x > width - cornerSize) return "tr"; } else if (point.y > height - cornerSize) { if (point.x < cornerSize) return "bl"; if (point.x > width - cornerSize) return "br"; } return null; } addPage(element, page) { if (!element) return this; let incPages = false; page = page || this.data.totalPages + 1; if (page > this.data.totalPages) { this.data.totalPages = page; incPages = true; } const pageWrapper = document.createElement("div"); pageWrapper.className = "turn-page-wrapper"; pageWrapper.style.position = "absolute"; pageWrapper.style.overflow = "hidden"; pageWrapper.style.width = `${this.getPageWidth()}px`; pageWrapper.style.height = `${this.element.offsetHeight}px`; element.style.width = `${this.getPageWidth()}px`; element.style.height = `${this.element.offsetHeight}px`; pageWrapper.appendChild(element); this.data.pageObjs[page] = element; this.data.pageWrap[page] = pageWrapper; this.data.pagePlace[page] = page; this.element.appendChild(pageWrapper); return this; } getPageWidth() { return this.data.display === "double" ? this.element.offsetWidth / 2 : this.element.offsetWidth; } setDisplay(display) { if (!displays.includes(display)) { throw new Error(`Invalid display mode: ${display}`); } if (this.data.display === display) return; const previousDisplay = this.data.display; this.data.display = display; if (this.data.done) { const width = this.getPageWidth(); Object.values(this.data.pageWrap).forEach((wrapper) => { wrapper.style.width = `${width}px`; }); Object.values(this.data.pageObjs).forEach((page) => { page.style.width = `${width}px`; }); this.updatePagesPosition(); } this.dispatchEvent("displayChanged", { previous: previousDisplay, current: display }); } updatePagesPosition() { const view = this.view(); for (let page = 1; page <= this.data.totalPages; page++) { const wrapper = this.data.pageWrap[page]; if (!wrapper) continue; if (view.includes(page)) { const index = this.data.display === "double" ? page % 2 : 0; const position = pagePositions[index]; if (!position) { console.warn(`No position found for page ${page}`); continue; } wrapper.style.visibility = "visible"; wrapper.style.top = `${position.top}px`; if (position.left === "auto") { wrapper.style.left = "auto"; } else { wrapper.style.left = `${position.left}px`; } if (position.right === "auto") { wrapper.style.right = "auto"; } else { wrapper.style.right = `${position.right}px`; } if (position.bottom === "auto") { wrapper.style.bottom = "auto"; } else { wrapper.style.bottom = `${position.bottom}px`; } } else { wrapper.style.visibility = "hidden"; } } } setPage(pageNumber) { if (pageNumber < 1 || pageNumber > this.data.totalPages) { throw new Error(`Invalid page number: ${pageNumber}`); } if (this.data.page === pageNumber) return; const previousPage = this.data.page; this.data.page = pageNumber; this.updatePagesPosition(); this.dispatchEvent("pageChanged", { previous: previousPage, current: pageNumber }); } next() { const view = this.view(); const nextPage = Math.max(...view) + 1; if (nextPage <= this.data.totalPages) { this.setPage(nextPage); } } previous() { const view = this.view(); const prevPage = Math.min(...view) - 1; if (prevPage >= 1) { this.setPage(prevPage); } } view() { const page = this.data.page || 1; if (this.data.display === "double") { return page % 2 ? [page, page + 1] : [page - 1, page]; } return [page]; } dispatchEvent(name, detail) { const event = new CustomEvent(name, { detail }); this.element.dispatchEvent(event); } destroy() { this.element.removeEventListener(events.start, this.handleStart); document.removeEventListener(events.move, this.handleMove); document.removeEventListener(events.end, this.handleEnd); Object.values(this.data.pageWrap).forEach((wrapper) => wrapper.remove()); this.data = { pageObjs: {}, pages: {}, pageWrap: {}, pagePlace: {}, pageMv: [], totalPages: 0, page: 1, display: "double", disabled: false, done: false }; } startFlip(corner) { if (this.data.disabled || this.data.pageMv.length) return; const view = this.view(); let page; if (corner.includes("r")) { page = view[0]; } else { page = view[view.length - 1]; } if (typeof page !== "number") return; const wrapper = this.data.pageWrap[page]; if (!wrapper) return; const nextPage = corner.includes("r") ? page + 1 : page - 1; if (nextPage < 1 || nextPage > this.data.totalPages) return; this.data.pageMv = [page]; wrapper.style.zIndex = String(this.data.totalPages); wrapper.style.visibility = "visible"; const width = this.getPageWidth(); const height = this.element.offsetHeight; const initialFold = this.calculateFoldPosition(corner, { x: 0, y: 0 }, width, height); this.applyFoldTransform(wrapper, initialFold); this.dispatchEvent("flipStart", { page, corner }); } calculateFoldPosition(corner, point, width, height) { let x = 0, y = 0; let angle = 0; switch (corner) { case "tl": x = point.x || 0; y = point.y || 0; angle = Math.atan2(y, x); break; case "tr": x = (point.x || width) - width; y = point.y || 0; angle = Math.atan2(y, x); break; case "bl": x = point.x || 0; y = (point.y || height) - height; angle = Math.atan2(y, x); break; case "br": x = (point.x || width) - width; y = (point.y || height) - height; angle = Math.atan2(y, x); break; default: console.warn(`Unexpected corner value: ${corner}`); } return { x, y, angle }; } updateFlip(point) { if (!this.data.pageMv.length) return; const page = this.data.pageMv[0]; if (typeof page !== "number") return; const wrapper = this.data.pageWrap[page]; if (!wrapper) return; const width = this.getPageWidth(); const height = this.element.offsetHeight; const corner = this.detectCorner(point); if (!corner) return; const fold = this.calculateFoldPosition(corner, point, width, height); this.applyFoldTransform(wrapper, fold); this.dispatchEvent("flipMove", { page, progress: Math.abs(fold.x) / width }); } applyFoldTransform(wrapper, fold) { const rad2deg = (rad) => rad * 180 / Math.PI; const origin = `${fold.x}px ${fold.y}px`; const rotation = rad2deg(fold.angle); wrapper.style.transformOrigin = origin; wrapper.style.webkitTransformOrigin = origin; const transform = `rotate(${rotation}deg)`; if (this.options.acceleration) { wrapper.style.transform = transform + " translateZ(0)"; wrapper.style.webkitTransform = transform + " translateZ(0)"; } else { wrapper.style.transform = transform; wrapper.style.webkitTransform = transform; } } completeFlip() { if (!this.data.pageMv.length) return; const page = this.data.pageMv[0]; if (typeof page !== "number") return; const wrapper = this.data.pageWrap[page]; if (!wrapper) return; const duration = this.options.duration || 600; wrapper.style.transition = `all ${duration}ms ease-out`; wrapper.style.webkitTransition = `all ${duration}ms ease-out`; wrapper.style.transform = "none"; wrapper.style.webkitTransform = "none"; const nextPage = this.data.display === "double" ? page % 2 === 0 ? page - 1 : page + 1 : page; if (nextPage >= 1 && nextPage <= this.data.totalPages) { this.setPage(nextPage); } this.data.pageMv = []; setTimeout(() => { if (wrapper) { wrapper.style.transition = ""; wrapper.style.webkitTransition = ""; } }, duration); this.dispatchEvent("flipComplete", { page }); } }; // src/factory.ts function createTurnPage(element, options) { return new LeafPage(element, options); } function createFlipPage(element, options) { return new FlipPage(element, options); } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { FlipPage, LeafPage, Utils, createFlipPage, createTurnPage });