Skip to content

Commit 1974c93

Browse files
feat: make the marker ephemeral (#187)
* feat: add passive focus indicator * feat: add passive_focus.ts * chore: remove old marker code * chore: undo unwanted formatting * chore: code cleanup * chore: code cleanup * feat: create a DOM element for the passive focus indicator * fix: hide cursor when opening toolbox or flyout
1 parent 6155b0e commit 1974c93

File tree

2 files changed

+209
-44
lines changed

2 files changed

+209
-44
lines changed

src/navigation.ts

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
registrationType as cursorRegistrationType,
1818
FlyoutCursor,
1919
} from './flyout_cursor';
20+
import {PassiveFocus} from './passive_focus';
2021

2122
/**
2223
* Class that holds all methods necessary for keyboard navigation to work.
@@ -33,11 +34,6 @@ export class Navigation {
3334
*/
3435
WS_MOVE_DISTANCE = 40;
3536

36-
/**
37-
* The name of the marker to use for keyboard navigation.
38-
*/
39-
MARKER_NAME = 'local_marker_1';
40-
4137
/**
4238
* The default coordinate to use when focusing on the workspace and no
4339
* blocks are present. In pixel coordinates, but will be converted to
@@ -72,6 +68,17 @@ export class Navigation {
7268
*/
7369
protected workspaces: Blockly.WorkspaceSvg[] = [];
7470

