Skip to content

Commit 3e33a73

Browse files
authored
fix: Scroll flyout contents into bounds. (#248)
* fix: Scroll flyout contents into bounds. * fix: Allow focusing/scrolling flyout labels into view. * fix: Clone bounds before modifying them.
1 parent a73e1c0 commit 3e33a73

File tree

5 files changed

+111
-55
lines changed

5 files changed

+111
-55
lines changed

src/flyout_cursor.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import * as Blockly from 'blockly/core';
13+
import {scrollBoundsIntoView} from './workspace_utilities';
1314

1415
/**
1516
* Class for a flyout cursor.
@@ -20,7 +21,7 @@ export class FlyoutCursor extends Blockly.Cursor {
2021
/**
2122
* The constructor for the FlyoutCursor.
2223
*/
23-
constructor() {
24+
constructor(private readonly flyout: Blockly.IFlyout) {
2425
super();
2526
}
2627

@@ -79,6 +80,31 @@ export class FlyoutCursor extends Blockly.Cursor {
7980
override out(): null {
8081
return null;
8182
}
83+
84+
override setCurNode(node: Blockly.ASTNode) {
85+
super.setCurNode(node);
86+
87+
const location = node.getLocation();
88+
let bounds: Blockly.utils.Rect | undefined;
89+
if (
90+
'getBoundingRectangle' in location &&
91+
typeof location.getBoundingRectangle === 'function'
92+
) {
93+
bounds = location.getBoundingRectangle();
94+
} else if (location instanceof Blockly.FlyoutButton) {
95+
const {x, y} = location.getPosition();
96+
bounds = new Blockly.utils.Rect(
97+
y,
98+
y + location.height,
99+
x,
100+
x + location.width,
101+
);
102+
}
103+
104+
if (!(bounds instanceof Blockly.utils.Rect)) return;
105+
106+
scrollBoundsIntoView(bounds, this.flyout.getWorkspace());
107+
}
82108
}
83109

84110
export const registrationType = Blockly.registry.Type.CURSOR;

src/line_cursor.ts

Lines changed: 6 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import * as Blockly from 'blockly/core';
1717
import {ASTNode, Marker} from 'blockly/core';
18+
import {scrollBoundsIntoView} from './workspace_utilities';
1819

1920
/** Options object for LineCursor instances. */
2021
export type CursorOptions = {
@@ -549,7 +550,11 @@ export class LineCursor extends Marker {
549550
this.drawMarker(oldNode, newNode);
550551
// Try to scroll cursor into view.
551552
if (newNode?.getType() === ASTNode.types.BLOCK) {
552-
this.scrollBlockIntoView(newNode.getLocation() as Blockly.BlockSvg);
553+
const block = newNode.getLocation() as Blockly.BlockSvg;
554+
scrollBoundsIntoView(
555+
block.getBoundingRectangleWithoutChildren(),
556+
block.workspace,
557+
);
553558
}
554559
}
555560

@@ -568,57 +573,6 @@ export class LineCursor extends Marker {
568573
this.drawMarker(curNode, curNode);
569574
}
570575

571-
/**
572-
* Scrolls the provided block into view.
573-
*
574-
* This is a basic implementation that scrolls just enough to get the block
575-
* into bounds. For very small workspaces/high zoom levels the entire block
576-
* may not fit, but a decent portion of it should still be visible at least.
577-
*
578-
* @param block The block to scroll into bounds.
579-
*/
580-
private scrollBlockIntoView(block: Blockly.BlockSvg) {
581-
const workspace = block.workspace;
582-
const scale = workspace.getScale();
583-
const bounds = block.getBoundingRectangleWithoutChildren();
584-
const rawViewport = workspace.getMetricsManager().getViewMetrics(true);
585-
const viewport = new Blockly.utils.Rect(
586-
rawViewport.top,
587-
rawViewport.top + rawViewport.height,
588-
rawViewport.left,
589-
rawViewport.left + rawViewport.width,
590-
);
591-
592-
if (
593-
bounds.left >= viewport.left &&
594-
bounds.top >= viewport.top &&
595-
bounds.right <= viewport.right &&
596-
bounds.bottom <= viewport.bottom
597-
) {
598-
// Do nothing if the block is fully inside the viewport.
599-
return;
600-
}
601-
602-
let deltaX = 0;
603-
let deltaY = 0;
604-
605-
if (bounds.left < viewport.left) {
606-
deltaX = viewport.left - bounds.left;
607-
} else if (bounds.right > viewport.right) {
608-
deltaX = viewport.right - bounds.right;
609-
}
610-
611-
if (bounds.top < viewport.top) {
612-
deltaY = viewport.top - bounds.top;
613-
} else if (bounds.bottom > viewport.bottom) {
614-
deltaY = viewport.bottom - bounds.bottom;
615-
}
616-
617-
deltaX *= scale;
618-
deltaY *= scale;
619-
workspace.scroll(workspace.scrollX + deltaX, workspace.scrollY + deltaY);
620-
}
621-
622576
/**
623577
* Draw this cursor's marker.
624578
*

src/navigation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ export class Navigation {
162162
cursorRegistrationName,
163163
);
164164
if (FlyoutCursorClass) {
165-
flyoutWorkspace.getMarkerManager().setCursor(new FlyoutCursorClass());
165+
flyoutWorkspace
166+
.getMarkerManager()
167+
.setCursor(new FlyoutCursorClass(flyout));
166168
}
167169
}
168170

@@ -1452,7 +1454,7 @@ export class Navigation {
14521454
);
14531455
if (typeof buttonCallback === 'function') {
14541456
buttonCallback(button);
1455-
} else {
1457+
} else if (!button.isLabel()) {
14561458
throw new Error('No callback function found for flyout button.');
14571459
}
14581460
}

src/workspace_utilities.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from 'blockly/core';
8+
9+
/**
10+
* Scrolls the provided bounds into view.
11+
*
12+
* In the case of small workspaces/large bounds, this function prioritizes
13+
* getting the top left corner of the bounds into view. It also adds some
14+
* padding around the bounds to allow the element to be comfortably in view.
15+
*
16+
* @param bounds A rectangle to scroll into view, as best as possible.
17+
* @param workspace The workspace to scroll the given bounds into view in.
18+
*/
19+
export function scrollBoundsIntoView(
20+
originalBounds: Blockly.utils.Rect,
21+
workspace: Blockly.WorkspaceSvg,
22+
) {
23+
const scale = workspace.getScale();
24+
25+
const bounds = originalBounds.clone();
26+
27+
// Add some padding to the bounds so the element is scrolled comfortably
28+
// into view.
29+
bounds.top -= 10;
30+
bounds.bottom += 10;
31+
bounds.left -= 10;
32+
bounds.right += 10;
33+
34+
const rawViewport = workspace.getMetricsManager().getViewMetrics(true);
35+
const viewport = new Blockly.utils.Rect(
36+
rawViewport.top,
37+
rawViewport.top + rawViewport.height,
38+
rawViewport.left,
39+
rawViewport.left + rawViewport.width,
40+
);
41+
42+
if (
43+
bounds.left >= viewport.left &&
44+
bounds.top >= viewport.top &&
45+
bounds.right <= viewport.right &&
46+
bounds.bottom <= viewport.bottom
47+
) {
48+
// Do nothing if the block is fully inside the viewport.
49+
return;
50+
}
51+
52+
let deltaX = 0;
53+
let deltaY = 0;
54+
55+
if (bounds.left < viewport.left) {
56+
deltaX = viewport.left - bounds.left;
57+
} else if (bounds.right > viewport.right) {
58+
deltaX = viewport.right - bounds.right;
59+
}
60+
61+
if (bounds.top < viewport.top) {
62+
deltaY = viewport.top - bounds.top;
63+
} else if (bounds.bottom > viewport.bottom) {
64+
deltaY = viewport.bottom - bounds.bottom;
65+
}
66+
67+
deltaX *= scale;
68+
deltaY *= scale;
69+
workspace.scroll(workspace.scrollX + deltaX, workspace.scrollY + deltaY);
70+
}

test/blocks/toolbox.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export const p5CategoryContents = [
3535
},
3636
},
3737
},
38+
{
39+
kind: 'label',
40+
text: 'Writing text',
41+
},
3842
{
3943
kind: 'block',
4044
type: 'write_text_with_shadow',

0 commit comments

Comments
 (0)