diff mrjunejune/src/tools/latex_editor/index.js @ 244:b8aa08503378

[tools] Add sandboxed online LaTeX editor Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 16:56:25 -0700
parents
children 3843bb6253ac
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/tools/latex_editor/index.js	Mon Aug 03 16:56:25 2026 -0700
@@ -0,0 +1,200 @@
+(function latexEditor() {
+  "use strict";
+
+  const SOURCE_LIMIT = 64 * 1024;
+  const STORAGE_KEY = "mrjunejune-latex-source";
+  const AUTO_COMPILE_DELAY = 700;
+
+  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 compileTimer = null;
+    let compileGeneration = 0;
+    let controller = null;
+
+    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 compile = async () => {
+      clearTimeout(compileTimer);
+      const generation = ++compileGeneration;
+      if (controller) controller.abort();
+      controller = null;
+      compileButton.disabled = false;
+      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;
+      }
+
+      controller = new AbortController();
+      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: controller.signal,
+        });
+        if (generation !== compileGeneration) return;
+
+        if (!response.ok) {
+          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 (generation === compileGeneration) {
+          compileButton.disabled = false;
+          controller = null;
+        }
+      }
+    };
+
+    const queueCompile = () => {
+      clearTimeout(compileTimer);
+      if (!autoCompile.checked) return;
+      compileTimer = setTimeout(compile, AUTO_COMPILE_DELAY);
+    };
+
+    const invalidateCompile = () => {
+      compileGeneration++;
+      if (controller) controller.abort();
+      controller = null;
+      compileButton.disabled = false;
+    };
+
+    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",
+      );
+      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();
+    });
+    window.addEventListener("beforeunload", () => {
+      if (controller) controller.abort();
+      if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl);
+    });
+
+    updateSize();
+    compile();
+  });
+})();