import { basicSetup, EditorView } from 'codemirror'; import { python } from '@codemirror/lang-python'; import { indentUnit } from '@codemirror/language'; import { Compartment } from '@codemirror/state'; import { keymap } from '@codemirror/view'; import { defaultKeymap } from '@codemirror/commands'; import { oneDarkTheme } from '@codemirror/theme-one-dark'; import { getAttribute, ensureUniqueId, htmlDecode } from '../utils'; import type { Runtime } from '../runtime'; import { pyExec, pyDisplay } from '../pyexec'; import { getLogger } from '../logger'; const logger = getLogger('py-repl'); export function make_PyRepl(runtime: Runtime) { /* High level structure of py-repl DOM, and the corresponding JS names. this shadow #shadow-root boxDiv
editorLabel editorDiv
outDiv
*/ class PyRepl extends HTMLElement { shadow: ShadowRoot; outDiv: HTMLElement; editor: EditorView; constructor() { super(); } connectedCallback() { ensureUniqueId(this); this.shadow = this.attachShadow({ mode: 'open' }); const slot = document.createElement('slot'); this.shadow.appendChild(slot); if (!this.hasAttribute('exec-id')) { this.setAttribute('exec-id', '1'); } if (!this.hasAttribute('root')) { this.setAttribute('root', this.id); } const pySrc = htmlDecode(this.innerHTML).trim(); this.innerHTML = ''; this.editor = this.makeEditor(pySrc); const boxDiv = this.makeBoxDiv(); this.appendChild(boxDiv); this.editor.focus(); logger.debug(`element ${this.id} successfully connected`); } /** Create and configure the codemirror editor */ makeEditor(pySrc: string): EditorView { const languageConf = new Compartment(); const extensions = [ indentUnit.of(' '), basicSetup, languageConf.of(python()), keymap.of([ ...defaultKeymap, { key: 'Ctrl-Enter', run: this.execute.bind(this) }, { key: 'Shift-Enter', run: this.execute.bind(this) }, ]), ]; if (getAttribute(this, 'theme') === 'dark') { extensions.push(oneDarkTheme); } return new EditorView({ doc: pySrc, extensions, }); } // ******** main entry point for py-repl DOM building ********** // // The following functions are written in a top-down, depth-first // order (so that the order of code roughly matches the order of // execution) makeBoxDiv(): HTMLElement { const boxDiv = document.createElement('div'); boxDiv.className = 'py-repl-box'; const editorDiv = this.makeEditorDiv(); const editorLabel = this.makeLabel('Python Script Area', editorDiv); this.outDiv = this.makeOutDiv(); boxDiv.append(editorLabel); boxDiv.appendChild(editorDiv); boxDiv.appendChild(this.outDiv); return boxDiv; } makeEditorDiv(): HTMLElement { const editorDiv = document.createElement('div'); editorDiv.id = 'code-editor'; editorDiv.className = 'py-repl-editor'; editorDiv.appendChild(this.editor.dom); const runButton = this.makeRunButton(); const runLabel = this.makeLabel('Python Script Run Button', runButton); editorDiv.appendChild(runLabel); editorDiv.appendChild(runButton); return editorDiv; } makeLabel(text: string, elementFor: HTMLElement): HTMLElement { ensureUniqueId(elementFor); const lbl = document.createElement('label'); lbl.innerHTML = text; lbl.htmlFor = elementFor.id; // XXX this should be a CSS class // Styles that we use to hide the labels whilst also keeping it accessible for screen readers const labelStyle = 'overflow:hidden; display:block; width:1px; height:1px'; lbl.setAttribute('style', labelStyle); return lbl; } makeRunButton(): HTMLElement { const runButton = document.createElement('button'); runButton.id = 'runButton'; runButton.className = 'absolute py-repl-run-button'; // XXX I'm sure there is a better way to embed svn into typescript runButton.innerHTML = ''; runButton.addEventListener('click', this.execute.bind(this)); return runButton; } makeOutDiv(): HTMLElement { const outDiv = document.createElement('div'); outDiv.className = 'py-repl-output'; outDiv.id = this.id + '-' + this.getAttribute('exec-id'); return outDiv; } // ********************* execution logic ********************* /** Execute the python code written in the editor, and automatically * display() the last evaluated expression */ async execute(): Promise { const pySrc = this.getPySrc(); // determine the output element const outEl = this.getOutputElement(); if (outEl === undefined) { // this happens if we specified output="..." but we couldn't // find the ID. We already displayed an error message inside // getOutputElement, stop the execution. return; } // clear the old output before executing the new code outEl.innerHTML = ''; // execute the python code const pyResult = await pyExec(runtime, pySrc, outEl); // display the value of the last evaluated expression (REPL-style) if (pyResult !== undefined) { pyDisplay(runtime, pyResult, { target: outEl.id }); } this.autogenerateMaybe(); } getPySrc(): string { return this.editor.state.doc.toString(); } getOutputElement(): HTMLElement { const outputID = getAttribute(this, 'output'); if (outputID !== null) { const el = document.getElementById(outputID); if (el === null) { const err = `py-repl ERROR: cannot find the output element #${outputID} in the DOM`; this.outDiv.innerText = err; return undefined; } return el; } else { return this.outDiv; } } // XXX the autogenerate logic is very messy. We should redo it, and it // should be the default. autogenerateMaybe(): void { if (this.hasAttribute('auto-generate')) { const allPyRepls = document.querySelectorAll(`py-repl[root='${this.getAttribute('root')}'][exec-id]`); const lastRepl = allPyRepls[allPyRepls.length - 1]; const lastExecId = lastRepl.getAttribute('exec-id'); const nextExecId = parseInt(lastExecId) + 1; const newPyRepl = document.createElement('py-repl'); newPyRepl.setAttribute('root', this.getAttribute('root')); newPyRepl.id = this.getAttribute('root') + '-' + nextExecId.toString(); if (this.hasAttribute('auto-generate')) { newPyRepl.setAttribute('auto-generate', ''); this.removeAttribute('auto-generate'); } const outputMode = getAttribute(this, 'output-mode'); if (outputMode) { newPyRepl.setAttribute('output-mode', outputMode); } const addReplAttribute = (attribute: string) => { const attr = getAttribute(this, attribute); if (attr) { newPyRepl.setAttribute(attribute, attr); } }; addReplAttribute('output'); newPyRepl.setAttribute('exec-id', nextExecId.toString()); if (this.parentElement) { this.parentElement.appendChild(newPyRepl); } } } } return PyRepl; }