Skip to content

Commit eba8bff

Browse files
committed
Merge branch 'main' into add-new-tests-for-validating-auto-ephemeral-focus-lost-behaviors
2 parents de93f58 + d5151f2 commit eba8bff

File tree

17 files changed

+421
-187
lines changed

17 files changed

+421
-187
lines changed

.github/workflows/pages.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ jobs:
6464
uses: actions/upload-pages-artifact@v3
6565
with:
6666
# Upload build folder
67-
path: './blockly-keyboard-experimentation/dist'
67+
path: './blockly-keyboard-experimentation/build'
6868

6969
deploy:
7070
environment:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
"scripts": {
66
"audit:fix": "blockly-scripts auditFix",
77
"build": "blockly-scripts build",
8-
"ghpages": "webpack",
98
"clean": "blockly-scripts clean",
109
"lint": "eslint .",
1110
"lint:fix": "eslint . --fix",
1211
"format": "prettier --write .",
1312
"format:check": "prettier --check .",
13+
"ghpages": "node scripts/deploy.js",
1414
"predeploy": "blockly-scripts predeploy",
1515
"prepublishOnly": "npm login --registry https://wombat-dressing-room.appspot.com",
1616
"start": "blockly-scripts start",

scripts/deploy.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
const execSync = require('child_process').execSync;
8+
const fs = require('fs');
9+
10+
console.log(`Preparing test page for gh-pages deployment.`);
11+
12+
execSync(`npm run build && npm run predeploy`, {stdio: 'pipe'});
13+
14+
// Copy test/index.html to build/ directory.
15+
// Update the path at which the test_bundle can be found.
16+
let testPage = fs.readFileSync('./test/index.html').toString();
17+
testPage = testPage.replace('../build/test_bundle.js', 'test_bundle.js');
18+
fs.writeFileSync('build/index.html', testPage, 'utf-8');
19+
console.log(
20+
`Open 'build/index.html' in a browser to see results, or upload the 'build' directory to ghpages.`,
21+
);

src/actions/duplicate.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
BlockSvg,
9+
clipboard,
10+
ContextMenuRegistry,
11+
ICopyable,
12+
ShortcutRegistry,
13+
utils,
14+
comments,
15+
ICopyData,
16+
} from 'blockly';
17+
import * as Constants from '../constants';
18+
import {getMenuItem} from '../shortcut_formatting';
19+
20+
/**
21+
* Duplicate action that adds a keyboard shortcut for duplicate and overrides
22+
* the context menu item to show it if the context menu item is registered.
23+
*/
24+
export class DuplicateAction {
25+
private duplicateShortcut: ShortcutRegistry.KeyboardShortcut | null = null;
26+
private uninstallHandlers: Array<() => void> = [];
27+
28+
/**
29+
* Install the shortcuts and override context menu entries.
30+
*
31+
* No change is made if there's already a 'duplicate' shortcut.
32+
*/
33+
install() {
34+
this.duplicateShortcut = this.registerDuplicateShortcut();
35+
if (this.duplicateShortcut) {
36+
this.uninstallHandlers.push(
37+
overrideContextMenuItemForShortcutText(
38+
'blockDuplicate',
39+
Constants.SHORTCUT_NAMES.DUPLICATE,
40+
),
41+
);
42+
this.uninstallHandlers.push(
43+
overrideContextMenuItemForShortcutText(
44+
'commentDuplicate',
45+
Constants.SHORTCUT_NAMES.DUPLICATE,
46+
),
47+
);
48+
}
49+
}
50+
51+
/**
52+
* Unregister the shortcut and reinstate the original context menu entries.
53+
*/
54+
uninstall() {
55+
this.uninstallHandlers.forEach((handler) => handler());
56+
this.uninstallHandlers.length = 0;
57+
if (this.duplicateShortcut) {
58+
ShortcutRegistry.registry.unregister(this.duplicateShortcut.name);
59+
}
60+
}
61+
62+
/**
63+
* Create and register the keyboard shortcut for the duplicate action.
64+
* Same behaviour as for the core context menu.
65+
* Skipped if there is a shortcut with a matching name already.
66+
*/
67+
private registerDuplicateShortcut(): ShortcutRegistry.KeyboardShortcut | null {
68+
if (
69+
ShortcutRegistry.registry.getRegistry()[
70+
Constants.SHORTCUT_NAMES.DUPLICATE
71+
]
72+
) {
73+
return null;
74+
}
75+
76+
const shortcut: ShortcutRegistry.KeyboardShortcut = {
77+
name: Constants.SHORTCUT_NAMES.DUPLICATE,
78+
// Equivalent to the core context menu entry.
79+
preconditionFn(workspace, scope) {
80+
const {focusedNode} = scope;
81+
if (focusedNode instanceof BlockSvg) {
82+
return (
83+
!focusedNode.isInFlyout &&
84+
focusedNode.isDeletable() &&
85+
focusedNode.isMovable() &&
86+
focusedNode.isDuplicatable()
87+
);
88+
} else if (focusedNode instanceof comments.RenderedWorkspaceComment) {
89+
return focusedNode.isMovable();
90+
}
91+
return false;
92+
},
93+
callback(workspace, e, shortcut, scope) {
94+
const copyable = scope.focusedNode as ICopyable<ICopyData>;
95+
const data = copyable.toCopyData();
96+
if (!data) return false;
97+
return !!clipboard.paste(data, workspace);
98+
},
99+
keyCodes: [utils.KeyCodes.D],
100+
};
101+
ShortcutRegistry.registry.register(shortcut);
102+
return shortcut;
103+
}
104+
}
105+
106+
/**
107+
* Replace a context menu item to add shortcut text to its displayText.
108+
*
109+
* Nothing happens if there is not a matching context menu item registered.
110+
*
111+
* @param registryId Context menu registry id to replace if present.
112+
* @param shortcutName The corresponding shortcut name.
113+
* @returns A function to reinstate the original context menu entry.
114+
*/
115+
function overrideContextMenuItemForShortcutText(
116+
registryId: string,
117+
shortcutName: string,
118+
): () => void {
119+
const original = ContextMenuRegistry.registry.getItem(registryId);
120+
if (!original || 'separator' in original) {
121+
return () => {};
122+
}
123+
124+
const override: ContextMenuRegistry.RegistryItem = {
125+
...original,
126+
displayText: (scope: ContextMenuRegistry.Scope) => {
127+
const displayText =
128+
typeof original.displayText === 'function'
129+
? original.displayText(scope)
130+
: original.displayText;
131+
if (displayText instanceof HTMLElement) {
132+
// We can't cope in this scenario.
133+
return displayText;
134+
}
135+
return getMenuItem(displayText, shortcutName);
136+
},
137+
};
138+
ContextMenuRegistry.registry.unregister(registryId);
139+
ContextMenuRegistry.registry.register(override);
140+
141+
return () => {
142+
ContextMenuRegistry.registry.unregister(registryId);
143+
ContextMenuRegistry.registry.register(original);
144+
};
145+
}

