@webwriter/block-based-code
Version:
Write block-based code (e.g. Scratch) and run it.
360 lines (328 loc) • 11.8 kB
text/typescript
import {
customElement, property, query, state,
} from "lit/decorators.js";
import { LitElementWw } from "@webwriter/lit";
import {
CSSResult, html, LitElement, TemplateResult,
} from "lit";
import { Task, TaskStatus } from "@lit/task";
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import AdjustmentsIcon from "@tabler/icons/outline/adjustments.svg";
import PlayerStopIcon from "@tabler/icons/outline/player-stop.svg";
import PlayerPlayIcon from "@tabler/icons/outline/player-play.svg";
import RefreshIcon from "@tabler/icons/outline/refresh.svg";
import { codeStyles, styles } from "./stage.styles";
import { Logger } from "../../utils";
import { msg } from "../../locales";
import { PixiApplication } from "../../lib/pixi";
import { CodeHighlightingEvent, StageApplication, StageType } from "../../types";
import { Toolbar } from "../toolbar";
import { ToolbarButton } from "../toolbar-button";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"
import SlCheckbox from "@shoelace-style/shoelace/dist/components/checkbox/checkbox.component.js"
import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.component.js"
import SlDivider from "@shoelace-style/shoelace/dist/components/divider/divider.component.js"
import SlRange from "@shoelace-style/shoelace/dist/components/range/range.component.js"
import SlSpinner from "@shoelace-style/shoelace/dist/components/spinner/spinner.component.js"
import SlTab from "@shoelace-style/shoelace/dist/components/tab/tab.component.js"
import SlTabGroup from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js"
import SlTabPanel from "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.component.js"
/**
* The stage component.
*/
export class Stage extends LitElementWw {
/**
* The application that is used to render the stage.
*/
public stageApplication: StageApplication;
/**
* The selected stage type.
*/
({ type: String })
public accessor stageType: StageType;
/**
* The readable code.
*/
({ type: String })
public accessor readableCode: string;
/**
* The executable code.
*/
({ type: String })
public accessor executableCode: string;
/**
* The stage element.
* @private
*/
("#stage")
private accessor stageElement!: SlTabPanel;
/**
* The VM options dialog element.
* @private
*/
("#vm-options-dialog")
private accessor vmOptionsDialog!: SlDialog;
/**
* Whether block highlighting is enabled.
* @private
*/
()
private accessor vmBlockHighlighting: boolean = true;
/**
* The delay between each block execution.
* @private
*/
()
private accessor vmDelay: number = 100;
/**
* The resize observer.
* @private
*/
private readonly resizeObserver: ResizeObserver;
/**
* The application ready task.
* @private
*/
private readonly applicationReady: Task;
/**
* The execution running task.
* @private
*/
private readonly executionRunning: Task;
/**
* @inheritDoc
*/
public static get scopedElements(): Record<string, typeof LitElement> {
return {
"webwriter-blocks-toolbar": Toolbar,
"webwriter-blocks-toolbar-button": ToolbarButton,
"sl-spinner": SlSpinner,
"sl-tab-group": SlTabGroup,
"sl-tab": SlTab,
"sl-tab-panel": SlTabPanel,
"sl-dialog": SlDialog,
"sl-button": SlButton,
"sl-checkbox": SlCheckbox,
"sl-range": SlRange,
"sl-divider": SlDivider,
};
}
/**
* @inheritDoc
*/
public static get styles(): CSSResult[] {
return [
styles,
codeStyles,
];
}
constructor() {
super();
hljs.registerLanguage("javascript", javascript);
this.applicationReady = new Task(this, {
task: async () => {
await this.stageApplication.initComplete;
},
autoRun: false,
onComplete: () => {
this.stageElement.appendChild(this.stageApplication.container);
this.stageApplication.virtualMachine.setHighlightCallback(this.handleCodeHighlighting.bind(this));
this.stageApplication.show();
Logger.log(this, "Initialized!");
},
});
this.executionRunning = new Task<[string, number]>(this, {
task: async ([code, delay], options) => {
Logger.log(this, "Starting execution...");
options.signal.addEventListener("abort", () => {
this.stageApplication.virtualMachine.stop();
Logger.log(this, "Execution aborted!");
});
await this.stageApplication.virtualMachine.start(code, delay);
},
autoRun: false,
onComplete: () => {
Logger.log(this, "Execution completed!");
},
});
this.resizeObserver = new ResizeObserver(() => this.handleResize());
}
/**
* @inheritDoc
*/
public connectedCallback() {
super.connectedCallback();
this.resizeObserver.observe(this);
this.applyStageType();
}
/**
* @inheritDoc
*/
public disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver.disconnect();
this.applicationReady.abort();
}
/**
* @inheritDoc
*/
public render(): TemplateResult {
const renderer: Parameters<typeof this.applicationReady["render"]>[0] = {
pending: () => html`<sl-spinner></sl-spinner>`,
error: (error: Error) => {
Logger.log(this, error);
return html`<div class="error">${msg("ERROR")}</div>`;
},
};
return html`
<webwriter-blocks-toolbar>
<div>
<webwriter-blocks-toolbar-button id="settings"
label=${msg("EXECUTION_OPTIONS")}
icon=${AdjustmentsIcon}
.disabled=${this.executionRunning.status === TaskStatus.PENDING}
@click=${this.handleVmOptionsClick}>
</webwriter-blocks-toolbar-button>
</div>
<div>
<webwriter-blocks-toolbar-button id="stop"
label=${msg("STOP")}
icon=${PlayerStopIcon}
.disabled=${this.executionRunning.status !== TaskStatus.PENDING}
@click=${this.handleStopClick}>
</webwriter-blocks-toolbar-button>
<webwriter-blocks-toolbar-button id="start"
label=${this.executionRunning.status === TaskStatus.PENDING ? msg("RESTART") : msg("START")}
icon=${this.executionRunning.status === TaskStatus.PENDING ? RefreshIcon : PlayerPlayIcon}
@click=${this.handleStartClick}>
</webwriter-blocks-toolbar-button>
</div>
</webwriter-blocks-toolbar>
<sl-tab-group placement="bottom">
<sl-tab slot="nav" panel="stage" active>${msg(`OPTIONS.STAGE_TYPES.${this.stageType.toUpperCase() as Uppercase<StageType>}`)}
</sl-tab>
<sl-tab slot="nav" panel="code">${msg("OPTIONS.STAGE_TYPES.CODE")}</sl-tab>
<sl-tab-panel name="stage" id="stage" active>
${this.applicationReady.render(renderer)}
</sl-tab-panel>
<sl-tab-panel name="code" id="code">
<pre><code>${unsafeHTML(hljs.highlight(this.readableCode, { language: "javascript" }).value)}</code></pre>
</sl-tab-panel>
</sl-tab-group>
<sl-dialog label="Execution Options" id="vm-options-dialog">
<div class="group">
<sl-checkbox help-text="Highlight blocks as they are executed."
.checked=${this.vmBlockHighlighting}
@sl-change=${this.handleBlockHighlightingChange}>
Block highlighting
</sl-checkbox>
</div>
<sl-divider></sl-divider>
<div class="group" style="gap: 0">
<span class="label">Delay (ms)</span>
<div style="display: flex; gap: var(--sl-spacing-x-small);">
<sl-range help-text="The delay between each block execution."
style="width: 100%;"
min="0"
max="1000"
step="100"
.value=${this.vmDelay}
@sl-change=${this.handleDelayChange}>
</sl-range>
<span style="width: 100px; font-style: italic; color: var(--sl-color-gray-500); font-size: 14px; padding-top: 3px">
${this.vmDelay} ms
</span>
</div>
</div>
<sl-button slot="footer"
variant="primary"
@click="${() => this.vmOptionsDialog.hide()}">
Save
</sl-button>
</sl-dialog>
`;
}
/**
* @inheritDoc
*/
protected updated(changedProperties: Map<string | number | symbol, unknown>): void {
if (changedProperties.get("stageType")) {
this.applyStageType();
}
}
/**
* Handles the resize event.
* @private
*/
private handleResize(): void {
this.stageApplication.resize();
}
/**
* Handles the VM options click event.
* @private
*/
private handleVmOptionsClick(): void {
this.vmOptionsDialog.show().catch();
}
/**
* Handles the start click event.
* @private
*/
private async handleStartClick(): Promise<void> {
await this.executionRunning.run([this.executableCode, this.vmDelay]);
}
/**
* Handles the stop click event.
* @private
*/
private handleStopClick(): void {
this.executionRunning.abort();
}
/**
* Handles the block highlighting change event.
* @param event The change event.
* @private
*/
private handleBlockHighlightingChange(event: Event): void {
const checkbox = event.target as SlCheckbox;
this.vmBlockHighlighting = checkbox.checked;
}
/**
* Handles the delay change event.
* @param event The change event.
* @private
*/
private handleDelayChange(event: Event): void {
const range = event.target as SlRange;
this.vmDelay = range.value as number;
}
/**
* Handles the code highlighting event.
* @param id The block ID.
* @private
*/
private handleCodeHighlighting(id: string): void {
if (!this.vmBlockHighlighting) return;
const event = new CodeHighlightingEvent(id);
this.dispatchEvent(event);
}
/**
* Applies the selected stage type.
* @private
*/
private applyStageType(): void {
if (this.stageApplication) {
this.stageApplication.destroy();
}
switch (this.stageType) {
case StageType.CANVAS:
this.stageApplication = new PixiApplication();
break;
default:
throw new Error("Invalid stage type.");
}
this.applicationReady.run();
}
}