// PyScript py-editor plugin import { Hook, XWorker, dedent } from "polyscript/exports"; import { TYPES, stdlib } from "../core.js"; const RUN_BUTTON = ``; let id = 0; const getID = (type) => `${type}-editor-${id++}`; const envs = new Map(); const configs = new Map(); const hooks = { worker: { codeBeforeRun: () => stdlib, // works on both Pyodide and MicroPython onReady: ({ runAsync, io }, { sync }) => { io.stdout = io.buffered(sync.write); io.stderr = io.buffered(sync.writeErr); sync.revoke(); sync.runAsync = runAsync; }, }, }; async function execute({ currentTarget }) { const { env, pySrc, outDiv } = this; const hasRunButton = !!currentTarget; if (hasRunButton) { currentTarget.disabled = true; outDiv.innerHTML = ""; } if (!envs.has(env)) { const srcLink = URL.createObjectURL(new Blob([""])); const details = { type: this.interpreter }; const { config } = this; if (config) { details.configURL = config; const { parse } = config.endsWith(".toml") ? await import(/* webpackIgnore: true */ "../3rd-party/toml.js") : JSON; details.config = parse(await fetch(config).then((r) => r.text())); } const xworker = XWorker.call(new Hook(null, hooks), srcLink, details); const { sync } = xworker; const { promise, resolve } = Promise.withResolvers(); envs.set(env, promise); sync.revoke = () => { URL.revokeObjectURL(srcLink); resolve(xworker); }; } // wait for the env then set the target div // before executing the current code envs.get(env).then((xworker) => { xworker.onerror = ({ error }) => { if (hasRunButton) { outDiv.innerHTML += `${ error.message || error }\n`; } console.error(error); }; const enable = () => { if (hasRunButton) currentTarget.disabled = false; }; const { sync } = xworker; sync.write = (str) => { if (hasRunButton) outDiv.innerText += `${str}\n`; }; sync.writeErr = (str) => { if (hasRunButton) { outDiv.innerHTML += `${str}\n`; } }; sync.runAsync(pySrc).then(enable, enable); }); } const makeRunButton = (listener, type) => { const runButton = document.createElement("button"); runButton.className = `absolute ${type}-editor-run-button`; runButton.innerHTML = RUN_BUTTON; runButton.setAttribute("aria-label", "Python Script Run Button"); runButton.addEventListener("click", listener); return runButton; }; const makeEditorDiv = (listener, type) => { const editorDiv = document.createElement("div"); editorDiv.className = `${type}-editor-input`; editorDiv.setAttribute("aria-label", "Python Script Area"); const runButton = makeRunButton(listener, type); const editorShadowContainer = document.createElement("div"); // avoid outer elements intercepting key events (reveal as example) editorShadowContainer.addEventListener("keydown", (event) => { event.stopPropagation(); }); editorDiv.append(runButton, editorShadowContainer); return editorDiv; }; const makeOutDiv = (type) => { const outDiv = document.createElement("div"); outDiv.className = `${type}-editor-output`; outDiv.id = `${getID(type)}-output`; return outDiv; }; const makeBoxDiv = (listener, type) => { const boxDiv = document.createElement("div"); boxDiv.className = `${type}-editor-box`; const editorDiv = makeEditorDiv(listener, type); const outDiv = makeOutDiv(type); boxDiv.append(editorDiv, outDiv); return [boxDiv, outDiv]; }; const init = async (script, type, interpreter) => { const [ { basicSetup, EditorView }, { Compartment }, { python }, { indentUnit }, { keymap }, { defaultKeymap }, ] = await Promise.all([ import(/* webpackIgnore: true */ "../3rd-party/codemirror.js"), import(/* webpackIgnore: true */ "../3rd-party/codemirror_state.js"), import( /* webpackIgnore: true */ "../3rd-party/codemirror_lang-python.js" ), import(/* webpackIgnore: true */ "../3rd-party/codemirror_language.js"), import(/* webpackIgnore: true */ "../3rd-party/codemirror_view.js"), import(/* webpackIgnore: true */ "../3rd-party/codemirror_commands.js"), ]); const isSetup = script.hasAttribute("setup"); const hasConfig = script.hasAttribute("config"); const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`; if (hasConfig && configs.has(env)) { throw new SyntaxError( configs.get(env) ? `duplicated config for env: ${env}` : `unable to add a config to the env: ${env}`, ); } configs.set(env, hasConfig); const source = script.src ? await fetch(script.src).then((b) => b.text()) : script.textContent; const context = { interpreter, env, config: hasConfig && new URL(script.getAttribute("config"), location.href).href, get pySrc() { return isSetup ? source : editor.state.doc.toString(); }, get outDiv() { return isSetup ? null : outDiv; }, }; if (isSetup) { execute.call(context, { currentTarget: null }); return; } const selector = script.getAttribute("target"); let target; if (selector) { target = document.getElementById(selector) || document.querySelector(selector); if (!target) throw new Error(`Unknown target ${selector}`); } else { target = document.createElement(`${type}-editor`); target.style.display = "block"; script.after(target); } if (!target.id) target.id = getID(type); if (!target.hasAttribute("exec-id")) target.setAttribute("exec-id", 0); if (!target.hasAttribute("root")) target.setAttribute("root", target.id); // @see https://github.com/JeffersGlass/mkdocs-pyscript/blob/main/mkdocs_pyscript/js/makeblocks.js const listener = execute.bind(context); const [boxDiv, outDiv] = makeBoxDiv(listener, type); boxDiv.dataset.env = script.hasAttribute("env") ? env : interpreter; const inputChild = boxDiv.querySelector(`.${type}-editor-input > div`); const parent = inputChild.attachShadow({ mode: "open" }); // avoid inheriting styles from the outer component parent.innerHTML = ``; target.appendChild(boxDiv); const doc = dedent(script.textContent).trim(); // preserve user indentation, if any const indentation = /^(\s+)/m.test(doc) ? RegExp.$1 : " "; const editor = new EditorView({ extensions: [ indentUnit.of(indentation), new Compartment().of(python()), keymap.of([ ...defaultKeymap, { key: "Ctrl-Enter", run: listener, preventDefault: true }, { key: "Cmd-Enter", run: listener, preventDefault: true }, { key: "Shift-Enter", run: listener, preventDefault: true }, ]), basicSetup, ], parent, doc, }); editor.focus(); }; // avoid too greedy MutationObserver operations at distance let timeout = 0; // avoid delayed initialization let queue = Promise.resolve(); // reset interval value then check for new scripts const resetTimeout = () => { timeout = 0; pyEditor(); }; // triggered both ASAP on the living DOM and via MutationObserver later const pyEditor = () => { if (timeout) return; timeout = setTimeout(resetTimeout, 250); for (const [type, interpreter] of TYPES) { const selector = `script[type="${type}-editor"]`; for (const script of document.querySelectorAll(selector)) { // avoid any further bootstrap by changing the type as active script.type += "-active"; // don't await in here or multiple calls might happen // while the first script is being initialized queue = queue.then(() => init(script, type, interpreter)); } } return queue; }; new MutationObserver(pyEditor).observe(document, { childList: true, subtree: true, }); // try to check the current document ASAP export default pyEditor();