|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright 2025 Porpoiseful LLC |
| 4 | + * |
| 5 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | + * you may not use this file except in compliance with the License. |
| 7 | + * You may obtain a copy of the License at |
| 8 | + * |
| 9 | + * https://www.apache.org/licenses/LICENSE-2.0 |
| 10 | + * |
| 11 | + * Unless required by applicable law or agreed to in writing, software |
| 12 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | + * See the License for the specific language governing permissions and |
| 15 | + * limitations under the License. |
| 16 | + */ |
| 17 | + |
| 18 | +/** |
| 19 | + * @fileoverview Mutator for steps. |
| 20 | + * @author alan@porpoiseful.com (Alan Smith) |
| 21 | + */ |
| 22 | +import * as Blockly from 'blockly'; |
| 23 | +import { MRC_STYLE_CLASS_BLOCKS } from '../themes/styles'; |
| 24 | + |
| 25 | +export const STEP_CONTAINER_BLOCK_NAME = 'mrc_step_container'; |
| 26 | +const STEP_ITEM_BLOCK_NAME = 'mrc_step_item'; |
| 27 | + |
| 28 | +export const setup = function () { |
| 29 | + Blockly.Blocks[STEP_CONTAINER_BLOCK_NAME] = STEP_CONTAINER; |
| 30 | + Blockly.Blocks[STEP_ITEM_BLOCK_NAME] = STEP_ITEM; |
| 31 | +}; |
| 32 | + |
| 33 | +// The step container block. |
| 34 | + |
| 35 | +const INPUT_STACK = 'STACK'; |
| 36 | + |
| 37 | +export type StepContainerBlock = StepContainerMixin & Blockly.BlockSvg; |
| 38 | +interface StepContainerMixin extends StepContainerMixinType { } |
| 39 | +type StepContainerMixinType = typeof STEP_CONTAINER; |
| 40 | + |
| 41 | +const STEP_CONTAINER = { |
| 42 | + init: function (this: StepContainerBlock) { |
| 43 | + this.appendDummyInput().appendField(Blockly.Msg.STEPS); |
| 44 | + this.appendStatementInput(INPUT_STACK); |
| 45 | + this.setStyle(MRC_STYLE_CLASS_BLOCKS); |
| 46 | + this.contextMenu = false; |
| 47 | + }, |
| 48 | + getStepItemBlocks: function (this: StepContainerBlock): StepItemBlock[] { |
| 49 | + const stepItemBlocks: StepItemBlock[] = []; |
| 50 | + let block = this.getInputTargetBlock(INPUT_STACK); |
| 51 | + while (block && !block.isInsertionMarker()) { |
| 52 | + if (block.type !== STEP_ITEM_BLOCK_NAME) { |
| 53 | + throw new Error('getItemNames: block.type should be ' + STEP_ITEM_BLOCK_NAME); |
| 54 | + } |
| 55 | + stepItemBlocks.push(block as StepItemBlock); |
| 56 | + block = block.nextConnection && block.nextConnection.targetBlock(); |
| 57 | + } |
| 58 | + return stepItemBlocks; |
| 59 | + }, |
| 60 | +}; |
| 61 | + |
| 62 | +// The step item block. |
| 63 | + |
| 64 | +const FIELD_NAME = 'NAME'; |
| 65 | + |
| 66 | +export type StepItemBlock = StepItemMixin & Blockly.BlockSvg; |
| 67 | +interface StepItemMixin extends StepItemMixinType { |
| 68 | + originalName: string, |
| 69 | + conditionShadowState?: any; |
| 70 | + conditionTargetConnection?: Blockly.Connection | null; |
| 71 | + statementTargetConnection?: Blockly.Connection | null; |
| 72 | +} |
| 73 | + |
| 74 | +type StepItemMixinType = typeof STEP_ITEM; |
| 75 | + |
| 76 | +const STEP_ITEM = { |
| 77 | + init: function (this: StepItemBlock) { |
| 78 | + this.appendDummyInput() |
| 79 | + .appendField(new Blockly.FieldTextInput(''), FIELD_NAME); |
| 80 | + this.setPreviousStatement(true); |
| 81 | + this.setNextStatement(true); |
| 82 | + this.setStyle(MRC_STYLE_CLASS_BLOCKS); |
| 83 | + this.originalName = ''; |
| 84 | + this.contextMenu = false; |
| 85 | + }, |
| 86 | + makeNameLegal: function (this: StepItemBlock): void { |
| 87 | + const rootBlock: Blockly.Block | null = this.getRootBlock(); |
| 88 | + if (rootBlock) { |
| 89 | + const otherNames: string[] = [] |
| 90 | + rootBlock!.getDescendants(true)?.forEach(itemBlock => { |
| 91 | + if (itemBlock != this) { |
| 92 | + otherNames.push(itemBlock.getFieldValue(FIELD_NAME)); |
| 93 | + } |
| 94 | + }); |
| 95 | + let currentName = this.getFieldValue(FIELD_NAME); |
| 96 | + while (otherNames.includes(currentName)) { |
| 97 | + // Check if currentName ends with a number |
| 98 | + const match = currentName.match(/^(.*?)(\d+)$/); |
| 99 | + if (match) { |
| 100 | + // If it ends with a number, increment it |
| 101 | + const baseName = match[1]; |
| 102 | + const number = parseInt(match[2], 10); |
| 103 | + currentName = baseName + (number + 1); |
| 104 | + } else { |
| 105 | + // If it doesn't end with a number, append 2 |
| 106 | + currentName = currentName + '2'; |
| 107 | + } |
| 108 | + } |
| 109 | + this.setFieldValue(currentName, FIELD_NAME); |
| 110 | + updateMutatorFlyout(this.workspace); |
| 111 | + } |
| 112 | + }, |
| 113 | + getName: function (this: StepItemBlock): string { |
| 114 | + return this.getFieldValue(FIELD_NAME); |
| 115 | + }, |
| 116 | + getOriginalName: function (this: StepItemBlock): string { |
| 117 | + return this.originalName; |
| 118 | + }, |
| 119 | + setOriginalName: function (this: StepItemBlock, originalName: string): void { |
| 120 | + this.originalName = originalName; |
| 121 | + }, |
| 122 | +} |
| 123 | + |
| 124 | +/** |
| 125 | + * Updates the mutator's flyout so that it contains a single step item block |
| 126 | + * whose name is not a duplicate of an existing step item. |
| 127 | + * |
| 128 | + * @param workspace The mutator's workspace. This workspace's flyout is what is being updated. |
| 129 | + */ |
| 130 | +function updateMutatorFlyout(workspace: Blockly.WorkspaceSvg) { |
| 131 | + const usedNames: string[] = []; |
| 132 | + workspace.getBlocksByType(STEP_ITEM_BLOCK_NAME, false).forEach(block => { |
| 133 | + usedNames.push(block.getFieldValue(FIELD_NAME)); |
| 134 | + }); |
| 135 | + |
| 136 | + // Find the first unused number starting from 0 |
| 137 | + let counter = 0; |
| 138 | + let uniqueName = counter.toString(); |
| 139 | + while (usedNames.includes(uniqueName)) { |
| 140 | + counter++; |
| 141 | + uniqueName = counter.toString(); |
| 142 | + } |
| 143 | + |
| 144 | + const jsonBlock = { |
| 145 | + kind: 'block', |
| 146 | + type: STEP_ITEM_BLOCK_NAME, |
| 147 | + fields: { |
| 148 | + NAME: uniqueName, |
| 149 | + }, |
| 150 | + }; |
| 151 | + |
| 152 | + workspace.updateToolbox({ contents: [jsonBlock] }); |
| 153 | +} |
| 154 | + |
| 155 | +/** |
| 156 | + * The blockly event listener function for the mutator's workspace. |
| 157 | + */ |
| 158 | +function onChange(mutatorWorkspace: Blockly.Workspace, event: Blockly.Events.Abstract) { |
| 159 | + if (event.type === Blockly.Events.BLOCK_MOVE) { |
| 160 | + const blockMoveEvent = event as Blockly.Events.BlockMove; |
| 161 | + const reason: string[] = blockMoveEvent.reason ?? []; |
| 162 | + if (reason.includes('connect') && blockMoveEvent.blockId) { |
| 163 | + const block = mutatorWorkspace.getBlockById(blockMoveEvent.blockId); |
| 164 | + if (block && block.type === STEP_ITEM_BLOCK_NAME) { |
| 165 | + (block as StepItemBlock).makeNameLegal(); |
| 166 | + } |
| 167 | + } |
| 168 | + } else if (event.type === Blockly.Events.BLOCK_CHANGE) { |
| 169 | + const blockChangeEvent = event as Blockly.Events.BlockChange; |
| 170 | + if (blockChangeEvent.blockId) { |
| 171 | + const block = mutatorWorkspace.getBlockById(blockChangeEvent.blockId); |
| 172 | + if (block && block.type === STEP_ITEM_BLOCK_NAME) { |
| 173 | + (block as StepItemBlock).makeNameLegal(); |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +/** |
| 180 | + * Called for mrc_steps blocks when their mutator opesn. |
| 181 | + * Triggers a flyout update and adds an event listener to the mutator workspace. |
| 182 | + * |
| 183 | + * @param block The block whose mutator is open. |
| 184 | + */ |
| 185 | +export function onMutatorOpen(block: Blockly.BlockSvg) { |
| 186 | + const mutatorIcon = block.getIcon(Blockly.icons.MutatorIcon.TYPE) as Blockly.icons.MutatorIcon; |
| 187 | + const mutatorWorkspace = mutatorIcon.getWorkspace()!; |
| 188 | + updateMutatorFlyout(mutatorWorkspace); |
| 189 | + mutatorWorkspace.addChangeListener(event => onChange(mutatorWorkspace, event)); |
| 190 | +} |
| 191 | + |
| 192 | +/** |
| 193 | + * Returns the MutatorIcon for the given block. |
| 194 | + */ |
| 195 | +export function getMutatorIcon(block: Blockly.BlockSvg): Blockly.icons.MutatorIcon { |
| 196 | + return new Blockly.icons.MutatorIcon([STEP_ITEM_BLOCK_NAME], block); |
| 197 | +} |
| 198 | + |
| 199 | +export function createMutatorBlocks(workspace: Blockly.Workspace, stepNames: string[]): StepContainerBlock { |
| 200 | + // First create the container block. |
| 201 | + const containerBlock = workspace.newBlock(STEP_CONTAINER_BLOCK_NAME) as StepContainerBlock; |
| 202 | + containerBlock.initSvg(); |
| 203 | + |
| 204 | + // Then add one step item block for each step. |
| 205 | + let connection = containerBlock!.getInput(INPUT_STACK)!.connection; |
| 206 | + for (const stepName of stepNames) { |
| 207 | + const itemBlock = workspace.newBlock(STEP_ITEM_BLOCK_NAME) as StepItemBlock; |
| 208 | + itemBlock.initSvg(); |
| 209 | + itemBlock.setFieldValue(stepName, FIELD_NAME); |
| 210 | + itemBlock.originalName = stepName; |
| 211 | + connection!.connect(itemBlock.previousConnection!); |
| 212 | + connection = itemBlock.nextConnection; |
| 213 | + } |
| 214 | + return containerBlock; |
| 215 | +} |
0 commit comments