feat: Add support for keyboard navigation in/to workspace comments. (#9182)

* feat: Enhance the Rect API.

* feat: Add support for sorting IBoundedElements in general.

* fix: Improve typings of getTopElement/Comment methods.

* feat: Add classes to represent comment icons.

* refactor: Use comment icons in comment view.

* feat: Update navigation policies to support workspace comments.

* feat: Make the navigator and workspace handle workspace comments.

* feat: Visit workspace comments when navigating with the up/down arrows.

* chore: Make the linter happy.

* chore: Rename comment icons to bar buttons.

* refactor: Rename CommentIcons to CommentBarButtons.

* chore: Improve docstrings.

* chore: Clarify unit type.

* refactor: Remove workspace argument from `navigateStacks()`.

* fix: Fix errant find and replace in CSS.

* fix: Fix issue that could cause delete button to become misaligned.
This commit is contained in:
Aaron Dodson
2025-07-01 15:13:13 -07:00
committed by GitHub
parent c426c6d820
commit e5804e7095
13 changed files with 652 additions and 182 deletions

View File

@@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js';
export {CommentBarButton} from './comments/comment_bar_button.js';
export {CommentEditor} from './comments/comment_editor.js';
export {CommentView} from './comments/comment_view.js';
export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js';
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
export {WorkspaceComment} from './comments/workspace_comment.js';

View File

@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {CommentBarButton} from './comment_bar_button.js';
/**
* Magic string appended to the comment ID to create a unique ID for this button.
*/
export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER =
'_collapse_bar_button';
/**
* Button that toggles the collapsed state of a comment.
*/
export class CollapseCommentBarButton extends CommentBarButton {
/**
* Opaque ID used to unbind event handlers during disposal.
*/
private readonly bindId: browserEvents.Data;
/**
* SVG image displayed on this button.
*/
protected override readonly icon: SVGImageElement;
/**
* Creates a new CollapseCommentBarButton instance.
*
* @param id The ID of this button's parent comment.
* @param workspace The workspace this button's parent comment is displayed on.
* @param container An SVG group that this button should be a child of.
*/
constructor(
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
) {
super(id, workspace, container);
this.icon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyFoldoutIcon',
'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`,
'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
},
this.container,
);
this.bindId = browserEvents.conditionalBind(
this.icon,
'pointerdown',
this,
this.performAction.bind(this),
);
}
/**
* Disposes of this button.
*/
dispose() {
browserEvents.unbind(this.bindId);
}
/**
* Adjusts the positioning of this button within its container.
*/
override reposition() {
const margin = this.getMargin();
this.icon.setAttribute('y', `${margin}`);
this.icon.setAttribute('x', `${margin}`);
}
/**
* Toggles the collapsed state of the parent comment.
*
* @param e The event that triggered this action.
*/
override performAction(e?: Event) {
touch.clearTouchIdentifier();
const comment = this.getParentComment();
comment.view.bringToFront();
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
comment.setCollapsed(!comment.isCollapsed());
this.workspace.hideChaff();
e?.stopPropagation();
}
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {Rect} from '../utils/rect.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
/**
* Button displayed on a comment's top bar.
*/
export abstract class CommentBarButton implements IFocusableNode {
/**
* SVG image displayed on this button.
*/
protected abstract readonly icon: SVGImageElement;
/**
* Creates a new CommentBarButton instance.
*
* @param id The ID of this button's parent comment.
* @param workspace The workspace this button's parent comment is on.
* @param container An SVG group that this button should be a child of.
*/
constructor(
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
) {}
/**
* Returns whether or not this button is currently visible.
*/
isVisible(): boolean {
return this.icon.checkVisibility();
}
/**
* Returns the parent comment of this comment bar button.
*/
getParentComment(): RenderedWorkspaceComment {
const comment = this.workspace.getCommentById(this.id);
if (!comment) {
throw new Error(
`Comment bar button ${this.id} has no corresponding comment`,
);
}
return comment;
}
/** Adjusts the position of this button within its parent container. */
abstract reposition(): void;
/** Perform the action this button should take when it is acted on. */
abstract performAction(e?: Event): void;
/**
* Returns the dimensions of this button in workspace coordinates.
*
* @param includeMargin True to include the margin when calculating the size.
* @returns The size of this button.
*/
getSize(includeMargin = false): Rect {
const bounds = this.icon.getBBox();
const rect = Rect.from(bounds);
if (includeMargin) {
const margin = this.getMargin();
rect.left -= margin;
rect.top -= margin;
rect.bottom += margin;
rect.right += margin;
}
return rect;
}
/** Returns the margin in workspace coordinates surrounding this button. */
getMargin(): number {
return (this.container.getBBox().height - this.icon.getBBox().height) / 2;
}
/** Returns a DOM element representing this button that can receive focus. */
getFocusableElement() {
return this.icon;
}
/** Returns the workspace this button is a child of. */
getFocusableTree() {
return this.workspace;
}
/** Called when this button's focusable DOM element gains focus. */
onNodeFocus() {}
/** Called when this button's focusable DOM element loses focus. */
onNodeBlur() {}
/** Returns whether this button can be focused. True if it is visible. */
canBeFocused() {
return this.isVisible();
}
}

