feat: add scope to keyboard shortcuts and use it (#8917)

* feat: add scope to keyboard shortcuts

* feat: add scope to keyboard shortcuts and use it
This commit is contained in:
Maribeth Moffatt
2025-05-02 15:22:07 -04:00
committed by GitHub
parent 1c79e1ed77
commit dd3133baac
4 changed files with 154 additions and 60 deletions

View File

@@ -8,12 +8,12 @@
import {BlockSvg} from './block_svg.js';
import * as clipboard from './clipboard.js';
import * as common from './common.js';
import * as eventUtils from './events/utils.js';
import {Gesture} from './gesture.js';
import {ICopyData, isCopyable} from './interfaces/i_copyable.js';
import {isDeletable} from './interfaces/i_deletable.js';
import {isDraggable} from './interfaces/i_draggable.js';
import {isSelectable} from './interfaces/i_selectable.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {Coordinate} from './utils/coordinate.js';
import {KeyCodes} from './utils/keycodes.js';
@@ -43,9 +43,7 @@ export function registerEscape() {
return !workspace.isReadOnly();
},
callback(workspace) {
// AnyDuringMigration because: Property 'hideChaff' does not exist on
// type 'Workspace'.
(workspace as AnyDuringMigration).hideChaff();
workspace.hideChaff();
return true;
},
keyCodes: [KeyCodes.ESC],
@@ -59,28 +57,28 @@ export function registerEscape() {
export function registerDelete() {
const deleteShortcut: KeyboardShortcut = {
name: names.DELETE,
preconditionFn(workspace) {
const selected = common.getSelected();
preconditionFn(workspace, scope) {
const focused = scope.focusedNode;
return (
!workspace.isReadOnly() &&
selected != null &&
isDeletable(selected) &&
selected.isDeletable() &&
focused != null &&
isDeletable(focused) &&
focused.isDeletable() &&
!Gesture.inProgress()
);
},
callback(workspace, e) {
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 selected = common.getSelected();
if (selected instanceof BlockSvg) {
selected.checkAndDelete();
} else if (isDeletable(selected) && selected.isDeletable()) {
const focused = scope.focusedNode;
if (focused instanceof BlockSvg) {
focused.checkAndDelete();
} else if (isDeletable(focused) && focused.isDeletable()) {
eventUtils.setGroup(true);
selected.dispose();
focused.dispose();
eventUtils.setGroup(false);
}
return true;
@@ -110,33 +108,33 @@ export function registerCopy() {
const copyShortcut: KeyboardShortcut = {
name: names.COPY,
preconditionFn(workspace) {
const selected = common.getSelected();
preconditionFn(workspace, scope) {
const focused = scope.focusedNode;
return (
!workspace.isReadOnly() &&
!Gesture.inProgress() &&
selected != null &&
isDeletable(selected) &&
selected.isDeletable() &&
isDraggable(selected) &&
selected.isMovable() &&
isCopyable(selected)
focused != null &&
isDeletable(focused) &&
focused.isDeletable() &&
isDraggable(focused) &&
focused.isMovable() &&
isCopyable(focused)
);
},
callback(workspace, e) {
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();
workspace.hideChaff();
const selected = common.getSelected();
if (!selected || !isCopyable(selected)) return false;
copyData = selected.toCopyData();
const focused = scope.focusedNode;
if (!focused || !isCopyable(focused)) return false;
copyData = focused.toCopyData();
copyWorkspace =
selected.workspace instanceof WorkspaceSvg
? selected.workspace
focused.workspace instanceof WorkspaceSvg
? focused.workspace
: workspace;
copyCoords = isDraggable(selected)
? selected.getRelativeToSurfaceXY()
copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
return !!copyData;
},
@@ -161,39 +159,40 @@ export function registerCut() {
const cutShortcut: KeyboardShortcut = {
name: names.CUT,
preconditionFn(workspace) {
const selected = common.getSelected();
preconditionFn(workspace, scope) {
const focused = scope.focusedNode;
return (
!workspace.isReadOnly() &&
!Gesture.inProgress() &&
selected != null &&
isDeletable(selected) &&
selected.isDeletable() &&
isDraggable(selected) &&
selected.isMovable() &&
!selected.workspace!.isFlyout
focused != null &&
isDeletable(focused) &&
focused.isDeletable() &&
isDraggable(focused) &&
focused.isMovable() &&
isSelectable(focused) &&
!focused.workspace.isFlyout
);
},
callback(workspace) {
const selected = common.getSelected();
callback(workspace, e, shortcut, scope) {
const focused = scope.focusedNode;
if (selected instanceof BlockSvg) {
copyData = selected.toCopyData();
if (focused instanceof BlockSvg) {
copyData = focused.toCopyData();
copyWorkspace = workspace;
copyCoords = selected.getRelativeToSurfaceXY();
selected.checkAndDelete();
copyCoords = focused.getRelativeToSurfaceXY();
focused.checkAndDelete();
return true;
} else if (
isDeletable(selected) &&
selected.isDeletable() &&
isCopyable(selected)
isDeletable(focused) &&
focused.isDeletable() &&
isCopyable(focused)
) {
copyData = selected.toCopyData();
copyData = focused.toCopyData();
copyWorkspace = workspace;
copyCoords = isDraggable(selected)
? selected.getRelativeToSurfaceXY()
copyCoords = isDraggable(focused)
? focused.getRelativeToSurfaceXY()
: null;
selected.dispose();
focused.dispose();
return true;
}
return false;

View File

@@ -12,6 +12,8 @@
*/
// Former goog.module ID: Blockly.ShortcutRegistry
import {Scope} from './contextmenu_registry.js';
import {getFocusManager} from './focus_manager.js';
import {KeyCodes} from './utils/keycodes.js';
import * as object from './utils/object.js';
import {WorkspaceSvg} from './workspace_svg.js';
@@ -249,12 +251,20 @@ export class ShortcutRegistry {
const shortcut = this.shortcuts.get(shortcutName);
if (
!shortcut ||
(shortcut.preconditionFn && !shortcut.preconditionFn(workspace))
(shortcut.preconditionFn &&
!shortcut.preconditionFn(workspace, {
focusedNode: getFocusManager().getFocusedNode(),
}))
) {
continue;
}
// If the key has been handled, stop processing shortcuts.
if (shortcut.callback?.(workspace, e, shortcut)) return true;
if (
shortcut.callback?.(workspace, e, shortcut, {
focusedNode: getFocusManager().getFocusedNode(),
})
)
return true;
}
return false;
}
@@ -372,6 +382,8 @@ export namespace ShortcutRegistry {
* @param e The event that caused the shortcut to be activated.
* @param shortcut The `KeyboardShortcut` that was activated
* (i.e., the one this callback is attached to).
* @param scope Information about the focused item when the
* shortcut was invoked.
* @returns Returning true ends processing of the invoked keycode.
* Returning false causes processing to continue with the
* next-most-recently registered shortcut for the invoked
@@ -381,6 +393,7 @@ export namespace ShortcutRegistry {
workspace: WorkspaceSvg,
e: Event,
shortcut: KeyboardShortcut,
scope: Scope,
) => boolean;
/** The name of the shortcut. Should be unique. */
@@ -393,9 +406,11 @@ export namespace ShortcutRegistry {
*
* @param workspace The `WorkspaceSvg` where the shortcut was
* invoked.
* @param scope Information about the focused item when the
* shortcut would be invoked.
* @returns True iff `callback` function should be called.
*/
preconditionFn?: (workspace: WorkspaceSvg) => boolean;
preconditionFn?: (workspace: WorkspaceSvg, scope: Scope) => boolean;
/** Optional arbitray extra data attached to the shortcut. */
metadata?: object;

View File

@@ -31,6 +31,7 @@ suite('Key Down', function () {
defineStackBlock();
const block = workspace.newBlock('stack_block');
Blockly.common.setSelected(block);
sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(block);
return block;
}

View File

@@ -5,6 +5,7 @@
*/
import {assert} from '../../node_modules/chai/chai.js';
import {createTestBlock} from './test_helpers/block_definitions.js';
import {
sharedTestSetup,
sharedTestTeardown,
@@ -299,7 +300,7 @@ suite('Keyboard Shortcut Registry Test', function () {
'callback': function () {
return true;
},
'precondition': function () {
'preconditionFn': function () {
return true;
},
};
@@ -319,6 +320,27 @@ suite('Keyboard Shortcut Registry Test', function () {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.D);
assert.isFalse(this.registry.onKeyDown(this.workspace, event));
});
test('No callback if precondition fails', function () {
const shortcut = {
'name': 'test_shortcut',
'callback': function () {
return true;
},
'preconditionFn': function () {
return false;
},
};
const callBackStub = addShortcut(
this.registry,
shortcut,
Blockly.utils.KeyCodes.C,
true,
);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C);
assert.isFalse(this.registry.onKeyDown(this.workspace, event));
sinon.assert.notCalled(callBackStub);
});
test('No precondition available - execute callback', function () {
delete this.testShortcut['precondition'];
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C);
@@ -332,8 +354,8 @@ suite('Keyboard Shortcut Registry Test', function () {
'callback': function () {
return false;
},
'precondition': function () {
return false;
'preconditionFn': function () {
return true;
},
};
const testShortcut2Stub = addShortcut(
@@ -353,8 +375,8 @@ suite('Keyboard Shortcut Registry Test', function () {
'callback': function () {
return false;
},
'precondition': function () {
return false;
'preconditionFn': function () {
return true;
},
};
const testShortcut2Stub = addShortcut(
@@ -367,6 +389,63 @@ suite('Keyboard Shortcut Registry Test', function () {
sinon.assert.calledOnce(testShortcut2Stub);
sinon.assert.notCalled(this.callBackStub);
});
suite('interaction with FocusManager', function () {
setup(function () {
this.testShortcutWithScope = {
'name': 'test_shortcut',
'callback': function (workspace, e, shortcut, scope) {
return true;
},
'preconditionFn': function (workspace, scope) {
return true;
},
};
// Stub the focus manager
this.focusedBlock = createTestBlock();
sinon
.stub(Blockly.getFocusManager(), 'getFocusedNode')
.returns(this.focusedBlock);
});
test('Callback receives the focused node', function () {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C);
const callbackStub = addShortcut(
this.registry,
this.testShortcutWithScope,
Blockly.utils.KeyCodes.C,
true,
);
this.registry.onKeyDown(this.workspace, event);
const expectedScope = {focusedNode: this.focusedBlock};
sinon.assert.calledWithExactly(
callbackStub,
this.workspace,
event,
this.testShortcutWithScope,
expectedScope,
);
});
test('Precondition receives the focused node', function () {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C);
const callbackStub = addShortcut(
this.registry,
this.testShortcutWithScope,
Blockly.utils.KeyCodes.C,
true,
);
const preconditionStub = sinon
.stub(this.testShortcutWithScope, 'preconditionFn')
.returns(true);
this.registry.onKeyDown(this.workspace, event);
const expectedScope = {focusedNode: this.focusedBlock};
sinon.assert.calledWithExactly(
preconditionStub,
this.workspace,
expectedScope,
);
});
});
});
suite('createSerializedKey', function () {