mirror of
https://github.com/google/blockly.git
synced 2026-01-15 21:07:14 +01:00
_Note: This is a roll forward of #8920 that was reverted in #8933. See Additional Information below._ ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8918 Fixes #8919 Fixes part of #8943 Fixes part of #8771 ### Proposed Changes This updates several classes in order to make toolboxes and flyouts focusable: - `IFlyout` is now an `IFocusableTree` with corresponding implementations in `FlyoutBase`. - `IToolbox` is now an `IFocusableTree` with corresponding implementations in `Toolbox`. - `IToolboxItem` is now an `IFocusableNode` with corresponding implementations in `ToolboxItem`. - As the primary toolbox items, `ToolboxCategory` and `ToolboxSeparator` were updated to have -1 tab indexes and defined IDs to help `ToolboxItem` fulfill its contracted for `IFocusableNode.getFocusableElement`. - `FlyoutButton` is now an `IFocusableNode` (with corresponding ID generation, tab index setting, and ID matching for retrieval in `WorkspaceSvg`). Each of these two new focusable trees have specific noteworthy behaviors behaviors: - `Toolbox` will automatically indicate that its first item should be focused (if one is present), even overriding the ability to focus the toolbox's root (however there are some cases where that can still happen). - `Toolbox` will automatically synchronize its selection state with its item nodes being focused. - `FlyoutBase`, now being a focusable tree, has had a tab index of 0 added. Normally a tab index of -1 is all that's needed, but the keyboard navigation plugin specifically uses 0 for flyout so that the flyout is tabbable. This is a **new** tab stop being introduced. - `FlyoutBase` holds a workspace (for rendering blocks) and, since `WorkspaceSvg` is already set up to be a focusable tree, it's represented as a subtree to `FlyoutBase`. This does introduce some wonky behaviors: the flyout's root will have passive focus while its contents have active focus. This could be manually disabled with some CSS if it ends up being a confusing user experience. - Both `FlyoutBase` and `WorkspaceSvg` have built-in behaviors for detecting when a user tries navigating away from an open flyout to ensure that the flyout is closed when it's supposed to be. That is, the flyout is auto-hideable and a non-flyout, non-toolbox node has then been focused. This matches parity with the `T`/`Esc` flows supported in the keyboard navigation plugin playground. One other thing to note: `Toolbox` had a few tests to update that were trying to reinit a toolbox without first disposing of it (which was caught by one of `FocusManager`'s state guardrails). This only addresses part of #8943: it adds support for `FlyoutButton` which covers both buttons and labels. However, a longer-term solution may be to change `FlyoutItem` itself to force using an `IFocusableNode` as its element. ### Reason for Changes This is part of an ongoing effort to ensure key components of Blockly are focusable so that they can be keyboard-navigable (with other needed changes yet both in Core Blockly and the keyboard navigation plugin). ### Test Coverage No new tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915. ### Documentation No documentation changes should be needed here. ### Additional Information This includes changes that have been pulled from #8875. This was originally merged in #8916 but was reverted in #8933 due to https://github.com/google/blockly-keyboard-experimentation/issues/481. Note that this does contain a number of differences from the original PR (namely, changes in `WorkspaceSvg` and `FlyoutButton` in order to make `FlyoutButton`s focusable). Otherwise, this has the same caveats as those noted in #8938 with regards to the experimental keyboard navigation plugin.
1194 lines
34 KiB
TypeScript
1194 lines
34 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2020 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Toolbox from whence to create blocks.
|
|
*
|
|
* @class
|
|
*/
|
|
// Former goog.module ID: Blockly.Toolbox
|
|
|
|
// Unused import preserved for side-effects. Remove if unneeded.
|
|
import {BlockSvg} from '../block_svg.js';
|
|
import type {BlocklyOptions} from '../blockly_options.js';
|
|
import * as browserEvents from '../browser_events.js';
|
|
import * as common from '../common.js';
|
|
import {ComponentManager} from '../component_manager.js';
|
|
import * as Css from '../css.js';
|
|
import {DeleteArea} from '../delete_area.js';
|
|
import '../events/events_toolbox_item_select.js';
|
|
import {EventType} from '../events/type.js';
|
|
import * as eventUtils from '../events/utils.js';
|
|
import {getFocusManager} from '../focus_manager.js';
|
|
import type {IAutoHideable} from '../interfaces/i_autohideable.js';
|
|
import type {ICollapsibleToolboxItem} from '../interfaces/i_collapsible_toolbox_item.js';
|
|
import {isDeletable} from '../interfaces/i_deletable.js';
|
|
import type {IDraggable} from '../interfaces/i_draggable.js';
|
|
import type {IFlyout} from '../interfaces/i_flyout.js';
|
|
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
|
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
|
import type {IKeyboardAccessible} from '../interfaces/i_keyboard_accessible.js';
|
|
import type {ISelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.js';
|
|
import {isSelectableToolboxItem} from '../interfaces/i_selectable_toolbox_item.js';
|
|
import type {IStyleable} from '../interfaces/i_styleable.js';
|
|
import type {IToolbox} from '../interfaces/i_toolbox.js';
|
|
import type {IToolboxItem} from '../interfaces/i_toolbox_item.js';
|
|
import {Options} from '../options.js';
|
|
import * as registry from '../registry.js';
|
|
import type {KeyboardShortcut} from '../shortcut_registry.js';
|
|
import * as Touch from '../touch.js';
|
|
import * as aria from '../utils/aria.js';
|
|
import * as dom from '../utils/dom.js';
|
|
import {Rect} from '../utils/rect.js';
|
|
import * as toolbox from '../utils/toolbox.js';
|
|
import type {WorkspaceSvg} from '../workspace_svg.js';
|
|
import type {ToolboxCategory} from './category.js';
|
|
import {CollapsibleToolboxCategory} from './collapsible_category.js';
|
|
|
|
/**
|
|
* Class for a Toolbox.
|
|
* Creates the toolbox's DOM.
|
|
*/
|
|
export class Toolbox
|
|
extends DeleteArea
|
|
implements
|
|
IAutoHideable,
|
|
IKeyboardAccessible,
|
|
IStyleable,
|
|
IToolbox,
|
|
IFocusableNode
|
|
{
|
|
/**
|
|
* The unique ID for this component that is used to register with the
|
|
* ComponentManager.
|
|
*/
|
|
override id = 'toolbox';
|
|
protected toolboxDef_: toolbox.ToolboxInfo;
|
|
private readonly horizontalLayout: boolean;
|
|
|
|
/** The HTML container for the toolbox. */
|
|
HtmlDiv: HTMLDivElement | null = null;
|
|
|
|
/** The HTML container for the contents of a toolbox. */
|
|
protected contentsDiv_: HTMLDivElement | null = null;
|
|
|
|
/** Whether the Toolbox is visible. */
|
|
protected isVisible_ = false;
|
|
|
|
/** The width of the toolbox. */
|
|
protected width_ = 0;
|
|
|
|
/** The height of the toolbox. */
|
|
protected height_ = 0;
|
|
RTL: boolean;
|
|
|
|
/** The flyout for the toolbox. */
|
|
private flyout: IFlyout | null = null;
|
|
|
|
/** Map from ID to the corresponding toolbox item. */
|
|
protected contents = new Map<string, IToolboxItem>();
|
|
|
|
toolboxPosition: toolbox.Position;
|
|
|
|
/** The currently selected item. */
|
|
protected selectedItem_: ISelectableToolboxItem | null = null;
|
|
|
|
/** The previously selected item. */
|
|
protected previouslySelectedItem_: ISelectableToolboxItem | null = null;
|
|
|
|
/**
|
|
* Array holding info needed to unbind event handlers.
|
|
* Used for disposing.
|
|
* Ex: [[node, name, func], [node, name, func]].
|
|
*/
|
|
protected boundEvents_: browserEvents.Data[] = [];
|
|
|
|
/** The workspace this toolbox is on. */
|
|
protected readonly workspace_: WorkspaceSvg;
|
|
|
|
/** @param workspace The workspace in which to create new blocks. */
|
|
constructor(workspace: WorkspaceSvg) {
|
|
super();
|
|
|
|
this.workspace_ = workspace;
|
|
|
|
/** The JSON describing the contents of this toolbox. */
|
|
this.toolboxDef_ = workspace.options.languageTree || {
|
|
contents: new Array<toolbox.ToolboxItemInfo>(),
|
|
};
|
|
|
|
/** Whether the toolbox should be laid out horizontally. */
|
|
this.horizontalLayout = workspace.options.horizontalLayout;
|
|
|
|
/** Is RTL vs LTR. */
|
|
this.RTL = workspace.options.RTL;
|
|
|
|
/** Position of the toolbox and flyout relative to the workspace. */
|
|
this.toolboxPosition = workspace.options.toolboxPosition;
|
|
}
|
|
|
|
/**
|
|
* Handles the given keyboard shortcut.
|
|
*
|
|
* @param _shortcut The shortcut to be handled.
|
|
* @returns True if the shortcut has been handled, false otherwise.
|
|
*/
|
|
onShortcut(_shortcut: KeyboardShortcut): boolean {
|
|
return false;
|
|
}
|
|
|
|
/** Initializes the toolbox */
|
|
init() {
|
|
const workspace = this.workspace_;
|
|
const svg = workspace.getParentSvg();
|
|
|
|
this.flyout = this.createFlyout_();
|
|
|
|
this.HtmlDiv = this.createDom_(this.workspace_);
|
|
const flyoutDom = this.flyout.createDom('svg');
|
|
dom.addClass(flyoutDom, 'blocklyToolboxFlyout');
|
|
dom.insertAfter(flyoutDom, svg);
|
|
this.setVisible(true);
|
|
this.flyout.init(workspace);
|
|
|
|
this.render(this.toolboxDef_);
|
|
const themeManager = workspace.getThemeManager();
|
|
themeManager.subscribe(
|
|
this.HtmlDiv,
|
|
'toolboxBackgroundColour',
|
|
'background-color',
|
|
);
|
|
themeManager.subscribe(this.HtmlDiv, 'toolboxForegroundColour', 'color');
|
|
this.workspace_.getComponentManager().addComponent({
|
|
component: this,
|
|
weight: ComponentManager.ComponentWeight.TOOLBOX_WEIGHT,
|
|
capabilities: [
|
|
ComponentManager.Capability.AUTOHIDEABLE,
|
|
ComponentManager.Capability.DELETE_AREA,
|
|
ComponentManager.Capability.DRAG_TARGET,
|
|
],
|
|
});
|
|
getFocusManager().registerTree(this);
|
|
}
|
|
|
|
/**
|
|
* Creates the DOM for the toolbox.
|
|
*
|
|
* @param workspace The workspace this toolbox is on.
|
|
* @returns The HTML container for the toolbox.
|
|
*/
|
|
protected createDom_(workspace: WorkspaceSvg): HTMLDivElement {
|
|
const svg = workspace.getParentSvg();
|
|
|
|
const container = this.createContainer_();
|
|
|
|
this.contentsDiv_ = this.createContentsContainer_();
|
|
aria.setRole(this.contentsDiv_, aria.Role.TREE);
|
|
container.appendChild(this.contentsDiv_);
|
|
|
|
svg.parentNode!.insertBefore(container, svg);
|
|
|
|
this.attachEvents_(container, this.contentsDiv_);
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Creates the container div for the toolbox.
|
|
*
|
|
* @returns The HTML container for the toolbox.
|
|
*/
|
|
protected createContainer_(): HTMLDivElement {
|
|
const toolboxContainer = document.createElement('div');
|
|
toolboxContainer.tabIndex = 0;
|
|
toolboxContainer.setAttribute('layout', this.isHorizontal() ? 'h' : 'v');
|
|
dom.addClass(toolboxContainer, 'blocklyToolbox');
|
|
toolboxContainer.setAttribute('dir', this.RTL ? 'RTL' : 'LTR');
|
|
return toolboxContainer;
|
|
}
|
|
|
|
/**
|
|
* Creates the container for all the contents in the toolbox.
|
|
*
|
|
* @returns The HTML container for the toolbox contents.
|
|
*/
|
|
protected createContentsContainer_(): HTMLDivElement {
|
|
const contentsContainer = document.createElement('div');
|
|
dom.addClass(contentsContainer, 'blocklyToolboxCategoryGroup');
|
|
if (this.isHorizontal()) {
|
|
contentsContainer.style.flexDirection = 'row';
|
|
}
|
|
return contentsContainer;
|
|
}
|
|
|
|
/**
|
|
* Adds event listeners to the toolbox container div.
|
|
*
|
|
* @param container The HTML container for the toolbox.
|
|
* @param contentsContainer The HTML container for the contents of the
|
|
* toolbox.
|
|
*/
|
|
protected attachEvents_(
|
|
container: HTMLDivElement,
|
|
contentsContainer: HTMLDivElement,
|
|
) {
|
|
// Clicking on toolbox closes popups.
|
|
const clickEvent = browserEvents.conditionalBind(
|
|
container,
|
|
'pointerdown',
|
|
this,
|
|
this.onClick_,
|
|
/* opt_noCaptureIdentifier */ false,
|
|
);
|
|
this.boundEvents_.push(clickEvent);
|
|
|
|
const keyDownEvent = browserEvents.conditionalBind(
|
|
contentsContainer,
|
|
'keydown',
|
|
this,
|
|
this.onKeyDown_,
|
|
/* opt_noCaptureIdentifier */ false,
|
|
);
|
|
this.boundEvents_.push(keyDownEvent);
|
|
}
|
|
|
|
/**
|
|
* Handles on click events for when the toolbox or toolbox items are clicked.
|
|
*
|
|
* @param e Click event to handle.
|
|
*/
|
|
protected onClick_(e: PointerEvent) {
|
|
if (browserEvents.isRightButton(e) || e.target === this.HtmlDiv) {
|
|
// Close flyout.
|
|
(common.getMainWorkspace() as WorkspaceSvg).hideChaff(false);
|
|
} else {
|
|
const targetElement = e.target;
|
|
const itemId = (targetElement as Element).getAttribute('id');
|
|
if (itemId) {
|
|
const item = this.getToolboxItemById(itemId);
|
|
if (item!.isSelectable()) {
|
|
this.setSelectedItem(item);
|
|
(item as ISelectableToolboxItem).onClick(e);
|
|
}
|
|
}
|
|
// Just close popups.
|
|
(common.getMainWorkspace() as WorkspaceSvg).hideChaff(true);
|
|
}
|
|
Touch.clearTouchIdentifier();
|
|
}
|
|
|
|
/**
|
|
* Handles key down events for the toolbox.
|
|
*
|
|
* @param e The key down event.
|
|
*/
|
|
protected onKeyDown_(e: KeyboardEvent) {
|
|
let handled = false;
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
handled = this.selectNext();
|
|
break;
|
|
case 'ArrowUp':
|
|
handled = this.selectPrevious();
|
|
break;
|
|
case 'ArrowLeft':
|
|
handled = this.selectParent();
|
|
break;
|
|
case 'ArrowRight':
|
|
handled = this.selectChild();
|
|
break;
|
|
case 'Enter':
|
|
case ' ':
|
|
if (this.selectedItem_ && this.selectedItem_.isCollapsible()) {
|
|
const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem;
|
|
collapsibleItem.toggleExpanded();
|
|
handled = true;
|
|
}
|
|
break;
|
|
default:
|
|
handled = false;
|
|
break;
|
|
}
|
|
if (!handled && this.selectedItem_) {
|
|
// TODO(#6097): Figure out who implements onKeyDown and which interface it
|
|
// should be part of.
|
|
if ((this.selectedItem_ as any).onKeyDown) {
|
|
handled = (this.selectedItem_ as any).onKeyDown(e);
|
|
}
|
|
}
|
|
|
|
if (handled) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates the flyout based on the toolbox layout.
|
|
*
|
|
* @returns The flyout for the toolbox.
|
|
* @throws {Error} If missing a require for `Blockly.HorizontalFlyout`,
|
|
* `Blockly.VerticalFlyout`, and no flyout plugin is specified.
|
|
*/
|
|
protected createFlyout_(): IFlyout {
|
|
const workspace = this.workspace_;
|
|
// TODO (#4247): Look into adding a makeFlyout method to Blockly Options.
|
|
const workspaceOptions = new Options({
|
|
'parentWorkspace': workspace,
|
|
'rtl': workspace.RTL,
|
|
'oneBasedIndex': workspace.options.oneBasedIndex,
|
|
'horizontalLayout': workspace.horizontalLayout,
|
|
'renderer': workspace.options.renderer,
|
|
'rendererOverrides': workspace.options.rendererOverrides,
|
|
'move': {
|
|
'scrollbars': true,
|
|
},
|
|
} as BlocklyOptions);
|
|
// Options takes in either 'end' or 'start'. This has already been parsed to
|
|
// be either 0 or 1, so set it after.
|
|
workspaceOptions.toolboxPosition = workspace.options.toolboxPosition;
|
|
let FlyoutClass = null;
|
|
if (workspace.horizontalLayout) {
|
|
FlyoutClass = registry.getClassFromOptions(
|
|
registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX,
|
|
workspace.options,
|
|
true,
|
|
);
|
|
} else {
|
|
FlyoutClass = registry.getClassFromOptions(
|
|
registry.Type.FLYOUTS_VERTICAL_TOOLBOX,
|
|
workspace.options,
|
|
true,
|
|
);
|
|
}
|
|
return new FlyoutClass!(workspaceOptions);
|
|
}
|
|
|
|
/**
|
|
* Fills the toolbox with new toolbox items and removes any old contents.
|
|
*
|
|
* @param toolboxDef Object holding information for creating a toolbox.
|
|
* @internal
|
|
*/
|
|
render(toolboxDef: toolbox.ToolboxInfo) {
|
|
this.toolboxDef_ = toolboxDef;
|
|
this.contents.forEach((item) => item.dispose());
|
|
this.contents.clear();
|
|
this.renderContents_(toolboxDef['contents']);
|
|
this.position();
|
|
this.handleToolboxItemResize();
|
|
}
|
|
|
|
/**
|
|
* Adds all the toolbox items to the toolbox.
|
|
*
|
|
* @param toolboxDef Array holding objects containing information on the
|
|
* contents of the toolbox.
|
|
*/
|
|
protected renderContents_(toolboxDef: toolbox.ToolboxItemInfo[]) {
|
|
// This is for performance reasons. By using document fragment we only have
|
|
// to add to the DOM once.
|
|
const fragment = document.createDocumentFragment();
|
|
for (let i = 0; i < toolboxDef.length; i++) {
|
|
const toolboxItemDef = toolboxDef[i];
|
|
this.createToolboxItem(toolboxItemDef, fragment);
|
|
}
|
|
this.contentsDiv_!.appendChild(fragment);
|
|
}
|
|
|
|
/**
|
|
* Creates and renders the toolbox item.
|
|
*
|
|
* @param toolboxItemDef Any information that can be used to create an item in
|
|
* the toolbox.
|
|
* @param fragment The document fragment to add the child toolbox elements to.
|
|
*/
|
|
private createToolboxItem(
|
|
toolboxItemDef: toolbox.ToolboxItemInfo,
|
|
fragment: DocumentFragment,
|
|
) {
|
|
let registryName = toolboxItemDef['kind'];
|
|
|
|
// Categories that are collapsible are created using a class registered
|
|
// under a different name.
|
|
if (
|
|
registryName.toUpperCase() === 'CATEGORY' &&
|
|
toolbox.isCategoryCollapsible(toolboxItemDef as toolbox.CategoryInfo)
|
|
) {
|
|
registryName = CollapsibleToolboxCategory.registrationName;
|
|
}
|
|
|
|
const ToolboxItemClass = registry.getClass(
|
|
registry.Type.TOOLBOX_ITEM,
|
|
registryName.toLowerCase(),
|
|
);
|
|
if (ToolboxItemClass) {
|
|
const toolboxItem = new ToolboxItemClass(toolboxItemDef, this);
|
|
toolboxItem.init();
|
|
this.addToolboxItem_(toolboxItem);
|
|
const toolboxItemDom = toolboxItem.getDiv();
|
|
if (toolboxItemDom) {
|
|
fragment.appendChild(toolboxItemDom);
|
|
}
|
|
// Adds the ID to the HTML element that can receive a click.
|
|
// This is used in onClick_ to find the toolboxItem that was clicked.
|
|
if (toolboxItem.getClickTarget()) {
|
|
toolboxItem.getClickTarget()!.setAttribute('id', toolboxItem.getId());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds an item to the toolbox.
|
|
*
|
|
* @param toolboxItem The item in the toolbox.
|
|
*/
|
|
protected addToolboxItem_(toolboxItem: IToolboxItem) {
|
|
this.contents.set(toolboxItem.getId(), toolboxItem);
|
|
if (toolboxItem.isCollapsible()) {
|
|
const collapsibleItem = toolboxItem as ICollapsibleToolboxItem;
|
|
const childToolboxItems = collapsibleItem.getChildToolboxItems();
|
|
for (let i = 0; i < childToolboxItems.length; i++) {
|
|
const child = childToolboxItems[i];
|
|
this.addToolboxItem_(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the items in the toolbox.
|
|
*
|
|
* @returns The list of items in the toolbox.
|
|
*/
|
|
getToolboxItems(): IToolboxItem[] {
|
|
return [...this.contents.values()];
|
|
}
|
|
|
|
/**
|
|
* Adds a style on the toolbox. Usually used to change the cursor.
|
|
*
|
|
* @param style The name of the class to add.
|
|
* @internal
|
|
*/
|
|
addStyle(style: string) {
|
|
if (style && this.HtmlDiv) {
|
|
dom.addClass(this.HtmlDiv, style);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a style from the toolbox. Usually used to change the cursor.
|
|
*
|
|
* @param style The name of the class to remove.
|
|
* @internal
|
|
*/
|
|
removeStyle(style: string) {
|
|
if (style && this.HtmlDiv) {
|
|
dom.removeClass(this.HtmlDiv, style);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the bounding rectangle of the drag target area in pixel units
|
|
* relative to viewport.
|
|
*
|
|
* @returns The component's bounding box. Null if drag target area should be
|
|
* ignored.
|
|
*/
|
|
override getClientRect(): Rect | null {
|
|
if (!this.HtmlDiv || !this.isVisible_) {
|
|
return null;
|
|
}
|
|
// BIG_NUM is offscreen padding so that blocks dragged beyond the toolbox
|
|
// area are still deleted. Must be smaller than Infinity, but larger than
|
|
// the largest screen size.
|
|
const BIG_NUM = 10000000;
|
|
const toolboxRect = this.HtmlDiv.getBoundingClientRect();
|
|
|
|
const top = toolboxRect.top;
|
|
const bottom = top + toolboxRect.height;
|
|
const left = toolboxRect.left;
|
|
const right = left + toolboxRect.width;
|
|
|
|
// Assumes that the toolbox is on the SVG edge. If this changes
|
|
// (e.g. toolboxes in mutators) then this code will need to be more complex.
|
|
if (this.toolboxPosition === toolbox.Position.TOP) {
|
|
return new Rect(-BIG_NUM, bottom, -BIG_NUM, BIG_NUM);
|
|
} else if (this.toolboxPosition === toolbox.Position.BOTTOM) {
|
|
return new Rect(top, BIG_NUM, -BIG_NUM, BIG_NUM);
|
|
} else if (this.toolboxPosition === toolbox.Position.LEFT) {
|
|
return new Rect(-BIG_NUM, BIG_NUM, -BIG_NUM, right);
|
|
} else {
|
|
// Right
|
|
return new Rect(-BIG_NUM, BIG_NUM, left, BIG_NUM);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether the provided block or bubble would be deleted if dropped on
|
|
* this area.
|
|
* This method should check if the element is deletable and is always called
|
|
* before onDragEnter/onDragOver/onDragExit.
|
|
*
|
|
* @param element The block or bubble currently being dragged.
|
|
* @returns Whether the element provided would be deleted if dropped on this
|
|
* area.
|
|
*/
|
|
override wouldDelete(element: IDraggable): boolean {
|
|
if (element instanceof BlockSvg) {
|
|
const block = element;
|
|
this.updateWouldDelete_(!block.getParent() && block.isDeletable());
|
|
} else {
|
|
this.updateWouldDelete_(isDeletable(element) && element.isDeletable());
|
|
}
|
|
return this.wouldDelete_;
|
|
}
|
|
|
|
/**
|
|
* Handles when a cursor with a block or bubble enters this drag target.
|
|
*
|
|
* @param _dragElement The block or bubble currently being dragged.
|
|
*/
|
|
override onDragEnter(_dragElement: IDraggable) {
|
|
this.updateCursorDeleteStyle_(true);
|
|
}
|
|
|
|
/**
|
|
* Handles when a cursor with a block or bubble exits this drag target.
|
|
*
|
|
* @param _dragElement The block or bubble currently being dragged.
|
|
*/
|
|
override onDragExit(_dragElement: IDraggable) {
|
|
this.updateCursorDeleteStyle_(false);
|
|
}
|
|
|
|
/**
|
|
* Handles when a block or bubble is dropped on this component.
|
|
* Should not handle delete here.
|
|
*
|
|
* @param _dragElement The block or bubble currently being dragged.
|
|
*/
|
|
override onDrop(_dragElement: IDraggable) {
|
|
this.updateCursorDeleteStyle_(false);
|
|
}
|
|
|
|
/**
|
|
* Updates the internal wouldDelete_ state.
|
|
*
|
|
* @param wouldDelete The new value for the wouldDelete state.
|
|
*/
|
|
protected override updateWouldDelete_(wouldDelete: boolean) {
|
|
if (wouldDelete === this.wouldDelete_) {
|
|
return;
|
|
}
|
|
// This logic handles updating the deleteStyle properly if the delete state
|
|
// changes while the block is over the Toolbox. This could happen if the
|
|
// implementation of wouldDeleteBlock depends on the couldConnect parameter
|
|
// or if the isDeletable property of the block currently being dragged
|
|
// changes during the drag.
|
|
this.updateCursorDeleteStyle_(false);
|
|
this.wouldDelete_ = wouldDelete;
|
|
this.updateCursorDeleteStyle_(true);
|
|
}
|
|
|
|
/**
|
|
* Adds or removes the CSS style of the cursor over the toolbox based whether
|
|
* the block or bubble over it is expected to be deleted if dropped (using the
|
|
* internal this.wouldDelete_ property).
|
|
*
|
|
* @param addStyle Whether the style should be added or removed.
|
|
*/
|
|
protected updateCursorDeleteStyle_(addStyle: boolean) {
|
|
const style = this.wouldDelete_
|
|
? 'blocklyToolboxDelete'
|
|
: 'blocklyToolboxGrab';
|
|
if (addStyle) {
|
|
this.addStyle(style);
|
|
} else {
|
|
this.removeStyle(style);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the toolbox item with the given ID.
|
|
*
|
|
* @param id The ID of the toolbox item.
|
|
* @returns The toolbox item with the given ID, or null if no item exists.
|
|
*/
|
|
getToolboxItemById(id: string): IToolboxItem | null {
|
|
return this.contents.get(id) || null;
|
|
}
|
|
|
|
/**
|
|
* Gets the width of the toolbox.
|
|
*
|
|
* @returns The width of the toolbox.
|
|
*/
|
|
getWidth(): number {
|
|
return this.width_;
|
|
}
|
|
|
|
/**
|
|
* Gets the height of the toolbox.
|
|
*
|
|
* @returns The width of the toolbox.
|
|
*/
|
|
getHeight(): number {
|
|
return this.height_;
|
|
}
|
|
|
|
/**
|
|
* Gets the toolbox flyout.
|
|
*
|
|
* @returns The toolbox flyout.
|
|
*/
|
|
getFlyout(): IFlyout | null {
|
|
return this.flyout;
|
|
}
|
|
|
|
/**
|
|
* Gets the workspace for the toolbox.
|
|
*
|
|
* @returns The parent workspace for the toolbox.
|
|
*/
|
|
getWorkspace(): WorkspaceSvg {
|
|
return this.workspace_;
|
|
}
|
|
|
|
/**
|
|
* Gets the selected item.
|
|
*
|
|
* @returns The selected item, or null if no item is currently selected.
|
|
*/
|
|
getSelectedItem(): ISelectableToolboxItem | null {
|
|
return this.selectedItem_;
|
|
}
|
|
|
|
/**
|
|
* Gets the previously selected item.
|
|
*
|
|
* @returns The previously selected item, or null if no item was previously
|
|
* selected.
|
|
*/
|
|
getPreviouslySelectedItem(): ISelectableToolboxItem | null {
|
|
return this.previouslySelectedItem_;
|
|
}
|
|
|
|
/**
|
|
* Gets whether or not the toolbox is horizontal.
|
|
*
|
|
* @returns True if the toolbox is horizontal, false if the toolbox is
|
|
* vertical.
|
|
*/
|
|
isHorizontal(): boolean {
|
|
return this.horizontalLayout;
|
|
}
|
|
|
|
/**
|
|
* Positions the toolbox based on whether it is a horizontal toolbox and
|
|
* whether the workspace is in rtl.
|
|
*/
|
|
position() {
|
|
const workspaceMetrics = this.workspace_.getMetrics();
|
|
const toolboxDiv = this.HtmlDiv;
|
|
if (!toolboxDiv) {
|
|
// Not initialized yet.
|
|
return;
|
|
}
|
|
|
|
if (this.horizontalLayout) {
|
|
toolboxDiv.style.left = '0';
|
|
toolboxDiv.style.height = 'auto';
|
|
toolboxDiv.style.width = '100%';
|
|
this.height_ = toolboxDiv.offsetHeight;
|
|
this.width_ = workspaceMetrics.viewWidth;
|
|
if (this.toolboxPosition === toolbox.Position.TOP) {
|
|
toolboxDiv.style.top = '0';
|
|
} else {
|
|
// Bottom
|
|
toolboxDiv.style.bottom = '0';
|
|
}
|
|
} else {
|
|
if (this.toolboxPosition === toolbox.Position.RIGHT) {
|
|
toolboxDiv.style.right = '0';
|
|
} else {
|
|
// Left
|
|
toolboxDiv.style.left = '0';
|
|
}
|
|
toolboxDiv.style.height = '100%';
|
|
this.width_ = toolboxDiv.offsetWidth;
|
|
this.height_ = workspaceMetrics.viewHeight;
|
|
}
|
|
this.flyout!.position();
|
|
}
|
|
|
|
/**
|
|
* Handles resizing the toolbox when a toolbox item resizes.
|
|
*
|
|
* @internal
|
|
*/
|
|
handleToolboxItemResize() {
|
|
// Reposition the workspace so that (0,0) is in the correct position
|
|
// relative to the new absolute edge (ie toolbox edge).
|
|
const workspace = this.workspace_;
|
|
const rect = this.HtmlDiv!.getBoundingClientRect();
|
|
const flyout = this.getFlyout();
|
|
const newX =
|
|
this.toolboxPosition === toolbox.Position.LEFT
|
|
? workspace.scrollX +
|
|
rect.width +
|
|
(flyout?.isVisible() ? flyout.getWidth() : 0)
|
|
: workspace.scrollX;
|
|
const newY =
|
|
this.toolboxPosition === toolbox.Position.TOP
|
|
? workspace.scrollY +
|
|
rect.height +
|
|
(flyout?.isVisible() ? flyout.getHeight() : 0)
|
|
: workspace.scrollY;
|
|
workspace.translate(newX, newY);
|
|
|
|
// Even though the div hasn't changed size, the visible workspace
|
|
// surface of the workspace has, so we may need to reposition everything.
|
|
common.svgResize(workspace);
|
|
}
|
|
|
|
/** Unhighlights any previously selected item. */
|
|
clearSelection() {
|
|
this.setSelectedItem(null);
|
|
}
|
|
|
|
/**
|
|
* Updates the category colours and background colour of selected categories.
|
|
*
|
|
* @internal
|
|
*/
|
|
refreshTheme() {
|
|
this.contents.forEach((child) => {
|
|
// TODO(#6097): Fix types or add refreshTheme to IToolboxItem.
|
|
const childAsCategory = child as ToolboxCategory;
|
|
if (childAsCategory.refreshTheme) {
|
|
childAsCategory.refreshTheme();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates the flyout's content without closing it. Should be used in
|
|
* response to a change in one of the dynamic categories, such as variables or
|
|
* procedures.
|
|
*/
|
|
refreshSelection() {
|
|
if (
|
|
this.selectedItem_ &&
|
|
this.selectedItem_.isSelectable() &&
|
|
this.selectedItem_.getContents().length
|
|
) {
|
|
this.flyout!.show(this.selectedItem_.getContents());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows or hides the toolbox.
|
|
*
|
|
* @param isVisible True if toolbox should be visible.
|
|
*/
|
|
setVisible(isVisible: boolean) {
|
|
if (this.isVisible_ === isVisible) {
|
|
return;
|
|
}
|
|
|
|
this.HtmlDiv!.style.display = isVisible ? 'block' : 'none';
|
|
this.isVisible_ = isVisible;
|
|
// Invisible toolbox is ignored as drag targets and must have the drag
|
|
// target updated.
|
|
this.workspace_.recordDragTargets();
|
|
}
|
|
|
|
/**
|
|
* Hides the component. Called in WorkspaceSvg.hideChaff.
|
|
*
|
|
* @param onlyClosePopups Whether only popups should be closed.
|
|
* Flyouts should not be closed if this is true.
|
|
*/
|
|
autoHide(onlyClosePopups: boolean) {
|
|
if (!onlyClosePopups && this.flyout && this.flyout.autoClose) {
|
|
this.clearSelection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the given item as selected.
|
|
* No-op if the item is not selectable.
|
|
*
|
|
* @param newItem The toolbox item to select.
|
|
*/
|
|
setSelectedItem(newItem: IToolboxItem | null) {
|
|
const oldItem = this.selectedItem_;
|
|
|
|
if (
|
|
(!newItem && !oldItem) ||
|
|
(newItem && !isSelectableToolboxItem(newItem))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (this.shouldDeselectItem_(oldItem, newItem) && oldItem !== null) {
|
|
this.deselectItem_(oldItem);
|
|
}
|
|
|
|
if (this.shouldSelectItem_(oldItem, newItem) && newItem !== null) {
|
|
this.selectItem_(oldItem, newItem);
|
|
}
|
|
|
|
this.updateFlyout_(oldItem, newItem);
|
|
this.fireSelectEvent(oldItem, newItem);
|
|
}
|
|
|
|
/**
|
|
* Decides whether the old item should be deselected.
|
|
*
|
|
* @param oldItem The previously selected toolbox item.
|
|
* @param newItem The newly selected toolbox item.
|
|
* @returns True if the old item should be deselected, false otherwise.
|
|
*/
|
|
protected shouldDeselectItem_(
|
|
oldItem: ISelectableToolboxItem | null,
|
|
newItem: ISelectableToolboxItem | null,
|
|
): boolean {
|
|
// Deselect the old item unless the old item is collapsible and has been
|
|
// previously clicked on.
|
|
return (
|
|
oldItem !== null && (!oldItem.isCollapsible() || oldItem !== newItem)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Decides whether the new item should be selected.
|
|
*
|
|
* @param oldItem The previously selected toolbox item.
|
|
* @param newItem The newly selected toolbox item.
|
|
* @returns True if the new item should be selected, false otherwise.
|
|
*/
|
|
protected shouldSelectItem_(
|
|
oldItem: ISelectableToolboxItem | null,
|
|
newItem: ISelectableToolboxItem | null,
|
|
): boolean {
|
|
// Select the new item unless the old item equals the new item.
|
|
return newItem !== null && newItem !== oldItem;
|
|
}
|
|
|
|
/**
|
|
* Deselects the given item, marks it as unselected, and updates aria state.
|
|
*
|
|
* @param item The previously selected toolbox item which should be
|
|
* deselected.
|
|
*/
|
|
protected deselectItem_(item: ISelectableToolboxItem) {
|
|
this.selectedItem_ = null;
|
|
this.previouslySelectedItem_ = item;
|
|
item.setSelected(false);
|
|
aria.setState(
|
|
this.contentsDiv_ as Element,
|
|
aria.State.ACTIVEDESCENDANT,
|
|
'',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Selects the given item, marks it selected, and updates aria state.
|
|
*
|
|
* @param oldItem The previously selected toolbox item.
|
|
* @param newItem The newly selected toolbox item.
|
|
*/
|
|
protected selectItem_(
|
|
oldItem: ISelectableToolboxItem | null,
|
|
newItem: ISelectableToolboxItem,
|
|
) {
|
|
this.selectedItem_ = newItem;
|
|
this.previouslySelectedItem_ = oldItem;
|
|
newItem.setSelected(true);
|
|
aria.setState(
|
|
this.contentsDiv_ as Element,
|
|
aria.State.ACTIVEDESCENDANT,
|
|
newItem.getId(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Selects the toolbox item by its position in the list of toolbox items.
|
|
*
|
|
* @param position The position of the item to select.
|
|
*/
|
|
selectItemByPosition(position: number) {
|
|
const item = this.getToolboxItems()[position];
|
|
if (item) {
|
|
this.setSelectedItem(item);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decides whether to hide or show the flyout depending on the selected item.
|
|
*
|
|
* @param oldItem The previously selected toolbox item.
|
|
* @param newItem The newly selected toolbox item.
|
|
*/
|
|
protected updateFlyout_(
|
|
oldItem: ISelectableToolboxItem | null,
|
|
newItem: ISelectableToolboxItem | null,
|
|
) {
|
|
if (
|
|
!newItem ||
|
|
(oldItem === newItem && !newItem.isCollapsible()) ||
|
|
!newItem.getContents().length
|
|
) {
|
|
this.flyout!.hide();
|
|
} else {
|
|
this.flyout!.show(newItem.getContents());
|
|
this.flyout!.scrollToStart();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emits an event when a new toolbox item is selected.
|
|
*
|
|
* @param oldItem The previously selected toolbox item.
|
|
* @param newItem The newly selected toolbox item.
|
|
*/
|
|
private fireSelectEvent(
|
|
oldItem: ISelectableToolboxItem | null,
|
|
newItem: ISelectableToolboxItem | null,
|
|
) {
|
|
const oldElement = oldItem && oldItem.getName();
|
|
let newElement = newItem && newItem.getName();
|
|
// In this case the toolbox closes, so the newElement should be null.
|
|
if (oldItem === newItem) {
|
|
newElement = null;
|
|
}
|
|
const event = new (eventUtils.get(EventType.TOOLBOX_ITEM_SELECT))(
|
|
oldElement,
|
|
newElement,
|
|
this.workspace_.id,
|
|
);
|
|
eventUtils.fire(event);
|
|
}
|
|
|
|
/**
|
|
* Closes the current item if it is expanded, or selects the parent.
|
|
*
|
|
* @returns True if a parent category was selected, false otherwise.
|
|
*/
|
|
private selectParent(): boolean {
|
|
if (!this.selectedItem_) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
this.selectedItem_.isCollapsible() &&
|
|
(this.selectedItem_ as ICollapsibleToolboxItem).isExpanded()
|
|
) {
|
|
const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem;
|
|
collapsibleItem.toggleExpanded();
|
|
return true;
|
|
} else if (
|
|
this.selectedItem_.getParent() &&
|
|
this.selectedItem_.getParent()!.isSelectable()
|
|
) {
|
|
this.setSelectedItem(this.selectedItem_.getParent());
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Selects the first child of the currently selected item, or nothing if the
|
|
* toolbox item has no children.
|
|
*
|
|
* @returns True if a child category was selected, false otherwise.
|
|
*/
|
|
private selectChild(): boolean {
|
|
if (!this.selectedItem_ || !this.selectedItem_.isCollapsible()) {
|
|
return false;
|
|
}
|
|
const collapsibleItem = this.selectedItem_ as ICollapsibleToolboxItem;
|
|
if (!collapsibleItem.isExpanded()) {
|
|
collapsibleItem.toggleExpanded();
|
|
return true;
|
|
} else {
|
|
this.selectNext();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Selects the next visible toolbox item.
|
|
*
|
|
* @returns True if a next category was selected, false otherwise.
|
|
*/
|
|
private selectNext(): boolean {
|
|
if (!this.selectedItem_) {
|
|
return false;
|
|
}
|
|
|
|
const items = [...this.contents.values()];
|
|
let nextItemIdx = items.indexOf(this.selectedItem_) + 1;
|
|
if (nextItemIdx > -1 && nextItemIdx < items.length) {
|
|
let nextItem = items[nextItemIdx];
|
|
while (nextItem && !nextItem.isSelectable()) {
|
|
nextItem = items[++nextItemIdx];
|
|
}
|
|
if (nextItem && nextItem.isSelectable()) {
|
|
this.setSelectedItem(nextItem);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Selects the previous visible toolbox item.
|
|
*
|
|
* @returns True if a previous category was selected, false otherwise.
|
|
*/
|
|
private selectPrevious(): boolean {
|
|
if (!this.selectedItem_) {
|
|
return false;
|
|
}
|
|
|
|
const items = [...this.contents.values()];
|
|
let prevItemIdx = items.indexOf(this.selectedItem_) - 1;
|
|
if (prevItemIdx > -1 && prevItemIdx < items.length) {
|
|
let prevItem = items[prevItemIdx];
|
|
while (prevItem && !prevItem.isSelectable()) {
|
|
prevItem = items[--prevItemIdx];
|
|
}
|
|
if (prevItem && prevItem.isSelectable()) {
|
|
this.setSelectedItem(prevItem);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Disposes of this toolbox. */
|
|
dispose() {
|
|
this.workspace_.getComponentManager().removeComponent('toolbox');
|
|
this.flyout!.dispose();
|
|
this.contents.forEach((item) => item.dispose());
|
|
|
|
for (let j = 0; j < this.boundEvents_.length; j++) {
|
|
browserEvents.unbind(this.boundEvents_[j]);
|
|
}
|
|
this.boundEvents_ = [];
|
|
this.contents.clear();
|
|
|
|
if (this.HtmlDiv) {
|
|
this.workspace_.getThemeManager().unsubscribe(this.HtmlDiv);
|
|
dom.removeNode(this.HtmlDiv);
|
|
}
|
|
|
|
getFocusManager().unregisterTree(this);
|
|
}
|
|
|
|
/** See IFocusableNode.getFocusableElement. */
|
|
getFocusableElement(): HTMLElement | SVGElement {
|
|
if (!this.HtmlDiv) throw Error('Toolbox DOM has not yet been created.');
|
|
return this.HtmlDiv;
|
|
}
|
|
|
|
/** See IFocusableNode.getFocusableTree. */
|
|
getFocusableTree(): IFocusableTree {
|
|
return this;
|
|
}
|
|
|
|
/** See IFocusableNode.onNodeFocus. */
|
|
onNodeFocus(): void {}
|
|
|
|
/** See IFocusableNode.onNodeBlur. */
|
|
onNodeBlur(): void {}
|
|
|
|
/** See IFocusableTree.getRootFocusableNode. */
|
|
getRootFocusableNode(): IFocusableNode {
|
|
return this;
|
|
}
|
|
|
|
/** See IFocusableTree.getRestoredFocusableNode. */
|
|
getRestoredFocusableNode(
|
|
previousNode: IFocusableNode | null,
|
|
): IFocusableNode | null {
|
|
// Always try to select the first selectable toolbox item rather than the
|
|
// root of the toolbox.
|
|
if (!previousNode || previousNode === this) {
|
|
return this.getToolboxItems().find((item) => item.isSelectable()) ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** See IFocusableTree.getNestedTrees. */
|
|
getNestedTrees(): Array<IFocusableTree> {
|
|
return [];
|
|
}
|
|
|
|
/** See IFocusableTree.lookUpFocusableNode. */
|
|
lookUpFocusableNode(id: string): IFocusableNode | null {
|
|
return this.getToolboxItemById(id) as IFocusableNode;
|
|
}
|
|
|
|
/** See IFocusableTree.onTreeFocus. */
|
|
onTreeFocus(
|
|
node: IFocusableNode,
|
|
_previousTree: IFocusableTree | null,
|
|
): void {
|
|
if (node !== this) {
|
|
// Only select the item if it isn't already selected so as to not toggle.
|
|
if (this.getSelectedItem() !== node) {
|
|
this.setSelectedItem(node as IToolboxItem);
|
|
}
|
|
} else {
|
|
this.clearSelection();
|
|
}
|
|
}
|
|
|
|
/** See IFocusableTree.onTreeBlur. */
|
|
onTreeBlur(_nextTree: IFocusableTree | null): void {}
|
|
}
|
|
|
|
/** CSS for Toolbox. See css.js for use. */
|
|
Css.register(`
|
|
.blocklyToolboxDelete {
|
|
cursor: url("<<<PATH>>>/handdelete.cur"), auto;
|
|
}
|
|
|
|
.blocklyToolboxGrab {
|
|
cursor: url("<<<PATH>>>/handclosed.cur"), auto;
|
|
cursor: grabbing;
|
|
cursor: -webkit-grabbing;
|
|
}
|
|
|
|
/* Category tree in Toolbox. */
|
|
.blocklyToolbox {
|
|
user-select: none;
|
|
-ms-user-select: none;
|
|
-webkit-user-select: none;
|
|
background-color: #ddd;
|
|
overflow-x: visible;
|
|
overflow-y: auto;
|
|
padding: 4px 0 4px 0;
|
|
position: absolute;
|
|
z-index: 70; /* so blocks go under toolbox when dragging */
|
|
-webkit-tap-highlight-color: transparent; /* issue #1345 */
|
|
}
|
|
|
|
.blocklyToolboxCategoryGroup {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.blocklyToolboxCategoryGroup:focus {
|
|
outline: none;
|
|
}
|
|
`);
|
|
|
|
registry.register(registry.Type.TOOLBOX, registry.DEFAULT, Toolbox);
|