diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 360b3ac2f..58b748433 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -13,7 +13,7 @@ import type {Abstract as AbstractEvent} from './events/events_abstract.js'; import type {Block} from './block.js'; -import type {BlockSvg} from './block_svg.js'; +import {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; import * as common from './common.js'; import {ComponentManager} from './component_manager.js'; @@ -161,6 +161,11 @@ export abstract class Flyout */ protected buttons_: FlyoutButton[] = []; + /** + * List of visible buttons and blocks. + */ + protected contents: FlyoutItem[] = []; + /** * List of event listeners. */ @@ -546,6 +551,32 @@ export abstract class Flyout } } + /** + * Get the list of buttons and blocks of the current flyout. + * + * @returns The array of flyout buttons and blocks. + */ + getContents(): FlyoutItem[] { + return this.contents; + } + + /** + * Store the list of buttons and blocks on the flyout. + * + * @param contents - The array of items for the flyout. + */ + setContents(contents: FlyoutItem[]): void { + const blocksAndButtons = contents.map((item) => { + if (item.type === 'block' && item.block) { + return item.block as BlockSvg; + } + if (item.type === 'button' && item.button) { + return item.button as FlyoutButton; + } + }); + + this.contents = blocksAndButtons as FlyoutItem[]; + } /** * Update the display property of the flyout based whether it thinks it should * be visible and whether its containing workspace is visible. @@ -651,6 +682,8 @@ export abstract class Flyout renderManagement.triggerQueuedRenders(this.workspace_); + this.setContents(flyoutInfo.contents); + this.layout_(flyoutInfo.contents, flyoutInfo.gaps); if (this.horizontalLayout) { diff --git a/core/flyout_button.ts b/core/flyout_button.ts index 498b9b1ad..5b20d24da 100644 --- a/core/flyout_button.ts +++ b/core/flyout_button.ts @@ -20,11 +20,12 @@ import * as style from './utils/style.js'; import {Svg} from './utils/svg.js'; import type * as toolbox from './utils/toolbox.js'; import type {WorkspaceSvg} from './workspace_svg.js'; +import type {IASTNodeLocationSvg} from './blockly.js'; /** * Class for a button or label in the flyout. */ -export class FlyoutButton { +export class FlyoutButton implements IASTNodeLocationSvg { /** The horizontal margin around the text in the button. */ static TEXT_MARGIN_X = 5; @@ -55,6 +56,12 @@ export class FlyoutButton { /** The SVG element with the text of the label or button. */ private svgText: SVGTextElement | null = null; + /** + * Holds the cursors svg element when the cursor is attached to the button. + * This is null if there is no cursor on the button. + */ + cursorSvg: SVGElement | null = null; + /** * @param workspace The workspace in which to place this button. * @param targetWorkspace The flyout's target workspace. @@ -255,6 +262,15 @@ export class FlyoutButton { return this.targetWorkspace; } + /** + * Get the button's workspace. + * + * @returns The workspace in which to place this button. + */ + getWorkspace(): WorkspaceSvg { + return this.workspace; + } + /** Dispose of this button. */ dispose() { if (this.onMouseUpWrapper) { @@ -268,6 +284,32 @@ export class FlyoutButton { } } + /** + * Add the cursor SVG to this buttons's SVG group. + * + * @param cursorSvg The SVG root of the cursor to be added to the button SVG + * group. + */ + setCursorSvg(cursorSvg: SVGElement) { + if (!cursorSvg) { + this.cursorSvg = null; + return; + } + if (this.svgGroup) { + this.svgGroup.appendChild(cursorSvg); + this.cursorSvg = cursorSvg; + } + } + + /** + * Required by IASTNodeLocationSvg, but not used. A marker cannot be set on a + * button. If the 'mark' shortcut is used on a button, its associated callback + * function is triggered. + */ + setMarkerSvg() { + throw new Error('Attempted to set a marker on a button.'); + } + /** * Do something when the button is clicked. * diff --git a/core/keyboard_nav/ast_node.ts b/core/keyboard_nav/ast_node.ts index 97ec9f731..fb20539be 100644 --- a/core/keyboard_nav/ast_node.ts +++ b/core/keyboard_nav/ast_node.ts @@ -21,6 +21,9 @@ import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js'; import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js'; import {Coordinate} from '../utils/coordinate.js'; import type {Workspace} from '../workspace.js'; +import {FlyoutButton} from '../flyout_button.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; +import {Flyout} from '../flyout_base.js'; /** * Class for an AST node. @@ -286,6 +289,9 @@ export class ASTNode { if (!curLocationAsBlock || curLocationAsBlock.isDeadOrDying()) { return null; } + if (curLocationAsBlock.workspace.isFlyout) { + return this.navigateFlyoutContents(forward); + } const curRoot = curLocationAsBlock.getRootBlock(); const topBlocks = curRoot.workspace.getTopBlocks(true); for (let i = 0; i < topBlocks.length; i++) { @@ -304,6 +310,50 @@ export class ASTNode { ); } + /** + * Navigate between buttons and stacks of blocks on the flyout workspace. + * + * @param forward True to go forward. False to go backwards. + * @returns The next button, or next stack's first block, or null + */ + private navigateFlyoutContents(forward: boolean): ASTNode | null { + const nodeType = this.getType(); + let location; + let targetWorkspace; + + switch (nodeType) { + case ASTNode.types.STACK: { + location = this.getLocation() as Block; + const workspace = location.workspace as WorkspaceSvg; + targetWorkspace = workspace.targetWorkspace as WorkspaceSvg; + break; + } + case ASTNode.types.BUTTON: { + location = this.getLocation() as FlyoutButton; + targetWorkspace = location.getTargetWorkspace() as WorkspaceSvg; + break; + } + default: + return null; + } + + const flyout = targetWorkspace.getFlyout() as Flyout; + const flyoutContents = flyout.getContents() as (Block | FlyoutButton)[]; + + const currentIndex = flyoutContents.indexOf(location); + const resultIndex = forward ? currentIndex + 1 : currentIndex - 1; + if (resultIndex === -1 || resultIndex === flyoutContents.length) { + return null; + } + + const newLocation = flyoutContents[resultIndex]; + if (newLocation instanceof FlyoutButton) { + return ASTNode.createButtonNode(newLocation); + } else { + return ASTNode.createStackNode(newLocation); + } + } + /** * Finds the top most AST node for a given block. * This is either the previous connection, output connection or block @@ -385,7 +435,7 @@ export class ASTNode { * Finds the source block of the location of this node. * * @returns The source block of the location, or null if the node is of type - * workspace. + * workspace or button. */ getSourceBlock(): Block | null { if (this.getType() === ASTNode.types.BLOCK) { @@ -394,6 +444,8 @@ export class ASTNode { return this.getLocation() as Block; } else if (this.getType() === ASTNode.types.WORKSPACE) { return null; + } else if (this.getType() === ASTNode.types.BUTTON) { + return null; } else { return (this.getLocation() as IASTNodeLocationWithBlock).getSourceBlock(); } @@ -435,6 +487,8 @@ export class ASTNode { const targetConnection = connection.targetConnection; return ASTNode.createConnectionNode(targetConnection!); } + case ASTNode.types.BUTTON: + return this.navigateFlyoutContents(true); } return null; @@ -513,6 +567,8 @@ export class ASTNode { const connection = this.location as Connection; return ASTNode.createBlockNode(connection.getSourceBlock()); } + case ASTNode.types.BUTTON: + return this.navigateFlyoutContents(false); } return null; @@ -688,6 +744,22 @@ export class ASTNode { return new ASTNode(ASTNode.types.STACK, topBlock); } + /** + * Create an AST node of type button. A button in this case refers + * specifically to a button in a flyout. + * + * @param button A top block has no parent and can be found in the list + * returned by workspace.getTopBlocks(). + * @returns An AST node of type stack that points to the top block on the + * stack. + */ + static createButtonNode(button: FlyoutButton): ASTNode | null { + if (!button) { + return null; + } + return new ASTNode(ASTNode.types.BUTTON, button); + } + /** * Creates an AST node pointing to a workspace. * @@ -740,6 +812,7 @@ export namespace ASTNode { PREVIOUS = 'previous', STACK = 'stack', WORKSPACE = 'workspace', + BUTTON = 'button', } } diff --git a/core/renderers/common/marker_svg.ts b/core/renderers/common/marker_svg.ts index 5581dc317..995783576 100644 --- a/core/renderers/common/marker_svg.ts +++ b/core/renderers/common/marker_svg.ts @@ -24,6 +24,7 @@ import * as svgPaths from '../../utils/svg_paths.js'; import type {WorkspaceSvg} from '../../workspace_svg.js'; import type {ConstantProvider, Notch, PuzzleTab} from './constants.js'; +import {FlyoutButton} from '../../flyout_button.js'; /** The name of the CSS class for a cursor. */ const CURSOR_CLASS = 'blocklyCursor'; @@ -205,6 +206,8 @@ export class MarkerSvg { this.showWithCoordinates_(curNode); } else if (curNode.getType() === ASTNode.types.STACK) { this.showWithStack_(curNode); + } else if (curNode.getType() === ASTNode.types.BUTTON) { + this.showWithButton_(curNode); } } @@ -378,6 +381,38 @@ export class MarkerSvg { this.showCurrent_(); } + /** + * Position and display the marker for a flyout button. + * This is a box with extra padding around the button. + * + * @param curNode The node to draw the marker for. + */ + protected showWithButton_(curNode: ASTNode) { + const button = curNode.getLocation() as FlyoutButton; + + // Gets the height and width of entire stack. + const heightWidth = {height: button.height, width: button.width}; + + // Add padding so that being on a button looks similar to being on a stack. + const width = heightWidth.width + this.constants_.CURSOR_STACK_PADDING; + const height = heightWidth.height + this.constants_.CURSOR_STACK_PADDING; + + // Shift the rectangle slightly to upper left so padding is equal on all + // sides. + const xPadding = -this.constants_.CURSOR_STACK_PADDING / 2; + const yPadding = -this.constants_.CURSOR_STACK_PADDING / 2; + + let x = xPadding; + const y = yPadding; + + if (this.workspace.RTL) { + x = -(width + xPadding); + } + this.positionRect_(x, y, width, height); + this.setParent_(button); + this.showCurrent_(); + } + /** Show the current marker. */ protected showCurrent_() { this.hide();