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