Neale Pickett
·
2024-04-09
workspace.mjs
1import {Toast} from "../common.mjs"
2//import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"
3import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"
4import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"
5import * as CodeJar from "https://cdn.jsdelivr.net/npm/codejar@4.2.0"
6
7Prism.plugins.autoloader.languages_path = "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/"
8const prismCssUrl = "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css"
9
10export class Workspace {
11 /**
12 *
13 * @param codeBlock {HTMLElement} The element containing the source code
14 * @param id {string} A unique identifier of this workspace
15 * @param attachmentUrls {URL[]} List of attachment URLs
16 */
17 constructor(codeBlock, id, attachmentUrls) {
18 // Show a progress bar
19 let loadingElement = document.createElement("progress")
20 codeBlock.insertAdjacentElement("afterend", loadingElement)
21
22 this.language = "unknown"
23 for (let c of codeBlock.classList) {
24 let parts = c.split("-")
25 if ((parts.length == 2) && parts[0].startsWith("lang")) {
26 this.language = parts[1]
27 }
28 }
29
30 this.element = document.createElement("div")
31 this.element.classList.add("workspace")
32 let template = document.querySelector("template#workspace")
33 this.element.appendChild(template.content.cloneNode(true))
34
35 this.originalCode = codeBlock.textContent
36 this.attachmentUrls = attachmentUrls
37 this.storageKey = "code:" + id
38
39 // Get our document and window
40 this.document = this.element.ownerDocument
41 this.window = this.document.defaultView
42
43 // Load user modifications, if there are any
44 this.code = localStorage[this.storageKey] || this.originalCode
45
46 this.status = this.element.querySelector(".status")
47 this.linenos = this.element.querySelector(".editor .linenos")
48 this.editor = this.element.querySelector(".editor .text")
49 this.stdout = this.element.querySelector(".stdout")
50 this.stderr = this.element.querySelector(".stderr")
51 this.traceback = this.element.querySelector(".traceback")
52 this.stdinfo = this.element.querySelector(".stdinfo")
53 this.runButton = this.element.querySelector("button.run")
54 this.revertButton = this.element.querySelector("button.revert")
55 this.fontButton = this.element.querySelector("button.font")
56 this.element.querySelector(".language").textContent = this.language
57
58 this.runButton.disabled = true
59
60 // Load in the editor
61 this.editor.classList.add("language-" + this.language)
62 this.jar = CodeJar.CodeJar(this.editor, (editor) => this.highlight(editor), {window: this.window})
63 this.jar.updateCode(this.code)
64 switch (this.language) {
65 case "python":
66 this.jar.updateOptions({
67 tab: " ",
68 indentOn: /:$/,
69 })
70 break
71 }
72
73 // Load the interpreter
74 this.initLanguage(this.language)
75 .then(() => {
76 codeBlock.parentElement.replaceWith(this.element)
77 })
78 .catch(err => console.warn(`Unable to load interpreter: `, this.language))
79 .finally(() => {
80 loadingElement.remove()
81 })
82 this.runButton.addEventListener("click", () => this.run())
83 this.revertButton.addEventListener("click", () => this.revert())
84 this.fontButton.addEventListener("click", () => this.font())
85 }
86
87 initLanguage(language) {
88 let start = performance.now()
89 this.status.textContent = "Initializing..."
90 this.status.appendChild(document.createElement("progress"))
91
92 let workerUrl = new URL(language + ".mjs", import.meta.url)
93 this.worker = new Worker(workerUrl, {type: "module"})
94
95 // XXX: There has got to be a cleaner way to do this
96 return new Promise((resolve, reject) => {
97 this.worker.addEventListener("error", err => reject(err))
98 this.workerMessage({type: "nop"})
99 .then(() => {
100 let runtime = performance.now() - start
101 let duration = new Date(runtime).toISOString().slice(11, -1)
102 this.status.textContent = "Loaded in " + duration
103 this.runButton.disabled = false
104
105 for (let a of this.attachmentUrls) {
106 let filename = a.pathname.split("/").pop()
107 this.workerMessage({type: "wget", url: a.href || a})
108 .then(ret => {
109 this.stdinfo.appendChild(this.document.createElement("div")).textContent = "Downloaded " + filename
110 })
111 }
112 resolve()
113 })
114 })
115 }
116
117 workerMessage(message) {
118 let chan = new MessageChannel()
119 message.channel = chan.port2
120 this.worker.postMessage(message, [chan.port2])
121 let p = new Promise(
122 (resolve, reject) => {
123 chan.port1.addEventListener("message", e => resolve(e.data), {once: true})
124 }
125 )
126 chan.port1.start()
127 return p
128 }
129
130 workerReady() {
131 return this.workerMessage({type: "nop"})
132 }
133
134 workerWget(url) {
135 return this.workerMessage({
136 type: "wget",
137 url: url.href || url,
138 })
139 }
140
141 /**
142 * highlight provides a code highlighter for CodeJar
143 *
144 * It calls Prism.highlightElement, then updates line numbers
145 */
146 highlight(editor) {
147 if (Prism) {
148 // Sometimes it loads slowly
149 Prism.highlightElement(editor)
150 } else {
151 console.warn("No highlighter!", Prism, this.window.document.scripts)
152 }
153
154 // Create a line numbers column
155 if (true) {
156 const code = editor.textContent || ""
157 const lines = code.split("\n")
158 let linesCount = lines.length
159 if (lines[linesCount-1]) {
160 linesCount += 1
161 }
162
163 let ltxt = ""
164 for (let i = 1; i < linesCount; i++) {
165 ltxt += i + "\n"
166 }
167 this.linenos.textContent = ltxt
168 }
169 }
170
171 setAnswer(answer) {
172 let evt = new CustomEvent("setAnswer", {detail: {value: answer}, bubbles: true, cancelable: true})
173 this.element.dispatchEvent(evt)
174
175 this.stdinfo.appendChild(this.document.createTextNode("Set answer to "))
176 this.stdinfo.appendChild(this.document.createElement("code")).textContent = answer
177 }
178
179 async run() {
180 let start = performance.now()
181 this.runButton.disabled = true
182 this.status.textContent = "Running..."
183
184 // Save first. Always save first.
185 let program = this.jar.toString()
186 if (program != this.originalCode) {
187 localStorage[this.storageKey] = program
188 }
189
190 let result = await this.workerMessage({
191 type: "run",
192 code: program,
193 })
194
195 this.stdout.textContent = result.stdout
196 this.stderr.textContent = result.stderr
197 this.traceback.textContent = result.traceback
198 while (this.stdinfo.firstChild) this.stdinfo.firstChild.remove()
199 if (result.answer) {
200 this.setAnswer(result.answer)
201 }
202
203 let runtime = performance.now() - start
204 let duration = new Date(runtime).toISOString().slice(11, -1)
205 this.status.textContent = "Ran in " + duration
206 this.runButton.disabled = false
207 }
208
209 revert() {
210 let currentCode = this.jar.toString()
211 let savedCode = localStorage[this.storageKey]
212 if ((currentCode == this.originalCode) && savedCode) {
213 this.jar.updateCode(savedCode)
214 Toast("Re-loaded saved code")
215 } else {
216 this.jar.updateCode(this.originalCode)
217 Toast("Reverted to original code")
218 }
219 }
220
221 font() {
222 this.element.classList.toggle("fixed")
223 }
224}
225
226
227function init() {
228 let link = document.head.appendChild(document.createElement("link"))
229 link.rel = "stylesheet"
230 link.href = prismCssUrl
231}
232
233if (document.readyState === "loading") {
234 document.addEventListener("DOMContentLoaded", init)
235} else {
236 init()
237}