Skip to content
wiki.fftac.org

Manuscript Workstation - Source Excerpt 06

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

viewerStateParamKeys.forEach((key) => {
        url.searchParams.delete(key);
      });

      Object.entries(this.serializeViewerState(mode)).forEach(([key, value]) => {
        if (`${value || ''}`.trim()) {
          url.searchParams.set(key, value);
        }
      });

      return url.toString();
    }

    syncNavigationStateUrls() {
      const mode = 'minimal';

      this.pageLinks.forEach((link) => {
        const baseHref = this.getLinkBaseHref(link);
        if (baseHref) {
          link.setAttribute('href', this.buildViewerStateUrl(baseHref, { mode }));
        }

        const baseSpreadHref = this.getLinkBaseSpreadHref(link);
        if (baseSpreadHref) {
          link.dataset.spreadHref = this.buildViewerStateUrl(baseSpreadHref, { mode });
        }
      });

      if (this.jumpSelect) {
        Array.from(this.jumpSelect.options || []).forEach((option) => {
          const baseValue = this.getOptionBaseValue(option);
          if (baseValue) {
            option.value = this.buildViewerStateUrl(baseValue, { mode });
          }
        });
      }
    }

    requestViewerStateSync(options = {}) {
      if (!this.viewerStateReady || this.isApplyingViewerState) {
        return;
      }

      const { immediate = false } = options;
      const commit = () => {
        this.syncNavigationStateUrls();

        if (!window.history || typeof window.history.replaceState !== 'function') {
          return;
        }

        const nextUrl = this.buildViewerStateUrl(window.location.href, { mode: 'minimal' });
        if (nextUrl && nextUrl !== window.location.href) {
          window.history.replaceState(window.history.state, '', nextUrl);
        }
      };

      window.clearTimeout(this.viewerStateSyncTimer);

      if (immediate) {
        commit();
        return;
      }

      this.viewerStateSyncTimer = window.setTimeout(commit, 140);
    }

    refreshViewportState() {
      if (this.state.fitMode === 'actual' || this.state.fitMode === 'custom') {
        this.renderSurface();
        return;
      }

      this.fitTo(this.state.fitMode, false);
    }

    getScaleBounds() {
      const widthScale = this.getFitScale('width');
      const heightScale = this.getFitScale('height');
      const minScale = Math.min(widthScale || 1, heightScale || 1, 1) * 0.75;

      return {
        min: Math.max(0.15, minScale || 0.15),
        max: this.state.rawMode ? 10 : 6,
      };
    }

    ensurePannableScale() {
      const { maxX, maxY } = this.getPanExtents();
      if (maxX > 0 || maxY > 0) {
        return false;
      }

      const viewportWidth = Math.max(1, this.viewport.clientWidth - 24);
      const viewportHeight = Math.max(1, this.viewport.clientHeight - 24);
      const contentWidth = Math.max(1, this.content.offsetWidth);
      const contentHeight = Math.max(1, this.content.offsetHeight);
      const bounds = this.getScaleBounds();
      const targetScale = Math.max(
        this.state.scale,
        (viewportWidth + 120) / contentWidth,
        (viewportHeight + 120) / contentHeight
      );
      const clampedScale = clamp(targetScale, bounds.min, bounds.max);

      if (clampedScale <= this.state.scale + 0.001) {
        return false;
      }

      this.state.fitMode = 'custom';
      this.state.scale = clampedScale;
      this.syncButtons();
      return true;
    }

    canUseSwipeTurnGesture() {
      if (!this.isBrowserRoute || this.isTurningPage) {
        return false;
      }

      const { maxX, maxY } = this.getPanExtents();
      return maxX <= 18 && maxY <= 18;
    }

    isHorizontalTurnGesture(deltaX = 0, deltaY = 0) {
      const absX = Math.abs(deltaX);
      const absY = Math.abs(deltaY);

      return absX > 12 && absX >= absY * 1.15;
    }

    hasTurnTarget(direction = 'next') {
      const link = direction === 'prev' ? this.prevLink : this.nextLink;
      return link instanceof HTMLElement && !link.hidden && Boolean(this.getNavigationHref(link));
    }

    getDragTurnThreshold() {
      if (!this.viewport) {
        return 108;
      }

      return Math.min(180, Math.max(96, this.viewport.clientWidth * 0.14));
    }

    setDragTurnOffset(offsetX = 0) {
      if (!this.content) {
        return;
      }

      const maxOffset = this.viewport
        ? Math.min(220, Math.max(72, this.viewport.clientWidth * 0.24))
        : 180;
      const clampedOffset = clamp(offsetX, -maxOffset, maxOffset);

      this.content.style.setProperty('--spiralist-workstation-drag-shift', `${clampedOffset}px`);
    }

    resetDragTurnOffset() {
      if (!this.content) {
        return;
      }

      this.content.style.setProperty('--spiralist-workstation-drag-shift', '0px');
    }

    updateStatus(message) {
      if (this.statusTarget) {
        this.statusTarget.textContent = message;
      }
    }

    updateRuntimeStatus(message) {
      if (this.runtimeStatus) {
        this.runtimeStatus.textContent = message;
      }
    }

    getRuntimeHeaders(includeJson = false) {
      const headers = {
        Accept: 'application/json',
      };

      if (includeJson) {
        headers['Content-Type'] = 'application/json';
      }

      if (config.restNonce) {
        headers['X-WP-Nonce'] = config.restNonce;
      }

      return headers;
    }

    normalizeRuntimeNode(entry = {}) {
      if (typeof entry === 'string') {
        return {
          id: entry,
          label: entry,
          inputState: '',
          outputState: '',
        };
      }

      if (!entry || typeof entry !== 'object') {
        return null;
      }

      const id = `${entry.node_id || entry.nodeId || entry.id || ''}`.trim();
      if (!id) {
        return null;
      }

      return {
        id,
        label: `${entry.label || entry.title || id}`.trim(),
        inputState: `${entry.input_state || entry.inputState || entry.input || ''}`.trim(),
        outputState: `${entry.output_state || entry.outputState || entry.output || ''}`.trim(),
      };
    }

    normalizeRuntimePath(payload = {}) {
      const nextNodes = Array.isArray(payload.next_nodes)
        ? payload.next_nodes
        : (Array.isArray(payload.nextNodes) ? payload.nextNodes : []);
      const activeSymbols = Array.isArray(payload.active_symbols)
        ? payload.active_symbols
        : (Array.isArray(payload.activeSymbols) ? payload.activeSymbols : []);

      return {
        currentState: `${payload.current_state || payload.currentState || ''}`.trim(),
        currentNode: `${payload.current_node || payload.currentNode || ''}`.trim(),
        activeSymbols: activeSymbols.map((symbol) => `${symbol || ''}`.trim()).filter(Boolean),
        history: Array.isArray(payload.history) ? payload.history.filter((event) => event && typeof event === 'object') : [],
        nextNodes: nextNodes.map((entry) => this.normalizeRuntimeNode(entry)).filter(Boolean),
        storage: `${payload.storage || ''}`.trim(),
      };
    }

    getStoredRuntimePath() {
      if (config.isUserLoggedIn) {
        return null;
      }

      const stored = readStoredRuntimePath();
      if (!stored) {
        return null;
      }

      const path = this.normalizeRuntimePath({
        ...stored,
        storage: 'browser',
      });

      return path.currentState || path.currentNode || path.history.length ? path : null;
    }

    persistRuntimePath() {
      if (config.isUserLoggedIn || !this.runtimePath.currentState) {
        return;
      }

      writeStoredRuntimePath({
        ...this.runtimePath,
        storage: 'browser',
        storedAt: new Date().toISOString(),
      });
    }

    clearRuntimePathStorage() {
      if (!config.isUserLoggedIn) {
        clearStoredRuntimePath();
      }
    }

    announceRuntimePath(source = 'workstation') {
      emitRuntimePathUpdate(this.runtimePath, source);
    }

    setRuntimeText(target, value, fallback = 'Not available') {
      if (target) {
        target.textContent = value || fallback;
      }
    }

    renderRuntimePath() {
      if (!this.runtimeDrawer) {
        return;
      }

      const path = this.runtimePath;
      const currentState = path.currentState || 'No state';
      const currentNode = path.currentNode || 'No current node';
      const nextCount = path.nextNodes.length;
      const historyCount = path.history.length;

      this.setRuntimeText(this.runtimeCurrentStateChip, currentState, 'State pending');
      this.setRuntimeText(this.runtimeNextCountChip, `${nextCount} next node${nextCount === 1 ? '' : 's'}`, 'No next nodes');
      this.setRuntimeText(this.runtimeHistoryCountChip, `${historyCount} path event${historyCount === 1 ? '' : 's'}`, 'No path history');