src/actions/mover.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ export class Mover {
318318
const info = this.moves.get(workspace);
319319
if (!info) throw new Error('no move info for workspace');
320320

321+
if (info.draggable instanceof comments.RenderedWorkspaceComment) {
322+
return this.moveUnconstrained(workspace, direction);
323+
}
324+
321325
info.dragger.onDrag(
322326
info.fakePointerEvent('pointermove', direction),
323327
info.totalDelta.clone().scale(workspace.scale),

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export enum SHORTCUT_NAMES {
4242
COPY = 'keyboard_nav_copy',
4343
CUT = 'keyboard_nav_cut',
4444
PASTE = 'keyboard_nav_paste',
45+
DUPLICATE = 'duplicate',
4546
MOVE_WS_CURSOR_UP = 'workspace_up',
4647
MOVE_WS_CURSOR_DOWN = 'workspace_down',
4748
MOVE_WS_CURSOR_LEFT = 'workspace_left',
@@ -89,6 +90,7 @@ SHORTCUT_CATEGORIES[Msg['SHORTCUTS_EDITING']] = [
8990
'cut',
9091
'copy',
9192
'paste',
93+
SHORTCUT_NAMES.DUPLICATE,
9294
'undo',
9395
'redo',
9496
];

src/index.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,137 @@ export class KeyboardNavigation {
128128
toggleShortcutDialog(): void {
129129
this.navigationController.shortcutDialog.toggle(this.workspace);
130130
}
131+
132+
/**
133+
* Register CSS used by the plugin.
134+
* This is broken up into sections by purpose, with some notes about
135+
* where it should eventually live.
136+
* Must be called before `Blockly.inject`.
137+
*/
138+
static registerKeyboardNavigationStyles() {
139+
// Enable the delete icon for comments.
140+
//
141+
// This should remain in the plugin for the time being because we do
142+
// not want to display the delete icon by default.
143+
Blockly.Css.register(`
144+
.blocklyDeleteIcon {
145+
display: block;
146+
}
147+
`);
148+
149+
// Set variables that will be used to control the appearance of the
150+
// focus indicators. Attach them to the injectionDiv since they will
151+
// apply to things contained therein.
152+
//
153+
// This should be moved to core, either to core/css.ts
154+
// or to core/renderers/.
155+
Blockly.Css.register(`
156+
.injectionDiv {
157+
--blockly-active-node-color: #fff200;
158+
--blockly-active-tree-color: #60a5fa;
159+
--blockly-selection-width: 3px;
160+
}
161+
`);
162+
163+
// Styling focusing blocks, connections and fields.
164+
//
165+
// This should be moved to core, being integrated into the
166+
// existing styling of renderers in core/renderers/*/constants.ts.
167+
Blockly.Css.register(`
168+
/* Blocks, connections and fields. */
169+
.blocklyKeyboardNavigation
170+
.blocklyActiveFocus:is(.blocklyPath, .blocklyHighlightedConnectionPath),
171+
.blocklyKeyboardNavigation
172+
.blocklyActiveFocus.blocklyField
173+
> .blocklyFieldRect,
174+
.blocklyKeyboardNavigation
175+
.blocklyActiveFocus.blocklyIconGroup
176+
> .blocklyIconShape:first-child {
177+
stroke: var(--blockly-active-node-color);
178+
stroke-width: var(--blockly-selection-width);
179+
}
180+
.blocklyKeyboardNavigation
181+
.blocklyPassiveFocus:is(
182+
.blocklyPath:not(.blocklyFlyout .blocklyPath),
183+
.blocklyHighlightedConnectionPath
184+
),
185+
.blocklyKeyboardNavigation
186+
.blocklyPassiveFocus.blocklyField
187+
> .blocklyFieldRect,
188+
.blocklyKeyboardNavigation
189+
.blocklyPassiveFocus.blocklyIconGroup
190+
> .blocklyIconShape:first-child {
191+
stroke: var(--blockly-active-node-color);
192+
stroke-dasharray: 5px 3px;
193+
stroke-width: var(--blockly-selection-width);
194+
}
195+
.blocklyKeyboardNavigation
196+
.blocklyPassiveFocus.blocklyHighlightedConnectionPath {
197+
/* The connection path is being unexpectedly hidden in core */
198+
display: unset !important;
199+
}
200+
`);
201+
202+
// Styling for focusing the toolbox and flyout.
203+
//
204+
// This should be moved to core, to core/css.ts if not to somewhere
205+
// more specific in core/toolbox/.
206+
Blockly.Css.register(`
207+
.blocklyKeyboardNavigation .blocklyFlyout:has(.blocklyActiveFocus),
208+
.blocklyKeyboardNavigation .blocklyToolbox:has(.blocklyActiveFocus),
209+
.blocklyKeyboardNavigation
210+
.blocklyActiveFocus:is(.blocklyFlyout, .blocklyToolbox) {
211+
outline-offset: calc(var(--blockly-selection-width) * -1);
212+
outline: var(--blockly-selection-width) solid
213+
var(--blockly-active-tree-color);
214+
}
215+
.blocklyKeyboardNavigation
216+
.blocklyToolboxCategoryContainer:focus-visible {
217+
outline: none;
218+
}
219+
`);
220+
221+
// Styling for focusing the Workspace.
222+
//
223+
// This should be move to core, probably to core/css.ts.
224+
Blockly.Css.register(`
225+
.blocklyKeyboardNavigation
226+
.blocklyWorkspace:has(.blocklyActiveFocus)
227+
.blocklyWorkspaceFocusRing,
228+
.blocklyKeyboardNavigation
229+
.blocklySvg:has(~ .blocklyBlockDragSurface .blocklyActiveFocus)
230+
.blocklyWorkspaceFocusRing,
231+
.blocklyKeyboardNavigation
232+
.blocklyWorkspace.blocklyActiveFocus
233+
.blocklyWorkspaceFocusRing {
234+
stroke: var(--blockly-active-tree-color);
235+
stroke-width: calc(var(--blockly-selection-width) * 2);
236+
}
237+
.blocklyKeyboardNavigation
238+
.blocklyWorkspace.blocklyActiveFocus
239+
.blocklyWorkspaceSelectionRing {
240+
stroke: var(--blockly-active-node-color);
241+
stroke-width: var(--blockly-selection-width);
242+
}
243+
`);
244+
245+
// Keyboard-nav-specific styling for the context menu.
246+
//
247+
// This should remain in the plugin for the time being because the
248+
// classes selected are currently only defined in the plugin.
249+
Blockly.Css.register(`
250+
.blocklyRTL .blocklyMenuItemContent .blocklyShortcutContainer {
251+
flex-direction: row-reverse;
252+
}
253+
.blocklyMenuItemContent .blocklyShortcutContainer {
254+
width: 100%;
255+
display: flex;
256+
justify-content: space-between;
257+
gap: 16px;
258+
}
259+
.blocklyMenuItemContent .blocklyShortcutContainer .blocklyShortcut {
260+
color: #ccc;
261+
}
262+
`);
263+
}
131264
}

src/navigation_controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {DisconnectAction} from './actions/disconnect';
3535
import {ActionMenu} from './actions/action_menu';
3636
import {MoveActions} from './actions/move';
3737
import {Mover} from './actions/mover';
38+
import {DuplicateAction} from './actions/duplicate';
3839

3940
const KeyCodes = BlocklyUtils.KeyCodes;
4041

@@ -59,6 +60,8 @@ export class NavigationController {
5960

6061
clipboard: Clipboard = new Clipboard(this.navigation);
6162

63+
duplicateAction = new DuplicateAction();
64+
6265
workspaceMovement: WorkspaceMovement = new WorkspaceMovement(this.navigation);
6366

6467
/** Keyboard navigation actions for the arrow keys. */
@@ -244,6 +247,7 @@ export class NavigationController {
244247
this.actionMenu.install();
245248

246249
this.clipboard.install();
250+
this.duplicateAction.install();
247251
this.moveActions.install();
248252
this.shortcutDialog.install();
249253

@@ -262,6 +266,7 @@ export class NavigationController {
262266
this.editAction.uninstall();
263267
this.disconnectAction.uninstall();
264268
this.clipboard.uninstall();
269+
this.duplicateAction.uninstall();
265270
this.workspaceMovement.uninstall();
266271
this.arrowNavigation.uninstall();
267272
this.exitAction.uninstall();

0 commit comments

Comments
 (0)