|
4 | 4 | * Replaces static <pre> blocks with editable <textarea> elements. |
5 | 5 | * Users can modify code, click Run to execute via Pyodide (in-browser |
6 | 6 | * WebAssembly Python), and click Reset to restore the original code. |
| 7 | + * |
| 8 | + * Compatible with Chrome, Edge, Firefox, Safari. |
7 | 9 | */ |
8 | 10 | (function () { |
9 | 11 | "use strict"; |
10 | 12 |
|
11 | | - const PYODIDE_CDN = |
| 13 | + var PYODIDE_CDN = |
12 | 14 | "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"; |
13 | 15 |
|
14 | 16 | // Hint the browser to start fetching the Pyodide script immediately. |
15 | | - var prelink = document.createElement("link"); |
16 | | - prelink.rel = "preload"; |
17 | | - prelink.href = PYODIDE_CDN; |
18 | | - prelink.as = "script"; |
19 | | - document.head.appendChild(prelink); |
| 17 | + try { |
| 18 | + var prelink = document.createElement("link"); |
| 19 | + prelink.rel = "preload"; |
| 20 | + prelink.href = PYODIDE_CDN; |
| 21 | + prelink.as = "script"; |
| 22 | + prelink.crossOrigin = "anonymous"; |
| 23 | + document.head.appendChild(prelink); |
| 24 | + } catch (e) { /* preload is optional */ } |
20 | 25 |
|
21 | | - let pyodidePromise = null; |
| 26 | + var pyodideReady = null; |
22 | 27 |
|
23 | 28 | /** Lazy-load Pyodide the first time someone clicks Run. */ |
24 | | - function loadPyodide() { |
25 | | - if (pyodidePromise) return pyodidePromise; |
| 29 | + function ensurePyodide() { |
| 30 | + if (pyodideReady) return pyodideReady; |
26 | 31 |
|
27 | | - pyodidePromise = new Promise((resolve, reject) => { |
28 | | - const script = document.createElement("script"); |
| 32 | + pyodideReady = new Promise(function (resolve, reject) { |
| 33 | + var script = document.createElement("script"); |
29 | 34 | script.src = PYODIDE_CDN; |
30 | | - script.onload = async () => { |
31 | | - try { |
32 | | - const py = await globalThis.loadPyodide(); |
33 | | - resolve(py); |
34 | | - } catch (err) { |
35 | | - reject(err); |
36 | | - } |
| 35 | + script.crossOrigin = "anonymous"; |
| 36 | + script.onload = function () { |
| 37 | + // Pyodide script sets window.loadPyodide |
| 38 | + var loader = (typeof globalThis !== "undefined" && globalThis.loadPyodide) || window.loadPyodide; |
| 39 | + if (!loader) { reject(new Error("Pyodide loader not found")); return; } |
| 40 | + loader().then(resolve).catch(reject); |
37 | 41 | }; |
38 | | - script.onerror = reject; |
| 42 | + script.onerror = function () { reject(new Error("Failed to load Pyodide CDN")); }; |
39 | 43 | document.head.appendChild(script); |
40 | 44 | }); |
41 | | - return pyodidePromise; |
| 45 | + return pyodideReady; |
42 | 46 | } |
43 | 47 |
|
44 | | - /** Run a code string and return { output, error }. */ |
45 | | - async function runCode(pyodide, code) { |
| 48 | + /** Run Python code and capture stdout/stderr. */ |
| 49 | + function runCode(pyodide, code) { |
46 | 50 | pyodide.runPython( |
47 | 51 | "import sys, io\nsys.stdout = io.StringIO()\nsys.stderr = io.StringIO()" |
48 | 52 | ); |
49 | 53 | try { |
50 | | - const result = pyodide.runPython(code); |
51 | | - const stdout = pyodide.runPython("sys.stdout.getvalue()"); |
52 | | - const stderr = pyodide.runPython("sys.stderr.getvalue()"); |
53 | | - let output = stdout; |
| 54 | + var result = pyodide.runPython(code); |
| 55 | + var stdout = pyodide.runPython("sys.stdout.getvalue()"); |
| 56 | + var stderr = pyodide.runPython("sys.stderr.getvalue()"); |
| 57 | + var output = stdout; |
54 | 58 | if (stderr) output += stderr; |
55 | 59 | if (result !== undefined && result !== null && String(result) !== "None") { |
56 | 60 | output += String(result); |
57 | 61 | } |
58 | 62 | return { output: output, error: false }; |
59 | 63 | } catch (err) { |
60 | | - const stderr = pyodide.runPython("sys.stderr.getvalue()"); |
61 | | - return { output: stderr + "\n" + err.message, error: true }; |
| 64 | + var errStderr = ""; |
| 65 | + try { errStderr = pyodide.runPython("sys.stderr.getvalue()"); } catch (e) { /* ignore */ } |
| 66 | + return { output: errStderr + "\n" + err.message, error: true }; |
62 | 67 | } finally { |
63 | | - pyodide.runPython("sys.stdout = sys.__stdout__\nsys.stderr = sys.__stderr__"); |
| 68 | + try { pyodide.runPython("sys.stdout = sys.__stdout__\nsys.stderr = sys.__stderr__"); } catch (e) { /* ignore */ } |
64 | 69 | } |
65 | 70 | } |
66 | 71 |
|
67 | | - /** Show or update the output area below the editor. */ |
| 72 | + /** Show or update the output area. */ |
68 | 73 | function showOutput(wrapper, text, isError) { |
69 | 74 | var el = wrapper.querySelector(".pyodide-output"); |
70 | 75 | if (!el) { |
|
73 | 78 | wrapper.appendChild(el); |
74 | 79 | } |
75 | 80 | el.textContent = text || "(no output)"; |
76 | | - el.classList.toggle("pyodide-error", isError); |
| 81 | + if (isError) { el.classList.add("pyodide-error"); } else { el.classList.remove("pyodide-error"); } |
77 | 82 | el.style.display = "block"; |
78 | 83 | } |
79 | 84 |
|
80 | | - /** Auto-resize textarea to fit content. */ |
81 | | - function autoResize(textarea) { |
82 | | - textarea.style.height = "auto"; |
83 | | - textarea.style.height = textarea.scrollHeight + "px"; |
| 85 | + /** Auto-resize textarea to fit its content. */ |
| 86 | + function autoResize(ta) { |
| 87 | + ta.style.height = "auto"; |
| 88 | + ta.style.height = ta.scrollHeight + "px"; |
84 | 89 | } |
85 | 90 |
|
86 | | - /** Convert a code block into an editable cell with Run/Reset/Copy buttons. */ |
| 91 | + /** Cross-browser copy to clipboard. */ |
| 92 | + function copyToClipboard(text, btn) { |
| 93 | + function onSuccess() { |
| 94 | + btn.textContent = "✓ Copied!"; |
| 95 | + setTimeout(function () { btn.textContent = "\uD83D\uDCCB Copy"; }, 1500); |
| 96 | + } |
| 97 | + function onFail() { |
| 98 | + // Fallback: select a hidden textarea |
| 99 | + var tmp = document.createElement("textarea"); |
| 100 | + tmp.value = text; |
| 101 | + tmp.style.position = "fixed"; |
| 102 | + tmp.style.opacity = "0"; |
| 103 | + document.body.appendChild(tmp); |
| 104 | + tmp.select(); |
| 105 | + try { document.execCommand("copy"); onSuccess(); } catch (e) { /* silent */ } |
| 106 | + document.body.removeChild(tmp); |
| 107 | + } |
| 108 | + if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { |
| 109 | + navigator.clipboard.writeText(text).then(onSuccess).catch(onFail); |
| 110 | + } else { |
| 111 | + onFail(); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + /** Convert a code block into an editable cell with Run / Reset / Copy. */ |
87 | 116 | function makeEditable(container) { |
88 | 117 | var preEl = container.querySelector("pre"); |
89 | 118 | if (!preEl) return; |
| 119 | + // Avoid double-init |
| 120 | + if (container.querySelector(".pyodide-cell")) return; |
90 | 121 |
|
91 | 122 | var codeEl = preEl.querySelector("code") || preEl; |
92 | 123 | var originalCode = codeEl.textContent; |
93 | 124 |
|
94 | | - // Create wrapper |
| 125 | + // Build wrapper |
95 | 126 | var wrapper = document.createElement("div"); |
96 | 127 | wrapper.className = "pyodide-cell"; |
97 | 128 |
|
98 | | - // Toolbar with buttons |
| 129 | + // Toolbar |
99 | 130 | var toolbar = document.createElement("div"); |
100 | 131 | toolbar.className = "pyodide-toolbar"; |
101 | 132 |
|
102 | 133 | var runBtn = document.createElement("button"); |
103 | 134 | runBtn.className = "pyodide-run-btn"; |
104 | | - runBtn.textContent = "▶ Run"; |
| 135 | + runBtn.type = "button"; |
| 136 | + runBtn.textContent = "\u25B6 Run"; |
105 | 137 | runBtn.title = "Run this code (Shift+Enter)"; |
106 | 138 |
|
107 | 139 | var resetBtn = document.createElement("button"); |
108 | 140 | resetBtn.className = "pyodide-reset-btn"; |
109 | | - resetBtn.textContent = "↺ Reset"; |
| 141 | + resetBtn.type = "button"; |
| 142 | + resetBtn.textContent = "\u21BA Reset"; |
110 | 143 | resetBtn.title = "Restore original code"; |
111 | 144 |
|
112 | 145 | var copyBtn = document.createElement("button"); |
113 | 146 | copyBtn.className = "pyodide-copy-btn"; |
114 | | - copyBtn.textContent = "📋 Copy"; |
| 147 | + copyBtn.type = "button"; |
| 148 | + copyBtn.textContent = "\uD83D\uDCCB Copy"; |
115 | 149 | copyBtn.title = "Copy code to clipboard"; |
116 | 150 |
|
117 | 151 | toolbar.appendChild(runBtn); |
|
123 | 157 | textarea.className = "pyodide-editor"; |
124 | 158 | textarea.value = originalCode; |
125 | 159 | textarea.spellcheck = false; |
126 | | - textarea.autocomplete = "off"; |
127 | | - textarea.autocorrect = "off"; |
128 | | - textarea.autocapitalize = "off"; |
| 160 | + textarea.setAttribute("autocomplete", "off"); |
| 161 | + textarea.setAttribute("autocorrect", "off"); |
| 162 | + textarea.setAttribute("autocapitalize", "off"); |
129 | 163 |
|
130 | | - // Replace the original pre block |
| 164 | + // Replace original content |
131 | 165 | wrapper.appendChild(toolbar); |
132 | 166 | wrapper.appendChild(textarea); |
133 | 167 | container.innerHTML = ""; |
134 | 168 | container.appendChild(wrapper); |
135 | 169 |
|
136 | | - // Auto-size on load and input |
| 170 | + // Size & events |
137 | 171 | autoResize(textarea); |
138 | 172 | textarea.addEventListener("input", function () { autoResize(textarea); }); |
139 | 173 |
|
140 | | - // Shift+Enter to run |
141 | 174 | textarea.addEventListener("keydown", function (e) { |
142 | 175 | if (e.key === "Enter" && e.shiftKey) { |
143 | 176 | e.preventDefault(); |
144 | 177 | runBtn.click(); |
145 | 178 | } |
146 | | - // Tab inserts spaces instead of moving focus |
147 | 179 | if (e.key === "Tab") { |
148 | 180 | e.preventDefault(); |
149 | | - var start = textarea.selectionStart; |
| 181 | + var s = textarea.selectionStart; |
150 | 182 | var end = textarea.selectionEnd; |
151 | | - textarea.value = textarea.value.substring(0, start) + " " + textarea.value.substring(end); |
152 | | - textarea.selectionStart = textarea.selectionEnd = start + 4; |
| 183 | + textarea.value = textarea.value.substring(0, s) + " " + textarea.value.substring(end); |
| 184 | + textarea.selectionStart = textarea.selectionEnd = s + 4; |
153 | 185 | } |
154 | 186 | }); |
155 | 187 |
|
156 | | - // Run button |
157 | | - runBtn.addEventListener("click", async function () { |
| 188 | + runBtn.addEventListener("click", function () { |
158 | 189 | var code = textarea.value; |
159 | 190 | runBtn.disabled = true; |
160 | | - runBtn.textContent = "⏳ Loading…"; |
161 | | - try { |
162 | | - var pyodide = await loadPyodide(); |
163 | | - runBtn.textContent = "⏳ Running…"; |
164 | | - var result = await runCode(pyodide, code); |
| 191 | + runBtn.textContent = "\u23F3 Loading\u2026"; |
| 192 | + ensurePyodide().then(function (pyodide) { |
| 193 | + runBtn.textContent = "\u23F3 Running\u2026"; |
| 194 | + var result = runCode(pyodide, code); |
165 | 195 | showOutput(wrapper, result.output, result.error); |
166 | | - } catch (err) { |
| 196 | + }).catch(function (err) { |
167 | 197 | showOutput(wrapper, "Failed to load Pyodide:\n" + err.message, true); |
168 | | - } finally { |
| 198 | + }).finally(function () { |
169 | 199 | runBtn.disabled = false; |
170 | | - runBtn.textContent = "▶ Run"; |
171 | | - } |
| 200 | + runBtn.textContent = "\u25B6 Run"; |
| 201 | + }); |
172 | 202 | }); |
173 | 203 |
|
174 | | - // Reset button |
175 | 204 | resetBtn.addEventListener("click", function () { |
176 | 205 | textarea.value = originalCode; |
177 | 206 | autoResize(textarea); |
178 | | - var outputEl = wrapper.querySelector(".pyodide-output"); |
179 | | - if (outputEl) outputEl.style.display = "none"; |
| 207 | + var out = wrapper.querySelector(".pyodide-output"); |
| 208 | + if (out) out.style.display = "none"; |
180 | 209 | }); |
181 | 210 |
|
182 | | - // Copy button |
183 | 211 | copyBtn.addEventListener("click", function () { |
184 | | - navigator.clipboard.writeText(textarea.value).then(function () { |
185 | | - copyBtn.textContent = "✓ Copied!"; |
186 | | - setTimeout(function () { copyBtn.textContent = "📋 Copy"; }, 1500); |
187 | | - }); |
| 212 | + copyToClipboard(textarea.value, copyBtn); |
188 | 213 | }); |
189 | 214 | } |
190 | 215 |
|
191 | | - /** Find all code-cell blocks and make them editable. */ |
| 216 | + /** Find code-cell blocks and make them editable. */ |
192 | 217 | function init() { |
193 | | - // myst-nb renders {code-cell} as <div class="cell ..."><div class="cell_input">... |
194 | 218 | var cells = document.querySelectorAll(".cell .cell_input"); |
195 | | - cells.forEach(makeEditable); |
| 219 | + console.log("[pyodide-runner] Found " + cells.length + " code cells"); |
| 220 | + |
| 221 | + for (var i = 0; i < cells.length; i++) { |
| 222 | + try { makeEditable(cells[i]); } catch (e) { console.error("[pyodide-runner]", e); } |
| 223 | + } |
196 | 224 |
|
197 | | - // Fallback: target plain highlighted python blocks |
| 225 | + // Fallback for pages without {code-cell} blocks |
198 | 226 | if (cells.length === 0) { |
199 | | - document |
200 | | - .querySelectorAll("div.highlight-python, div.highlight-default") |
201 | | - .forEach(function (block) { |
202 | | - if (block.closest(".cell_input")) return; |
203 | | - makeEditable(block); |
204 | | - }); |
| 227 | + var blocks = document.querySelectorAll("div.highlight-python, div.highlight-default"); |
| 228 | + for (var j = 0; j < blocks.length; j++) { |
| 229 | + if (blocks[j].parentElement && blocks[j].parentElement.classList.contains("cell_input")) continue; |
| 230 | + try { makeEditable(blocks[j]); } catch (e) { console.error("[pyodide-runner]", e); } |
| 231 | + } |
205 | 232 | } |
206 | 233 |
|
207 | 234 | // Preload Pyodide in the background |
208 | | - if (document.querySelectorAll(".cell .cell_input, div.highlight-python").length > 0) { |
209 | | - (typeof requestIdleCallback === "function" ? requestIdleCallback : function (cb) { setTimeout(cb, 2000); })( |
210 | | - function () { loadPyodide(); } |
211 | | - ); |
212 | | - } |
| 235 | + setTimeout(function () { ensurePyodide(); }, 2000); |
213 | 236 | } |
214 | 237 |
|
| 238 | + // Run after DOM is ready and copybutton.js has finished (it uses setTimeout 250ms) |
| 239 | + function scheduleInit() { setTimeout(init, 500); } |
| 240 | + |
215 | 241 | if (document.readyState === "loading") { |
216 | | - document.addEventListener("DOMContentLoaded", init); |
| 242 | + document.addEventListener("DOMContentLoaded", scheduleInit); |
217 | 243 | } else { |
218 | | - init(); |
| 244 | + scheduleInit(); |
219 | 245 | } |
220 | 246 | })(); |
0 commit comments