← back to Dw Marketing Reels

public/collections/graduate-flipbook/page-flip.js

313 lines

/*!
 * page-flip.js — a zero-dependency drag-to-paginate book / sketchbook UI.
 *
 * Seeded by Meng To (@MengTo)'s 2026-08-06 "page-flipping sketchbook" post:
 *   "AI gives you the basics unless you hand it a solid open-source to work
 *    from. So I asked for drag to paginate, which took many fixes for shadows."
 * This file IS that solid open-source to work from. The hard parts it gets
 * right — the ones "the basics" skip — are called out inline: the curl
 * ambient-occlusion shadow, the gutter/spine shadow, velocity-flick release,
 * and keeping the shadow synced to the page THROUGH the snap animation.
 *
 * Contract (see demo.html):
 *   <div class="pageflip" data-pageflip>
 *     <div class="pf-page">…page 1…</div>
 *     <div class="pf-page">…page 2…</div>
 *     …
 *   </div>
 * Each direct .pf-page child becomes one leaf. Author content lives on the
 * front; the back is an auto-generated paper backside.
 *
 * Usage:
 *   const book = new PageFlip(el, { onFlip(i){…} });   // or auto-init below
 *   book.next(); book.prev(); book.goTo(3);
 *
 * No build step, no framework, no license friction — vanilla ES2019.
 */
