@webwriter/code
Version:
Write and run code as a code cell. Supports several languages (HTML/CSS/JS, TypeScript, Python).
640 lines (558 loc) • 24 kB
text/typescript
import '@shoelace-style/shoelace/dist/themes/light.css';
import { LitElementWw } from '@webwriter/lit';
import { property, query } from 'lit/decorators.js';
import { LitElement, PropertyValueMap, html } from 'lit';
import { style } from './ww-code-css-single';
import { v4 as uuidv4 } from 'uuid';
// import readOnlyRangesExtension from 'codemirror-readonly-ranges';
// CodeMirror
import { syntaxHighlighting } from '@codemirror/language';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorState, Compartment } from '@codemirror/state';
import { autocompletion } from '@codemirror/autocomplete';
import { EditorView } from '@codemirror/view';
import { basicSetup } from './codemirror-setup';
import { highlightSelection } from './highlight';
// Shoelace Components
import SlButton from '@shoelace-style/shoelace/dist/components/button/button.component.js';
import SlSwitch from '@shoelace-style/shoelace/dist/components/switch/switch.component.js';
import SlInput from '@shoelace-style/shoelace/dist/components/input/input.component.js';
import SLDetails from '@shoelace-style/shoelace/dist/components/details/details.component.js';
import LockMarker from './CodeMirror/LockMarker';
import CustomGutter from './CodeMirror/CustomGutter';
import { faPlay, faCirclePlay } from './fontawesome.css';
export default abstract class Code extends LitElementWw {
static styles = style;
// static JSONConverter = {
// fromAttribute: (value: string) => {
// return JSON.parse(value);
// },
// toAttribute: (value: Array<number>) => {
// return JSON.stringify(value);
// },
// };
codeMirror: EditorView = new EditorView();
accessor lockedLines: number[] = [];
accessor name: string = '';
accessor didRunOnce: boolean = false;
accessor dependencies: string[] = [];
accessor runAsModule = false;
accessor visible = true;
accessor autoRun = false;
accessor runnable = true;
accessor autocomplete = false;
accessor hideExecutionTime = true;
accessor hideExecutionCount = true;
accessor code = this.codeMirror.state.doc.toString();
accessor canChangeLanguage = true;
accessor executionCount = 0;
accessor globalExecution = true;
disabledLines: Array<number> = [];
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
accessor languageModule: any;
accessor languages: any
accessor errorListener: any;
accessor dependecyListening: boolean = false;
get codeRunner() {
return this.languageModule.executionFunction;
}
accessor results: Array<{ text: string; color: string } | undefined> = [];
accessor executionTime: number = 0;
accessor iframePreview: HTMLIFrameElement | undefined;
language = new Compartment();
autocompletion = new Compartment();
// readOnlyRanges = new Compartment();
theme = new Compartment();
highlightStyle = new Compartment();
lockGutter = CustomGutter('lock', new LockMarker(), (_: EditorView, l: number) =>
this.makeLineReadOnly(this.codeMirror.state.doc.lineAt(l).number)
);
static get scopedElements() {
let componentList = {
'sl-button': SlButton,
'sl-input': SlInput,
'sl-switch': SlSwitch,
'sl-details': SLDetails,
} as any;
return componentList;
}
isEditable() {
return this.contentEditable === 'true' || this.contentEditable === '';
}
focus() {
// this.codeMirror?.focus();
}
accessor pre!: HTMLPreElement;
firstUpdated() {
this.name = uuidv4();
this.codeMirror = this.createCodeMirror(this.pre);
// this.codeMirror.focus();
if (this.iframePreview) {
this.iframePreview.addEventListener('load', () => {
if (this.iframePreview && this.iframePreview.contentWindow) {
this.iframePreview.height = this.iframePreview.contentWindow.document.body.scrollHeight + 16 + 'px';
}
});
}
for (const line of this.lockedLines) {
this.makeLineReadOnly(line);
}
window.addEventListener('error', (e) => {
//Check if language is JS
if (this.languageModule.name !== 'JS') {
return;
}
this.results.push({ color: 'red', text: e.message });
});
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('languageName')) {
this.codeMirror.dispatch({ effects: this.language.reconfigure(this.languageModule.languageExtension) });
}
// console.log(_changedProperties);
}
changeLanguage(language: any) {
this.results = [];
this.languageModule = language;
this.codeMirror.dispatch({ effects: this.language.reconfigure(this.languageModule.languageExtension) });
// this.codeMirror.focus();
}
handleKeyPress(e: KeyboardEvent) {
if(e.ctrlKey && e.key==="v"){
navigator.clipboard
.readText()
.then((clipText) => {
this.codeMirror.dispatch({
changes: { from: 0, to: this.codeMirror.state.doc.length, insert: this.codeMirror.state.doc.toString()+clipText },
});
});
}
if(e.ctrlKey && e.key==="c"){
if(window.getSelection){
navigator.clipboard
.writeText(window.getSelection().toString())
}
}
}
getVisibleStyle() {
if (this.isEditable()) {
return this.visible ? '' : 'opacity: 0.5';
}
return this.visible ? '' : 'display: none';
}
render() {
return html`
<style>
${style}
</style>
${this.Code()} ${this.Footer()} ${this.codeRunner !== undefined ? this.Output() : null}
${this.isEditable() ? this.Options() : ''}
`;
}
Code() {
return html`<pre style=${this.getVisibleStyle()} =${(e: KeyboardEvent)=>(this.handleKeyPress(e))}></pre>`;
}
Footer() {
return html`<footer style=${this.getVisibleStyle()}>
<button
class="ww-code-button"
?disabled=${this.codeRunner === undefined}
="${this.runCode}"
style=${this.runnable && this.codeRunner !== undefined ? '' : 'display: none'}
>
${this.autoRun ? faCirclePlay : faPlay} Run
${this.globalExecution ? '' : 'in isolation'}${this.runAsModule && this.globalExecution
? ' as module'
: ''}
${this.hideExecutionCount ? '' : `(${this.executionCount})`}
</button>
<button
class="ww-code-button"
=${() => {
this.results = [];
this.executionTime = 0;
// this.codeMirror.focus();
}}
style=${this.runnable && this.codeRunner !== undefined ? '' : 'display: none'}
>
Clear Output
</button>
<select
value=${this.languageModule.name}
?disabled=${true}
class="ww-code-select"
=${(e: any) => {
this.changeLanguage(this.languageModule.name.find((l) => l.name === e.target.value));
}}
>
${this.languages.map(
(l) => html` <option value="${l.name}" ?selected=${l.name === this.languageModule.name}>${l.name}</option>`
)}
</select>
</footer>`;
}
Output() {
return html`<output style=${this.getVisibleStyle()}> ${this.Result()} </output>`;
}
Options() {
return html`<aside part="options" style="z-index: 1000">
<!-- <sl-input
-input=${(e: any) => (this.name = e.target.value)}
value=${this.name}
placeholder="Code Cell Name"
></sl-input> -->
<sl-details summary="Execution" ?disabled=${this.codeRunner === undefined}>
<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}
>Allow Code execution</sl-switch
>
<sl-switch
-change=${(event: any) => {
if (event.target) {
let target = event.target as SlSwitch;
this.globalExecution = target.checked;
}
}}
?checked=${this.globalExecution}
?disabled=${this.codeRunner === undefined}
>Global execution</sl-switch
>
<!-- <sl-switch
-change=${(event: any) => {
if (event.target) {
let target = event.target as SlSwitch;
this.runAsModule = target.checked;
}
}}
?disabled=${!this.globalExecution || this.codeRunner === undefined}
?checked=${this.runAsModule}
>Run as module</sl-switch
> -->
<sl-switch
-change=${(e: any) => (this.autoRun = e.target.checked)}
?checked=${this.autoRun}
?disabled=${this.codeRunner === undefined}
>Run on load</sl-switch
>
</sl-details>
<sl-details summary="Editor">
<!-- <sl-switch
-change=${(event: any) => {
if (event.target) {
let target = event.target as SlSwitch;
this.canChangeLanguage = target.checked;
}
}}
?checked=${this.canChangeLanguage}
>Allow Language change</sl-switch
> -->
<sl-switch
-change=${(event: any) => {
if (event.target) {
let target = event.target as SlSwitch;
this.setAutocompletion(target.checked);
}
}}
?checked=${this.autocomplete}
>Autocompletion</sl-switch
>
<sl-switch -change=${(e: any) => (this.visible = e.target.checked)} ?checked=${this.visible}
>Visible</sl-switch
>
</sl-details>
<sl-details summary="Results" ?disabled=${this.codeRunner === undefined}>
<sl-switch
-change=${(e: any) => (this.hideExecutionTime = !e.target.checked)}
?checked=${!this.hideExecutionTime}
>Show execution time</sl-switch
>
<sl-switch
-change=${(e: any) => (this.hideExecutionCount = !e.target.checked)}
?checked=${!this.hideExecutionCount}
>Show execution count</sl-switch
>
<sl-button =${() => (this.executionCount = 0)}>Reset execution count</sl-button>
</sl-details>
<!-- <sl-details summary="Dependencies" ?disabled=${this.codeRunner === undefined}>
<table>
<tbody>
${this.dependencies.map(
(d) => html`<tr>
<td>${d}#${(document.getElementById(d) as Code)?.name}</td>
<td>
<sl-button
=${() =>
(this.dependencies = this.dependencies.filter((dep) => dep !== d))}
size="small"
>
X
</sl-button>
</td>
</tr>`
)}
${this.dependencies.length === 0
? html`<tr>
<td colspan="2"><i>No dependencies</i></td>
</tr>`
: ''}
</tbody>
<tfoot>
<tr>
<td>
<sl-button
=${() => {
this.dependencies = [];
}}
size="small"
variant="danger"
outline
>Clear dependencies</sl-button
>
</td>
<td>
<sl-button
=${this.addDependencyAddListener}
variant=${this.dependecyListening ? 'primary' : 'neutral'}
size="small"
outline
>Add</sl-button
>
</td>
</tr>
</tfoot>
</table>
</sl-details> -->
</aside>`;
}
Result() {
switch (this.languageModule.name) {
case 'JS':
case 'TS':
case 'Python':
case 'WebAssembly':
const outputs = this.results
.filter((r) => r !== undefined)
.map((r) => html`<pre style="color:${r?.color}">${r?.text}</pre>`);
return html` <div class="outputs">${outputs}</div>
<div class="executionTime">${this.executionTime.toFixed(1)}ms</div>`;
case 'HTML':
return html` <iframe id="iframePreview" class="htmlPreview" srcdoc=${this.results[0]}></iframe>`;
case 'CSS':
const src = `
<style>
html, body {
overflow: hidden;
}
${this.results[0]}
</style>
<div class="cssPreview"></div>
`;
return html` <iframe id="iframePreview" class="cssPreviewWrapper" srcdoc=${src} seamless></iframe> `;
default:
return html``;
}
}
addDependency(codeCell: Code) {
console.log(codeCell, this.dependencies);
if (codeCell === this) {
console.warn('Cannot add self as dependency');
return;
}
if (this.dependencies.includes(codeCell.name)) {
console.warn('Dependency already added');
} else {
//Check for circular dependencies
if (codeCell.dependencies.includes(this.name)) {
console.warn('Circular dependency detected');
return;
}
this.dependencies.push(codeCell.name);
this.dependencies = [...this.dependencies];
}
}
addDependencyAddListener() {
//one time event listener on click
window.addEventListener(
'mousedown',
(e) => {
console.log(e.target);
//check if the target is a code cell
if (e.target && (e.target as HTMLElement).tagName === 'WEBWRITER-CODE') {
const target = e.target as Code;
this.addDependency(target);
} else {
console.warn('Target is not a code cell');
}
document.body.style.cursor = 'default';
this.dependecyListening = false;
},
{ once: true }
);
document.body.style.cursor = 'crosshair';
this.dependecyListening = true;
}
async runCode() {
if (!this.codeRunner) {
return;
}
this.results = [];
console.log(this.dependencies);
let allRun = true;
if (this.dependencies.length > 0) {
for (const dependency of this.dependencies) {
const dependencyElement = document.getElementById(dependency);
if (dependencyElement) {
if (!(dependencyElement as Code).didRunOnce) {
this.results.push({
color: 'red',
text: `Dependency ${
(document.getElementById(dependency) as Code).name
} did not run yet. Please run it first.`,
});
allRun = false;
}
}
}
}
if (!allRun) {
return;
}
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;
this.didRunOnce = true;
}
setAutocompletion(value: boolean) {
this.autocomplete = value;
this.codeMirror.dispatch({
effects: this.autocompletion.reconfigure(value ? autocompletion() : []),
});
// this.codeMirror.focus();
}
getReadOnlyRanges = (
targetState: EditorState
): Array<{ from: number | undefined; to: number | undefined }> => {
return this.disabledLines.map((line) => {
return { from: targetState.doc.line(line).from, to: targetState.doc.line(line).to };
});
};
makeLineReadOnly = (line: number) => {
if (!this.isEditable()) {
return;
}
const state = this.codeMirror.state;
const lineStart = this.codeMirror.state.doc.line(line).from;
const lineEnd = this.codeMirror.state.doc.line(line).to;
if (!this.disabledLines.includes(line)) {
this.disabledLines.push(line);
if (this.codeMirror.state.doc.line(line).text !== '') {
highlightSelection(this.codeMirror, [{ from: lineStart, to: lineEnd }]);
}
this.codeMirror.dispatch({
effects: this.lockGutter.effect.of({ pos: lineStart, on: true }),
});
} else {
// dirty fix to remove the highlight
this.disabledLines = this.disabledLines.filter((l) => l !== line);
this.codeMirror.dispatch({
changes: {
from: state.doc.line(line).from,
to: state.doc.line(line).to,
insert: '',
},
});
this.codeMirror.dispatch({
changes: {
from: state.doc.line(line).from,
to: undefined,
insert: state.doc.line(line).text,
},
});
this.codeMirror.dispatch({
effects: this.lockGutter.effect.of({ pos: lineStart, on: false }),
});
}
/* this.codeMirror.dispatch({
effects: this.readOnlyRanges.reconfigure(readOnlyRangesExtension(this.getReadOnlyRanges)),
});*/
this.lockedLines = [...this.disabledLines];
};
createCodeMirror(parentObject: any) {
const editorView = new EditorView({
state: EditorState.create({
doc: this.code,
extensions: [
basicSetup,
this.language.of(this.languageModule.languageExtension),
this.autocompletion.of(autocompletion()),
this.theme.of([]),
this.highlightStyle.of(syntaxHighlighting(oneDarkHighlightStyle, { fallback: true })),
// this.readOnlyRanges.of(readOnlyRangesExtension(this.getReadOnlyRanges)),
// this.lockGutter.gutter,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
this.code = update.state.doc.toString();
}
}),
],
}),
parent: parentObject,
});
return editorView;
}
}