Skip to content

Commit 47d67fb

Browse files
feat: toasts for move/cut/copy and enter on a block (#230)
* feat: toasts for move/cut/copy and enter on a block The enter on a block toast shows generic help and we might consider showing it earlier if e.g. the user tabs to the workspace or otherwise focusses it by keyboard. * chore: rename isTopLevelBlock -> isStartBlock
1 parent da997e7 commit 47d67fb

File tree

7 files changed

+173
-12
lines changed

7 files changed

+173
-12
lines changed

src/actions/clipboard.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {BlockSvg, WorkspaceSvg} from 'blockly';
1919
import {Navigation} from '../navigation';
2020
import {getShortActionShortcut} from '../shortcut_formatting';
2121
import * as Blockly from 'blockly';
22+
import {clearPasteHints, showCopiedHint, showCutHint} from '../hints';
2223

2324
const KeyCodes = blocklyUtils.KeyCodes;
2425
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
@@ -168,6 +169,10 @@ export class Clipboard {
168169
if (cursor instanceof LineCursor) cursor.preDelete(sourceBlock);
169170
sourceBlock.checkAndDelete();
170171
if (cursor instanceof LineCursor) cursor.postDelete();
172+
const cut = !!this.copyData;
173+
if (cut) {
174+
showCutHint(workspace);
175+
}
171176
return true;
172177
}
173178

@@ -274,8 +279,11 @@ export class Clipboard {
274279
this.copyData = sourceBlock.toCopyData();
275280
this.copyWorkspace = sourceBlock.workspace;
276281
const copied = !!this.copyData;
277-
if (copied && navigationState === Constants.STATE.FLYOUT) {
278-
this.navigation.focusWorkspace(workspace);
282+
if (copied) {
283+
if (navigationState === Constants.STATE.FLYOUT) {
284+
this.navigation.focusWorkspace(workspace);
285+
}
286+
showCopiedHint(workspace);
279287
}
280288
return copied;
281289
}
@@ -361,6 +369,8 @@ export class Clipboard {
361369
*/
362370
private pasteCallback(workspace: WorkspaceSvg) {
363371
if (!this.copyData || !this.copyWorkspace) return false;
372+
clearPasteHints(workspace);
373+
364374
const pasteWorkspace = this.copyWorkspace.isFlyout
365375
? workspace
366376
: this.copyWorkspace;

src/actions/enter.ts

+17-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
Events,
1010
ShortcutRegistry,
1111
utils as BlocklyUtils,
12-
dialog,
1312
} from 'blockly/core';
1413

1514
import type {
@@ -22,8 +21,12 @@ import type {
2221

2322
import * as Constants from '../constants';
2423
import type {Navigation} from '../navigation';
25-
import {getShortActionShortcut} from '../shortcut_formatting';
2624
import {Mover} from './mover';
25+
import {
26+
showConstrainedMovementHint,
27+
showHelpHint,
28+
showUnconstrainedMoveHint,
29+
} from '../hints';
2730

2831
const KeyCodes = BlocklyUtils.KeyCodes;
2932

@@ -104,9 +107,7 @@ export class EnterAction {
104107
} else if (nodeType === ASTNode.types.BLOCK) {
105108
const block = curNode.getLocation() as Block;
106109
if (!this.tryShowFullBlockFieldEditor(block)) {
107-
const shortcut = getShortActionShortcut('list_shortcuts');
108-
const message = `Press ${shortcut} for help on keyboard controls`;
109-
dialog.alert(message);
110+
showHelpHint(workspace);
110111
}
111112
} else if (curNode.isConnection() || nodeType === ASTNode.types.WORKSPACE) {
112113
this.navigation.openToolboxOrFlyout(workspace);
@@ -120,6 +121,7 @@ export class EnterAction {
120121
* Tries to find a connection on the block to connect to the marked
121122
* location. If no connection has been marked, or there is not a compatible
122123
* connection then the block is placed on the workspace.
124+
* Trigger a toast per session if possible.
123125
*
124126
* @param workspace The main workspace. The workspace
125127
* the block will be placed on.
@@ -150,6 +152,16 @@ export class EnterAction {
150152
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
151153
workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!);
152154
this.mover.startMove(workspace);
155+
156+
const isStartBlock =
157+
!newBlock.outputConnection &&
158+
!newBlock.nextConnection &&
159+
!newBlock.previousConnection;
160+
if (isStartBlock) {
161+
showUnconstrainedMoveHint(workspace, false);
162+
} else {
163+
showConstrainedMovementHint(workspace);
164+
}
153165
}
154166

155167
/**

src/actions/mover.ts

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as Constants from '../constants';
1717
import {Direction, getXYFromDirection} from '../drag_direction';
1818
import {KeyboardDragStrategy} from '../keyboard_drag_strategy';
1919
import {Navigation} from '../navigation';
20+
import {clearMoveHints} from '../hints';
2021

2122
/**
2223
* The distance to move an item, in workspace coordinates, when
@@ -134,6 +135,8 @@ export class Mover {
134135
* @returns True iff move successfully finished.
135136
*/
136137
finishMove(workspace: WorkspaceSvg) {
138+
clearMoveHints(workspace);
139+
137140
const info = this.moves.get(workspace);
138141
if (!info) throw new Error('no move info for workspace');
139142

@@ -159,6 +162,8 @@ export class Mover {
159162
* @returns True iff move successfully aborted.
160163
*/
161164
abortMove(workspace: WorkspaceSvg) {
165+
clearMoveHints(workspace);
166+
162167
const info = this.moves.get(workspace);
163168
if (!info) throw new Error('no move info for workspace');
164169

src/hints.ts

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Centralises hints that we show.
3+
*
4+
* @license
5+
* Copyright 2025 Google LLC
6+
* SPDX-License-Identifier: Apache-2.0
7+
*/
8+
9+
import {WorkspaceSvg, Toast} from 'blockly';
10+
import {SHORTCUT_NAMES} from './constants';
11+
import {getShortActionShortcut} from './shortcut_formatting';
12+
13+
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
14+
const constrainedMoveHintId = 'constrainedMoveHint';
15+
const copiedHintId = 'copiedHint';
16+
const cutHintId = 'cutHint';
17+
const helpHintId = 'helpHint';
18+
19+
/**
20+
* Nudge the user to use unconstrained movement.
21+
*
22+
* @param workspace Workspace.
23+
* @param force Set to show it even if previously shown.
24+
*/
25+
export function showUnconstrainedMoveHint(
26+
workspace: WorkspaceSvg,
27+
force = false,
28+
) {
29+
const enter = getShortActionShortcut(SHORTCUT_NAMES.EDIT_OR_CONFIRM);
30+
const modifier = navigator.platform.startsWith('Mac') ? '⌥' : 'Ctrl';
31+
const message = `Hold ${modifier} and use arrow keys to move freely, then ${enter} to accept the position`;
32+
Toast.show(workspace, {
33+
message,
34+
id: unconstrainedMoveHintId,
35+
oncePerSession: !force,
36+
});
37+
}
38+
39+
/**
40+
* Nudge the user to move a block that's in move mode.
41+
*
42+
* @param workspace Workspace.
43+
*/
44+
export function showConstrainedMovementHint(workspace: WorkspaceSvg) {
45+
const enter = getShortActionShortcut(SHORTCUT_NAMES.EDIT_OR_CONFIRM);
46+
const message = `Use the arrow keys to move, then ${enter} to accept the position`;
47+
Toast.show(workspace, {
48+
message,
49+
id: constrainedMoveHintId,
50+
oncePerSession: true,
51+
});
52+
}
53+
54+
/**
55+
* Clear active move-related hints, if any.
56+
*
57+
* @param workspace The workspace.
58+
*/
59+
export function clearMoveHints(workspace: WorkspaceSvg) {
60+
Toast.hide(workspace, constrainedMoveHintId);
61+
Toast.hide(workspace, unconstrainedMoveHintId);
62+
}
63+
64+
/**
65+
* Nudge the user to paste after a copy.
66+
*
67+
* @param workspace Workspace.
68+
*/
69+
export function showCopiedHint(workspace: WorkspaceSvg) {
70+
Toast.show(workspace, {
71+
message: `Copied. Press ${getShortActionShortcut('paste')} to paste.`,
72+
duration: 7000,
73+
id: copiedHintId,
74+
});
75+
}
76+
77+
/**
78+
* Nudge the user to paste after a cut.
79+
*
80+
* @param workspace Workspace.
81+
*/
82+
export function showCutHint(workspace: WorkspaceSvg) {
83+
Toast.show(workspace, {
84+
message: `Cut. Press ${getShortActionShortcut('paste')} to paste.`,
85+
duration: 7000,
86+
id: cutHintId,
87+
});
88+
}
89+
90+
/**
91+
* Clear active paste-related hints, if any.
92+
*
93+
* @param workspace The workspace.
94+
*/
95+
export function clearPasteHints(workspace: WorkspaceSvg) {
96+
Toast.hide(workspace, cutHintId);
97+
Toast.hide(workspace, copiedHintId);
98+
}
99+
100+
/**
101+
* Nudge the user to open the help.
102+
*
103+
* @param workspace The workspace.
104+
*/
105+
export function showHelpHint(workspace: WorkspaceSvg) {
106+
const shortcut = getShortActionShortcut('list_shortcuts');
107+
const message = `Press ${shortcut} for help on keyboard controls`;
108+
const id = helpHintId;
109+
Toast.show(workspace, {message, id});
110+
}
111+
112+
/**
113+
* Clear the help hint.
114+
*
115+
* @param workspace The workspace.
116+
*/
117+
export function clearHelpHint(workspace: WorkspaceSvg) {
118+
// TODO: We'd like to do this in MakeCode too as we override.
119+
// Could have an option for showing help in the plugin?
120+
Toast.hide(workspace, helpHintId);
121+
}

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export class KeyboardNavigation {
290290
* Toggle visibility of a help dialog for the keyboard shortcuts.
291291
*/
292292
toggleShortcutDialog(): void {
293-
this.navigationController.shortcutDialog.toggle();
293+
this.navigationController.shortcutDialog.toggle(this.workspace);
294294
}
295295

296296
/**

src/keyboard_drag_strategy.ts

+7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
utils,
1414
} from 'blockly';
1515
import {Direction, getDirectionFromXY} from './drag_direction';
16+
import {showUnconstrainedMoveHint} from './hints';
1617

1718
// Copied in from core because it is not exported.
1819
interface ConnectionCandidate {
@@ -70,6 +71,12 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy {
7071
} else {
7172
// Handle the case when unconstrained drag was far from any candidate.
7273
this.searchNode = null;
74+
75+
if (this.isConstrainedMovement()) {
76+
// @ts-expect-error private field
77+
const workspace = this.workspace;
78+
showUnconstrainedMoveHint(workspace, true);
79+
}
7380
}
7481
}
7582

src/shortcut_dialog.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getLongActionShortcutsAsKeys,
1212
upperCaseFirst,
1313
} from './shortcut_formatting';
14+
import {clearHelpHint} from './hints';
1415

1516
/**
1617
* Class for handling the shortcuts dialog.
@@ -64,7 +65,12 @@ export class ShortcutDialog {
6465
}
6566
}
6667

67-
toggle() {
68+
toggle(workspace: Blockly.WorkspaceSvg) {
69+
clearHelpHint(workspace);
70+
this.toggleInternal();
71+
}
72+
73+
toggleInternal() {
6874
if (this.modalContainer && this.shortcutDialog) {
6975
// Use built in dialog methods.
7076
if (this.shortcutDialog.hasAttribute('open')) {
@@ -132,7 +138,7 @@ export class ShortcutDialog {
132138
// Can we also intercept the Esc key to dismiss.
133139
if (this.closeButton) {
134140
this.closeButton.addEventListener('click', (e) => {
135-
this.toggle();
141+
this.toggleInternal();
136142
});
137143
}
138144
}
@@ -161,8 +167,8 @@ export class ShortcutDialog {
161167
/** List all of the currently registered shortcuts. */
162168
const announceShortcut: ShortcutRegistry.KeyboardShortcut = {
163169
name: Constants.SHORTCUT_NAMES.LIST_SHORTCUTS,
164-
callback: () => {
165-
this.toggle();
170+
callback: (workspace) => {
171+
this.toggle(workspace);
166172
return true;
167173
},
168174
keyCodes: [Blockly.utils.KeyCodes.SLASH],

0 commit comments

Comments
 (0)