leaf-flip
Version:
A modern TypeScript library for creating realistic page turn effects
691 lines (684 loc) • 22.2 kB
JavaScript
"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
});