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 });