mirror of
https://github.com/google/blockly.git
synced 2026-01-31 20:50:10 +01:00
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8965 Fixes #8978 Fixes #8970 Fixes https://github.com/google/blockly-keyboard-experimentation/issues/523 Fixes https://github.com/google/blockly-keyboard-experimentation/issues/547 Fixes part of #8910 ### Proposed Changes Fives groups of changes are included in this PR: 1. Support for automatic tab index management for focusable trees. 2. Support for automatic tab index management for focusable nodes. 3. Support for automatically hiding the flyout when back navigating from the toolbox. 4. A fix for `FocusManager` losing DOM syncing that was introduced in #9082. 5. Some cleanups for flyout and some tests for previous behavior changes to `FocusManager`. ### Reason for Changes Infrastructure changes reasoning: - Automatically managing tab indexes for both focusable trees and roots can largely reduce the difficulty of providing focusable nodes/trees and generally interacting with `FocusManager`. This facilitates a more automated navigation experience. - The fix for losing DOM syncing is possibly not reliable, but there are at least now tests to cover for it. This may be a case where a `try{} finally{}` could be warranted, but the code will stay as-is unless requested otherwise. `Flyout` changes: - `Flyout` no longer needs to be a focusable tree, but removing that would be an API breakage. Instead, it throws for most of the normal tree/node calls as it should no longer be used as such. Instead, its workspace has been made top-level tabbable (in addition to the main workspace) which solves the extra tab stop issues and general confusing inconsistencies between the flyout, toolbox, and workspace. - `Flyout` now correctly auto-selects the first block (#9103 notwithstanding). Technically it did before, however the extra `Flyout` tabstop before its workspace caused the inconsistency (since focusing the `Flyout` itself did not auto-select, only selecting its workspace did). Important caveats: - `getAttribute` is used in place of directly fetching `.tabIndex` since the latter can apparently default to `-1` (and possibly `0`) in cases when it's not actually set. This is a very surprising behavior that leads to incorrect test results. - Sometimes tab index still needs to be introduced (such as in cases where native DOM focus is needed, e.g. via `focus()` calls or clicking). This is demonstrated both by updates to `FocusManager`'s tests as well as toolbox's category and separator. This can be slightly tricky to miss as large parts of Blockly now depend on focus to represent their state, so clicking either needs to be managed by Blockly (with corresponding `focusNode` calls) or automatic (with a tab index defined for the element that can be clicked, or which has a child that can be clicked). Note that nearly all elements used for testing focus in the test `index.html` page have had their tab indexes removed to lean on `FocusManager`'s automatic tab management (though as mentioned above there is still some manual tab index management required for `focus()`-specific tests). ### Test Coverage New tests were added for all of the updated behaviors to `FocusManager`, including a new need to explicitly provide (and reset) tab indexes for all `focus()`-esque tests. This also includes adding new tests for some behaviors introduced in past PRs (a la #8910). Note that all of the new and affected conditionals in `FocusManager` have been verified as having at least 1 test that breaks when it's removed (inverted conditions weren't thoroughly tested, but it's expected that they should also be well covered now). Additional tests to cover the actual navigation flows will be added to the keyboard experimentation plugin repository as part of https://github.com/google/blockly-keyboard-experimentation/pull/557 (this PR needs to be merged first). For manual testing, I mainly verified keyboard navigation with some cursory mouse & click testing in the simple playground. @rachel-fenichel also performed more thorough mouse & click testing (that yielded an actual issue that was fixed--see discussion below). The core webdriver tests have been verified to have seemingly the same existing failures with and without these changes. All of the following new keyboard navigation plugin tests have been verified as failing without the fixes introduced in this branch (and passing with them): - `Tab navigating to flyout should auto-select first block` - `Keyboard nav to different toolbox category should auto-select first block` - `Keyboard nav to different toolbox category and block should select different block` - `Tab navigate away from toolbox restores focus to initial element` - `Tab navigate away from toolbox closes flyout` - `Tab navigate away from flyout to toolbox and away closes flyout` - `Tabbing to the workspace after selecting flyout block should close the flyout` - `Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout` - `Tabbing back from workspace should reopen the flyout` - `Navigation position in workspace should be retained when tabbing to flyout and back` - `Clicking outside Blockly with focused toolbox closes the flyout` - `Clicking outside Blockly with focused flyout closes the flyout` - `Clicking on toolbox category focuses it and opens flyout` ### Documentation No documentation changes are needed beyond the code doc changes included in the PR. ### Additional Information An additional PR will be introduced for the keyboard experimentation plugin repository to add tests there (see test coverage above). This description will be updated with a link to that PR once it exists.
364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2024 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as browserEvents from '../browser_events.js';
|
|
import {
|
|
WorkspaceCommentCopyData,
|
|
WorkspaceCommentPaster,
|
|
} from '../clipboard/workspace_comment_paster.js';
|
|
import * as common from '../common.js';
|
|
import * as contextMenu from '../contextmenu.js';
|
|
import {ContextMenuRegistry} from '../contextmenu_registry.js';
|
|
import {CommentDragStrategy} from '../dragging/comment_drag_strategy.js';
|
|
import {getFocusManager} from '../focus_manager.js';
|
|
import {IBoundedElement} from '../interfaces/i_bounded_element.js';
|
|
import {IContextMenu} from '../interfaces/i_contextmenu.js';
|
|
import {ICopyable} from '../interfaces/i_copyable.js';
|
|
import {IDeletable} from '../interfaces/i_deletable.js';
|
|
import {IDraggable} from '../interfaces/i_draggable.js';
|
|
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
|
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
|
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
|
|
import {ISelectable} from '../interfaces/i_selectable.js';
|
|
import * as layers from '../layers.js';
|
|
import * as commentSerialization from '../serialization/workspace_comments.js';
|
|
import {Coordinate} from '../utils/coordinate.js';
|
|
import * as dom from '../utils/dom.js';
|
|
import {Rect} from '../utils/rect.js';
|
|
import {Size} from '../utils/size.js';
|
|
import * as svgMath from '../utils/svg_math.js';
|
|
import {WorkspaceSvg} from '../workspace_svg.js';
|
|
import {CommentView} from './comment_view.js';
|
|
import {WorkspaceComment} from './workspace_comment.js';
|
|
|
|
export class RenderedWorkspaceComment
|
|
extends WorkspaceComment
|
|
implements
|
|
IBoundedElement,
|
|
IRenderedElement,
|
|
IDraggable,
|
|
ISelectable,
|
|
IDeletable,
|
|
ICopyable<WorkspaceCommentCopyData>,
|
|
IContextMenu,
|
|
IFocusableNode
|
|
{
|
|
/** The class encompassing the svg elements making up the workspace comment. */
|
|
private view: CommentView;
|
|
|
|
public readonly workspace: WorkspaceSvg;
|
|
|
|
private dragStrategy = new CommentDragStrategy(this);
|
|
|
|
/** Constructs the workspace comment, including the view. */
|
|
constructor(workspace: WorkspaceSvg, id?: string) {
|
|
super(workspace, id);
|
|
|
|
this.workspace = workspace;
|
|
|
|
this.view = new CommentView(workspace);
|
|
// Set the size to the default size as defined in the superclass.
|
|
this.view.setSize(this.getSize());
|
|
this.view.setEditable(this.isEditable());
|
|
this.view.getSvgRoot().setAttribute('data-id', this.id);
|
|
this.view.getSvgRoot().setAttribute('id', this.id);
|
|
|
|
this.addModelUpdateBindings();
|
|
|
|
browserEvents.conditionalBind(
|
|
this.view.getSvgRoot(),
|
|
'pointerdown',
|
|
this,
|
|
this.startGesture,
|
|
);
|
|
// Don't zoom with mousewheel; let it scroll instead.
|
|
browserEvents.conditionalBind(
|
|
this.view.getSvgRoot(),
|
|
'wheel',
|
|
this,
|
|
(e: Event) => {
|
|
e.stopPropagation();
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Adds listeners to the view that updates the model (i.e. the superclass)
|
|
* when changes are made to the view.
|
|
*/
|
|
private addModelUpdateBindings() {
|
|
this.view.addTextChangeListener(
|
|
(_, newText: string) => void super.setText(newText),
|
|
);
|
|
this.view.addSizeChangeListener(
|
|
(_, newSize: Size) => void super.setSize(newSize),
|
|
);
|
|
this.view.addOnCollapseListener(
|
|
() => void super.setCollapsed(this.view.isCollapsed()),
|
|
);
|
|
this.view.addDisposeListener(() => {
|
|
if (!this.isDeadOrDying()) this.dispose();
|
|
});
|
|
}
|
|
|
|
/** Sets the text of the comment. */
|
|
override setText(text: string): void {
|
|
// setText will trigger the change listener that updates
|
|
// the model aka superclass.
|
|
this.view.setText(text);
|
|
}
|
|
|
|
/** Sets the placeholder text displayed if the comment is empty. */
|
|
setPlaceholderText(text: string): void {
|
|
this.view.setPlaceholderText(text);
|
|
}
|
|
|
|
/** Sets the size of the comment. */
|
|
override setSize(size: Size) {
|
|
// setSize will trigger the change listener that updates
|
|
// the model aka superclass.
|
|
this.view.setSize(size);
|
|
}
|
|
|
|
/** Sets whether the comment is collapsed or not. */
|
|
override setCollapsed(collapsed: boolean) {
|
|
// setCollapsed will trigger the change listener that updates
|
|
// the model aka superclass.
|
|
this.view.setCollapsed(collapsed);
|
|
}
|
|
|
|
/** Sets whether the comment is editable or not. */
|
|
override setEditable(editable: boolean): void {
|
|
super.setEditable(editable);
|
|
// Use isEditable rather than isOwnEditable to account for workspace state.
|
|
this.view.setEditable(this.isEditable());
|
|
}
|
|
|
|
/** Returns the root SVG element of this comment. */
|
|
getSvgRoot(): SVGElement {
|
|
return this.view.getSvgRoot();
|
|
}
|
|
|
|
/**
|
|
* Returns the comment's size in workspace units.
|
|
* Does not respect collapsing.
|
|
*/
|
|
getSize(): Size {
|
|
return super.getSize();
|
|
}
|
|
|
|
/**
|
|
* Returns the bounding rectangle of this comment in workspace coordinates.
|
|
* Respects collapsing.
|
|
*/
|
|
getBoundingRectangle(): Rect {
|
|
const loc = this.getRelativeToSurfaceXY();
|
|
const size = this.view?.getSize() ?? this.getSize();
|
|
let left;
|
|
let right;
|
|
if (this.workspace.RTL) {
|
|
left = loc.x - size.width;
|
|
right = loc.x;
|
|
} else {
|
|
left = loc.x;
|
|
right = loc.x + size.width;
|
|
}
|
|
return new Rect(loc.y, loc.y + size.height, left, right);
|
|
}
|
|
|
|
/** Move the comment by the given amounts in workspace coordinates. */
|
|
moveBy(dx: number, dy: number, reason?: string[] | undefined): void {
|
|
const loc = this.getRelativeToSurfaceXY();
|
|
const newLoc = new Coordinate(loc.x + dx, loc.y + dy);
|
|
this.moveTo(newLoc, reason);
|
|
}
|
|
|
|
/** Moves the comment to the given location in workspace coordinates. */
|
|
override moveTo(location: Coordinate, reason?: string[] | undefined): void {
|
|
super.moveTo(location, reason);
|
|
this.view.moveTo(location);
|
|
}
|
|
|
|
/**
|
|
* Moves the comment during a drag. Doesn't fire move events.
|
|
*
|
|
* @internal
|
|
*/
|
|
moveDuringDrag(location: Coordinate): void {
|
|
this.location = location;
|
|
this.view.moveTo(location);
|
|
}
|
|
|
|
/**
|
|
* Adds the dragging CSS class to this comment.
|
|
*
|
|
* @internal
|
|
*/
|
|
setDragging(dragging: boolean): void {
|
|
if (dragging) {
|
|
dom.addClass(this.getSvgRoot(), 'blocklyDragging');
|
|
} else {
|
|
dom.removeClass(this.getSvgRoot(), 'blocklyDragging');
|
|
}
|
|
}
|
|
|
|
/** Disposes of the view. */
|
|
override dispose() {
|
|
this.disposing = true;
|
|
const focusManager = getFocusManager();
|
|
if (focusManager.getFocusedNode() === this) {
|
|
setTimeout(() => focusManager.focusTree(this.workspace), 0);
|
|
}
|
|
if (!this.view.isDeadOrDying()) this.view.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
/**
|
|
* Starts a gesture because we detected a pointer down on the comment
|
|
* (that wasn't otherwise gobbled up, e.g. by resizing).
|
|
*/
|
|
private startGesture(e: PointerEvent) {
|
|
const gesture = this.workspace.getGesture(e);
|
|
if (gesture) {
|
|
if (browserEvents.isTargetInput(e)) {
|
|
// If the text area was the focus, don't allow this event to bubble up
|
|
// and steal focus away from the editor/comment.
|
|
e.stopPropagation();
|
|
} else {
|
|
gesture.handleCommentStart(e, this);
|
|
}
|
|
getFocusManager().focusNode(this);
|
|
}
|
|
}
|
|
|
|
/** Visually indicates that this comment would be deleted if dropped. */
|
|
setDeleteStyle(wouldDelete: boolean): void {
|
|
if (wouldDelete) {
|
|
dom.addClass(this.getSvgRoot(), 'blocklyDraggingDelete');
|
|
} else {
|
|
dom.removeClass(this.getSvgRoot(), 'blocklyDraggingDelete');
|
|
}
|
|
}
|
|
|
|
/** Returns whether this comment is movable or not. */
|
|
isMovable(): boolean {
|
|
return this.dragStrategy.isMovable();
|
|
}
|
|
|
|
/** Starts a drag on the comment. */
|
|
startDrag(): void {
|
|
this.dragStrategy.startDrag();
|
|
}
|
|
|
|
/** Drags the comment to the given location. */
|
|
drag(newLoc: Coordinate): void {
|
|
this.dragStrategy.drag(newLoc);
|
|
}
|
|
|
|
/** Ends the drag on the comment. */
|
|
endDrag(): void {
|
|
this.dragStrategy.endDrag();
|
|
}
|
|
|
|
/** Moves the comment back to where it was at the start of a drag. */
|
|
revertDrag(): void {
|
|
this.dragStrategy.revertDrag();
|
|
}
|
|
|
|
/** Visually highlights the comment. */
|
|
select(): void {
|
|
dom.addClass(this.getSvgRoot(), 'blocklySelected');
|
|
common.fireSelectedEvent(this);
|
|
}
|
|
|
|
/** Visually unhighlights the comment. */
|
|
unselect(): void {
|
|
dom.removeClass(this.getSvgRoot(), 'blocklySelected');
|
|
common.fireSelectedEvent(null);
|
|
}
|
|
|
|
/**
|
|
* Returns a JSON serializable representation of this comment's state that
|
|
* can be used for pasting.
|
|
*/
|
|
toCopyData(): WorkspaceCommentCopyData | null {
|
|
return {
|
|
paster: WorkspaceCommentPaster.TYPE,
|
|
commentState: commentSerialization.save(this, {
|
|
addCoordinates: true,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Show a context menu for this comment. */
|
|
showContextMenu(e: Event): void {
|
|
const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
|
|
{comment: this, focusedNode: this},
|
|
e,
|
|
);
|
|
|
|
let location: Coordinate;
|
|
if (e instanceof PointerEvent) {
|
|
location = new Coordinate(e.clientX, e.clientY);
|
|
} else {
|
|
// Show the menu based on the location of the comment
|
|
const xy = svgMath.wsToScreenCoordinates(
|
|
this.workspace,
|
|
this.getRelativeToSurfaceXY(),
|
|
);
|
|
location = xy.translate(10, 10);
|
|
}
|
|
|
|
contextMenu.show(
|
|
e,
|
|
menuOptions,
|
|
this.workspace.RTL,
|
|
this.workspace,
|
|
location,
|
|
);
|
|
}
|
|
|
|
/** Snap this comment to the nearest grid point. */
|
|
snapToGrid(): void {
|
|
if (this.isDeadOrDying()) return;
|
|
const grid = this.workspace.getGrid();
|
|
if (!grid?.shouldSnap()) return;
|
|
const currentXY = this.getRelativeToSurfaceXY();
|
|
const alignedXY = grid.alignXY(currentXY);
|
|
if (alignedXY !== currentXY) {
|
|
this.moveTo(alignedXY, ['snap']);
|
|
}
|
|
}
|
|
|
|
/** See IFocusableNode.getFocusableElement. */
|
|
getFocusableElement(): HTMLElement | SVGElement {
|
|
return this.getSvgRoot();
|
|
}
|
|
|
|
/** See IFocusableNode.getFocusableTree. */
|
|
getFocusableTree(): IFocusableTree {
|
|
return this.workspace;
|
|
}
|
|
|
|
/** See IFocusableNode.onNodeFocus. */
|
|
onNodeFocus(): void {
|
|
this.select();
|
|
// Ensure that the comment is always at the top when focused.
|
|
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
|
|
}
|
|
|
|
/** See IFocusableNode.onNodeBlur. */
|
|
onNodeBlur(): void {
|
|
this.unselect();
|
|
}
|
|
|
|
/** See IFocusableNode.canBeFocused. */
|
|
canBeFocused(): boolean {
|
|
return true;
|
|
}
|
|
}
|