UNPKG

@blockly/dev-tools

Version:

A library of common utilities for Blockly extension development.

646 lines (592 loc) 19.7 kB
/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Entry point for the Blockly playground. * @author samelh@google.com (Sam El-Husseini) */ import {toolboxTestBlocks, toolboxTestBlocksInit} from '@blockly/block-test'; import {dartGenerator} from 'blockly/dart'; import {javascriptGenerator} from 'blockly/javascript'; import {luaGenerator} from 'blockly/lua'; import {phpGenerator} from 'blockly/php'; import {pythonGenerator} from 'blockly/python'; import {downloadWorkspaceScreenshot} from '../screenshot'; import toolboxCategories from '../toolboxCategories'; import toolboxSimple from '../toolboxSimple'; import {id} from './id'; import {addCodeEditor} from './monaco'; import {addGUIControls} from './options'; import {LocalStorageState} from './state'; import {renderCheckbox, renderCodeTab, renderPlayground} from './ui'; // Declare external types to make eslint happy. /* global dat, monaco */ /** * @typedef {function(!HTMLElement,!Blockly.BlocklyOptions): * Blockly.WorkspaceSvg} */ let CreateWorkspaceFn; /** * @typedef {{ * generate: function(): void, * state: ?, * tabElement: !HTMLElement * }} */ let PlaygroundTab; /** * @typedef {Blockly.utils.toolbox.ToolboxDefinition} BlocklyToolbox */ /** * @typedef {Object} PlaygroundConfig * @property {boolean} [auto] Whether or not to automatically import, and run * the XML and code generators. * @property {Object<string,BlocklyToolbox>} [toolboxes] The toolbox registry. */ /** * @typedef {{ * state: ?, * addAction:function(string,function(!Blockly.Workspace):void,string=): * dat.GUI, * addCheckboxAction:function(string, * function(!Blockly.Workspace,boolean):void,string=,boolean=):dat.GUI, * addGenerator: function(string,!Blockly.Generator,string=):void, * getCurrentTab: function():!PlaygroundTab, * getGUI: function():!dat.GUI, * getWorkspace: function():!Blockly.WorkspaceSvg * }} */ let PlaygroundAPI; /** * Create the Blockly playground. * @param {!HTMLElement} container Container element. * @param {CreateWorkspaceFn=} createWorkspace A workspace creation method * called every time the toolbox is re-configured. * @param {Blockly.BlocklyOptions=} defaultOptions The default workspace options * to use. * @param {PlaygroundConfig=} config Optional Playground config. * @param {string=} vsEditorPath Optional editor path. * @returns {Promise<PlaygroundAPI>} A promise to the playground API. */ export function createPlayground( container, createWorkspace = Blockly.inject, defaultOptions = { toolbox: toolboxCategories, }, config = {}, vsEditorPath, ) { const { blocklyDiv, minimizeButton, monacoDiv, guiContainer, playgroundDiv, tabButtons, tabsDiv, } = renderPlayground(container); const monacoOptions = { model: null, language: 'xml', minimap: { enabled: false, }, theme: 'vs-dark', scrollBeyondLastLine: false, automaticLayout: true, }; // Load the code editor. return addCodeEditor(monacoDiv, monacoOptions, vsEditorPath).then( (editor) => { let workspace; // Create a model for displaying errors. const errorModel = window.monaco.editor.createModel(''); const editorXmlContextKey = editor.createContextKey('isEditorXml', true); const editorJsonContextKey = editor.createContextKey( 'isEditorJson', true, ); // Load / Save playground state. // setting active tab as JSON const playgroundState = new LocalStorageState(`playgroundState_${id}`, { activeTab: 'JSON', playgroundOpen: true, autoGenerate: config && config.auto != undefined ? config.auto : true, workspaceJson: '', }); playgroundState.load(); /** * Register a generator and create a new code tab for it. * @param {string} name The generator label. * @param {string} language The monaco language to use. * @param {function(Blockly.WorkspaceSvg):string} generator * The Blockly generator. * @param {boolean=} isReadOnly Whether the editor should be set to * read-only mode. * @returns {!PlaygroundTab} An object that represents the newly created * tab. */ function registerGenerator(name, language, generator, isReadOnly) { const tabElement = renderCodeTab(name); tabElement.setAttribute('data-tab', name); tabsDiv.appendChild(tabElement); // Create a monaco editor model for each tab. const model = window.monaco.editor.createModel('', language); const state = { name, model, language, viewState: undefined, }; /** * Call the generator, displaying an error message if it fails. */ function generate() { let text; let generateModel = model; try { text = generator(workspace); } catch (e) { console.error(e); text = e.message; generateModel = errorModel; editor.updateOptions({ wordWrap: true, }); } if ( generateModel.getValue() === text && editor.getModel() === generateModel ) { return; } generateModel.pushEditOperations( [], [{range: generateModel.getFullModelRange(), text}], () => null, ); editor.setModel(generateModel); editor.setSelection(new window.monaco.Range(0, 0, 0, 0)); } const tab = { generate, state, tabElement, }; return tab; } /** * Set the active tab. * @param {!PlaygroundTab} tab The new tab. */ const setActiveTab = (tab) => { currentTab = tab; currentGenerate = tab.generate; const isXml = tab.state.name == 'XML'; const isJson = tab.state.name == 'JSON'; editor.setModel(currentTab.state.model); editor.updateOptions({ readOnly: !isXml && !isJson, wordWrap: false, }); // Update tab UI. Object.values(tabs).forEach( (t) => (t.tabElement.style.background = t.tabElement == tab.tabElement ? '#1E1E1E' : '#2D2D2D'), ); // Update editor state. editorXmlContextKey.set(isXml); editorJsonContextKey.set(isJson); playgroundState.set('activeTab', tab.state.name); playgroundState.save(); }; /** * Call the current generate method if we are in 'auto' mode. In * addition, persist the current workspace xml regardless of which tab * we are in. */ const updateEditor = () => { if (playgroundState.get('autoGenerate')) { if (initialWorkspaceJson && isFirstLoad) { isFirstLoad = false; try { Blockly.serialization.workspaces.load( JSON.parse(initialWorkspaceJson), workspace, ); } catch (e) { console.warn('Failed to auto import.', e); } } if (currentGenerate) { currentGenerate(); } let code = ''; try { code = JSON.stringify( Blockly.serialization.workspaces.save(workspace), null, 2, ); } catch (e) { console.warn('Failed to auto save.', e); } playgroundState.set('workspaceJson', code); playgroundState.save(); } }; // Register default tabs. const tabs = { JSON: registerGenerator('JSON', 'json', (ws) => { return JSON.stringify( Blockly.serialization.workspaces.save(ws), null, 2, ); }), XML: registerGenerator('XML', 'xml', (ws) => { return Blockly.Xml.domToPrettyText(Blockly.Xml.workspaceToDom(ws)); }), JavaScript: registerGenerator( 'JavaScript', 'javascript', (ws) => (javascriptGenerator || Blockly.JavaScript).workspaceToCode(ws), true, ), Python: registerGenerator( 'Python', 'python', (ws) => (pythonGenerator || Blockly.Python).workspaceToCode(ws), true, ), Dart: registerGenerator( 'Dart', 'dart', (ws) => (dartGenerator || Blockly.Dart).workspaceToCode(ws), true, ), Lua: registerGenerator( 'Lua', 'lua', (ws) => (luaGenerator || Blockly.Lua).workspaceToCode(ws), true, ), PHP: registerGenerator( 'PHP', 'php', (ws) => (phpGenerator || Blockly.PHP).workspaceToCode(ws), true, ), }; // Handle tab click. tabsDiv.addEventListener('click', (e) => { const target = /** @type {HTMLElement} */ (e.target); const tabName = target.getAttribute('data-tab'); if (!tabName) { // Not a tab. return; } const tab = tabs[tabName]; // Save current tab state (eg: scroll position). currentTab.state.viewState = editor.saveViewState(); setActiveTab(tab); updateEditor(); // Restore tab state (eg: scroll position). editor.restoreViewState(currentTab.state.viewState); editor.focus(); }); // Initialized saved JSON and bind change listener. const initialWorkspaceJson = playgroundState.get('workspaceJson') || ''; const jsonTab = tabs['JSON']; const jsonModel = jsonTab.state.model; let isFirstLoad = true; jsonModel.setValue(initialWorkspaceJson); jsonModel.onDidChangeContent(() => { playgroundState.set('workspaceJson', jsonModel.getValue()); playgroundState.save(); }); // Set the initial tab as active. const activeTab = playgroundState.get('activeTab'); let currentTab = tabs[activeTab]; let currentGenerate; if (currentTab) { setActiveTab(currentTab); } // Load the GUI controls. const gui = addGUIControls( (options) => { workspace = createWorkspace(blocklyDiv, options); // Initialize the test toolbox. toolboxTestBlocksInit( /** @type {!Blockly.WorkspaceSvg} */ (workspace), ); // Add download screenshot option. const prevConfigureContextMenu = workspace.configureContextMenu; workspace.configureContextMenu = (menuOptions, e) => { prevConfigureContextMenu && prevConfigureContextMenu.call(null, menuOptions, e); const screenshotOption = { text: 'Download Screenshot', enabled: workspace.getTopBlocks().length, callback: function () { downloadWorkspaceScreenshot(workspace); }, }; menuOptions.push(screenshotOption); }; updateEditor(); workspace.addChangeListener((e) => { if (e.type !== 'ui' && e.type !== 'viewport_change') { updateEditor(); } }); return workspace; }, defaultOptions, { disableResize: true, toolboxes: config.toolboxes || { 'categories': toolboxCategories, 'simple': toolboxSimple, 'test blocks': toolboxTestBlocks, }, }, ); // Move the GUI Element to the gui container. const guiElement = gui.domElement; guiElement.removeChild(guiElement.firstChild); guiElement.style.position = 'relative'; guiElement.style.minWidth = '100%'; guiContainer.appendChild(guiElement); // Click handler to toggle the playground. const togglePlayground = (e) => { const shouldOpen = playgroundDiv.style.display === 'none'; if (shouldOpen) { playgroundDiv.style.display = 'flex'; minimizeButton.textContent = 'Collapse'; } else { playgroundDiv.style.display = 'none'; minimizeButton.textContent = 'Expand'; } playgroundState.set('playgroundOpen', shouldOpen); playgroundState.save(); Blockly.svgResize(workspace); }; minimizeButton.addEventListener('click', togglePlayground); // Start minimized if the playground was previously closed. if (playgroundState.get('playgroundOpen') === false) { togglePlayground(); } // Playground API. /** * Get the current GUI controls. * @returns {!dat.GUI} The GUI controls. */ const getGUI = function () { return gui; }; /** * Get the current workspace. * @returns {!Blockly.WorkspaceSvg} The Blockly workspace. */ const getWorkspace = function () { return workspace; }; /** * Get the current tab. * @returns {!PlaygroundTab} The current tab. */ const getCurrentTab = function () { return currentTab; }; /** * Add a generator tab. * @param {string} label The label of the generator tab. * @param {Blockly.Generator} generator The Blockly generator. * @param {string=} language Optional editor language, defaults to * 'javascript'. */ const addGenerator = function (label, generator, language) { if (!label || !generator) { throw Error('usage: addGenerator(label, generator, language?);'); } tabs[label] = registerGenerator( label, language || 'javascript', (ws) => generator.workspaceToCode(ws), true, ); if (activeTab === label) { // Set the new generator as the current tab if it is currently // active. This occurs when a dynamically added generator is active // and the page is reloaded. setActiveTab(tabs[label]); } }; /** * Removes a generator tab. * @param {string} label The label of the generator tab to remove. */ const removeGenerator = function (label) { if (!label) { throw Error('usage: removeGenerator(label);'); } if (!(label in tabs)) { throw Error('removeGenerator called on invalid label: ' + label); } const tab = tabs[label]; tabsDiv.removeChild(tab.tabElement); delete tabs[label]; const tabsKeys = Object.keys(tabs); if (activeTab === label && tabsKeys.length) { // Set the active tab to another tab if the active tab corresponded // to a removed generator. setActiveTab(tabs[tabsKeys[0]]); } }; const playground = { state: playgroundState, addAction: /** @type {?} */ (gui).addAction, addCheckboxAction: /** @type {?} */ (gui).addCheckboxAction, addGenerator, getCurrentTab, getGUI, getWorkspace, removeGenerator, }; // Add tab buttons. registerTabButtons(editor, playground, tabButtons, updateEditor); // Register editor commands. registerEditorCommands(editor, playground); return playground; }, ); } /** * Register tab buttons. * @param {monaco.editor.IStandaloneCodeEditor} editor The monaco editor. * @param {PlaygroundAPI} playground The current playground. * @param {!HTMLElement} tabButtons Tab buttons element wrapper. * @param {function():void} updateEditor Update Editor method. */ function registerTabButtons(editor, playground, tabButtons, updateEditor) { const [autoGenerateCheckbox, autoGenerateLabel] = renderCheckbox( 'autoGenerate', 'Auto', ); /** @type {HTMLInputElement} */ (autoGenerateCheckbox).checked = playground.state.get('autoGenerate'); autoGenerateCheckbox.addEventListener('change', (e) => { const inputTarget = /** @type {HTMLInputElement} */ (e.target); playground.state.set('autoGenerate', !!inputTarget.checked); playground.state.save(); updateEditor(); }); tabButtons.appendChild(autoGenerateCheckbox); tabButtons.appendChild(autoGenerateLabel); } /** * Register editor commands / shortcuts. * @param {monaco.editor.IStandaloneCodeEditor} editor The monaco editor. * @param {PlaygroundAPI} playground The current playground. */ function registerEditorCommands(editor, playground) { const loadXml = () => { const xml = editor.getModel().getValue(); const workspace = playground.getWorkspace(); try { Blockly.Xml.domToWorkspace(Blockly.utils.xml.textToDom(xml), workspace); } catch (e) { // If this fails that's fine. return false; } return true; }; const loadJson = () => { const json = editor.getModel().getValue(); const workspace = playground.getWorkspace(); try { Blockly.serialization.workspaces.load(JSON.parse(json), workspace); } catch (e) { // If this fails that's fine. return false; } return true; }; const save = () => { playground.getCurrentTab().generate(); }; // Add XMl Import action (only available on the XML tab). editor.addAction({ id: 'import-xml', label: 'Import from XML', keybindings: [window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.Enter], precondition: 'isEditorXml', contextMenuGroupId: 'playground', contextMenuOrder: 0, run: loadXml, }); // Add XMl Export action (only available on the XML tab). editor.addAction({ id: 'export-xml', label: 'Export to XML', keybindings: [window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KEY_S], precondition: 'isEditorXml', contextMenuGroupId: 'playground', contextMenuOrder: 1, run: save, }); // Add Clean XML action (only available on the XML tab). editor.addAction({ id: 'clean-xml', label: 'Clean XML', precondition: 'isEditorXml', contextMenuGroupId: 'playground', contextMenuOrder: 2, run: () => { const model = editor.getModel(); const text = model.getValue().replace(/ (x|y|id)="[^"]*"/gim, ''); model.pushEditOperations( [], [{range: model.getFullModelRange(), text}], () => null, ); editor.setSelection(new window.monaco.Range(0, 0, 0, 0)); }, }); // Add JSON Import action (only available on the JSON tab). editor.addAction({ id: 'import-json', label: 'Import from JSON', keybindings: [window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.Enter], precondition: 'isEditorJson', contextMenuGroupId: 'playground', contextMenuOrder: 0, run: loadJson, }); // Add a Generator generate action. editor.addAction({ id: 'generate', label: 'Generate', keybindings: [window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KEY_S], precondition: '!isEditorXml', contextMenuGroupId: 'playground', contextMenuOrder: 1, run: save, }); document.addEventListener('keydown', (e) => { const ctrlCmd = e.metaKey || e.ctrlKey; if (ctrlCmd && e.key === 's') { save(); e.preventDefault(); } else if (ctrlCmd && e.key === 'Enter') { if (!loadJson()) loadXml(); e.preventDefault(); } }); }