(function (global) {
  'use strict';

  var DEFAULTS = {
    dragZone: 0.5,       // fraction of page width (from the outer edge) that starts a drag
    flipThreshold: 0.5,  // release past this fraction of the turn (0..1) completes the flip
    flickVelocity: 0.6,  // px/ms; a faster release completes the flip regardless of threshold
    snapMs: 420,         // snap animation duration
    curlShadow: 0.42,    // peak opacity of the curl ambient-occlusion shadow (0..1)
    gutterShadow: 0.28,  // peak opacity of the fixed spine/gutter shadow (0..1)
    perspective: 2200,   // px; larger = flatter/less dramatic depth
    onFlip: null,        // (index) => void, fired after a completed flip settles
    onTurn: null         // (progress, dir) => void, fired continuously while turning (progress 0..1)
  };

  // power-in-out easing keeps the snap feeling weighted, not linear/robotic.
  function easeInOut(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
  function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }

  function PageFlip(root, opts) {
    if (!root) throw new Error('PageFlip: root element required');
    if (root.__pageflip) return root.__pageflip; // idempotent
    this.root = root;
    this.o = Object.assign({}, DEFAULTS, opts || {});
    this.pages = [];
    this.index = 0;          // top face-up leaf
    this.dragging = false;
    this.raf = 0;
    root.__pageflip = this;
    this._build();
    this._bind();
    this._layout();
  }

  PageFlip.prototype._build = function () {
    var root = this.root;
    root.classList.add('pageflip');
    root.style.setProperty('--pf-persp', this.o.perspective + 'px');

    // Promote each .pf-page child into a two-faced leaf with a curl-shadow layer.
    var kids = Array.prototype.filter.call(root.children, function (c) {
      return c.classList && c.classList.contains('pf-page');
    });
    var self = this;
    kids.forEach(function (leaf, i) {
      // Move author content into a front face (once — re-init safe).
      if (!leaf.querySelector(':scope > .pf-front')) {
        var front = document.createElement('div');
        front.className = 'pf-face pf-front';
        while (leaf.firstChild) front.appendChild(leaf.firstChild);
        leaf.appendChild(front);

        var back = document.createElement('div');
        back.className = 'pf-face pf-back';
        back.setAttribute('aria-hidden', 'true');
        leaf.appendChild(back);

        // Curl shadow: a gradient overlay whose opacity we drive from the turn
        // angle. This is the "many fixes for shadows" layer — see physics.md.
        var curl = document.createElement('div');
        curl.className = 'pf-curl';
        curl.setAttribute('aria-hidden', 'true');
        leaf.appendChild(curl);
      }
      leaf.dataset.pfIndex = String(i);
      leaf.setAttribute('role', 'group');
      leaf.setAttribute('aria-roledescription', 'page');
      self.pages.push(leaf);
    });

    // Fixed gutter/spine shadow — always present at the binding edge so the
    // book reads as bound even when no page is turning.
    if (!root.querySelector(':scope > .pf-gutter')) {
      var gutter = document.createElement('div');
      gutter.className = 'pf-gutter';
      gutter.setAttribute('aria-hidden', 'true');
      root.appendChild(gutter);
      root.style.setProperty('--pf-gutter', this.o.gutterShadow);
    }
  };

  PageFlip.prototype._layout = function () {
    // Resting state: leaves before `index` are turned (-180°) and stack on the
    // left ascending; leaves at/after `index` are flat and stack on the right
    // with `index` on top.
    var n = this.pages.length;
    for (var i = 0; i < n; i++) {
      var leaf = this.pages[i];
      if (i < this.index) {
        this._setLeaf(leaf, -180, 0);
        leaf.style.zIndex = String(i);            // earlier turned pages sink lower
        leaf.classList.add('pf-turned');
      } else {
        this._setLeaf(leaf, 0, 0);
        leaf.style.zIndex = String(n - i);        // current on top, rest descend
        leaf.classList.remove('pf-turned');
      }
      leaf.setAttribute('aria-hidden', i === this.index || i === this.index + 1 ? 'false' : 'true');
    }
    this.root.dataset.pfIndex = String(this.index);
  };

  // Apply a rotation + its matching shadow to one leaf. angleDeg in [-180, 0].
  PageFlip.prototype._setLeaf = function (leaf, angleDeg, forceShadow) {
    leaf.style.transform = 'rotateY(' + angleDeg + 'deg)';
    var curl = leaf.querySelector(':scope > .pf-curl');
    if (!curl) return;
    // sin() peaks at 90° (page standing up) and vanishes flat at 0°/180° — the
    // physically-right shape for ambient occlusion of a curling sheet.
    var a = Math.abs(angleDeg);
    var s = (forceShadow != null ? forceShadow : Math.sin(a * Math.PI / 180)) * this.o.curlShadow;
    curl.style.opacity = String(s);
    // Past 90° we're looking at the back of the sheet: flip the gradient so the
    // shading gathers toward the (now leading) spine edge, not the free edge.
    curl.style.setProperty('--pf-curl-dir', a > 90 ? '1' : '0');
  };

  PageFlip.prototype._bind = function () {
    var self = this;
    this._onDown = function (e) { self._down(e); };
    this._onMove = function (e) { self._move(e); };
    this._onUp = function (e) { self._up(e); };
    this.root.addEventListener('pointerdown', this._onDown);
    // Keyboard paging for accessibility. Only set tabIndex when the element
    // has no explicit tabindex attribute; avoids overwriting intentional -1.
    if (!this.root.hasAttribute('tabindex')) this.root.tabIndex = 0;
    // Stored (not anonymous) so destroy() can remove it — otherwise re-init on
    // the same element stacks keydown listeners.
    this._onKey = function (e) {
      if (e.key === 'ArrowRight' || e.key === 'PageDown') { self.next(); e.preventDefault(); }
      else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { self.prev(); e.preventDefault(); }
    };
    this.root.addEventListener('keydown', this._onKey);
    this._onResize = function () { self.width = self.root.clientWidth; };
    window.addEventListener('resize', this._onResize);
    this.width = this.root.clientWidth;
  };

  PageFlip.prototype._down = function (e) {
    if (this.animating) return;
    var rect = this.root.getBoundingClientRect();
    var x = (e.clientX - rect.left) / rect.width; // 0 (spine/left) .. 1 (outer/right)
    var dir;
    // Right dragZone turns forward (needs a next page); left turns back.
    if (x >= 1 - this.o.dragZone && this.index < this.pages.length - 1) dir = 1;
    else if (x <= this.o.dragZone && this.index > 0) dir = -1;
    else return;

    this.dragging = true;
    this.dir = dir;
    this.startX = e.clientX;
    this.width = rect.width;
    this.lastX = e.clientX;
    this.lastT = e.timeStamp;
    this.vel = 0;

    // The leaf that visually turns: the current leaf when going forward, the
    // previous (already-turned) leaf when going back.
    this.active = dir === 1 ? this.pages[this.index] : this.pages[this.index - 1];
    this.active.style.zIndex = String(this.pages.length + 1); // ride on top while turning
    this.active.classList.add('pf-turning');
    this.root.classList.add('pf-grabbing');

    try { this.root.setPointerCapture(e.pointerId); } catch (_) {}
    window.addEventListener('pointermove', this._onMove);
    window.addEventListener('pointerup', this._onUp);
    window.addEventListener('pointercancel', this._onUp);
    e.preventDefault();
  };

  PageFlip.prototype._move = function (e) {
    if (!this.dragging) return;
    var dt = e.timeStamp - this.lastT;
    if (dt > 0) this.vel = (e.clientX - this.lastX) / dt; // px/ms, signed
    this.lastX = e.clientX; this.lastT = e.timeStamp;

    var dx = e.clientX - this.startX;
    // progress 0 (flat/resting) .. 1 (fully turned) for this drag direction.
    var progress = this.dir === 1
      ? clamp(-dx / this.width, 0, 1)          // forward: drag left (dx<0)
      : clamp(dx / this.width, 0, 1);          // back: drag right (dx>0)
    // Forward turns the current leaf 0 -> -180; back turns the prev leaf -180 -> 0.
    var angle = this.dir === 1 ? -progress * 180 : -180 + progress * 180;
    this._setLeaf(this.active, angle);
    this.progress = progress;
    if (this.o.onTurn) this.o.onTurn(progress, this.dir);
  };

  PageFlip.prototype._up = function () {
    if (!this.dragging) return;
    this.dragging = false;
    this.root.classList.remove('pf-grabbing');
    window.removeEventListener('pointermove', this._onMove);
    window.removeEventListener('pointerup', this._onUp);
    window.removeEventListener('pointercancel', this._onUp);

    // A quick flick completes the flip even if you didn't cross the threshold —
    // velocity sign must agree with the turn direction (drag left = negative).
    var flick = (this.dir === 1 && this.vel < -this.o.flickVelocity) ||
                (this.dir === -1 && this.vel > this.o.flickVelocity);
    var complete = this.progress >= this.o.flipThreshold || flick;
    this._settle(complete);
  };

  // Animate the active leaf to its resting angle, driving the shadow the whole
  // way so it never "pops" at the seam between drag and snap.
  PageFlip.prototype._settle = function (complete) {
    var self = this;
    this.animating = true;
    var fromProg = this.progress || 0;
    var toProg = complete ? 1 : 0;
    var t0 = null;
    cancelAnimationFrame(this.raf);

    function frame(t) {
      if (t0 == null) t0 = t;
      var k = clamp((t - t0) / self.o.snapMs, 0, 1);
      var p = fromProg + (toProg - fromProg) * easeInOut(k);
      var angle = self.dir === 1 ? -p * 180 : -180 + p * 180;
      self._setLeaf(self.active, angle);
      if (self.o.onTurn) self.o.onTurn(p, self.dir);
      if (k < 1) { self.raf = requestAnimationFrame(frame); return; }

      // Landed. Commit the index and re-layout to the clean resting state.
      self.active.classList.remove('pf-turning');
      if (complete) self.index += self.dir;
      self.animating = false;
      self._layout();
      if (complete && self.o.onFlip) self.o.onFlip(self.index);
    }
    this.raf = requestAnimationFrame(frame);
  };

  // Programmatic paging animates via the same settle path for a consistent feel.
  PageFlip.prototype._go = function (dir) {
    if (this.animating || this.dragging) return false;
    if (dir === 1 && this.index >= this.pages.length - 1) return false;
    if (dir === -1 && this.index <= 0) return false;
    this.dir = dir;
    this.width = this.root.clientWidth;
    this.progress = 0;
    this.active = dir === 1 ? this.pages[this.index] : this.pages[this.index - 1];
    this.active.style.zIndex = String(this.pages.length + 1);
    this.active.classList.add('pf-turning');
    this._settle(true);
    return true;
  };

  PageFlip.prototype.next = function () { return this._go(1); };
  PageFlip.prototype.prev = function () { return this._go(-1); };
  PageFlip.prototype.goTo = function (i) {
    i = clamp(i | 0, 0, this.pages.length - 1);
    if (this.animating || this.dragging) return;
    this.index = i; this._layout();
    if (this.o.onFlip) this.o.onFlip(this.index);
  };
  PageFlip.prototype.count = function () { return this.pages.length; };
  PageFlip.prototype.current = function () { return this.index; };

  PageFlip.prototype.destroy = function () {
    cancelAnimationFrame(this.raf);
    this.root.removeEventListener('pointerdown', this._onDown);
    this.root.removeEventListener('keydown', this._onKey);
    window.removeEventListener('pointermove', this._onMove);
    window.removeEventListener('pointerup', this._onUp);
    window.removeEventListener('pointercancel', this._onUp);
    window.removeEventListener('resize', this._onResize);
    delete this.root.__pageflip;
  };

  // Auto-init any [data-pageflip] on DOM ready so the drop-in "just works".
  function auto() {
    var els = document.querySelectorAll('[data-pageflip]');
    Array.prototype.forEach.call(els, function (el) {
      if (!el.__pageflip) new PageFlip(el);
    });
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', auto);
  } else {
    auto();
  }

  global.PageFlip = PageFlip;
  if (typeof module !== 'undefined' && module.exports) module.exports = PageFlip;
})(typeof window !== 'undefined' ? window : this);