View File

@@ -16,14 +16,17 @@ import * as drag from '../utils/drag.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {CollapseCommentBarButton} from './collapse_comment_bar_button.js';
import {CommentBarButton} from './comment_bar_button.js';
import {CommentEditor} from './comment_editor.js';
import {DeleteCommentBarButton} from './delete_comment_bar_button.js';
export class CommentView implements IRenderedElement {
/** The root group element of the comment view. */
private svgRoot: SVGGElement;
/**
* The svg rect element that we use to create a hightlight around the comment.
* The SVG rect element that we use to create a highlight around the comment.
*/
private highlightRect: SVGRectElement;
@@ -33,11 +36,11 @@ export class CommentView implements IRenderedElement {
/** The rect background for the top bar. */
private topBarBackground: SVGRectElement;
/** The delete icon that goes in the top bar. */
private deleteIcon: SVGImageElement;
/** The delete button that goes in the top bar. */
private deleteButton: DeleteCommentBarButton;
/** The foldout icon that goes in the top bar. */
private foldoutIcon: SVGImageElement;
/** The foldout button that goes in the top bar. */
private foldoutButton: CollapseCommentBarButton;
/** The text element that goes in the top bar. */
private textPreview: SVGTextElement;
@@ -99,7 +102,7 @@ export class CommentView implements IRenderedElement {
constructor(
readonly workspace: WorkspaceSvg,
private commentId?: string,
private commentId: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
@@ -110,11 +113,11 @@ export class CommentView implements IRenderedElement {
({
topBarGroup: this.topBarGroup,
topBarBackground: this.topBarBackground,
deleteIcon: this.deleteIcon,
foldoutIcon: this.foldoutIcon,
deleteButton: this.deleteButton,
foldoutButton: this.foldoutButton,
textPreview: this.textPreview,
textPreviewNode: this.textPreviewNode,
} = this.createTopBar(this.svgRoot, workspace));
} = this.createTopBar(this.svgRoot));
this.commentEditor = this.createTextArea();
@@ -147,14 +150,11 @@ export class CommentView implements IRenderedElement {
* Creates the top bar and the elements visually within it.
* Registers event listeners.
*/
private createTopBar(
svgRoot: SVGGElement,
workspace: WorkspaceSvg,
): {
private createTopBar(svgRoot: SVGGElement): {
topBarGroup: SVGGElement;
topBarBackground: SVGRectElement;
deleteIcon: SVGImageElement;
foldoutIcon: SVGImageElement;
deleteButton: DeleteCommentBarButton;
foldoutButton: CollapseCommentBarButton;
textPreview: SVGTextElement;
textPreviewNode: Text;
} {
@@ -172,22 +172,14 @@ export class CommentView implements IRenderedElement {
},
topBarGroup,
);
// TODO: Before merging, does this mean to override an individual image,
// folks need to replace the whole media folder?
const deleteIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyDeleteIcon',
'href': `${workspace.options.pathToMedia}delete-icon.svg`,
},
const deleteButton = new DeleteCommentBarButton(
this.commentId,
this.workspace,
topBarGroup,
);
const foldoutIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyFoldoutIcon',
'href': `${workspace.options.pathToMedia}foldout-icon.svg`,
},
const foldoutButton = new CollapseCommentBarButton(
this.commentId,
this.workspace,
topBarGroup,
);
const textPreview = dom.createSvgElement(
@@ -200,27 +192,11 @@ export class CommentView implements IRenderedElement {
const textPreviewNode = document.createTextNode('');
textPreview.appendChild(textPreviewNode);
// TODO(toychest): Triggering this on pointerdown means that we can't start
// drags on the foldout icon. We need to open up the gesture system
// to fix this.
browserEvents.conditionalBind(
foldoutIcon,
'pointerdown',
this,
this.onFoldoutDown,
);
browserEvents.conditionalBind(
deleteIcon,
'pointerdown',
this,
this.onDeleteDown,
);
return {
topBarGroup,
topBarBackground,
deleteIcon,
foldoutIcon,
deleteButton,
foldoutButton,
textPreview,
textPreviewNode,
};
@@ -300,15 +276,10 @@ export class CommentView implements IRenderedElement {
*/
setSizeWithoutFiringEvents(size: Size) {
const topBarSize = this.topBarBackground.getBBox();
const deleteSize = this.deleteIcon.getBBox();
const foldoutSize = this.foldoutIcon.getBBox();
const textPreviewSize = this.textPreview.getBBox();
const resizeSize = this.resizeHandle.getBBox();
size = Size.max(
size,
this.calcMinSize(topBarSize, foldoutSize, deleteSize),
);
size = Size.max(size, this.calcMinSize(topBarSize));
this.size = size;
this.svgRoot.setAttribute('height', `${size.height}`);
@@ -317,15 +288,9 @@ export class CommentView implements IRenderedElement {
this.updateHighlightRect(size);
this.updateTopBarSize(size);
this.commentEditor.updateSize(size, topBarSize);
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
this.updateTextPreviewSize(
size,
topBarSize,
textPreviewSize,
deleteSize,
resizeSize,
);
this.deleteButton.reposition();
this.foldoutButton.reposition();
this.updateTextPreviewSize(size, topBarSize, textPreviewSize);
this.updateResizeHandlePosition(size, resizeSize);
}
@@ -347,25 +312,18 @@ export class CommentView implements IRenderedElement {
*
* The minimum height is based on the height of the top bar.
*/
private calcMinSize(
topBarSize: Size,
foldoutSize: Size,
deleteSize: Size,
): Size {
private calcMinSize(topBarSize: Size): Size {
this.updateTextPreview(this.commentEditor.getText() ?? '');
const textPreviewWidth = dom.getTextWidth(this.textPreview);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
let width = textPreviewWidth;
if (this.foldoutIcon.checkVisibility()) {
width += foldoutSize.width + foldoutMargin * 2;
if (this.foldoutButton.isVisible()) {
width += this.foldoutButton.getSize(true).getWidth();
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin before text.
}
if (this.deleteIcon.checkVisibility()) {
width += deleteSize.width + deleteMargin * 2;
if (this.deleteButton.isVisible()) {
width += this.deleteButton.getSize(true).getWidth();
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin after text.
}
@@ -376,16 +334,6 @@ export class CommentView implements IRenderedElement {
return new Size(width, height);
}
/** Calculates the margin that should exist around the delete icon. */
private calcDeleteMargin(topBarSize: Size, deleteSize: Size) {
return (topBarSize.height - deleteSize.height) / 2;
}
/** Calculates the margin that should exist around the foldout icon. */
private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) {
return (topBarSize.height - foldoutSize.height) / 2;
}
/** Updates the size of the highlight rect to reflect the new size. */
private updateHighlightRect(size: Size) {
this.highlightRect.setAttribute('height', `${size.height}`);
@@ -400,31 +348,6 @@ export class CommentView implements IRenderedElement {
this.topBarBackground.setAttribute('width', `${size.width}`);
}
/**
* Updates the position of the delete icon elements to reflect the new size.
*/
private updateDeleteIconPosition(
size: Size,
topBarSize: Size,
deleteSize: Size,
) {
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
this.deleteIcon.setAttribute('y', `${deleteMargin}`);
this.deleteIcon.setAttribute(
'x',
`${size.width - deleteSize.width - deleteMargin}`,
);
}
/**
* Updates the position of the foldout icon elements to reflect the new size.
*/
private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
}
/**
* Updates the size and position of the text preview elements to reflect the new size.
*/
@@ -432,25 +355,14 @@ export class CommentView implements IRenderedElement {
size: Size,
topBarSize: Size,
textPreviewSize: Size,
deleteSize: Size,
foldoutSize: Size,
) {
const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const foldoutSize = this.foldoutButton.getSize(true);
const deleteSize = this.deleteButton.getSize(true);
const textPreviewWidth =
size.width -
foldoutSize.width -
foldoutMargin * 2 -
deleteSize.width -
deleteMargin * 2;
this.textPreview.setAttribute(
'x',
`${
foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
}`,
);
size.width - foldoutSize.getWidth() - deleteSize.getWidth();
this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`);
this.textPreview.setAttribute(
'y',
`${textPreviewMargin + textPreviewSize.height / 2}`,
@@ -601,25 +513,6 @@ export class CommentView implements IRenderedElement {
);
}
/**
* Toggles the collapsedness of the block when we receive a pointer down
* event on the foldout icon.
*/
private onFoldoutDown(e: PointerEvent) {
touch.clearTouchIdentifier();
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.setCollapsed(!this.collapsed);
this.workspace.hideChaff();
e.stopPropagation();
}
/** Returns true if the comment is currently editable. */
isEditable(): boolean {
return this.editable;
@@ -692,7 +585,7 @@ export class CommentView implements IRenderedElement {
}
/** Brings the workspace comment to the front of its layer. */
private bringToFront() {
bringToFront() {
const parent = this.svgRoot.parentNode;
const childNodes = parent!.childNodes;
// Avoid moving the comment if it's already at the bottom.
@@ -719,6 +612,8 @@ export class CommentView implements IRenderedElement {
/** Disposes of this comment view. */
dispose() {
this.disposing = true;
this.foldoutButton.dispose();
this.deleteButton.dispose();
dom.removeNode(this.svgRoot);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
@@ -749,6 +644,13 @@ export class CommentView implements IRenderedElement {
removeDisposeListener(listener: () => void) {
this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
}
/**
* @internal
*/
getCommentBarButtons(): CommentBarButton[] {
return [this.foldoutButton, this.deleteButton];
}
}
css.register(`

View File

@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as browserEvents from '../browser_events.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {CommentBarButton} from './comment_bar_button.js';
/**
* Magic string appended to the comment ID to create a unique ID for this button.
*/
export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button';
/**
* Button that deletes a comment.
*/
export class DeleteCommentBarButton extends CommentBarButton {
/**
* Opaque ID used to unbind event handlers during disposal.
*/
private readonly bindId: browserEvents.Data;
/**
* SVG image displayed on this button.
*/
protected override readonly icon: SVGImageElement;
/**
* Creates a new DeleteCommentBarButton instance.
*
* @param id The ID of this button's parent comment.
* @param workspace The workspace this button's parent comment is shown on.
* @param container An SVG group that this button should be a child of.
*/
constructor(
protected readonly id: string,
protected readonly workspace: WorkspaceSvg,
protected readonly container: SVGGElement,
) {
super(id, workspace, container);
this.icon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyDeleteIcon',
'href': `${this.workspace.options.pathToMedia}delete-icon.svg`,
'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
},
container,
);
this.bindId = browserEvents.conditionalBind(
this.icon,
'pointerdown',
this,
this.performAction.bind(this),
);
}
/**
* Disposes of this button.
*/
dispose() {
browserEvents.unbind(this.bindId);
}
/**
* Adjusts the positioning of this button within its container.
*/
override reposition() {
const margin = this.getMargin();
// Reset to 0 so that our position doesn't force the parent container to
// grow.
this.icon.setAttribute('x', `0`);
const containerSize = this.container.getBBox();
this.icon.setAttribute('y', `${margin}`);
this.icon.setAttribute(
'x',
`${containerSize.width - this.getSize(true).getWidth()}`,
);
}
/**
* Deletes parent comment.
*
* @param e The event that triggered this action.
*/
override performAction(e?: Event) {
touch.clearTouchIdentifier();
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.getParentComment().dispose();
e?.stopPropagation();
}
}

View File

@@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js';
import {ConnectionType} from '../connection_type.js';
import type {Field} from '../field.js';
import type {Icon} from '../icons/icon.js';
import type {IBoundedElement} from '../interfaces/i_bounded_element.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import type {ISelectable} from '../interfaces/i_selectable.js';
import {RenderedConnection} from '../rendered_connection.js';
import {WorkspaceSvg} from '../workspace_svg.js';
@@ -143,21 +146,25 @@ function getBlockNavigationCandidates(
}
/**
* Returns the next/previous stack relative to the given block's stack.
* Returns the next/previous stack relative to the given element's stack.
*
* @param current The block whose stack will be navigated relative to.
* @param current The element whose stack will be navigated relative to.
* @param delta The difference in index to navigate; positive values navigate
* to the nth next stack, while negative values navigate to the nth previous
* stack.
* @returns The first block in the stack offset by `delta` relative to the
* current block's stack, or the last block in the stack offset by `delta`
* relative to the current block's stack when navigating backwards.
* @returns The first element in the stack offset by `delta` relative to the
* current element's stack, or the last element in the stack offset by
* `delta` relative to the current element's stack when navigating backwards.
*/
export function navigateStacks(current: BlockSvg, delta: number) {
const stacks = current.workspace.getTopBlocks(true);
const currentIndex = stacks.indexOf(current.getRootBlock());
export function navigateStacks(current: ISelectable, delta: number) {
const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg)
.getTopBoundedElements(true)
.filter((element: IBoundedElement) => isFocusableNode(element));
const currentIndex = stacks.indexOf(
current instanceof BlockSvg ? current.getRootBlock() : current,
);
const targetIndex = currentIndex + delta;
let result: BlockSvg | null = null;
let result: IFocusableNode | null = null;
if (targetIndex >= 0 && targetIndex < stacks.length) {
result = stacks[targetIndex];
} else if (targetIndex < 0) {
@@ -166,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) {
result = stacks[0];
}
// When navigating to a previous stack, our previous sibling is the last
// When navigating to a previous block stack, our previous sibling is the last
// block in it.
if (delta < 0 && result) {
if (delta < 0 && result instanceof BlockSvg) {
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
}

View File

@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {CommentBarButton} from '../comments/comment_bar_button.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 CommentBarButton.
*/
export class CommentBarButtonNavigationPolicy
implements INavigationPolicy<CommentBarButton>
{
/**
* Returns the first child of the given CommentBarButton.
*
* @param _current The CommentBarButton to return the first child of.
* @returns Null.
*/
getFirstChild(_current: CommentBarButton): IFocusableNode | null {
return null;
}
/**
* Returns the parent of the given CommentBarButton.
*
* @param current The CommentBarButton to return the parent of.
* @returns The parent comment of the given CommentBarButton.
*/
getParent(current: CommentBarButton): IFocusableNode | null {
return current.getParentComment();
}
/**
* Returns the next peer node of the given CommentBarButton.
*
* @param current The CommentBarButton to find the following element of.
* @returns The next CommentBarButton, if any.
*/
getNextSibling(current: CommentBarButton): IFocusableNode | null {
const children = current.getParentComment().view.getCommentBarButtons();
const currentIndex = children.indexOf(current);
if (currentIndex >= 0 && currentIndex + 1 < children.length) {
return children[currentIndex + 1];
}
return null;
}
/**
* Returns the previous peer node of the given CommentBarButton.
*
* @param current The CommentBarButton to find the preceding element of.
* @returns The CommentBarButton's previous CommentBarButton, if any.
*/
getPreviousSibling(current: CommentBarButton): IFocusableNode | null {
const children = current.getParentComment().view.getCommentBarButtons();
const currentIndex = children.indexOf(current);
if (currentIndex > 0) {
return children[currentIndex - 1];
}
return null;
}
/**
* Returns whether or not the given CommentBarButton can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given CommentBarButton can be focused.
*/
isNavigable(current: CommentBarButton): 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 CommentBarButton.
*/
isApplicable(current: any): current is CommentBarButton {
return current instanceof CommentBarButton;
}
}

View File

@@ -14,6 +14,7 @@
*/
import {BlockSvg} from '../block_svg.js';
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import {Field} from '../field.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
@@ -38,11 +39,11 @@ export class LineCursor extends Marker {
}
/**
* Moves the cursor to the next previous connection, next connection or block
* in the pre order traversal. Finds the next node in the pre order traversal.
* Moves the cursor to the next block or workspace comment in the pre-order
* traversal.
*
* @returns The next node, or null if the current node is
* not set or there is no next value.
* @returns The next node, or null if the current node is not set or there is
* no next value.
*/
next(): IFocusableNode | null {
const curNode = this.getCurNode();
@@ -53,8 +54,9 @@ export class LineCursor extends Marker {
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
(candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()) ||
candidate instanceof RenderedWorkspaceComment
);
},
true,
@@ -87,11 +89,11 @@ export class LineCursor extends Marker {
return newNode;
}
/**
* Moves the cursor to the previous next connection or previous connection in
* the pre order traversal.
* Moves the cursor to the previous block or workspace comment in the
* pre-order traversal.
*
* @returns The previous node, or null if the current node
* is not set or there is no previous value.
* @returns The previous node, or null if the current node is not set or there
* is no previous value.
*/
prev(): IFocusableNode | null {
const curNode = this.getCurNode();
@@ -102,8 +104,9 @@ export class LineCursor extends Marker {
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
(candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()) ||
candidate instanceof RenderedWorkspaceComment
);
},
true,

View File

@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
import {navigateStacks} from './block_navigation_policy.js';
/**
* Set of rules controlling keyboard navigation from an RenderedWorkspaceComment.
*/
export class WorkspaceCommentNavigationPolicy
implements INavigationPolicy<RenderedWorkspaceComment>
{
/**
* Returns the first child of the given workspace comment.
*
* @param current The workspace comment to return the first child of.
* @returns The first child button of the given comment.
*/
getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null {
return current.view.getCommentBarButtons()[0];
}
/**
* Returns the parent of the given workspace comment.
*
* @param current The workspace comment to return the parent of.
* @returns The parent workspace of the given comment.
*/
getParent(current: RenderedWorkspaceComment): IFocusableNode | null {
return current.workspace;
}
/**
* Returns the next peer node of the given workspace comment.
*
* @param current The workspace comment to find the following element of.
* @returns The next workspace comment or block stack, if any.
*/
getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
return navigateStacks(current, 1);
}
/**
* Returns the previous peer node of the given workspace comment.
*
* @param current The workspace comment to find the preceding element of.
* @returns The previous workspace comment or block stack, if any.
*/
getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null {
return navigateStacks(current, -1);
}
/**
* Returns whether or not the given workspace comment can be navigated to.
*
* @param current The instance to check for navigability.
* @returns True if the given workspace comment can be focused.
*/
isNavigable(current: RenderedWorkspaceComment): 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 RenderedWorkspaceComment.
*/
isApplicable(current: any): current is RenderedWorkspaceComment {
return current instanceof RenderedWorkspaceComment;
}
}

View File

@@ -7,9 +7,11 @@
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {INavigationPolicy} from './interfaces/i_navigation_policy.js';
import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js';
import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_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';
import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js';
import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js';
type RuleList<T> = INavigationPolicy<T>[];
@@ -29,6 +31,8 @@ export class Navigator {
new ConnectionNavigationPolicy(),
new WorkspaceNavigationPolicy(),
new IconNavigationPolicy(),
new WorkspaceCommentNavigationPolicy(),
new CommentBarButtonNavigationPolicy(),
];
/**

View File

@@ -32,6 +32,16 @@ export class Rect {
public right: number,
) {}
/**
* Converts a DOM or SVG Rect to a Blockly Rect.
*
* @param rect The rectangle to convert.
* @returns A representation of the same rectangle as a Blockly Rect.
*/
static from(rect: DOMRect | SVGRect): Rect {
return new Rect(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width);
}
/**
* Creates a new copy of this rectangle.
*
@@ -51,6 +61,11 @@ export class Rect {
return this.right - this.left;
}
/** Returns the top left coordinate of this rectangle. */
getOrigin(): Coordinate {
return new Coordinate(this.left, this.top);
}
/**
* Tests whether this rectangle contains a x/y coordinate.
*

View File

@@ -21,6 +21,7 @@ import * as common from './common.js';
import type {ConnectionDB} from './connection_db.js';
import type {Abstract} from './events/events_abstract.js';
import * as eventUtils from './events/utils.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IConnectionChecker} from './interfaces/i_connection_checker.js';
import {IProcedureMap} from './interfaces/i_procedure_map.js';
import type {IVariableMap} from './interfaces/i_variable_map.js';
@@ -35,6 +36,7 @@ import * as arrayUtils from './utils/array.js';
import * as deprecation from './utils/deprecation.js';
import * as idGenerator from './utils/idgenerator.js';
import * as math from './utils/math.js';
import {Rect} from './utils/rect.js';
import type * as toolbox from './utils/toolbox.js';
import {deleteVariable, getVariableUsesById} from './variables.js';
@@ -181,10 +183,31 @@ export class Workspace {
a: Block | WorkspaceComment,
b: Block | WorkspaceComment,
): number {
const wrap = (element: Block | WorkspaceComment) => {
return {
getBoundingRectangle: () => {
const xy = element.getRelativeToSurfaceXY();
return new Rect(xy.y, xy.y, xy.x, xy.x);
},
moveBy: () => {},
};
};
return this.sortByOrigin(wrap(a), wrap(b));
}
/**
* Sorts bounded elements on the workspace by their relative position, top to
* bottom (with slight LTR or RTL bias).
*
* @param a The first element to sort.
* @param b The second elment to sort.
* @returns -1, 0 or 1 depending on the sort order.
*/
protected sortByOrigin(a: IBoundedElement, b: IBoundedElement): number {
const offset =
Math.sin(math.toRadians(Workspace.SCAN_ANGLE)) * (this.RTL ? -1 : 1);
const aXY = a.getRelativeToSurfaceXY();
const bXY = b.getRelativeToSurfaceXY();
const aXY = a.getBoundingRectangle().getOrigin();
const bXY = b.getBoundingRectangle().getOrigin();
return aXY.y + offset * aXY.x - (bXY.y + offset * bXY.x);
}

View File

@@ -22,7 +22,9 @@ 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 {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';
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
import {WorkspaceComment} from './comments/workspace_comment.js';
import * as common from './common.js';
@@ -2266,8 +2268,8 @@ export class WorkspaceSvg
*
* @param comment comment to add.
*/
override addTopComment(comment: WorkspaceComment) {
this.addTopBoundedElement(comment as RenderedWorkspaceComment);
override addTopComment(comment: RenderedWorkspaceComment) {
this.addTopBoundedElement(comment);
super.addTopComment(comment);
}
@@ -2276,11 +2278,31 @@ export class WorkspaceSvg
*
* @param comment comment to remove.
*/
override removeTopComment(comment: WorkspaceComment) {
this.removeTopBoundedElement(comment as RenderedWorkspaceComment);
override removeTopComment(comment: RenderedWorkspaceComment) {
this.removeTopBoundedElement(comment);
super.removeTopComment(comment);
}
/**
* Returns a list of comments on this workspace.
*
* @param ordered If true, sorts the comments based on their position.
* @returns A list of workspace comments.
*/
override getTopComments(ordered = false): RenderedWorkspaceComment[] {
return super.getTopComments(ordered) as RenderedWorkspaceComment[];
}
/**
* Returns the workspace comment with the given ID, if any.
*
* @param id The ID of the comment to retrieve.
* @returns The workspace comment with the given ID, or null.
*/
override getCommentById(id: string): RenderedWorkspaceComment | null {
return super.getCommentById(id) as RenderedWorkspaceComment | null;
}
override getRootWorkspace(): WorkspaceSvg | null {
return super.getRootWorkspace() as WorkspaceSvg | null;
}
@@ -2308,8 +2330,15 @@ export class WorkspaceSvg
*
* @returns The top-level bounded elements.
*/
getTopBoundedElements(): IBoundedElement[] {
return new Array<IBoundedElement>().concat(this.topBoundedElements);
getTopBoundedElements(ordered = false): IBoundedElement[] {
const elements = new Array<IBoundedElement>().concat(
this.topBoundedElements,
);
if (ordered) {
elements.sort(this.sortByOrigin.bind(this));
}
return elements;
}
/**
@@ -2794,19 +2823,32 @@ export class WorkspaceSvg
return null;
}
// Search for a specific workspace comment editor
// (only if id seems like it is one).
const commentEditorIndicator = id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER);
if (commentEditorIndicator !== -1) {
const commentId = id.substring(0, commentEditorIndicator);
// Search for a specific workspace comment or comment icon if the ID
// indicates the presence of one.
const commentIdSeparatorIndex = Math.max(
id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER),
id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER),
id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER),
);
if (commentIdSeparatorIndex !== -1) {
const commentId = id.substring(0, commentIdSeparatorIndex);
const comment = this.searchForWorkspaceComment(commentId);
if (comment) {
return comment.getEditorFocusableNode();
if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) {
return comment.getEditorFocusableNode();
} else {
return (
comment.view
.getCommentBarButtons()
.find((button) => button.getFocusableElement().id.includes(id)) ??
null
);
}
}
}
// Search for a specific block.
// Don't use `getBlockById` because the block ID is not guaranteeed
// Don't use `getBlockById` because the block ID is not guaranteed
// to be globally unique, but the ID on the focusable element is.
const block = this.getAllBlocks(false).find(
(block) => block.getFocusableElement().id === id,