Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 243:823f2a8b16c8 | 244:b8aa08503378 |
|---|---|
| 1 (function latexEditor() { | |
| 2 "use strict"; | |
| 3 | |
| 4 const SOURCE_LIMIT = 64 * 1024; | |
| 5 const STORAGE_KEY = "mrjunejune-latex-source"; | |
| 6 const AUTO_COMPILE_DELAY = 700; | |
| 7 | |
| 8 window.addEventListener("DOMContentLoaded", () => { | |
| 9 const source = document.querySelector("#latexSource"); | |
| 10 const compileButton = document.querySelector("#compileButton"); | |
| 11 const resetButton = document.querySelector("#resetButton"); | |
| 12 const autoCompile = document.querySelector("#autoCompile"); | |
| 13 const status = document.querySelector("#latexStatus"); | |
| 14 const sourceSize = document.querySelector("#sourceSize"); | |
| 15 const preview = document.querySelector("#pdfPreview"); | |
| 16 const diagnostics = document.querySelector("#latexDiagnostics"); | |
| 17 const download = document.querySelector("#downloadButton"); | |
| 18 if (!source || | |
| 19 !compileButton || | |
| 20 !resetButton || | |
| 21 !autoCompile || | |
| 22 !status || | |
| 23 !sourceSize || | |
| 24 !preview || | |
| 25 !diagnostics || | |
| 26 !download) return; | |
| 27 | |
| 28 const defaultSource = source.value; | |
| 29 const savedSource = localStorage.getItem(STORAGE_KEY); | |
| 30 if (savedSource) source.value = savedSource; | |
| 31 | |
| 32 let currentPdfUrl = null; | |
| 33 let compileTimer = null; | |
| 34 let compileGeneration = 0; | |
| 35 let controller = null; | |
| 36 | |
| 37 const byteLength = value => new TextEncoder().encode(value).byteLength; | |
| 38 | |
| 39 const setStatus = (message, state) => { | |
| 40 status.textContent = message; | |
| 41 status.dataset.state = state; | |
| 42 }; | |
| 43 | |
| 44 const updateSize = () => { | |
| 45 const size = byteLength(source.value); | |
| 46 sourceSize.textContent = `${size.toLocaleString()} / ${SOURCE_LIMIT.toLocaleString()} bytes`; | |
| 47 sourceSize.dataset.overLimit = size > SOURCE_LIMIT ? "true" : "false"; | |
| 48 return size; | |
| 49 }; | |
| 50 | |
| 51 const clearPdf = () => { | |
| 52 if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); | |
| 53 currentPdfUrl = null; | |
| 54 preview.src = "about:blank"; | |
| 55 download.removeAttribute("href"); | |
| 56 download.removeAttribute("download"); | |
| 57 download.classList.add("disabled"); | |
| 58 download.setAttribute("aria-disabled", "true"); | |
| 59 }; | |
| 60 | |
| 61 const showDiagnostics = message => { | |
| 62 clearPdf(); | |
| 63 diagnostics.textContent = message; | |
| 64 diagnostics.hidden = false; | |
| 65 preview.hidden = true; | |
| 66 }; | |
| 67 | |
| 68 const showPdf = blob => { | |
| 69 if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); | |
| 70 currentPdfUrl = URL.createObjectURL(blob); | |
| 71 diagnostics.hidden = true; | |
| 72 preview.hidden = false; | |
| 73 preview.src = currentPdfUrl; | |
| 74 download.href = currentPdfUrl; | |
| 75 download.download = "document.pdf"; | |
| 76 download.classList.remove("disabled"); | |
| 77 download.setAttribute("aria-disabled", "false"); | |
| 78 }; | |
| 79 | |
| 80 const compile = async () => { | |
| 81 clearTimeout(compileTimer); | |
| 82 const generation = ++compileGeneration; | |
| 83 if (controller) controller.abort(); | |
| 84 controller = null; | |
| 85 compileButton.disabled = false; | |
| 86 const text = source.value; | |
| 87 const size = updateSize(); | |
| 88 if (!text.trim()) { | |
| 89 showDiagnostics("Write some LaTeX before compiling."); | |
| 90 setStatus("Nothing to compile.", "error"); | |
| 91 return; | |
| 92 } | |
| 93 if (size > SOURCE_LIMIT) { | |
| 94 showDiagnostics("The server accepts at most 64 KiB of LaTeX source."); | |
| 95 setStatus("Source is too large.", "error"); | |
| 96 return; | |
| 97 } | |
| 98 | |
| 99 controller = new AbortController(); | |
| 100 compileButton.disabled = true; | |
| 101 setStatus("Compiling on the server...", "working"); | |
| 102 | |
| 103 try { | |
| 104 const response = await fetch("/api/latex/render", { | |
| 105 method: "POST", | |
| 106 headers: { | |
| 107 "Content-Type": "text/plain; charset=utf-8", | |
| 108 }, | |
| 109 body: text, | |
| 110 cache: "no-store", | |
| 111 signal: controller.signal, | |
| 112 }); | |
| 113 if (generation !== compileGeneration) return; | |
| 114 | |
| 115 if (!response.ok) { | |
| 116 const message = await response.text(); | |
| 117 showDiagnostics(message || `Compilation failed (${response.status}).`); | |
| 118 setStatus("Fix the LaTeX errors shown in the preview pane.", "error"); | |
| 119 return; | |
| 120 } | |
| 121 | |
| 122 const blob = await response.blob(); | |
| 123 if (blob.type !== "application/pdf" || blob.size < 5) { | |
| 124 throw new Error("The server returned an invalid PDF."); | |
| 125 } | |
| 126 showPdf(blob); | |
| 127 setStatus( | |
| 128 `PDF ready (${Math.ceil(blob.size / 1024).toLocaleString()} KiB).`, | |
| 129 "ready", | |
| 130 ); | |
| 131 } catch (error) { | |
| 132 if (error.name === "AbortError" || generation !== compileGeneration) return; | |
| 133 showDiagnostics(error.message || "Unable to compile this document."); | |
| 134 setStatus("The PDF request failed.", "error"); | |
| 135 } finally { | |
| 136 if (generation === compileGeneration) { | |
| 137 compileButton.disabled = false; | |
| 138 controller = null; | |
| 139 } | |
| 140 } | |
| 141 }; | |
| 142 | |
| 143 const queueCompile = () => { | |
| 144 clearTimeout(compileTimer); | |
| 145 if (!autoCompile.checked) return; | |
| 146 compileTimer = setTimeout(compile, AUTO_COMPILE_DELAY); | |
| 147 }; | |
| 148 | |
| 149 const invalidateCompile = () => { | |
| 150 compileGeneration++; | |
| 151 if (controller) controller.abort(); | |
| 152 controller = null; | |
| 153 compileButton.disabled = false; | |
| 154 }; | |
| 155 | |
| 156 source.addEventListener("input", () => { | |
| 157 localStorage.setItem(STORAGE_KEY, source.value); | |
| 158 invalidateCompile(); | |
| 159 const size = updateSize(); | |
| 160 if (!source.value.trim()) { | |
| 161 showDiagnostics("Write some LaTeX before compiling."); | |
| 162 setStatus("Nothing to compile.", "error"); | |
| 163 return; | |
| 164 } | |
| 165 if (size > SOURCE_LIMIT) { | |
| 166 showDiagnostics("The server accepts at most 64 KiB of LaTeX source."); | |
| 167 setStatus("Source is too large.", "error"); | |
| 168 return; | |
| 169 } | |
| 170 setStatus( | |
| 171 autoCompile.checked ? "Waiting to compile..." : "Changes ready to compile.", | |
| 172 "working", | |
| 173 ); | |
| 174 queueCompile(); | |
| 175 }); | |
| 176 source.addEventListener("keydown", event => { | |
| 177 if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { | |
| 178 event.preventDefault(); | |
| 179 compile(); | |
| 180 } | |
| 181 }); | |
| 182 compileButton.addEventListener("click", compile); | |
| 183 resetButton.addEventListener("click", () => { | |
| 184 source.value = defaultSource; | |
| 185 localStorage.removeItem(STORAGE_KEY); | |
| 186 updateSize(); | |
| 187 compile(); | |
| 188 }); | |
| 189 autoCompile.addEventListener("change", () => { | |
| 190 if (autoCompile.checked) queueCompile(); | |
| 191 }); | |
| 192 window.addEventListener("beforeunload", () => { | |
| 193 if (controller) controller.abort(); | |
| 194 if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); | |
| 195 }); | |
| 196 | |
| 197 updateSize(); | |
| 198 compile(); | |
| 199 }); | |
| 200 })(); |