Skip to content

Commit 1edbf69

Browse files
Merge branch 'toast' into april-ut
2 parents df487af + 64b4f53 commit 1edbf69

File tree

8 files changed

+195
-42
lines changed

8 files changed

+195
-42
lines changed

src/actions/clipboard.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {BlockSvg, WorkspaceSvg} from 'blockly';
1919
import {Navigation} from '../navigation';
2020
import {ScopeWithConnection} from './action_menu';
2121
import {getShortActionShortcut} from '../shortcut_formatting';
22-
import {toast} from '../toast';
22+
import {clearPasteHints, showCopiedHint} from '../hints';
2323

2424
const KeyCodes = blocklyUtils.KeyCodes;
2525
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
@@ -279,10 +279,7 @@ export class Clipboard {
279279
if (navigationState === Constants.STATE.FLYOUT) {
280280
this.navigation.focusWorkspace(workspace);
281281
}
282-
toast(workspace, {
283-
message: `Copied. Press ${getShortActionShortcut('paste')} to paste.`,
284-
duration: 7000,
285-
});
282+
showCopiedHint(workspace);
286283
}
287284
return copied;
288285
}
@@ -359,6 +356,8 @@ export class Clipboard {
359356
*/
360357
private pasteCallback(workspace: WorkspaceSvg) {
361358
if (!this.copyData || !this.copyWorkspace) return false;
359+
clearPasteHints(workspace);
360+
362361
const pasteWorkspace = this.copyWorkspace.isFlyout
363362
? workspace
364363
: this.copyWorkspace;

src/actions/enter.ts

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ import type {
2121

2222
import * as Constants from '../constants';
2323
import type {Navigation} from '../navigation';
24-
import {getShortActionShortcut} from '../shortcut_formatting';
2524
import {Mover} from './mover';
26-
import {toast} from '../toast';
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-
toast(workspace, {message});
110+
showHelpHint(workspace);
110111
}
111112
} else if (curNode.isConnection() || nodeType === ASTNode.types.WORKSPACE) {
112113
this.navigation.openToolboxOrFlyout(workspace);
@@ -152,30 +153,14 @@ export class EnterAction {
152153
workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!);
153154
this.mover.startMove(workspace);
154155

155-
const sessionItemKey = 'isToastInsertFromFlyoutShown';
156-
if (!this.sessionStorageIfPossible[sessionItemKey]) {
157-
const enter = getShortActionShortcut(
158-
Constants.SHORTCUT_NAMES.EDIT_OR_CONFIRM,
159-
);
160-
const message = `Use the arrow keys to move, then ${enter} to accept the position`;
161-
toast(workspace, {message});
162-
this.sessionStorageIfPossible[sessionItemKey] = 'true';
163-
}
164-
}
165-
166-
private sessionStorageIfPossible = this.getSessionStorageIfPossible();
167-
168-
/**
169-
* Gets session storage if possible.
170-
* If session storage is not possible, fallback on internal tracker, which
171-
* resets per intialisation instead of per session.
172-
*/
173-
private getSessionStorageIfPossible() {
174-
try {
175-
return window.sessionStorage;
176-
} catch (e) {
177-
// Handle possible SecurityError, absent window.
178-
return {} as Record<string, string>;
156+
const isTopLevelBlock =
157+
!newBlock.outputConnection &&
158+
!newBlock.nextConnection &&
159+
!newBlock.previousConnection;
160+
if (isTopLevelBlock) {
161+
showUnconstrainedMoveHint(workspace, false);
162+
} else {
163+
showConstrainedMovementHint(workspace);
179164
}
180165
}
181166

src/actions/mover.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +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 {blocks} from 'node_modules/blockly/core/serialization';
20+
import {clearMoveHints} from '../hints';
2121

2222
/**
2323
* The distance to move an item, in workspace coordinates, when
@@ -141,6 +141,7 @@ export class Mover {
141141
*/
142142
finishMove(workspace: WorkspaceSvg) {
143143
this.removeMoveIndicator();
144+
clearMoveHints(workspace);
144145

145146
const info = this.moves.get(workspace);
146147
if (!info) throw new Error('no move info for workspace');
@@ -166,6 +167,7 @@ export class Mover {
166167
*/
167168
abortMove(workspace: WorkspaceSvg) {
168169
this.removeMoveIndicator();
170+
clearMoveHints(workspace);
169171

170172
const info = this.moves.get(workspace);
171173
if (!info) throw new Error('no move info for workspace');

src/hints.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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} from 'blockly';
10+
import {SHORTCUT_NAMES} from './constants';
11+
import {getShortActionShortcut} from './shortcut_formatting';
12+
import {clearToast, toast} from './toast';
13+
14+
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
15+
const constrainedMoveHintId = 'constrainedMoveHint';
16+
const copiedHintId = 'copiedHint';
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(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(workspace, {message, id: constrainedMoveHintId, oncePerSession: true});
48+
}
49+
50+
/**
51+
* Clear active move-related hints, if any.
52+
*
53+
* @param workspace The workspace.
54+
*/
55+
export function clearMoveHints(workspace: WorkspaceSvg) {
56+
clearToast(workspace, constrainedMoveHintId);
57+
clearToast(workspace, unconstrainedMoveHintId);
58+
}
59+
60+
/**
61+
* Nudge the user to paste after a copy.
62+
*
63+
* @param workspace Workspace.
64+
*/
65+
export function showCopiedHint(workspace: WorkspaceSvg) {
66+
toast(workspace, {
67+
message: `Copied. Press ${getShortActionShortcut('paste')} to paste.`,
68+
duration: 7000,
69+
id: copiedHintId,
70+
});
71+
}
72+
73+
/**
74+
* Clear active paste-related hints, if any.
75+
*
76+
* @param workspace The workspace.
77+
*/
78+
export function clearPasteHints(workspace: WorkspaceSvg) {
79+
// TODO: cut?
80+
clearToast(workspace, copiedHintId);
81+
}
82+
83+
/**
84+
* Nudge the user to open the help.
85+
*
86+
* @param workspace The workspace.
87+
*/
88+
export function showHelpHint(workspace: WorkspaceSvg) {
89+
const shortcut = getShortActionShortcut('list_shortcuts');
90+
const message = `Press ${shortcut} for help on keyboard controls`;
91+
const id = helpHintId;
92+
toast(workspace, {message, id});
93+
}
94+
95+
/**
96+
* Clear the help hint.
97+
*
98+
* @param workspace The workspace.
99+
*/
100+
export function clearHelpHint(workspace: WorkspaceSvg) {
101+
// TODO: We'd like to do this in MakeCode too as we override.
102+
// Could have an option for showing help in the plugin?
103+
clearToast(workspace, helpHintId);
104+
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as Blockly from 'blockly/core';
88
import {NavigationController} from './navigation_controller';
99
import {getFlyoutElement, getToolboxElement} from './workspace_utilities';
10+
import {clearHelpHint} from './hints';
1011

1112
/** Options object for KeyboardNavigation instances. */
1213
export interface NavigationOptions {
@@ -283,7 +284,7 @@ export class KeyboardNavigation {
283284
* Toggle visibility of a help dialog for the keyboard shortcuts.
284285
*/
285286
toggleShortcutDialog(): void {
286-
this.navigationController.shortcutDialog.toggle();
287+
this.navigationController.shortcutDialog.toggle(this.workspace);
287288
}
288289

289290
/**

src/keyboard_drag_strategy.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
utils,
1515
} from 'blockly';
1616
import {Direction, getDirectionFromXY} from './drag_direction';
17+
import {showUnconstrainedMoveHint} from './hints';
1718

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

src/shortcut_dialog.ts

Lines changed: 10 additions & 4 deletions
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],

src/toast.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,27 @@
11
import {WorkspaceSvg} from 'blockly';
22

3+
const storage = (() => {
4+
try {
5+
return window.sessionStorage;
6+
} catch (e) {
7+
// Handle possible SecurityError, absent window.
8+
return {} as Record<string, string>;
9+
}
10+
})();
11+
312
/**
413
* Toast options.
514
*/
615
export interface ToastOptions {
16+
/**
17+
* Message id.
18+
*/
19+
id?: string;
20+
/**
21+
* Flag to show the toast once per session only.
22+
* Subsequent calls are ignored.
23+
*/
24+
oncePerSession?: boolean;
725
/**
826
* Message text.
927
*/
@@ -15,6 +33,8 @@ export interface ToastOptions {
1533
duration?: number;
1634
}
1735

36+
const className = 'blocklyToast';
37+
1838
/**
1939
* Shows a message as a toast positioned over the workspace.
2040
*
@@ -31,12 +51,26 @@ export interface ToastOptions {
3151
* @param options Options.
3252
*/
3353
export function toast(workspace: WorkspaceSvg, options: ToastOptions): void {
54+
if (options.oncePerSession && options.id) {
55+
if (!options.id) {
56+
throw new Error('Must pass id to use oncePerSession');
57+
}
58+
const key = 'blocklyKeyboardNavShownHints';
59+
const value = storage[key] ?? '[]';
60+
const shown = JSON.parse(value);
61+
if (shown.includes(options.id)) {
62+
return;
63+
}
64+
shown.push(options.id);
65+
storage[key] = JSON.stringify(shown);
66+
}
67+
3468
const {message, duration = 10000} = options;
35-
const className = 'blocklyToast';
36-
workspace.getInjectionDiv().querySelector(`.${className}`)?.remove();
69+
clearToast(workspace);
3770

3871
const foregroundColor = 'black';
3972
const toast = document.createElement('div');
73+
toast.dataset.toastId = options.id;
4074
toast.className = className;
4175
toast.setAttribute('role', 'status');
4276
toast.setAttribute('aria-live', 'polite');
@@ -107,6 +141,21 @@ export function toast(workspace: WorkspaceSvg, options: ToastOptions): void {
107141
setToastTimeout();
108142
}
109143

144+
/**
145+
* Clear a toast, e.g. in response to a user action.
146+
*
147+
* @param workspace The workspace
148+
* @param id The toast id, or undefined for any toast.
149+
*/
150+
export function clearToast(workspace: WorkspaceSvg, id?: string) {
151+
const toast = workspace
152+
.getInjectionDiv()
153+
.querySelector(`.${className}`) as HTMLElement | null;
154+
if (toast && (!id || id === toast.dataset.toastId)) {
155+
toast.remove();
156+
}
157+
}
158+
110159
function icon(innerHTML: string, style: Partial<CSSStyleDeclaration> = {}) {
111160
const icon = document.createElement('svg');
112161
assignStyle(icon, style);

0 commit comments

Comments
 (0)