Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 244:b8aa08503378 | 245:3843bb6253ac |
|---|---|
| 1 (function latexEditor() { | 1 (function latexEditor() { |
| 2 "use strict"; | 2 "use strict"; |
| 3 | 3 |
| 4 const SOURCE_LIMIT = 64 * 1024; | 4 const SOURCE_LIMIT = 64 * 1024; |
| 5 const STORAGE_KEY = "mrjunejune-latex-source"; | 5 const STORAGE_KEY = "mrjunejune-latex-source"; |
| 6 const AUTO_COMPILE_DELAY = 700; | 6 const AUTO_COMPILE_DELAY = 500; |
| 7 | 7 |
| 8 window.addEventListener("DOMContentLoaded", () => { | 8 window.addEventListener("DOMContentLoaded", () => { |
| 9 const source = document.querySelector("#latexSource"); | 9 const source = document.querySelector("#latexSource"); |
| 10 const compileButton = document.querySelector("#compileButton"); | 10 const compileButton = document.querySelector("#compileButton"); |
| 11 const resetButton = document.querySelector("#resetButton"); | 11 const resetButton = document.querySelector("#resetButton"); |
| 28 const defaultSource = source.value; | 28 const defaultSource = source.value; |
| 29 const savedSource = localStorage.getItem(STORAGE_KEY); | 29 const savedSource = localStorage.getItem(STORAGE_KEY); |
| 30 if (savedSource) source.value = savedSource; | 30 if (savedSource) source.value = savedSource; |
| 31 | 31 |
| 32 let currentPdfUrl = null; | 32 let currentPdfUrl = null; |
| 33 let compileTimer = null; | |
| 34 let compileGeneration = 0; | 33 let compileGeneration = 0; |
| 35 let controller = null; | 34 let controller = null; |
| 35 let compilePending = false; | |
| 36 let retryDelay = 0; | |
| 36 | 37 |
| 37 const byteLength = value => new TextEncoder().encode(value).byteLength; | 38 const byteLength = value => new TextEncoder().encode(value).byteLength; |
| 38 | 39 |
| 39 const setStatus = (message, state) => { | 40 const setStatus = (message, state) => { |
| 40 status.textContent = message; | 41 status.textContent = message; |
| 75 download.download = "document.pdf"; | 76 download.download = "document.pdf"; |
| 76 download.classList.remove("disabled"); | 77 download.classList.remove("disabled"); |
| 77 download.setAttribute("aria-disabled", "false"); | 78 download.setAttribute("aria-disabled", "false"); |
| 78 }; | 79 }; |
| 79 | 80 |
| 81 const createDebouncer = (callback, delay) => { | |
| 82 let timer = null; | |
| 83 const schedule = (nextDelay = delay) => { | |
| 84 clearTimeout(timer); | |
| 85 timer = setTimeout(() => { | |
| 86 timer = null; | |
| 87 callback(); | |
| 88 }, nextDelay); | |
| 89 }; | |
| 90 schedule.cancel = () => { | |
| 91 clearTimeout(timer); | |
| 92 timer = null; | |
| 93 }; | |
| 94 return schedule; | |
| 95 }; | |
| 96 | |
| 97 let queueCompile; | |
| 80 const compile = async () => { | 98 const compile = async () => { |
| 81 clearTimeout(compileTimer); | 99 queueCompile.cancel(); |
| 82 const generation = ++compileGeneration; | 100 const generation = ++compileGeneration; |
| 83 if (controller) controller.abort(); | |
| 84 controller = null; | |
| 85 compileButton.disabled = false; | |
| 86 const text = source.value; | 101 const text = source.value; |
| 87 const size = updateSize(); | 102 const size = updateSize(); |
| 88 if (!text.trim()) { | 103 if (!text.trim()) { |
| 89 showDiagnostics("Write some LaTeX before compiling."); | 104 showDiagnostics("Write some LaTeX before compiling."); |
| 90 setStatus("Nothing to compile.", "error"); | 105 setStatus("Nothing to compile.", "error"); |
| 94 showDiagnostics("The server accepts at most 64 KiB of LaTeX source."); | 109 showDiagnostics("The server accepts at most 64 KiB of LaTeX source."); |
| 95 setStatus("Source is too large.", "error"); | 110 setStatus("Source is too large.", "error"); |
| 96 return; | 111 return; |
| 97 } | 112 } |
| 98 | 113 |
| 99 controller = new AbortController(); | 114 if (controller) { |
| 115 compilePending = true; | |
| 116 setStatus("Finishing the current PDF, then compiling your latest changes...", "working"); | |
| 117 return; | |
| 118 } | |
| 119 | |
| 120 compilePending = false; | |
| 121 const requestController = new AbortController(); | |
| 122 controller = requestController; | |
| 100 compileButton.disabled = true; | 123 compileButton.disabled = true; |
| 101 setStatus("Compiling on the server...", "working"); | 124 setStatus("Compiling on the server...", "working"); |
| 102 | 125 |
| 103 try { | 126 try { |
| 104 const response = await fetch("/api/latex/render", { | 127 const response = await fetch("/api/latex/render", { |
| 106 headers: { | 129 headers: { |
| 107 "Content-Type": "text/plain; charset=utf-8", | 130 "Content-Type": "text/plain; charset=utf-8", |
| 108 }, | 131 }, |
| 109 body: text, | 132 body: text, |
| 110 cache: "no-store", | 133 cache: "no-store", |
| 111 signal: controller.signal, | 134 signal: requestController.signal, |
| 112 }); | 135 }); |
| 113 if (generation !== compileGeneration) return; | 136 if (generation !== compileGeneration) return; |
| 114 | 137 |
| 115 if (!response.ok) { | 138 if (!response.ok) { |
| 139 if (response.status === 429) { | |
| 140 compilePending = true; | |
| 141 retryDelay = 1000; | |
| 142 setStatus("The compiler is busy. Your latest changes are queued.", "working"); | |
| 143 return; | |
| 144 } | |
| 116 const message = await response.text(); | 145 const message = await response.text(); |
| 117 showDiagnostics(message || `Compilation failed (${response.status}).`); | 146 showDiagnostics(message || `Compilation failed (${response.status}).`); |
| 118 setStatus("Fix the LaTeX errors shown in the preview pane.", "error"); | 147 setStatus("Fix the LaTeX errors shown in the preview pane.", "error"); |
| 119 return; | 148 return; |
| 120 } | 149 } |
| 131 } catch (error) { | 160 } catch (error) { |
| 132 if (error.name === "AbortError" || generation !== compileGeneration) return; | 161 if (error.name === "AbortError" || generation !== compileGeneration) return; |
| 133 showDiagnostics(error.message || "Unable to compile this document."); | 162 showDiagnostics(error.message || "Unable to compile this document."); |
| 134 setStatus("The PDF request failed.", "error"); | 163 setStatus("The PDF request failed.", "error"); |
| 135 } finally { | 164 } finally { |
| 136 if (generation === compileGeneration) { | 165 if (controller === requestController) { |
| 166 controller = null; | |
| 137 compileButton.disabled = false; | 167 compileButton.disabled = false; |
| 138 controller = null; | 168 } |
| 139 } | 169 if (compilePending && !controller) { |
| 140 } | 170 compilePending = false; |
| 141 }; | 171 const delay = retryDelay; |
| 142 | 172 retryDelay = 0; |
| 143 const queueCompile = () => { | 173 queueCompile(delay); |
| 144 clearTimeout(compileTimer); | 174 } |
| 145 if (!autoCompile.checked) return; | 175 } |
| 146 compileTimer = setTimeout(compile, AUTO_COMPILE_DELAY); | 176 }; |
| 147 }; | 177 |
| 178 queueCompile = createDebouncer(compile, AUTO_COMPILE_DELAY); | |
| 148 | 179 |
| 149 const invalidateCompile = () => { | 180 const invalidateCompile = () => { |
| 150 compileGeneration++; | 181 compileGeneration++; |
| 151 if (controller) controller.abort(); | 182 compilePending = false; |
| 152 controller = null; | 183 retryDelay = 0; |
| 153 compileButton.disabled = false; | 184 queueCompile.cancel(); |
| 154 }; | 185 }; |
| 155 | 186 |
| 156 source.addEventListener("input", () => { | 187 source.addEventListener("input", () => { |
| 157 localStorage.setItem(STORAGE_KEY, source.value); | 188 localStorage.setItem(STORAGE_KEY, source.value); |
| 158 invalidateCompile(); | 189 invalidateCompile(); |
| 169 } | 200 } |
| 170 setStatus( | 201 setStatus( |
| 171 autoCompile.checked ? "Waiting to compile..." : "Changes ready to compile.", | 202 autoCompile.checked ? "Waiting to compile..." : "Changes ready to compile.", |
| 172 "working", | 203 "working", |
| 173 ); | 204 ); |
| 174 queueCompile(); | 205 if (autoCompile.checked) queueCompile(); |
| 175 }); | 206 }); |
| 176 source.addEventListener("keydown", event => { | 207 source.addEventListener("keydown", event => { |
| 177 if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { | 208 if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { |
| 178 event.preventDefault(); | 209 event.preventDefault(); |
| 179 compile(); | 210 compile(); |
| 186 updateSize(); | 217 updateSize(); |
| 187 compile(); | 218 compile(); |
| 188 }); | 219 }); |
| 189 autoCompile.addEventListener("change", () => { | 220 autoCompile.addEventListener("change", () => { |
| 190 if (autoCompile.checked) queueCompile(); | 221 if (autoCompile.checked) queueCompile(); |
| 222 else { | |
| 223 queueCompile.cancel(); | |
| 224 compilePending = false; | |
| 225 } | |
| 191 }); | 226 }); |
| 192 window.addEventListener("beforeunload", () => { | 227 window.addEventListener("beforeunload", () => { |
| 193 if (controller) controller.abort(); | 228 if (controller) controller.abort(); |
| 194 if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); | 229 if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); |
| 195 }); | 230 }); |