From e5804e709563f6e939a2a7957c3150403414c7b1 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 1 Jul 2025 15:13:13 -0700 Subject: [PATCH] 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. --- core/comments.ts | 3 + core/comments/collapse_comment_bar_button.ts | 101 ++++++++++ core/comments/comment_bar_button.ts | 105 ++++++++++ core/comments/comment_view.ts | 190 +++++------------- core/comments/delete_comment_bar_button.ts | 102 ++++++++++ core/keyboard_nav/block_navigation_policy.ts | 29 ++- .../comment_bar_button_navigation_policy.ts | 86 ++++++++ core/keyboard_nav/line_cursor.ts | 27 +-- .../workspace_comment_navigation_policy.ts | 77 +++++++ core/navigator.ts | 4 + core/utils/rect.ts | 15 ++ core/workspace.ts | 27 ++- core/workspace_svg.ts | 68 +++++-- 13 files changed, 652 insertions(+), 182 deletions(-) create mode 100644 core/comments/collapse_comment_bar_button.ts create mode 100644 core/comments/comment_bar_button.ts create mode 100644 core/comments/delete_comment_bar_button.ts create mode 100644 core/keyboard_nav/comment_bar_button_navigation_policy.ts create mode 100644 core/keyboard_nav/workspace_comment_navigation_policy.ts diff --git a/core/comments.ts b/core/comments.ts index 86e8f50b9..179ab4a33 100644 --- a/core/comments.ts +++ b/core/comments.ts @@ -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'; diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts new file mode 100644 index 000000000..b0738d707 --- /dev/null +++ b/core/comments/collapse_comment_bar_button.ts @@ -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(); + } +} diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts new file mode 100644 index 000000000..d78a7fd86 --- /dev/null +++ b/core/comments/comment_bar_button.ts @@ -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(); + } +} diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 1e5ad4a52..936d74650 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -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(` diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts new file mode 100644 index 000000000..ccdd02539 --- /dev/null +++ b/core/comments/delete_comment_bar_button.ts @@ -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(); + } +} diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 2637ad49d..9f56b5384 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -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; } diff --git a/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/core/keyboard_nav/comment_bar_button_navigation_policy.ts new file mode 100644 index 000000000..f676f4655 --- /dev/null +++ b/core/keyboard_nav/comment_bar_button_navigation_policy.ts @@ -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 +{ + /** + * 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; + } +} diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 89668dedb..aeb80cff1 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -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, diff --git a/core/keyboard_nav/workspace_comment_navigation_policy.ts b/core/keyboard_nav/workspace_comment_navigation_policy.ts new file mode 100644 index 000000000..7fe70cead --- /dev/null +++ b/core/keyboard_nav/workspace_comment_navigation_policy.ts @@ -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 +{ + /** + * 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; + } +} diff --git a/core/navigator.ts b/core/navigator.ts index 77bb64cd8..2f095f6f9 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -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 = INavigationPolicy[]; @@ -29,6 +31,8 @@ export class Navigator { new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), ]; /** diff --git a/core/utils/rect.ts b/core/utils/rect.ts index c7da2a686..5a6822633 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -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. * diff --git a/core/workspace.ts b/core/workspace.ts index f7b866447..5f2051939 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -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); } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 3033eacd7..00eef5653 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -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().concat(this.topBoundedElements); + getTopBoundedElements(ordered = false): IBoundedElement[] { + const elements = new Array().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,