Skip to content

Commit e5804e7

Browse files
authored
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.
1 parent c426c6d commit e5804e7

13 files changed

Lines changed: 652 additions & 182 deletions

core/comments.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js';
8+
export {CommentBarButton} from './comments/comment_bar_button.js';
79
export {CommentEditor} from './comments/comment_editor.js';
810
export {CommentView} from './comments/comment_view.js';
11+
export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js';
912
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
1013
export {WorkspaceComment} from './comments/workspace_comment.js';
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as browserEvents from '../browser_events.js';
8+
import * as touch from '../touch.js';
9+
import * as dom from '../utils/dom.js';
10+
import {Svg} from '../utils/svg.js';
11+
import type {WorkspaceSvg} from '../workspace_svg.js';
12+
import {CommentBarButton} from './comment_bar_button.js';
13+
14+
/**
15+
* Magic string appended to the comment ID to create a unique ID for this button.
16+
*/
17+
export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER =
18+
'_collapse_bar_button';
19+
20+
/**
21+
* Button that toggles the collapsed state of a comment.
22+
*/
23+
export class CollapseCommentBarButton extends CommentBarButton {
24+
/**
25+
* Opaque ID used to unbind event handlers during disposal.
26+
*/
27+
private readonly bindId: browserEvents.Data;
28+
29+
/**
30+
* SVG image displayed on this button.
31+
*/
32+
protected override readonly icon: SVGImageElement;
33+
34+
/**
35+
* Creates a new CollapseCommentBarButton instance.
36+
*
37+
* @param id The ID of this button's parent comment.
38+
* @param workspace The workspace this button's parent comment is displayed on.
39+
* @param container An SVG group that this button should be a child of.
40+
*/
41+
constructor(
42+
protected readonly id: string,
43+
protected readonly workspace: WorkspaceSvg,
44+
protected readonly container: SVGGElement,
45+
) {
46+
super(id, workspace, container);
47+
48+
this.icon = dom.createSvgElement(
49+
Svg.IMAGE,
50+
{
51+
'class': 'blocklyFoldoutIcon',
52+
'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`,
53+
'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
54+
},
55+
this.container,
56+
);
57+
this.bindId = browserEvents.conditionalBind(
58+
this.icon,
59+
'pointerdown',
60+
this,
61+
this.performAction.bind(this),
62+
);
63+
}
64+
65+
/**
66+
* Disposes of this button.
67+
*/
68+
dispose() {
69+
browserEvents.unbind(this.bindId);
70+
}
71+
72+
/**
73+
* Adjusts the positioning of this button within its container.
74+
*/
75+
override reposition() {
76+
const margin = this.getMargin();
77+
this.icon.setAttribute('y', `${margin}`);
78+
this.icon.setAttribute('x', `${margin}`);
79+
}
80+
81+
/**
82+
* Toggles the collapsed state of the parent comment.
83+
*
84+
* @param e The event that triggered this action.
85+
*/
86+
override performAction(e?: Event) {
87+
touch.clearTouchIdentifier();
88+
89+
const comment = this.getParentComment();
90+
comment.view.bringToFront();
91+
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
92+
e.stopPropagation();
93+
return;
94+
}
95+
96+
comment.setCollapsed(!comment.isCollapsed());
97+
this.workspace.hideChaff();
98+
99+
e?.stopPropagation();
100+
}
101+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
8+
import {Rect} from '../utils/rect.js';
9+
import type {WorkspaceSvg} from '../workspace_svg.js';
10+
import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
11+
12+
/**
13+
* Button displayed on a comment's top bar.
14+
*/
15+
export abstract class CommentBarButton implements IFocusableNode {
16+
/**
17+
* SVG image displayed on this button.
18+
*/
19+
protected abstract readonly icon: SVGImageElement;
20+
21+
/**
22+
* Creates a new CommentBarButton instance.
23+
*
24+
* @param id The ID of this button's parent comment.
25+
* @param workspace The workspace this button's parent comment is on.
26+
* @param container An SVG group that this button should be a child of.
27+
*/
28+
constructor(
29+
protected readonly id: string,
30+
protected readonly workspace: WorkspaceSvg,
31+
protected readonly container: SVGGElement,
32+
) {}
33+
34+
/**
35+
* Returns whether or not this button is currently visible.
36+
*/
37+
isVisible(): boolean {
38+
return this.icon.checkVisibility();
39+
}
40+
41+
/**
42+
* Returns the parent comment of this comment bar button.
43+
*/
44+
getParentComment(): RenderedWorkspaceComment {
45+
const comment = this.workspace.getCommentById(this.id);
46+
if (!comment) {
47+
throw new Error(
48+
`Comment bar button ${this.id} has no corresponding comment`,
49+
);
50+
}
51+
52+
return comment;
53+
}
54+
55+
/** Adjusts the position of this button within its parent container. */
56+
abstract reposition(): void;
57+
58+
/** Perform the action this button should take when it is acted on. */
59+
abstract performAction(e?: Event): void;
60+
61+
/**
62+
* Returns the dimensions of this button in workspace coordinates.
63+
*
64+
* @param includeMargin True to include the margin when calculating the size.
65+
* @returns The size of this button.
66+
*/
67+
getSize(includeMargin = false): Rect {
68+
const bounds = this.icon.getBBox();
69+
const rect = Rect.from(bounds);
70+
if (includeMargin) {
71+
const margin = this.getMargin();
72+
rect.left -= margin;
73+
rect.top -= margin;
74+
rect.bottom += margin;
75+
rect.right += margin;
76+
}
77+
return rect;
78+
}
79+
80+
/** Returns the margin in workspace coordinates surrounding this button. */
81+
getMargin(): number {
82+
return (this.container.getBBox().height - this.icon.getBBox().height) / 2;
83+
}
84+
85+
/** Returns a DOM element representing this button that can receive focus. */
86+
getFocusableElement() {
87+
return this.icon;
88+
}
89+
90+
/** Returns the workspace this button is a child of. */
91+
getFocusableTree() {
92+
return this.workspace;
93+
}
94+
95+
/** Called when this button's focusable DOM element gains focus. */
96+
onNodeFocus() {}
97+
98+
/** Called when this button's focusable DOM element loses focus. */
99+
onNodeBlur() {}
100+
101+
/** Returns whether this button can be focused. True if it is visible. */
102+
canBeFocused() {
103+
return this.isVisible();
104+
}
105+
}

0 commit comments

Comments
 (0)