diff --git a/extensions/scripts/bundles/plugins.ts b/extensions/scripts/bundles/plugins.ts index d1d50536a..f73e8d00e 100644 --- a/extensions/scripts/bundles/plugins.ts +++ b/extensions/scripts/bundles/plugins.ts @@ -18,6 +18,7 @@ import { setAuxiliaryInfoForExtension } from "./auxiliaryInfo"; import { getAppInventorGenerator } from "scripts/utils/interop"; import { createFilter, FilterPattern } from '@rollup/pluginutils'; import { vmDeclarations } from "scripts/utils/generate"; +import { chromium } from 'playwright'; export const clearDestinationDirectories = (): Plugin => { const runner = runOncePerBundling(); @@ -186,6 +187,36 @@ export const mp3Bundler = (info: BundleInfo): Plugin => { }; } +async function playwrightTest(framework, bundledJsPath) { + console.log("LAUNCHING PLAYWRIGHT TEST"); + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + var detailsJSON = {}; + await page.goto('about:blank'); + + page.on('console', async (msg) => { + const args = await Promise.all(msg.args().map(arg => arg.jsonValue())); + console.log(`Console Log from page:`, ...args); + for (const arg of args) { + if (arg.includes("DETAILS: ")) { + let prefix = "DETAILS: "; + let jsonVal = arg.substring(prefix.length).trim(); + jsonVal = JSON.parse(jsonVal.trim()); + detailsJSON = jsonVal; + } + } + }); + + const bundledJs = fs.readFileSync(bundledJsPath, 'utf8'); + await page.evaluate(` + ${framework} + ${bundledJs}`) + await page.waitForTimeout(1000); + await browser.close(); + return detailsJSON; +} + export const finalizeConfigurableExtensionBundle = (info: BundleInfo): Plugin => { const { bundleDestination, menuDetails, name, directory } = info; @@ -193,20 +224,30 @@ export const finalizeConfigurableExtensionBundle = (info: BundleInfo): Plugin => const framework = await frameworkBundle.content; let success = false; - extensionBundleEvent.registerCallback(function (extensionInfo, removeSelf) { - const { details } = extensionInfo; - for (const key in menuDetails) delete menuDetails[key]; - for (const key in details) menuDetails[key] = details[key]; - success = true; - removeSelf(); - }); + // extensionBundleEvent.registerCallback(function (extensionInfo, removeSelf) { + // const { details } = extensionInfo; + // for (const key in menuDetails) delete menuDetails[key]; + // for (const key in details) menuDetails[key] = details[key]; + // console.log("DETAILS GOT ADDED"); + // console.log(details); + // success = true; + // removeSelf(); + // }); const generateAppInventor = getAppInventorGenerator(info); - eval(framework + "\n" + fs.readFileSync(bundleDestination, "utf-8")); - if (!success) throw new Error(`No extension registered for '${name}'. Check your usage of the 'extension(...)' factory function.`); + const detailsJSON: any = await playwrightTest(framework, bundleDestination); + for (const key in menuDetails) delete menuDetails[key]; + for (const key in detailsJSON) menuDetails[key] = detailsJSON[key]; + console.log("DETAILS GOT ADDED"); + console.log(detailsJSON); generateAppInventor(); + + // eval(framework + "\n" + fs.readFileSync(bundleDestination, "utf-8")); + + + } const runner = runOncePerBundling(); diff --git a/extensions/src/common/extension/index.ts b/extensions/src/common/extension/index.ts index d9a36ba6d..95a87ac9f 100644 --- a/extensions/src/common/extension/index.ts +++ b/extensions/src/common/extension/index.ts @@ -47,7 +47,11 @@ export const extension = ( ...addOns: Writeable ): ExtensionWithFunctionality<[...TSupported]> & typeof ExtensionBase => { - if (details) extensionBundleEvent?.fire({ details, addOns }); + if (details) { + let detailStr = `DETAILS: ${JSON.stringify(details)}`; + console.log(detailStr); + extensionBundleEvent?.fire({ details, addOns }); + } const Base = scratchInfo(supported(ExtensionBase, addOns)) as ExtensionWithFunctionality<[...TSupported]>; diff --git a/extensions/src/poseBody/package-lock.json b/extensions/src/poseBody/package-lock.json new file mode 100644 index 000000000..85a08fe5b --- /dev/null +++ b/extensions/src/poseBody/package-lock.json @@ -0,0 +1,157 @@ +{ + "name": "pose-body-extension", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "pose-body-extension", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@tensorflow-models/posenet": "2.2.1" + } + }, + "node_modules/@tensorflow-models/posenet": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tensorflow-models/posenet/-/posenet-2.2.1.tgz", + "integrity": "sha512-n9/g6DfjAyrBTf/zt1haRCyWsgALxUCzg9/Ks3Y2mbYavRZVSCSTRPy/qlE5Hr4tLfyckGfDN14zmGTthNcg/g==", + "peerDependencies": { + "@tensorflow/tfjs-converter": "^1.3.0", + "@tensorflow/tfjs-core": "^1.3.0" + } + }, + "node_modules/@tensorflow/tfjs-converter": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-1.7.4.tgz", + "integrity": "sha512-B/Ux9I3osI0CXoESGR0Xe5C6BsEfC04+g2xn5zVaW9KEuVEnGEgnuBQxgijRFzkqTwoyLv4ptAmjyIghVARX0Q==", + "peer": true, + "peerDependencies": { + "@tensorflow/tfjs-core": "1.7.4" + } + }, + "node_modules/@tensorflow/tfjs-core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.7.4.tgz", + "integrity": "sha512-3G4VKJ6nPs7iCt6gs3bjRj8chihKrYWenf63R0pm7D9MhlrVoX/tpN4LYVMGgBL7jHPxMLKdOkoAZJrn/J88HQ==", + "peer": true, + "dependencies": { + "@types/offscreencanvas": "~2019.3.0", + "@types/seedrandom": "2.4.27", + "@types/webgl-ext": "0.0.30", + "@types/webgl2": "0.0.4", + "node-fetch": "~2.1.2", + "seedrandom": "2.4.3" + }, + "engines": { + "yarn": ">= 1.3.2" + } + }, + "node_modules/@tensorflow/tfjs-core/node_modules/@types/seedrandom": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz", + "integrity": "sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ==", + "peer": true + }, + "node_modules/@tensorflow/tfjs-core/node_modules/node-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz", + "integrity": "sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==", + "peer": true, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@tensorflow/tfjs-core/node_modules/seedrandom": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz", + "integrity": "sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q==", + "peer": true + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.3.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", + "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", + "peer": true + }, + "node_modules/@types/webgl-ext": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", + "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg==", + "peer": true + }, + "node_modules/@types/webgl2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz", + "integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw==", + "peer": true + } + }, + "dependencies": { + "@tensorflow-models/posenet": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@tensorflow-models/posenet/-/posenet-2.2.1.tgz", + "integrity": "sha512-n9/g6DfjAyrBTf/zt1haRCyWsgALxUCzg9/Ks3Y2mbYavRZVSCSTRPy/qlE5Hr4tLfyckGfDN14zmGTthNcg/g==", + "requires": {} + }, + "@tensorflow/tfjs-converter": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-1.7.4.tgz", + "integrity": "sha512-B/Ux9I3osI0CXoESGR0Xe5C6BsEfC04+g2xn5zVaW9KEuVEnGEgnuBQxgijRFzkqTwoyLv4ptAmjyIghVARX0Q==", + "peer": true, + "requires": {} + }, + "@tensorflow/tfjs-core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.7.4.tgz", + "integrity": "sha512-3G4VKJ6nPs7iCt6gs3bjRj8chihKrYWenf63R0pm7D9MhlrVoX/tpN4LYVMGgBL7jHPxMLKdOkoAZJrn/J88HQ==", + "peer": true, + "requires": { + "@types/offscreencanvas": "~2019.3.0", + "@types/seedrandom": "2.4.27", + "@types/webgl-ext": "0.0.30", + "@types/webgl2": "0.0.4", + "node-fetch": "~2.1.2", + "seedrandom": "2.4.3" + }, + "dependencies": { + "@types/seedrandom": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz", + "integrity": "sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ==", + "peer": true + }, + "node-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz", + "integrity": "sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==", + "peer": true + }, + "seedrandom": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz", + "integrity": "sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q==", + "peer": true + } + } + }, + "@types/offscreencanvas": { + "version": "2019.3.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", + "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", + "peer": true + }, + "@types/webgl-ext": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", + "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg==", + "peer": true + }, + "@types/webgl2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz", + "integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw==", + "peer": true + } + } +} diff --git a/extensions/src/poseBody/package.json b/extensions/src/poseBody/package.json index 0730ec52f..5c7e07979 100644 --- a/extensions/src/poseBody/package.json +++ b/extensions/src/poseBody/package.json @@ -11,6 +11,6 @@ "author": "", "license": "ISC", "dependencies": { - "@tensorflow-models/posenet": "^2.2.1" + "@tensorflow-models/posenet": "2.2.1" } } \ No newline at end of file diff --git a/package.json b/package.json index c38a6ae13..02f0764ae 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,8 @@ "ts-node": { "typescript": "$typescript" } + }, + "dependencies": { + "playwright": "^1.44.1" } } \ No newline at end of file diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx new file mode 100644 index 000000000..2ac1d59ee --- /dev/null +++ b/packages/scratch-gui/src/containers/blocks.jsx @@ -0,0 +1,720 @@ +import bindAll from 'lodash.bindall'; +import debounce from 'lodash.debounce'; +import defaultsDeep from 'lodash.defaultsdeep'; +import makeToolboxXML from '../lib/make-toolbox-xml'; +import PropTypes from 'prop-types'; +import React from 'react'; +import VMScratchBlocks from '../lib/blocks'; +import VM from 'scratch-vm'; + +import log from '../lib/log.js'; +import Prompt from './prompt.jsx'; +import BlocksComponent from '../components/blocks/blocks.jsx'; +import ExtensionLibrary from './extension-library.jsx'; +import extensionData from '../lib/libraries/extensions/index.jsx'; +import CustomProcedures from './custom-procedures.jsx'; +import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; +import { STAGE_DISPLAY_SIZES } from '../lib/layout-constants'; +import DropAreaHOC from '../lib/drop-area-hoc.jsx'; +import DragConstants from '../lib/drag-constants'; +import defineDynamicBlock from '../lib/define-dynamic-block'; + +import { connect } from 'react-redux'; +import { updateToolbox } from '../reducers/toolbox'; +import { activateColorPicker } from '../reducers/color-picker'; +import { closeExtensionLibrary, openSoundRecorder, openConnectionModal, openTextModelModal, openClassifierModelModal, openProgrammaticModal } from '../reducers/modals'; +import { activateCustomProcedures, deactivateCustomProcedures } from '../reducers/custom-procedures'; +import { setConnectionModalExtensionId } from '../reducers/connection-modal'; +import { openUIEvent, registerButtonCallbackEvent } from "../../../../extensions/dist/globals"; + +import { + activateTab, + SOUNDS_TAB_INDEX +} from '../reducers/editor-tab'; + +const addFunctionListener = (object, property, callback) => { + const oldFn = object[property]; + object[property] = function () { + const result = oldFn.apply(this, arguments); + callback.apply(this, result); + return result; + }; +}; + +const DroppableBlocks = DropAreaHOC([ + DragConstants.BACKPACK_CODE +])(BlocksComponent); + +class Blocks extends React.Component { + constructor(props) { + super(props); + this.ScratchBlocks = VMScratchBlocks(props.vm); + bindAll(this, [ + 'attachVM', + 'detachVM', + 'getToolboxXML', + 'handleCategorySelected', + 'handleConnectionModalStart', + 'handleDrop', + 'handleStatusButtonUpdate', + 'handleOpenSoundRecorder', + 'handlePromptStart', + 'handlePromptCallback', + 'handlePromptClose', + 'handleCustomProceduresClose', + 'onScriptGlowOn', + 'onScriptGlowOff', + 'onBlockGlowOn', + 'onBlockGlowOff', + 'handleExtensionAdded', + 'handleBlocksInfoUpdate', + 'onTargetsUpdate', + 'onVisualReport', + 'onWorkspaceUpdate', + 'onWorkspaceMetricsChange', + 'setBlocks', + 'setLocale' + ]); + this.ScratchBlocks.prompt = this.handlePromptStart; + this.ScratchBlocks.statusButtonCallback = this.handleConnectionModalStart; + this.ScratchBlocks.recordSoundCallback = this.handleOpenSoundRecorder; + + this.state = { + workspaceMetrics: {}, + prompt: null + }; + this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100); + this.toolboxUpdateQueue = []; + } + componentDidMount() { + this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; + this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures; + this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); + + const workspaceConfig = defaultsDeep({}, + Blocks.defaultOptions, + this.props.options, + { rtl: this.props.isRtl, toolbox: this.props.toolboxXML } + ); + this.workspace = this.ScratchBlocks.inject(this.blocks, workspaceConfig); + + // Register buttons under new callback keys for creating variables, + // lists, and procedures from extensions. + + const toolboxWorkspace = this.workspace.getFlyout().getWorkspace(); + + const varListButtonCallback = type => + (() => this.ScratchBlocks.Variables.createVariable(this.workspace, null, type)); + const procButtonCallback = () => { + this.ScratchBlocks.Procedures.createProcedureDefCallback_(this.workspace); + }; + + const connectMicrobitRobotCallback = () => { + this.props.vm.runtime.emit('CONNECT_MICROBIT_ROBOT'); + } + + toolboxWorkspace.registerButtonCallback('MAKE_A_VARIABLE', varListButtonCallback('')); + toolboxWorkspace.registerButtonCallback('MAKE_A_LIST', varListButtonCallback('list')); + toolboxWorkspace.registerButtonCallback('MAKE_A_PROCEDURE', procButtonCallback); + toolboxWorkspace.registerButtonCallback('CONNECT_MICROBIT_ROBOT', connectMicrobitRobotCallback); + + this.props.vm.runtime.on(openUIEvent, (details) => this.props.onOpenProgrammaticModal(details)); + + // Store the xml of the toolbox that is actually rendered. + // This is used in componentDidUpdate instead of prevProps, because + // the xml can change while e.g. on the costumes tab. + this._renderedToolboxXML = this.props.toolboxXML; + + // we actually never want the workspace to enable "refresh toolbox" - this basically re-renders the + // entire toolbox every time we reset the workspace. We call updateToolbox as a part of + // componentDidUpdate so the toolbox will still correctly be updated + this.setToolboxRefreshEnabled = this.workspace.setToolboxRefreshEnabled.bind(this.workspace); + this.workspace.setToolboxRefreshEnabled = () => { + this.setToolboxRefreshEnabled(false); + }; + + // @todo change this when blockly supports UI events + addFunctionListener(this.workspace, 'translate', this.onWorkspaceMetricsChange); + addFunctionListener(this.workspace, 'zoom', this.onWorkspaceMetricsChange); + + this.attachVM(); + // Only update blocks/vm locale when visible to avoid sizing issues + // If locale changes while not visible it will get handled in didUpdate + if (this.props.isVisible) { + this.setLocale(); + } + } + shouldComponentUpdate(nextProps, nextState) { + return ( + this.state.prompt !== nextState.prompt || + this.props.isVisible !== nextProps.isVisible || + this._renderedToolboxXML !== nextProps.toolboxXML || + this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible || + this.props.customProceduresVisible !== nextProps.customProceduresVisible || + this.props.locale !== nextProps.locale || + this.props.anyModalVisible !== nextProps.anyModalVisible || + this.props.stageSize !== nextProps.stageSize + ); + } + componentDidUpdate(prevProps) { + // If any modals are open, call hideChaff to close z-indexed field editors + if (this.props.anyModalVisible && !prevProps.anyModalVisible) { + this.ScratchBlocks.hideChaff(); + } + + // Only rerender the toolbox when the blocks are visible and the xml is + // different from the previously rendered toolbox xml. + // Do not check against prevProps.toolboxXML because that may not have been rendered. + if (this.props.isVisible && this.props.toolboxXML !== this._renderedToolboxXML) { + this.requestToolboxUpdate(); + } + + if (this.props.isVisible === prevProps.isVisible) { + if (this.props.stageSize !== prevProps.stageSize) { + // force workspace to redraw for the new stage size + window.dispatchEvent(new Event('resize')); + } + return; + } + // @todo hack to resize blockly manually in case resize happened while hidden + // @todo hack to reload the workspace due to gui bug #413 + if (this.props.isVisible) { // Scripts tab + this.workspace.setVisible(true); + if (prevProps.locale !== this.props.locale || this.props.locale !== this.props.vm.getLocale()) { + // call setLocale if the locale has changed, or changed while the blocks were hidden. + // vm.getLocale() will be out of sync if locale was changed while not visible + this.setLocale(); + } else { + this.props.vm.refreshWorkspace(); + this.requestToolboxUpdate(); + } + + window.dispatchEvent(new Event('resize')); + } else { + this.workspace.setVisible(false); + } + } + componentWillUnmount() { + this.detachVM(); + this.workspace.dispose(); + clearTimeout(this.toolboxUpdateTimeout); + } + requestToolboxUpdate() { + clearTimeout(this.toolboxUpdateTimeout); + this.toolboxUpdateTimeout = setTimeout(() => { + this.updateToolbox(); + }, 0); + } + setLocale() { + this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); + this.props.vm.setLocale(this.props.locale, this.props.messages) + .then(() => { + this.workspace.getFlyout().setRecyclingEnabled(false); + this.props.vm.refreshWorkspace(); + this.requestToolboxUpdate(); + this.withToolboxUpdates(() => { + this.workspace.getFlyout().setRecyclingEnabled(true); + + // Moved from `componentDidMount` due to strange blockly error after changing locale. + // Worth retesting after updating scratch, as this may have been addressed in later blockly versions. + const registerButtonCallback = (event) => + this.workspace.getFlyout() + ? this.workspace.registerButtonCallback(event, () => { this.props.vm.runtime.emit(event) }) + : this.props.vm.runtime.off(registerButtonCallbackEvent, registerButtonCallback) + + this.props.vm.runtime.on(registerButtonCallbackEvent, registerButtonCallback.bind(this)); + }); + }); + } + + updateToolbox() { + this.toolboxUpdateTimeout = false; + + const categoryId = this.workspace.toolbox_.getSelectedCategoryId(); + const offset = this.workspace.toolbox_.getCategoryScrollOffset(); + this.workspace.updateToolbox(this.props.toolboxXML); + this._renderedToolboxXML = this.props.toolboxXML; + + // In order to catch any changes that mutate the toolbox during "normal runtime" + // (variable changes/etc), re-enable toolbox refresh. + // Using the setter function will rerender the entire toolbox which we just rendered. + this.workspace.toolboxRefreshEnabled_ = true; + + const currentCategoryPos = this.workspace.toolbox_.getCategoryPositionById(categoryId); + const currentCategoryLen = this.workspace.toolbox_.getCategoryLengthById(categoryId); + if (offset < currentCategoryLen) { + this.workspace.toolbox_.setFlyoutScrollPos(currentCategoryPos + offset); + } else { + this.workspace.toolbox_.setFlyoutScrollPos(currentCategoryPos); + } + + const queue = this.toolboxUpdateQueue; + this.toolboxUpdateQueue = []; + queue.forEach(fn => fn()); + } + + withToolboxUpdates(fn) { + // if there is a queued toolbox update, we need to wait + if (this.toolboxUpdateTimeout) { + this.toolboxUpdateQueue.push(fn); + } else { + fn(); + } + } + + attachVM() { + this.workspace.addChangeListener(this.props.vm.blockListener); + this.flyoutWorkspace = this.workspace + .getFlyout() + .getWorkspace(); + this.flyoutWorkspace.addChangeListener(this.props.vm.flyoutBlockListener); + this.flyoutWorkspace.addChangeListener(this.props.vm.monitorBlockListener); + this.props.vm.addListener('SCRIPT_GLOW_ON', this.onScriptGlowOn); + this.props.vm.addListener('SCRIPT_GLOW_OFF', this.onScriptGlowOff); + this.props.vm.addListener('BLOCK_GLOW_ON', this.onBlockGlowOn); + this.props.vm.addListener('BLOCK_GLOW_OFF', this.onBlockGlowOff); + this.props.vm.addListener('VISUAL_REPORT', this.onVisualReport); + this.props.vm.addListener('workspaceUpdate', this.onWorkspaceUpdate); + this.props.vm.addListener('targetsUpdate', this.onTargetsUpdate); + this.props.vm.addListener('EXTENSION_ADDED', this.handleExtensionAdded); + this.props.vm.addListener('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate); + this.props.vm.addListener('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate); + this.props.vm.addListener('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate); + } + detachVM() { + this.props.vm.removeListener('SCRIPT_GLOW_ON', this.onScriptGlowOn); + this.props.vm.removeListener('SCRIPT_GLOW_OFF', this.onScriptGlowOff); + this.props.vm.removeListener('BLOCK_GLOW_ON', this.onBlockGlowOn); + this.props.vm.removeListener('BLOCK_GLOW_OFF', this.onBlockGlowOff); + this.props.vm.removeListener('VISUAL_REPORT', this.onVisualReport); + this.props.vm.removeListener('workspaceUpdate', this.onWorkspaceUpdate); + this.props.vm.removeListener('targetsUpdate', this.onTargetsUpdate); + this.props.vm.removeListener('EXTENSION_ADDED', this.handleExtensionAdded); + this.props.vm.removeListener('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate); + this.props.vm.removeListener('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate); + this.props.vm.removeListener('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate); + } + + updateToolboxBlockValue(id, value) { + this.withToolboxUpdates(() => { + const block = this.workspace + .getFlyout() + .getWorkspace() + .getBlockById(id); + if (block) { + block.inputList[0].fieldRow[0].setValue(value); + } + }); + } + + onTargetsUpdate() { + if (this.props.vm.editingTarget && this.workspace.getFlyout()) { + ['glide', 'move', 'set'].forEach(prefix => { + this.updateToolboxBlockValue(`${prefix}x`, Math.round(this.props.vm.editingTarget.x).toString()); + this.updateToolboxBlockValue(`${prefix}y`, Math.round(this.props.vm.editingTarget.y).toString()); + }); + } + } + onWorkspaceMetricsChange() { + const target = this.props.vm.editingTarget; + if (target && target.id) { + const workspaceMetrics = Object.assign({}, this.state.workspaceMetrics, { + [target.id]: { + scrollX: this.workspace.scrollX, + scrollY: this.workspace.scrollY, + scale: this.workspace.scale + } + }); + this.setState({ workspaceMetrics }); + } + } + onScriptGlowOn(data) { + this.workspace.glowStack(data.id, true); + } + onScriptGlowOff(data) { + this.workspace.glowStack(data.id, false); + } + onBlockGlowOn(data) { + this.workspace.glowBlock(data.id, true); + } + onBlockGlowOff(data) { + this.workspace.glowBlock(data.id, false); + } + onVisualReport(data) { + this.workspace.reportValue(data.id, data.value); + } + getToolboxXML() { + // Use try/catch because this requires digging pretty deep into the VM + // Code inside intentionally ignores several error situations (no stage, etc.) + // Because they would get caught by this try/catch + try { + let { editingTarget: target, runtime } = this.props.vm; + const stage = runtime.getTargetForStage(); + if (!target) target = stage; // If no editingTarget, use the stage + + const stageCostumes = stage.getCostumes(); + const targetCostumes = target.getCostumes(); + const targetSounds = target.getSounds(); + const dynamicBlocksXML = this.props.vm.runtime.getBlocksXML(); + return makeToolboxXML(target.isStage, target.id, dynamicBlocksXML, + targetCostumes[targetCostumes.length - 1].name, + stageCostumes[stageCostumes.length - 1].name, + targetSounds.length > 0 ? targetSounds[targetSounds.length - 1].name : '' + ); + } catch { + return null; + } + } + onWorkspaceUpdate(data) { + // When we change sprites, update the toolbox to have the new sprite's blocks + const toolboxXML = this.getToolboxXML(); + if (toolboxXML) { + this.props.updateToolboxState(toolboxXML); + } + + if (this.props.vm.editingTarget && !this.state.workspaceMetrics[this.props.vm.editingTarget.id]) { + this.onWorkspaceMetricsChange(); + } + + // Remove and reattach the workspace listener (but allow flyout events) + this.workspace.removeChangeListener(this.props.vm.blockListener); + const dom = this.ScratchBlocks.Xml.textToDom(data.xml); + try { + this.ScratchBlocks.Xml.clearWorkspaceAndLoadFromXml(dom, this.workspace); + } catch (error) { + // The workspace is likely incomplete. What did update should be + // functional. + // + // Instead of throwing the error, by logging it and continuing as + // normal lets the other workspace update processes complete in the + // gui and vm, which lets the vm run even if the workspace is + // incomplete. Throwing the error would keep things like setting the + // correct editing target from happening which can interfere with + // some blocks and processes in the vm. + if (error.message) { + error.message = `Workspace Update Error: ${error.message}`; + } + log.error(error); + } + this.workspace.addChangeListener(this.props.vm.blockListener); + + if (this.props.vm.editingTarget && this.state.workspaceMetrics[this.props.vm.editingTarget.id]) { + const { scrollX, scrollY, scale } = this.state.workspaceMetrics[this.props.vm.editingTarget.id]; + this.workspace.scrollX = scrollX; + this.workspace.scrollY = scrollY; + this.workspace.scale = scale; + this.workspace.resize(); + } + + // Clear the undo state of the workspace since this is a + // fresh workspace and we don't want any changes made to another sprites + // workspace to be 'undone' here. + this.workspace.clearUndo(); + } + handleExtensionAdded(categoryInfo) { + const defineBlocks = blockInfoArray => { + if (blockInfoArray && blockInfoArray.length > 0) { + const staticBlocksJson = []; + const dynamicBlocksInfo = []; + blockInfoArray.forEach(blockInfo => { + if (blockInfo.info && blockInfo.info.isDynamic) { + dynamicBlocksInfo.push(blockInfo); + } else if (blockInfo.json) { + staticBlocksJson.push(blockInfo.json); + } + // otherwise it's a non-block entry such as '---' + }); + + this.ScratchBlocks.defineBlocksWithJsonArray(staticBlocksJson); + dynamicBlocksInfo.forEach(blockInfo => { + // This is creating the block factory / constructor -- NOT a specific instance of the block. + // The factory should only know static info about the block: the category info and the opcode. + // Anything else will be picked up from the XML attached to the block instance. + const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`; + const blockDefinition = + defineDynamicBlock(this.ScratchBlocks, categoryInfo, blockInfo, extendedOpcode); + this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition; + }); + } + }; + + // scratch-blocks implements a menu or custom field as a special kind of block ("shadow" block) + // these actually define blocks and MUST run regardless of the UI state + defineBlocks( + Object.getOwnPropertyNames(categoryInfo.customFieldTypes) + .map(fieldTypeName => categoryInfo.customFieldTypes[fieldTypeName].scratchBlocksDefinition)); + defineBlocks(categoryInfo.menus); + defineBlocks(categoryInfo.blocks); + + // Update the toolbox with new blocks if possible + const toolboxXML = this.getToolboxXML(); + if (toolboxXML) { + this.props.updateToolboxState(toolboxXML); + } + } + handleBlocksInfoUpdate(categoryInfo) { + // @todo Later we should replace this to avoid all the warnings from redefining blocks. + this.handleExtensionAdded(categoryInfo); + } + handleCategorySelected(categoryId) { + const extension = extensionData.find(ext => ext.extensionId === categoryId); + if (extension && extension.launchPeripheralConnectionFlow) { + this.handleConnectionModalStart(categoryId); + } + + this.withToolboxUpdates(() => { + this.workspace.toolbox_.setSelectedCategoryById(categoryId); + }); + } + setBlocks(blocks) { + this.blocks = blocks; + } + handlePromptStart(message, defaultValue, callback, optTitle, optVarType) { + const p = { prompt: { callback, message, defaultValue } }; + p.prompt.title = optTitle ? optTitle : + this.ScratchBlocks.Msg.VARIABLE_MODAL_TITLE; + p.prompt.varType = typeof optVarType === 'string' ? + optVarType : this.ScratchBlocks.SCALAR_VARIABLE_TYPE; + p.prompt.showVariableOptions = // This flag means that we should show variable/list options about scope + optVarType !== this.ScratchBlocks.BROADCAST_MESSAGE_VARIABLE_TYPE && + p.prompt.title !== this.ScratchBlocks.Msg.RENAME_VARIABLE_MODAL_TITLE && + p.prompt.title !== this.ScratchBlocks.Msg.RENAME_LIST_MODAL_TITLE; + p.prompt.showCloudOption = (optVarType === this.ScratchBlocks.SCALAR_VARIABLE_TYPE) && this.props.canUseCloud; + this.setState(p); + } + handleConnectionModalStart(extensionId) { + let prgCustomExtensions = ['microbitRobot', 'teachableMachine']; + if (!prgCustomExtensions.includes(extensionId)) { + this.props.onOpenConnectionModal(extensionId); + } + } + handleStatusButtonUpdate() { + this.ScratchBlocks.refreshStatusButtons(this.workspace); + } + handleOpenSoundRecorder() { + this.props.onOpenSoundRecorder(); + } + + /* + * Pass along information about proposed name and variable options (scope and isCloud) + * and additional potentially conflicting variable names from the VM + * to the variable validation prompt callback used in scratch-blocks. + */ + handlePromptCallback(input, variableOptions) { + this.state.prompt.callback( + input, + this.props.vm.runtime.getAllVarNamesOfType(this.state.prompt.varType), + variableOptions); + this.handlePromptClose(); + } + handlePromptClose() { + this.setState({ prompt: null }); + } + handleCustomProceduresClose(data) { + this.props.onRequestCloseCustomProcedures(data); + const ws = this.workspace; + ws.refreshToolboxSelection_(); + ws.toolbox_.scrollToCategoryById('myBlocks'); + } + handleDrop(dragInfo) { + fetch(dragInfo.payload.bodyUrl) + .then(response => response.json()) + .then(blocks => this.props.vm.shareBlocksToTarget(blocks, this.props.vm.editingTarget.id)) + .then(() => { + this.props.vm.refreshWorkspace(); + this.updateToolbox(); // To show new variables/custom blocks + }); + } + render() { + /* eslint-disable no-unused-vars */ + const { + anyModalVisible, + canUseCloud, + customProceduresVisible, + extensionLibraryVisible, + options, + stageSize, + vm, + isRtl, + isVisible, + onActivateColorPicker, + onOpenConnectionModal, + onOpenSoundRecorder, + updateToolboxState, + onActivateCustomProcedures, + onRequestCloseExtensionLibrary, + onRequestCloseCustomProcedures, + onOpenTextModelModal, + onOpenClassifierModelModal, + toolboxXML, + ...props + } = this.props; + /* eslint-enable no-unused-vars */ + return ( + + + {this.state.prompt ? ( + + ) : null} + {extensionLibraryVisible ? ( + + ) : null} + {customProceduresVisible ? ( + + ) : null} + + ); + } +} + +Blocks.propTypes = { + anyModalVisible: PropTypes.bool, + canUseCloud: PropTypes.bool, + customProceduresVisible: PropTypes.bool, + extensionLibraryVisible: PropTypes.bool, + isRtl: PropTypes.bool, + isVisible: PropTypes.bool, + locale: PropTypes.string.isRequired, + messages: PropTypes.objectOf(PropTypes.string), + onActivateColorPicker: PropTypes.func, + onActivateCustomProcedures: PropTypes.func, + onOpenConnectionModal: PropTypes.func, + onOpenTextModelModal: PropTypes.func, + onOpenClassifierModelModal: PropTypes.func, + onOpenSoundRecorder: PropTypes.func, + onRequestCloseCustomProcedures: PropTypes.func, + onRequestCloseExtensionLibrary: PropTypes.func, + options: PropTypes.shape({ + media: PropTypes.string, + zoom: PropTypes.shape({ + controls: PropTypes.bool, + wheel: PropTypes.bool, + startScale: PropTypes.number + }), + colours: PropTypes.shape({ + workspace: PropTypes.string, + flyout: PropTypes.string, + toolbox: PropTypes.string, + toolboxSelected: PropTypes.string, + scrollbar: PropTypes.string, + scrollbarHover: PropTypes.string, + insertionMarker: PropTypes.string, + insertionMarkerOpacity: PropTypes.number, + fieldShadow: PropTypes.string, + dragShadowOpacity: PropTypes.number + }), + comments: PropTypes.bool, + collapse: PropTypes.bool + }), + stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, + toolboxXML: PropTypes.string, + updateToolboxState: PropTypes.func, + vm: PropTypes.instanceOf(VM).isRequired +}; + +Blocks.defaultOptions = { + zoom: { + controls: true, + wheel: true, + startScale: 0.675 + }, + grid: { + spacing: 40, + length: 2, + colour: '#ddd' + }, + colours: { + workspace: '#F9F9F9', + flyout: '#F9F9F9', + toolbox: '#FFFFFF', + toolboxSelected: '#E9EEF2', + scrollbar: '#CECDCE', + scrollbarHover: '#CECDCE', + insertionMarker: '#000000', + insertionMarkerOpacity: 0.2, + fieldShadow: 'rgba(255, 255, 255, 0.3)', + dragShadowOpacity: 0.6 + }, + comments: true, + collapse: false, + sounds: false +}; + +Blocks.defaultProps = { + isVisible: true, + options: Blocks.defaultOptions +}; + +const mapStateToProps = state => ({ + anyModalVisible: ( + Object.keys(state.scratchGui.modals).some(key => state.scratchGui.modals[key]) || + state.scratchGui.mode.isFullScreen + ), + extensionLibraryVisible: state.scratchGui.modals.extensionLibrary, + isRtl: state.locales.isRtl, + locale: state.locales.locale, + messages: state.locales.messages, + toolboxXML: state.scratchGui.toolbox.toolboxXML, + customProceduresVisible: state.scratchGui.customProcedures.active +}); + +const mapDispatchToProps = dispatch => ({ + onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), + onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)), + onOpenConnectionModal: id => { + dispatch(setConnectionModalExtensionId(id)); + dispatch(openConnectionModal()); + }, + onOpenTextModelModal: () => { + dispatch(openTextModelModal()); + }, + onOpenClassifierModelModal: () => { + dispatch(openClassifierModelModal()); + }, + onOpenSoundRecorder: () => { + dispatch(activateTab(SOUNDS_TAB_INDEX)); + dispatch(openSoundRecorder()); + }, + onOpenProgrammaticModal: (details) => { + dispatch(openProgrammaticModal(details)) + }, + onRequestCloseExtensionLibrary: () => { + dispatch(closeExtensionLibrary()); + }, + onRequestCloseCustomProcedures: data => { + dispatch(deactivateCustomProcedures(data)); + }, + updateToolboxState: toolboxXML => { + dispatch(updateToolbox(toolboxXML)); + } +}); + +export default errorBoundaryHOC('Blocks')( + connect( + mapStateToProps, + mapDispatchToProps + )(Blocks) +);