util/gallery.js

/**
 * Inline Gallery installer shared by the standalone component and consuming themes.
 * Installing it does not enable or download either SDK. Keep it self-contained after Babel so it
 * can also be used without the Hexo asset generator.
 * @module util/gallery
 */
function installGallery(window) {
  if (window.HexoGallery) return;

  const document = window.document;
  const requests = new Map();
  const layouts = new Map();
  const lightboxes = new Map();

  function destroyLightbox(container) {
    const state = lightboxes.get(container);
    if (!state) return;
    container.removeEventListener('click', state.click);
    // The SDK removes its container through parentNode, even after a host replaced the body.
    const dialog = state.instance.$container && state.instance.$container.get();
    if (dialog && !dialog.parentNode) {
      dialog.style.display = 'none';
      document.body.appendChild(dialog);
    }
    if (state.instance.lgOpened) {
      state.instance.settings.startAnimationDuration = 0;
      state.instance.settings.backdropDuration = 0;
    }
    state.instance.destroy();
    lightboxes.delete(container);
  }

  function lightbox(container, enabled) {
    if (!enabled) {
      destroyLightbox(container);
      return;
    }
    if (typeof window.lightGallery !== 'function') return;
    const plugins = (window.HexoGallery.lightGalleryPlugins || []).map((name) => window[name]);
    if (plugins.some((plugin) => typeof plugin !== 'function')) return;
    const items = Array.from(container.querySelectorAll('a.gallery-item[href]'));
    if (!items.length) {
      destroyLightbox(container);
      return;
    }
    const elements = items.map((link) => {
      const image = link.querySelector('img');
      const caption = document.createElement('span');
      caption.textContent = image ? image.alt : '';
      return {
        src: link.href,
        thumb: image ? image.currentSrc || image.src : link.href,
        subHtml: caption.innerHTML,
        alt: caption.innerHTML.replace(/"/g, '"'),
      };
    });
    const signature = JSON.stringify(elements);
    let state = lightboxes.get(container);
    if (state) {
      if (!sameItems(state.items, items) || state.signature !== signature) {
        if (state.instance.lgOpened) {
          state.instance.updateSlides(
            elements,
            Math.min(state.instance.index, elements.length - 1),
          );
        } else {
          state.instance.refresh(elements);
        }
        state.items = items;
        state.signature = signature;
      }
      return;
    }
    const instance = window.lightGallery(container, {
      dynamic: true,
      dynamicEl: elements,
      plugins,
    });
    state = { instance, items, signature, click: null };
    state.click = (event) => {
      if (
        event.defaultPrevented ||
        event.button !== 0 ||
        event.ctrlKey ||
        event.metaKey ||
        event.shiftKey ||
        event.altKey
      )
        return;
      const link = event.target.closest('a.gallery-item');
      const index = state.items.indexOf(link);
      if (index < 0) return;
      event.preventDefault();
      instance.openGallery(index, link);
    };
    container.addEventListener('click', state.click);
    lightboxes.set(container, state);
  }

  function sameItems(items, current) {
    return (
      items.length === current.length && Array.from(items).every((item, i) => item === current[i])
    );
  }

  function enhance(container) {
    container.querySelectorAll('img:not(.not-gallery-item)').forEach((image) => {
      // Third-party SDK icons are controls, not article images. Script-load retries also
      // scan icons inserted after the article's initial enhancement.
      if (image.closest('a, .sharethis-inline-share-buttons, .a2a_kit, [data-share-provider]'))
        return;
      const link = document.createElement('a');
      link.className = 'gallery-item';
      const src = image.getAttribute('src');
      if (src !== null) link.setAttribute('href', src);
      image.parentNode.insertBefore(link, image);
      link.appendChild(image);
      if (image.alt) {
        const caption = document.createElement('p');
        caption.className = 'has-text-centered is-size-6 caption';
        caption.textContent = image.alt;
        link.appendChild(caption);
      }
    });
  }

  function apply(root, options) {
    const containers = root === document ? root.querySelectorAll('.article') : [root];
    containers.forEach(enhance);
    // Do not capture an incomplete article while HTML is still being parsed.
    if (document.readyState === 'loading') return;
    containers.forEach((container) => lightbox(container, options.lightGallery));
    if (options.justifiedGallery && typeof window.JustifiedGallery === 'function') {
      const galleries = Array.from(root.querySelectorAll('.justified-gallery'));
      if (root !== document && root.matches('.justified-gallery')) galleries.unshift(root);
      galleries.forEach((gallery) => {
        const paragraphs = new Set(
          Array.from(
            gallery.querySelectorAll(':scope > p > .gallery-item'),
            (link) => link.parentNode,
          ),
        );
        paragraphs.forEach((paragraph) => {
          paragraph.replaceWith(...paragraph.childNodes);
        });
        gallery.querySelectorAll(':scope > .gallery-item > .caption').forEach((caption) => {
          caption.classList.add('jg-caption');
        });
        let state = layouts.get(gallery);
        const items = () =>
          Array.from(gallery.children).filter(
            (item) =>
              !item.classList.contains('jg-spinner') &&
              (item.matches(state.instance.settings.selector) || item.matches('div')),
          );
        if (state) {
          if (sameItems(state.items, items())) return;
          // init refreshes entries but starts another width watcher; stop and rewind first.
          state.instance.widthWatcher.stop();
          state.instance.stopImgAnalyzerStarter();
          state.instance.rewind();
        } else {
          // The SDK reuses .jg-caption and safely creates captions for pre-existing links.
          state = { instance: new window.JustifiedGallery(gallery) };
          layouts.set(gallery, state);
        }
        state.instance.init();
        state.items = items();
      });
    }
  }

  function prune() {
    layouts.forEach((state, gallery) => {
      if (!gallery.isConnected) destroyLayout(gallery);
    });
    lightboxes.forEach((state, container) => {
      if (!container.isConnected) destroyLightbox(container);
    });
    requests.forEach((options, root) => {
      if (root !== document && !root.isConnected) requests.delete(root);
    });
  }

  function destroyLayout(gallery) {
    const { instance } = layouts.get(gallery);
    // The SDK's destroy does not stop an active loading spinner.
    instance.stopLoadingSpinnerAnimation();
    instance.destroy();
    layouts.delete(gallery);
  }

  function retry() {
    prune();
    requests.forEach((options, root) => apply(root, options));
  }

  window.HexoGallery = {
    /**
     * Enhance images in a content container, optionally initializing the two Gallery SDKs.
     * Repeated calls reuse existing wrappers, captions and plugin instances. Missing SDKs can be
     * retried by calling init again; DOM ready, window load and subsequent script loads also retry
     * connected containers with their latest options. SDK initialization waits for DOM readiness.
     * Changed item collections refresh their existing SDK instance. Disabling the lightbox destroys
     * it; the independently managed layout plugin is unchanged.
     * @param {Document|Element} [root=document] Content container; document targets .article images
     *     and lightboxes, and all .justified-gallery elements.
     * @param {Object} [options={}] SDK opt-ins; omitted flags disable plugin initialization.
     * @param {boolean} [options.lightGallery=false] Initialize lightGallery when available.
     * @param {boolean} [options.justifiedGallery=false] Initialize justifiedGallery when available.
     */
    init: function (root, options) {
      root = root || document;
      options = options || {};
      const enabled = {
        lightGallery: options.lightGallery === true,
        justifiedGallery: options.justifiedGallery === true,
      };
      prune();
      requests.set(root, enabled);
      apply(root, enabled);
    },
    /** Destroy lightboxes, layouts and pending retries before content is replaced (e.g. PJAX). */
    destroy: function (root) {
      root = root || document;
      lightboxes.forEach((state, container) => {
        if (root === document || root === container || root.contains(container))
          destroyLightbox(container);
      });
      layouts.forEach((state, gallery) => {
        if (root === document || root === gallery || root.contains(gallery)) destroyLayout(gallery);
      });
      requests.forEach((options, target) => {
        if (root === document || root === target || root.contains(target)) requests.delete(target);
      });
    },
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', retry, { once: true });
  }
  if (document.readyState !== 'complete') window.addEventListener('load', retry, { once: true });
  document.addEventListener('hexo:gallery:ready', retry);
  // Deferred scripts may arrive after init, including on the first enabled page reached by PJAX.
  // Retry the latest requests, rather than capturing a previous page's enabled configuration.
  document.addEventListener(
    'load',
    (event) => {
      if (event.target.tagName === 'SCRIPT') retry();
    },
    true,
  );
}

module.exports = '(' + installGallery.toString() + ')(window);';