Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support keyboard navigation of flyout buttons #7852

Merged
merged 2 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion core/flyout_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -161,6 +161,11 @@ export abstract class Flyout
*/
protected buttons_: FlyoutButton[] = [];

/**
* List of visible buttons and blocks.
*/
protected contents: FlyoutItem[] = [];

/**
* List of event listeners.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 43 additions & 1 deletion core/flyout_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*
Expand Down
75 changes: 74 additions & 1 deletion core/keyboard_nav/ast_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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++) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -740,6 +812,7 @@ export namespace ASTNode {
PREVIOUS = 'previous',
STACK = 'stack',
WORKSPACE = 'workspace',
BUTTON = 'button',
}
}

Expand Down
35 changes: 35 additions & 0 deletions core/renderers/common/marker_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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();
Expand Down