Skip to content
wiki.fftac.org

Book Shell - Source Excerpt 15

Back to Book Shell

Summary

This source excerpt preserves a bounded section of Spiralist/wp-content/plugins/ns12-manuscript/assets/js/book-shell.js so readers can inspect the evidence without opening the full source file.

**Source path:** Spiralist/wp-content/plugins/ns12-manuscript/assets/js/book-shell.js

const deltaY = event.changedTouches[0].clientY - swipeState.startY;
          resetSwipe();

          if (Math.abs(deltaX) < 72 || Math.abs(deltaX) < Math.abs(deltaY) * 1.4) {
            return;
          }

          if (deltaX > 0 && prevUrl) {
            navigateToPage(prevUrl, 'prev');
          }

          if (deltaX < 0 && nextUrl) {
            navigateToPage(nextUrl, 'next');
          }
        },
        { passive: true }
      );

      stage.addEventListener('touchcancel', resetSwipe, { passive: true });
    }

    document.addEventListener('keydown', (event) => {
      const target = event.target;
      const tagName = target && target.tagName ? `${target.tagName}`.toUpperCase() : '';
      const isEditable = Boolean(
        target &&
        (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName))
      );

      if (
        isEditable ||
        document.body.classList.contains('is-manuscript-zoom-open') ||
        event.altKey ||
        event.ctrlKey ||
        event.metaKey ||
        event.shiftKey
      ) {
        return;
      }

      if (event.key === 'ArrowLeft' && prevUrl) {
        event.preventDefault();
        navigateToPage(prevUrl, 'prev');
      }

      if (event.key === 'ArrowRight' && nextUrl) {
        event.preventDefault();
        navigateToPage(nextUrl, 'next');
      }
    });

    if (!zoom || !viewport || !image) {
      return;
    }

    const openers = reader.querySelectorAll('[data-manuscript-zoom-open]');
    const closers = zoom.querySelectorAll('[data-manuscript-zoom-close]');
    const zoomInButton = zoom.querySelector('[data-manuscript-zoom-in]');
    const zoomOutButton = zoom.querySelector('[data-manuscript-zoom-out]');
    const resetButton = zoom.querySelector('[data-manuscript-zoom-reset]');
    const percent = zoom.querySelector('[data-manuscript-zoom-percent]');
    const message = zoom.querySelector('[data-manuscript-zoom-message]');
    const defaultMessage = message ? message.textContent || '' : '';
    const state = {
      scale: 1,
      minScale: 1,
      maxScale: 6,
      x: 0,
      y: 0,
      dragging: false,
      lastPointerX: 0,
      lastPointerY: 0,
      pinching: false,
      pinchDistance: 0,
      pinchScale: 1,
      framePadding: 32,
      previousFocus: null,
    };

    const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
    const getTouchDistance = (touches) => {
      if (!touches || touches.length < 2) {
        return 0;
      }

      const deltaX = touches[0].clientX - touches[1].clientX;
      const deltaY = touches[0].clientY - touches[1].clientY;
      return Math.hypot(deltaX, deltaY);
    };
    const getTouchMidpoint = (touches) => ({
      clientX: (touches[0].clientX + touches[1].clientX) / 2,
      clientY: (touches[0].clientY + touches[1].clientY) / 2,
    });
    const stopPinching = () => {
      state.pinching = false;
      state.pinchDistance = 0;
      state.pinchScale = state.scale;
    };

    const getBaseDimensions = () => {
      const naturalWidth = image.naturalWidth || viewport.clientWidth || 1;
      const naturalHeight = image.naturalHeight || viewport.clientHeight || 1;
      const availableWidth = Math.max(1, viewport.clientWidth - state.framePadding);
      const availableHeight = Math.max(1, viewport.clientHeight - state.framePadding);
      const ratio = naturalWidth / naturalHeight || 1;

      let width = availableWidth;
      let height = width / ratio;

      if (height > availableHeight) {
        height = availableHeight;
        width = height * ratio;
      }

      return { width, height, availableWidth, availableHeight };
    };

    const clampPan = () => {
      const { width, height, availableWidth, availableHeight } = getBaseDimensions();
      const maxX = Math.max(0, (width * state.scale - availableWidth) / 2);
      const maxY = Math.max(0, (height * state.scale - availableHeight) / 2);

      state.x = clamp(state.x, -maxX, maxX);
      state.y = clamp(state.y, -maxY, maxY);
    };

    const updateControls = () => {
      const atBaseScale = state.scale <= state.minScale + 0.01;
      const atMaxScale = state.scale >= state.maxScale - 0.01;

      if (zoomOutButton) {
        zoomOutButton.disabled = atBaseScale;
      }

      if (resetButton) {
        resetButton.disabled = atBaseScale && Math.abs(state.x) < 0.5 && Math.abs(state.y) < 0.5;
      }

      if (zoomInButton) {
        zoomInButton.disabled = atMaxScale;
      }

      if (percent) {
        percent.textContent = `${Math.round(state.scale * 100)}%`;
      }

      if (message) {
        message.textContent = atBaseScale
          ? defaultMessage
          : t('manuscriptZoomPanInstructions', 'Drag or pinch to pan across the page. Use the arrow keys to nudge the view and press 0 to reset.');
      }
    };

    const render = () => {
      clampPan();
      viewport.classList.toggle('is-zoomed', state.scale > state.minScale + 0.01);
      viewport.classList.toggle('is-dragging', state.dragging);
      image.style.transform = `translate(-50%, -50%) translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
      updateControls();
    };

    const fitToViewport = () => {
      stopPinching();
      state.scale = state.minScale;
      state.x = 0;
      state.y = 0;
      render();
    };

    const zoomTo = (nextScale, clientX = null, clientY = null) => {
      const clampedScale = clamp(nextScale, state.minScale, state.maxScale);
      if (Math.abs(clampedScale - state.scale) < 0.001) {
        return;
      }

      const rect = viewport.getBoundingClientRect();
      const originX = typeof clientX === 'number' ? clientX - rect.left - rect.width / 2 : 0;
      const originY = typeof clientY === 'number' ? clientY - rect.top - rect.height / 2 : 0;
      const ratio = clampedScale / state.scale;

      state.x = originX - (originX - state.x) * ratio;
      state.y = originY - (originY - state.y) * ratio;
      state.scale = clampedScale;
      render();
    };

    const stopDragging = (event = null) => {
      if (!state.dragging) {
        return;
      }

      state.dragging = false;

      if (event) {
        releasePointerCapture(viewport, event.pointerId);
      }

      render();
    };

    const ensureImageReady = (callback) => {
      if (image.complete && image.naturalWidth > 0) {
        callback();
        return;
      }

      image.addEventListener(
        'load',
        () => {
          callback();
        },
        { once: true }
      );
    };

    const openZoom = () => {
      state.previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
      zoom.hidden = false;
      zoom.setAttribute('aria-hidden', 'false');
      document.body.classList.add('is-manuscript-zoom-open');
      ensureImageReady(() => {
        fitToViewport();
        viewport.focus({ preventScroll: true });
      });
    };

    const closeZoom = () => {
      stopDragging();
      stopPinching();
      fitToViewport();
      zoom.hidden = true;
      zoom.setAttribute('aria-hidden', 'true');
      document.body.classList.remove('is-manuscript-zoom-open');

      if (state.previousFocus && typeof state.previousFocus.focus === 'function') {
        state.previousFocus.focus({ preventScroll: true });
      }
    };

    openers.forEach((opener) => {
      opener.addEventListener('click', (event) => {
        event.preventDefault();
        openZoom();
      });

      const tagName = opener.tagName ? `${opener.tagName}`.toUpperCase() : '';
      if (tagName !== 'BUTTON' && tagName !== 'A') {
        opener.addEventListener('keydown', (event) => {
          if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault();
            openZoom();
          }
        });
      }
    });

    closers.forEach((closer) => {
      closer.addEventListener('click', (event) => {
        event.preventDefault();
        closeZoom();
      });
    });

    if (zoomInButton) {
      zoomInButton.addEventListener('click', () => {
        zoomTo(state.scale + 0.25);
      });
    }

    if (zoomOutButton) {
      zoomOutButton.addEventListener('click', () => {
        zoomTo(state.scale - 0.25);
      });
    }

    if (resetButton) {
      resetButton.addEventListener('click', () => {
        fitToViewport();
      });
    }

    viewport.addEventListener(
      'wheel',
      (event) => {
        if (zoom.hidden) {
          return;
        }

        event.preventDefault();
        const step = event.deltaY < 0 ? 0.2 : -0.2;
        zoomTo(state.scale + step, event.clientX, event.clientY);
      },
      { passive: false }
    );