@webwriter/code
Version:
Write and run code as a code cell. Supports several languages (HTML, JavaScript/TypeScript, Python, Java, WebAssembly).
392 lines (340 loc) • 14.8 kB
text/typescript
import "@shoelace-style/shoelace/dist/themes/light.css";
import { LitElementWw } from "@webwriter/lit";
import { LitElement, PropertyValueMap, html } from "lit";
import { property, query } from "lit/decorators.js";
import { style } from "./ww-code-css-single";
// CodeMirror
import { autocompletion } from "@codemirror/autocomplete";
import { LanguageSupport } from "@codemirror/language";
import { Compartment, StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { lineLockEffect, lineLockField, setupCodeMirror } from "./codemirror-setup";
// Shoelace Components
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.js";
import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.js";
import SlInput from "@shoelace-style/shoelace/dist/components/input/input.js";
import SlSwitch from "@shoelace-style/shoelace/dist/components/switch/switch.js";
import "./shoelace-icons";
import { msg } from "@lit/localize";
import LOCALIZE from "../../localization/generated";
export type LanguageModule = {
name: string;
executionFunction: ((code: string, context: Code) => any) | undefined;
languageExtension: LanguageSupport;
};
export type Diagnostic = {
message: string;
start?: number;
line?: number;
character?: number;
};
export default abstract class Code extends LitElementWw {
static styles = style;
static get scopedElements() {
return {
"sl-button": SlButton,
"sl-input": SlInput,
"sl-switch": SlSwitch,
"sl-details": SlDetails,
"sl-icon": SlIcon,
};
}
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
localize = LOCALIZE;
private codeMirror: EditorView = new EditorView();
private languageModule!: LanguageModule;
/** The source code content displayed in the editor. */
accessor code = this.codeMirror.state.doc.toString();
/** Whether the code editor is visible to the user. */
accessor visible = true;
/** Whether to automatically run the code when the component is first loaded. */
accessor autoRun = false;
/** Whether the code execution is allowed and the run button is enabled. */
accessor runnable = true;
/** Whether autocompletion is enabled in the code editor. */
accessor autocomplete = false;
/** Array of line numbers that should be locked from editing. */
accessor lockedLines: number[] = [];
/** Whether to display the execution time in the controls. */
accessor showExecutionTime = false;
/** The execution time in milliseconds of the last code run. */
accessor executionTime: number = 0;
/** Whether to display the execution count in the run button. */
accessor showExecutionCount = false;
/** The number of times the code has been executed. */
accessor executionCount = 0;
/** The results from the last code execution. */
accessor results: any = [];
/** Compilation or runtime errors from the last code execution. */
accessor diagnostics: Diagnostic[] = [];
// @ts-expect-error
accessor iframePreview: HTMLIFrameElement | undefined;
accessor pre!: HTMLPreElement;
get codeRunner() {
return this.languageModule.executionFunction;
}
language = new Compartment();
autocompletion = new Compartment();
highlightStyle = new Compartment();
constructor(languageModule: LanguageModule) {
super();
this.languageModule = languageModule;
}
isEditable() {
return this.contentEditable === "true" || this.contentEditable === "";
}
firstUpdated() {
this.codeMirror = setupCodeMirror(
this.code,
this.pre,
this.isEditable(),
[
this.language.of(this.languageModule.languageExtension),
this.autocompletion.of(autocompletion()),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
this.code = update.state.doc.toString();
}
}),
],
() => msg("This section of code is locked and cannot be edited"),
);
if (this.lockedLines.length > 0) {
this.codeMirror.dispatch({
effects: this.lockedLines
.map((lineNumber) => {
try {
const line = this.codeMirror.state.doc.line(lineNumber);
return lineLockEffect.of({ pos: line.from, on: true });
} catch (error) {
console.warn(`Line number ${lineNumber} is out of bounds for the document.`);
return null;
}
})
.filter((effect) => effect !== null),
});
}
this.codeMirror.state.field(lineLockField).onLockedLinesChange = (lockedLines: number[]) => {
this.lockedLines = lockedLines;
};
if (this.autoRun) {
this.runCode();
}
}
protected updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
if (_changedProperties.has("autocomplete")) {
this.setAutocompletion(this.autocomplete);
}
if (_changedProperties.has("code")) {
if (this.codeMirror.state.doc.toString() !== this.code) {
this.codeMirror.dispatch({
changes: { from: 0, to: this.codeMirror.state.doc.length, insert: this.code },
});
}
}
if (_changedProperties.has("lockedLines")) {
let remainingLinesToLock = this.lockedLines;
let effects: StateEffect<any>[] = [];
this.codeMirror.state.field(lineLockField).markers.between(0, this.codeMirror.state.doc.length, (from) => {
const line = this.codeMirror.state.doc.lineAt(from);
if (!remainingLinesToLock.includes(line.number)) {
effects.push(lineLockEffect.of({ pos: from, on: false }));
} else {
remainingLinesToLock = remainingLinesToLock.filter((l) => l !== line.number);
}
});
remainingLinesToLock.forEach((lineNumber) => {
if (lineNumber < 1 || lineNumber > this.codeMirror.state.doc.lines) {
console.warn(`Line number ${lineNumber} is out of bounds for the document.`);
return;
}
const line = this.codeMirror.state.doc.line(lineNumber);
effects.push(lineLockEffect.of({ pos: line.from, on: true }));
});
if (effects.length > 0) this.codeMirror.dispatch({ effects });
}
}
getVisibleStyle() {
if (this.isEditable()) {
return this.visible ? "" : "opacity: 0.5";
}
return this.visible ? "" : "display: none";
}
render() {
return html`
${this.Code()} ${this.Controls()} ${this.codeRunner !== undefined ? this.Output() : null}
${this.isEditable() ? this.Options() : ""}
`;
}
Code() {
return html`<pre style=${this.getVisibleStyle()}></pre>`;
}
Controls() {
return html`<div class="controls" style=${this.getVisibleStyle()}>
<sl-button
variant="primary"
size="small"
?disabled=${this.codeRunner === undefined}
="${this.runCode}"
style=${this.runnable && this.codeRunner !== undefined ? "" : "display: none"}
>
<sl-icon name="${this.autoRun ? "play-circle" : "play-fill"}" slot="prefix"></sl-icon>
${msg("Run")} ${this.showExecutionCount ? `(${this.executionCount})` : ""}
</sl-button>
${this.showExecutionTime ? html`<div class="executionTime">${this.executionTime.toFixed(1)}ms</div>` : ""}
<div class="language-label">${this.languageModule.name}</div>
<sl-button
size="small"
=${() => {
this.results = [];
this.diagnostics = [];
this.executionTime = 0;
}}
style=${this.runnable && this.codeRunner !== undefined ? "" : "display: none"}
>
${msg("Clear Output")}
</sl-button>
</div>`;
}
Output() {
return html`<output style=${this.getVisibleStyle()}>
${this.diagnostics?.length > 0 ? this.Diagnostics() : this.Result()}
</output>`;
}
Options() {
return html`<aside part="options" style="z-index: 1000">
<h2>${msg("Execution")}</h2>
<sl-switch
-change=${(event: any) => {
if (event.target) {
let target = event.target as SlSwitch;
this.runnable = target.checked;
}
}}
?checked=${this.runnable}
?disabled=${this.codeRunner === undefined}
>${msg("Allow Code execution")}</sl-switch
>
<sl-switch
-change=${(e: any) => (this.autoRun = e.target.checked)}
?checked=${this.autoRun}
?disabled=${this.codeRunner === undefined}
>${msg("Run on load")}</sl-switch
>
<h2>${msg("Editor")}</h2>
<sl-switch
-change=${(event: any) => {
if (event.target) {
let target = event.target as SlSwitch;
this.setAutocompletion(target.checked);
}
}}
?checked=${this.autocomplete}
>${msg("Autocompletion")}</sl-switch
>
<sl-switch -change=${(e: any) => (this.visible = e.target.checked)} ?checked=${this.visible}
>${msg("Visible")}</sl-switch
>
<h2>${msg("Results")}</h2>
<sl-switch
-change=${(e: any) => (this.showExecutionTime = e.target.checked)}
?checked=${this.showExecutionTime}
>${msg("Show execution time")}</sl-switch
>
<sl-switch
-change=${(e: any) => (this.showExecutionCount = e.target.checked)}
?checked=${this.showExecutionCount}
>${msg("Show execution count")}</sl-switch
>
<sl-button =${() => (this.executionCount = 0)}
><span class="button-label-linebreak">${msg("Reset execution count")}</span></sl-button
>
</aside>`;
}
Result() {
switch (this.languageModule.name) {
case "Python":
case "WebAssembly":
case "Java":
const outputs = this.results
.filter((r: any) => r !== undefined)
.map((r: any) => html`<pre style="color:${r?.color}">${r?.text}</pre>`);
return html` <div class="outputs">${outputs}</div>`;
case "HTML":
return html` <iframe
id="iframePreview"
class="htmlPreview"
srcdoc=${this.results[0]}
sandbox="allow-scripts allow-modals"
></iframe>`;
default:
return html``;
}
}
Diagnostics() {
return html`
<div class="diagnostics-container">
${this.languageModule.name} compilation failed with ${this.diagnostics.length}
error${this.diagnostics.length > 1 ? "s" : ""}:
<div class="diagnostics-list">
${this.diagnostics.map(
(d) => html`
<sl-icon name="exclamation-triangle-fill" class="diagnostic-icon"></sl-icon>
${d.start
? html` <a
class="diagnostic-line-number"
href="#"
=${(event: Event) => {
event.preventDefault();
this.codeMirror.focus();
if (typeof d.start === "number") {
this.codeMirror.dispatch({
selection: { anchor: d.start },
});
}
}}
>${d.line}:${d.character}</a
>`
: ""}
<div class="diagnostic-message">${d.message}</div>
`,
)}
</div>
</div>
`;
}
async runCode() {
if (!this.codeRunner) {
return;
}
this.results = [];
this.diagnostics = [];
this.executionCount++;
const code = this.codeMirror.state.doc.toString();
const startTime = performance.now();
await this.codeRunner(code, this);
const endTime = performance.now();
this.executionTime = endTime - startTime;
}
setAutocompletion(value: boolean) {
this.autocomplete = value;
this.codeMirror.dispatch({
effects: this.autocompletion.reconfigure(value ? autocompletion() : []),
});
}
}