Skip to content
wiki.fftac.org

Manuscript Workstation - Source Excerpt 13

Back to Manuscript Workstation

Summary

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

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

if (handle === 'move') {
        nextRect.x += deltaX;
        nextRect.y += deltaY;
      }

      if (handle.includes('n')) {
        nextRect.y += deltaY;
        nextRect.height -= deltaY;
      }

      if (handle.includes('s')) {
        nextRect.height += deltaY;
      }

      if (handle.includes('e')) {
        nextRect.width += deltaX;
      }

      if (handle.includes('w')) {
        nextRect.x += deltaX;
        nextRect.width -= deltaX;
      }

      const normalized = normalizeCropRect(nextRect);
      if (JSON.stringify(normalized) === JSON.stringify(normalizeCropRect(rect))) {
        return false;
      }

      Object.assign(rect, normalized);
      return true;
    }

    bindKeyboard() {
      document.addEventListener('keydown', (event) => {
        if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
          if (this.canEdit && !isEditableTarget(event.target)) {
            event.preventDefault();
            this.saveDirtyFolios();
          }
          return;
        }

        if (event.target instanceof HTMLElement && event.target.closest('[data-workstation-crop-handle]')) {
          return;
        }

        if (isEditableTarget(event.target) || event.altKey || event.ctrlKey || event.metaKey) {
          return;
        }

        if (this.isAnyModalOpen()) {
          return;
        }

        if (event.key === 'ArrowLeft' && this.prevLink && !this.prevLink.hidden) {
          event.preventDefault();
          this.turnPage(this.getNavigationHref(this.prevLink), 'prev');
        }

        if (event.key === 'ArrowRight' && this.nextLink && !this.nextLink.hidden) {
          event.preventDefault();
          this.turnPage(this.getNavigationHref(this.nextLink), 'next');
        }

        if (event.key === 'PageUp' && this.prevLink && !this.prevLink.hidden) {
          event.preventDefault();
          this.turnPage(this.getNavigationHref(this.prevLink), 'prev');
        }

        if (event.key === 'PageDown' && this.nextLink && !this.nextLink.hidden) {
          event.preventDefault();
          this.turnPage(this.getNavigationHref(this.nextLink), 'next');
        }

        if (event.key === '0') {
          event.preventDefault();
          this.fitTo(this.getResetFitMode());
        }

        if (event.key === '+' || event.key === '=') {
          event.preventDefault();
          this.zoomTo(this.state.scale + 0.2);
        }

        if (event.key === '-') {
          event.preventDefault();
          this.zoomTo(this.state.scale - 0.2);
        }

        if (event.key.toLowerCase() === 'f' && this.fullscreenButton && this.isFullscreenSupported()) {
          event.preventDefault();
          this.fullscreenButton.click();
        }
      });
    }

    renderAllCrops() {
      this.folios.forEach((folio) => {
        this.renderCrop(folio);
      });
    }

    renderCrop(folio) {
      if (!folio.cropLayer) {
        return;
      }

      const crop = normalizeCropRect(folio.crop);
      folio.crop = crop;
      folio.cropLayer.style.setProperty('--crop-x', `${crop.x * 100}%`);
      folio.cropLayer.style.setProperty('--crop-y', `${crop.y * 100}%`);
      folio.cropLayer.style.setProperty('--crop-width', `${crop.width * 100}%`);
      folio.cropLayer.style.setProperty('--crop-height', `${crop.height * 100}%`);
    }

    setCrop(folio, rect, options = {}) {
      const { dirty = true, autoDetected = false } = options;
      folio.crop = normalizeCropRect(rect);
      this.renderCrop(folio);

      if (dirty) {
        this.dirtyFolios.add(folio.slug);
      }

      if (autoDetected) {
        this.autoDetected.add(folio.slug);
      } else {
        this.autoDetected.delete(folio.slug);
      }

      this.updateDirtyState();
    }

    getFitScale(mode) {
      const viewportWidth = Math.max(1, this.viewport.clientWidth - 48);
      const viewportHeight = Math.max(1, this.viewport.clientHeight - 48);
      const contentWidth = Math.max(1, this.content.offsetWidth);
      const contentHeight = Math.max(1, this.content.offsetHeight);

      if (mode === 'height') {
        return viewportHeight / contentHeight;
      }

      if (mode === 'actual') {
        return 1;
      }

      return viewportWidth / contentWidth;
    }

    fitTo(mode = 'width', announce = true) {
      const nextMode = mode === 'height' || mode === 'actual' ? mode : 'width';
      this.state.fitMode = nextMode;
      this.baseFitMode = nextMode;
      this.state.scale = this.getFitScale(nextMode);
      this.state.panX = 0;
      this.state.panY = 0;
      this.syncButtons();
      this.renderSurface();

      if (!announce) {
        return;
      }

      this.updateStatus(
        mode === 'height'
          ? 'Viewer fit to height.'
          : mode === 'actual'
            ? 'Viewer set to 100%.'
            : 'Viewer fit to width.'
      );
    }

    clampPan() {
      const { maxX, maxY } = this.getPanExtents();

      this.state.panX = clamp(this.state.panX, -maxX, maxX);
      this.state.panY = clamp(this.state.panY, -maxY, maxY);
    }

    renderSurface() {
      this.clampPan();
      this.surface.style.transform = `translate(-50%, -50%) translate(${this.state.panX}px, ${this.state.panY}px) scale(${this.state.scale})`;
      this.requestViewerStateSync();
    }

    zoomTo(nextScale, clientX = null, clientY = null) {
      const bounds = this.getScaleBounds();
      const clampedScale = clamp(nextScale, bounds.min, bounds.max);

      if (Math.abs(clampedScale - this.state.scale) < 0.001) {
        return;
      }

      if (typeof clientX === 'number' && typeof clientY === 'number') {
        const viewportRect = this.viewport.getBoundingClientRect();
        const viewportX = clientX - viewportRect.left - viewportRect.width / 2;
        const viewportY = clientY - viewportRect.top - viewportRect.height / 2;
        const contentCenterX = this.content.offsetWidth / 2;
        const contentCenterY = this.content.offsetHeight / 2;
        const contentX = (viewportX - this.state.panX) / this.state.scale + contentCenterX;
        const contentY = (viewportY - this.state.panY) / this.state.scale + contentCenterY;

        this.state.panX = viewportX - (contentX - contentCenterX) * clampedScale;
        this.state.panY = viewportY - (contentY - contentCenterY) * clampedScale;
      }

      this.state.fitMode = 'custom';
      this.state.scale = clampedScale;
      this.syncButtons();
      this.renderSurface();
      this.updateStatus(`Viewer zoom ${Math.round(this.state.scale * 100)}%.`);
    }

    ensureActiveImagesReady(callback) {
      const activeFolios = this.getVisibleFolios();
      if (!activeFolios.length) {
        callback();
        return;
      }

      let remaining = 0;
      const done = () => {
        remaining -= 1;
        if (remaining <= 0) {
          callback();
        }
      };

      activeFolios.forEach((folio) => {
        if (!folio.image) {
          return;
        }

        remaining += 1;
        if (folio.image.complete && folio.image.naturalWidth > 0) {
          done();
          return;
        }

        folio.image.addEventListener('load', done, { once: true });
        folio.image.addEventListener('error', done, { once: true });
      });

      if (!remaining) {
        callback();
      }
    }

    swapImageSources() {
      const visibleFolios = this.getVisibleFolios();
      const activeFolios = visibleFolios.length ? visibleFolios : [this.getCurrentFolio()].filter(Boolean);
      const activeSlugs = new Set(activeFolios.map((folio) => folio.slug));
      const targetLevel = this.state.rawMode ? 'full' : 'standard';

      return Promise.all(
        this.folios.map((folio) => {
          const nextLevel = activeSlugs.has(folio.slug) ? targetLevel : 'preview';
          return this.applyFolioImageSource(folio, nextLevel);
        })
      );
    }

    async calibrateVisibleFolios() {
      const activeFolios = this.getVisibleFolios();

      for (const folio of activeFolios) {
        if (!folio.image) {
          continue;
        }

        const profile = await this.sampleBorderProfile(folio.image);
        if (profile) {
          this.calibrationProfiles.set(folio.slug, profile);
        }
      }

      this.updateStatus('Calibration complete. Visible folio borders were sampled for auto-crop detection.');
    }

    async autoCropVisibleFolios() {
      const activeFolios = this.getVisibleFolios();
      if (!activeFolios.length) {
        return;
      }

      await this.calibrateVisibleFolios();

      for (const folio of activeFolios) {
        if (!folio.image) {
          continue;
        }