Mercurial
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(); + }); +})();