refactor: Make focusable elements responsible for scrolling themselves into bounds. (#9288)

* refactor: Make focusable elements responsible for scrolling themselves into bounds.

* chore: Add tests for scrolling focused elements into view.

* fix: Removed inadvertent `.only`.

* fix: Scroll parent block of connections into bounds on focus.
This commit is contained in:
Aaron Dodson
2025-08-28 11:28:40 -07:00
committed by GitHub
parent fd0aaedb10
commit 47307a9e53
11 changed files with 276 additions and 36 deletions

View File

@@ -1844,6 +1844,9 @@ export class BlockSvg
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.select();
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
);
}
/** See IFocusableNode.onNodeBlur. */

View File

@@ -707,6 +707,10 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
onNodeFocus(): void {
this.select();
this.bringToFront();
const xy = this.getRelativeToSurfaceXY();
const size = this.getSize();
const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width);
this.workspace.scrollBoundsIntoView(bounds);
}
/** See IFocusableNode.onNodeBlur. */

View File

@@ -87,7 +87,13 @@ export abstract class CommentBarButton implements IFocusableNode {
}
/** Called when this button's focusable DOM element gains focus. */
onNodeFocus() {}
onNodeFocus() {
const commentView = this.getCommentView();
const xy = commentView.getRelativeToSurfaceXY();
const size = commentView.getSize();
const bounds = new Rect(xy.y, xy.y + size.height, xy.x, xy.x + size.width);
commentView.workspace.scrollBoundsIntoView(bounds);
}
/** Called when this button's focusable DOM element loses focus. */
onNodeBlur() {}

View File

@@ -10,8 +10,10 @@ import {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import * as touch from '../touch.js';
import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import * as svgMath from '../utils/svg_math.js';
import {WorkspaceSvg} from '../workspace_svg.js';
/**
@@ -188,7 +190,16 @@ export class CommentEditor implements IFocusableNode {
getFocusableTree(): IFocusableTree {
return this.workspace;
}
onNodeFocus(): void {}
onNodeFocus(): void {
const bbox = Rect.from(this.foreignObject.getBoundingClientRect());
this.workspace.scrollBoundsIntoView(
Rect.createFromPoint(
svgMath.screenToWsCoordinates(this.workspace, bbox.getOrigin()),
bbox.getWidth(),
bbox.getHeight(),
),
);
}
onNodeBlur(): void {}
canBeFocused(): boolean {
if (this.id) return true;

View File

@@ -347,6 +347,7 @@ export class RenderedWorkspaceComment
this.select();
// Ensure that the comment is always at the top when focused.
this.workspace.getLayerManager()?.append(this, layers.BLOCK);
this.workspace.scrollBoundsIntoView(this.getBoundingRectangle());
}
/** See IFocusableNode.onNodeBlur. */

View File

@@ -1380,7 +1380,12 @@ export abstract class Field<T = any>
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
onNodeFocus(): void {
const block = this.getSourceBlock() as BlockSvg;
block.workspace.scrollBoundsIntoView(
block.getBoundingRectangleWithoutChildren(),
);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

View File

@@ -398,7 +398,11 @@ export class FlyoutButton
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
onNodeFocus(): void {
const xy = this.getPosition();
const bounds = new Rect(xy.y, xy.y + this.height, xy.x, xy.x + this.width);
this.workspace.scrollBoundsIntoView(bounds);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

View File

@@ -14,6 +14,7 @@ import * as tooltip from '../tooltip.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
@@ -168,7 +169,16 @@ export abstract class Icon implements IIcon {
}
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {}
onNodeFocus(): void {
const blockBounds = (this.sourceBlock as BlockSvg).getBoundingRectangle();
const bounds = new Rect(
blockBounds.top + this.offsetInBlock.y,
blockBounds.top + this.offsetInBlock.y + this.getSize().height,
blockBounds.left + this.offsetInBlock.x,
blockBounds.left + this.offsetInBlock.x + this.getSize().width,
);
(this.sourceBlock as BlockSvg).workspace.scrollBoundsIntoView(bounds);
}
/** See IFocusableNode.onNodeBlur. */
onNodeBlur(): void {}

View File

@@ -14,14 +14,11 @@
*/
import {BlockSvg} from '../block_svg.js';
import {CommentBarButton} from '../comments/comment_bar_button.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';
import * as registry from '../registry.js';
import {Rect} from '../utils/rect.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
/**
@@ -392,31 +389,6 @@ export class LineCursor extends Marker {
*/
setCurNode(newNode: IFocusableNode) {
getFocusManager().focusNode(newNode);
// Try to scroll cursor into view.
if (newNode instanceof BlockSvg) {
newNode.workspace.scrollBoundsIntoView(
newNode.getBoundingRectangleWithoutChildren(),
);
} else if (newNode instanceof Field) {
const block = newNode.getSourceBlock() as BlockSvg;
block.workspace.scrollBoundsIntoView(
block.getBoundingRectangleWithoutChildren(),
);
} else if (newNode instanceof RenderedWorkspaceComment) {
newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle());
} else if (newNode instanceof CommentBarButton) {
const commentView = newNode.getCommentView();
const xy = commentView.getRelativeToSurfaceXY();
const size = commentView.getSize();
const bounds = new Rect(
xy.y,
xy.y + size.height,
xy.x,
xy.x + size.width,
);
commentView.workspace.scrollBoundsIntoView(bounds);
}
}
/**

View File

@@ -644,6 +644,9 @@ export class RenderedConnection
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.highlight();
this.getSourceBlock().workspace.scrollBoundsIntoView(
this.getSourceBlock().getBoundingRectangleWithoutChildren(),
);
}
/** See IFocusableNode.onNodeBlur. */
@@ -656,12 +659,12 @@ export class RenderedConnection
return true;
}
private findHighlightSvg(): SVGElement | null {
private findHighlightSvg(): SVGPathElement | null {
// This cast is valid as TypeScript's definition is wrong. See:
// https://github.com/microsoft/TypeScript/issues/60996.
return document.getElementById(this.id) as
| unknown
| null as SVGElement | null;
| null as SVGPathElement | null;
}
}