diff --git a/packages/docs/docs/codelabs/custom-mutator/code-generation.mdx b/packages/docs/docs/codelabs/custom-mutator/code-generation.mdx new file mode 100644 index 00000000000..bbfc74baba4 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/code-generation.mdx @@ -0,0 +1,68 @@ +--- +description: Generating code for the mutator. +--- + +import Image from '@site/src/components/Image'; + +# Build a custom mutator + +## 7. Generating code + +Now, it's time to revisit the [block-code generator](https://docs.blockly.com/guides/create-custom-blocks/code-generation/block-code/) +in `javascript.js`. Since the sample app displays JavaScript code, you'll make a +generator for JavaScript. + +### Update the code generator + +Update the `resizable_list` generator in `javascript.js` to contain the +following code: + +```js +forBlock['resizable_list'] = function (block, generator) { + // Create a list with any number of elements of any type. + const elements = new Array(block.itemCount); + for (let i = 0; i < block.itemCount; i++) { + elements[i] = generator.valueToCode(block, 'ADD' + i, Order.NONE) || 'null'; + } + const code = '[' + elements.join(', ') + ']'; + return [code, Order.ATOMIC]; +}; +``` + +This code generator converts each input to code, then comma-separates the list +and joins them all between two brackets. This is how a block like this: + +A list block with three inputs: the first is the integer 123, the second is empty, the third is a string that says 'Hello, world!' + +Generates this code: + +```js +[123, null, 'Hello, world!']; +``` + +### Test it + +Test your mutator by dragging it into the workspace, right clicking, and +adding an input. You may notice something strange. Although the block generates +code when it first enters the workspace, adding a new input does not update the +code. + +Now try moving the list block. The code window will be updated. + +### Why doesn't the code update immediately? + +In the sample app, a function called `runCode()` handles generating the code +for the code window. A [change listener](https://docs.blockly.com/guides/configure/events/#listen-to-events-from-the-workspace) +in `index.js` calls `runCode()` when certain types of events are detected. + +This means that when blocks are changed, moved, or added, an event is fired and +the code is regenerated. However, the new mutator does not fire any events, so adding +or removing an input is not enough to cause the code to be regenerated. In order for the code +to be regenerated, a different event has to occur (like moving the block). + +The next step will address this issue by adding a function to the mutator that +fires an event when the number of inputs are changed. diff --git a/packages/docs/docs/codelabs/custom-mutator/codelab-overview.mdx b/packages/docs/docs/codelabs/custom-mutator/codelab-overview.mdx new file mode 100644 index 00000000000..df347ebee2c --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/codelab-overview.mdx @@ -0,0 +1,31 @@ +--- +pagination_prev: null +description: Overview of the "Build a custom mutator" codelab. +--- + +import Image from '@site/src/components/Image'; + +# Build a custom mutator + +## 1. Codelab overview + +### What you'll learn +- What mutators are and when to use them +- How to create a custom mutator +- How to use context menus to create a custom mutator UI +- When and how to fire events during a mutation + +### What you'll build +A custom list mutator that uses the context menu to add and remove items. + +Screenshot of the block built in this codelab. There are multiple inputs to the block, with a context menu open that shows options for 'Add Item' and 'Remove Item' + +### What you'll need +- Basic understanding of Blockly blocks, toolboxes, and workspaces. +- Understanding of context menus in Blockly. +- NPM installed ([instructions](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)). +- Comfort using the command line/terminal. diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/README.md b/packages/docs/docs/codelabs/custom-mutator/complete-code/README.md new file mode 100644 index 00000000000..504a02069a7 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/README.md @@ -0,0 +1,50 @@ +# Blockly Sample App + +## Purpose + +This app illustrates how to use Blockly together with common programming tools like node/npm, webpack, typescript, eslint, and others. You can use it as the starting point for your own application and modify it as much as you'd like. It contains basic infrastructure for running, building, testing, etc. that you can use even if you don't understand how to configure the related tool yet. When your needs outgrow the functionality provided here, you can replace the provided configuration or tool with your own. + +## Quick Start + +1. [Install](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) npm if you haven't before. +2. Run [`npx @blockly/create-package app `](https://www.npmjs.com/package/@blockly/create-package) to clone this application to your own machine. +3. Run `npm install` to install the required dependencies. +4. Run `npm run start` to run the development server and see the app in action. +5. If you make any changes to the source code, just refresh the browser while the server is running to see them. + +## Tooling + +The application uses many of the same tools that the Blockly team uses to develop Blockly itself. Following is a brief overview, and you can read more about them on our [developer site](https://docs.blockly.com/guides/contribute/core/development_tools/). + +- Structure: The application is built as an npm package. You can use npm to manage the dependencies of the application. +- Modules: ES6 modules to handle imports to/exports from other files. +- Building/bundling: Webpack to build the source code and bundle it into one file for serving. +- Development server: webpack-dev-server to run locally while in development. +- Testing: Mocha to run unit tests. +- Linting: Eslint to lint the code and ensure it conforms with a standard style. +- UI Framework: Does not use a framework. For more complex applications, you may wish to integrate a UI framework like React or Angular. + +You can disable, reconfigure, or replace any of these tools at any time, but they are preconfigured to get you started developing your Blockly application quickly. + +## Structure + +- `package.json` contains basic information about the app. This is where the scripts to run, build, etc. are listed. +- `package-lock.json` is used by npm to manage dependencies +- `webpack.config.js` is the configuration for webpack. This handles bundling the application and running our development server. +- `src/` contains the rest of the source code. +- `dist/` contains the packaged output (that you could host on a server, for example). This is ignored by git and will only appear after you run `npm run build` or `npm run start`. + +### Source Code + +- `index.html` contains the skeleton HTML for the page. This file is modified during the build to import the bundled source code output by webpack. +- `index.js` is the entry point of the app. It configures Blockly and sets up the page to show the blocks, the generated code, and the output of running the code in JavaScript. +- `serialization.js` has code to save and load the workspace using the browser's local storage. This is how your workspace is saved even after refreshing or leaving the page. You could replace this with code that saves the user's data to a cloud database instead. +- `toolbox.js` contains the toolbox definition for the app. The current toolbox contains nearly every block that Blockly provides out of the box. You probably want to replace this definition with your own toolbox that uses your custom blocks and only includes the default blocks that are relevant to your application. +- `blocks/text.js` has code for a custom text block, just as an example of creating your own blocks. You probably want to delete this block, and add your own blocks in this directory. +- `generators/javascript.js` contains the JavaScript generator for the custom text block. You'll need to include block generators for any custom blocks you create, in whatever programming language(s) your application will use. + +## Serving + +To run your app locally, run `npm run start` to run the development server. This mode generates source maps and ingests the source maps created by Blockly, so that you can debug using unminified code. + +To deploy your app so that others can use it, run `npm run build` to run a production build. This will bundle your code and minify it to reduce its size. You can then host the contents of the `dist` directory on a web server of your choosing. If you're just getting started, try using [GitHub Pages](https://pages.github.com/). diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/package.json b/packages/docs/docs/codelabs/custom-mutator/complete-code/package.json new file mode 100644 index 00000000000..1296cb73f59 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/package.json @@ -0,0 +1,29 @@ +{ + "name": "walkthrough-mutator-codelab", + "version": "1.0.0", + "description": "A sample app using Blockly", + "main": "index.js", + "private": true, + "scripts": { + "test": "echo \"Warning: no test specified\" && exit 0", + "build": "webpack --mode production", + "start": "webpack serve --open --mode development" + }, + "keywords": [ + "blockly" + ], + "author": "", + "license": "Apache-2.0", + "devDependencies": { + "css-loader": "^7.1.4", + "html-webpack-plugin": "^5.6.7", + "source-map-loader": "^5.0.0", + "style-loader": "^4.0.0", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3", + "webpack-dev-server": "^6.0.0" + }, + "dependencies": { + "blockly": "^13.3.0" + } +} \ No newline at end of file diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/blocks/list.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/blocks/list.js new file mode 100644 index 00000000000..249eaa53151 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/blocks/list.js @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +const resizableListBlock = { + type: 'resizable_list', + message0: 'resizable list with %1', + args0: [ + { + type: 'input_value', + name: 'ADD0', + }, + ], + output: null, + style: 'list_blocks', + mutator: 'list_mutator', + tooltip: '', + helpUrl: '', +}; + +// Create the block definitions +export const mutatorBlocks = Blockly.common.createBlockDefinitionsFromJsonArray( + [resizableListBlock], +); diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/blocks/text.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/blocks/text.js new file mode 100644 index 00000000000..244eaf1c9d4 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/blocks/text.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +// Create a custom block called 'add_text' that adds +// text to the output div on the sample app. +// This is just an example and you should replace this with your +// own custom blocks. +const addText = { + type: 'add_text', + message0: 'Add text %1', + args0: [ + { + type: 'input_value', + name: 'TEXT', + check: 'String', + }, + ], + previousStatement: null, + nextStatement: null, + colour: 160, + tooltip: '', + helpUrl: '', +}; + +// Create the block definitions for the JSON-only blocks. +// This does not register their definitions with Blockly. +// This file has no side effects! +export const blocks = Blockly.common.createBlockDefinitionsFromJsonArray([ + addText, +]); diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/generators/javascript.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/generators/javascript.js new file mode 100644 index 00000000000..54f48a37a66 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/generators/javascript.js @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Order } from 'blockly/javascript'; + +// Export all the code generators for our custom blocks, +// but don't register them with Blockly yet. +// This file has no side effects! +export const forBlock = Object.create(null); + +forBlock['add_text'] = function (block, generator) { + const text = generator.valueToCode(block, 'TEXT', Order.NONE) || "''"; + const addText = generator.provideFunction_( + 'addText', + `function ${generator.FUNCTION_NAME_PLACEHOLDER_}(text) { + + // Add text to the output area. + const outputDiv = document.getElementById('output'); + const textEl = document.createElement('p'); + textEl.innerText = text; + outputDiv.appendChild(textEl); +}`, + ); + // Generate the function call for this block. + const code = `${addText}(${text});\n`; + return code; +}; + +forBlock['resizable_list'] = function (block, generator) { + // Create a list with any number of elements of any type. + const elements = new Array(block.itemCount); + for (let i = 0; i < block.itemCount; i++) { + elements[i] = generator.valueToCode(block, 'ADD' + i, Order.NONE) || 'null'; + } + const code = '[' + elements.join(', ') + ']'; + return [code, Order.ATOMIC]; +}; diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.css b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.css new file mode 100644 index 00000000000..f282700701f --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.css @@ -0,0 +1,40 @@ +body { + margin: 0; + max-width: 100vw; +} + +pre, +code { + overflow: auto; +} + +#pageContainer { + display: flex; + width: 100%; + max-width: 100vw; + height: 100vh; +} + +#blocklyDiv { + flex-basis: 100%; + height: 100%; + min-width: 600px; +} + +#outputPane { + display: flex; + flex-direction: column; + width: 400px; + flex: 0 0 400px; + overflow: auto; + margin: 1rem; +} + +#generatedCode { + height: 50%; + background-color: rgb(247, 240, 228); +} + +#output { + height: 50%; +} diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.html b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.html new file mode 100644 index 00000000000..36d8eeacd99 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.html @@ -0,0 +1,16 @@ + + + + + Blockly Sample App + + +
+
+
+
+
+
+
+ + diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.js new file mode 100644 index 00000000000..bcde484ecd5 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/index.js @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly'; +import { blocks } from './blocks/text'; +import { mutatorBlocks } from './blocks/list'; +import { LIST_MUTATOR } from './mutators/list_mutator'; +import { forBlock } from './generators/javascript'; +import { javascriptGenerator } from 'blockly/javascript'; +import { save, load } from './serialization'; +import { toolbox } from './toolbox'; +import './index.css'; + +// Register the blocks and generator with Blockly +Blockly.common.defineBlocks(blocks); +Blockly.common.defineBlocks(mutatorBlocks); +Blockly.Extensions.registerMutator('list_mutator', LIST_MUTATOR, function () { + this.itemCount = 1; +}); + +Object.assign(javascriptGenerator.forBlock, forBlock); +registerAddItem(); +registerRemoveItem(); + +// Set up UI elements and inject Blockly +const codeDiv = document.getElementById('generatedCode').firstChild; +const outputDiv = document.getElementById('output'); +const blocklyDiv = document.getElementById('blocklyDiv'); +const ws = Blockly.inject(blocklyDiv, { toolbox }); + +function registerAddItem() { + const addItem = { + displayText: 'Add Item', + preconditionFn: function (scope) { + if ( + scope.focusedNode instanceof Blockly.BlockSvg && + !scope.focusedNode.isInFlyout && + scope.focusedNode.type === 'resizable_list' + ) { + return 'enabled'; + } + return 'hidden'; + }, + callback: (scope) => { + scope.focusedNode.addConnection(); + }, + id: 'add_item', + weight: 100, + }; + Blockly.ContextMenuRegistry.registry.register(addItem); +} + +function registerRemoveItem() { + const removeItem = { + displayText: 'Remove Item', + preconditionFn: function (scope) { + if ( + scope.focusedNode instanceof Blockly.BlockSvg && + !scope.focusedNode.isInFlyout && + scope.focusedNode.type === 'resizable_list' + ) { + if (scope.focusedNode.itemCount <= 1) { + return 'disabled'; + } + return 'enabled'; + } + return 'hidden'; + }, + callback: (scope) => { + scope.focusedNode.removeConnection(); + }, + id: 'remove_item', + weight: 110, + }; + Blockly.ContextMenuRegistry.registry.register(removeItem); +} + +// This function resets the code and output divs, shows the +// generated code from the workspace, and evals the code. +// In a real application, you probably shouldn't use `eval`. +const runCode = () => { + const code = javascriptGenerator.workspaceToCode(ws); + codeDiv.innerText = code; + + outputDiv.innerHTML = ''; + + // Wrap `eval` in a `try/catch` so that any runtime errors are + // logged to the console, instead of failing quietly. + try { + eval(code); + } catch (error) { + console.log(error); + } +}; + +// Load the initial state from storage and run the code. +load(ws); +runCode(); + +// Every time the workspace changes state, save the changes to storage. +ws.addChangeListener((e) => { + // UI events are things like scrolling, zooming, etc. + // No need to save after one of these. + if (e.isUiEvent) return; + save(ws); +}); + +// Whenever the workspace changes meaningfully, run the code again. +ws.addChangeListener((e) => { + // Don't run the code when the workspace finishes loading; we're + // already running it once when the application starts. + // Don't run the code during drags; we might have invalid state. + if ( + e.isUiEvent || + e.type == Blockly.Events.FINISHED_LOADING || + ws.isDragging() + ) { + return; + } + runCode(); +}); diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/mutators/list_mutator.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/mutators/list_mutator.js new file mode 100644 index 00000000000..9c8c2d6d41b --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/mutators/list_mutator.js @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +// The mutator mixin +export const LIST_MUTATOR = { + saveExtraState: function () { + return { + itemCount: this.itemCount, + }; + }, + + loadExtraState: function (state) { + this.itemCount = state['itemCount']; + this.updateShape(); + }, + + updateShape: function () { + // Add new inputs. + for (let i = 1; i < this.itemCount; i++) { + if (!this.getInput('ADD' + i)) { + this.appendValueInput('ADD' + i).setAriaLabelProvider( + () => 'value ' + (i + 1), + ); + } + } + // Remove deleted inputs. + for (let i = this.itemCount; this.getInput('ADD' + i); i++) { + this.removeInput('ADD' + i); + } + }, + + addConnection: function () { + this.setItemCount(this.itemCount + 1); + }, + + removeConnection: function () { + if (this.itemCount > 1) { + this.setItemCount(this.itemCount - 1); + } + }, + + setItemCount: function (newCount) { + // If there's no event group, start one so that the whole mutation is one event + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + + const oldCountState = JSON.stringify(this.saveExtraState()); + + this.itemCount = newCount; + this.updateShape(); + + const newCountState = JSON.stringify(this.saveExtraState()); + + // If the state has changed, create and fire a BLOCK_CHANGE event + if (newCountState !== oldCountState) { + const BlockChangeClass = Blockly.Events.get(Blockly.Events.BLOCK_CHANGE); + const blockChangeEvent = new BlockChangeClass( + this, + 'mutation', + null, + oldCountState, + newCountState, + ); + Blockly.Events.fire(blockChangeEvent); + } + + Blockly.Events.setGroup(existingGroup); + }, +}; diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/serialization.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/serialization.js new file mode 100644 index 00000000000..230d4d5bacf --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/serialization.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +const storageKey = 'customMutatorWorkspace'; + +/** + * Saves the state of the workspace to browser's local storage. + * @param {Blockly.Workspace} workspace Blockly workspace to save. + */ +export const save = function (workspace) { + const data = Blockly.serialization.workspaces.save(workspace); + window.localStorage?.setItem(storageKey, JSON.stringify(data)); +}; + +/** + * Loads saved state from local storage into the given workspace. + * @param {Blockly.Workspace} workspace Blockly workspace to load into. + */ +export const load = function (workspace) { + const data = window.localStorage?.getItem(storageKey); + if (!data) return; + + // Don't emit events during loading. + Blockly.Events.disable(); + Blockly.serialization.workspaces.load(JSON.parse(data), workspace, false); + Blockly.Events.enable(); +}; diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/src/toolbox.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/toolbox.js new file mode 100644 index 00000000000..dcf3a25b357 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/src/toolbox.js @@ -0,0 +1,633 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/* +This toolbox contains nearly every single built-in block that Blockly offers, +in addition to the custom block 'add_text' this sample app adds. +You probably don't need every single block, and should consider either rewriting +your toolbox from scratch, or carefully choosing whether you need each block +listed here. +*/ + +export const toolbox = { + kind: 'categoryToolbox', + contents: [ + { + kind: 'category', + name: 'Logic', + categorystyle: 'logic_category', + contents: [ + { + kind: 'block', + type: 'controls_if', + }, + { + kind: 'block', + type: 'logic_compare', + }, + { + kind: 'block', + type: 'logic_operation', + }, + { + kind: 'block', + type: 'logic_negate', + }, + { + kind: 'block', + type: 'logic_boolean', + }, + { + kind: 'block', + type: 'logic_null', + }, + { + kind: 'block', + type: 'logic_ternary', + }, + ], + }, + { + kind: 'category', + name: 'Loops', + categorystyle: 'loop_category', + contents: [ + { + kind: 'block', + type: 'controls_repeat_ext', + inputs: { + TIMES: { + shadow: { + type: 'math_number', + fields: { + NUM: 10, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'controls_whileUntil', + }, + { + kind: 'block', + type: 'controls_for', + inputs: { + FROM: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + TO: { + shadow: { + type: 'math_number', + fields: { + NUM: 10, + }, + }, + }, + BY: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'controls_forEach', + }, + { + kind: 'block', + type: 'controls_flow_statements', + }, + ], + }, + { + kind: 'category', + name: 'Math', + categorystyle: 'math_category', + contents: [ + { + kind: 'block', + type: 'math_number', + fields: { + NUM: 123, + }, + }, + { + kind: 'block', + type: 'math_arithmetic', + inputs: { + A: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + B: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_single', + inputs: { + NUM: { + shadow: { + type: 'math_number', + fields: { + NUM: 9, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_trig', + inputs: { + NUM: { + shadow: { + type: 'math_number', + fields: { + NUM: 45, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_constant', + }, + { + kind: 'block', + type: 'math_number_property', + inputs: { + NUMBER_TO_CHECK: { + shadow: { + type: 'math_number', + fields: { + NUM: 0, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_round', + fields: { + OP: 'ROUND', + }, + inputs: { + NUM: { + shadow: { + type: 'math_number', + fields: { + NUM: 3.1, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_on_list', + fields: { + OP: 'SUM', + }, + }, + { + kind: 'block', + type: 'math_modulo', + inputs: { + DIVIDEND: { + shadow: { + type: 'math_number', + fields: { + NUM: 64, + }, + }, + }, + DIVISOR: { + shadow: { + type: 'math_number', + fields: { + NUM: 10, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_constrain', + inputs: { + VALUE: { + shadow: { + type: 'math_number', + fields: { + NUM: 50, + }, + }, + }, + LOW: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + HIGH: { + shadow: { + type: 'math_number', + fields: { + NUM: 100, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_random_int', + inputs: { + FROM: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + TO: { + shadow: { + type: 'math_number', + fields: { + NUM: 100, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'math_random_float', + }, + { + kind: 'block', + type: 'math_atan2', + inputs: { + X: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + Y: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + }, + }, + ], + }, + { + kind: 'category', + name: 'Text', + categorystyle: 'text_category', + contents: [ + { + kind: 'block', + type: 'text', + }, + { + kind: 'block', + type: 'text_join', + }, + { + kind: 'block', + type: 'text_append', + inputs: { + TEXT: { + shadow: { + type: 'text', + fields: { + TEXT: '', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_length', + inputs: { + VALUE: { + shadow: { + type: 'text', + fields: { + TEXT: 'abc', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_isEmpty', + inputs: { + VALUE: { + shadow: { + type: 'text', + fields: { + TEXT: '', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_indexOf', + inputs: { + VALUE: { + block: { + type: 'variables_get', + }, + }, + FIND: { + shadow: { + type: 'text', + fields: { + TEXT: 'abc', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_charAt', + inputs: { + VALUE: { + block: { + type: 'variables_get', + }, + }, + }, + }, + { + kind: 'block', + type: 'text_getSubstring', + inputs: { + STRING: { + block: { + type: 'variables_get', + }, + }, + }, + }, + { + kind: 'block', + type: 'text_changeCase', + inputs: { + TEXT: { + shadow: { + type: 'text', + fields: { + TEXT: 'abc', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_trim', + inputs: { + TEXT: { + shadow: { + type: 'text', + fields: { + TEXT: 'abc', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_count', + inputs: { + SUB: { + shadow: { + type: 'text', + }, + }, + TEXT: { + shadow: { + type: 'text', + }, + }, + }, + }, + { + kind: 'block', + type: 'text_replace', + inputs: { + FROM: { + shadow: { + type: 'text', + }, + }, + TO: { + shadow: { + type: 'text', + }, + }, + TEXT: { + shadow: { + type: 'text', + }, + }, + }, + }, + { + kind: 'block', + type: 'text_reverse', + inputs: { + TEXT: { + shadow: { + type: 'text', + }, + }, + }, + }, + { + kind: 'block', + type: 'add_text', + inputs: { + TEXT: { + shadow: { + type: 'text', + fields: { + TEXT: 'abc', + }, + }, + }, + }, + }, + ], + }, + { + kind: 'category', + name: 'Lists', + categorystyle: 'list_category', + contents: [ + { + kind: 'block', + type: 'resizable_list', + }, + { + kind: 'block', + type: 'lists_create_with', + }, + { + kind: 'block', + type: 'lists_create_with', + }, + { + kind: 'block', + type: 'lists_repeat', + inputs: { + NUM: { + shadow: { + type: 'math_number', + fields: { + NUM: 5, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'lists_length', + }, + { + kind: 'block', + type: 'lists_isEmpty', + }, + { + kind: 'block', + type: 'lists_indexOf', + inputs: { + VALUE: { + block: { + type: 'variables_get', + }, + }, + }, + }, + { + kind: 'block', + type: 'lists_getIndex', + inputs: { + VALUE: { + block: { + type: 'variables_get', + }, + }, + }, + }, + { + kind: 'block', + type: 'lists_setIndex', + inputs: { + LIST: { + block: { + type: 'variables_get', + }, + }, + }, + }, + { + kind: 'block', + type: 'lists_getSublist', + inputs: { + LIST: { + block: { + type: 'variables_get', + }, + }, + }, + }, + { + kind: 'block', + type: 'lists_split', + inputs: { + DELIM: { + shadow: { + type: 'text', + fields: { + TEXT: ',', + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'lists_sort', + }, + { + kind: 'block', + type: 'lists_reverse', + }, + ], + }, + { + kind: 'sep', + }, + { + kind: 'category', + name: 'Variables', + categorystyle: 'variable_category', + custom: 'VARIABLE', + }, + { + kind: 'category', + name: 'Functions', + categorystyle: 'procedure_category', + custom: 'PROCEDURE', + }, + ], +}; diff --git a/packages/docs/docs/codelabs/custom-mutator/complete-code/webpack.config.js b/packages/docs/docs/codelabs/custom-mutator/complete-code/webpack.config.js new file mode 100644 index 00000000000..1a095648fd7 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/complete-code/webpack.config.js @@ -0,0 +1,59 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); + +// Base config that applies to either development or production mode. +const config = { + entry: './src/index.js', + output: { + // Compile the source files into a bundle. + filename: 'bundle.js', + path: path.resolve(__dirname, 'dist'), + clean: true, + }, + // Enable webpack-dev-server to get hot refresh of the app. + devServer: { + static: './build', + }, + module: { + rules: [ + { + // Load CSS files. They can be imported into JS files. + test: /\.css$/i, + use: ['style-loader', 'css-loader'], + }, + ], + }, + plugins: [ + // Generate the HTML index page based on our template. + // This will output the same index page with the bundle we + // created above added in a script tag. + new HtmlWebpackPlugin({ + template: 'src/index.html', + }), + ], +}; + +module.exports = (env, argv) => { + if (argv.mode === 'development') { + // Set the output path to the `build` directory + // so we don't clobber production builds. + config.output.path = path.resolve(__dirname, 'build'); + + // Generate source maps for our code for easier debugging. + // Not suitable for production builds. If you want source maps in + // production, choose a different one from https://webpack.js.org/configuration/devtool + config.devtool = 'eval-cheap-module-source-map'; + + // Include the source maps for Blockly for easier debugging Blockly code. + config.module.rules.push({ + test: /(blockly[/\\].*\.js)$/, + use: [require.resolve('source-map-loader')], + enforce: 'pre', + }); + + // Ignore spurious warnings from source-map-loader + // It can't find source maps for some Closure modules and that is expected + config.ignoreWarnings = [/Failed to parse source map.*blockly/]; + } + return config; +}; diff --git a/packages/docs/docs/codelabs/custom-mutator/create-the-mutator.mdx b/packages/docs/docs/codelabs/custom-mutator/create-the-mutator.mdx new file mode 100644 index 00000000000..4f8658e886d --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/create-the-mutator.mdx @@ -0,0 +1,115 @@ +--- +description: How to create and register a mutator that saves and loads extra state. +--- + +# Build a custom mutator + +## 4. Create the mutator + +Since mutators contain extra state, this extra state needs to be serialized and +deserialized with the rest of the state of the block. Although mutators can +vary greatly, any mutator requires [serialization hooks](https://docs.blockly.com/guides/create-custom-blocks/mutators/#serialization-hooks). + +### Create the mutator mixin object + +Create a new folder called `mutators`, then create a file in that folder called +`list_mutator.js`. In `list_mutator.js`, import Blockly, then create an empty object: + +```js +import * as Blockly from 'blockly/core'; + +// The mutator mixin +export const LIST_MUTATOR = { + // Add mutator functions here +}; +``` + +The `LIST_MUTATOR` will be the mixin object for the custom mutator. + +### Serialize + +The mutator must serialize the extra state of the block. This is accomplished by +implementing the function `saveExtraState()`. Since this codelab implements a +list mutator, you can use the same `saveExtraState()` code as the default +`lists_create_with` block. This function should return a JSON serializable value. + +Add the following code to the `LIST_MUTATOR` object: + +```js +export const LIST_MUTATOR = { + saveExtraState: function() { + return { + 'itemCount': this.itemCount, + }; + }, +}; +``` + +### Deserialize + +The mutator mixin must also load, or deserialize, the extra state of the block. +To deserialize, `loadExtraState()` reads in the `itemCount` value from the +`state` of the block. + +Add the following code to the `LIST_MUTATOR`, just after `saveExtraState()`: + +```js +loadExtraState: function(state) { + this.itemCount = state['itemCount']; +}, +``` + +### Register the mutator + +Now, register the mutator with [`Blockly.Extensions.registerMutator`](https://docs.blockly.com/reference/namespaces/Extensions/functions/registerMutator/). +This function requires a string name and a mixin object as parameters. It also +takes two optional parameters: +- A function which runs after the mixin functions are added to the block. +- A list of blocks to include in the mutator flyout, if you are using the default +mutator UI. This parameter is not applicable in this codelab, since you will be +creating a custom UI. + +In this case, the optional function is a great way to initialize the number of +list items in the mutator. Since the block starts with one input (see the +[block definition](/codelabs/custom-mutator/the-basics#create-the-custom-block)), you'll set a variable on the block +called `itemCount` to 1. + +Add the following import and registration to `index.js`: + +```js {1,9-13} +import {LIST_MUTATOR} from './mutators/list_mutator'; + +... + +// Register the blocks and generator with Blockly +Blockly.common.defineBlocks(blocks); +Blockly.common.defineBlocks(mutatorBlocks); +Object.assign(javascriptGenerator.forBlock, forBlock); +Blockly.Extensions.registerMutator( + 'list_mutator', + LIST_MUTATOR, + function() { this.itemCount = 1; } +); +``` + +### Add the mutator to the block + +Now that the mutator is registered, add it to the block definition in `list.js`: + +```js {14} +const resizableListBlock = { + type: 'resizable_list', + message0: 'resizable list with %1', + args0: [ + { + type: 'input_value', + name: 'ADD0', + } + ], + output: null, + style: "list_blocks", + tooltip: '', + helpUrl: '', + mutator: 'list_mutator', +}; +``` diff --git a/packages/docs/docs/codelabs/custom-mutator/custom-ui.mdx b/packages/docs/docs/codelabs/custom-mutator/custom-ui.mdx new file mode 100644 index 00000000000..6227d3e3875 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/custom-ui.mdx @@ -0,0 +1,175 @@ +--- +description: Choosing and implementing a UI for the mutator. +--- + +import Image from '@site/src/components/Image'; + +# Build a custom mutator + +## 6. Create a UI + +Now that you've created, registered, and set up the mutator, it's time to +consider how the user should add and remove inputs. + +### UI options + +The best UI for your mutator is highly dependent on what the mutator does, and +who the audience of your Blockly app is. There are a few options for your +mutator UI: + +1. Blockly default: Blockly mutators use a gear icon which opens up a mini workspace +in which users can drag blocks to form their desired mutator shape. See the +[mutators guide](https://docs.blockly.com/guides/create-custom-blocks/mutators/#compose-and-decompose) +for more information on how to implement the default UI. + +An if-else block that uses the default mutator, with the mutator workspace open + +2. Plus-minus: The [plus-minus plugin](https://raspberrypifoundation.github.io/blockly-samples/plugins/block-plus-minus/test/index.html) +adds + and - icons that the user can click to add or remove inputs. + +An if-else block with a plus icon beside 'if' and a minus icon beside 'else if' + +3. Dynamic connections: The [dynamic connections plugin](https://raspberrypifoundation.github.io/blockly-samples/plugins/block-dynamic-connection/test/index.html) +automatically adds and removes inputs based on user interaction. + + + +4. Custom UI: In addition to the above options, you can also implement your own +custom ways of interacting with the mutator. + +### Custom UI with context menus + +This codelab will implement a custom UI using Blockly's context menus. + +See the [context menu guide](https://docs.blockly.com/guides/configure/context-menus/) +for general information about context menus in Blockly. For a walkthrough of +adding custom context menu items, see the [context menu codelab](https://docs.blockly.com/codelabs/context-menu-option/codelab-overview/). + +Any mutator UI option has benefits and drawbacks. Using context menus +to define mutator behavior is a helpful example to see how custom UI behavior +can work. However, options in the context menu are not very discoverable to +users, so this approach is generally *not* recommended unless you have a +specific reason to use context menus. + +### Add callbacks + +Two more functions complete the `LIST_MUTATOR` object: `addConnection()` +and `removeConnection()`. These functions can be called to add or remove an input. +They simply update the `itemCount` and call `updateShape()` to update the block. + +Add the following code to the `LIST_MUTATOR` object: + +```js +export const LIST_MUTATOR = { + ... + + addConnection: function() { + this.itemCount++; + this.updateShape(); + }, + removeConnection: function() { + if (this.itemCount > 1) { + this.itemCount--; + this.updateShape(); + } + }, +}; +``` + +### Create the "Add Item" context menu option + +First, create the context menu option that adds an input to the list. For the +precondition, check if this is the right type of block. This will determine +whether the "Add Item" option should be enabled or hidden. + +Since the mutator is "mixed in" to the block definition, you can call +`addConnection()` directly on the block for the callback function. + +Add the following code to `index.js`: + +```js +function registerAddItem() { + const addItem = { + displayText: 'Add Item', + preconditionFn: function (scope) { + if ( + scope.focusedNode instanceof Blockly.BlockSvg && + !scope.focusedNode.isInFlyout && + scope.focusedNode.type === 'resizable_list' + ) { + return 'enabled'; + } + return 'hidden'; + }, + callback: (scope) => { scope.focusedNode.addConnection(); }, + id: 'add_item', + weight: 100, + }; + Blockly.ContextMenuRegistry.registry.register(addItem); +} +``` + +### Create the "Remove Item" context menu option + +The "Remove Item" option is largely the same as the "Add Item" option. However, +it also includes a guard to ensure that the total number of items will not be +less than one. If there is only one input, the "Remove Item" option will be +greyed out: + +Custom list block with one input and an open context menu that shows a greyed out 'Remove Item' option + +Add the following code to `index.js`: + +```js +function registerRemoveItem() { + const removeItem = { + displayText: 'Remove Item', + preconditionFn: function (scope) { + if ( + scope.focusedNode instanceof Blockly.BlockSvg && + !scope.focusedNode.isInFlyout && + scope.focusedNode.type === 'resizable_list' + ) { + if(scope.focusedNode.itemCount <= 1) { + return 'disabled'; + } + return 'enabled'; + } + return 'hidden'; + }, + callback: (scope) => { scope.focusedNode.removeConnection(); }, + id: 'remove_item', + weight: 110, + }; + Blockly.ContextMenuRegistry.registry.register(removeItem); +} +``` + +### Call the register functions + +Finally, call both `registerAddItem()` and `registerRemoveItem()` in `index.js`, +just after the block and mutator registrations: + +```js {} +registerAddItem(); +registerRemoveItem(); +``` + diff --git a/packages/docs/docs/codelabs/custom-mutator/event-handling.mdx b/packages/docs/docs/codelabs/custom-mutator/event-handling.mdx new file mode 100644 index 00000000000..27021c97f94 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/event-handling.mdx @@ -0,0 +1,150 @@ +--- +description: Adding event firing and event groups to the mutator. +--- + +# Build a custom mutator + +## 8. Add event handling + +Currently, the code window of the app does not update when an input is added or +removed. + +The best way to fix this issue is to fire an event when the block is mutated. +There are many [Blockly event options](https://docs.blockly.com/reference/namespaces/Events/#classes) +to choose from. In this scenario, [`BlockChange`](https://docs.blockly.com/reference/namespaces/Events/classes/BlockChange/) +is the best choice. + +### Add a wrapper function + +Since `updateShape()` is called from `loadExtraState()`, it is not the ideal +location for your new event. Adding the event to `updateShape()` would mean that +a `BlockChange` event is (incorrectly) fired every time the workspace is loaded. + +Instead, build a wrapper function to update the `itemCount`, the shape, and fire +an event when necessary. Add the following function to the +`LIST_MUTATOR` object. + +```js +setItemCount: function(newCount) { + // Keep track of the old state for creating the event + const oldCountState = JSON.stringify(this.saveExtraState()); + + // Update the count and shape + this.itemCount = newCount; + this.updateShape(); + + // Keep track of the new state for creating the event + const newCountState = JSON.stringify(this.saveExtraState()); + + // If the state has changed, create and fire a BLOCK_CHANGE event + if (newCountState !== oldCountState) { + const BlockChangeClass = Blockly.Events.get(Blockly.Events.BLOCK_CHANGE); + const blockChangeEvent = new BlockChangeClass( + this, + 'mutation', + null, + oldCountState, + newCountState + ); + Blockly.Events.fire(blockChangeEvent); + } +}, +``` + +Note that this function keeps track of the JSON state of both the old and new +state, so that the event can fire with information from both states. + +### Update `addConnection` and `removeConnection` + +Update the implementation of the `addConnection()` and `removeConnection()` +functions in the `LIST_MUTATOR` to use the new `setItemCount()` helper: + +```js +export const LIST_MUTATOR = { + ... + + addConnection: function() { + this.setItemCount(this.itemCount + 1); + }, + removeConnection: function() { + if (this.itemCount > 1) { + this.setItemCount(this.itemCount - 1); + } + }, + + ... +}; +``` + +### Test it + +Test the new behavior. Run `npm run start` in your app's folder or refresh the +running app. When you add or remove connections to the list, your code window +now should update accordingly. + +However, there's still a problem. Try the following sequence: +1. Add a block to the last input in the list. +1. Remove an input. +1. Press Ctrl+z (or Cmd+z on Mac) to undo the input removal. + +You'll notice that it takes multiple undo actions to get back to the state the +blocks were in before removing the last input. + +### Set the event group + +The reason for this issue is that there are multiple events involved in removing +an input. The mutation includes the event you fire in `setItemCount()` *and* +other events that Blockly fires as part of the mutation process. For instance, +after the last input is removed, the block that was attached to that input is +moved away from the list block. This fires a `BlockMove` event. + +Effectively, all of these events are part of the same process: the block +mutation. The solution for this is grouping all of the mutation events together +in one event group. Setting an event group at the beginning of the mutation +means that the whole mutation will be one action in the undo/redo stream. + +Update `setItemCount()` by adding the highlighted lines of code: + +```js {2,3,4,26} +setItemCount: function(newCount) { + // If there's no event group, start one so the whole mutation is one event + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + + const oldCountState = JSON.stringify(this.saveExtraState()); + + this.itemCount = newCount; + this.updateShape(); + + const newCountState = JSON.stringify(this.saveExtraState()); + + // If the state has changed, create and fire a BLOCK_CHANGE event + if (newCountState !== oldCountState) { + const BlockChangeClass = Blockly.Events.get(Blockly.Events.BLOCK_CHANGE); + const blockChangeEvent = new BlockChangeClass( + this, + 'mutation', + null, + oldCountState, + newCountState + ); + Blockly.Events.fire(blockChangeEvent); + } + + Blockly.Events.setGroup(existingGroup); +}, +``` + +If something else outside of the mutation has already grouped this mutation into +an event group, respect that group. In code, this means that if `getGroup()` +returns a value, do not set a new group. + +### Test it (again) + +Test the event group by running `npm run start` or refreshing the app. Follow +the same sequence as before: +1. Add a block to the last input in the list. +1. Remove an input. +1. Press Ctrl+z (or Cmd+z on Mac) to undo the input removal. + +The undo should now fully undo the entire action. diff --git a/packages/docs/docs/codelabs/custom-mutator/setup.mdx b/packages/docs/docs/codelabs/custom-mutator/setup.mdx new file mode 100644 index 00000000000..0d7d79fe649 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/setup.mdx @@ -0,0 +1,37 @@ +--- +description: Setting up the "Build a custom mutator" codelab. +--- + +import Image from '@site/src/components/Image'; + +# Build a custom mutator + +## 2. Setup + +This codelab will demonstrate how to add code to the Blockly sample app to create a custom mutator. + +### The application + +Use the [`npx @blockly/create-package app`](https://www.npmjs.com/package/@blockly/create-package) command to create a standalone application that contains a sample setup of Blockly, including custom blocks and a display of the generated code and output. + +1. Run `npx @blockly/create-package app custom-mutator-codelab`. This will create a blockly application in the folder `custom-mutator-codelab`. +1. `cd` into the new directory: `cd custom-mutator-codelab`. +1. Run `npm start` to start the server and run the sample application. +1. The sample app will automatically run in the browser window that opens. + +### Change the storage key + +Before setting up the rest of the application, change the storage key used for this codelab application. This will ensure that the workspace is saved in its own storage, separate from the regular sample app, so that it doesn't interfere with other demos. + +In `serialization.js`, change the value of `storageKey` to some unique string. `customMutatorWorkspace` will work: + +```js +// Use a unique storage key for this codelab +const storageKey = 'customMutatorWorkspace'; +``` + +### Explore the starter code + +Take a moment to explore the code you just created in the `custom-mutator-codelab` folder. If you'd like a more in-depth explanation of all of the code in the sample app, check out the [getting started codelab](/codelabs/getting-started/codelab-overview) which goes through creating this sample app from scratch. + +For a briefer outline of the code, visit the [sample app overview](/guides/get-started/sample-app-overview/) page for an overview of the included files and tooling. diff --git a/packages/docs/docs/codelabs/custom-mutator/summary.mdx b/packages/docs/docs/codelabs/custom-mutator/summary.mdx new file mode 100644 index 00000000000..8fa374cab42 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/summary.mdx @@ -0,0 +1,28 @@ +--- +pagination_next: null +description: Summary of the build a custom mutator codelab. +--- + +# Build a custom mutator + +## 9. Summary + +And with that, you've finished building a custom mutator! + +In this codelab, you learned: +- How to create a mutator in Blockly +- How to serialize and deserialize the mutator's extra state +- What UI options exist for Blockly mutators +- How to create a custom UI for a mutator +- How to fire events during a mutation +- When and how to use event groups + +## Resources + +- [Mutator guides](https://docs.blockly.com/guides/create-custom-blocks/mutators/) +- [Plus-minus plugin demo](https://raspberrypifoundation.github.io/blockly-samples/plugins/block-plus-minus/test/index.html) +- [Dynamic connections plugin demo](https://raspberrypifoundation.github.io/blockly-samples/plugins/block-dynamic-connection/test/index.html) + +If you're implementing a custom mutator UI, it may also be useful to view the +source code for the [plus-minus plugin](https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/block-plus-minus) +and the [dynamic connections plugin](https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/block-dynamic-connection). diff --git a/packages/docs/docs/codelabs/custom-mutator/the-basics.mdx b/packages/docs/docs/codelabs/custom-mutator/the-basics.mdx new file mode 100644 index 00000000000..7c8c82f9ffe --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/the-basics.mdx @@ -0,0 +1,132 @@ +--- +description: What a mutator is, and setup of the custom block. +--- + +import Image from '@site/src/components/Image'; + +# Build a custom mutator + +## 3. Mutator overview and block creation + +When a block has extra state that can't be captured by its fields, it needs a +mutator. Common examples are if/else and list blocks, which need to keep +track of how many inputs they have. + +Mutators are a type of [mixin](https://docs.blockly.com/guides/create-custom-blocks/define/extensions/#mixins), +meaning that they add custom functions to a block. The functions that a mutator +includes depends on the desired behavior of the mutator. At the very least, +mutators all need [serialization hooks](https://docs.blockly.com/guides/create-custom-blocks/mutators/#serialization-hooks) +in order to save and load the extra state. This codelab uses a custom mutator UI, +but mutators that implement the default UI must also include `compose` and +`decompose` functions. For more on mutators and the default mutator UI, visit +the [mutators guide](https://docs.blockly.com/guides/create-custom-blocks/mutators/). + +In this codelab, you will be making a custom list block mutator. This starts with +creating the block. + +### Create the custom block + +In the `blocks` folder, create a new file called `list.js`. Add the following +code to create the custom block definition: + +```js +import * as Blockly from 'blockly/core'; + +const resizableListBlock = { + type: 'resizable_list', + message0: 'resizable list with %1', + args0: [ + { + type: 'input_value', + name: 'ADD0', + } + ], + output: null, + style: "list_blocks", + tooltip: '', + helpUrl: '', +}; + +// Create the block definitions +export const mutatorBlocks = Blockly.common.createBlockDefinitionsFromJsonArray( + [resizableListBlock] +); +``` + +For more on block definitions, review the [block definition guides](https://docs.blockly.com/guides/create-custom-blocks/define/block-definitions/). + +### Use the new block + +Now, register the new block and add it to the toolbox in order to use it in the +workspace. In `index.js`, import the block definition, then register the block where the other +custom block and generator are registered: + +```js {1,7} +import {mutatorBlocks} from './blocks/list'; // Add this import to index.js + +... + +// Register the blocks and generator with Blockly +Blockly.common.defineBlocks(blocks); +Blockly.common.defineBlocks(mutatorBlocks); // Add this line to index.js +Object.assign(javascriptGenerator.forBlock, forBlock); +``` + +The toolbox should also contain the new block, so that it can be dragged into +the workspace and used. Find the 'Lists' category in `toolbox.js`, then add an +entry for the new block: + +```js {6-9} +{ + kind: 'category', + name: 'Lists', + categorystyle: 'list_category', + contents: [ + { + kind: 'block', + type: 'resizable_list', + }, + { + kind: 'block', + type: 'lists_create_with', + }, + ... +} +``` + +### Add a stand-in generator + +If you start the app now by running `npm run start` in the codelab folder, then +attempt to drag the new block into the workspace, you'll likely get an error: +`JavaScript generator does not know how to generate code for block type "resizable_list".` + +This error occurs because sample app's code window displays the code for all +blocks on the workspace, but this new block does not yet have a +[block-code generator](https://docs.blockly.com/guides/create-custom-blocks/code-generation/block-code/). +Without the block-code generator, Blockly's JavaScript generator can't generate +the code for the new block. + +For now, insert a placeholder generator to avoid this error. After you add the +mutator functions in the next few steps, you will circle back and implement this +generator code. + +Add the following code to `javascript.js`, which is in the `generators` folder: + +```js +forBlock['resizable_list'] = function (block, generator) { + // `resizable_list` has an output, so its generator must return a [code, order] tuple + return ['', Order.ATOMIC]; +} +``` + +### Test it + +Start the app by first navigating to the codelab folder in the terminal, then +run `npm run start`. Open the list category to see the new block. It now can be +dragged into the workspace, but there is not a way to add or remove inputs yet. + +The new list block seen in the 'List' category of the toolbox flyout diff --git a/packages/docs/docs/codelabs/custom-mutator/update-shape.mdx b/packages/docs/docs/codelabs/custom-mutator/update-shape.mdx new file mode 100644 index 00000000000..44c2fd09e24 --- /dev/null +++ b/packages/docs/docs/codelabs/custom-mutator/update-shape.mdx @@ -0,0 +1,108 @@ +--- +description: How to update the shape of a mutator block. +--- + +import Image from '@site/src/components/Image'; + +# Build a custom mutator + +## 5. Update the block shape + +The `resizable_list` needs to add or remove inputs depending on the number +of items in the list. This means that the shape of the block should be updated +based on the `itemCount`. + +A block with `itemCount==1` should have just one input: +A list block with one input + +A block with `itemCount==2` should have two inputs: +A list block with two inputs + +And so on. The block implemented in this codelab will always have a minimum of +one input. + +Note that blocks with mutators don't always *need* to change their shape based +on the extra state. In this case, updating the shape is necessary. + +### Adding an input + +The same `updateShape()` function will be responsible for updating the shape +regardless of whether an input has been added or removed. First, focus on adding +a new input. Add the following function and code to the `LIST_MUTATOR`: + +```js +export const LIST_MUTATOR = { + ... + + updateShape: function() { + // Add new inputs. + for (let i = 1; i < this.itemCount; i++) { + if (!this.getInput('ADD' + i)) { + this.appendValueInput('ADD' + i).setAriaLabelProvider( + () => 'value ' + (i + 1) + ); + } + } + }, +}; +``` + +The `updateShape()` function adds `itemCount - 1` inputs, since the block +starts with 1 input. This function also sets the aria label for each input +manually, so that screenreaders distinguish clearly between each input. + +Finally, note that the inputs are named numerically. The input that you defined +in the block definition is called `ADD0`, so subsequent blocks are named `ADD1`, +`ADD2`, `ADD3`, etc. + +### Removing an input + +To remove an input, remove the appropriate number of inputs from the end of the +list. Finish the `updateShape()` function with the code below: + +```js +export const LIST_MUTATOR = { + ... + + updateShape: function() { + // Add new inputs. + for (let i = 1; i < this.itemCount; i++) { + if (!this.getInput('ADD' + i)) { + this.appendValueInput('ADD' + i).setAriaLabelProvider( + () => 'value ' + (i + 1) + ); + } + } + // Remove deleted inputs. + for (let i = this.itemCount; this.getInput('ADD' + i); i++) { + this.removeInput('ADD' + i); + } + }, +}; +``` + +### Updating after load +Since this list block will add or remove inputs based on the value of +`itemCount`, it is important to update the physical shape after deserialization. +Add a call to `updateShape()` in `loadExtraState()`: + +```js {3} +loadExtraState: function(state) { + this.itemCount = state['itemCount']; + this.updateShape(); +}, +``` + +### Next steps + +Although `updateShape()` is complete, only the deserializer calls it. The next +step of creating a mutator is considering how the user will interact with the +mutator in order to modify the `extraState`. diff --git a/packages/docs/docs/codelabs/index.mdx b/packages/docs/docs/codelabs/index.mdx index 8269dc98fa6..776e6ad7d47 100644 --- a/packages/docs/docs/codelabs/index.mdx +++ b/packages/docs/docs/codelabs/index.mdx @@ -90,4 +90,14 @@ Blockly Codelabs provide a guided, tutorial, hands-on coding experience of Block srcDark="/images/codelabs/card_thumbnails_custom-renderers-dark.png" /> + + Two 'resizable list with' blocks: the first with two filled inputs, the second with the same two filled inputs and an empty third input + diff --git a/packages/docs/sidebars.js b/packages/docs/sidebars.js index d99433492d1..2f58dce47f3 100644 --- a/packages/docs/sidebars.js +++ b/packages/docs/sidebars.js @@ -428,6 +428,57 @@ const sidebars = { }, ], }, + { + type: 'category', + label: 'Build a custom mutator', + items: [ + { + type: 'doc', + label: '1. Codelab-overview', + id: 'codelabs/custom-mutator/codelab-overview', + }, + { + type: 'doc', + label: '2. Setup', + id: 'codelabs/custom-mutator/setup', + }, + { + type: 'doc', + label: '3. Mutator overview', + id: 'codelabs/custom-mutator/the-basics', + }, + { + type: 'doc', + label: '4. Create the mutator', + id: 'codelabs/custom-mutator/create-the-mutator', + }, + { + type: 'doc', + label: '5. Update the block shape', + id: 'codelabs/custom-mutator/update-shape', + }, + { + type: 'doc', + label: '6. Create a UI', + id: 'codelabs/custom-mutator/custom-ui', + }, + { + type: 'doc', + label: '7. Generate code', + id: 'codelabs/custom-mutator/code-generation', + }, + { + type: 'doc', + label: '8. Event handling', + id: 'codelabs/custom-mutator/event-handling', + }, + { + type: 'doc', + label: '9. Summary', + id: 'codelabs/custom-mutator/summary', + }, + ], + }, ], guidesSidebar: [ { diff --git a/packages/docs/static/images/codelabs/card_thumbnails_custom-mutator-dark.png b/packages/docs/static/images/codelabs/card_thumbnails_custom-mutator-dark.png new file mode 100644 index 00000000000..9509bc97171 Binary files /dev/null and b/packages/docs/static/images/codelabs/card_thumbnails_custom-mutator-dark.png differ diff --git a/packages/docs/static/images/codelabs/card_thumbnails_custom-mutator.png b/packages/docs/static/images/codelabs/card_thumbnails_custom-mutator.png new file mode 100644 index 00000000000..4b9a3c8b675 Binary files /dev/null and b/packages/docs/static/images/codelabs/card_thumbnails_custom-mutator.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/block_in_flyout.png b/packages/docs/static/images/codelabs/custom-mutator/block_in_flyout.png new file mode 100644 index 00000000000..0c30f9dee57 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/block_in_flyout.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/code_block.png b/packages/docs/static/images/codelabs/custom-mutator/code_block.png new file mode 100644 index 00000000000..089a894d5e7 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/code_block.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/finished_block.png b/packages/docs/static/images/codelabs/custom-mutator/finished_block.png new file mode 100644 index 00000000000..76171c3a482 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/finished_block.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/greyed_out_context_option.png b/packages/docs/static/images/codelabs/custom-mutator/greyed_out_context_option.png new file mode 100644 index 00000000000..f4512a997f0 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/greyed_out_context_option.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/if_else_default.png b/packages/docs/static/images/codelabs/custom-mutator/if_else_default.png new file mode 100644 index 00000000000..354a589a029 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/if_else_default.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/if_else_dynamic.gif b/packages/docs/static/images/codelabs/custom-mutator/if_else_dynamic.gif new file mode 100644 index 00000000000..dcd73feb57e Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/if_else_dynamic.gif differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/if_else_plus_minus.png b/packages/docs/static/images/codelabs/custom-mutator/if_else_plus_minus.png new file mode 100644 index 00000000000..b0978dd49b6 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/if_else_plus_minus.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/list_block_one_input.png b/packages/docs/static/images/codelabs/custom-mutator/list_block_one_input.png new file mode 100644 index 00000000000..758f27bf2e6 Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/list_block_one_input.png differ diff --git a/packages/docs/static/images/codelabs/custom-mutator/list_block_two_inputs.png b/packages/docs/static/images/codelabs/custom-mutator/list_block_two_inputs.png new file mode 100644 index 00000000000..8a5feb9c92f Binary files /dev/null and b/packages/docs/static/images/codelabs/custom-mutator/list_block_two_inputs.png differ