i2psnark/res/js/lightbox.js

/**
 * @file lightbox.js - Image lightbox for I2PSnark file viewer.
 * @description A self-contained lightbox class for viewing thumbnail images in a fullscreen
 * overlay. Supports grouped image navigation (prev/next), slideshow playback with configurable
 * delay, responsive resizing, iframe-aware positioning, and preloading of adjacent images.
 * Based on jsOnlyLightbox by Felix Hagspiel.
 * @author dr|z3d
 * @see {@link https://github.com/felixhagspiel/jsOnlyLightbox}
 */

/**
 * @type {?HTMLElement}
 * @description The #snarkFiles container element. Lightbox initialization is skipped if absent.
 */
const snarkFiles = document.getElementById("snarkFiles");

/**
 * @class Lightbox
 * @description Provides a fullscreen image viewing experience with navigation controls,
 * slideshow playback, and responsive image sizing. Operates within iframes and supports
 * grouped image galleries.
 * @example
 * const lightbox = new Lightbox();
 * lightbox.load({ maxImgSize: 0.8 });
 */
class Lightbox {
  /**
   * @constructor
   * @description Initializes the Lightbox instance. Sets up internal state for image tracking,
   * slideshow intervals, and option handling. Calls initialize() to create the lightbox DOM.
   */
  constructor() {
    if (!snarkFiles) return;
    this.prefix = "lb";
    this.data_attr = `data-${this.prefix}`;
    this.body = document.body;
    this.parentDoc = window.parent.document;
    this.currGroup = this.currThumb = null;
    this.currImages = [];
    this.isOpen = false;
    this.currImage = {};
    this.opt = {};
    this.intervalId = null;
    this.slideshowDelay = 5000;
    this.transition = null;       // active crossfade: { out, in }
    this.settleTimer = null;      // fires settleDone at the end of the fade
    this.transitionToken = 0;     // invalidates decode callbacks from superseded clicks
    this.initialize();
  }

  /**
   * @method initialize
   * @description Creates the lightbox container element and attaches it to the document body.
   * @returns {void}
   */
  initialize() {
    this.box = this.createEl("div", this.prefix);
    this.body.appendChild(this.box);
    this.addEventListeners();
  }

  /**
   * @method createEl
   * @description Creates a new DOM element with the specified tag and id.
   * @param {string} tag - The HTML tag name for the element.
   * @param {string} id - The id attribute to assign to the element.
   * @returns {HTMLElement} The newly created element.
   */
  createEl(tag, id) {
    const el = document.createElement(tag);
    el.id = id;
    return el;
  }

