Skip to content

Commit 0092431

Browse files
refactor: move action menu to its own file (#314)
* refactor: move context menu opening to action_menu.ts * refactor: import scope from action menu
1 parent a09cbd7 commit 0092431

File tree

4 files changed

+309
-243
lines changed

4 files changed

+309
-243
lines changed

src/actions/action_menu.ts

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
ASTNode,
9+
Connection,
10+
ContextMenu,
11+
ContextMenuRegistry,
12+
ShortcutRegistry,
13+
comments,
14+
utils as BlocklyUtils,
15+
WidgetDiv,
16+
} from 'blockly';
17+
import * as Constants from '../constants';
18+
import type {BlockSvg, WorkspaceSvg} from 'blockly';
19+
import {Navigation} from '../navigation';
20+
21+
const KeyCodes = BlocklyUtils.KeyCodes;
22+
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
23+
ShortcutRegistry.registry,
24+
);
25+
26+
export interface Scope {
27+
block?: BlockSvg;
28+
workspace?: WorkspaceSvg;
29+
comment?: comments.RenderedWorkspaceComment;
30+
connection?: Connection;
31+
}
32+
33+
/**
34+
* Keyboard shortcut to show the action menu on Cmr/Ctrl/Alt+Enter key.
35+
*/
36+
export class ActionMenu {
37+
/**
38+
* Function provided by the navigation controller to say whether navigation
39+
* is allowed.
40+
*/
41+
private canCurrentlyNavigate: (ws: WorkspaceSvg) => boolean;
42+
43+
/**
44+
* Registration name for the keyboard shortcut.
45+
*/
46+
private shortcutName = Constants.SHORTCUT_NAMES.MENU;
47+
48+
constructor(
49+
private navigation: Navigation,
50+
canNavigate: (ws: WorkspaceSvg) => boolean,
51+
) {
52+
this.canCurrentlyNavigate = canNavigate;
53+
}
54+
55+
/**
56+
* Install this action.
57+
*/
58+
install() {
59+
this.registerShortcut();
60+
}
61+
62+
/**
63+
* Uninstall this action.
64+
*/
65+
uninstall() {
66+
ShortcutRegistry.registry.unregister(this.shortcutName);
67+
}
68+
69+
/**
70+
* Create and register the keyboard shortcut for this action.
71+
*/
72+
private registerShortcut() {
73+
const menuShortcut: ShortcutRegistry.KeyboardShortcut = {
74+
name: Constants.SHORTCUT_NAMES.MENU,
75+
preconditionFn: (workspace) => this.canCurrentlyNavigate(workspace),
76+
callback: (workspace) => {
77+
switch (this.navigation.getState(workspace)) {
78+
case Constants.STATE.WORKSPACE:
79+
return this.openActionMenu(workspace);
80+
default:
81+
return false;
82+
}
83+
},
84+
keyCodes: [
85+
createSerializedKey(KeyCodes.ENTER, [KeyCodes.CTRL]),
86+
createSerializedKey(KeyCodes.ENTER, [KeyCodes.ALT]),
87+
createSerializedKey(KeyCodes.ENTER, [KeyCodes.META]),
88+
],
89+
};
90+
ShortcutRegistry.registry.register(menuShortcut);
91+
}
92+
93+
/**
94+
* Show the action menu for the current node.
95+
*
96+
* The action menu will contain entries for relevant actions for the
97+
* node's location. If the location is a block, this will include
98+
* the contents of the block's context menu (if any).
99+
*
100+
* Returns true if it is possible to open the action menu in the
101+
* current location, even if the menu was not opened due there being
102+
* no applicable menu items.
103+
*/
104+
private openActionMenu(workspace: WorkspaceSvg): boolean {
105+
let menuOptions: Array<
106+
| ContextMenuRegistry.ContextMenuOption
107+
| ContextMenuRegistry.LegacyContextMenuOption
108+
> = [];
109+
let rtl: boolean;
110+
111+
const cursor = workspace.getCursor();
112+
if (!cursor) throw new Error('workspace has no cursor');
113+
const node = cursor.getCurNode();
114+
const nodeType = node.getType();
115+
switch (nodeType) {
116+
case ASTNode.types.BLOCK:
117+
const block = node.getLocation() as BlockSvg;
118+
rtl = block.RTL;
119+
// Reimplement BlockSvg.prototype.generateContextMenu as that
120+
// method is protected.
121+
if (!workspace.options.readOnly && block.contextMenu) {
122+
menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
123+
ContextMenuRegistry.ScopeType.BLOCK,
124+
{block},
125+
);
126+
127+
// Allow the block to add or modify menuOptions.
128+
block.customContextMenu?.(menuOptions);
129+
}
130+
// End reimplement.
131+
break;
132+
133+
// case Blockly.ASTNode.types.INPUT:
134+
case ASTNode.types.NEXT:
135+
case ASTNode.types.PREVIOUS:
136+
case ASTNode.types.INPUT:
137+
const connection = node.getLocation() as Connection;
138+
rtl = connection.getSourceBlock().RTL;
139+
140+
// Slightly hacky: get insert action from registry. Hacky
141+
// because registry typings don't include {connection: ...} as
142+
// a possible kind of scope.
143+
const insertAction = ContextMenuRegistry.registry.getItem('insert');
144+
if (!insertAction) throw new Error("can't find insert action");
145+
146+
const pasteAction = ContextMenuRegistry.registry.getItem(
147+
'blockPasteFromContextMenu',
148+
);
149+
if (!pasteAction) throw new Error("can't find paste action");
150+
const possibleOptions = [insertAction, pasteAction /* etc.*/];
151+
152+
// Check preconditions and get menu texts.
153+
const scope = {
154+
connection,
155+
} as unknown as ContextMenuRegistry.Scope;
156+
for (const option of possibleOptions) {
157+
const precondition = option.preconditionFn(scope);
158+
if (precondition === 'hidden') continue;
159+
const displayText =
160+
typeof option.displayText === 'function'
161+
? option.displayText(scope)
162+
: option.displayText;
163+
menuOptions.push({
164+
text: displayText,
165+
enabled: precondition === 'enabled',
166+
callback: option.callback,
167+
scope,
168+
weight: option.weight,
169+
});
170+
}
171+
break;
172+
173+
default:
174+
console.info(`No action menu for ASTNode of type ${nodeType}`);
175+
return false;
176+
}
177+
178+
if (!menuOptions?.length) return true;
179+
const fakeEvent = this.fakeEventForNode(node);
180+
ContextMenu.show(fakeEvent, menuOptions, rtl, workspace);
181+
setTimeout(() => {
182+
WidgetDiv.getDiv()
183+
?.querySelector('.blocklyMenu')
184+
?.dispatchEvent(
185+
new KeyboardEvent('keydown', {
186+
key: 'ArrowDown',
187+
code: 'ArrowDown',
188+
keyCode: KeyCodes.DOWN,
189+
which: KeyCodes.DOWN,
190+
bubbles: true,
191+
cancelable: true,
192+
}),
193+
);
194+
}, 10);
195+
return true;
196+
}
197+
198+
/**
199+
* Create a fake PointerEvent for opening the action menu for the
200+
* given ASTNode.
201+
*
202+
* @param node The node to open the action menu for.
203+
* @returns A synthetic pointerdown PointerEvent.
204+
*/
205+
private fakeEventForNode(node: ASTNode): PointerEvent {
206+
switch (node.getType()) {
207+
case ASTNode.types.BLOCK:
208+
return this.fakeEventForBlockNode(node);
209+
case ASTNode.types.NEXT:
210+
case ASTNode.types.PREVIOUS:
211+
case ASTNode.types.INPUT:
212+
return this.fakeEventForConnectionNode(node);
213+
default:
214+
throw new TypeError('unhandled node type');
215+
}
216+
}
217+
218+
/**
219+
* Create a fake PointerEvent for opening the action menu for the
220+
* given ASTNode of type BLOCK.
221+
*
222+
* @param node The node to open the action menu for.
223+
* @returns A synthetic pointerdown PointerEvent.
224+
*/
225+
private fakeEventForBlockNode(node: ASTNode): PointerEvent {
226+
if (node.getType() !== ASTNode.types.BLOCK) {
227+
throw new TypeError('can only create PointerEvents for BLOCK nodes');
228+
}
229+
230+
// Get the location of the top-left corner of the block in
231+
// screen coordinates.
232+
const block = node.getLocation() as BlockSvg;
233+
const blockCoords = BlocklyUtils.svgMath.wsToScreenCoordinates(
234+
block.workspace,
235+
block.getRelativeToSurfaceXY(),
236+
);
237+
238+
// Prefer a y position below the first field in the block.
239+
const fieldBoundingClientRect = block.inputList
240+
.filter((input) => input.isVisible())
241+
.flatMap((input) => input.fieldRow)
242+
.filter((f) => f.isVisible())[0]
243+
?.getSvgRoot()
244+
?.getBoundingClientRect();
245+
246+
const clientY =
247+
fieldBoundingClientRect && fieldBoundingClientRect.height
248+
? fieldBoundingClientRect.y + fieldBoundingClientRect.height
249+
: blockCoords.y + block.height;
250+
251+
// Create a fake event for the action menu code to work from.
252+
return new PointerEvent('pointerdown', {
253+
clientX: blockCoords.x + 5,
254+
clientY: clientY + 5,
255+
});
256+
}
257+
258+
/**
259+
* Create a fake PointerEvent for opening the action menu for the
260+
* given ASTNode of type NEXT, PREVIOUS or INPUT.
261+
*
262+
* For now this just puts the action menu in the same place as the
263+
* context menu for the source block.
264+
*
265+
* @param node The node to open the action menu for.
266+
* @returns A synthetic pointerdown PointerEvent.
267+
*/
268+
private fakeEventForConnectionNode(node: ASTNode): PointerEvent {
269+
if (
270+
node.getType() !== ASTNode.types.NEXT &&
271+
node.getType() !== ASTNode.types.PREVIOUS &&
272+
node.getType() !== ASTNode.types.INPUT
273+
) {
274+
throw new TypeError('can only create PointerEvents for connection nodes');
275+
}
276+
277+
const connection = node.getLocation() as Connection;
278+
const block = connection.getSourceBlock();
279+
const workspace = block.workspace as WorkspaceSvg;
280+
281+
if (typeof connection.x !== 'number') {
282+
// No coordinates for connection? Fall back to the parent block.
283+
const blockNode = new ASTNode(ASTNode.types.BLOCK, block);
284+
return this.fakeEventForBlockNode(blockNode);
285+
}
286+
const connectionWSCoords = new BlocklyUtils.Coordinate(
287+
connection.x,
288+
connection.y,
289+
);
290+
const connectionScreenCoords = BlocklyUtils.svgMath.wsToScreenCoordinates(
291+
workspace,
292+
connectionWSCoords,
293+
);
294+
return new PointerEvent('pointerdown', {
295+
clientX: connectionScreenCoords.x + 5,
296+
clientY: connectionScreenCoords.y + 5,
297+
});
298+
}
299+
}

src/actions/insert.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,10 @@ import {
1414
import * as Constants from '../constants';
1515
import type {BlockSvg, WorkspaceSvg} from 'blockly';
1616
import {Navigation} from '../navigation';
17+
import {Scope} from './action_menu';
1718

1819
const KeyCodes = BlocklyUtils.KeyCodes;
1920

20-
interface Scope {
21-
block?: BlockSvg;
22-
workspace?: WorkspaceSvg;
23-
comment?: comments.RenderedWorkspaceComment;
24-
connection?: Connection;
25-
}
26-
2721
/**
2822
* Action to insert a block into the workspace.
2923
*

0 commit comments

Comments
 (0)