71+
/**
72+
* An object that renders a passive focus indicator at a specified location.
73+
*/
74+
protected passiveFocusIndicator: PassiveFocus = new PassiveFocus();
75+
76+
/**
77+
* The node that has passive focus when the cursor has moved to the flyout
78+
* or toolbox; null if the cursor is moving around the main workspace.
79+
*/
80+
protected markedNode: Blockly.ASTNode | null = null;
81+
7582
/**
7683
* Constructor for keyboard navigation.
7784
*/
@@ -90,9 +97,6 @@ export class Navigation {
9097
addWorkspace(workspace: Blockly.WorkspaceSvg) {
9198
this.workspaces.push(workspace);
9299
const flyout = workspace.getFlyout();
93-
workspace
94-
.getMarkerManager()
95-
.registerMarker(this.MARKER_NAME, new Blockly.Marker());
96100
workspace.addChangeListener(this.wsChangeWrapper);
97101

98102
if (flyout) {
@@ -116,9 +120,7 @@ export class Navigation {
116120
if (workspaceIdx > -1) {
117121
this.workspaces.splice(workspaceIdx, 1);
118122
}
119-
if (workspace.getMarkerManager()) {
120-
workspace.getMarkerManager().unregisterMarker(this.MARKER_NAME);
121-
}
123+
this.passiveFocusIndicator.dispose();
122124
workspace.removeChangeListener(this.wsChangeWrapper);
123125

124126
if (flyout) {
@@ -146,16 +148,6 @@ export class Navigation {
146148
return this.workspaceStates[workspace.id];
147149
}
148150

149-
/**
150-
* Gets the marker created for keyboard navigation.
151-
*
152-
* @param workspace The workspace to get the marker from.
153-
* @returns The marker created for keyboard navigation.
154-
*/
155-
getMarker(workspace: Blockly.WorkspaceSvg): Blockly.Marker | null {
156-
return workspace.getMarker(this.MARKER_NAME);
157-
}
158-
159151
/**
160152
* Adds all event listeners and cursors to the flyout that are needed for
161153
* keyboard navigation to work.
@@ -480,13 +472,11 @@ export class Navigation {
480472
return;
481473
}
482474

475+
this.markAtCursor(workspace);
476+
workspace.getCursor()?.hide();
483477
this.setState(workspace, Constants.STATE.TOOLBOX);
484478
this.resetFlyout(workspace, false /* shouldHide */);
485479

486-
if (!this.getMarker(workspace)!.getCurNode()) {
487-
this.markAtCursor(workspace);
488-
}
489-
490480
if (!toolbox.getSelectedItem()) {
491481
// Find the first item that is selectable.
492482
const toolboxItems = (toolbox as any).getToolboxItems();
@@ -506,14 +496,12 @@ export class Navigation {
506496
* @param workspace The workspace the flyout is on.
507497
*/
508498
focusFlyout(workspace: Blockly.WorkspaceSvg) {
509-
const flyout = workspace.getFlyout();
499+
workspace.getCursor()?.hide();
500+
this.markAtCursor(workspace);
510501

502+
const flyout = workspace.getFlyout();
511503
this.setState(workspace, Constants.STATE.FLYOUT);
512504

513-
if (!this.getMarker(workspace)!.getCurNode()) {
514-
this.markAtCursor(workspace);
515-
}
516-
517505
if (flyout && flyout.getWorkspace()) {
518506
const flyoutContents = flyout.getContents();
519507
const firstFlyoutItem = flyoutContents[0];
@@ -612,11 +600,14 @@ export class Navigation {
612600
if (!newBlock) {
613601
return;
614602
}
615-
const markerNode = this.getMarker(workspace)!.getCurNode();
603+
if (!this.markedNode) {
604+
this.warn('No marked node when inserting from flyout.');
605+
return;
606+
}
616607
if (
617608
!this.tryToConnectNodes(
618609
workspace,
619-
markerNode,
610+
this.markedNode,
620611
Blockly.ASTNode.createBlockNode(newBlock)!,
621612
)
622613
) {
@@ -681,19 +672,21 @@ export class Navigation {
681672
}
682673

683674
/**
684-
* Connects the location of the marker and the location of the cursor.
685-
* No-op if the marker or cursor node are null.
675+
* Connects the location of the marked node and the location of the cursor.
676+
* No-op if the marked node or cursor node are null.
686677
*
687678
* @param workspace The main workspace.
688679
* @returns True if the cursor and marker locations were connected,
689680
* false otherwise.
690681
*/
691682
connectMarkerAndCursor(workspace: Blockly.WorkspaceSvg): boolean {
692-
const markerNode = this.getMarker(workspace)!.getCurNode();
693683
const cursorNode = workspace.getCursor()!.getCurNode();
694684

695-
if (markerNode && cursorNode) {
696-
return this.tryToConnectNodes(workspace, markerNode, cursorNode);
685+
if (this.markedNode && cursorNode) {
686+
if (this.tryToConnectNodes(workspace, this.markedNode, cursorNode)) {
687+
this.removeMark(workspace);
688+
return true;
689+
}
697690
}
698691
return false;
699692
}
@@ -1109,22 +1102,24 @@ export class Navigation {
11091102
}
11101103

11111104
/**
1112-
* Moves the marker to the cursor's current location.
1105+
* Moves the passive focus indicator to the cursor's current location.
11131106
*
11141107
* @param workspace The workspace.
11151108
*/
11161109
markAtCursor(workspace: Blockly.WorkspaceSvg) {
1117-
this.getMarker(workspace)!.setCurNode(workspace.getCursor()!.getCurNode());
1110+
const cursor = workspace.getCursor()!;
1111+
this.markedNode = cursor.getCurNode();
1112+
this.passiveFocusIndicator.show(this.markedNode);
11181113
}
11191114

11201115
/**
1121-
* Removes the marker from its current location and hide it.
1116+
* Removes the passive focus indicator from its current location and hides it.
11221117
*
11231118
* @param workspace The workspace.
11241119
*/
11251120
removeMark(workspace: Blockly.WorkspaceSvg) {
1126-
const marker = this.getMarker(workspace);
1127-
marker?.hide();
1121+
this.passiveFocusIndicator.hide();
1122+
this.markedNode = null;
11281123
}
11291124

11301125
/**
@@ -1154,8 +1149,8 @@ export class Navigation {
11541149
workspace.keyboardAccessibilityMode
11551150
) {
11561151
workspace.keyboardAccessibilityMode = false;
1152+
this.markAtCursor(workspace);
11571153
workspace.getCursor()!.hide();
1158-
this.getMarker(workspace)!.hide();
11591154
if (this.getFlyoutCursor(workspace)) {
11601155
this.getFlyoutCursor(workspace)!.hide();
11611156
}
@@ -1247,7 +1242,6 @@ export class Navigation {
12471242
curNode.isConnection() ||
12481243
nodeType == Blockly.ASTNode.types.WORKSPACE
12491244
) {
1250-
this.markAtCursor(workspace);
12511245
if (workspace.getToolbox()) {
12521246
this.focusToolbox(workspace);
12531247
} else {
@@ -1327,6 +1321,7 @@ export class Navigation {
13271321
) as Blockly.BlockSvg;
13281322
if (block) {
13291323
isHandled = this.insertPastedBlock(workspace, block);
1324+
this.removeMark(workspace);
13301325
}
13311326
Blockly.Events.setGroup(false);
13321327
return isHandled;

src/passive_focus.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
ASTNode,
9+
RenderedConnection,
10+
BlockSvg,
11+
ConnectionType,
12+
utils,
13+
} from 'blockly/core';
14+
15+
/**
16+
* A renderer for passive focus on AST node locations on the main workspace.
17+
* Responsible for showing and hiding in an ephemeral way. Not
18+
* guaranteed to stay up to date if workspace contents change.
19+
* In general, passive focus should be hidden when the main workspace
20+
* has active focus.
21+
*/
22+
export class PassiveFocus {
23+
// The node where the indicator is drawn, if any.
24+
private curNode: ASTNode | null = null;
25+
26+
// The line drawn to indicate passive focus on a next connection.
27+
nextConnectionIndicator: SVGRectElement;
28+
29+
constructor() {
30+
this.nextConnectionIndicator = this.createNextIndicator();
31+
}
32+
33+
/** Dispose of this indicator. Do any necessary cleanup. */
34+
dispose() {
35+
this.hide();
36+
if (this.nextConnectionIndicator) {
37+
utils.dom.removeNode(this.nextConnectionIndicator);
38+
}
39+
}
40+
41+
/**
42+
* Hide the currently visible passive focus indicator.
43+
* Implementation varies based on location type.
44+
*/
45+
hide() {
46+
if (!this.curNode) return;
47+
const type = this.curNode.getType();
48+
const location = this.curNode.getLocation();
49+
50+
// If old node was a block, unselect it or remove fake selection.
51+
if (type === ASTNode.types.BLOCK) {
52+
this.hideAtBlock(this.curNode);
53+
return;
54+
} else if (this.curNode.isConnection()) {
55+
const curNodeAsConnection = location as RenderedConnection;
56+
const connectionType = curNodeAsConnection.type;
57+
if (connectionType === ConnectionType.NEXT_STATEMENT) {
58+
this.hideAtNext(this.curNode);
59+
return;
60+
}
61+
}
62+
console.log('Could not hide passive focus indicator');
63+
}
64+
65+
/**
66+
* Show the passive focus indicator at the specified location.
67+
* Implementation varies based on location type.
68+
*/
69+
show(node: ASTNode) {
70+
// Hide last shown.
71+
this.hide();
72+
this.curNode = node;
73+
74+
const type = this.curNode.getType();
75+
const location = this.curNode.getLocation();
76+
if (type === ASTNode.types.BLOCK) {
77+
this.showAtBlock(this.curNode);
78+
return;
79+
} else if (this.curNode.isConnection()) {
80+
const curNodeAsConnection = location as RenderedConnection;
81+
const connectionType = curNodeAsConnection.type;
82+
if (connectionType === ConnectionType.NEXT_STATEMENT) {
83+
this.showAtNext(this.curNode);
84+
return;
85+
}
86+
}
87+
console.log('Could not show passive focus indicator');
88+
}
89+
90+
/**
91+
* Show a passive focus indicator on a block.
92+
*
93+
* @param node The passively-focused block.
94+
*/
95+
showAtBlock(node: ASTNode) {
96+
const block = node.getLocation() as BlockSvg;
97+
// Note that this changes rendering but does not change Blockly's
98+
// internal selected state.
99+
block.addSelect();
100+
}
101+
102+
/**
103+
* Hide a passive focus indicator on a block.
104+
*
105+
* @param node The passively-focused block.
106+
*/
107+
hideAtBlock(node: ASTNode) {
108+
const block = node.getLocation() as BlockSvg;
109+
// Note that this changes rendering but does not change Blockly's
110+
// internal selected state.
111+
block.removeSelect();
112+
}
113+
114+
/**
115+
* Creates DOM elements for the next connection indicator.
116+
*
117+
* @returns The root element of the next indicator.
118+
*/
119+
createNextIndicator() {
120+
// A horizontal line used to represent a next connection.
121+
const indicator = utils.dom.createSvgElement(utils.Svg.RECT, {
122+
'width': 100,
123+
'height': 5,
124+
'class': 'passiveNextIndicator',
125+
'stroke': '#4286f4',
126+
'fill': '#4286f4',
127+
});
128+
return indicator;
129+
}
130+
131+
/**
132+
* Show a passive focus indicator on a next connection.
133+
*
134+
* @param node The passively-focused connection.
135+
*/
136+
showAtNext(node: ASTNode) {
137+
const connection = node.getLocation() as RenderedConnection;
138+
const targetBlock = connection.getSourceBlock();
139+
140+
// Make the connection indicator a child of the block's SVG group.
141+
const blockSvgRoot = targetBlock.getSvgRoot();
142+
blockSvgRoot.appendChild(this.nextConnectionIndicator);
143+
144+
// Move the indicator relative to the origin of the block's SVG group.
145+
let x = 0;
146+
const y = connection.getOffsetInBlock().y;
147+
const width = targetBlock.getHeightWidth().width;
148+
if (targetBlock.workspace.RTL) {
149+
x = -width;
150+
}
151+
152+
this.nextConnectionIndicator.setAttribute('x', `${x}`);
153+
this.nextConnectionIndicator.setAttribute('y', `${y}`);
154+
this.nextConnectionIndicator.setAttribute('width', `${width}`);
155+
156+
this.nextConnectionIndicator.style.display = '';
157+
}
158+
159+
/**
160+
* Hide a passive focus indicator on a next connection.
161+
*
162+
* @param node The passively-focused connection.
163+
*/
164+
hideAtNext(node: ASTNode) {
165+
this.nextConnectionIndicator.parentNode?.removeChild(
166+
this.nextConnectionIndicator,
167+
);
168+
this.nextConnectionIndicator.style.display = 'none';
169+
}
170+
}

0 commit comments

Comments
 (0)