From 19f238ce4ae2efbe8b1e712296277bf9c815cc3b Mon Sep 17 00:00:00 2001 From: JeremyGamer13 <69337718+JeremyGamer13@users.noreply.github.com> Date: Sat, 16 Sep 2023 01:52:03 -0600 Subject: [PATCH] use new scratch addons' middle click --- .../middle-click-popup/BlockRenderer.js | 353 +++++ .../middle-click-popup/BlockTypeInfo.js | 565 +++++++ .../middle-click-popup/WorkspaceQuerier.js | 1342 +++++++++++++++++ .../middle-click-popup/_manifest_entry.js | 26 + .../addons/middle-click-popup/module.js | 37 + .../addons/middle-click-popup/userscript.js | 858 +++++------ .../addons/middle-click-popup/userstyle.css | 202 +-- 7 files changed, 2831 insertions(+), 552 deletions(-) create mode 100644 src/addons/addons/middle-click-popup/BlockRenderer.js create mode 100644 src/addons/addons/middle-click-popup/BlockTypeInfo.js create mode 100644 src/addons/addons/middle-click-popup/WorkspaceQuerier.js create mode 100644 src/addons/addons/middle-click-popup/module.js diff --git a/src/addons/addons/middle-click-popup/BlockRenderer.js b/src/addons/addons/middle-click-popup/BlockRenderer.js new file mode 100644 index 00000000000..b968724076e --- /dev/null +++ b/src/addons/addons/middle-click-popup/BlockRenderer.js @@ -0,0 +1,353 @@ +/** + * @file Contains the code for rendering the blocks in the middle click dropdown. + * Main function is {@link renderBlock} which takes in a block and returns a renderer SVG element. + * @author Tacodiva + */ + +import { BlockShape, BlockInstance, BlockInputEnum, BlockInputBoolean, BlockInputBlock } from "./BlockTypeInfo.js"; +import { getTextWidth } from "./module.js"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +const BlockShapes = { + // eg (my variable) + Round: { + padding: 12, + minWidth: 20, + backgroundPath: (width) => `m -12 -20 m 20 0 h ${width - 16} a 20 20 0 0 1 0 40 H 8 a 20 20 0 0 1 0 -40 z`, + + /** + * 'Snuggling' is my wholesome term for when a block can sit extra close to a block + * of the same shape as it. Take a look at the blocks ( ( "" + "" ) - "" ) and + * ( < "" = "" > - "" ), observe how there's a lot more blank space in the outer + * block in the second example, this is because in the first example the '+' block + * can snuggle with the '-' block. + */ + snugglePadding: 0, + get snuggleWith() { + // Don't feel bad BlockShapes.Round, I only snuggle with myself too :_( + return [BlockShapes.Round]; + }, + }, + + // eg <() = ()> + Boolean: { + padding: 20, + minWidth: 20, + backgroundPath: (width) => `m -20 -20 m 20 0 h ${width} l 20 20 l -20 20 H 0 l -20 -20 l 20 -20 z`, + + snugglePadding: 0, + get snuggleWith() { + return [BlockShapes.Boolean]; + }, + }, + + // Square dropdowns like variables + SquareInput: { + padding: 8, + minWidth: 20, + backgroundPath: (width) => + `m -2 -16 h ${width + 4} a 4 4 0 0 1 4 4 V 12 a 4 4 0 0 1 -4 4 H -2 a 4 4 0 0 1 -4 -4 V -12 a 4 4 0 0 1 4 -4`, + }, + + // eg show + Stack: { + padding: 8, + minWidth: 60, + backgroundPath: (width) => + `m -8 -20 A 4 4 0 0 1 -4 -24 H 4 c 2 0 3 1 4 2 l 4 4 c 1 1 2 2 4 2 h 12 c 2 0 3 -1 4 -2 l 4 -4 C 37 -23 38 -24 40 -24 H ${width} a 4 4 0 0 1 4 4 v 40 a 4 4 0 0 1 -4 4 H 40 c -2 0 -3 1 -4 2 l -4 4 c -1 1 -2 2 -4 2 h -12 c -2 0 -3 -1 -4 -2 l -4 -4 c -1 -1 -2 -2 -4 -2 H -4 a 4 4 0 0 1 -4 -4 z`, + }, + + // eg when I start as a clone + Hat: { + padding: 8, + minWidth: 60, + backgroundPath: (width) => + `m -8 -20 A 4 4 0 0 1 -4 -24 H ${width} a 4 4 0 0 1 4 4 v 40 a 4 4 0 0 1 -4 4 H 40 c -2 0 -3 1 -4 2 l -4 4 c -1 1 -2 2 -4 2 h -12 c -2 0 -3 -1 -4 -2 l -4 -4 c -1 -1 -2 -2 -4 -2 H -4 a 4 4 0 0 1 -4 -4 z`, + }, + + // eg delete this clone + End: { + padding: 8, + minWidth: 60, + backgroundPath: (width) => + `m -8 -20 A 4 4 0 0 1 -4 -24 H 4 c 2 0 3 1 4 2 l 4 4 c 1 1 2 2 4 2 h 12 c 2 0 3 -1 4 -2 l 4 -4 C 37 -23 38 -24 40 -24 H ${width} a 4 4 0 0 1 4 4 v 40 a 4 4 0 0 1 -4 4 H -4 a 4 4 0 0 1 -4 -4 z`, + }, + + // The white oval for text or number inputs + TextInput: { + padding: 12, + minWidth: 16, + backgroundPath: (width) => `m -12 -16 m 16 0 h ${width - 8} a 16 16 0 0 1 0 32 H 4 a 16 16 0 0 1 0 -32 z`, + + snugglePadding: 4, + get snuggleWith() { + return [BlockShapes.Round]; + }, + }, + + BooleanInput: { + padding: 16, + minWidth: 16, + backgroundPath: (width) => `m 0 -16 h ${width} l 16 16 l -16 16 h -16 l -16 -16 l 16 -16 z`, + + snugglePadding: 6, + get snuggleWith() { + return [BlockShapes.Boolean]; + }, + }, + + HorizontalBlock: { + padding: 16, + minWidth: 45, + backgroundPath: (width) => + `M -4 -20 a 4 4 0 0 1 4 -4 H ${ + width + 8 + } a 4 4 0 0 1 4 4 v 2 c 0 2 -1 3 -2 4 l -4 4 c -1 1 -2 2 -2 4 v 12 c 0 2 1 3 2 4 l 4 4 c 1 1 2 2 2 4 v 2 a 4 4 0 0 1 -4 4 H 0 a 4 4 0 0 1 -4 -4 v -2 c 0 -2 -1 -3 -2 -4 l -4 -4 c -1 -1 -2 -2 -2 -4 v -12 c 0 -2 1 -3 2 -4 l 4 -4 c 1 -1 2 -2 2 -4 z`, + }, + + HorizontalBlockEnd: { + padding: 16, + minWidth: 45, + backgroundPath: (width) => + `M -4 -20 a 4 4 0 0 1 4 -4 H ${ + width + 8 + } a 4 4 0 0 1 4 4 V 20 a 4 4 0 0 1 -4 4 H 0 a 4 4 0 0 1 -4 -4 v -2 c 0 -2 -1 -3 -2 -4 l -4 -4 c -1 -1 -2 -2 -2 -4 v -12 c 0 -2 1 -3 2 -4 l 4 -4 c 1 -1 2 -2 2 -4 z`, + }, +}; + +/** + * Gets the block shape info from {@link BlockShapes} given a {@link BlockShape}. + * @param {BlockShape} shape + */ +function getShapeInfo(shape, isVertical) { + if (shape === BlockShape.Round) return BlockShapes.Round; + if (shape === BlockShape.Boolean) return BlockShapes.Boolean; + if (shape === BlockShape.Stack) return isVertical ? BlockShapes.Stack : BlockShapes.HorizontalBlock; + if (shape === BlockShape.Hat) return BlockShapes.Hat; + if (shape === BlockShape.End) return isVertical ? BlockShapes.End : BlockShapes.HorizontalBlockEnd; + throw new Error(shape); +} + +/** + * @param {BlockInstance} block + * @returns {number} + */ +export function getBlockHeight(block) { + switch (block.typeInfo.shape) { + case BlockShape.End: + case BlockShape.Hat: + case BlockShape.Stack: + return 62; + case BlockShape.Boolean: + case BlockShape.Round: + return 48; + } + return 0; +} + +const BLOCK_ELEMENT_SPACING = 8; + +/** + * A part of a block. Think of these like the different parts in the 'make a block' menu. + */ +export class BlockComponent { + constructor(element, padding, width, snuggleWith, snugglePadding) { + this.dom = element; + this.padding = padding; + this.width = width; + this.snuggleWith = snuggleWith; + this.snugglePadding = snugglePadding; + } +} + +/** + * Creates a BlockComponent with some text. Like the 'label' element in the make a block menu. + * @param {string} text The contents of the component. + * @param {SVGElement} container The element to add the text to. + * @returns {BlockComponent} The BlockComponent. + */ +function createTextComponent(text, fillVar, container) { + let textElement = container.appendChild(document.createElementNS(SVG_NS, "text")); + textElement.setAttribute("class", "blocklyText"); + textElement.style.fill = `var(${fillVar})`; + textElement.setAttribute("dominant-baseline", "middle"); + textElement.setAttribute("dy", 1); + textElement.appendChild(document.createTextNode(text)); + return new BlockComponent(textElement, 0, getTextWidth(textElement)); +} + +/** + * Creates a DOM element to hold all the contents of a block. + * A block could be the top level block, or it could be a block like (() + ()) that's inside + * another block. + * @returns {SVGElement} The SVGElement which will contain all the block's components. + */ +function createBlockContainer() { + let container = document.createElementNS(SVG_NS, "g"); + let background = document.createElementNS(SVG_NS, "path"); + background.setAttribute("class", "blocklyPath"); + container.appendChild(background); + return container; +} + +/** + * Creates a block component from a container containing all its components. + * @param {SVGElement} container The block container, created by {@link createBlockContainer}. + * @param {object} shape An object containing information of the shape of the block to be created. From the {@link BlockShapes} object. + * @param {string} categoryClass The category of the block, used for filling the background. + * @param {string} fill + * @param {string} stroke + * @param {number} width The width of the background of the block. + */ +function createBlockComponent(container, shape, categoryClass, fill, stroke, width) { + if (width < shape.minWidth) width = shape.minWidth; + container.classList.add("sa-block-color", categoryClass); + const background = container.children[0]; + let style = ""; + if (fill) style += `fill: var(${fill});`; + if (stroke) style += `stroke: var(${stroke});`; + background.setAttribute("style", style); + background.setAttribute("d", shape.backgroundPath(width)); + return new BlockComponent( + container, + shape.padding, + width + shape.padding * 2, + shape.snuggleWith, + shape.snugglePadding + ); +} + +function createBackedTextedComponent(text, container, shape, categoryClass, fill, stroke, textVar) { + const blockContainer = createBlockContainer(); + container.appendChild(blockContainer); + const textElement = createTextComponent(text, textVar, blockContainer); + if (textElement.width < shape.minWidth) { + textElement.dom.setAttribute("x", (shape.minWidth - textElement.width) / 2); + } + + const blockElement = createBlockComponent(blockContainer, shape, categoryClass, fill, stroke, textElement.width); + return blockElement; +} + +/** + * Renders a block, with the center of it's leftmost side located at 0, 0. + * @param {BlockInstance} block + * @param {SVGElement} container + * @returns {BlockComponent} The rendered block + */ +export default function renderBlock(block, container) { + var blockComponent = _renderBlock(block, container, block.typeInfo.category, true); + blockComponent.dom.classList.add("sa-block-color"); + blockComponent.dom.setAttribute("transform", `translate(${blockComponent.padding}, 0)`); + return blockComponent; +} + +/** + * Renders a block, with the center of it's leftmost side located at 0, 0. + * @param {BlockInstance} block + * @param {SVGAElement} container + * @param {string} parentCategory The category of this blocks parent. If no parent, than this blocks category. + * @returns {BlockComponent} The rendered component. + */ +function _renderBlock(block, container, parentCategory, isVertical) { + const blockContainer = container.appendChild(createBlockContainer()); + const shape = getShapeInfo(block.typeInfo.shape, isVertical); + const category = block.typeInfo.category; + const categoryClass = "sa-block-color-" + String(category.name).replace(/[^a-z0-9\-_]+/gmi, '_'); + + let xOffset = 0; + let inputIdx = 0; + + for (let partIdx = 0; partIdx < block.typeInfo.parts.length; partIdx++) { + const blockPart = block.typeInfo.parts[partIdx]; + + let component; + if (typeof blockPart === "string") { + component = createTextComponent(blockPart, "--sa-block-text", blockContainer); + } else { + const blockInput = block.inputs[inputIdx++]; + if (blockInput instanceof BlockInstance) { + component = _renderBlock(blockInput, blockContainer, block.typeInfo.category, false); + } else if (blockPart instanceof BlockInputEnum) { + if (blockPart.isRound) { + component = createBackedTextedComponent( + blockInput?.string ?? blockPart.values[0].string, + blockContainer, + BlockShapes.TextInput, + categoryClass, + `--sa-block-background-secondary, ${category.colorSecondary}`, + `--sa-block-background-tertiary, ${category.colorTertiary}`, + "--sa-block-text" + ); + } else { + component = createBackedTextedComponent( + blockInput?.string ?? blockPart.values[0].string, + blockContainer, + BlockShapes.SquareInput, + categoryClass, + `--sa-block-background-primary, ${category.colorPrimary}`, + `--sa-block-background-tertiary, ${category.colorTertiary}`, + "--sa-block-text" + ); + } + } else if (blockPart instanceof BlockInputBoolean) { + component = createBackedTextedComponent( + "", + blockContainer, + BlockShapes.BooleanInput, + categoryClass, + `--sa-block-field-background, ${category.colorTertiary}`, + `--sa-block-field-background, ${category.colorTertiary}`, + "--sa-block-text" + ); + } else if (blockPart instanceof BlockInputBlock) { + component = createBackedTextedComponent( + "", + blockContainer, + BlockShapes.HorizontalBlock, + categoryClass, + `--sa-block-field-background, ${category.colorTertiary}`, + `--sa-block-field-background, ${category.colorTertiary}`, + "--sa-block-text" + ); + } else { + component = createBackedTextedComponent( + blockInput?.toString() ?? blockPart.defaultValue ?? "", + blockContainer, + BlockShapes.TextInput, + categoryClass, + `--sa-block-input-color, white`, + `--sa-block-background-tertiary, ${category.colorTertiary}`, + "--sa-block-input-text" + ); + component.dom.classList.add("blocklyNonEditableText"); + } + } + + let xTranslation = xOffset + component.padding; + + if (partIdx === 0 || partIdx === block.typeInfo.parts.length - 1) { + if (component.snuggleWith && component.snuggleWith.indexOf(shape) !== -1) { + const positionDelta = component.snugglePadding - component.padding; + component.width += positionDelta; + + if (partIdx === 0) { + xTranslation += positionDelta; + } + } + } + + component.dom.setAttribute("transform", `translate(${xTranslation}, 0)`); + xOffset += BLOCK_ELEMENT_SPACING + component.width; + } + + return createBlockComponent( + blockContainer, + shape, + categoryClass, + `--sa-block-background-primary, ${category.colorPrimary}`, + `--sa-block-background-tertiary, ${category.colorTertiary}`, + xOffset - BLOCK_ELEMENT_SPACING + ); +} diff --git a/src/addons/addons/middle-click-popup/BlockTypeInfo.js b/src/addons/addons/middle-click-popup/BlockTypeInfo.js new file mode 100644 index 00000000000..1f78fbe5a94 --- /dev/null +++ b/src/addons/addons/middle-click-popup/BlockTypeInfo.js @@ -0,0 +1,565 @@ +/** + * @file Contains the code for enumerating the different types of blocks in a workspace, + * and provides a more friendly way to create instances blocks with some inputs. + */ + +// import * as SABlocks from "../../addon-api/content-script/blocks.js"; + +/** + * A numeric value to represent the type of an {@link BlockInput} + * @readonly + * @enum {number} + */ +export const BlockInputType = { + STRING: 0, + NUMBER: 1, + BOOLEAN: 2, + COLOUR: 3, + ENUM: 4, + BLOCK: 5, +}; + +/** + * @abstract + */ +export class BlockInput { + /** + * @param {BlockInputType} type + * @param {number} inputIdx + * @param {number} fieldIdx + */ + constructor(type, inputIdx, fieldIdx) { + if (this.constructor === BlockInput) throw new Error("Abstract classes can't be instantiated."); + /** @type {BlockInputType} */ + this.type = type; + /** @type {number} The index of this input in the workspace version of the block's input array. */ + this.inputIdx = inputIdx; + /** + * The index of this input in the workspace version of the block's field array. + * The special case of -1 means that in the workspace version, this input is inside a sub-block, + * that has been abstracted away. + * @type {number} + */ + this.fieldIdx = fieldIdx; + } + + /** + * Sets the field this input refers to on a block to a value. + * @param {BlockInstance} block + * @param {*} value + * @abstract + */ + setValue(block, value) { + throw new Error("Sub-class must override abstract method."); + } + + /** + * Gets the input this block input refers to on block. + * @param {BlockInstance} block + * @returns {*} + * @protected + */ + getInput(block) { + return block.inputList[this.inputIdx]; + } + + /** + * Gets the field this block input refers to on block. + * @param {BlockInstance} block + * @returns {*} + * @protected + */ + getField(block) { + if (this.fieldIdx === -1) { + return this.getInput(block).connection.targetBlock().inputList[0].fieldRow[0]; + } else { + return this.getInput(block).fieldRow[this.fieldIdx]; + } + } +} + +/** + * The base class for any round input. + * @abstract + */ +export class BlockInputRound extends BlockInput { + constructor(type, inputIdx, fieldIdx, defaultValue) { + super(type, inputIdx, fieldIdx); + if (this.constructor === BlockInputRound) throw new Error("Abstract classes can't be instantiated."); + this.defaultValue = defaultValue; + } + + setValue(block, value) { + if (value instanceof BlockInstance) { + const subblock = value.createWorkspaceForm(); + if (!subblock.outputConnection) + throw new Error('Cannot put block "' + subblock.typeInfo.id + '" into a round type input.'); + subblock.outputConnection.connect(this.getInput(block).connection); + } else { + this.getField(block).setValue(this._toFieldValue(value)); + } + } + + /** + * Converts a value passed in to setValue to a value we can set the block's field to. + * @param {*} value + * @protected + */ + _toFieldValue(value) { + throw new Error("Sub-class must override abstract method."); + } +} + +export class BlockInputString extends BlockInputRound { + constructor(inputIdx, fieldIdx, defaultValue) { + super(BlockInputType.STRING, inputIdx, fieldIdx, defaultValue); + } + + _toFieldValue(value) { + const type = typeof value; + if (type === "number") return value; + if (type === "string") return value; + throw new Error("Cannot set round type input to value of type " + type); + } +} + +export class BlockInputNumber extends BlockInputRound { + constructor(inputIdx, fieldIdx, defaultValue) { + super(BlockInputType.NUMBER, inputIdx, fieldIdx, defaultValue); + } + + _toFieldValue(value) { + const type = typeof value; + if (type === "number") return value; + if (type === "string") { + const number = parseFloat(value); + if (isNaN(number)) throw new Error('Cannot set numeric type input to string "' + value + '".'); + return value; + } + throw new Error("Cannot set round type input to value of type " + type); + } +} + +export class BlockInputBoolean extends BlockInput { + constructor(inputIdx, fieldIdx) { + super(BlockInputType.BOOLEAN, inputIdx, fieldIdx); + } + + setValue(block, value) { + if (value instanceof BlockInstance) { + const subblock = value.createWorkspaceForm(); + if (!subblock.outputConnection || value.typeInfo.shape !== BlockShape.Boolean) + throw new Error('Cannot put block "' + value.typeInfo.id + '" into a boolean type input.'); + subblock.outputConnection.connect(this.getInput(block).connection); + } else { + throw new Error("Boolean type inputs can only contain blocks."); + } + } +} + +export class BlockInputColour extends BlockInput { + constructor(inputIdx, fieldIdx) { + super(BlockInputType.COLOUR, inputIdx, fieldIdx); + } + + setValue(block, value) { + if (typeof value !== "string") throw new Error("Cannot set color type input to value of type " + typeof type); + if (!value.match(/^#[0-9a-fA-F]{6}$/)) throw new Error('Invalid color "' + value + '".'); + this.getField(block).setValue(value); + } +} + +/** + * @typedef BlockInputEnumOption + * @property {string} value The internal name of this input option + * @property {string} string The localized name of this input option. + */ + +/** + * A block input that can be one of a list of values. + * Usually represented by a dropdown menu in Scratch. + */ +export class BlockInputEnum extends BlockInput { + static INVALID_VALUES = [ + "DELETE_VARIABLE_ID", + "RENAME_VARIABLE_ID", + "NEW_BROADCAST_MESSAGE_ID", + "NEW_BROADCAST_MESSAGE_ID", + // editor-searchable-dropdowns compatibility + "createGlobalVariable", + "createLocalVariable", + "createGlobalList", + "createLocalList", + "createBroadcast", + // rename-broadcasts compatibility + "RENAME_BROADCAST_MESSAGE_ID", + ]; + + /** + * @param {Array} options + * @param {number} inputIdx + * @param {number} fieldIdx + */ + constructor(options, inputIdx, fieldIdx, isRound) { + super(BlockInputType.ENUM, inputIdx, fieldIdx); + /** @type {BlockInputEnumOption[]} */ + this.values = []; + for (let i = 0; i < options.length; i++) { + if (typeof options[i][1] === "string" && BlockInputEnum.INVALID_VALUES.indexOf(options[i][1]) === -1) { + this.values.push({ value: options[i][1], string: options[i][0].replaceAll(String.fromCharCode(160), " ") }); + } + } + this.isRound = isRound; + } + + /** + * @param {BlockInputEnumOption} value + */ + setValue(block, value) { + if (this.isRound && value instanceof BlockInstance) { + value.createWorkspaceForm().outputConnection.connect(this.getInput(block).connection); + } else { + if (this.values.indexOf(value) === -1) + throw new Error("Invalid enum value. Expected item from the options list."); + this.getField(block).setValue(value.value); + } + } +} + +/** + * A block input that is a stack of blocks. + * The 'if' block has a single block input, the 'if else' block has two block inputs. + */ +export class BlockInputBlock extends BlockInput { + constructor(inputIdx, fieldIdx) { + super(BlockInputType.BLOCK, inputIdx, fieldIdx); + } + + setValue(block, value) { + if (value instanceof BlockInstance) { + const subblock = value.createWorkspaceForm(); + if (!subblock.previousConnection || !value.typeInfo.shape.canStackUp) + throw new Error('Cannot put block "' + value.typeInfo.id + '" into a block type input.'); + subblock.previousConnection.connect(this.getInput(block).connection); + } else { + throw new Error("Block type inputs can only contain blocks."); + } + } +} + +/** + * Because everyone was thinking "You know what Scratch really needs, ANOTHER way to represent blocks!" + * + * Another way to represent a Scratch block. + */ +export class BlockInstance { + constructor(typeInfo, ...inputs) { + /** @type {BlockTypeInfo} */ + this.typeInfo = typeInfo; + /** @type {Array} */ + this.inputs = inputs; + } + + /** + * Creates a real Scratch block from this imaginary representation. + * @returns {*} A 'workspace form' block. + */ + createWorkspaceForm() { + if (this.typeInfo.id === "control_stop") { + this.typeInfo.domForm + .querySelector("mutation") + .setAttribute("hasnext", "" + (this.inputs[0].value === "other scripts in sprite")); + } + + const block = this.typeInfo.Blockly.Xml.domToBlock(this.typeInfo.domForm, this.typeInfo.workspace); + for (let i = 0; i < this.inputs.length; i++) { + if (this.inputs[i] !== null) this.typeInfo.inputs[i].setValue(block, this.inputs[i]); + } + + return block; + } +} + +/** + * An enum for the different shapes of blocks. + * Contains information on what each type of block can do. + */ +export class BlockShape { + static Round = new BlockShape(false, false, true); + static Boolean = new BlockShape(false, false, true); + static Hat = new BlockShape(false, true, false); + static End = new BlockShape(true, false, false); + static Stack = new BlockShape(true, true, false); + + static getBlockShape(workspaceBlock) { + if (workspaceBlock.edgeShape_ === 2) { + return BlockShape.Round; + } else if (workspaceBlock.edgeShape_ === 1) { + return BlockShape.Boolean; + } else if (workspaceBlock.startHat_) { + return BlockShape.Hat; + } else if (!workspaceBlock.nextConnection) { + return BlockShape.End; + } else { + return BlockShape.Stack; + } + } + + constructor(canStackUp, canStackDown, canBeRound) { + /** @type {boolean} Can blocks be stacked above this block? */ + this.canStackUp = canStackUp; + /** @type {boolean} Can blocks be stacked below this block? */ + this.canStackDown = canStackDown; + /** @type {boolean} Does this block fit into a round hole? */ + this.canBeRound = canBeRound; + } +} + +/** + * @typedef BlockCategory + * @property {string} name + * @property {string} colorPrimary + * @property {string} colorSecondary + * @property {string} colorTertiary + */ + +/** + * A type of Scratch block, like 'move () steps'. Every instance of the 'move () steps' + * block shares this type info. + */ +export class BlockTypeInfo { + /** + * @param {*} block Block in workspace form + * @param {*} vm + * @returns {BlockCategory} The block's category + */ + static getBlockCategory(block, vm) { + let name; + + if (block.type === "procedures_call") { + if (vm.getAddonBlock(block.getProcCode())) name = "addon-custom-block"; + else name = "more"; + } else if (block.isScratchExtension) name = "pen"; + else if (block.type === "sensing_of") name = "sensing"; + else if (block.type === "event_whenbackdropswitchesto") name = "events"; + else name = block.category_; + + return { + name, + colorPrimary: block.colour_, + colorSecondary: block.colourSecondary_, + colorTertiary: block.colourTertiary_, + }; + } + + /** + * Enumerates all the different types of blocks, given a workspace. + * @param {Blockly} Blockly + * @param {*} vm + * @param {*} workspace + * @param {(string) => string} locale The translations used for converting icons into text + * @returns {BlockTypeInfo[]} + */ + static getBlocks(Blockly, vm, workspace, locale) { + const flyoutWorkspace = workspace.getToolbox()?.flyout_.getWorkspace(); + if (!flyoutWorkspace) return []; + + const blocks = []; + + const flyoutDom = Blockly.Xml.workspaceToDom(flyoutWorkspace); + const flyoutDomBlockMap = {}; + for (const blockDom of flyoutDom.children) { + if (blockDom.tagName === "BLOCK") { + let id = blockDom.getAttribute("id"); + flyoutDomBlockMap[id] = blockDom; + } + } + for (const workspaceBlock of flyoutWorkspace.getTopBlocks()) { + blocks.push( + ...BlockTypeInfo._createBlocks( + workspace, + vm, + Blockly, + locale, + workspaceBlock, + flyoutDomBlockMap[workspaceBlock.id] + ) + ); + } + + return blocks; + } + + static _createBlocks(workspace, vm, Blockly, locale, workspaceForm, domForm) { + let parts = []; + let inputs = []; + + const addInput = (input) => { + parts.push(input); + inputs.push(input); + }; + + const addFieldInputs = (field, inputIdx, fieldIdx) => { + if (field.className_ === "blocklyText blocklyDropdownText") { + const options = field.getOptions(); + addInput(new BlockInputEnum(options, inputIdx, fieldIdx, fieldIdx === -1)); + } else if (field instanceof Blockly.FieldImage) { + switch (field.src_) { + case "/static/blocks-media/green-flag.svg": + parts.push(locale("/_general/blocks/green-flag")); + break; + case "/static/blocks-media/rotate-right.svg": + parts.push(locale("/_general/blocks/clockwise")); + break; + case "/static/blocks-media/rotate-left.svg": + parts.push(locale("/_general/blocks/anticlockwise")); + break; + } + } else { + if (!field.argType_) { + if (field.getText().trim().length !== 0) parts.push(field.getText()); + } else if (field.argType_[0] === "colour") { + addInput(new BlockInputColour(inputIdx, fieldIdx)); + } else if (field.argType_[1] === "number") { + addInput(new BlockInputNumber(inputIdx, fieldIdx, field.text_)); + } else { + addInput(new BlockInputString(inputIdx, fieldIdx, field.text_)); + } + } + }; + + for (let inputIdx = 0; inputIdx < workspaceForm.inputList?.length; inputIdx++) { + const input = workspaceForm.inputList[inputIdx]; + for (let fieldIdx = 0; fieldIdx < input.fieldRow.length; fieldIdx++) { + addFieldInputs(input.fieldRow[fieldIdx], inputIdx, fieldIdx); + } + + if (input.connection) { + const innerBlock = input.connection.targetBlock(); + if (innerBlock) { + if (innerBlock.inputList.length !== 1 || innerBlock.inputList[0].fieldRow.length !== 1) + throw new Error("This should never happen."); + let innerField = innerBlock.inputList[0].fieldRow[0]; + addFieldInputs(innerField, inputIdx, -1); + } else { + if (input.outlinePath) { + addInput(new BlockInputBoolean(inputIdx, -1)); + } else { + addInput(new BlockInputBlock(inputIdx, -1)); + } + } + } + } + + if (workspaceForm.id === "of") { + let blocks = []; + + let baseVarInputIdx, baseTargetInputIdx; + // In most languages, the 'of' block inputs are: [variable] of [sprite], and in others + // it's the opposite (sprite then variable). We can tell that the variable comes first + // if the first input is round. + if (inputs[0].isRound) { + baseVarInputIdx = 1; + baseTargetInputIdx = 0; + } else { + baseVarInputIdx = 0; + baseTargetInputIdx = 1; + } + + let baseVarInput = inputs[baseVarInputIdx]; + let baseTargetInput = inputs[baseTargetInputIdx]; + + const baseVarPartIdx = parts.indexOf(baseVarInput); + const baseTargetPartIdx = parts.indexOf(baseTargetInput); + + // Adapted from https://github.com/scratchfoundation/scratch-gui/blob/cc6e6324064493cf1788f3c7c0ff31e4057964ee/src/lib/blocks.js#L230 + const stageOptions = [ + [Blockly.Msg.SENSING_OF_BACKDROPNUMBER, "backdrop #"], + [Blockly.Msg.SENSING_OF_BACKDROPNAME, "backdrop name"], + [Blockly.Msg.SENSING_OF_VOLUME, "volume"], + ]; + + const spriteOptions = [ + [Blockly.Msg.SENSING_OF_XPOSITION, "x position"], + [Blockly.Msg.SENSING_OF_YPOSITION, "y position"], + [Blockly.Msg.SENSING_OF_DIRECTION, "direction"], + [Blockly.Msg.SENSING_OF_COSTUMENUMBER, "costume #"], + [Blockly.Msg.SENSING_OF_COSTUMENAME, "costume name"], + [Blockly.Msg.SENSING_OF_SIZE, "size"], + [Blockly.Msg.SENSING_OF_VOLUME, "volume"], + ]; + + for (const targetInput of baseTargetInput.values) { + let options; + const isStage = targetInput.value === "_stage_"; + + if (isStage) { + const stageVariableOptions = vm.runtime.getTargetForStage().getAllVariableNamesInScopeByType(""); + options = stageVariableOptions.map((variable) => [variable, variable]).concat(stageOptions); + } else { + const sprite = vm.runtime.getSpriteTargetByName(targetInput.value); + const spriteVariableOptions = sprite.getAllVariableNamesInScopeByType("", true); + options = spriteVariableOptions.map((variable) => [variable, variable]).concat(spriteOptions); + } + + const ofInputs = []; + ofInputs[baseVarInputIdx] = new BlockInputEnum(options, baseVarInput.inputIdx, baseVarInput.fieldIdx, false); + ofInputs[baseTargetInputIdx] = new BlockInputEnum( + [[targetInput.string, targetInput.value]], + baseTargetInput.inputIdx, + baseTargetInput.fieldIdx, + isStage + ); + + const ofParts = [...parts]; + ofParts[baseVarPartIdx] = ofInputs[baseVarInputIdx]; + ofParts[baseTargetPartIdx] = ofInputs[baseTargetInputIdx]; + + blocks.push(new BlockTypeInfo(workspace, Blockly, vm, workspaceForm, domForm, ofParts, ofInputs)); + } + + return blocks; + } + + return [new BlockTypeInfo(workspace, Blockly, vm, workspaceForm, domForm, parts, inputs)]; + } + + constructor(workspace, Blockly, vm, workspaceForm, domForm, parts, inputs) { + /** @type {string} */ + this.id = workspaceForm.id; + this.workspaceForm = workspaceForm; + this.domForm = domForm; + /** @type {BlockShape} */ + this.shape = BlockShape.getBlockShape(this.workspaceForm); + /** @type {BlockCategory} */ + this.category = BlockTypeInfo.getBlockCategory(this.workspaceForm, vm); + this.workspace = workspace; + this.Blockly = Blockly; + + /** + * A list of all the 'parts' of this block. Each part is either an instance + * of BlockInput or a string for some text which is a part of a block. + * + * For example, for the 'say' block, the first element of the array would be + * the string 'say', and the second element would be a BlockInput of type + * BlockInputString. + * @type {(BlockInput | string)[]} + */ + this.parts = parts; + /** + * A list of all this block's inputs. The same as this.parts, but with the + * strings omitted. + * @type {BlockInput[]} + */ + this.inputs = inputs; + } + + /** + * Creates a block of this type with the given inputs + * @param {...any} inputs + * @returns {BlockInstance} + */ + createBlock(...inputs) { + return new BlockInstance(this, ...inputs); + } +} diff --git a/src/addons/addons/middle-click-popup/WorkspaceQuerier.js b/src/addons/addons/middle-click-popup/WorkspaceQuerier.js new file mode 100644 index 00000000000..cce22887109 --- /dev/null +++ b/src/addons/addons/middle-click-popup/WorkspaceQuerier.js @@ -0,0 +1,1342 @@ +/** + * @file Contains all the logic for the parsing of queries by the {@link WorkspaceQuerier}. + * I'm really sorry if somebody other than me ever has to debug this. + * Wish you luck <3 + * + * Once you *think* you understand the function of the major classes, read the docs on + * {@link WorkspaceQuerier._createTokenGroups} for some more specifics on the algorithm works, + * and to achieve maximum enlightenment. + * + * @author Tacodiva + */ + +import { BlockInputType, BlockInstance, BlockShape, BlockTypeInfo } from "./BlockTypeInfo.js"; + +/** + * + * A token is a part of a query that is interpreted in a specific way. + * + * In the query 'say 1 = Hello World', the base tokens are 'say', '1', '=, and 'Hello World'. + * Each token contains where in the query it is located and what {@link TokenType} it is. + * + * Sometimes the same section of a query has multiple tokens because there are different + * interpretations of what type of token it is. For example, imagine you had a variable named + * 'x'. The query 'set x to 10', is ambiguous because you could be referring to the motion block + * `set x to ()` or the data block `set [x] to ()`. This ambiguity results in two different + * tokens being creating for 'x', one is 'set x to' referring to the motion block, and the other + * is just 'x', referring to the variable. + * + * Calling this a 'token' is somewhat misleading, often language interpreters will have a 'parse tree' + * with tokens and an 'abstract syntax tree' with higher level elements, but I have chosen to make these + * two trees one in the same. Because of this, every token represents a logical part of a block. + * Going back to the 'say 1 = Hello World' example, there are two 'parent' tokens, both are of type + * {@link TokenTypeBlock}. The first is for the equals block, which contains three subtokens; '1', + * '=' and 'Hello World'. The second is the say block, whos first child is 'say' and second child is + * the token for the equals block (which itself has three children). For a query result to be valid, + * it must have a token which encapsulates the entire query, in this case the say block token starts + * at the first letter and ends at the last letter, so it's a valid interpretation. The token which + * encapsulates the whole query is referred to as the root token. + */ +class Token { + /** + * @param {number} start + * @param {number} end + * @param {TokenType} type + * @param {*} value + * @param {number} score + * @param {number} precedence + * @param {boolean} isTruncated + * @param {boolean} isLegal + */ + constructor(start, end, type, value, score = 100, precedence = -1, isTruncated = false, isLegal = true) { + /** @type {number} The index of the first letter of this token in the query */ + this.start = start; + /** @type {number} The index of the last letter of this token in the query */ + this.end = end; + /** @type {TokenType} The type of this token. */ + this.type = type; + /** @type {*} Additional information about this token, controlled and interpreted by the token type. */ + this.value = value; + /** + * A number which represents how 'good' this interpretation of the query is. This value is used + * to order the results once the query is finished from best to worst ('best' being the result we + * think is most likely the interpretation the user intended). + * The score of a parent block incorporates the scores of its children so results are ordered based + * on the score of their root token. + * @type {number} + */ + this.score = score; + /** + * The precedence of this token, used to implement order of operations. Tokens with a higher + * precedence should be evaluated *after* those with a lower precedence. Brackets have a + * precedence of 0 so they are always evaluated first. A precedence of -1 means that precedence + * is not specified and the parser makes no guarantees about the order of operations. + * @type {number} + */ + this.precedence = precedence; + /** + * Sometimes, tokens are truncated. Imagine the query 'say Hello for 10 se', here the last + * token should be 'seconds', but it's truncated. For this token, the isTruncated value is set + * to true. Additionally, the token for the whole block (which contains the tokens 'say', 'Hello', + * 'for', '10' and 'se') also has it's isTruncated value set to true, because it contains a + * truncated token. + * @type {boolean} + */ + this.isTruncated = isTruncated; + /** + * Used to generate autocomplete text, even if that autocomplete text doesn't make a valid query + * by itself. For example in the query 'if my varia', we want to autocomplete to 'my variable', + * but the query 'if my variable' is still not valid, because my variable is not a boolean. In + * this case, the 'my variable' token would still be emitted as the second child of the 'if' token, + * but it would be marked as illegal. + */ + this.isLegal = isLegal; + } + + /** + * @see {TokenType.createBlockValue} + * @param {QueryInfo} query + * @returns + */ + createBlockValue(query) { + return this.type.createBlockValue(this, query); + } +} + +/** + * The parent of any class that can enumerate tokens given a query and a location within that + * query to search. + * + * As the same position in a query can have multiple interpretations (see {@link Token}), every + * token provider's {@link parseTokens} method can return multiple tokens for the same index. + * + * Like tokens, there is a token provider tree. See {@link WorkspaceQuerier._createTokenGroups} + * for more info on this tree. + * + * @abstract + */ +class TokenProvider { + constructor(shouldCache) { + if (this.constructor === TokenProvider) throw new Error("Abstract classes can't be instantiated."); + /** + * Can the results of this token provider be stored? True + * if {@link parseTokens} will always return the same thing for the same inputs or if + * this token provider already caches it's result, so caching it again is redundant. + * @type {boolean} + */ + this.shouldCache = shouldCache; + } + + /** + * Return the tokens found by this token provider in `query` at character `idx`. + * @param {QueryInfo} query The query to search + * @param {number} idx The index to start the search at + * @yields {Token} All the tokens found + * @abstract + */ + // eslint-disable-next-line require-yield + *parseTokens(query, idx) { + throw new Error("Sub-class must override abstract method."); + } +} + +/** + * A token provider which wraps around another token provider, always returning a blank token in + * addition to whatever the inner token provider returns. + * + * Used for tokens that can possibility be omitted, like numbers. For example, the '+' block always + * needs three inputs, but the user could query '1 +'. In this case its subtokens are '1', '+' and + * a {@link TokenTypeBlank}, provided by this provider. + */ +class TokenProviderOptional extends TokenProvider { + /** + * @param {TokenProvider} inner + */ + constructor(inner) { + super(inner.shouldCache); + /** @type {TokenProvider} The inner token provider to return along with the blank token. */ + this.inner = inner; + } + + *parseTokens(query, idx) { + yield TokenTypeBlank.INSTANCE.createToken(idx); + yield* this.inner.parseTokens(query, idx); + } +} + +/** + * Caches the output of an inner token provider. + * Used for tokens that are a part of multiple token provider groups. + */ +class TokenProviderSingleCache extends TokenProvider { + /** + * @param {TokenProvider} inner + */ + constructor(inner) { + super(false); + /** @type {TokenProvider} */ + this.inner = inner; + if (this.inner.shouldCache) { + /** @type {Token[]?} */ + this.cache = []; + /** @type {number?} */ + this.cacheQueryID = null; + } + } + + *parseTokens(query, idx) { + if (!this.inner.shouldCache) { + yield* this.inner.parseTokens(); + return; + } + if (this.cacheQueryID !== query.id) { + this.cache = []; + this.cacheQueryID = query.id; + } + let cacheEntry = this.cache[idx]; + if (cacheEntry) { + yield* cacheEntry; + return; + } + this.cacheEntry = []; + for (const token of this.inner.parseTokens(query, idx)) { + this.cacheEntry.push(token); + yield token; + } + } +} + +/** + * Collects multiple inner token providers into one token provider group. + * Additionally, caches the results of all the cacheable inner token providers. + */ +class TokenProviderGroup extends TokenProvider { + constructor() { + // No need to cache this as it already caches it's own output. + super(false); + /** @type {TokenProvider[]} The providers that make up this group */ + this.providers = []; + /** @type {TokenProvider[]} Providers that are a part of the group, but tokens they produce are illegal */ + this.illegalProviders = []; + /** @type {Object?} The cache */ + this.cache = null; + /** @type {number?} The query ID of the query whos results are currently cached */ + this.cacheQueryID = null; + /** @type {boolean} Are any of our inner tokens cacheable? */ + this.hasCacheable = false; + } + + /** + * @typedef CacheEntry + * @property {Token[][]} tokenCaches + * @property {TokenProvider[][]} providerCaches + */ + + /** + * Adds token providers to this token provider group. + * @param {TokenProvider[]} providers + * @param {boolean} legal Are the results of this provider legal in the current context? + */ + pushProviders(providers, legal = true) { + if (!this.hasCacheable) + for (const provider of providers) { + if (provider.shouldCache) { + this.hasCacheable = true; + break; + } + } + if (legal) this.providers.push(...providers); + else this.illegalProviders.push(...providers); + } + + *parseTokens(query, idx) { + // If none of our providers are cacheable, just parse all the tokens again + if (!this.hasCacheable) { + for (const provider of this.providers) yield* provider.parseTokens(query, idx, false); + return; + } + + // If the query ID has changed, the cache is no longer valid + if (this.cacheQueryID !== query.id) { + this.cache = []; + this.cacheQueryID = query.id; + } else { + // Otherwise, search for a cache entry for idx + const cacheEntry = this.cache[idx]; + if (cacheEntry) { + // If we find one, yield all the cached results + const tokenCaches = cacheEntry.tokenCaches; + const providerCaches = cacheEntry.providerCaches; + for (let i = 0; i < tokenCaches.length; i++) { + const tokenCache = tokenCaches[i]; + const providerCache = providerCaches[i]; + for (const provider of providerCache) yield* provider.parseTokens(query, idx, false); + yield* tokenCache; + } + return; + } + } + + // No applicable cache entry was found :( + // Call all our child token providers and create a new cache entry + + let tokenCache = []; + let providerCache = []; + + const tokenCaches = [tokenCache]; + const providerCaches = [providerCache]; + this.cache[idx] = { tokenCaches, providerCaches }; + + for (const provider of this.providers) { + if (provider.shouldCache) { + for (const token of provider.parseTokens(query, idx, false)) { + tokenCache.push(token); + yield token; + } + } else { + if (tokenCache.length !== 0) { + tokenCache = []; + providerCache = []; + tokenCaches.push(tokenCache); + providerCaches.push(providerCache); + } + providerCache.push(provider); + yield* provider.parseTokens(query, idx, false); + } + } + for (const provider of this.illegalProviders) { + for (let token of provider.parseTokens(query, idx, false)) { + token = { ...token, isLegal: false }; + tokenCache.push(token); + yield token; + } + } + } +} + +/** + * A class representing the type of a token (see {@link Token.type}) + * + * All token types extend from {@link TokenProvider} and they provide all the tokens + * of their type they can find. + * + * @abstract + */ +class TokenType extends TokenProvider { + constructor(dontCache = false) { + super(!dontCache); + + if (this.constructor === TokenType) throw new Error("Abstract classes can't be instantiated."); + + /** + * If we see this token, should we know what block it's connected to? + * + * For example, in the query 'say Hi', 'say' is a defining feature because + * we can narrow down what block it's from based only the fact that it's present. + * 'Hi', however, is not a defining feature as it could be a part of lots of + * different blocks. + * + * This is used to help eliminate some dodgey interpretations of queries, if a block + * has no subtokens marked a defining feature it's disguarded. + * @type {boolean} + */ + this.isDefiningFeature = false; + /** @type {boolean} Is this token type always represented by the same string of characters? */ + this.isConstant = false; + } + + /** + * Turns `token` into a value which can be passed into the {@link BlockInstance} constructor. + * For example, in string literal tokens, this gets the string value of the token which can then + * be used to create a block. + * @param {Token} token + * @param {QueryInfo} query + * @returns {*} + */ + createBlockValue(token, query) { + return token.value; + } + + /** + * Creates the string form of this token in the same format that was used in the query. + * If the token was only partially typed in the query, creating the text will complete the token. + * @param {Token} token + * @param {QueryInfo} query + * @param {boolean} endOnly Should we only append to the end of the query. If this is false, we + * can create text in the middle of the query that wasn't there. This is used to autocomplete + * {@link StringEnum.GriffTokenType} tokens in the middle of a query. + * @returns {string} + */ + createText(token, query, endOnly) { + throw new Error("Sub-class must override abstract method."); + } + + /** + * @param {Token} token + * @param {QueryInfo} query + * @returns {Token[]} + */ + getSubtokens(token, query) { + return undefined; + } +} + +/** + * The type for tokens that represent an omitted field. + * Used by {@link TokenProviderOptional} + */ +class TokenTypeBlank extends TokenType { + static INSTANCE = new TokenTypeBlank(); + + constructor() { + super(); + this.isConstant = true; + } + + *parseTokens(query, idx) { + yield this.createToken(idx); + } + + /** + * Create a new blank token + * @param {number} idx The position of the blank token + * @returns {Token} + */ + createToken(idx) { + return new Token(idx, idx, this, null, -5000); + } + + createText(token, query) { + return ""; + } +} + +/** + * Represents a token whos value must be one of a predetermined set of strings. + * For example, a token for a dropdown menu (like the one in `set [my variable] to x`) is a + * string enum, because the value must be one of a set of strings. + * + * String enums are also used for values that can only be one specific value (like the 'set' from + * `set [my variable] to x`). These cases are just string enums with one possible value. + */ +class TokenTypeStringEnum extends TokenType { + /** + * @typedef StringEnumValue + * @property {string} value The string that needs to be in the query + * @property {string} lower Cached value.toLowerCase() + * @property {string[]} parts lower, split up by ignoreable characters. + */ + + /** + * @param {(import("./BlockTypeInfo").BlockInputEnumOption[]} values + */ + constructor(values) { + super(); + this.isConstant = values.length === 1; + this.isDefiningFeature = true; + + /** @type {StringEnumValue[]} */ + this.values = []; + for (const value of values) { + let lower = value.string.toLowerCase(); + // Strip emoji + lower = lower.replaceAll(/\p{Extended_Pictographic}/gu, ""); + const parts = []; + { + let lastPart = 0; + for (let i = 0; i <= lower.length; i++) { + const char = lower[i]; + if (QueryInfo.IGNORABLE_CHARS.indexOf(char) !== -1 || !char) { + parts.push(lower.substring(lastPart, i)); + i = QueryInfo.skipIgnorable(lower, i); + lastPart = i; + } + } + } + this.values.push({ lower, parts, value }); + } + } + + *parseTokens(query, idx) { + for (let valueIdx = 0; valueIdx < this.values.length; valueIdx++) { + const valueInfo = this.values[valueIdx]; + let yieldedToken = false; + + const remainingChar = query.length - idx; + if (remainingChar < valueInfo.lower.length) { + if (valueInfo.lower.startsWith(query.lowercase.substring(idx))) { + const end = remainingChar < 0 ? 0 : query.length; + yield new Token(idx, end, this, valueInfo, 100000, undefined, true); + yieldedToken = true; + } + } else { + if ( + query.lowercase.startsWith(valueInfo.lower, idx) && + TokenTypeStringLiteral.TERMINATORS.indexOf(query.lowercase[idx + valueInfo.lower.length]) !== -1 + ) { + yield new Token(idx, idx + valueInfo.lower.length, this, valueInfo, 100000); + yieldedToken = true; + } + } + } + } + + createBlockValue(token, query) { + return token.value.value; + } + + createText(token, query, endOnly) { + if (!token) return this.values[0].lower; + return token.value.lower; + } +} + +/** + * The token type for a literal string, like 'Hello World' in the query `say Hello World` + */ +class TokenTypeStringLiteral extends TokenType { + static TERMINATORS = [undefined, " ", "+", "-", "*", "/", "=", "<", ">", ")"]; + + /** + * Each time we encounter a 'terminator' we have to return the string we've read so far as a + * possible interpretation. If we didn't, when looking for a string at index 4 of 'say Hello + * World for 10 seconds' we would just return 'Hello World for 10 seconds', leading to the + * only result being `say "Hello World for 10 seconds"`. This also means in addition to + * 'Hello World' we also return 'Hello', 'Hello World for', 'Hello World for 10' and ' + * Hello World for 10 seconds', but that's just the price we pay for trying to enumerate every + * interpretation. + */ + *parseTokens(query, idx) { + // First, look for strings in quotes + let quoteEnd = -1; + if (query.str[idx] === '"' || query.str[idx] === '"') { + const quote = query.str[idx]; + for (let i = idx + 1; i <= query.length; i++) { + if (query.str[i] === "\\") { + ++i; + } else if (query.str[i] === quote) { + yield new Token(idx, i + 1, this, query.str.substring(idx + 1, i), 100000); + quoteEnd = i + 1; + break; + } + } + } + // Then all the other strings + let wasTerminator = false, + wasIgnorable = false; + for (let i = idx; i <= query.length; i++) { + const isTerminator = TokenTypeStringLiteral.TERMINATORS.indexOf(query.str[i]) !== -1; + if (wasTerminator !== isTerminator && !wasIgnorable && i !== idx && i !== quoteEnd) { + const value = query.str.substring(idx, i); + let score = -10; + if (TokenTypeNumberLiteral.isValidNumber(value)) score = 1000; + yield new Token(idx, i, this, value, score); + } + wasTerminator = isTerminator; + wasIgnorable = QueryInfo.IGNORABLE_CHARS.indexOf(query.str[i]) !== -1; + } + } + + createText(token, query, endOnly) { + return query.str.substring(token.start, token.end); + } +} + +/** + * The token type for a literal number, like 69 in the query 'Hello + 69' + * This token type also supports numbers in formats scratch doesn't let you type, + * but accepts like '0xFF', 'Infinity' or '1e3'. + */ +class TokenTypeNumberLiteral extends TokenType { + static isValidNumber(str) { + return !isNaN(str) && !isNaN(parseFloat(str)); + } + + *parseTokens(query, idx) { + for (let i = idx; i <= query.length; i++) { + if (TokenTypeStringLiteral.TERMINATORS.indexOf(query.str[i]) !== -1 && i !== idx) { + const value = query.str.substring(idx, i); + if (TokenTypeNumberLiteral.isValidNumber(value)) { + yield new Token(idx, i, this, value, 100000); + break; + } + } + } + } + + createText(token, query, endOnly) { + return query.str.substring(token.start, token.end); + } +} + +/** + * A token type for literal colors, like '#ffffff' for white. + */ +class TokenTypeColor extends TokenType { + static INSTANCE = new TokenProviderOptional(new TokenTypeColor()); + static HEX_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]; + + *parseTokens(query, idx) { + if (!query.str.startsWith("#", idx)) return; + for (let i = 0; i < 6; i++) { + if (TokenTypeColor.HEX_CHARS.indexOf(query.lowercase[idx + i + 1]) === -1) return; + } + yield new Token(idx, idx + 7, this, query.str.substring(idx, idx + 7)); + } + + createText(token, query, endOnly) { + return query.query.substring(token.start, token.end); + } +} + +/** + * A token type for tokens that are in brackets, like (1 + 1) in '(1 + 1) * 2'. + */ +class TokenTypeBrackets extends TokenType { + /** + * @param {TokenProvider} tokenProvider + */ + constructor(tokenProvider) { + super(); + /** @type {TokenProvider} The tokens to look for between the brackets */ + this.tokenProvider = tokenProvider; + } + + *parseTokens(query, idx) { + const start = idx; + if (query.str[idx++] !== "(") return; + idx = query.skipIgnorable(idx); + for (const token of this.tokenProvider.parseTokens(query, idx)) { + if (token.type instanceof TokenTypeBlank) continue; // Do not allow empty brackets like '()' + var tokenEnd = query.skipIgnorable(token.end); + let isTruncated = token.isTruncated; + if (!isTruncated) { + if (tokenEnd === query.length) isTruncated = true; + else if (query.str[tokenEnd] === ")") ++tokenEnd; + else continue; + } + // Note that for bracket tokens, precedence = 0 + const newToken = new Token(start, tokenEnd, this, token.value, token.score + 100, 0, isTruncated, token.isLegal); + newToken.innerToken = token; + yield newToken; + } + } + + createBlockValue(token, query) { + return token.innerToken.createBlockValue(token.innerToken, query); + } + + createText(token, query, endOnly) { + let text = "("; + text += query.str.substring(token.start + 1, token.innerToken.start); + text += token.innerToken.type.createText(token.innerToken, query, endOnly); + if (token.innerToken.end !== token.end) text += query.str.substring(token.innerToken.end, token.end - 1); + text += ")"; + return text; + } + + getSubtokens(token, query) { + return [token.innerToken]; + } +} + +/** + * The token type for a block, like 'say Hello' or '1 + 1'. + */ +class TokenTypeBlock extends TokenType { + /** + * @param {WorkspaceQuerier} querier + * @param {BlockInstance} block + * @private + */ + constructor(querier, block) { + super(); + this.block = block; + this.hasSubTokens = true; + /** + * The list of token types that make up this block. + * + * For example, for the non-griff version of the 'say' block this array would contains two + * providers, the first is a {@link StringEnum.FullTokenType} containing only the value 'say' + * and the second is equal to querier.tokenGroupString. + * + * @type {TokenProvider[]} + */ + this.fullTokenProviders = []; + + for (const blockPart of block.parts) { + let fullTokenProvider; + if (typeof blockPart === "string") { + fullTokenProvider = new TokenTypeStringEnum([{ value: null, string: blockPart }]); + } else { + switch (blockPart.type) { + case BlockInputType.ENUM: + fullTokenProvider = new TokenTypeStringEnum(blockPart.values); + if (blockPart.isRound) { + const enumGroup = new TokenProviderGroup(); + enumGroup.pushProviders([fullTokenProvider, querier.tokenGroupRoundBlocks]); + fullTokenProvider = enumGroup; + } + break; + case BlockInputType.STRING: + fullTokenProvider = querier.tokenGroupString; + break; + case BlockInputType.NUMBER: + fullTokenProvider = querier.tokenGroupNumber; + break; + case BlockInputType.COLOUR: + fullTokenProvider = TokenTypeColor.INSTANCE; + break; + case BlockInputType.BOOLEAN: + fullTokenProvider = querier.tokenGroupBoolean; + break; + case BlockInputType.BLOCK: + fullTokenProvider = querier.tokenGroupStack; + break; + } + } + this.fullTokenProviders.push(fullTokenProvider); + } + + /** + * @type {{strings: string[], inputs: [], score: number}[]} + */ + this.stringForms = []; + + const enumerateStringForms = (partIdx = 0, strings = [], inputs = []) => { + for (; partIdx < block.parts.length; partIdx++) { + let blockPart = block.parts[partIdx]; + if (typeof blockPart === "string") { + strings.push(...blockPart.toLowerCase().split(" ")); + } else if (blockPart.type === BlockInputType.ENUM) { + for (const enumValue of blockPart.values) { + enumerateStringForms( + partIdx + 1, + [...strings, ...enumValue.string.toLowerCase().split(" ")], + [...inputs, enumValue] + ); + } + return; + } else { + inputs.push(null); + } + } + const flattedMap = strings.flatMap((s) => s.length); + const score = -10 * (flattedMap.length ? flattedMap.reduce((a, b) => a + b + 1) : 1); + this.stringForms.push({ strings, inputs, score }); + }; + + enumerateStringForms(); + } + + /** + * + * @param {QueryInfo} query + * @param {*} idx + * @returns + */ + *parseTokens(query, idx) { + let yieldedTokens = false; + + for (const subtokens of this._parseSubtokens(query, idx, this.fullTokenProviders)) { + let token = this._createToken(query, idx, this.fullTokenProviders, subtokens); + if (token) { + yield token; + yieldedTokens = true; + } + } + + if (yieldedTokens) return; + + outer: for (const stringForm of this.stringForms) { + let lastPartIdx = -1; + let i = idx; + let hasDefiningFeature = false; + + while (i < query.length) { + i = query.skipIgnorable(i); + + const wordEnd = query.skipUnignorable(i); + + if (wordEnd === i) { + yield new Token(idx, wordEnd, this, { stringForm, lastPartIdx: -1 }, stringForm.score, -1, false); + } else { + const word = query.lowercase.substring(i, wordEnd); + let match = -1; + + for (let formPartIdx = lastPartIdx + 1; formPartIdx < stringForm.strings.length; formPartIdx++) { + const stringFormPart = stringForm.strings[formPartIdx]; + + if (stringFormPart.startsWith(word)) { + match = formPartIdx; + break; + } + } + + if (match === -1) continue outer; + lastPartIdx = match; + + hasDefiningFeature ||= !TokenTypeNumberLiteral.isValidNumber(word); + + if (hasDefiningFeature) + yield new Token(idx, wordEnd, this, { stringForm, lastPartIdx, i }, stringForm.score, -1, false); + i = wordEnd; + } + } + } + } + + /** + * @private + * @param {QueryInfo} query + * @param {number} idx + * @param {TokenProvider[]} subtokenProviders + * @param {Token[]} subtokens + * @returns {Token?} + */ + _createToken(query, idx, subtokenProviders, subtokens) { + subtokens.reverse(); + let score = 0; + let isLegal = true; + let isTruncated = subtokens.length < subtokenProviders.length; + let hasDefiningFeature = false; + + // Calculate the score of this block, through a lot of arbitrary math that seems to work ok. + + for (const subtoken of subtokens) { + isTruncated |= subtoken.isTruncated; // If any of our kids are truncated, so are we + isLegal &&= subtoken.isLegal; // If any of our kids are illegal, so are we + if (!subtoken.isTruncated) score += subtoken.score; + else score += subtoken.score / 100000 - 10; // Big score penalty if truncated + if (subtoken.type.isDefiningFeature && subtoken.start < query.length) hasDefiningFeature = true; + } + score += Math.floor(1000 * (subtokens.length / subtokenProviders.length)); + + /** See {@link TokenType.isDefiningFeature} */ + if (!hasDefiningFeature) return null; + const end = query.skipIgnorable(subtokens[subtokens.length - 1].end); + return new Token(idx, end, this, { subtokens }, score, this.block.precedence, isTruncated, isLegal); + } + + /** + * Parse all the tokens from this.tokenProviders[tokenProviderIdx] then + * recursively call this for the next token. Returns a list of tokens for + * each combination of possible interpretations of the subtokens. + * + * Note that the tokens in the returned token arrays are in reverse to the + * order of their providers in this.tokenProviders, just to confuse you :P + * + * @private + * @param {QueryInfo} query + * @param {number} idx + * @param {TokenProvider[]} subtokenProviders + * @param {number} tokenProviderIdx + * @param {boolean} parseSubSubTokens + * @yields {Token[]} + */ + *_parseSubtokens(query, idx, subtokenProviders, tokenProviderIdx = 0, parseSubSubTokens = true) { + idx = query.skipIgnorable(idx); + let tokenProvider = subtokenProviders[tokenProviderIdx]; + + for (const token of tokenProvider.parseTokens(query, idx)) { + ++query.tokenCount; + if (!query.canCreateMoreTokens()) break; + + if (this.block.precedence !== -1) { + // If we care about the precedence of this block + // Discard this token if its precedence is higher than ours, meaning it should be calculated + // before us not afterward. + if (token.precedence > this.block.precedence) continue; + /** + * This check eliminates thousands of results by making sure blocks with equal precedence + * can only contain themselves as their own first input. Without this, the query '1 + 2 + 3' + * would have two interpretations '(1 + 2) + 3' and '1 + (2 + 3)'. This rule makes the second + * of those invalid because the root '+' block contains itself as its third token. + */ + if (token.precedence === this.block.precedence && tokenProviderIdx !== 0) continue; + } + + if (!parseSubSubTokens || !token.isLegal || tokenProviderIdx === subtokenProviders.length - 1) { + yield [token]; + } else { + for (const subTokenArr of this._parseSubtokens( + query, + token.end, + subtokenProviders, + tokenProviderIdx + 1, + !token.isTruncated + )) { + subTokenArr.push(token); + yield subTokenArr; + } + } + } + } + + createBlockValue(token, query) { + if (!token.isLegal) throw new Error("Cannot create a block from an illegal token."); + let blockInputs; + + if (token.value.stringForm) { + blockInputs = token.value.stringForm.inputs; + } else { + const subtokens = token.value.subtokens; + blockInputs = []; + for (let i = 0; i < subtokens.length; i++) { + const blockPart = this.block.parts[i]; + if (typeof blockPart !== "string") blockInputs.push(subtokens[i].createBlockValue(query)); + } + while (blockInputs.length < this.block.inputs.length) blockInputs.push(null); + } + + return this.block.createBlock(...blockInputs); + } + + createText(token, query, endOnly) { + if (token.value.stringForm) { + if (endOnly) { + if (token.value.lastPartIdx === -1) { + return query.str.substring(token.start, token.end); + } else { + return ( + query.str.substring(token.start, token.end) + + token.value.stringForm.strings[token.value.lastPartIdx].substring(token.end - token.value.i) + + " " + + token.value.stringForm.strings.slice(token.value.lastPartIdx + 1).join(" ") + ); + } + } + + return token.value.stringForm.strings.join(" "); + } + if (!token.isTruncated && endOnly) return query.str.substring(token.start, token.end); + const subtokens = token.value.subtokens; + let text = ""; + if (token.start !== subtokens[0].start) { + text += query.str.substring(token.start, subtokens[0].start); + } + let i; + for (i = 0; i < subtokens.length; i++) { + const subtoken = subtokens[i]; + const subtokenText = subtoken.type.createText(subtoken, query, endOnly) ?? ""; + text += subtokenText; + if (i !== subtokens.length - 1) { + const next = subtokens[i + 1]; + const nextStart = next.start; + if (nextStart !== subtoken.end) { + text += query.str.substring(subtoken.end, nextStart); + } else { + if ( + (!endOnly || nextStart >= query.length) && + subtokenText.length !== 0 && + QueryInfo.IGNORABLE_CHARS.indexOf(subtokenText.at(-1)) === -1 + ) + text += " "; + } + } + } + for (; i < this.fullTokenProviders.length; i++) { + const provider = this.fullTokenProviders[i]; + if (!provider.isConstant) break; + if (!text.endsWith(" ")) text += " "; + text += provider.createText(); + } + return text; + } + + getSubtokens(token, query) { + return token.value.subtokens; + } +} + +/** + * A single interpretation of a query. + */ +export class QueryResult { + constructor(query, token) { + /** + * The query that this is a result of. + * @type {QueryInfo} + */ + this.query = query; + /** + * The root token of this result. + * + * The root token is a token which encapsules the entire query. + * @type {Token} + */ + this.token = token; + } + + get isTruncated() { + return this.token.isTruncated; + } + + /** + * @param {boolean} endOnly + * @returns {string} + */ + toText(endOnly) { + return this.token.type.createText(this.token, this.query, endOnly) ?? ""; + } + + /** + * @returns {BlockInstance} + */ + createBlock() { + return this.token.createBlockValue(this.query); + } +} + +/** + * Information on the current query being executed, with some utility + * functions for helping out token providers. + */ +class QueryInfo { + /** Characters that can be safely skipped over. */ + static IGNORABLE_CHARS = [" "]; + + constructor(querier, query, id) { + /** @type {WorkspaceQuerier} */ + this.querier = querier; + /** @type {string} The query */ + this.str = query.replaceAll(String.fromCharCode(160), " "); + /** @type {string} A lowercase version of the query. Used for case insensitive comparisons. */ + this.lowercase = this.str.toLowerCase(); + /** @type {number} A unique identifier for this query */ + this.id = id; + /** @type{number} The number of tokens we've found so far */ + this.tokenCount = 0; + } + + /** + * @param {string} str + * @param {number} idx The index to start at. + * @returns {number} The index of the next non-ignorable character in str, after idx. + */ + static skipIgnorable(str, idx) { + while (QueryInfo.IGNORABLE_CHARS.indexOf(str[idx]) !== -1) ++idx; + return idx; + } + + /** + * @param {number} idx The index to start at. + * @returns {number} The index of the next non-ignorable character in the query, after idx. + */ + skipIgnorable(idx) { + return QueryInfo.skipIgnorable(this.lowercase, idx); + } + + /** + * @param {string} str + * @param {number} idx The index to start at. + * @returns {number} The index of the next ignorable character in str, after idx. + */ + static skipUnignorable(str, idx) { + while (QueryInfo.IGNORABLE_CHARS.indexOf(str[idx]) === -1 && idx < str.length) ++idx; + return idx; + } + + /** + * @param {number} idx The index to start at. + * @returns {number} The index of the next ignorable character in the query, after idx. + */ + skipUnignorable(idx) { + return QueryInfo.skipUnignorable(this.lowercase, idx); + } + + /** @type {number} The length in characters of the query. */ + get length() { + return this.str.length; + } + + canCreateMoreTokens() { + return this.tokenCount < WorkspaceQuerier.MAX_TOKENS; + } +} + +/** + * Workspace queriers keep track of all the data needed to query a given workspace (referred to as + * the 'workspace index') and provides the methods to execute queries on the indexed workspace. + */ +export default class WorkspaceQuerier { + static ORDER_OF_OPERATIONS = [ + null, // brackets + "operator_round", + "operator_mathop", + "operator_mod", + "operator_divide", + "operator_multiply", + "operator_subtract", + "operator_add", + "operator_equals", + "operator_lt", + "operator_gt", + "operator_or", + "operator_and", + "operator_not", + ]; + + static CATEGORY_PRIORITY = ["control", "events", "data", "operators"]; + + /** + * An artificial way to increase the score of common blocks so they show up first. + */ + static SCORE_BUMP = { + control_if: 100000, + control_if_else: 100000, + data_setvariableto: 99999, + }; + + /** + * The maximum number of results to find before giving up. + */ + static MAX_RESULTS = 1000; + + /** + * The maximum number of tokens to find before giving up. + */ + static MAX_TOKENS = 10000; + + /** + * Indexes a workspace in preparation for querying it. + * @param {BlockTypeInfo[]} blocks The list of blocks in the workspace. + */ + indexWorkspace(blocks) { + this._queryCounter = 0; + this._createTokenGroups(); + this._populateTokenGroups(blocks); + this.workspaceIndexed = true; + } + + /** + * Queries the indexed workspace for blocks matching the query string. + * @param {string} queryStr The query. + * @returns {{results: QueryResult[], illegalResult: QueryResult | null, limited: boolean}} A list of the results of the query, sorted by their relevance score. + */ + queryWorkspace(queryStr) { + if (!this.workspaceIndexed) throw new Error("A workspace must be indexed before it can be queried!"); + if (queryStr.trim().length === 0) return { results: [], illegalResult: null, limited: false }; + + const query = new QueryInfo(this, queryStr, this._queryCounter++); + const results = []; + let foundTokenCount = 0; + let bestIllegalResult = null; + let limited = false; + + for (const option of this.tokenGroupBlocks.parseTokens(query, 0)) { + if (option.end >= queryStr.length) { + if (option.isLegal) { + option.score += WorkspaceQuerier.SCORE_BUMP[option.type.block.id] ?? 0; + results.push(new QueryResult(query, option)); + } else if (!bestIllegalResult || option.score >= bestIllegalResult.token.score) { + bestIllegalResult = new QueryResult(query, option); + } + } + ++foundTokenCount; + if (foundTokenCount > WorkspaceQuerier.MAX_RESULTS) { + console.log("Warning: Workspace query exceeded maximum result count."); + limited = true; + break; + } + if (!query.canCreateMoreTokens()) { + console.log("Warning: Workspace query exceeded maximum token count."); + limited = true; + break; + } + } + + // Eliminate blocks who's strings can be parsed as something else. + // This step removes silly suggestions like `if <(1 + 1) = "2 then"> then` + const canBeString = Array(queryStr.length).fill(true); + function searchToken(token) { + const subtokens = token.type.getSubtokens(token, query); + if (subtokens) for (const subtoken of subtokens) searchToken(subtoken); + else if (!(token.type instanceof TokenTypeStringLiteral) && !token.isTruncated) + for (let i = token.start; i < token.end; i++) { + canBeString[i] = false; + } + } + for (const result of results) searchToken(result.token); + function checkValidity(token) { + const subtokens = token.type.getSubtokens(token, query); + if (subtokens) { + for (const subtoken of subtokens) if (!checkValidity(subtoken)) return false; + } else if (token.type instanceof TokenTypeStringLiteral && !TokenTypeNumberLiteral.isValidNumber(token.value)) { + for (let i = token.start; i < token.end; i++) if (!canBeString[i]) return false; + } + return true; + } + const validResults = []; + for (const result of results) if (checkValidity(result.token)) validResults.push(result); + + return { + results: validResults.sort((a, b) => b.token.score - a.token.score), + illegalResult: bestIllegalResult, + limited, + }; + } + + /** + * Creates the token group hierarchy used by this querier. + * + * Each of these token groups represents a list of all the tokens that could be encountered + * when we're looking for a specific type of input. For example, tokenGroupString contains all + * the tokens that could be encountered when we're looking for a string input (like after the + * word 'say' for the `say ()` block). tokenGroupBlocks is an interesting one, it contains all + * the tokens that could be the root token of a query result. In practice, this just means all + * the stackable blocks (like 'say') and all the reporter blocks (like '+'). + * + * But wait, there's a problem. Blocks like `() + ()` have two inputs, both of which are numbers. + * The issue arises when you realize the block '+' itself also returns a number. So when we + * try to call parseTokens on the '+' block, it will try to look for it's first parameter thus + * calling parseTokens on tokenGroupNumber, which will call parseTokens on the '+' block again + * (because + can return a number) which will call tokenGroupNumber again... and we're in an + * infinite loop. We can't just exclude blocks from being their own first parameter because then + * queries like '1 + 2 + 3' wouldn't work. The solution is something you might have only thought + * of as a performance boost; caching. When tokenGroupNumber gets queried for the second time, + * it's mid way though building its cache from the first query. If this happens, it just returns + * all the tokens it had already found, but no more. So in the example above, when the + block calls + * tokenGroupNumber for the second time it finds only the number literal '1'. It then finds the + * second number literal '2' and yields the block '1 + 2' which gets added to tokenGroupNumber's + * cache. '1 + 2' then gets disguarded by the queryWorkspace function because it doesn't cover the + * whole query. But the '+' block's query to tokenGroupNumber never finished, so it will continue + * and, because the first one we found is now a part of the cache, tokenGroupNumber will yield + * '1 + 2' as a result. The + block will continue parsing, find the second '+' and the number '3' + * and yield '(1 + 2) + 3'. No infinite loops! + * + * A consequence of this system is something I implicitly implied in the above paragraph "when the + * + block calls tokenGroupNumber for the second time it finds only the number literal '1'" This + * is only true if 'TokenTypeNumberLiteral' is searched before the '+' block. This is why the order + * the token providers are in is critically important. I'll leave it as an exercise to the reader to + * work out why, but the same parsing order problems crops up when implementing order of operations. + * If a suggestion that should show up isn't showing up, it's probably because the token providers + * in one of the groups is in the wrong order. Ordering the providers within the base groups is dealt + * with by {@link _populateTokenGroups} and the inter-group ordering is dealt with below, by the + * order they are passed into pushProviders. + * + * @private + */ + _createTokenGroups() { + this.tokenTypeStringLiteral = new TokenProviderSingleCache(new TokenTypeStringLiteral()); + this.tokenTypeNumberLiteral = new TokenProviderSingleCache(new TokenTypeNumberLiteral()); + + this.tokenGroupRoundBlocks = new TokenProviderGroup(); // Round blocks like (() + ()) or (my variable) + this.tokenGroupBooleanBlocks = new TokenProviderGroup(); // Boolean blocks like + this.tokenGroupStackBlocks = new TokenProviderGroup(); // Stackable blocks like `move (10) steps` + this.tokenGroupHatBlocks = new TokenProviderGroup(); // Hat block like `when green flag clicked` + + // Anything that fits into a boolean hole. (Boolean blocks + Brackets) + this.tokenGroupBoolean = new TokenProviderOptional(new TokenProviderGroup()); + this.tokenGroupBoolean.inner.pushProviders([ + this.tokenGroupBooleanBlocks, + new TokenTypeBrackets(this.tokenGroupBoolean), + ]); + this.tokenGroupBoolean.inner.pushProviders([this.tokenGroupRoundBlocks], false); + + // Anything that fits into a number hole. (Round blocks + Boolean blocks + Number Literals + Brackets) + this.tokenGroupNumber = new TokenProviderOptional(new TokenProviderGroup()); + this.tokenGroupNumber.inner.pushProviders([ + this.tokenTypeNumberLiteral, + this.tokenGroupRoundBlocks, + this.tokenGroupBooleanBlocks, + new TokenTypeBrackets(this.tokenGroupNumber), + ]); + + // Anything that fits into a string hole (Round blocks + Boolean blocks + String Literals + Brackets) + this.tokenGroupString = new TokenProviderOptional(new TokenProviderGroup()); + this.tokenGroupString.inner.pushProviders([ + this.tokenTypeStringLiteral, + this.tokenGroupRoundBlocks, + this.tokenGroupBooleanBlocks, + new TokenTypeBrackets(this.tokenGroupString), + ]); + + // Anything that fits into a c shaped hole (Stackable blocks) + this.tokenGroupStack = new TokenProviderOptional(this.tokenGroupStackBlocks); + + // Anything you can spawn using the menu (All blocks) + this.tokenGroupBlocks = new TokenProviderGroup(); + this.tokenGroupBlocks.pushProviders([ + this.tokenGroupStackBlocks, + this.tokenGroupBooleanBlocks, + this.tokenGroupRoundBlocks, + this.tokenGroupHatBlocks, + ]); + } + + /** + * Populates the token groups created by {@link _createTokenGroups} with the blocks + * found in the workspace. + * @param {BlockTypeInfo[]} blocks The list of blocks in the workspace. + * @private + */ + _populateTokenGroups(blocks) { + blocks.sort( + (a, b) => + WorkspaceQuerier.CATEGORY_PRIORITY.indexOf(b.category.name) - + WorkspaceQuerier.CATEGORY_PRIORITY.indexOf(a.category.name) + ); + + // Apply order of operations + for (const block of blocks) { + block.precedence = WorkspaceQuerier.ORDER_OF_OPERATIONS.indexOf(block.id); + } + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.precedence !== -1) { + const target = blocks.length - (WorkspaceQuerier.ORDER_OF_OPERATIONS.length - (block.precedence - 1)); + if (i !== target) { + const oldBlock = blocks[target]; + blocks[target] = block; + blocks[i] = oldBlock; + } + } + } + + for (const block of blocks) { + const blockTokenType = new TokenTypeBlock(this, block); + switch (block.shape) { + case BlockShape.Round: + this.tokenGroupRoundBlocks.pushProviders([blockTokenType]); + break; + case BlockShape.Boolean: + this.tokenGroupBooleanBlocks.pushProviders([blockTokenType]); + break; + case BlockShape.Stack: + case BlockShape.End: + this.tokenGroupStackBlocks.pushProviders([blockTokenType]); + break; + case BlockShape.Hat: + this.tokenGroupHatBlocks.pushProviders([blockTokenType]); + break; + } + } + } + + /** + * Clears the memory used by the workspace index. + */ + clearWorkspaceIndex() { + this.workspaceIndexed = false; + this._destroyTokenGroups(); + } + + /** + * @private + */ + _destroyTokenGroups() { + this.tokenTypeStringLiteral = null; + this.tokenTypeNumberLiteral = null; + + this.tokenGroupBooleanBlocks = null; + this.tokenGroupRoundBlocks = null; + this.tokenGroupStackBlocks = null; + this.tokenGroupHatBlocks = null; + this.tokenGroupBoolean = null; + this.tokenGroupNumber = null; + this.tokenGroupString = null; + this.tokenGroupStack = null; + this.tokenGroupBlocks = null; + } +} diff --git a/src/addons/addons/middle-click-popup/_manifest_entry.js b/src/addons/addons/middle-click-popup/_manifest_entry.js index 5e4c1dac28b..051f7628f12 100644 --- a/src/addons/addons/middle-click-popup/_manifest_entry.js +++ b/src/addons/addons/middle-click-popup/_manifest_entry.js @@ -26,6 +26,32 @@ const manifest = { "url": "userstyle.css" } ], + "settings": [ + { + "name": "Popup Block Size", + "id": "popup_scale", + "type": "integer", + "min": 1, + "max": 100, + "default": 64 + }, + { + "name": "Popup Default Width", + "id": "popup_width", + "type": "integer", + "min": 1, + "max": 100, + "default": 16 + }, + { + "name": "Popup Max Search Height", + "id": "popup_max_height", + "type": "integer", + "min": 1, + "max": 100, + "default": 40 + } + ], "info": [ { "text": "This addon was previously part of the \"developer tools\" addon but has moved here.", diff --git a/src/addons/addons/middle-click-popup/module.js b/src/addons/addons/middle-click-popup/module.js new file mode 100644 index 00000000000..91ad01fa061 --- /dev/null +++ b/src/addons/addons/middle-click-popup/module.js @@ -0,0 +1,37 @@ +const textWidthCache = new Map(); +const textWidthCacheSize = 1000; + +const eventTarget = new EventTarget(); +const eventClearTextCache = "clearTextCache"; + +/** + * Gets the width of an svg text element, with caching. + * @param {SVGTextElement} textElement + */ +export function getTextWidth(textElement) { + let string = textElement.innerHTML; + if (string.length === 0) return 0; + let width = textWidthCache.get(string); + if (width) return width; + width = textElement.getBoundingClientRect().width; + textWidthCache.set(string, width); + if (textWidthCache.size > textWidthCacheSize) { + textWidthCache.delete(textWidthCache.keys().next()); + } + return width; +} + +/** + * Clears the text width cache of the middle click popup. + */ +export function clearTextWidthCache() { + textWidthCache.clear(); + eventTarget.dispatchEvent(new CustomEvent(eventClearTextCache)); +} + +/** + * @param {() => void} func + */ +export function onClearTextWidthCache(func) { + eventTarget.addEventListener(eventClearTextCache, func); +} diff --git a/src/addons/addons/middle-click-popup/userscript.js b/src/addons/addons/middle-click-popup/userscript.js index 7a23ea6446a..ed86b5a3ec5 100644 --- a/src/addons/addons/middle-click-popup/userscript.js +++ b/src/addons/addons/middle-click-popup/userscript.js @@ -1,492 +1,496 @@ -export default async function ({ addon, msg, console }) { - const Blockly = await addon.tab.traps.getBlockly(); - let mouse = { x: 0, y: 0 }; +//@ts-check - class FloatingInput { - constructor() { - this.floatBar = null; - this.floatInput = null; - this.dropdownOut = null; - this.dropdown = null; +import WorkspaceQuerier, { QueryResult } from "./WorkspaceQuerier.js"; +import renderBlock, { BlockComponent, getBlockHeight } from "./BlockRenderer.js"; +import { BlockInstance, BlockShape, BlockTypeInfo } from "./BlockTypeInfo.js"; +import { onClearTextWidthCache } from "./module.js"; - this.prevVal = ""; - - this.DROPDOWN_BLOCK_LIST_MAX_ROWS = 25; +export default async function ({ addon, msg, console }) { + const Blockly = await addon.tab.traps.getBlockly(); + const vm = addon.tab.traps.vm; + + const PREVIEW_LIMIT = 50; + + const popupRoot = document.body.appendChild(document.createElement("div")); + popupRoot.classList.add("sa-mcp-root"); + popupRoot.dir = addon.tab.direction; + popupRoot.style.display = "none"; + + const popupContainer = popupRoot.appendChild(document.createElement("div")); + popupContainer.classList.add("sa-mcp-container"); + + const popupInputContainer = popupContainer.appendChild(document.createElement("div")); + popupInputContainer.classList.add(addon.tab.scratchClass("input_input-form")); + popupInputContainer.classList.add("sa-mcp-input-wrapper"); + + const popupInputSuggestion = popupInputContainer.appendChild(document.createElement("input")); + popupInputSuggestion.classList.add("sa-mcp-input-suggestion"); + + const popupInput = popupInputContainer.appendChild(document.createElement("input")); + popupInput.classList.add("sa-mcp-input"); + popupInput.setAttribute("autocomplete", "off"); + + const popupPreviewContainer = popupContainer.appendChild(document.createElement("div")); + popupPreviewContainer.classList.add("sa-mcp-preview-container"); + + const popupPreviewScrollbarSVG = popupContainer.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "svg") + ); + popupPreviewScrollbarSVG.classList.add( + "sa-mcp-preview-scrollbar", + "blocklyScrollbarVertical", + "blocklyMainWorkspaceScrollbar" + ); + popupPreviewScrollbarSVG.style.display = "none"; + + const popupPreviewScrollbarBackground = popupPreviewScrollbarSVG.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "rect") + ); + popupPreviewScrollbarBackground.setAttribute("width", "11"); + popupPreviewScrollbarBackground.classList.add("blocklyScrollbarBackground"); + + const popupPreviewScrollbarHandle = popupPreviewScrollbarSVG.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "rect") + ); + popupPreviewScrollbarHandle.setAttribute("rx", "3"); + popupPreviewScrollbarHandle.setAttribute("ry", "3"); + popupPreviewScrollbarHandle.setAttribute("width", "6"); + popupPreviewScrollbarHandle.setAttribute("x", "2.5"); + popupPreviewScrollbarHandle.classList.add("blocklyScrollbarHandle"); + + const popupPreviewBlocks = popupPreviewContainer.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "svg") + ); + popupPreviewBlocks.classList.add("sa-mcp-preview-blocks"); + + const querier = new WorkspaceQuerier(); + + let mousePosition = { x: 0, y: 0 }; + document.addEventListener("mousemove", (e) => { + mousePosition = { x: e.clientX, y: e.clientY }; + }); - this.createDom(); - } + onClearTextWidthCache(closePopup); + + /** + * @typedef ResultPreview + * @property {BlockInstance} block + * @property {((endOnly: boolean) => string)?} autocompleteFactory + * @property {BlockComponent} renderedBlock + * @property {SVGGElement} svgBlock + * @property {SVGRectElement} svgBackground + */ + /** @type {ResultPreview[]} */ + let queryPreviews = []; + /** @type {QueryResult | null} */ + let queryIllegalResult = null; + let selectedPreviewIdx = 0; + /** @type {BlockTypeInfo[]?} */ + let blockTypes = null; + let limited = false; + + let allowMenuClose = true; + let popupPosition = null; + + let previewWidth = 0; + let previewHeight = 0; + + let previewScale = 0; + + let previewMinHeight = 0; + let previewMaxHeight = 0; + + function openPopup() { + if (addon.self.disabled) return; + + // Don't show the menu if we're not in the code editor + if (addon.tab.redux.state.scratchGui.editorTab.activeTabIndex !== 0) return; + + blockTypes = BlockTypeInfo.getBlocks(Blockly, vm, Blockly.getMainWorkspace(), msg); + querier.indexWorkspace([...blockTypes]); + blockTypes.sort((a, b) => { + const prio = (block) => ["operators", "data"].indexOf(block.category.name) - block.id.startsWith("data_"); + return prio(b) - prio(a); + }); + + previewScale = window.innerWidth * 0.00005 + addon.settings.get("popup_scale") / 100; + previewWidth = (window.innerWidth * addon.settings.get("popup_width")) / 100; + previewMaxHeight = (window.innerHeight * addon.settings.get("popup_max_height")) / 100; + + popupContainer.style.width = previewWidth + "px"; + + popupPosition = { x: mousePosition.x + 16, y: mousePosition.y - 8 }; + popupRoot.style.top = popupPosition.y + "px"; + popupRoot.style.left = popupPosition.x + "px"; + popupRoot.style.display = ""; + popupInput.value = ""; + popupInput.focus(); + updateInput(); + } - get workspace() { - return Blockly.getMainWorkspace(); + function closePopup() { + if (allowMenuClose) { + popupPosition = null; + popupRoot.style.display = "none"; + blockTypes = null; + querier.clearWorkspaceIndex(); } + } - get selectedTab() { - return addon.tab.redux.state.scratchGui.editorTab.activeTabIndex; + popupInput.addEventListener("input", updateInput); + + function updateInput() { + /** + * @typedef MenuItem + * @property {BlockInstance} block + * @property {(endOnly: boolean) => string} [autocompleteFactory] + */ + /** @type {MenuItem[]} */ + const blockList = []; + + if (popupInput.value.trim().length === 0) { + queryIllegalResult = null; + if (blockTypes) + for (const blockType of blockTypes) { + blockList.push({ + block: blockType.createBlock(), + }); + } + limited = false; + } else { + // Get the list of blocks to display using the input content + const queryResultObj = querier.queryWorkspace(popupInput.value); + const queryResults = queryResultObj.results; + queryIllegalResult = queryResultObj.illegalResult; + limited = queryResultObj.limited; + + if (queryResults.length > PREVIEW_LIMIT) queryResults.length = PREVIEW_LIMIT; + + for (const queryResult of queryResults) { + blockList.push({ + block: queryResult.createBlock(), + autocompleteFactory: (endOnly) => queryResult.toText(endOnly), + }); + } } - createDom() { - // Popup new input box for block injection - this.floatBar = document.body.appendChild(document.createElement("div")); - this.floatBar.className = "sa-float-bar"; - this.floatBar.dir = addon.tab.direction; - this.floatBar.style.display = "none"; - - this.dropdownOut = this.floatBar.appendChild(document.createElement("div")); - this.dropdownOut.className = "sa-float-bar-dropdown-out"; - - this.floatInput = this.dropdownOut.appendChild(document.createElement("input")); - this.floatInput.placeholder = msg("start-typing"); - this.floatInput.className = "sa-float-bar-input"; - this.floatInput.className = addon.tab.scratchClass("input_input-form", { - others: "sa-float-bar-input", - }); - - this.dropdown = this.dropdownOut.appendChild(document.createElement("ul")); - this.dropdown.className = "sa-float-bar-dropdown"; + // @ts-ignore Delete the old previews + while (popupPreviewBlocks.firstChild) popupPreviewBlocks.removeChild(popupPreviewBlocks.lastChild); - this.floatInput.addEventListener("keyup", () => this.inputChange()); - this.floatInput.addEventListener("focus", () => this.inputChange()); - this.floatInput.addEventListener("keydown", (...e) => this.inputKeyDown(...e)); - this.floatInput.addEventListener("focusout", () => this.hide()); + // Create the new previews + queryPreviews.length = 0; + let y = 0; + for (let resultIdx = 0; resultIdx < blockList.length; resultIdx++) { + const result = blockList[resultIdx]; - this.dropdownOut.addEventListener("mousedown", (...e) => this.onClick(...e)); + const mouseMoveListener = () => { + updateSelection(resultIdx); + }; - document.addEventListener("keydown", (e) => { - if (addon.tab.editorMode !== "editor") { - return; - } + const mouseDownListener = (e) => { + e.stopPropagation(); + e.preventDefault(); + updateSelection(resultIdx); + allowMenuClose = !e.shiftKey; + selectBlock(); + allowMenuClose = true; + if (e.shiftKey) popupInput.focus(); + else closePopup(); + }; - let ctrlKey = e.ctrlKey || e.metaKey; + const svgBackground = popupPreviewBlocks.appendChild( + document.createElementNS("http://www.w3.org/2000/svg", "rect") + ); - if (e.key === " " && ctrlKey) { - // Ctrl + Space (Inject Code) - this.show(e); - e.cancelBubble = true; - e.preventDefault(); - return true; - } + const height = getBlockHeight(result.block); + svgBackground.setAttribute("transform", `translate(0, ${(y + height / 10) * previewScale})`); + svgBackground.setAttribute("height", height * previewScale + "px"); + svgBackground.classList.add("sa-mcp-preview-block-bg"); + svgBackground.addEventListener("mousemove", mouseMoveListener); + svgBackground.addEventListener("mousedown", mouseDownListener); + + const svgBlock = popupPreviewBlocks.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "g")); + svgBlock.addEventListener("mousemove", mouseMoveListener); + svgBlock.addEventListener("mousedown", mouseDownListener); + svgBlock.classList.add("sa-mcp-preview-block"); + + const renderedBlock = renderBlock(result.block, svgBlock); + + queryPreviews.push({ + block: result.block, + autocompleteFactory: result.autocompleteFactory ?? null, + renderedBlock, + svgBlock, + svgBackground, }); - } - show(e) { - if (this.selectedTab !== 0) { - return; - } - - e.cancelBubble = true; - e.preventDefault(); - - this.buildFilterList(); - - this.floatBar.style.left = (e.clientX ?? mouse.x) + 16 + "px"; - this.floatBar.style.top = (e.clientY ?? mouse.y) - 8 + "px"; - this.floatBar.style.display = ""; - this.floatInput.value = ""; - this.floatInput.focus(); + y += height; } - onClick(e) { - e.cancelBubble = true; - if (!e.target.closest("input")) { - e.preventDefault(); - } - - let sel = e && e.target; - if (sel.tagName === "B") { - sel = sel.parentNode; - } + const height = (y + 8) * previewScale; - if (e instanceof MouseEvent && sel.tagName !== "LI") { - // Mouse clicks need to be on a block... - return; - } + if (height < previewMinHeight) previewHeight = previewMinHeight; + else if (height > previewMaxHeight) previewHeight = previewMaxHeight; + else previewHeight = height; - if (!sel || !sel.data) { - sel = this.dropdown.querySelector(".sel"); - } + popupPreviewBlocks.setAttribute("height", `${height}px`); + popupPreviewContainer.style.height = previewHeight + "px"; + popupPreviewScrollbarSVG.style.height = previewHeight + "px"; + popupPreviewScrollbarBackground.setAttribute("height", "" + previewHeight); + popupInputContainer.dataset["error"] = "" + limited; - if (!sel) { - return; - } + selectedPreviewIdx = -1; + updateSelection(0); + updateCursor(); + updateScrollbar(); + } - this.createDraggingBlock(sel, e); + function updateSelection(newIdx) { + if (selectedPreviewIdx === newIdx) return; - if (e.shiftKey) { - this.floatBar.style.display = ""; - this.floatInput.focus(); - } + const oldSelection = queryPreviews[selectedPreviewIdx]; + if (oldSelection) { + oldSelection.svgBackground.classList.remove("sa-mcp-preview-block-bg-selection"); + oldSelection.svgBlock.classList.remove("sa-mcp-preview-block-selection"); } - createDraggingBlock(sel, e) { - let option = sel.data.option; - // block:option.block, dom:option.dom, option:option.option - if (option.option) { - // We need to tweak the dropdown in this xml... - let field = option.dom.querySelector("field[name=" + option.pickField + "]"); - if (field.getAttribute("id")) { - field.innerText = option.option[0]; - field.setAttribute("id", option.option[1] + "-" + option.option[0]); - } else { - field.innerText = option.option[1]; - } + if (queryPreviews.length === 0 && queryIllegalResult) { + popupInputSuggestion.value = + popupInput.value + queryIllegalResult.toText(true).substring(popupInput.value.length); + return; + } - // Handle "stop other scripts in sprite" - if (option.option[1] === "other scripts in sprite") { - option.dom.querySelector("mutation").setAttribute("hasnext", "true"); - } - } + const newSelection = queryPreviews[newIdx]; + if (newSelection && newSelection.autocompleteFactory) { + newSelection.svgBackground.classList.add("sa-mcp-preview-block-bg-selection"); + newSelection.svgBlock.classList.add("sa-mcp-preview-block-selection"); - // This is mostly copied from https://github.com/LLK/scratch-blocks/blob/893c7e7ad5bfb416eaed75d9a1c93bdce84e36ab/core/scratch_blocks_utils.js#L171 - // Some bits were removed or changed to fit our needs. - this.workspace.setResizesEnabled(false); + newSelection.svgBackground.scrollIntoView({ + block: "nearest", + behavior: Math.abs(newIdx - selectedPreviewIdx) > 1 ? "smooth" : "auto", + }); - Blockly.Events.disable(); - try { - var newBlock = Blockly.Xml.domToBlock(option.dom, this.workspace); + popupInputSuggestion.value = + popupInput.value + newSelection.autocompleteFactory(true).substring(popupInput.value.length); + } else { + popupInputSuggestion.value = ""; + } - Blockly.scratchBlocksUtils.changeObscuredShadowIds(newBlock); + selectedPreviewIdx = newIdx; + } - var svgRootNew = newBlock.getSvgRoot(); - if (!svgRootNew) { - throw new Error("newBlock is not rendered."); - } + // @ts-ignore + document.addEventListener("selectionchange", updateCursor); - let blockBounds = newBlock.svgPath_.getBoundingClientRect(); - let newBlockX = Math.floor((mouse.x - (blockBounds.left + blockBounds.right) / 2) / this.workspace.scale); - let newBlockY = Math.floor((mouse.y - (blockBounds.top + blockBounds.bottom) / 2) / this.workspace.scale); - newBlock.moveBy(newBlockX, newBlockY); - } finally { - Blockly.Events.enable(); - } - if (Blockly.Events.isEnabled()) { - Blockly.Events.fire(new Blockly.Events.BlockCreate(newBlock)); - } + function updateCursor() { + const cursorPos = popupInput.selectionStart ?? 0; + const cursorPosRel = popupInput.value.length === 0 ? 0 : cursorPos / popupInput.value.length; - var fakeEvent = { - clientX: mouse.x, - clientY: mouse.y, - type: "mousedown", - preventDefault: function () { - e.preventDefault(); - }, - stopPropagation: function () { - e.stopPropagation(); - }, - target: sel, - }; - this.workspace.startDragWithFakeEvent(fakeEvent, newBlock); - } + let y = 0; + for (let previewIdx = 0; previewIdx < queryPreviews.length; previewIdx++) { + const preview = queryPreviews[previewIdx]; - inputChange() { - // Filter the list... - let val = (this.floatInput.value || "").toLowerCase(); - if (val === this.prevVal) { - return; - } + var blockX = 5; + // pm: The pop-up can be resized, so we dont need to account for this actually + // if (blockX + preview.renderedBlock.width > previewWidth / previewScale) + // blockX += (previewWidth / previewScale - blockX - preview.renderedBlock.width) * previewScale * cursorPosRel; + var blockY = (y + 30) * previewScale; - this.prevVal = val; - - let p = this.dropdown.parentNode; - this.dropdown.remove(); - - let count = 0; - - let split = val.split(" "); - let listLI = this.dropdown.getElementsByTagName("li"); - for (const li of listLI) { - const procCode = li.data.text; - const lower = li.data.lower; - // let i = li.data.lower.indexOf(val); - // let array = regExp.exec(li.data.lower); - - let im = 0; - let match = []; - for (let si = 0; si < split.length; si++) { - let find = " " + split[si]; - let idx = lower.indexOf(find, im); - if (idx === -1) { - match = null; - break; - } - match.push(idx); - im = idx + find.length; - } + // if (preview.block.typeInfo.shape.canBeRound) { + // preview.svgBlock.setAttribute("transform", `translate(${blockX}, ${blockY}) scale(${previewScale})`); + // } else { + // preview.svgBlock.setAttribute("transform", `translate(6, ${blockY}) scale(${previewScale})`); + // } + preview.svgBlock.setAttribute("transform", `translate(${blockX}, ${blockY}) scale(${previewScale})`); - if (count < this.DROPDOWN_BLOCK_LIST_MAX_ROWS && match) { - li.style.display = "block"; - while (li.firstChild) { - li.removeChild(li.firstChild); - } - - let i = 0; - - for (let iM = 0; iM < match.length; iM++) { - let im = match[iM]; - if (im > i) { - li.appendChild(document.createTextNode(procCode.substring(i, im))); - i = im; - } - let bText = document.createElement("b"); - let len = split[iM].length; - bText.appendChild(document.createTextNode(procCode.substr(i, len))); - li.appendChild(bText); - i += len; - } - - if (i < procCode.length) { - li.appendChild(document.createTextNode(procCode.substr(i))); - } - - if (count === 0) { - li.classList.add("sel"); - } else { - li.classList.remove("sel"); - } - count++; - } else { - li.style.display = "none"; - li.classList.remove("sel"); - } - } - p.append(this.dropdown); + y += getBlockHeight(preview.block); } - inputKeyDown(e) { - if (e.keyCode === 38) { - this.navigateFloatFilter(-1); - e.preventDefault(); - return; - } - if (e.keyCode === 40) { - this.navigateFloatFilter(1); - e.preventDefault(); - return; - } - if (e.keyCode === 13) { - // Enter - let sel = this.dropdown.querySelector(".sel"); - if (sel) { - this.onClick(e); - this.hide(); - } - e.cancelBubble = true; - e.preventDefault(); - return; - } - if (e.keyCode === 27) { - // Escape - if (this.floatInput.value.length > 0) { - this.floatInput.value = ""; // Clear search first, then close on second press - this.inputChange(e); - } else { - this.hide(); - } - e.preventDefault(); - return; - } - } + popupInputSuggestion.scrollLeft = popupInput.scrollLeft; + } - buildFilterList() { - let options = []; + popupPreviewContainer.addEventListener("scroll", updateScrollbar); - let toolbox = this.workspace.getToolbox(); + function updateScrollbar() { + const scrollTop = popupPreviewContainer.scrollTop; + const scrollY = popupPreviewContainer.scrollHeight; - // This can happen during custom block creation, for example - if (!toolbox) return; + if (scrollY <= previewHeight) { + popupPreviewScrollbarSVG.style.display = "none"; + return; + } - let blocks = toolbox.flyout_.getWorkspace().getTopBlocks(); - // 107 blocks, not in order... but we can sort by y value or description right :) + const scrollbarHeight = (previewHeight / scrollY) * previewHeight; + const scrollbarY = (scrollTop / scrollY) * previewHeight; - let fullDom = Blockly.Xml.workspaceToDom(toolbox.flyout_.getWorkspace()); - const doms = {}; - for (const x of fullDom.children) { - if (x.tagName === "BLOCK") { - let id = x.getAttribute("id"); - doms[id] = x; - } - } + // pm: whats the point of the scroll bar if it doesnt work? + popupPreviewScrollbarSVG.style.display = "none"; + popupPreviewScrollbarHandle.setAttribute("height", "" + scrollbarHeight); + popupPreviewScrollbarHandle.setAttribute("y", "" + scrollbarY); + } - for (const block of blocks) { - this.getBlockText(block, options, doms); - } + function selectBlock() { + const selectedPreview = queryPreviews[selectedPreviewIdx]; + if (!selectedPreview) return; - options.sort((a, b) => - a.desc.length < b.desc.length ? -1 : a.desc.length > b.desc.length ? 1 : a.desc.localeCompare(b.desc) - ); + const workspace = Blockly.getMainWorkspace(); + // This is mostly copied from https://github.com/scratchfoundation/scratch-blocks/blob/893c7e7ad5bfb416eaed75d9a1c93bdce84e36ab/core/scratch_blocks_utils.js#L171 + // Some bits were removed or changed to fit our needs. + workspace.setResizesEnabled(false); - let count = 0; + let newBlock; + Blockly.Events.disable(); + try { + newBlock = selectedPreview.block.createWorkspaceForm(); + Blockly.scratchBlocksUtils.changeObscuredShadowIds(newBlock); - while (this.dropdown.firstChild) { - this.dropdown.removeChild(this.dropdown.firstChild); - } - for (const option of options) { - const li = document.createElement("li"); - const desc = option.desc; - - // bType = hat block reporter boolean - - let bType = this.getEdgeTypeClass(option.block); - - count++; - - li.innerText = desc; - li.data = { text: desc, lower: " " + desc.toLowerCase(), option: option }; - - const blockTypes = { - // Some of these blocks in the flyout have a category of `null` for some reason, the - // same as procedures. Without making bigger changes to the custom block color system - // hardcoding these is the best solution for now. - sensing_of: "sensing", - event_whenbackdropswitchesto: "events", - }; - let ending = option.block.getCategory() || blockTypes[option.block.type] || "null"; - if (option.block.isScratchExtension) { - ending = "pen"; - } else if (addon.tab.getCustomBlock(option.block.procCode_)) { - ending = "addon-custom-block"; - } - - li.className = "sa-block-color sa-block-color-" + ending + " sa-" + bType; - if (count > this.DROPDOWN_BLOCK_LIST_MAX_ROWS) { - // Limit maximum number of rows to prevent lag when no filter is applied - li.style.display = "none"; - } - this.dropdown.appendChild(li); + var svgRootNew = newBlock.getSvgRoot(); + if (!svgRootNew) { + throw new Error("newBlock is not rendered."); } - this.dropdownOut.classList.add("vis"); + let blockBounds = newBlock.svgPath_.getBoundingClientRect(); + let newBlockX = Math.floor((mousePosition.x - (blockBounds.left + blockBounds.right) / 2) / workspace.scale); + let newBlockY = Math.floor((mousePosition.y - (blockBounds.top + blockBounds.bottom) / 2) / workspace.scale); + newBlock.moveBy(newBlockX, newBlockY); + } finally { + Blockly.Events.enable(); + } + if (Blockly.Events.isEnabled()) { + Blockly.Events.fire(new Blockly.Events.BlockCreate(newBlock)); } - navigateFloatFilter(dir) { - let sel = this.dropdown.getElementsByClassName("sel"); - let nxt; - if (sel.length > 0 && sel[0].style.display !== "none") { - nxt = dir === -1 ? sel[0].previousSibling : sel[sel.length - 1].nextSibling; - } else { - nxt = this.dropdown.children[0]; - dir = 1; - } - while (nxt && nxt.style.display === "none") { - nxt = dir === -1 ? nxt.previousSibling : nxt.nextSibling; - } - if (nxt) { - for (const i of sel) { - i.classList.remove("sel"); - } - nxt.classList.add("sel"); - // centerTop(nxt.data.labelID); - } + let fakeEvent = { + clientX: mousePosition.x, + clientY: mousePosition.y, + type: "mousedown", + stopPropagation: function () {}, + preventDefault: function () {}, + target: selectedPreview.svgBlock, + }; + if (workspace.getGesture(fakeEvent)) { + workspace.startDragWithFakeEvent(fakeEvent, newBlock); } + } - getBlockText(block, options, doms) { - // block.type; "looks_nextbackdrop" - - let desc; - let picklist, pickField; - - let dom = doms[block.id]; - - // dom = doms[block.type]; - - const process = (block) => { - for (const input of block.inputList) { - // input.name = "", input.type = 5 - let fields = input.fieldRow; - for (const field of fields) { - // field --- Blockly.FieldLabel .className = "blocklyText" - // Blockly.FieldDropdown --- .className = "blocklyText blocklyDropdownText" - - let text; - - if (!picklist && field.className_ === "blocklyText blocklyDropdownText") { - picklist = field.getOptions(); - pickField = field.name; - if (picklist && picklist.length > 0) { - text = "^^"; - } else { - text = field.getText(); - } - } else { - text = field.getText(); - } - - desc = (desc ? desc + " " : "") + text; - } - - if (input.connection) { - let innerBlock = input.connection.targetBlock(); - if (innerBlock) { - process(innerBlock); // Recursive process connected child blocks... - } - } - } - }; + function acceptAutocomplete() { + let factory; + if (queryPreviews[selectedPreviewIdx]) factory = queryPreviews[selectedPreviewIdx].autocompleteFactory; + else factory = () => popupInputSuggestion.value; + if (popupInputSuggestion.value.length === 0 || !factory) return; + popupInput.value = factory(false); + // Move cursor to the end of the newly inserted text + popupInput.selectionStart = popupInput.value.length + 1; + updateInput(); + } - process(block); - - if (picklist) { - for (const item of picklist) { - let code = item[1]; - if ( - typeof code !== "string" || // Audio Record is a function! - code === "DELETE_VARIABLE_ID" || - code === "RENAME_VARIABLE_ID" || - code === "NEW_BROADCAST_MESSAGE_ID" || - code === "NEW_BROADCAST_MESSAGE_ID" || - // editor-searchable-dropdowns compatibility - code === "createGlobalVariable" || - code === "createLocalVariable" || - code === "createGlobalList" || - code === "createLocalList" || - code === "createBroadcast" || - // rename-broadcasts compatibility - code === "RENAME_BROADCAST_MESSAGE_ID" - ) { - continue; // Skip these - } - options.push({ - desc: desc.replace("^^", item[0]), - block: block, - dom: dom, - option: item, - pickField: pickField, - }); + popupInput.addEventListener("keydown", (e) => { + switch (e.key) { + case "Escape": + // If there's something in the input, clear it + if (popupInput.value.length > 0) { + popupInput.value = ""; + updateInput(); + } else { + // If not, close the menu + closePopup(); } - } else { - options.push({ desc: desc, block: block, dom: dom }); - } - - return desc; + e.stopPropagation(); + e.preventDefault(); + break; + case "Tab": + acceptAutocomplete(); + e.stopPropagation(); + e.preventDefault(); + break; + case "Enter": + selectBlock(); + closePopup(); + e.stopPropagation(); + e.preventDefault(); + break; + case "ArrowDown": + if (selectedPreviewIdx + 1 >= queryPreviews.length) updateSelection(0); + else updateSelection(selectedPreviewIdx + 1); + e.stopPropagation(); + e.preventDefault(); + break; + case "ArrowUp": + if (selectedPreviewIdx - 1 < 0) updateSelection(queryPreviews.length - 1); + else updateSelection(selectedPreviewIdx - 1); + e.stopPropagation(); + e.preventDefault(); + break; } + }); - getEdgeTypeClass(block) { - switch (block.edgeShape_) { - case 1: - return "boolean"; - case 2: - return "reporter"; - default: - return block.startHat_ ? "hat" : "block"; - } + // pm: lets check other stuff before deciding we should close out (ie: scrolling with middle-click shouldnt close popup) + // popupInput.addEventListener("focusout", closePopup); + + let closablePopup = true; + popupInput.addEventListener("focusout", () => { + if (closablePopup) { + closePopup(); } + }); + + popupContainer.addEventListener("mousedown", () => { + popupInput.focus(); + }); + popupContainer.addEventListener("mousemove", () => { + popupInput.focus(); + }); + + popupContainer.addEventListener("mouseenter", () => { + closablePopup = false; + popupInput.focus(); + }); + popupContainer.addEventListener("mouseleave", () => { + closablePopup = true; + popupInput.focus(); + }); - hide() { - this.floatBar.style.display = "none"; + // pm: whats the point of the scroll bar if it doesnt work? + // popupPreviewScrollbarSVG.addEventListener("mousedown", () => { + // closablePopup = false; + // }); + // popupPreviewScrollbarSVG.addEventListener("mouseup", () => { + // setTimeout(() => { + // closablePopup = true; + // popupInput.focus(); + // }, 1); + // }); + + // Open on ctrl + space + document.addEventListener("keydown", (e) => { + if (e.key === " " && (e.ctrlKey || e.metaKey)) { + openPopup(); + e.preventDefault(); + e.stopPropagation(); } - } - const floatingInput = new FloatingInput(); + }); + // Open on mouse wheel button const _doWorkspaceClick_ = Blockly.Gesture.prototype.doWorkspaceClick_; Blockly.Gesture.prototype.doWorkspaceClick_ = function () { - if (!addon.self.disabled && (this.mostRecentEvent_.button === 1 || this.mostRecentEvent_.shiftKey)) { - // Wheel button... - floatingInput.show(this.mostRecentEvent_); - } - + if (this.mostRecentEvent_.button === 1 || this.mostRecentEvent_.shiftKey) openPopup(); + mousePosition = { x: this.mostRecentEvent_.clientX, y: this.mostRecentEvent_.clientY }; _doWorkspaceClick_.call(this); }; - document.addEventListener("mousemove", (e) => { - mouse = { x: e.clientX, y: e.clientY }; - }); + // The popup should delete blocks dragged ontop of it + const _isDeleteArea = Blockly.WorkspaceSvg.prototype.isDeleteArea; + Blockly.WorkspaceSvg.prototype.isDeleteArea = function (e) { + if (popupPosition) { + if ( + e.clientX > popupPosition.x && + e.clientX < popupPosition.x + previewWidth && + e.clientY > popupPosition.y && + e.clientY < popupPosition.y + previewHeight + ) { + return Blockly.DELETE_AREA_TOOLBOX; + } + } + return _isDeleteArea.call(this, e); + }; } diff --git a/src/addons/addons/middle-click-popup/userstyle.css b/src/addons/addons/middle-click-popup/userstyle.css index 5ba05791ce3..852a9153e45 100644 --- a/src/addons/addons/middle-click-popup/userstyle.css +++ b/src/addons/addons/middle-click-popup/userstyle.css @@ -1,165 +1,117 @@ @import url("../editor-theme3/compatibility.css"); -/* Find Input Box */ -.sa-float-bar-input { - width: 100%; - box-sizing: border-box !important; - /* !important required for extension, because CSS injection method (and hence order) differs from addon */ - height: 1.5rem; +.sa-mcp-root { + display: flex; + white-space: nowrap; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - /* Change Scratch default styles */ - border-radius: 0.25rem; - font-size: 0.75rem; - padding-left: 0.4em; -} -[theme="dark"] input.s3devInp { - color: #eee; - background: #3333; -} -[theme="dark"] input.s3devInp:hover { - background: #333; -} + position: absolute; + min-width: 100px; + background-color: white; + border-radius: 4px; + box-shadow: + rgba(0, 0, 0, 0.3) 0 0 3px, + rgba(0, 0, 0, 0.2) 0 3px 10px; -.sa-float-bar-input:focus { - /* Change Scratch default styles */ - box-shadow: none; + z-index: 999; } -/* Drop down from find button */ -.sa-float-bar-dropdown-out { - display: block; +.sa-mcp-container { + display: flex; + flex-flow: column; top: -6px; z-index: 100; - max-width: 16em; - padding: 4px; position: absolute; - width: 16em; - box-shadow: 0px 0px 8px 1px var(--ui-black-transparent, rgba(0, 0, 0, 0.3)); - background-color: var(--ui-primary, white); + box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.3); + background-color: white; border: none; border-radius: 4px; + overflow: auto; + resize: both; +} +[theme="dark"] .sa-mcp-container { + background-color: #222; } -/* Drop down from find button */ -.sa-float-bar-dropdown { - display: none; +.sa-mcp-input-wrapper { position: relative; - padding: 0.2em 0; - font-size: 0.75rem; - line-height: 1; - overflow-y: auto; - min-height: 128px; - user-select: none; - max-width: 100%; - max-height: 200px; - margin-bottom: 0; -} + margin: 4px; + /* !important required for extension, because CSS injection method (and hence order) differs from addon */ + box-sizing: border-box !important; + height: 1.5rem; + min-height: 1.5rem; -.sa-float-bar-dropdown-out.vis .sa-float-bar-dropdown { - display: block; - border: none; + /* Change Scratch default styles */ + border-radius: 0.25rem; + font-size: 0.75rem; + padding-left: 0.2rem; + padding-right: 0.2rem; } -/* Drop down items */ -.sa-float-bar-dropdown > li { - display: block; - padding: 0.5em 0.3em; - white-space: nowrap; - margin: 0; - font-weight: bold; - text-overflow: ellipsis; - overflow: hidden; - cursor: pointer; +.sa-mcp-input-wrapper:focus { + /* Change Scratch default styles */ + box-shadow: none; } -.sa-float-bar-dropdown > li > b { - background-color: #aaffaa; - color: black; +.sa-mcp-input-wrapper[data-error="true"] { + border-color: red; } -.sa-float-bar-dropdown > li { - height: 19px; - padding: 3px 8px; - margin: 2px 0.3em; +.sa-mcp-input-wrapper > input { + position: absolute; + border: 0; + background-color: transparent; + outline: none; + width: 100%; + height: 100%; + line-height: 100%; box-sizing: border-box; - position: relative; - background-color: var(--sa-block-colored-background); - color: var(--sa-block-text); - font-weight: bold; - width: min-content; -} -.sa-float-bar-dropdown > li:hover, -.sa-float-bar-dropdown > li.sel { - background-color: var(--sa-block-colored-background-secondary); } -.sa-float-bar-dropdown > li.sa-hat { - border-radius: 14px 14px 3px 3px; -} -.sa-float-bar-dropdown > li.sa-block { - border-radius: 3px; -} -.sa-float-bar-dropdown > li.sa-reporter { - border-radius: 10px; +.sa-mcp-input-suggestion { + color: hsla(225, 15%, 40%, 0.65); } -.sa-float-bar-dropdown > li.sa-boolean { - width: min-content; +.sa-mcp-preview-container { + flex: auto; + overflow-y: scroll; + scrollbar-width: none; } -.sa-float-bar-dropdown > li.sa-boolean::before { - content: ""; - position: absolute; - left: 0; - top: 0; - width: 0; - height: 0; - border-right: 9px solid transparent; - border-top: 9px solid var(--ui-primary, white); - border-bottom: 10px solid var(--ui-primary, white); -} -.sa-float-bar-dropdown > li.sa-boolean::after { - content: ""; - position: absolute; - right: 0; - top: 0; + +.sa-mcp-preview-container::-webkit-scrollbar { width: 0; height: 0; - border-left: 9px solid transparent; - border-top: 9px solid var(--ui-primary, white); - border-bottom: 10px solid var(--ui-primary, white); -} -[theme="dark"] .s3devDD > li.boolean::before { - border-top-color: #111; - border-bottom-color: #111; -} -[theme="dark"] .s3devDD > li.boolean::after { - border-top-color: #111; - border-bottom-color: #111; } -.sa-float-bar { - display: flex; - white-space: nowrap; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +.sa-mcp-preview-blocks { + width: 100%; + min-height: 100%; + /* https://stackoverflow.com/a/22166728/8448397 */ + float: left; +} +.sa-mcp-preview-scrollbar { position: absolute; - min-width: 128px; - background-color: white; - border-radius: 4px; - box-shadow: rgba(0, 0, 0, 0.3) 0 0 3px, rgba(0, 0, 0, 0.2) 0 3px 10px; + width: 11px; + right: 0; + bottom: 0; +} - z-index: 999; +.sa-mcp-preview-block-bg { + width: 100%; + fill: transparent; + cursor: grab; } -[theme="dark"] #s3devFloatingBar { - background-color: #111; + +.sa-mcp-preview-block { + filter: brightness(95%); + cursor: grab; } -.sa-float-bar-dropdown > li > b { - background-color: rgba(0, 0, 0, 0.6); - color: white; +.sa-mcp-preview-block-selection { + filter: brightness(103%); } -[data-highlighted="true"] { - background-color: hsla(30, 100%, 55%, 1) !important; /* orange */ - color: white !important; +.sa-mcp-preview-block-bg-selection { + fill: #7774; }