mirror of
https://github.com/google/blockly.git
synced 2026-01-08 01:20:12 +01:00
## The basics
- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)
## The details
### Resolves
Fixes part of https://github.com/RaspberryPiFoundation/blockly-keyboard-experimentation/issues/764
Fixes part of #9450 (infrastructure needs)
### Proposed Changes
Introduces support for two new "where am I?" shortcuts for helping to provide location context for users:
- `I`: re-reads the current selected block with full verbosity (i.e. also includes the block's field types with their values in the readout).
- `shift+I`: reads the current selected block's parent with full verbosity.
Note that this includes some functional changes to `Field` to allow for more powerful customization of a field's ARIA representation (by splitting up value and type), though a field's value defaults potentially to null which will be ignored in the final ARIA computed label. This seems necessary per the discussion here: https://github.com/RaspberryPiFoundation/blockly/pull/9470/files#r2541508565 but more consideration may be needed here as part of #9307.
Some limitations in the new shortcuts:
- They will not read out anything if a block is not selected (e.g. for fields and icons).
- They read out input blocks when the input block is selected.
- They cannot read out anything while in move mode (due to the behavior here in the plugin which automatically cancels moves if an unknown shortcut is pressed: a36f3662b0/src/actions/mover.ts (L166-L191)).
- The readout is limited by the problems of dynamic ARIA announcements (per #9460).
### Reason for Changes
https://github.com/RaspberryPiFoundation/blockly-keyboard-experimentation/issues/764 provides context on the specific needs addressed here.
### Test Coverage
Self tested. No new automated tests needed for experimental work.
### Documentation
No new documentation needed for experimental work.
### Additional Information
This was spun out of #9470 with the intent of getting shortcuts initially working checked in even if the entirety of the experience is incomplete.
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2020 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Former goog.module ID: Blockly.ShortcutItems
|
|
|
|
import {BlockSvg} from './block_svg.js';
|
|
import * as clipboard from './clipboard.js';
|
|
import {RenderedWorkspaceComment} from './comments.js';
|
|
import * as eventUtils from './events/utils.js';
|
|
import {getFocusManager} from './focus_manager.js';
|
|
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
|
|
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
|
|
import {isDraggable} from './interfaces/i_draggable.js';
|
|
import {IFocusableNode} from './interfaces/i_focusable_node.js';
|
|
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
|
|
import {aria} from './utils.js';
|
|
import {Coordinate} from './utils/coordinate.js';
|
|
import {KeyCodes} from './utils/keycodes.js';
|
|
import {Rect} from './utils/rect.js';
|
|
import * as svgMath from './utils/svg_math.js';
|
|
import {WorkspaceSvg} from './workspace_svg.js';
|
|
|
|
/**
|
|
* Object holding the names of the default shortcut items.
|
|
*/
|
|
export enum names {
|
|
ESCAPE = 'escape',
|
|
DELETE = 'delete',
|
|
COPY = 'copy',
|
|
CUT = 'cut',
|
|
PASTE = 'paste',
|
|
UNDO = 'undo',
|
|
REDO = 'redo',
|
|
READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary',
|
|
READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary',
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to hide chaff on escape.
|
|
*/
|
|
export function registerEscape() {
|
|
const escapeAction: KeyboardShortcut = {
|
|
name: names.ESCAPE,
|
|
preconditionFn(workspace) {
|
|
return !workspace.isReadOnly();
|
|
},
|
|
callback(workspace) {
|
|
workspace.hideChaff();
|
|
return true;
|
|
},
|
|
keyCodes: [KeyCodes.ESC],
|
|
};
|
|
ShortcutRegistry.registry.register(escapeAction);
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to delete a block on delete or backspace
|
|
*/
|
|
export function registerDelete() {
|
|
const deleteShortcut: KeyboardShortcut = {
|
|
name: names.DELETE,
|
|
preconditionFn(workspace, scope) {
|
|
const focused = scope.focusedNode;
|
|
return (
|
|
!workspace.isReadOnly() &&
|
|
focused != null &&
|
|
isIDeletable(focused) &&
|
|
focused.isDeletable() &&
|
|
!workspace.isDragging() &&
|
|
// Don't delete the block if a field editor is open
|
|
!getFocusManager().ephemeralFocusTaken()
|
|
);
|
|
},
|
|
callback(workspace, e, shortcut, scope) {
|
|
// Delete or backspace.
|
|
// Stop the browser from going back to the previous page.
|
|
// Do this first to prevent an error in the delete code from resulting in
|
|
// data loss.
|
|
e.preventDefault();
|
|
const focused = scope.focusedNode;
|
|
if (focused instanceof BlockSvg) {
|
|
focused.checkAndDelete();
|
|
} else if (isIDeletable(focused) && focused.isDeletable()) {
|
|
eventUtils.setGroup(true);
|
|
focused.dispose();
|
|
eventUtils.setGroup(false);
|
|
}
|
|
return true;
|
|
},
|
|
keyCodes: [KeyCodes.DELETE, KeyCodes.BACKSPACE],
|
|
};
|
|
ShortcutRegistry.registry.register(deleteShortcut);
|
|
}
|
|
|
|
/**
|
|
* Determine if a focusable node can be copied.
|
|
*
|
|
* This will use the isCopyable method if the node implements it, otherwise
|
|
* it will fall back to checking if the node is deletable and draggable not
|
|
* considering the workspace's edit state.
|
|
*
|
|
* @param focused The focused object.
|
|
*/
|
|
function isCopyable(focused: IFocusableNode): boolean {
|
|
if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused))
|
|
return false;
|
|
if (focused.isCopyable) {
|
|
return focused.isCopyable();
|
|
} else if (
|
|
focused instanceof BlockSvg ||
|
|
focused instanceof RenderedWorkspaceComment
|
|
) {
|
|
return focused.isOwnDeletable() && focused.isOwnMovable();
|
|
}
|
|
// This isn't a class Blockly knows about, so fall back to the stricter
|
|
// checks for deletable and movable.
|
|
return focused.isDeletable() && focused.isMovable();
|
|
}
|
|
|
|
/**
|
|
* Determine if a focusable node can be cut.
|
|
*
|
|
* This will check if the node can be both copied and deleted in its current
|
|
* workspace.
|
|
*
|
|
* @param focused The focused object.
|
|
*/
|
|
function isCuttable(focused: IFocusableNode): boolean {
|
|
return isCopyable(focused) && isIDeletable(focused) && focused.isDeletable();
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to copy a block on ctrl+c, cmd+c, or alt+c.
|
|
*/
|
|
export function registerCopy() {
|
|
const ctrlC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [
|
|
KeyCodes.CTRL,
|
|
]);
|
|
const metaC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [
|
|
KeyCodes.META,
|
|
]);
|
|
|
|
const copyShortcut: KeyboardShortcut = {
|
|
name: names.COPY,
|
|
preconditionFn(workspace, scope) {
|
|
const focused = scope.focusedNode;
|
|
|
|
const targetWorkspace = workspace.isFlyout
|
|
? workspace.targetWorkspace
|
|
: workspace;
|
|
return (
|
|
!!focused &&
|
|
!!targetWorkspace &&
|
|
!targetWorkspace.isDragging() &&
|
|
!getFocusManager().ephemeralFocusTaken() &&
|
|
isCopyable(focused)
|
|
);
|
|
},
|
|
callback(workspace, e, shortcut, scope) {
|
|
// Prevent the default copy behavior, which may beep or otherwise indicate
|
|
// an error due to the lack of a selection.
|
|
e.preventDefault();
|
|
|
|
const focused = scope.focusedNode;
|
|
if (!focused || !isICopyable(focused) || !isCopyable(focused))
|
|
return false;
|
|
const targetWorkspace = workspace.isFlyout
|
|
? workspace.targetWorkspace
|
|
: workspace;
|
|
if (!targetWorkspace) return false;
|
|
|
|
if (!focused.workspace.isFlyout) {
|
|
targetWorkspace.hideChaff();
|
|
}
|
|
|
|
const copyCoords =
|
|
isDraggable(focused) && focused.workspace == targetWorkspace
|
|
? focused.getRelativeToSurfaceXY()
|
|
: undefined;
|
|
return !!clipboard.copy(focused, copyCoords);
|
|
},
|
|
keyCodes: [ctrlC, metaC],
|
|
};
|
|
ShortcutRegistry.registry.register(copyShortcut);
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to copy and delete a block on ctrl+x, cmd+x, or alt+x.
|
|
*/
|
|
export function registerCut() {
|
|
const ctrlX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [
|
|
KeyCodes.CTRL,
|
|
]);
|
|
const metaX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [
|
|
KeyCodes.META,
|
|
]);
|
|
|
|
const cutShortcut: KeyboardShortcut = {
|
|
name: names.CUT,
|
|
preconditionFn(workspace, scope) {
|
|
const focused = scope.focusedNode;
|
|
return (
|
|
!!focused &&
|
|
!workspace.isReadOnly() &&
|
|
!workspace.isDragging() &&
|
|
!getFocusManager().ephemeralFocusTaken() &&
|
|
isCuttable(focused)
|
|
);
|
|
},
|
|
callback(workspace, e, shortcut, scope) {
|
|
const focused = scope.focusedNode;
|
|
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
|
|
return false;
|
|
}
|
|
const copyCoords = isDraggable(focused)
|
|
? focused.getRelativeToSurfaceXY()
|
|
: undefined;
|
|
const copyData = clipboard.copy(focused, copyCoords);
|
|
|
|
if (focused instanceof BlockSvg) {
|
|
focused.checkAndDelete();
|
|
} else if (isIDeletable(focused)) {
|
|
focused.dispose();
|
|
}
|
|
return !!copyData;
|
|
},
|
|
keyCodes: [ctrlX, metaX],
|
|
};
|
|
|
|
ShortcutRegistry.registry.register(cutShortcut);
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to paste a block on ctrl+v, cmd+v, or alt+v.
|
|
*/
|
|
export function registerPaste() {
|
|
const ctrlV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [
|
|
KeyCodes.CTRL,
|
|
]);
|
|
const metaV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [
|
|
KeyCodes.META,
|
|
]);
|
|
|
|
const pasteShortcut: KeyboardShortcut = {
|
|
name: names.PASTE,
|
|
preconditionFn() {
|
|
// Regardless of the currently focused workspace, we will only
|
|
// paste into the last-copied-from workspace.
|
|
const workspace = clipboard.getLastCopiedWorkspace();
|
|
// If we don't know where we copied from, we don't know where to paste.
|
|
// If the workspace isn't rendered (e.g. closed mutator workspace),
|
|
// we can't paste into it.
|
|
if (!workspace || !workspace.rendered) return false;
|
|
const targetWorkspace = workspace.isFlyout
|
|
? workspace.targetWorkspace
|
|
: workspace;
|
|
return (
|
|
!!clipboard.getLastCopiedData() &&
|
|
!!targetWorkspace &&
|
|
!targetWorkspace.isReadOnly() &&
|
|
!targetWorkspace.isDragging() &&
|
|
!getFocusManager().ephemeralFocusTaken()
|
|
);
|
|
},
|
|
callback(workspace: WorkspaceSvg, e: Event) {
|
|
const copyData = clipboard.getLastCopiedData();
|
|
if (!copyData) return false;
|
|
|
|
const copyWorkspace = clipboard.getLastCopiedWorkspace();
|
|
if (!copyWorkspace) return false;
|
|
|
|
const targetWorkspace = copyWorkspace.isFlyout
|
|
? copyWorkspace.targetWorkspace
|
|
: copyWorkspace;
|
|
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
|
|
|
|
if (e instanceof PointerEvent) {
|
|
// The event that triggers a shortcut would conventionally be a KeyboardEvent.
|
|
// However, it may be a PointerEvent if a context menu item was used as a
|
|
// wrapper for this callback, in which case the new block(s) should be pasted
|
|
// at the mouse coordinates where the menu was opened, and this PointerEvent
|
|
// is where the menu was opened.
|
|
const mouseCoords = svgMath.screenToWsCoordinates(
|
|
targetWorkspace,
|
|
new Coordinate(e.clientX, e.clientY),
|
|
);
|
|
return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
|
|
}
|
|
|
|
const copyCoords = clipboard.getLastCopiedLocation();
|
|
if (!copyCoords) {
|
|
// If we don't have location data about the original copyable, let the
|
|
// paster determine position.
|
|
return !!clipboard.paste(copyData, targetWorkspace);
|
|
}
|
|
|
|
const {left, top, width, height} = targetWorkspace
|
|
.getMetricsManager()
|
|
.getViewMetrics(true);
|
|
const viewportRect = new Rect(top, top + height, left, left + width);
|
|
|
|
if (viewportRect.contains(copyCoords.x, copyCoords.y)) {
|
|
// If the original copyable is inside the viewport, let the paster
|
|
// determine position.
|
|
return !!clipboard.paste(copyData, targetWorkspace);
|
|
}
|
|
|
|
// Otherwise, paste in the middle of the viewport.
|
|
const centerCoords = new Coordinate(left + width / 2, top + height / 2);
|
|
return !!clipboard.paste(copyData, targetWorkspace, centerCoords);
|
|
},
|
|
keyCodes: [ctrlV, metaV],
|
|
};
|
|
|
|
ShortcutRegistry.registry.register(pasteShortcut);
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to undo the previous action on ctrl+z, cmd+z, or alt+z.
|
|
*/
|
|
export function registerUndo() {
|
|
const ctrlZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
|
|
KeyCodes.CTRL,
|
|
]);
|
|
const metaZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
|
|
KeyCodes.META,
|
|
]);
|
|
|
|
const undoShortcut: KeyboardShortcut = {
|
|
name: names.UNDO,
|
|
preconditionFn(workspace) {
|
|
return (
|
|
!workspace.isReadOnly() &&
|
|
!workspace.isDragging() &&
|
|
!getFocusManager().ephemeralFocusTaken()
|
|
);
|
|
},
|
|
callback(workspace, e) {
|
|
// 'z' for undo 'Z' is for redo.
|
|
(workspace as WorkspaceSvg).hideChaff();
|
|
workspace.undo(false);
|
|
e.preventDefault();
|
|
return true;
|
|
},
|
|
keyCodes: [ctrlZ, metaZ],
|
|
};
|
|
ShortcutRegistry.registry.register(undoShortcut);
|
|
}
|
|
|
|
/**
|
|
* Keyboard shortcut to redo the previous action on ctrl+shift+z, cmd+shift+z,
|
|
* or alt+shift+z.
|
|
*/
|
|
export function registerRedo() {
|
|
const ctrlShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
|
|
KeyCodes.CTRL,
|
|
KeyCodes.SHIFT,
|
|
]);
|
|
const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [
|
|
KeyCodes.META,
|
|
KeyCodes.SHIFT,
|
|
]);
|
|
// Ctrl-y is redo in Windows. Command-y is never valid on Macs.
|
|
const ctrlY = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Y, [
|
|
KeyCodes.CTRL,
|
|
]);
|
|
|
|
const redoShortcut: KeyboardShortcut = {
|
|
name: names.REDO,
|
|
preconditionFn(workspace) {
|
|
return (
|
|
!workspace.isDragging() &&
|
|
!workspace.isReadOnly() &&
|
|
!getFocusManager().ephemeralFocusTaken()
|
|
);
|
|
},
|
|
callback(workspace, e) {
|
|
// 'z' for undo 'Z' is for redo.
|
|
(workspace as WorkspaceSvg).hideChaff();
|
|
workspace.undo(true);
|
|
e.preventDefault();
|
|
return true;
|
|
},
|
|
keyCodes: [ctrlShiftZ, metaShiftZ, ctrlY],
|
|
};
|
|
ShortcutRegistry.registry.register(redoShortcut);
|
|
}
|
|
|
|
/**
|
|
* Registers a keyboard shortcut for re-reading the current selected block's
|
|
* summary with additional verbosity to help provide context on where the user
|
|
* is currently navigated (for screen reader users only).
|
|
*/
|
|
export function registerReadFullBlockSummary() {
|
|
const i = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, null);
|
|
const readFullBlockSummaryShortcut: KeyboardShortcut = {
|
|
name: names.READ_FULL_BLOCK_SUMMARY,
|
|
preconditionFn(workspace) {
|
|
return (
|
|
!workspace.isDragging() &&
|
|
!getFocusManager().ephemeralFocusTaken() &&
|
|
!!getFocusManager().getFocusedNode() &&
|
|
getFocusManager().getFocusedNode() instanceof BlockSvg
|
|
);
|
|
},
|
|
callback(_, e) {
|
|
const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg;
|
|
const blockSummary = selectedBlock.computeAriaLabel(true);
|
|
aria.announceDynamicAriaState(`Current block: ${blockSummary}`);
|
|
e.preventDefault();
|
|
return true;
|
|
},
|
|
keyCodes: [i],
|
|
};
|
|
ShortcutRegistry.registry.register(readFullBlockSummaryShortcut);
|
|
}
|
|
|
|
/**
|
|
* Registers a keyboard shortcut for re-reading the current selected block's
|
|
* parent block summary with additional verbosity to help provide context on
|
|
* where the user is currently navigated (for screen reader users only).
|
|
*/
|
|
export function registerReadBlockParentSummary() {
|
|
const shiftI = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, [
|
|
KeyCodes.SHIFT,
|
|
]);
|
|
const readBlockParentSummaryShortcut: KeyboardShortcut = {
|
|
name: names.READ_BLOCK_PARENT_SUMMARY,
|
|
preconditionFn(workspace) {
|
|
return (
|
|
!workspace.isDragging() &&
|
|
!getFocusManager().ephemeralFocusTaken() &&
|
|
!!getFocusManager().getFocusedNode() &&
|
|
getFocusManager().getFocusedNode() instanceof BlockSvg
|
|
);
|
|
},
|
|
callback(_, e) {
|
|
const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg;
|
|
const parentBlock = selectedBlock.getParent();
|
|
if (parentBlock) {
|
|
const blockSummary = parentBlock.computeAriaLabel(true);
|
|
aria.announceDynamicAriaState(`Parent block: ${blockSummary}`);
|
|
} else {
|
|
aria.announceDynamicAriaState('Current block has no parent');
|
|
}
|
|
e.preventDefault();
|
|
return true;
|
|
},
|
|
keyCodes: [shiftI],
|
|
};
|
|
ShortcutRegistry.registry.register(readBlockParentSummaryShortcut);
|
|
}
|
|
|
|
/**
|
|
* Registers all default keyboard shortcut item. This should be called once per
|
|
* instance of KeyboardShortcutRegistry.
|
|
*
|
|
* @internal
|
|
*/
|
|
export function registerDefaultShortcuts() {
|
|
registerEscape();
|
|
registerDelete();
|
|
registerCopy();
|
|
registerCut();
|
|
registerPaste();
|
|
registerUndo();
|
|
registerRedo();
|
|
registerReadFullBlockSummary();
|
|
registerReadBlockParentSummary();
|
|
}
|
|
|
|
registerDefaultShortcuts();
|