From db5ca2c0159d7a9cd93e511c150ac13a08c80f7f Mon Sep 17 00:00:00 2001 From: GermanBluefox Date: Sun, 24 Nov 2024 23:40:49 +0000 Subject: [PATCH] Blockly works --- src-editor/package.json | 5 +- src-editor/src/Components/BlocklyEditor.tsx | 390 ++++++++- src-editor/src/Components/BlocklyEditorTS.tsx | 799 ++++++++++++++++++ src-editor/src/Editor.tsx | 1 - src-editor/src/i18n/de.json | 1 + src-editor/src/i18n/en.json | 1 + src-editor/src/i18n/es.json | 1 + src-editor/src/i18n/fr.json | 1 + src-editor/src/i18n/it.json | 1 + src-editor/src/i18n/nl.json | 1 + src-editor/src/i18n/pl.json | 1 + src-editor/src/i18n/pt.json | 1 + src-editor/src/i18n/ru.json | 1 + src-editor/src/i18n/uk.json | 1 + src-editor/src/i18n/zh-cn.json | 1 + src-editor/tsconfig.json | 3 +- 16 files changed, 1166 insertions(+), 43 deletions(-) create mode 100644 src-editor/src/Components/BlocklyEditorTS.tsx diff --git a/src-editor/package.json b/src-editor/package.json index 33b0c1ef..1c92a6c9 100644 --- a/src-editor/package.json +++ b/src-editor/package.json @@ -4,6 +4,8 @@ "private": true, "dependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@blockly/field-multilineinput": "^5.0.10", + "@blockly/field-colour": "^5.0.9", "@craco/craco": "^7.1.0", "@devbookhq/splitter": "^1.4.2", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", @@ -46,7 +48,8 @@ "start": "craco start", "lint": "eslint -c eslint.config.mjs", "build": "craco build", - "tsc": "tsc -p tsconfig.json" + "tsc": "tsc -p tsconfig.json", + "npm": "npm i -f" }, "eslintConfig": { "extends": "react-app" diff --git a/src-editor/src/Components/BlocklyEditor.tsx b/src-editor/src/Components/BlocklyEditor.tsx index 6d246c2b..5159d22e 100644 --- a/src-editor/src/Components/BlocklyEditor.tsx +++ b/src-editor/src/Components/BlocklyEditor.tsx @@ -4,11 +4,22 @@ import { I18n, Message as DialogMessage, type ThemeType } from '@iobroker/adapte import DialogError from '../Dialogs/Error'; import DialogExport from '../Dialogs/Export'; import DialogImport from '../Dialogs/Import'; -import * as BlocklyTS from 'blockly/core'; + +// Used only types of blockly, no code import type { WorkspaceSvg } from 'blockly/core/workspace_svg'; import type { BlockSvg } from 'blockly/core/block_svg'; -import { javascriptGenerator } from 'blockly/javascript'; import type { FlyoutDefinition } from 'blockly/core/utils/toolbox'; +import type { Block, BlocklyOptions, ISelectable, Theme } from 'blockly'; +import type { ConnectionType } from 'blockly/core'; +import type { ITheme } from 'blockly/core/theme'; +import type { JavascriptGenerator } from 'blockly/javascript'; + +// Multiline is now plugin. Together with FieldColor +import { FieldMultilineInput, installAllBlocks as installMultiBlocks } from '@blockly/field-multilineinput'; +import { FieldColour, installAllBlocks as installColourBlocks } from '@blockly/field-colour'; +import { common as BlocklyCommon } from 'blockly/core'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material'; +import { Cancel as IconCancel, Check as IconOk } from '@mui/icons-material'; let languageBlocklyLoaded = false; let languageOwnLoaded = false; @@ -21,6 +32,53 @@ interface CustomBlock { blocks: Record; } +interface BlocklyType { + CustomBlocks: string[]; + Words: Record>; + Action: CustomBlock; + Blocks: Record; + JavaScript: JavascriptGenerator; + Procedures: { + flyoutCategoryNew: (workspace: WorkspaceSvg) => FlyoutDefinition; + }; + Xml: { + workspaceToDom: (workspace: WorkspaceSvg) => Element; + domToText: (dom: Node) => string; + blockToDom: (block: Block, opt_noId?: boolean) => Element | DocumentFragment; + domToPrettyText: (dom: Node) => string; + domToWorkspace: (xml: Element, workspace: WorkspaceSvg) => string[]; + }; + svgResize: (workspace: WorkspaceSvg) => void; + INPUT_VALUE: ConnectionType.INPUT_VALUE; + OUTPUT_VALUE: ConnectionType.OUTPUT_VALUE; + NEXT_STATEMENT: ConnectionType.NEXT_STATEMENT; + PREVIOUS_STATEMENT: ConnectionType.PREVIOUS_STATEMENT; + getSelected(): ISelectable | null; + utils: { + xml: { + textToDom: (text: string) => Element; + }; + }; + Theme: { + defineTheme: (name: string, themeObj: ITheme) => Theme; + }; + inject: (container: Element | string, opt_options?: BlocklyOptions) => WorkspaceSvg; + Themes: { + Classic: Theme; + }; + Events: { + VIEWPORT_CHANGE: 'viewport_change'; + CREATE: 'create'; + UI: 'ui'; + }; + FieldMultilineInput: typeof FieldMultilineInput; + FieldColour: typeof FieldColour; + dialog: { + prompt: (promptText: string, defaultText: string, callback: (p1: string | null) => void) => void; + setPrompt: (promptFunction: (p1: string, p2: string, p3: (p1: string | null) => void) => void) => void; + }; +} + declare global { interface Window { ActiveXObject: any; @@ -30,18 +88,7 @@ declare global { blocklyWorkspace: WorkspaceSvg; scripts?: string[]; }; - Blockly: { - CustomBlocks: string[]; - Words: Record>; - Action: CustomBlock; - Blocks: Record; - JavaScript: { - forBlock: Record string>; - }; - Procedures: { - flyoutCategoryNew: (workspace: WorkspaceSvg) => FlyoutDefinition; - }; - }; + Blockly: BlocklyType; } } @@ -85,6 +132,12 @@ interface BlocklyEditorState { exportText: string; importText: boolean; searchText: string; + showInputPrompt: null | { + promptText: string; + defaultText: string; + callback: (p1: string | null) => void; + value: string; + }; } class BlocklyEditor extends React.Component { @@ -97,10 +150,11 @@ class BlocklyEditor extends React.Component void; + private readonly onResizeBind: () => void; private didUpdate: ReturnType | null = null; private lastCommand = ''; private lastSearch: string; + public static Blockly: BlocklyType = window.Blockly; constructor(props: BlocklyEditorProps) { super(props); @@ -115,6 +169,7 @@ class BlocklyEditor extends React.Component void): void => { + this.setState({ showInputPrompt: { promptText, defaultText, callback, value: defaultText } }); + }; + + static initBlockly(): void { + if (!BlocklyEditor.Blockly.FieldMultilineInput) { + installMultiBlocks({ javascript: BlocklyEditor.Blockly.JavaScript }); + BlocklyEditor.Blockly.FieldMultilineInput = FieldMultilineInput; + Object.assign( + BlocklyEditor.Blockly.Blocks, + BlocklyCommon.createBlockDefinitionsFromJsonArray([ + { + type: 'text_multiline', + message0: '%1 %2', + args0: [ + { + type: 'field_image', + src: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAARCAYAAADpP' + + 'U2iAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAdhgAAHYYBXaITgQAAABh0RVh0' + + 'U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAP1JREFUOE+Vks0KQUEYhjm' + + 'RIja4ABtZ2dm5A3t3Ia6AUm7CylYuQRaUhZSlLZJiQbFAyRnPN33y01HOW08z88' + + '73zpwzM4F3GWOCruvGIE4/rLaV+Nq1hVGMBqzhqlxgCys4wJA65xnogMHsQ5luj' + + 'nYHTejBBCK2mE4abjCgMGhNxHgDFWjDSG07kdfVa2pZMf4ZyMAdWmpZMfYOsLiD' + + 'MYMjlMB+K613QISRhTnITnsYg5yUd0DETmEoMlkFOeIT/A58iyK5E18BuTBfgYX' + + 'fwNJv4P9/oEBerLylOnRhygmGdPpTTBZAPkde61lbQe4moWUvYUZYLfUNftIY4z' + + 'wA5X2Z9AYnQrEAAAAASUVORK5CYII=', + width: 12, + height: 17, + alt: '\u00B6', + }, + { + type: 'field_multilinetext', + name: 'TEXT', + text: '', + }, + ], + output: 'String', + style: 'text_blocks', + helpUrl: '%{BKY_TEXT_TEXT_HELPURL}', + tooltip: '%{BKY_TEXT_TEXT_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], + }, + ]), + ); + } + if (!BlocklyEditor.Blockly.FieldColour) { + installColourBlocks({ javascript: BlocklyEditor.Blockly.JavaScript }); + BlocklyEditor.Blockly.FieldColour = FieldColour; + Object.assign( + BlocklyEditor.Blockly.Blocks, + BlocklyCommon.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_picker', + message0: '%1', + args0: [ + { + type: 'field_colour', + name: 'COLOUR', + colour: '#ff0000', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_PICKER_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_PICKER_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], + }, + ]), + ); + Object.assign( + BlocklyEditor.Blockly.Blocks, + BlocklyCommon.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_random', + message0: '%{BKY_COLOUR_RANDOM_TITLE}', + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RANDOM_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RANDOM_TOOLTIP}', + }, + ]), + ); + Object.assign( + BlocklyEditor.Blockly.Blocks, + BlocklyCommon.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_rgb', + message0: + '%{BKY_COLOUR_RGB_TITLE} %{BKY_COLOUR_RGB_RED} %1 %{BKY_COLOUR_RGB_GREEN} %2 %{BKY_COLOUR_RGB_BLUE} %3', + args0: [ + { + type: 'input_value', + name: 'RED', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'GREEN', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'BLUE', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RGB_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RGB_TOOLTIP}', + }, + ]), + ); + Object.assign( + BlocklyEditor.Blockly.Blocks, + BlocklyCommon.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_blend', + message0: + '%{BKY_COLOUR_BLEND_TITLE} %{BKY_COLOUR_BLEND_COLOUR1} ' + + '%1 %{BKY_COLOUR_BLEND_COLOUR2} %2 %{BKY_COLOUR_BLEND_RATIO} %3', + args0: [ + { + type: 'input_value', + name: 'COLOUR1', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'COLOUR2', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'RATIO', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_BLEND_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_BLEND_TOOLTIP}', + }, + ]), + ); + } + } + static loadJS(url: string, callback: () => void, location?: HTMLElement): void { const scriptTag = document.createElement('script'); try { @@ -147,8 +361,9 @@ class BlocklyEditor extends React.Component void): void { if (!scripts?.length) { if (callback) { - return callback(); + callback(); } + return; } const adapter = scripts.pop(); if (adapter && !scriptsLoaded.includes(adapter)) { @@ -201,7 +416,7 @@ class BlocklyEditor extends React.Component.*<\/variables>/g, ''); window.scripts.loading = true; - const xmlBlocks = BlocklyTS.utils.xml.textToDom(xml); + const xmlBlocks = BlocklyEditor.Blockly.utils.xml.textToDom(xml); if (xmlBlocks.nodeName === 'xml') { for (let b = 0; b < xmlBlocks.children.length; b++) { // @ts-expect-error fix later @@ -523,8 +739,8 @@ class BlocklyEditor extends React.Component'; window.scripts.loading = true; - const dom = BlocklyTS.utils.xml.textToDom(xml); - BlocklyTS.Xml.domToWorkspace(dom, this.blocklyWorkspace); + const dom = BlocklyEditor.Blockly.utils.xml.textToDom(xml); + BlocklyEditor.Blockly.Xml.domToWorkspace(dom, this.blocklyWorkspace); window.scripts.loading = false; } catch (e) { console.error(e); @@ -554,11 +770,11 @@ class BlocklyEditor extends React.Component { + const cb = this.state.showInputPrompt?.callback; + if (cb) { + cb(null); + } + this.setState({ showInputPrompt: null }); + }} + maxWidth="sm" + fullWidth + open={!0} + > + {this.state.showInputPrompt.promptText} + + { + if (e.key === 'Enter') { + const cb = this.state.showInputPrompt?.callback; + const value = this.state.showInputPrompt?.value; + if (cb) { + cb(value === undefined ? null : value); + } + this.setState({ showInputPrompt: null }); + } + }} + onChange={e => { + const showInputPrompt: { + promptText: string; + defaultText: string; + callback: (p1: string | null) => void; + value: string; + } = { ...this.state.showInputPrompt } as { + promptText: string; + defaultText: string; + callback: (p1: string | null) => void; + value: string; + }; + if (this.state.showInputPrompt?.callback) { + showInputPrompt.callback = this.state.showInputPrompt?.callback; + } + showInputPrompt.value = e.target.value; + this.setState({ showInputPrompt }); + }} + /> + + + + + + + ); + } + render(): (React.JSX.Element | null)[] | null { if (this.state.languageBlocklyLoaded && this.state.languageOwnLoaded) { this.didUpdate = setTimeout(() => { @@ -782,6 +1091,7 @@ class BlocklyEditor extends React.Component, + this.renderDialogPrompt(), this.renderMessageDialog(), this.renderErrorDialog(), this.renderExportDialog(), diff --git a/src-editor/src/Components/BlocklyEditorTS.tsx b/src-editor/src/Components/BlocklyEditorTS.tsx new file mode 100644 index 00000000..e20c23f4 --- /dev/null +++ b/src-editor/src/Components/BlocklyEditorTS.tsx @@ -0,0 +1,799 @@ +// This file uses Typescript Blockly from sources. The problem is, all blocks must be rewritten into TS, event dynamical one +// So this is just a POC + +import React from 'react'; + +import { I18n, Message as DialogMessage, type ThemeType } from '@iobroker/adapter-react-v5'; +import DialogError from '../Dialogs/Error'; +import DialogExport from '../Dialogs/Export'; +import DialogImport from '../Dialogs/Import'; +import * as BlocklyTS from 'blockly/core'; +import type { WorkspaceSvg } from 'blockly/core/workspace_svg'; +import type { BlockSvg } from 'blockly/core/block_svg'; +import { javascriptGenerator } from 'blockly/javascript'; +import type { FlyoutDefinition } from 'blockly/core/utils/toolbox'; + +let languageBlocklyLoaded = false; +let languageOwnLoaded = false; +let toolboxText: string | null = null; +let toolboxXml: Element | null = null; +const scriptsLoaded: string[] = []; + +interface CustomBlock { + HUE: number; + blocks: Record; +} + +declare global { + interface Window { + ActiveXObject: any; + MSG: string[]; + scripts: { + loading?: boolean; + blocklyWorkspace: WorkspaceSvg; + scripts?: string[]; + }; + Blockly: { + CustomBlocks: string[]; + Words: Record>; + Action: CustomBlock; + Blocks: Record; + JavaScript: { + forBlock: Record string>; + }; + Procedures: { + flyoutCategoryNew: (workspace: WorkspaceSvg) => FlyoutDefinition; + }; + }; + } +} + +// BF (2020-10-31) I have no Idea, why it does not work as static in BlocklyEditor, but outside BlocklyEditor it works +function searchXml(root: Element, text: string, _id?: string, _result?: string[]): string[] { + _result = _result || []; + if (root.tagName === 'BLOCK' || root.tagName === 'block') { + _id = root.id; + } + if (root.tagName === 'FIELD' || root.tagName === 'field') { + for (let a = 0; a < root.attributes.length; a++) { + const val = (root.attributes[a].value || '').toLowerCase(); + if (root.attributes[a].nodeName === 'name' && (val === 'oid' || val === 'text' || val === 'var')) { + if (_id && root.innerHTML?.toLowerCase().includes(text)) { + _result.push(_id); + } + } + } + } + root.childNodes.forEach(node => searchXml(node as HTMLElement, text, _id, _result)); + + return _result; +} + +interface BlocklyEditorProps { + command: '' | 'check' | 'export' | 'import'; + onChange: (code: string) => void; + searchText: string; + code: string; + scriptId: string; + themeType: ThemeType; +} + +interface BlocklyEditorState { + languageOwnLoaded: boolean; + languageBlocklyLoaded: boolean; + changed: boolean; + message: string | { text: string; title: string }; + error: string | { text: string; title: string }; + themeType: ThemeType; + exportText: string; + importText: boolean; + searchText: string; +} + +class BlocklyEditor extends React.Component { + private blockly: HTMLElement | null = null; + private blocklyWorkspace: WorkspaceSvg | null = null; + private originalCode: string; + private someSelected: string[] | null = null; + private changeTimer: ReturnType | null = null; + private someSelectedTime: number = 0; + private ignoreChanges: boolean = false; + private darkTheme: any; + private blinkBlock: any; + private onResizeBind: () => void; + private didUpdate: ReturnType | null = null; + private lastCommand = ''; + private lastSearch: string; + + constructor(props: BlocklyEditorProps) { + super(props); + + this.state = { + languageOwnLoaded, + languageBlocklyLoaded, + changed: false, + message: '', + error: '', + themeType: this.props.themeType, + exportText: '', + importText: false, + searchText: this.props.searchText || '', + }; + this.originalCode = props.code || ''; + + this.someSelected = null; + + this.onResizeBind = this.onResize.bind(this); + + this.lastSearch = this.props.searchText || ''; + this.blinkBlock = null; + this.loadLanguages(); + } + + static loadJS(url: string, callback: () => void, location?: HTMLElement): void { + const scriptTag = document.createElement('script'); + try { + scriptTag.src = url; + + scriptTag.onload = callback; + scriptTag.onerror = callback; + + (location || window.document.body).appendChild(scriptTag); + } catch (e) { + console.error(`Cannot load ${url}: ${e}`); + if (callback) { + callback(); + } + } + } + + static loadScripts(scripts: string[], callback: () => void): void { + if (!scripts?.length) { + if (callback) { + return callback(); + } + } + const adapter = scripts.pop(); + if (adapter && !scriptsLoaded.includes(adapter)) { + scriptsLoaded.push(adapter); + BlocklyEditor.loadJS(`../../adapter/${adapter}/blockly.js`, (/*data, textStatus, jqxhr*/) => + setTimeout(() => BlocklyEditor.loadScripts(scripts, callback), 0),); + } else { + setTimeout(() => BlocklyEditor.loadScripts(scripts, callback), 0); + } + } + + static loadCustomBlockly(adapters: Record, callback: () => void): void { + // get all adapters, that can have blockly + const toLoad: string[] = []; + for (const id in adapters) { + if ( + !Object.prototype.hasOwnProperty.call(adapters, id) || + !adapters[id] || + !id.match(/^system\.adapter\./) || + adapters[id].type !== 'adapter' + ) { + continue; + } + + if (adapters[id].common?.blockly) { + console.log(`Detected custom blockly: ${adapters[id].common.name}`); + toLoad.push(adapters[id].common.name); + } + } + + BlocklyEditor.loadScripts(toLoad, callback); + } + + static loadXMLDoc(text: string): Document | null { + let parseXml; + if (window.DOMParser) { + parseXml = (xmlStr: string): Document => new window.DOMParser().parseFromString(xmlStr, 'text/xml'); + } else if (typeof window.ActiveXObject !== 'undefined' && new window.ActiveXObject('Microsoft.XMLDOM')) { + parseXml = (xmlStr: string): Document => { + const xmlDoc = new window.ActiveXObject('Microsoft.XMLDOM'); + xmlDoc.async = 'false'; + xmlDoc.loadXML(xmlStr); + return xmlDoc; + }; + } else { + parseXml = () => null; + } + return parseXml(text); + } + + searchBlocks(text: string): string[] { + if (this.blocklyWorkspace) { + const dom: Element = BlocklyTS.Xml.workspaceToDom(this.blocklyWorkspace); + const ids = searchXml(dom, text.toLowerCase()); + + console.log(`Search "${text}" found blocks: ${ids.length ? JSON.stringify(ids) : 'none'}`); + + return ids; + } + + return []; + } + + searchId(): void { + const ids = this.lastSearch ? this.searchBlocks(this.lastSearch) : null; + if (ids?.length) { + this.someSelected = ids; + this.someSelected.forEach(id => this.blocklyWorkspace?.highlightBlock(id, true)); + this.someSelectedTime = Date.now(); + } else if (this.someSelected) { + // remove selection + this.someSelected.forEach(id => this.blocklyWorkspace?.highlightBlock(id, false)); + this.someSelected = null; + } + } + + UNSAFE_componentWillReceiveProps(nextProps: BlocklyEditorProps): void { + if (nextProps.command && this.lastCommand !== nextProps.command) { + this.lastCommand = nextProps.command; + setTimeout(() => (this.lastCommand = ''), 300); + if (this.lastCommand === 'check') { + this.blocklyCheckBlocks((err, badBlock) => { + if (!err) { + this.setState({ message: I18n.t('Ok') }); + } else { + badBlock && BlocklyEditor.blocklyBlinkBlock(badBlock); + this.setState({ error: { text: I18n.t(err), title: I18n.t('Error was found') } }); + this.blinkBlock = badBlock; + } + }); + } else if (this.lastCommand === 'export') { + this.exportBlocks(); + } else if (this.lastCommand === 'import') { + this.importBlocks(); + } + } + + if (nextProps.searchText !== this.lastSearch) { + this.lastSearch = nextProps.searchText; + this.searchId(); + } + + if (this.state.themeType !== nextProps.themeType) { + this.setState({ themeType: nextProps.themeType }, () => this.updateBackground()); + } + + if (this.originalCode !== nextProps.code) { + this.originalCode = nextProps.code || ''; + this.loadCode(); + this.searchId(); + } + } + + loadLanguages(): void { + // load blockly language + if (!languageBlocklyLoaded) { + const fileLang = window.document.createElement('script'); + fileLang.setAttribute('type', 'text/javascript'); + fileLang.setAttribute('src', `google-blockly/msg/js/${I18n.getLanguage()}.js`); + + // most browsers + fileLang.onload = () => { + languageBlocklyLoaded = true; + this.setState({ languageBlocklyLoaded }); + }; + window.document.getElementsByTagName('head')[0].appendChild(fileLang); + } + if (!languageOwnLoaded) { + const fileCustom = window.document.createElement('script'); + fileCustom.setAttribute('type', 'text/javascript'); + fileCustom.setAttribute('src', `google-blockly/own/msg/${I18n.getLanguage()}.js`); + // most browsers + fileCustom.onload = () => { + languageOwnLoaded = true; + this.setState({ languageOwnLoaded }); + }; + window.document.getElementsByTagName('head')[0].appendChild(fileCustom); + } + } + + onResize(): void { + if (this.blocklyWorkspace) { + BlocklyTS.svgResize(this.blocklyWorkspace); + } + } + + static jsCode2Blockly(text: string | undefined): string | null { + text = text || ''; + const lines = text.split(/[\r\n]+|\r|\n/g); + let xml = ''; + for (let l = lines.length - 1; l >= 0; l--) { + if (lines[l].substring(0, 2) === '//') { + xml = lines[l].substring(2); + break; + } + } + if (xml.substring(0, 4) === ' block.select(), i); + setTimeout(() => block.unselect(), i + 150); + } + } + + blocklyRemoveOrphanedShadows(): void { + if (this.blocklyWorkspace) { + const blocks = this.blocklyWorkspace.getAllBlocks(); + let block; + for (let i = 0; (block = blocks[i]); i++) { + if (block.isShadow()) { + const connections = block.getConnections_(true); + let conn; + for (let j = 0; (conn = connections[j]); j++) { + if (!conn.targetConnection) { + // remove it + block.dispose(); + break; + } + } + } + } + } + } + + blocklyCheckBlocks(cb: (warningText?: string, badBlock?: BlockSvg) => void): boolean { + let warningText; + if (!this.blocklyWorkspace || this.blocklyWorkspace.getAllBlocks().length === 0) { + cb && cb('no blocks found'); + return false; + } + let badBlock = this.blocklyGetUnconnectedBlock(); + if (badBlock) { + warningText = 'not properly connected'; + } else { + badBlock = this.blocklyGetBlockWithWarning(); + if (badBlock) { + warningText = 'warning on this block'; + } + } + + if (badBlock) { + if (cb) { + cb(warningText, badBlock); + } else { + BlocklyEditor.blocklyBlinkBlock(badBlock); + } + return false; + } + + cb(); + + return true; + } + + // get unconnected block + blocklyGetUnconnectedBlock(): BlockSvg | null { + const blocks: BlockSvg[] | undefined = this.blocklyWorkspace?.getAllBlocks(); + let block; + if (blocks) { + for (let i = 0; (block = blocks[i]); i++) { + const connections = block.getConnections_(true); + let conn; + for (let j = 0; (conn = connections[j]); j++) { + if ( + !conn.sourceBlock_ || + ((conn.type === BlocklyTS.INPUT_VALUE || conn.type === BlocklyTS.OUTPUT_VALUE) && + !conn.targetConnection && + // @ts-expect-error Check it later + !conn._optional) + ) { + return block; + } + } + } + } + return null; + } + + // get block with warning + blocklyGetBlockWithWarning(): BlockSvg | null { + const blocks = this.blocklyWorkspace?.getAllBlocks(); + let block; + if (blocks) { + for (let i = 0; (block = blocks[i]); i++) { + // @ts-expect-error fix later + if (block.warning) { + return block; + } + } + } + return null; + } + + blocklyCode2JSCode(oneWay?: boolean): string { + if (!this.blocklyWorkspace) { + return ''; + } + let code = javascriptGenerator.workspaceToCode(this.blocklyWorkspace); + if (!oneWay) { + code += '\n'; + const dom = BlocklyTS.Xml.workspaceToDom(this.blocklyWorkspace); + const text = BlocklyTS.Xml.domToText(dom); + code += `//${btoa(encodeURIComponent(text))}`; + } + + return code; + } + + exportBlocks(): void { + if (!this.blocklyWorkspace) { + return; + } + let exportText: string; + const selectedBlocks: BlocklyTS.BlockSvg | null = BlocklyTS.getSelected() as BlocklyTS.BlockSvg | null; + if (selectedBlocks) { + const xmlBlock: Element = BlocklyTS.Xml.blockToDom(selectedBlocks) as Element; + // @1ts-expect-error fix later. TODO!!!! + // if (BlocklyTS.dragMode_ !== BlocklyTS.DRAG_FREE) { + // BlocklyTS.Xml.deleteNext(xmlBlock); + // } + // Encode start position in XML. + const xy = selectedBlocks.getRelativeToSurfaceXY(); + xmlBlock.setAttribute('x', (selectedBlocks.RTL ? -xy.x : xy.x).toString()); + xmlBlock.setAttribute('y', xy.y.toString()); + + exportText = BlocklyTS.Xml.domToPrettyText(xmlBlock); + } else { + const dom = BlocklyTS.Xml.workspaceToDom(this.blocklyWorkspace); + exportText = BlocklyTS.Xml.domToPrettyText(dom); + } + this.setState({ exportText }); + } + + importBlocks(): void { + this.setState({ importText: true }); + } + + onImportBlocks(xml: string | undefined): void { + if (!this.blocklyWorkspace) { + return; + } + xml = (xml || '').trim(); + if (xml) { + try { + if (!xml.startsWith('${xml}`; + } + /* + // TODO: WHY?! + const variables = xml.replace(/[\n\r]/g, '').match(/(.*)<\/variables>/); + if (variables) { + const parser = new DOMParser(); + const vars = parser.parseFromString(`${variables[1]}`, 'text/xml').firstChild; + for (const child of vars.children) { + if (child.tagName === 'variable') { + // e.g. timeout or interval + const varType = child.getAttribute('type'); + if (varType) { + this.blocklyWorkspace.createVariable(child.getAttribute('id'), varType); + } + } + } + } + */ + xml = xml.replace(/[\n\r]/g, '').replace(/.*<\/variables>/g, ''); + window.scripts.loading = true; + + const xmlBlocks = BlocklyTS.utils.xml.textToDom(xml); + if (xmlBlocks.nodeName === 'xml') { + for (let b = 0; b < xmlBlocks.children.length; b++) { + // @ts-expect-error fix later + this.blocklyWorkspace.paste(xmlBlocks.children[b]); + } + } else { + // @ts-expect-error fix later + this.blocklyWorkspace.paste(xmlBlocks); + } + + window.scripts.loading = false; + + this.onBlocklyChanged(); + } catch (e) { + this.setState({ error: { text: (e as Error).toString(), title: I18n.t('Import error') } }); + } + } + } + + loadCode(): void { + if (!this.blocklyWorkspace) { + return; + } + + this.ignoreChanges = true; + this.blocklyWorkspace.clear(); + + try { + const xml = + BlocklyEditor.jsCode2Blockly(this.originalCode) || + ''; + window.scripts.loading = true; + const dom = BlocklyTS.utils.xml.textToDom(xml); + BlocklyTS.Xml.domToWorkspace(dom, this.blocklyWorkspace); + window.scripts.loading = false; + } catch (e) { + console.error(e); + setTimeout(() => this.setState({ error: I18n.t('Cannot extract Blockly code!') })); + } + setTimeout(() => (this.ignoreChanges = false), 100); + } + + onBlocklyChanged(): void { + this.blocklyRemoveOrphanedShadows(); + this.setState({ changed: true }); + this.onChange(); + } + + async componentDidUpdate(): Promise { + if (!this.blockly) { + return; + } + if (this.didUpdate) { + clearTimeout(this.didUpdate); + this.didUpdate = null; + } + + if (this.blocklyWorkspace) { + return; + } + + window.addEventListener('resize', this.onResizeBind, false); + toolboxText = toolboxText || (await this.getToolbox()); + toolboxXml = toolboxXml || BlocklyTS.utils.xml.textToDom(toolboxText); + + this.darkTheme = BlocklyTS.Theme.defineTheme('dark', { + name: 'dark', + base: BlocklyTS.Themes.Classic, + componentStyles: { + workspaceBackgroundColour: '#1e1e1e', + toolboxBackgroundColour: 'blackBackground', + toolboxForegroundColour: '#fff', + flyoutBackgroundColour: '#252526', + flyoutForegroundColour: '#ccc', + flyoutOpacity: 1, + scrollbarColour: '#797979', + insertionMarkerColour: '#fff', + insertionMarkerOpacity: 0.3, + scrollbarOpacity: 0.4, + cursorColour: '#d0d0d0', + }, + }); + + // https://developers.google.com/blockly/reference/js/blockly.blocklyoptions_interface.md + this.blocklyWorkspace = BlocklyTS.inject(this.blockly, { + renderer: 'thrasos', + theme: 'classic', + media: 'google-blockly/media/', + toolbox: toolboxXml, + zoom: { + controls: true, + wheel: false, + startScale: 1.0, + maxScale: 3, + minScale: 0.3, + scaleSpeed: 1.2, + pinch: true, + }, + move: { + scrollbars: { + horizontal: true, + vertical: true, + }, + drag: true, + wheel: true, + }, + trashcan: true, + grid: { + spacing: 25, + length: 1, + snap: true, + }, + sounds: false, // disable sounds + }); + // for blockly itself + window.scripts = { + blocklyWorkspace: this.blocklyWorkspace, + }; + + // Workaround: Replace procedure category flyout + this.blocklyWorkspace.registerToolboxCategoryCallback('PROCEDURE', window.Blockly.Procedures.flyoutCategoryNew); + + // Listen to events on master workspace. + this.blocklyWorkspace.addChangeListener(masterEvent => { + if (this.someSelected && Date.now() - this.someSelectedTime > 500) { + const allBlocks = this.blocklyWorkspace?.getAllBlocks(); + this.someSelected = null; + allBlocks?.forEach(b => b.removeSelect()); + } + + if ( + [BlocklyTS.Events.UI, BlocklyTS.Events.CREATE, BlocklyTS.Events.VIEWPORT_CHANGE].includes( + masterEvent.type, + ) + ) { + return; // Don't mirror UI events. + } + if (this.ignoreChanges) { + return; + } + + this.changeTimer && clearTimeout(this.changeTimer); + this.changeTimer = setTimeout(() => { + this.changeTimer = null; + this.onBlocklyChanged(); + }, 200); + }); + this.loadCode(); + this.onResize(); + // Move toolbar to the valid position + const toolbar = document.getElementsByClassName('blocklyToolboxDiv')[0]; + this.blockly.appendChild(toolbar); + + this.updateBackground(); + setTimeout(() => this.searchId(), 200); // select found blocks + } + + updateBackground(): void { + if (this.state.themeType === 'dark') { + this.blocklyWorkspace?.setTheme(this.darkTheme); + } else if (this.blocklyWorkspace) { + this.blocklyWorkspace.getThemeManager(); + this.blocklyWorkspace.setTheme(BlocklyTS.Themes.Classic); + } + } + + componentWillUnmount(): void { + if (!this.blocklyWorkspace) { + return; + } + this.blocklyWorkspace.dispose(); + this.blocklyWorkspace = null; + this.changeTimer && clearTimeout(this.changeTimer); + this.changeTimer = null; + window.removeEventListener('resize', this.onResizeBind); + } + + onChange(): void { + this.originalCode = this.blocklyCode2JSCode(); + this.props.onChange && this.props.onChange(this.originalCode); + } + + async getToolbox(retry?: boolean): Promise { + // Interpolate translated messages into toolbox. + const el = window.document.getElementById('toolbox'); + let toolboxText = el?.outerHTML; + if (!toolboxText) { + if (!retry) { + return new Promise(resolve => { + setTimeout(() => resolve(this.getToolbox(true)), 500); + }); + } + + console.error('Cannot load blocks!'); + return ''; + } + toolboxText = toolboxText.replace(/{(\w+)}/g, (m, p1) => window.MSG[p1]); + + if (window.Blockly.CustomBlocks) { + let blocks = ''; + const lang = I18n.getLanguage(); + for (let cb = 0; cb < window.Blockly.CustomBlocks.length; cb++) { + const name = window.Blockly.CustomBlocks[cb]; + // add blocks + const _block: CustomBlock = (window.Blockly as unknown as Record)[name]; + blocks += ``; + for (const _b in _block.blocks) { + if (Object.prototype.hasOwnProperty.call(_block.blocks, _b)) { + blocks += _block.blocks[_b]; + } + } + blocks += ''; + } + toolboxText = toolboxText.replace('%%CUSTOM_BLOCKS%%', blocks); + } + + return toolboxText; + } + + renderMessageDialog(): React.JSX.Element | null { + return this.state.message ? ( + this.setState({ message: '' })} + /> + ) : null; + } + + renderErrorDialog(): React.JSX.Element | null { + return this.state.error ? ( + { + if (this.blinkBlock) { + BlocklyEditor.blocklyBlinkBlock(this.blinkBlock); + this.blinkBlock = null; + } + this.setState({ error: '' }); + }} + /> + ) : null; + } + + renderExportDialog(): React.JSX.Element | null { + return this.state.exportText ? ( + this.setState({ exportText: '' })} + text={this.state.exportText} + scriptId={this.props.scriptId} + /> + ) : null; + } + + renderImportDialog(): React.JSX.Element | null { + return this.state.importText ? ( + { + this.setState({ importText: false }); + this.onImportBlocks(text); + }} + /> + ) : null; + } + + render(): (React.JSX.Element | null)[] | null { + if (this.state.languageBlocklyLoaded && this.state.languageOwnLoaded) { + this.didUpdate = setTimeout(() => { + this.didUpdate = null; + void this.componentDidUpdate(); + }, 100); + + return [ +
(this.blockly = el)} + style={{ + // marginLeft: 180, + width: '100%', // 'calc(100% - 180px)', + height: '100%', + // overflow: 'hidden', + position: 'relative', + }} + />, + + this.renderMessageDialog(), + this.renderErrorDialog(), + this.renderExportDialog(), + this.renderImportDialog(), + ]; + } + + return null; + } +} + +export default BlocklyEditor; diff --git a/src-editor/src/Editor.tsx b/src-editor/src/Editor.tsx index dadbbf3d..60e88d3d 100644 --- a/src-editor/src/Editor.tsx +++ b/src-editor/src/Editor.tsx @@ -1,5 +1,4 @@ import React from 'react'; -// @ts-expect-error no types import Tour from 'reactour'; import { diff --git a/src-editor/src/i18n/de.json b/src-editor/src/i18n/de.json index b6887f9e..67dbd77e 100644 --- a/src-editor/src/i18n/de.json +++ b/src-editor/src/i18n/de.json @@ -12,6 +12,7 @@ "All files will be accepted": "Alle Dateien werden akzeptiert", "Any": "Egal", "Any month": "Jeden Monat", + "Apply": "Anwenden", "April": "April", "Are you sure?": "Wirklich sicher?", "Ask": "Fragen", diff --git a/src-editor/src/i18n/en.json b/src-editor/src/i18n/en.json index b9bb3436..9ea3e8e9 100644 --- a/src-editor/src/i18n/en.json +++ b/src-editor/src/i18n/en.json @@ -12,6 +12,7 @@ "All files will be accepted": "All files will be accepted", "Any": "Any", "Any month": "Any month", + "Apply": "Apply", "April": "April", "Are you sure?": "Are you sure?", "Ask": "Ask", diff --git a/src-editor/src/i18n/es.json b/src-editor/src/i18n/es.json index eca1e739..33cede54 100644 --- a/src-editor/src/i18n/es.json +++ b/src-editor/src/i18n/es.json @@ -12,6 +12,7 @@ "All files will be accepted": "Todos los archivos serán aceptados.", "Any": "Alguna", "Any month": "Cualquier mes", + "Apply": "Apply", "April": "abril", "Are you sure?": "¿Estás seguro?", "Ask": "Preguntar", diff --git a/src-editor/src/i18n/fr.json b/src-editor/src/i18n/fr.json index 22ad4159..ecee4711 100644 --- a/src-editor/src/i18n/fr.json +++ b/src-editor/src/i18n/fr.json @@ -12,6 +12,7 @@ "All files will be accepted": "Tous les fichiers seront acceptés", "Any": "Tout", "Any month": "N'importe quel mois", + "Apply": "Postuler", "April": "avril", "Are you sure?": "Êtes-vous sûr?", "Ask": "Demander", diff --git a/src-editor/src/i18n/it.json b/src-editor/src/i18n/it.json index 89ea6b32..e2b37b6b 100644 --- a/src-editor/src/i18n/it.json +++ b/src-editor/src/i18n/it.json @@ -12,6 +12,7 @@ "All files will be accepted": "Tutti i file saranno accettati", "Any": "Qualunque", "Any month": "Ogni mese", + "Apply": "Applica", "April": "aprile", "Are you sure?": "Sei sicuro?", "Ask": "Chiedere", diff --git a/src-editor/src/i18n/nl.json b/src-editor/src/i18n/nl.json index 5a5be565..728f693a 100644 --- a/src-editor/src/i18n/nl.json +++ b/src-editor/src/i18n/nl.json @@ -12,6 +12,7 @@ "All files will be accepted": "Alle bestanden worden geaccepteerd", "Any": "Ieder", "Any month": "Elke maand", + "Apply": "Solliciteer", "April": "april", "Are you sure?": "Weet je zeker dat?", "Ask": "Vragen", diff --git a/src-editor/src/i18n/pl.json b/src-editor/src/i18n/pl.json index 76698362..8aa6fb89 100644 --- a/src-editor/src/i18n/pl.json +++ b/src-editor/src/i18n/pl.json @@ -12,6 +12,7 @@ "All files will be accepted": "Wszystkie pliki zostaną zaakceptowane", "Any": "Każdy", "Any month": "Dowolny miesiąc", + "Apply": "Zastosuj", "April": "Kwiecień", "Are you sure?": "Jesteś pewny?", "Ask": "Zapytać", diff --git a/src-editor/src/i18n/pt.json b/src-editor/src/i18n/pt.json index 759cf2df..bbd23b56 100644 --- a/src-editor/src/i18n/pt.json +++ b/src-editor/src/i18n/pt.json @@ -12,6 +12,7 @@ "All files will be accepted": "Todos os arquivos serão aceitos", "Any": "Nenhum", "Any month": "Qualquer mês", + "Apply": "Aplique", "April": "abril", "Are you sure?": "Você tem certeza?", "Ask": "Perguntar", diff --git a/src-editor/src/i18n/ru.json b/src-editor/src/i18n/ru.json index b2f7f28f..2fd1946f 100644 --- a/src-editor/src/i18n/ru.json +++ b/src-editor/src/i18n/ru.json @@ -12,6 +12,7 @@ "All files will be accepted": "Все файлы будут приняты", "Any": "Любая", "Any month": "В любой месяц", + "Apply": "Применить", "April": "Апрель", "Are you sure?": "Вы уверены?", "Ask": "Спросить", diff --git a/src-editor/src/i18n/uk.json b/src-editor/src/i18n/uk.json index d5413836..957fe108 100644 --- a/src-editor/src/i18n/uk.json +++ b/src-editor/src/i18n/uk.json @@ -12,6 +12,7 @@ "All files will be accepted": "Усі файли будуть прийняті", "Any": "Будь-який", "Any month": "Будь-який місяць", + "Apply": "Застосувати", "April": "квітень", "Are you sure?": "Ти впевнений?", "Ask": "Запитуйте", diff --git a/src-editor/src/i18n/zh-cn.json b/src-editor/src/i18n/zh-cn.json index cf1dba73..19e77257 100644 --- a/src-editor/src/i18n/zh-cn.json +++ b/src-editor/src/i18n/zh-cn.json @@ -12,6 +12,7 @@ "All files will be accepted": "所有文件都将被接受", "Any": "任何", "Any month": "任何月份", + "Apply": "申请", "April": "四月", "Are you sure?": "你确定吗?", "Ask": "问", diff --git a/src-editor/tsconfig.json b/src-editor/tsconfig.json index 400d5747..010772ac 100644 --- a/src-editor/tsconfig.json +++ b/src-editor/tsconfig.json @@ -19,5 +19,6 @@ "@/*": ["./src/*"] } }, - "include": ["./src/**/*", "./src/types.d.ts"] + "include": ["./src/**/*", "./src/types.d.ts"], + "exclude": ["./src/Components/BlocklyEditorTS.tsx"] }