@webwriter/block-based-code
Version:
Write block-based code (e.g. Scratch) and run it.
307 lines (273 loc) • 9.29 kB
text/typescript
import {
css, CSSResult, html, LitElement, TemplateResult,
} from "lit";
import { LitElementWw } from "@webwriter/lit";
import {
customElement, property, query, state,
} from "lit/decorators.js";
import ArrowsMaximizeIcon from "@tabler/icons/outline/arrows-maximize.svg";
import ArrowsMinimizeIcon from "@tabler/icons/outline/arrows-minimize.svg";
import { styleMap } from "lit/directives/style-map.js";
import {
Application, Editor, Options, Stage, Toolbar,
} from "../components";
import { msg, setLocale } from "../locales";
import { Logger } from "../utils";
import {
CodeHighlightingEvent, EditorChangeEvent, OptionsChangeEvent, StageType,
} from "../types";
import { BlockTypes, SelectedBlocks } from "../lib/blockly";
import "@shoelace-style/shoelace/dist/themes/light.css";
import { ToolbarButton } from "../components/toolbar-button";
import { Help } from "../components/help";
/**
* The main component of the Blocks widget.
*/
("webwriter-block-based-code")
export class WebwriterBlocks extends LitElementWw {
/**
* Whether the widget is in read-only mode, only effective when contentEditable is false.
*/
({ type: Number, reflect: true })
public accessor readonly: 0 | 1 = 0;
/**
* The selected stage type.
*/
({ type: String, reflect: true })
public accessor stageType: StageType = StageType.CANVAS;
/**
* The usable blocks. Only blocks in this list can be used in the editor.
*/
({
reflect: true,
converter: {
fromAttribute: (value: string | null) => {
if (!value) return ["events:when_start_clicked"];
try {
return JSON.parse(value);
} catch {
return ["events:when_start_clicked"];
}
},
toAttribute: (value: SelectedBlocks) => JSON.stringify(value),
},
})
public accessor usableBlocks: SelectedBlocks = ["events:when_start_clicked"];
/**
* The editor state.
*/
({ type: Object, reflect: true })
public accessor editorState: object = {};
/**
* The available blocks. These are all blocks that can be understood by the stage.
* @private
*/
()
private accessor availableBlocks: BlockTypes[] = [];
/**
* The readable generated code.
* @private
*/
()
private accessor readableCode: string = "";
/**
* The executable generated code.
* @private
*/
()
private accessor executableCode: string = "";
/**
* The editor element.
* @private
*/
("#editor")
private accessor editor!: Editor;
/**
* The stage element.
* @private
*/
("#stage")
private accessor stage!: Stage;
/**
* @inheritDoc
*/
public static get scopedElements(): Record<string, typeof LitElement> {
return {
"webwriter-blocks-toolbar": Toolbar,
"webwriter-blocks-toolbar-button": ToolbarButton,
"webwriter-blocks-application": Application,
"webwriter-blocks-editor": Editor,
"webwriter-blocks-stage": Stage,
"webwriter-blocks-options": Options,
"webwriter-blocks-help": Help,
};
}
/**
* @inheritDoc
*/
public static get styles(): CSSResult[] {
return [
css`
:host {
display: flex ;
flex-direction: column;
gap: var(--sl-spacing-x-small);
padding: var(--sl-spacing-x-small);
user-select: none;
box-sizing: border-box;
border: 1px solid var(--sl-color-gray-300);
border-radius: var(--sl-border-radius-medium);
background-color: var(--sl-color-gray-50);
}
:host * {
user-select: none;
};
`,
];
}
/**
* @inheritDoc
*/
public static get shadowRootOptions(): ShadowRootInit {
return {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
}
/**
* @inheritDoc
*/
public connectedCallback() {
super.connectedCallback();
setLocale(document.documentElement.lang?.split("-")[0] || navigator.language?.split("-")[0]);
this.addEventListener("fullscreenchange", () => this.requestUpdate());
const styleElement = this.ownerDocument.createElement("style");
styleElement.textContent = `:fullscreen .sl-scroll-lock {scrollbar-gutter: stable!important; overflow: hidden!important}`;
this.ownerDocument.head.appendChild(styleElement);
}
/**
* @inheritDoc
*/
public render(): TemplateResult {
return html`
<webwriter-blocks-application id="application" style=${styleMap({ height: this.isFullscreen ? "100%" : "500px" })}>
<webwriter-blocks-toolbar slot="editor">
<div>
<webwriter-blocks-toolbar-button icon=${this.isFullscreen ? ArrowsMinimizeIcon : ArrowsMaximizeIcon}
label=${this.isFullscreen ? msg("FULLSCREEN_EXIT") : msg("FULLSCREEN")}
@click=${this.handleFullscreenToggle}>
</webwriter-blocks-toolbar-button>
</div>
<div>
<webwriter-blocks-help></webwriter-blocks-help>
</div>
</webwriter-blocks-toolbar>
<webwriter-blocks-editor slot="editor"
id="editor"
.state=${this.editorState}
.selectedBlocks=${this.usableBlocks}
.readonly=${this.readonly === 1 && !this.isContentEditable}
@change=${this.handleEditorChange}>
</webwriter-blocks-editor>
<webwriter-blocks-stage slot="stage"
id="stage"
stageType=${this.stageType}
readableCode=${this.readableCode}
executableCode=${this.executableCode}
@highlight=${this.handleCodeHighlighting}>
</webwriter-blocks-stage>
</webwriter-blocks-application>
${!this.isFullscreen && this.isContentEditable ? html`
<webwriter-blocks-options part="options"
readonly=${this.readonly}
stageType=${this.stageType}
.availableBlocks=${this.availableBlocks}
.selectedBlocks=${this.usableBlocks}
@change=${this.handleOptionsChange}></webwriter-blocks-options>
` : null}
</webwriter-blocks-options>
`;
}
protected firstUpdated(_changedProperties: Map<string | number | symbol, unknown>): void {
super.firstUpdated(_changedProperties);
this.setBlocks();
}
/**
* Whether the editor is in fullscreen mode.
* @private
*/
private get isFullscreen(): boolean {
return this.ownerDocument.fullscreenElement === this;
}
/**
* Handles the fullscreen toggle event.
* @private
*/
private async handleFullscreenToggle() {
if (this.isFullscreen) {
await this.ownerDocument.exitFullscreen();
this.requestUpdate()
} else {
try {
await this.requestFullscreen();
this.requestUpdate()
} catch (error) {
Logger.error("Failed to enter fullscreen mode.");
Logger.log(this, error);
}
}
}
/**
* Handles the editor change event.
* @param event The editor change event.
* @private
*/
private handleEditorChange(event: EditorChangeEvent): void {
this.editorState = event.detail.state;
this.readableCode = event.detail.readableCode;
this.executableCode = event.detail.executableCode;
}
/**
* Handles the code highlighting event.
* @param event The code highlighting event.
* @private
*/
private handleCodeHighlighting(event: CodeHighlightingEvent): void {
this.editor.editorApplication.highlight(event.detail);
}
/**
* Handles the options change event.
* @param event The options change event.
* @private
*/
private async handleOptionsChange(event: OptionsChangeEvent): Promise<void> {
const options = event.detail;
if (options.selectedBlocks) {
this.usableBlocks = options.selectedBlocks;
}
if (options.stageType) {
this.stageType = options.stageType;
await this.updateComplete;
this.setBlocks();
}
if (options.readonly !== undefined) {
this.readonly = options.readonly;
}
}
/**
* Sets the available and usable blocks after changing the stage type.
* @private
*/
private setBlocks(): void {
const { usableBlocks } = this.stage.stageApplication;
this.availableBlocks = usableBlocks;
const isDefaultValue = this.usableBlocks.length === 1
&& this.usableBlocks[0] === "events:when_start_clicked";
if (isDefaultValue) {
this.usableBlocks = [...usableBlocks];
} else {
const availableSet = new Set(usableBlocks);
this.usableBlocks = this.usableBlocks.filter((block) => availableSet.has(block));
}
}
}