moth

Monarch Of The Hill game server
git clone https://git.woozle.org/neale/moth.git

moth / theme / workspace
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}