Skip to content

Commit 212e176

Browse files
feat: delete and clipboard actions (#234)
* feat: create and use DeleteAction class * feat: add clipboard class with associated actions * chore: format * feat: add context menu options for cut and paste * chore: organize code and set relative weights of context menu items * fix: move insert action above clipboard actions in context menu * chore: remove unused code * chore: add shortcut hint to delete context menu option * feat: disable paste context menu option if nothing is in the clipboard * chore: cleanpu in response to review * chore: reorder code for consistency * chore: format
1 parent ad139b7 commit 212e176

File tree

4 files changed

+575
-280
lines changed

4 files changed

+575
-280
lines changed

src/actions/clipboard.ts

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
ContextMenuRegistry,
9+
Gesture,
10+
ShortcutRegistry,
11+
utils as blocklyUtils,
12+
ICopyData,
13+
} from 'blockly';
14+
import * as Constants from '../constants';
15+
import type {BlockSvg, Workspace, WorkspaceSvg} from 'blockly';
16+
import {Navigation} from '../navigation';
17+
18+
const KeyCodes = blocklyUtils.KeyCodes;
19+
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
20+
ShortcutRegistry.registry,
21+
);
22+
23+
/**
24+
* Weight for the first of these three items in the context menu.
25+
* Changing base weight will change where this group goes in the context
26+
* menu; changing individual weights relative to base weight can change
27+
* the order within the clipboard group.
28+
*/
29+
const BASE_WEIGHT = 11;
30+
31+
/**
32+
* Logic and state for cut/copy/paste actions as both keyboard shortcuts
33+
* and context menu items.
34+
* In the long term, this will likely merge with the clipboard code in core.
35+
*/
36+
export class Clipboard {
37+
/** Data copied by the copy or cut keyboard shortcuts. */
38+
private copyData: ICopyData | null = null;
39+
40+
/** The workspace a copy or cut keyboard shortcut happened in. */
41+
private copyWorkspace: WorkspaceSvg | null = null;
42+
43+
/**
44+
* Function provided by the navigation controller to say whether editing
45+
* is allowed.
46+
*/
47+
private canCurrentlyEdit: (ws: WorkspaceSvg) => boolean;
48+
49+
constructor(
50+
private navigation: Navigation,
51+
canEdit: (ws: WorkspaceSvg) => boolean,
52+
) {
53+
this.canCurrentlyEdit = canEdit;
54+
}
55+
56+
/**
57+
* Install these actions as both keyboard shortcuts and context menu items.
58+
*/
59+
install() {
60+
this.registerCopyShortcut();
61+
this.registerCopyContextMenuAction();
62+
63+
this.registerPasteShortcut();
64+
this.registerPasteContextMenuAction();
65+
66+
this.registerCutShortcut();
67+
this.registerCutContextMenuAction();
68+
}
69+
70+
/**
71+
* Uninstall this action as both a keyboard shortcut and a context menu item.
72+
* Reinstall the original context menu action if possible.
73+
*/
74+
uninstall() {
75+
ContextMenuRegistry.registry.unregister('blockCutFromContextMenu');
76+
ContextMenuRegistry.registry.unregister('blockCopyFromContextMenu');
77+
ContextMenuRegistry.registry.unregister('blockPasteFromContextMenu');
78+
79+
ShortcutRegistry.registry.unregister(Constants.SHORTCUT_NAMES.CUT);
80+
ShortcutRegistry.registry.unregister(Constants.SHORTCUT_NAMES.COPY);
81+
ShortcutRegistry.registry.unregister(Constants.SHORTCUT_NAMES.PASTE);
82+
}
83+
84+
/**
85+
* Create and register the keyboard shortcut for the cut action.
86+
*/
87+
private registerCutShortcut() {
88+
const cutShortcut: ShortcutRegistry.KeyboardShortcut = {
89+
name: Constants.SHORTCUT_NAMES.CUT,
90+
preconditionFn: this.cutPrecondition.bind(this),
91+
callback: this.cutCallback.bind(this),
92+
keyCodes: [
93+
createSerializedKey(KeyCodes.X, [KeyCodes.CTRL]),
94+
createSerializedKey(KeyCodes.X, [KeyCodes.ALT]),
95+
createSerializedKey(KeyCodes.X, [KeyCodes.META]),
96+
],
97+
allowCollision: true,
98+
};
99+
100+
ShortcutRegistry.registry.register(cutShortcut);
101+
}
102+
103+
/**
104+
* Register the cut block action as a context menu item on blocks.
105+
* This function mixes together the keyboard and context menu preconditions
106+
* but only calls the keyboard callback.
107+
*/
108+
private registerCutContextMenuAction() {
109+
const cutAction: ContextMenuRegistry.RegistryItem = {
110+
displayText: (scope) => `Cut (${this.getPlatformPrefix()}X)`,
111+
preconditionFn: (scope) => {
112+
const ws = scope.block?.workspace;
113+
if (!ws) return 'hidden';
114+
115+
return this.cutPrecondition(ws) ? 'enabled' : 'disabled';
116+
},
117+
callback: (scope) => {
118+
const ws = scope.block?.workspace;
119+
if (!ws) return;
120+
return this.cutCallback(ws);
121+
},
122+
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
123+
id: 'blockCutFromContextMenu',
124+
weight: BASE_WEIGHT,
125+
};
126+
127+
ContextMenuRegistry.registry.register(cutAction);
128+
}
129+
130+
/**
131+
* Precondition function for cutting a block from keyboard
132+
* navigation. This precondition is shared between keyboard shortcuts
133+
* and context menu items.
134+
*
135+
* @param workspace The `WorkspaceSvg` where the shortcut was
136+
* invoked.
137+
* @returns True iff `cutCallback` function should be called.
138+
*/
139+
private cutPrecondition(workspace: WorkspaceSvg) {
140+
if (this.canCurrentlyEdit(workspace)) {
141+
const curNode = workspace.getCursor()?.getCurNode();
142+
if (curNode && curNode.getSourceBlock()) {
143+
const sourceBlock = curNode.getSourceBlock();
144+
return !!(
145+
!Gesture.inProgress() &&
146+
sourceBlock &&
147+
sourceBlock.isDeletable() &&
148+
sourceBlock.isMovable() &&
149+
!sourceBlock.workspace.isFlyout
150+
);
151+
}
152+
}
153+
return false;
154+
}
155+
156+
/**
157+
* Callback function for cutting a block from keyboard
158+
* navigation. This callback is shared between keyboard shortcuts
159+
* and context menu items.
160+
*
161+
* @param workspace The `WorkspaceSvg` where the shortcut was
162+
* invoked.
163+
* @returns True if this function successfully handled cutting.
164+
*/
165+
private cutCallback(workspace: WorkspaceSvg) {
166+
const sourceBlock = workspace
167+
.getCursor()
168+
?.getCurNode()
169+
.getSourceBlock() as BlockSvg;
170+
this.copyData = sourceBlock.toCopyData();
171+
this.copyWorkspace = sourceBlock.workspace;
172+
this.navigation.moveCursorOnBlockDelete(workspace, sourceBlock);
173+
sourceBlock.checkAndDelete();
174+
return true;
175+
}
176+
177+
/**
178+
* Create and register the keyboard shortcut for the copy action.
179+
*/
180+
private registerCopyShortcut() {
181+
const copyShortcut: ShortcutRegistry.KeyboardShortcut = {
182+
name: Constants.SHORTCUT_NAMES.COPY,
183+
preconditionFn: this.copyPrecondition.bind(this),
184+
callback: this.copyCallback.bind(this),
185+
keyCodes: [
186+
createSerializedKey(KeyCodes.C, [KeyCodes.CTRL]),
187+
createSerializedKey(KeyCodes.C, [KeyCodes.ALT]),
188+
createSerializedKey(KeyCodes.C, [KeyCodes.META]),
189+
],
190+
allowCollision: true,
191+
};
192+
ShortcutRegistry.registry.register(copyShortcut);
193+
}
194+
195+
/**
196+
* Register the copy block action as a context menu item on blocks.
197+
* This function mixes together the keyboard and context menu preconditions
198+
* but only calls the keyboard callback.
199+
*/
200+
private registerCopyContextMenuAction() {
201+
const copyAction: ContextMenuRegistry.RegistryItem = {
202+
displayText: (scope) => `Copy (${this.getPlatformPrefix()}C)`,
203+
preconditionFn: (scope) => {
204+
const ws = scope.block?.workspace;
205+
if (!ws) return 'hidden';
206+
207+
return this.copyPrecondition(ws) ? 'enabled' : 'disabled';
208+
},
209+
callback: (scope) => {
210+
const ws = scope.block?.workspace;
211+
if (!ws) return;
212+
return this.copyCallback(ws);
213+
},
214+
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
215+
id: 'blockCopyFromContextMenu',
216+
weight: BASE_WEIGHT + 1,
217+
};
218+
219+
ContextMenuRegistry.registry.register(copyAction);
220+
}
221+
222+
/**
223+
* Precondition function for copying a block from keyboard
224+
* navigation. This precondition is shared between keyboard shortcuts
225+
* and context menu items.
226+
*
227+
* @param workspace The `WorkspaceSvg` where the shortcut was
228+
* invoked.
229+
* @returns True iff `copyCallback` function should be called.
230+
*/
231+
private copyPrecondition(workspace: WorkspaceSvg) {
232+
if (!this.canCurrentlyEdit(workspace)) return false;
233+
switch (this.navigation.getState(workspace)) {
234+
case Constants.STATE.WORKSPACE:
235+
const curNode = workspace?.getCursor()?.getCurNode();
236+
const source = curNode?.getSourceBlock();
237+
return !!(
238+
source?.isDeletable() &&
239+
source?.isMovable() &&
240+
!Gesture.inProgress()
241+
);
242+
case Constants.STATE.FLYOUT:
243+
const flyoutWorkspace = workspace.getFlyout()?.getWorkspace();
244+
const sourceBlock = flyoutWorkspace
245+
?.getCursor()
246+
?.getCurNode()
247+
?.getSourceBlock();
248+
return !!(sourceBlock && !Gesture.inProgress());
249+
default:
250+
return false;
251+
}
252+
}
253+
254+
/**
255+
* Callback function for copying a block from keyboard
256+
* navigation. This callback is shared between keyboard shortcuts
257+
* and context menu items.
258+
*
259+
* @param workspace The `WorkspaceSvg` where the shortcut was
260+
* invoked.
261+
* @returns True if this function successfully handled copying.
262+
*/
263+
private copyCallback(workspace: WorkspaceSvg) {
264+
const navigationState = this.navigation.getState(workspace);
265+
let activeWorkspace: WorkspaceSvg | undefined = workspace;
266+
if (navigationState === Constants.STATE.FLYOUT) {
267+
activeWorkspace = workspace.getFlyout()?.getWorkspace();
268+
}
269+
const sourceBlock = activeWorkspace
270+
?.getCursor()
271+
?.getCurNode()
272+
.getSourceBlock() as BlockSvg;
273+
workspace.hideChaff();
274+
this.copyData = sourceBlock.toCopyData();
275+
this.copyWorkspace = sourceBlock.workspace;
276+
return !!this.copyData;
277+
}
278+
279+
/**
280+
* Create and register the keyboard shortcut for the paste action.
281+
*/
282+
private registerPasteShortcut() {
283+
const pasteShortcut: ShortcutRegistry.KeyboardShortcut = {
284+
name: Constants.SHORTCUT_NAMES.PASTE,
285+
preconditionFn: this.pastePrecondition.bind(this),
286+
callback: this.pasteCallback.bind(this),
287+
keyCodes: [
288+
createSerializedKey(KeyCodes.V, [KeyCodes.CTRL]),
289+
createSerializedKey(KeyCodes.V, [KeyCodes.ALT]),
290+
createSerializedKey(KeyCodes.V, [KeyCodes.META]),
291+
],
292+
allowCollision: true,
293+
};
294+
ShortcutRegistry.registry.register(pasteShortcut);
295+
}
296+
297+
/**
298+
* Register the paste block action as a context menu item on blocks.
299+
* This function mixes together the keyboard and context menu preconditions
300+
* but only calls the keyboard callback.
301+
*/
302+
private registerPasteContextMenuAction() {
303+
const pasteAction: ContextMenuRegistry.RegistryItem = {
304+
displayText: (scope) => `Paste (${this.getPlatformPrefix()}V)`,
305+
preconditionFn: (scope) => {
306+
const ws = scope.block?.workspace;
307+
if (!ws) return 'hidden';
308+
309+
return this.pastePrecondition(ws) ? 'enabled' : 'disabled';
310+
},
311+
callback: (scope) => {
312+
const ws = scope.block?.workspace;
313+
if (!ws) return;
314+
return this.pasteCallback(ws);
315+
},
316+
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
317+
id: 'blockPasteFromContextMenu',
318+
weight: BASE_WEIGHT + 2,
319+
};
320+
321+
ContextMenuRegistry.registry.register(pasteAction);
322+
}
323+
324+
/**
325+
* Precondition function for pasting a block from keyboard
326+
* navigation. This precondition is shared between keyboard shortcuts
327+
* and context menu items.
328+
*
329+
* @param workspace The `WorkspaceSvg` where the shortcut was
330+
* invoked.
331+
* @returns True iff `pasteCallback` function should be called.
332+
*/
333+
private pastePrecondition(workspace: WorkspaceSvg) {
334+
if (!this.copyData || !this.copyWorkspace) return false;
335+
336+
return this.canCurrentlyEdit(workspace) && !Gesture.inProgress();
337+
}
338+
339+
/**
340+
* Callback function for pasting a block from keyboard
341+
* navigation. This callback is shared between keyboard shortcuts
342+
* and context menu items.
343+
*
344+
* @param workspace The `WorkspaceSvg` where the shortcut was
345+
* invoked.
346+
* @returns True if this function successfully handled pasting.
347+
*/
348+
private pasteCallback(workspace: WorkspaceSvg) {
349+
if (!this.copyData || !this.copyWorkspace) return false;
350+
const pasteWorkspace = this.copyWorkspace.isFlyout
351+
? workspace
352+
: this.copyWorkspace;
353+
return this.navigation.paste(this.copyData, pasteWorkspace);
354+
}
355+
356+
/**
357+
* Check the platform and return a prefix for the keyboard shortcut.
358+
* TODO: https://github.com/google/blockly-keyboard-experimentation/issues/155
359+
* This will eventually be the responsibility of the action code ib
360+
* Blockly core.
361+
*
362+
* @returns A platform-appropriate string for the meta key.
363+
*/
364+
private getPlatformPrefix() {
365+
return navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl + ';
366+
}
367+
}

0 commit comments

Comments
 (0)