diff --git a/src/actions/clipboard.ts b/src/actions/clipboard.ts index cd1dd838..d34991a7 100644 --- a/src/actions/clipboard.ts +++ b/src/actions/clipboard.ts @@ -19,6 +19,7 @@ import type {BlockSvg, WorkspaceSvg} from 'blockly'; import {Navigation} from '../navigation'; import {getShortActionShortcut} from '../shortcut_formatting'; import * as Blockly from 'blockly'; +import {clearPasteHints, showCopiedHint, showCutHint} from '../hints'; const KeyCodes = blocklyUtils.KeyCodes; const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind( @@ -168,6 +169,10 @@ export class Clipboard { if (cursor instanceof LineCursor) cursor.preDelete(sourceBlock); sourceBlock.checkAndDelete(); if (cursor instanceof LineCursor) cursor.postDelete(); + const cut = !!this.copyData; + if (cut) { + showCutHint(workspace); + } return true; } @@ -274,8 +279,11 @@ export class Clipboard { this.copyData = sourceBlock.toCopyData(); this.copyWorkspace = sourceBlock.workspace; const copied = !!this.copyData; - if (copied && navigationState === Constants.STATE.FLYOUT) { - this.navigation.focusWorkspace(workspace); + if (copied) { + if (navigationState === Constants.STATE.FLYOUT) { + this.navigation.focusWorkspace(workspace); + } + showCopiedHint(workspace); } return copied; } @@ -361,6 +369,8 @@ export class Clipboard { */ private pasteCallback(workspace: WorkspaceSvg) { if (!this.copyData || !this.copyWorkspace) return false; + clearPasteHints(workspace); + const pasteWorkspace = this.copyWorkspace.isFlyout ? workspace : this.copyWorkspace; diff --git a/src/actions/enter.ts b/src/actions/enter.ts index 6f556cbb..72f6601b 100644 --- a/src/actions/enter.ts +++ b/src/actions/enter.ts @@ -9,7 +9,6 @@ import { Events, ShortcutRegistry, utils as BlocklyUtils, - dialog, } from 'blockly/core'; import type { @@ -22,8 +21,12 @@ import type { import * as Constants from '../constants'; import type {Navigation} from '../navigation'; -import {getShortActionShortcut} from '../shortcut_formatting'; import {Mover} from './mover'; +import { + showConstrainedMovementHint, + showHelpHint, + showUnconstrainedMoveHint, +} from '../hints'; const KeyCodes = BlocklyUtils.KeyCodes; @@ -104,9 +107,7 @@ export class EnterAction { } else if (nodeType === ASTNode.types.BLOCK) { const block = curNode.getLocation() as Block; if (!this.tryShowFullBlockFieldEditor(block)) { - const shortcut = getShortActionShortcut('list_shortcuts'); - const message = `Press ${shortcut} for help on keyboard controls`; - dialog.alert(message); + showHelpHint(workspace); } } else if (curNode.isConnection() || nodeType === ASTNode.types.WORKSPACE) { this.navigation.openToolboxOrFlyout(workspace); @@ -120,6 +121,7 @@ export class EnterAction { * Tries to find a connection on the block to connect to the marked * location. If no connection has been marked, or there is not a compatible * connection then the block is placed on the workspace. + * Trigger a toast per session if possible. * * @param workspace The main workspace. The workspace * the block will be placed on. @@ -150,6 +152,16 @@ export class EnterAction { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!); this.mover.startMove(workspace); + + const isStartBlock = + !newBlock.outputConnection && + !newBlock.nextConnection && + !newBlock.previousConnection; + if (isStartBlock) { + showUnconstrainedMoveHint(workspace, false); + } else { + showConstrainedMovementHint(workspace); + } } /** diff --git a/src/actions/mover.ts b/src/actions/mover.ts index db19eb99..da4de083 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -17,6 +17,7 @@ import * as Constants from '../constants'; import {Direction, getXYFromDirection} from '../drag_direction'; import {KeyboardDragStrategy} from '../keyboard_drag_strategy'; import {Navigation} from '../navigation'; +import {clearMoveHints} from '../hints'; /** * The distance to move an item, in workspace coordinates, when @@ -134,6 +135,8 @@ export class Mover { * @returns True iff move successfully finished. */ finishMove(workspace: WorkspaceSvg) { + clearMoveHints(workspace); + const info = this.moves.get(workspace); if (!info) throw new Error('no move info for workspace'); @@ -157,6 +160,8 @@ export class Mover { * @returns True iff move successfully aborted. */ abortMove(workspace: WorkspaceSvg) { + clearMoveHints(workspace); + const info = this.moves.get(workspace); if (!info) throw new Error('no move info for workspace'); diff --git a/src/hints.ts b/src/hints.ts new file mode 100644 index 00000000..70b53000 --- /dev/null +++ b/src/hints.ts @@ -0,0 +1,121 @@ +/** + * Centralises hints that we show. + * + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {WorkspaceSvg, Toast} from 'blockly'; +import {SHORTCUT_NAMES} from './constants'; +import {getShortActionShortcut} from './shortcut_formatting'; + +const unconstrainedMoveHintId = 'unconstrainedMoveHint'; +const constrainedMoveHintId = 'constrainedMoveHint'; +const copiedHintId = 'copiedHint'; +const cutHintId = 'cutHint'; +const helpHintId = 'helpHint'; + +/** + * Nudge the user to use unconstrained movement. + * + * @param workspace Workspace. + * @param force Set to show it even if previously shown. + */ +export function showUnconstrainedMoveHint( + workspace: WorkspaceSvg, + force = false, +) { + const enter = getShortActionShortcut(SHORTCUT_NAMES.EDIT_OR_CONFIRM); + const modifier = navigator.platform.startsWith('Mac') ? '⌥' : 'Ctrl'; + const message = `Hold ${modifier} and use arrow keys to move freely, then ${enter} to accept the position`; + Toast.show(workspace, { + message, + id: unconstrainedMoveHintId, + oncePerSession: !force, + }); +} + +/** + * Nudge the user to move a block that's in move mode. + * + * @param workspace Workspace. + */ +export function showConstrainedMovementHint(workspace: WorkspaceSvg) { + const enter = getShortActionShortcut(SHORTCUT_NAMES.EDIT_OR_CONFIRM); + const message = `Use the arrow keys to move, then ${enter} to accept the position`; + Toast.show(workspace, { + message, + id: constrainedMoveHintId, + oncePerSession: true, + }); +} + +/** + * Clear active move-related hints, if any. + * + * @param workspace The workspace. + */ +export function clearMoveHints(workspace: WorkspaceSvg) { + Toast.hide(workspace, constrainedMoveHintId); + Toast.hide(workspace, unconstrainedMoveHintId); +} + +/** + * Nudge the user to paste after a copy. + * + * @param workspace Workspace. + */ +export function showCopiedHint(workspace: WorkspaceSvg) { + Toast.show(workspace, { + message: `Copied. Press ${getShortActionShortcut('paste')} to paste.`, + duration: 7000, + id: copiedHintId, + }); +} + +/** + * Nudge the user to paste after a cut. + * + * @param workspace Workspace. + */ +export function showCutHint(workspace: WorkspaceSvg) { + Toast.show(workspace, { + message: `Cut. Press ${getShortActionShortcut('paste')} to paste.`, + duration: 7000, + id: cutHintId, + }); +} + +/** + * Clear active paste-related hints, if any. + * + * @param workspace The workspace. + */ +export function clearPasteHints(workspace: WorkspaceSvg) { + Toast.hide(workspace, cutHintId); + Toast.hide(workspace, copiedHintId); +} + +/** + * Nudge the user to open the help. + * + * @param workspace The workspace. + */ +export function showHelpHint(workspace: WorkspaceSvg) { + const shortcut = getShortActionShortcut('list_shortcuts'); + const message = `Press ${shortcut} for help on keyboard controls`; + const id = helpHintId; + Toast.show(workspace, {message, id}); +} + +/** + * Clear the help hint. + * + * @param workspace The workspace. + */ +export function clearHelpHint(workspace: WorkspaceSvg) { + // TODO: We'd like to do this in MakeCode too as we override. + // Could have an option for showing help in the plugin? + Toast.hide(workspace, helpHintId); +} diff --git a/src/index.ts b/src/index.ts index 526b9d6c..6acc6f38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -290,7 +290,7 @@ export class KeyboardNavigation { * Toggle visibility of a help dialog for the keyboard shortcuts. */ toggleShortcutDialog(): void { - this.navigationController.shortcutDialog.toggle(); + this.navigationController.shortcutDialog.toggle(this.workspace); } /** diff --git a/src/keyboard_drag_strategy.ts b/src/keyboard_drag_strategy.ts index 2bd4d1af..9134c349 100644 --- a/src/keyboard_drag_strategy.ts +++ b/src/keyboard_drag_strategy.ts @@ -13,6 +13,7 @@ import { utils, } from 'blockly'; import {Direction, getDirectionFromXY} from './drag_direction'; +import {showUnconstrainedMoveHint} from './hints'; // Copied in from core because it is not exported. interface ConnectionCandidate { @@ -70,6 +71,12 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { } else { // Handle the case when unconstrained drag was far from any candidate. this.searchNode = null; + + if (this.isConstrainedMovement()) { + // @ts-expect-error private field + const workspace = this.workspace; + showUnconstrainedMoveHint(workspace, true); + } } } diff --git a/src/shortcut_dialog.ts b/src/shortcut_dialog.ts index a0f52bdb..a4cf2abd 100644 --- a/src/shortcut_dialog.ts +++ b/src/shortcut_dialog.ts @@ -11,6 +11,7 @@ import { getLongActionShortcutsAsKeys, upperCaseFirst, } from './shortcut_formatting'; +import {clearHelpHint} from './hints'; /** * Class for handling the shortcuts dialog. @@ -64,7 +65,12 @@ export class ShortcutDialog { } } - toggle() { + toggle(workspace: Blockly.WorkspaceSvg) { + clearHelpHint(workspace); + this.toggleInternal(); + } + + toggleInternal() { if (this.modalContainer && this.shortcutDialog) { // Use built in dialog methods. if (this.shortcutDialog.hasAttribute('open')) { @@ -132,7 +138,7 @@ export class ShortcutDialog { // Can we also intercept the Esc key to dismiss. if (this.closeButton) { this.closeButton.addEventListener('click', (e) => { - this.toggle(); + this.toggleInternal(); }); } } @@ -161,8 +167,8 @@ export class ShortcutDialog { /** List all of the currently registered shortcuts. */ const announceShortcut: ShortcutRegistry.KeyboardShortcut = { name: Constants.SHORTCUT_NAMES.LIST_SHORTCUTS, - callback: () => { - this.toggle(); + callback: (workspace) => { + this.toggle(workspace); return true; }, keyCodes: [Blockly.utils.KeyCodes.SLASH],