  /**
   * @method createButton
   * @description Creates a clickable button element with text content and a click callback.
   * Stops event propagation on click.
   * @param {string} text - The text content for the button.
   * @param {string} id - The id attribute for the button element.
   * @param {Function} callback - The function to call when the button is clicked.
   * @returns {HTMLSpanElement} The created button element.
   */
  createButton(text, id, callback) {
    const btn = this.createEl("span", id);
    btn.textContent = text;
    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      callback();
    });
    return btn;
  }

  /**
   * @method createControls
   * @description Creates the slideshow control buttons (prev, next, play, pause) and appends
   * them to the lightbox container. Navigation buttons are added directly to the box;
   * play/pause are grouped in a container.
   * @returns {HTMLElement} The play/pause control container element.
   */
  createControls() {
    const container = this.createEl("div", `${this.prefix}-playpause`);
    const controls = [
      { text: "<", id: `${this.prefix}-prev`, action: this.prev.bind(this) },
      { text: ">", id: `${this.prefix}-next`, action: this.next.bind(this) },
      { text: "Play", id: `${this.prefix}-play`, action: this.play.bind(this) },
      { text: "Pause", id: `${this.prefix}-pause`, action: this.pause.bind(this) }
    ];

    controls.forEach(({ text, id, action }) => {
      const btn = this.createButton(text, id, action);
      if (id === `${this.prefix}-prev` || id === `${this.prefix}-next`) { this.box.appendChild(btn); }
      else { container.appendChild(btn); }
      if (id === `${this.prefix}-prev`) { this.prevBtn = btn; }
      if (id === `${this.prefix}-next`) { this.nextBtn = btn; }
      if (id === `${this.prefix}-play`) { this.playBtn = btn; }
      if (id === `${this.prefix}-pause`) { this.pauseBtn = btn; }
    });

    this.box.appendChild(container);
    return container;
  }

  /**
   * @method load
   * @description Loads thumbnail elements from the document, assigns index data attributes,
   * attaches click handlers, and creates slideshow controls if there are multiple thumbnails.
   * @param {Object} [opt={}] - Configuration options merged with defaults.
   * @param {boolean} [opt.preload=true] - Whether to preload adjacent images.
   * @param {number} [opt.maxImgSize=0.75] - Maximum image size as a fraction of viewport.
   * @returns {void}
   */
  load(opt = {}) {
    if (!snarkFiles) return;
    this.setOpt(opt);
    this.thumbnails = [...document.querySelectorAll(".thumb")];
    this.thumbnails.forEach((thumbnail, index) => {
      thumbnail.setAttribute(`${this.data_attr}-index`, index);
      this.addThumbnailClickHandler(thumbnail);
    });
    if (this.thumbnails.length > 1) {
      this.playPauseContainer = this.createControls();
    }
  }

  /**
   * @method setOpt
   * @description Sets lightbox options by merging provided options with defaults, and
   * attaches a click-to-close handler on the lightbox container.
   * @param {Object} [opt={}] - User-provided options to override defaults.
   * @returns {void}
   */
  setOpt(opt) {
    const defaults = { preload: true, maxImgSize: .75 };
    this.opt = { ...defaults, ...opt };
    this.box.addEventListener("click", this.close.bind(this));
  }

  /**
   * @method addThumbnailClickHandler
   * @description Attaches a click handler to a thumbnail that opens the lightbox with
   * the appropriate group and image.
   * @param {HTMLElement} thumbnail - The thumbnail image element to attach the handler to.
   * @returns {void}
   */
  addThumbnailClickHandler(thumbnail) {
    thumbnail.addEventListener("click", (e) => {
      e.preventDefault();
      this.currGroup = thumbnail.getAttribute(`${this.data_attr}-group`) || "";
      this.currThumb = thumbnail;
      this.openBox(thumbnail);
    });
  }

  /**
   * @method openBox
   * @description Opens the lightbox for the given element. Creates a new Image, sets its
   * source from the data attribute or element src, displays the lightbox container, and
   * triggers resize and positioning on image load. This is the initial-open path only;
   * in-gallery navigation goes through transitionTo for a crossfade.
   * @param {HTMLElement} el - The thumbnail element whose image to display.
   * @returns {void}
   */
  openBox(el) {
    if (!el) return;
    document.body.classList.add("lightbox");
    window.scrollTo(0, 0);
    this.currImage.img = new Image();
    const src = el.getAttribute(this.data_attr) || el.src;
    this.currImage.img.src = src;
    // Kill the CSS fadeAndBlur entrance animation: its 0.4s delay holds the image at
    // opacity 0 and would corrupt the first transition's fade-out start state.
    this.currImage.img.style.animation = "none";
    this.box.style.display = "flex";
    this.currImages = Array.from(document.querySelectorAll(`[${this.data_attr}-group="${this.currGroup}"]`));
    this.currImage.img.onload = () => this.onImageLoad();
    this.box.appendChild(this.currImage.img);
    this.adjustPosition();
    this.setupResizeObserver();
  }

  /**
   * @method onImageLoad
   * @description Called when the lightbox image finishes loading. Marks the lightbox as open,
   * resizes the image, shows navigation controls if multiple images exist, and repositions them.
   * @returns {void}
   */
  onImageLoad() {
    this.isOpen = true;
    this.resize();
    this.box.classList.add("active");
    if (this.currImages.length > 1) { this.toggleControlButtons(); }
    this.repositionControls();
  }

  /**
   * @method toggleControlButtons
   * @description Shows the navigation (prev/next) buttons and toggles play/pause button
   * visibility based on slideshow state. Triggers image preloading.
   * @returns {void}
   */
  toggleControlButtons() {
    if (this.prevBtn) this.prevBtn.classList.add("active");
    if (this.nextBtn) { this.nextBtn.classList.add("active"); }
    this.preload();
    if (this.playBtn) { this.playBtn.classList.toggle("active", !this.intervalId); }
    if (this.pauseBtn) { this.pauseBtn.classList.toggle("active", !!this.intervalId); }
  }

  /**
   * @method preload
   * @description Preloads the next and previous images in the current group to improve
   * navigation responsiveness.
   * @returns {void}
   */
  preload() {
    if (!this.currGroup) return;
    const currIndex = this.thumbnails.findIndex((thumb) => thumb === this.currThumb);
    const nextThumb = this.thumbnails[(currIndex + 1) % this.thumbnails.length];
    const prevThumb = this.thumbnails[(currIndex - 1 + this.thumbnails.length) % this.thumbnails.length];
    new Image().src = nextThumb.getAttribute(this.data_attr) || nextThumb.src;
    new Image().src = prevThumb.getAttribute(this.data_attr) || prevThumb.src;
  }

  /**
   * @method resize
   * @description Constrains the current lightbox image with max-width/max-height so the
   * browser scales it proportionally from its natural dimensions. Never sets explicit
   * width or height: the computed size keeps the image's true aspect ratio, so the frame
   * (border included) always matches the rendered content exactly.
   * @returns {void}
   */
  resize() {
    if (!this.currImage.img) return;
    Object.assign(this.currImage.img.style, this.getMaxSizing());
  }

  /**
   * @method getMaxSizing
   * @description Computes the max-width/max-height style object that fits images within
   * the viewport, constrained by the maxImgSize option. Flooring keeps the computed size
   * stable between calls so the frame cannot reflow at the end of a transition.
   * @returns {{maxWidth: string, maxHeight: string}} The constraint styles.
   */
  getMaxSizing() {
    const maxWidth = Math.floor(window.innerWidth * this.opt.maxImgSize);
    const maxHeight = Math.floor(window.innerHeight * this.opt.maxImgSize);
    return { maxWidth: `${maxWidth}px`, maxHeight: `${maxHeight}px` };
  }

  /**
   * @method close
   * @description Closes the lightbox: aborts any transition in progress, removes the image
   * from the DOM, resets parent styles, cleans up observers, and pauses the slideshow.
   * @returns {void}
   */
  close() {
    this.cancelTransition();
    this.isOpen = false;
    this.body.classList.remove("lightbox");
    this.box.classList.remove("active");
    // Removes the displayed image in every state: settled, mid-fade, or still decoding
    // (when currImage.img is an unattached placeholder and the shown image is in the box).
    this.box.querySelectorAll("img").forEach((img) => img.remove());
    this.resetParentStyles();
    this.cleanupObserversAndListeners();
    this.pause();
  }

  /**
   * @method adjustPosition
   * @description Adjusts the lightbox positioning for iframe or standalone mode. In iframe
   * mode, locks scrolling on the parent document. Sets the lightbox height to viewport height.
   * @returns {void}
   */
  adjustPosition() {
    if (this.isIframed()) {
      this.parentDoc.body.style.overflow = "hidden";
      this.parentDoc.body.style.contain = "paint";
      this.parentDoc.documentElement.style.overflow = "hidden";
      this.parentDoc.documentElement.classList.add("lightbox", "fullscreen");
      this.box.style.height = `${window.parent.innerHeight}px`;
    } else {this.box.style.height = "100vh";}
    window.scrollTo(0,0);
  }

  /**
   * @method resetParentStyles
   * @description Restores parent document styles when the lightbox is closed in iframe mode.
   * Removes lightbox and fullscreen classes and clears inline styles.
   * @returns {void}
   */
  resetParentStyles() {
    if (this.isIframed()) {
     this.parentDoc.documentElement.classList.remove("lightbox", "fullscreen");
     this.parentDoc.body.removeAttribute("style");
     this.parentDoc.documentElement.removeAttribute("style");
    }
  }

  /**
   * @method cleanupObserversAndListeners
   * @description Disconnects the ResizeObserver if active and nullifies the reference.
   * @returns {void}
   */
  cleanupObserversAndListeners() {
    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
      this.resizeObserver = null;
    }
  }

  /**
   * @method play
   * @description Starts the slideshow by setting an interval that advances to the next image
   * every slideshowDelay milliseconds. Updates button states.
   * @returns {void}
   */
  play() {
    if (this.intervalId) return;
    this.intervalId = setInterval(() => this.next(), this.slideshowDelay);
    this.togglePlayPauseButtons();
    this.body.classList.add("slideshow");
  }

  /**
   * @method pause
   * @description Pauses the slideshow by clearing the interval and resetting the interval ID.
   * Updates button states to reflect paused mode.
   * @returns {void}
   */
  pause() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
    this.togglePlayPauseButtons(true);
    this.body.classList.remove("slideshow");
  }

  /**
   * @method resetSlideshowTimer
   * @description Restarts the slideshow interval from zero when the timer is running, so a
   * manual navigation during playback gets the full delay before the next auto-advance.
   * @returns {void}
   */
  resetSlideshowTimer() {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = setInterval(() => this.next(), this.slideshowDelay);
    }
  }

  /**
   * @method togglePlayPauseButtons
   * @description Toggles the active state of play, pause, prev, and next buttons based
   * on whether the slideshow is paused.
   * @param {boolean} [isPaused=false] - Whether the slideshow is currently paused.
   * @returns {void}
   */
  togglePlayPauseButtons(isPaused = false) {
    if (this.playBtn) { this.playBtn.classList.toggle("active", isPaused); }
    if (this.pauseBtn) { this.pauseBtn.classList.toggle("active", !isPaused); }
    if (this.prevBtn) { this.prevBtn.classList.toggle("active", !isPaused); }
    if (this.nextBtn) { this.nextBtn.classList.toggle("active", !isPaused); }
  }

  /**
   * @method addEventListeners
   * @description Registers all event listeners, currently only the resize listener.
   * @returns {void}
   */
  addEventListeners() { this.addResizeEventListener(); }

  /**
   * @method addResizeEventListener
   * @description Attaches a passive resize listener to the window that resizes and
   * repositions controls when the lightbox is open.
   * @returns {void}
   */
  addResizeEventListener() {
    window.addEventListener("resize", () => {
      if (this.isOpen) {
        this.resize();
        this.repositionControls();
      }
    }, {passive: true});
  }

  /**
   * @method next
   * @description Advances to the next image in the current group, crossfading between the
   * current and next image. Wraps around to the first image if at the end of the group.
   * @returns {void}
   */
  next() {
    if (!this.currGroup) return;
    const pos = this.currImages.findIndex((thumbnail) => thumbnail === this.currThumb) + 1;
    this.transitionTo(this.currImages[pos] || this.currImages[0]);
  }

  /**
   * @method prev
   * @description Goes to the previous image in the current group, crossfading between the
   * current and previous image. Wraps around to the last image if at the beginning of the group.
   * @returns {void}
   */
  prev() {
    if (!this.currGroup) return;
    const pos = this.currImages.findIndex((thumbnail) => thumbnail === this.currThumb) - 1;
    this.transitionTo(this.currImages[pos] || this.currImages[this.currImages.length - 1]);
  }

  /**
   * @method transitionTo
   * @description Starts a crossfade transition to the given thumbnail image. Cancels any
   * transition in progress and keeps the current image displayed while the next one decodes,
   * so a slow load never leaves the box blank. Warms the preload cache for the following images.
   * @param {HTMLElement} thumb - The thumbnail element whose image to transition to.
   * @returns {void}
   */
  transitionTo(thumb) {
    if (!thumb) return;
    this.cancelTransition();
    // A manual jump restarts the slideshow countdown so the next auto-advance gets the
    // full delay instead of firing off the tail of the previous interval.
    this.resetSlideshowTimer();
    const oldImg = this.currImage.img;
    // Decode is async, so rapid next/prev clicks can leave several callbacks pending;
    // the token makes only the latest click's callback run the transition.
    const token = ++this.transitionToken;
    this.currThumb = thumb;
    const newImg = new Image();
    // currImage.img still names the displayed image until the fade actually starts
    // (runTransition), so a click that lands before the target decodes keeps working
    // with the visible image and a close() never targets an unattached placeholder.
    newImg.src = thumb.getAttribute(this.data_attr) || thumb.src;
    if (typeof newImg.decode === "function") {
      newImg.decode().then(() => {
        if (token !== this.transitionToken) { return; }
        this.runTransition(oldImg, newImg);
      }).catch(() => {
        // keep the current image displayed when the next one cannot be decoded
      });
    } else {
      newImg.onload = () => {
        if (token === this.transitionToken) { this.runTransition(oldImg, newImg); }
      };
      newImg.onerror = () => {};
      // img.complete is true for failed loads too; require actual dimensions
      if (newImg.complete && newImg.naturalWidth > 0) { this.runTransition(oldImg, newImg); }
    }
    this.preload();
  }

  /**
   * @method cancelTransition
   * @description Aborts any transition in progress: clears the settle timer and removes the
   * image that is not on screen. The image that is visible (or starting to fade in) becomes
   * the current display for the next transition.
   * @returns {void}
   */
  cancelTransition() {
    if (this.transition) {
      clearTimeout(this.settleTimer);
      this.settleTimer = null;
      // The incoming image is invisible until its fade-in starts, so when a transition is
      // canceled before the fade begins the outgoing image must be restored as the current
      // display. No sizes ever change during a transition, so opacity is all to restore.
      if (parseFloat(this.transition.in.style.opacity) === 0) {
        this.transition.in.remove();
        this.currImage.img = this.transition.out;
        this.transition.out.style.transition = "none";
        this.transition.out.style.opacity = "1";
      } else {
        this.transition.out.remove();
      }
      this.transition = null;
    }
  }

  /**
   * @method runTransition
   * @description Performs the image swap as a pure opacity cross-fade. Both images carry
   * the same max-width/max-height constraints, so the browser scales each proportionally
   * from its natural dimensions and the frame (border included) always matches the
   * rendered content exactly - images never distort and the border never has to adjust
   * to the image after it appears. The incoming image is first staged offscreen as a
   * direct child of the lightbox so the full CSS cascade (border, padding, radius) is
   * laid out and rasterized at final size before it fades in.
   * @param {HTMLImageElement} oldImg - The image being replaced.
   * @param {HTMLImageElement} newImg - The incoming image.
   * @returns {void}
   */
  runTransition(oldImg, newImg) {
    const fadeMs = 1000;
    // The new image becomes the displayed image only once the fade starts.
    this.currImage.img = newImg;
    const centered = { left: "0", right: "0", top: "0", bottom: "0", margin: "auto" };
    Object.assign(newImg.style, { position: "fixed", left: "-9999px", top: "0", animation: "none", opacity: "0" }, this.getMaxSizing());
    this.box.appendChild(newImg);
    // Fixed-position elements still paint, so the flush commits the offscreen raster too.
    void newImg.offsetWidth;
    Object.assign(newImg.style, centered);
    // Flush again: both images must be committed at their final positions with opacity 0
    // before the fade, otherwise the style changes coalesce into one frame and it jumps.
    void newImg.offsetWidth;
    // Fade the old out while the new fades in - the only animated property is opacity.
    oldImg.style.transition = `opacity ${fadeMs}ms ease`;
    oldImg.style.opacity = "0";
    newImg.style.transition = `opacity ${fadeMs}ms ease`;
    newImg.style.opacity = "1";
    // transition stays set until settleDone so a click (or close) mid-fade can abort both
    // the fade and the settle cleanup together.
    this.transition = { out: oldImg, in: newImg };
    this.settleTimer = setTimeout(() => this.settleDone(newImg, oldImg), fadeMs);
  }

  /**
   * @method settleDone
   * @description Removes the transition styling once the cross-fade finishes and drops the
   * outgoing image. The max-width/max-height constraints stay inline - the browser keeps
   * scaling proportionally - so the border stays exactly where the transition left it.
   * @param {HTMLImageElement} newImg - The image to restore normal styling on.
   * @param {HTMLImageElement} oldImg - The outgoing image to remove from the lightbox.
   * @returns {void}
   */
  settleDone(newImg, oldImg) {
    this.settleTimer = null;
    this.transition = null;
    oldImg.remove();
    // animation stays "none" inline: restoring the CSS fadeAndBlur here would re-trigger
    // the entrance animation and flash the settled image. Everything else returns to the
    // resize()-managed state; position returns to the CSS absolute/flex-centered layout.
    Object.assign(newImg.style, {
      left: "", right: "", top: "", bottom: "", margin: "",
      opacity: "", transform: "", transition: "", position: ""
    });
    this.resize();
  }

  /**
   * @method isIframed
   * @description Checks whether the page is running inside an iframe by inspecting
   * the "iframed" class on the html element or comparing window.top to window.self.
   * @returns {boolean} True if the page is in an iframe context.
   */
  isIframed() {
    return document.documentElement.classList.contains("iframed") || window.top !== window.self;
  }

  /**
   * @method setupResizeObserver
   * @description Creates a ResizeObserver on the document body (or parent body in iframe mode)
   * that triggers resize and control repositioning when the container size changes.
   * @returns {void}
   */
  setupResizeObserver() {
    this.resizeObserver = new ResizeObserver(entries => {
      entries.forEach(entry => {
        if (entry.target === (this.isIframed() ? this.parentDoc.body : document.body) && this.isOpen) {
          this.resize();
          this.repositionControls();
        }
      });
    });
    this.resizeObserver.observe(this.isIframed() ? this.parentDoc.body : document.body);
  }

  /**
   * @method repositionControls
   * @description Vertically centers the prev and next navigation buttons relative to the
   * viewport (or parent viewport in iframe mode).
   * @returns {void}
   */
  repositionControls() {
    if (this.prevBtn && this.nextBtn) {
      const btnTop = (this.isIframed() ? window.parent.innerHeight : window.innerHeight) / 2 - (this.prevBtn.offsetHeight / 2);
      [this.prevBtn, this.nextBtn].forEach(btn => {
        btn.style.top = `${btnTop}px`;
      });
    }
  }
}

export { Lightbox };