feat: Add support for keyboard navigation to/from block comments. (#9227)

* refactor: Update `TextInputBubble` to use `CommentEditor` for text editing.

* feat: Designate `Bubble` as implementing `IFocusableNode`.

* feat: Dismiss focused bubbles on Escape.

* feat: Add support for keyboard navigation to block comments.

* fix: Scroll comment editors rather than zooming the workspace.

* chore: Add param to docstring.
This commit is contained in:
Aaron Dodson
2025-07-11 10:54:19 -07:00
committed by GitHub
parent 60b7ee1325
commit d5f3d15726
10 changed files with 221 additions and 100 deletions

View File

@@ -9,7 +9,9 @@ import * as common from '../common.js';
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
import {getFocusManager} from '../focus_manager.js';
import {IBubble} from '../interfaces/i_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Scrollbar} from '../scrollbar.js';
@@ -27,7 +29,7 @@ import {WorkspaceSvg} from '../workspace_svg.js';
* bubble, where it has a "tail" that points to the block, and a "head" that
* displays arbitrary svg elements.
*/
export abstract class Bubble implements IBubble, ISelectable {
export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
/** The width of the border around the bubble. */
static readonly BORDER_WIDTH = 6;
@@ -100,12 +102,14 @@ export abstract class Bubble implements IBubble, ISelectable {
* element that's represented by this bubble (as a focusable node). This
* element will have its ID overwritten. If not provided, the focusable
* element of this node will default to the bubble's SVG root.
* @param owner The object responsible for hosting/spawning this bubble.
*/
constructor(
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
overriddenFocusableElement?: SVGElement | HTMLElement,
protected owner?: IHasBubble & IFocusableNode,
) {
this.id = idGenerator.getNextUniqueId();
this.svgRoot = dom.createSvgElement(
@@ -145,6 +149,13 @@ export abstract class Bubble implements IBubble, ISelectable {
this,
this.onMouseDown,
);
browserEvents.conditionalBind(
this.focusableElement,
'keydown',
this,
this.onKeyDown,
);
}
/** Dispose of this bubble. */
@@ -229,6 +240,19 @@ export abstract class Bubble implements IBubble, ISelectable {
getFocusManager().focusNode(this);
}
/**
* Handles key events when this bubble is focused. By default, closes the
* bubble on Escape.
*
* @param e The keyboard event to handle.
*/
protected onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && this.owner) {
this.owner.setBubbleVisible(false);
getFocusManager().focusNode(this.owner);
}
}
/** Positions the bubble relative to its anchor. Does not render its tail. */
protected positionRelativeToAnchor() {
let left = this.anchor.x;
@@ -694,4 +718,11 @@ export abstract class Bubble implements IBubble, ISelectable {
canBeFocused(): boolean {
return true;
}
/**
* Returns the object that owns/hosts this bubble, if any.
*/
getOwner(): (IHasBubble & IFocusableNode) | undefined {
return this.owner;
}
}

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentEditor} from '../comments/comment_editor.js';
import * as Css from '../css.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import * as touch from '../touch.js';
import {browserEvents} from '../utils.js';
import {Coordinate} from '../utils/coordinate.js';
@@ -21,12 +25,6 @@ import {Bubble} from './bubble.js';
* Used by the comment icon.
*/
export class TextInputBubble extends Bubble {
/** The root of the elements specific to the text element. */
private inputRoot: SVGForeignObjectElement;
/** The text input area element. */
private textArea: HTMLTextAreaElement;
/** The group containing the lines indicating the bubble is resizable. */
private resizeGroup: SVGGElement;
@@ -42,18 +40,12 @@ export class TextInputBubble extends Bubble {
*/
private resizePointerMoveListener: browserEvents.Data | null = null;
/** Functions listening for changes to the text of this bubble. */
private textChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the size of this bubble. */
private sizeChangeListeners: (() => void)[] = [];
/** Functions listening for changes to the location of this bubble. */
private locationChangeListeners: (() => void)[] = [];
/** The text of this bubble. */
private text = '';
/** The default size of this bubble, including borders. */
private readonly DEFAULT_SIZE = new Size(
160 + Bubble.DOUBLE_BORDER,
@@ -68,46 +60,47 @@ export class TextInputBubble extends Bubble {
private editable = true;
/** View responsible for supporting text editing. */
private editor: CommentEditor;
/**
* @param workspace The workspace this bubble belongs to.
* @param anchor The anchor location of the thing this bubble is attached to.
* The tail of the bubble will point to this location.
* @param ownerRect An optional rect we don't want the bubble to overlap with
* when automatically positioning.
* @param owner The object that owns/hosts this bubble.
*/
constructor(
public readonly workspace: WorkspaceSvg,
protected anchor: Coordinate,
protected ownerRect?: Rect,
protected owner?: IHasBubble & IFocusableNode,
) {
super(workspace, anchor, ownerRect, TextInputBubble.createTextArea());
super(workspace, anchor, ownerRect, undefined, owner);
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
this.textArea = this.getFocusableElement() as HTMLTextAreaElement;
this.inputRoot = this.createEditor(this.contentContainer, this.textArea);
this.editor = new CommentEditor(workspace, this.id, () => {
getFocusManager().focusNode(this);
});
this.contentContainer.appendChild(this.editor.getDom());
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
this.setSize(this.DEFAULT_SIZE, true);
}
/** @returns the text of this bubble. */
getText(): string {
return this.text;
return this.editor.getText();
}
/** Sets the text of this bubble. Calls change listeners. */
setText(text: string) {
this.text = text;
this.textArea.value = text;
this.onTextChange();
this.editor.setText(text);
}
/** Sets whether or not the text in the bubble is editable. */
setEditable(editable: boolean) {
this.editable = editable;
if (this.editable) {
this.textArea.removeAttribute('readonly');
} else {
this.textArea.setAttribute('readonly', '');
}
this.editor.setEditable(editable);
}
/** Returns whether or not the text in the bubble is editable. */
@@ -117,7 +110,7 @@ export class TextInputBubble extends Bubble {
/** Adds a change listener to be notified when this bubble's text changes. */
addTextChangeListener(listener: () => void) {
this.textChangeListeners.push(listener);
this.editor.addTextChangeListener(listener);
}
/** Adds a change listener to be notified when this bubble's size changes. */
@@ -130,58 +123,6 @@ export class TextInputBubble extends Bubble {
this.locationChangeListeners.push(listener);
}
/** Creates and returns the editable text area for this bubble's editor. */
private static createTextArea(): HTMLTextAreaElement {
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
textArea.className = 'blocklyTextarea blocklyText';
return textArea;
}
/** Creates and returns the UI container element for this bubble's editor. */
private createEditor(
container: SVGGElement,
textArea: HTMLTextAreaElement,
): SVGForeignObjectElement {
const inputRoot = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'x': Bubble.BORDER_WIDTH,
'y': Bubble.BORDER_WIDTH,
},
container,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
body.appendChild(textArea);
inputRoot.appendChild(body);
this.bindTextAreaEvents(textArea);
return inputRoot;
}
/** Binds events to the text area element. */
private bindTextAreaEvents(textArea: HTMLTextAreaElement) {
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => {
e.stopPropagation();
});
// Don't let the pointerdown event get to the workspace.
browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => {
e.stopPropagation();
touch.clearTouchIdentifier();
});
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
}
/** Creates the resize handler elements and binds events to them. */
private createResizeHandle(
container: SVGGElement,
@@ -220,8 +161,12 @@ export class TextInputBubble extends Bubble {
const widthMinusBorder = size.width - Bubble.DOUBLE_BORDER;
const heightMinusBorder = size.height - Bubble.DOUBLE_BORDER;
this.inputRoot.setAttribute('width', `${widthMinusBorder}`);
this.inputRoot.setAttribute('height', `${heightMinusBorder}`);
this.editor.updateSize(
new Size(widthMinusBorder, heightMinusBorder),
new Size(0, 0),
);
this.editor.getDom().setAttribute('x', `${Bubble.DOUBLE_BORDER / 2}`);
this.editor.getDom().setAttribute('y', `${Bubble.DOUBLE_BORDER / 2}`);
this.resizeGroup.setAttribute('y', `${heightMinusBorder}`);
if (this.workspace.RTL) {
@@ -312,14 +257,6 @@ export class TextInputBubble extends Bubble {
this.onSizeChange();
}
/** Handles a text change event for the text area. Calls event listeners. */
private onTextChange() {
this.text = this.textArea.value;
for (const listener of this.textChangeListeners) {
listener();
}
}
/** Handles a size change event for the text area. Calls event listeners. */
private onSizeChange() {
for (const listener of this.sizeChangeListeners) {
@@ -333,6 +270,15 @@ export class TextInputBubble extends Bubble {
listener();
}
}
/**
* Returns the text editor component of this bubble.
*
* @internal
*/
getEditor() {
return this.editor;
}
}
Css.register(`

View File

@@ -53,6 +53,7 @@ export class CommentEditor implements IFocusableNode {
'textarea',
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
@@ -86,6 +87,11 @@ export class CommentEditor implements IFocusableNode {
},
);
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(this.textArea, 'wheel', this, (e: Event) => {
e.stopPropagation();
});
// Register listener for keydown events that would finish editing.
browserEvents.conditionalBind(
this.textArea,

View File

@@ -74,15 +74,6 @@ export class RenderedWorkspaceComment
this,
this.startGesture,
);
// Don't zoom with mousewheel; let it scroll instead.
browserEvents.conditionalBind(
this.view.getSvgRoot(),
'wheel',
this,
(e: Event) => {
e.stopPropagation();
},
);
}
/**

View File

@@ -11,7 +11,6 @@ import type {BlockSvg} from '../block_svg.js';
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import type {ISerializable} from '../interfaces/i_serializable.js';
import * as renderManagement from '../render_management.js';
@@ -62,7 +61,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
/**
* The visibility of the bubble for this comment.
*
* This is used to track what the visibile state /should/ be, not necessarily
* This is used to track what the visible state /should/ be, not necessarily
* what it currently /is/. E.g. sometimes this will be true, but the block
* hasn't been rendered yet, so the bubble will not currently be visible.
*/
@@ -340,7 +339,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
}
/** See IHasBubble.getBubble. */
getBubble(): IBubble | null {
getBubble(): TextInputBubble | null {
return this.textInputBubble;
}
@@ -365,6 +364,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
this.sourceBlock.workspace as WorkspaceSvg,
this.getAnchorLocation(),
this.getBubbleOwnerRect(),
this,
);
this.textInputBubble.setText(this.getText());
this.textInputBubble.setSize(this.bubbleSize, true);

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from an TextInputBubble.
*/
export class BlockCommentNavigationPolicy
implements INavigationPolicy<TextInputBubble>
{
/**
* Returns the first child of the given block comment.
*
* @param current The block comment to return the first child of.
* @returns The text editor of the given block comment bubble.
*/
getFirstChild(current: TextInputBubble): IFocusableNode | null {
return current.getEditor();
}
/**
* Returns the parent of the given block comment.
*
* @param current The block comment to return the parent of.
* @returns The parent block of the given block comment.
*/
getParent(current: TextInputBubble): IFocusableNode | null {
return current.getOwner() ?? null;
}
/**
* Returns the next peer node of the given block comment.
*
* @param _current The block comment to find the following element of.
* @returns Null.
*/
getNextSibling(_current: TextInputBubble): IFocusableNode | null {
return null;
}
/**
* Returns the previous peer node of the given block comment.
*
* @param _current The block comment to find the preceding element of.
* @returns Null.
*/
getPreviousSibling(_current: TextInputBubble): IFocusableNode | null {
return null;
}
/**
* Returns whether or not the given block comment can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given block comment can be focused.
*/
isNavigable(current: TextInputBubble): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is an TextInputBubble.
*/
isApplicable(current: any): current is TextInputBubble {
return current instanceof TextInputBubble;
}
}

View File

@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentEditor} from '../comments/comment_editor.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from a comment editor.
* This is a no-op placeholder (other than isNavigable/isApplicable) since
* comment editors handle their own navigation when editing ends.
*/
export class CommentEditorNavigationPolicy
implements INavigationPolicy<CommentEditor>
{
getFirstChild(_current: CommentEditor): IFocusableNode | null {
return null;
}
getParent(_current: CommentEditor): IFocusableNode | null {
return null;
}
getNextSibling(_current: CommentEditor): IFocusableNode | null {
return null;
}
getPreviousSibling(_current: CommentEditor): IFocusableNode | null {
return null;
}
/**
* Returns whether or not the given comment editor can be navigated to.
*
* @param current The instance to check for navigability.
* @returns False.
*/
isNavigable(current: CommentEditor): boolean {
return current.canBeFocused();
}
/**
* Returns whether the given object can be navigated from by this policy.
*
* @param current The object to check if this policy applies to.
* @returns True if the object is a CommentEditor.
*/
isApplicable(current: any): current is CommentEditor {
return current instanceof CommentEditor;
}
}

View File

@@ -6,6 +6,7 @@
import {BlockSvg} from '../block_svg.js';
import {getFocusManager} from '../focus_manager.js';
import {CommentIcon} from '../icons/comment_icon.js';
import {Icon} from '../icons/icon.js';
import {MutatorIcon} from '../icons/mutator_icon.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
@@ -29,6 +30,12 @@ export class IconNavigationPolicy implements INavigationPolicy<Icon> {
getFocusManager().getFocusedNode() === current
) {
return current.getBubble()?.getWorkspace() ?? null;
} else if (
current instanceof CommentIcon &&
current.bubbleIsVisible() &&
getFocusManager().getFocusedNode() === current
) {
return current.getBubble()?.getEditor() ?? null;
}
return null;

View File

@@ -6,8 +6,10 @@
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
import {BlockCommentNavigationPolicy} from './keyboard_nav/block_comment_navigation_policy.js';
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js';
import {CommentEditorNavigationPolicy} from './keyboard_nav/comment_editor_navigation_policy.js';
import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js';
import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js';
import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js';
@@ -33,6 +35,8 @@ export class Navigator {
new IconNavigationPolicy(),
new WorkspaceCommentNavigationPolicy(),
new CommentBarButtonNavigationPolicy(),
new BlockCommentNavigationPolicy(),
new CommentEditorNavigationPolicy(),
];
/**

View File

@@ -22,6 +22,7 @@ import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import type {BlocklyOptions} from './blockly_options.js';
import * as browserEvents from './browser_events.js';
import {TextInputBubble} from './bubbles/textinput_bubble.js';
import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js';
import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js';
import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js';
@@ -2868,6 +2869,11 @@ export class WorkspaceSvg
bubble.getFocusableElement().id === id
) {
return bubble;
} else if (
bubble instanceof TextInputBubble &&
bubble.getEditor().getFocusableElement().id === id
) {
return bubble.getEditor();
}
}
}