view mrjunejune/src/tools/latex_editor/index.js @ 245:3843bb6253ac

[tools] Debounce live LaTeX preview Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 18:23:36 -0700
parents b8aa08503378
children
line wrap: on
line source

(function latexEditor() {
  "use strict";

  const SOURCE_LIMIT = 64 * 1024;
  const STORAGE_KEY = "mrjunejune-latex-source";
  const AUTO_COMPILE_DELAY = 500;

  window.addEventListener("DOMContentLoaded", () => {
    const source = document.querySelector("#latexSource");
    const compileButton = document.querySelector("#compileButton");
    const resetButton = document.querySelector("#resetButton");
    const autoCompile = document.querySelector("#autoCompile");
    const status = document.querySelector("#latexStatus");
    const sourceSize = document.querySelector("#sourceSize");
    const preview = document.querySelector("#pdfPreview");
    const diagnostics = document.querySelector("#latexDiagnostics");
    const download = document.querySelector("#downloadButton");
    if (!source ||
        !compileButton ||
        !resetButton ||
        !autoCompile ||
        !status ||
        !sourceSize ||
        !preview ||
        !diagnostics ||
        !download) return;

    const defaultSource = source.value;
    const savedSource = localStorage.getItem(STORAGE_KEY);
    if (savedSource) source.value = savedSource;

    let currentPdfUrl = null;
    let compileGeneration = 0;
    let controller = null;
    let compilePending = false;
    let retryDelay = 0;

    const byteLength = value => new TextEncoder().encode(value).byteLength;

    const setStatus = (message, state) => {
      status.textContent = message;
      status.dataset.state = state;
    };

    const updateSize = () => {
      const size = byteLength(source.value);
      sourceSize.textContent = `${size.toLocaleString()} / ${SOURCE_LIMIT.toLocaleString()} bytes`;
      sourceSize.dataset.overLimit = size > SOURCE_LIMIT ? "true" : "false";
      return size;
    };

    const clearPdf = () => {
      if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl);
      currentPdfUrl = null;
      preview.src = "about:blank";
      download.removeAttribute("href");
      download.removeAttribute("download");
      download.classList.add("disabled");
      download.setAttribute("aria-disabled", "true");
    };

    const showDiagnostics = message => {
      clearPdf();
      diagnostics.textContent = message;
      diagnostics.hidden = false;
      preview.hidden = true;
    };

    const showPdf = blob => {
      if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl);
      currentPdfUrl = URL.createObjectURL(blob);
      diagnostics.hidden = true;
      preview.hidden = false;
      preview.src = currentPdfUrl;
      download.href = currentPdfUrl;
      download.download = "document.pdf";
      download.classList.remove("disabled");
      download.setAttribute("aria-disabled", "false");
    };

    const createDebouncer = (callback, delay) => {
      let timer = null;
      const schedule = (nextDelay = delay) => {
        clearTimeout(timer);
        timer = setTimeout(() => {
          timer = null;
          callback();
        }, nextDelay);
      };
      schedule.cancel = () => {
        clearTimeout(timer);
        timer = null;
      };
      return schedule;
    };

    let queueCompile;
    const compile = async () => {
      queueCompile.cancel();
      const generation = ++compileGeneration;
      const text = source.value;
      const size = updateSize();
      if (!text.trim()) {
        showDiagnostics("Write some LaTeX before compiling.");
        setStatus("Nothing to compile.", "error");
        return;
      }
      if (size > SOURCE_LIMIT) {
        showDiagnostics("The server accepts at most 64 KiB of LaTeX source.");
        setStatus("Source is too large.", "error");
        return;
      }

      if (controller) {
        compilePending = true;
        setStatus("Finishing the current PDF, then compiling your latest changes...", "working");
        return;
      }

      compilePending = false;
      const requestController = new AbortController();
      controller = requestController;
      compileButton.disabled = true;
      setStatus("Compiling on the server...", "working");

      try {
        const response = await fetch("/api/latex/render", {
          method: "POST",
          headers: {
            "Content-Type": "text/plain; charset=utf-8",
          },
          body: text,
          cache: "no-store",
          signal: requestController.signal,
        });
        if (generation !== compileGeneration) return;

        if (!response.ok) {
          if (response.status === 429) {
            compilePending = true;
            retryDelay = 1000;
            setStatus("The compiler is busy. Your latest changes are queued.", "working");
            return;
          }
          const message = await response.text();
          showDiagnostics(message || `Compilation failed (${response.status}).`);
          setStatus("Fix the LaTeX errors shown in the preview pane.", "error");
          return;
        }

        const blob = await response.blob();
        if (blob.type !== "application/pdf" || blob.size < 5) {
          throw new Error("The server returned an invalid PDF.");
        }
        showPdf(blob);
        setStatus(
          `PDF ready (${Math.ceil(blob.size / 1024).toLocaleString()} KiB).`,
          "ready",
        );
      } catch (error) {
        if (error.name === "AbortError" || generation !== compileGeneration) return;
        showDiagnostics(error.message || "Unable to compile this document.");
        setStatus("The PDF request failed.", "error");
      } finally {
        if (controller === requestController) {
          controller = null;
          compileButton.disabled = false;
        }
        if (compilePending && !controller) {
          compilePending = false;
          const delay = retryDelay;
          retryDelay = 0;
          queueCompile(delay);
        }
      }
    };

    queueCompile = createDebouncer(compile, AUTO_COMPILE_DELAY);

    const invalidateCompile = () => {
      compileGeneration++;
      compilePending = false;
      retryDelay = 0;
      queueCompile.cancel();
    };

    source.addEventListener("input", () => {
      localStorage.setItem(STORAGE_KEY, source.value);
      invalidateCompile();
      const size = updateSize();
      if (!source.value.trim()) {
        showDiagnostics("Write some LaTeX before compiling.");
        setStatus("Nothing to compile.", "error");
        return;
      }
      if (size > SOURCE_LIMIT) {
        showDiagnostics("The server accepts at most 64 KiB of LaTeX source.");
        setStatus("Source is too large.", "error");
        return;
      }
      setStatus(
        autoCompile.checked ? "Waiting to compile..." : "Changes ready to compile.",
        "working",
      );
      if (autoCompile.checked) queueCompile();
    });
    source.addEventListener("keydown", event => {
      if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
        event.preventDefault();
        compile();
      }
    });
    compileButton.addEventListener("click", compile);
    resetButton.addEventListener("click", () => {
      source.value = defaultSource;
      localStorage.removeItem(STORAGE_KEY);
      updateSize();
      compile();
    });
    autoCompile.addEventListener("change", () => {
      if (autoCompile.checked) queueCompile();
      else {
        queueCompile.cancel();
        compilePending = false;
      }
    });
    window.addEventListener("beforeunload", () => {
      if (controller) controller.abort();
      if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl);
    });

    updateSize();
    compile();
  });
})();