Skip to content

Commit cf36b60

Browse files
feat: consistent focus handling; remove markedNode
Ensure we always focus the same element when keyboard navigation works in that element. Previously the toolbox sometimes was and sometimes wasn't focused when it navigated, the workspace or group or the workspace SVG could be focused and the flyout never took the focus. With this approach the relevant container takes focus. Manage the passive focus indicator based on focus rather than markedNode state. Integrate with Gesture to determine how mouse behaviours relate to focus. This prevents issues where a gesture would cause a focus change which would close the flyout, interrupting a normal drag. In time hopefully this code can move to core where it will be more natural and less fragile. Fixes #227 Fixes #200 Part of #229 (scrollbars still an issue) Fixes #222
1 parent 6916afc commit cf36b60

File tree

9 files changed

+380
-343
lines changed

9 files changed

+380
-343
lines changed

src/actions/clipboard.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,13 @@ export class Clipboard {
275275
?.getCursor()
276276
?.getCurNode()
277277
.getSourceBlock() as BlockSvg;
278-
workspace.hideChaff();
279278
this.copyData = sourceBlock.toCopyData();
280279
this.copyWorkspace = sourceBlock.workspace;
281-
return !!this.copyData;
280+
const copied = !!this.copyData;
281+
if (copied && navigationState === Constants.STATE.FLYOUT) {
282+
this.navigation.focusWorkspace(workspace);
283+
}
284+
return copied;
282285
}
283286

284287
/**
@@ -373,7 +376,6 @@ export class Clipboard {
373376
ASTNode.createBlockNode(block)!,
374377
);
375378
}
376-
this.navigation.removeMark(pasteWorkspace);
377379
Events.setGroup(false);
378380
return true;
379381
}

src/actions/enter.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ export class EnterAction {
144144

145145
this.navigation.focusWorkspace(workspace);
146146
workspace.getCursor()!.setCurNode(ASTNode.createBlockNode(newBlock)!);
147-
this.navigation.removeMark(workspace);
148147
}
149148

150149
/**

src/gesture_monkey_patch.js

Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,27 @@
55
*/
66

77
/**
8-
* @fileoverview Overrides methods on Blockly.Gesture in order to allow users
9-
* to move the cursor to blocks or the workspace using shift click.
10-
* TODO(google/blockly#4584): We do not have a way to do this currently without
11-
* monkey patching Blockly.
8+
* @fileoverview Overrides methods on Blockly.Gesture to integrate focus mangagement
9+
* with the gesture handling.
1210
* @author aschmiedt@google.com (Abby Schmiedt)
1311
*/
1412

1513
import * as Blockly from 'blockly/core';
1614

17-
const oldDoWorkspaceClick = Blockly.Gesture.prototype.doWorkspaceClick_;
15+
const oldDispose = Blockly.Gesture.prototype.dispose;
1816

1917
/**
20-
* Execute a workspace click. When in accessibility mode shift clicking will
21-
* move the cursor.
22-
* @param {!Event} e A mouse up or touch end event.
18+
* Intercept the end of a gesture and ensure the workspace is focused.
19+
* See also the listener is index.ts that subverts the markFocused call
20+
* in `doStart`.
2321
* @this {Blockly.Gesture}
2422
* @override
2523
*/
26-
Blockly.Gesture.prototype.doWorkspaceClick_ = function (e) {
27-
oldDoWorkspaceClick.call(this, e);
28-
const ws = this.creatorWorkspace_;
29-
if (e.shiftKey && ws.keyboardAccessibilityMode) {
30-
const screenCoord = new Blockly.utils.Coordinate(e.clientX, e.clientY);
31-
const wsCoord = Blockly.utils.svgMath.screenToWsCoordinates(
32-
ws,
33-
screenCoord,
34-
);
35-
const wsNode = Blockly.ASTNode.createWorkspaceNode(ws, wsCoord);
36-
ws.getCursor().setCurNode(wsNode);
37-
}
38-
};
39-
40-
const oldDoBlockClick = Blockly.Gesture.prototype.doBlockClick_;
41-
42-
/**
43-
* Execute a block click. When in accessibility mode shift clicking will move
44-
* the cursor to the block.
45-
* @this {Blockly.Gesture}
46-
* @override
47-
*/
48-
Blockly.Gesture.prototype.doBlockClick_ = function (e) {
49-
oldDoBlockClick.call(this, e);
50-
if (
51-
!this.targetBlock_.isInFlyout &&
52-
this.mostRecentEvent_.shiftKey &&
53-
this.targetBlock_.workspace.keyboardAccessibilityMode
54-
) {
55-
this.creatorWorkspace_
56-
.getCursor()
57-
.setCurNode(Blockly.ASTNode.createTopNode(this.targetBlock_));
24+
Blockly.Gesture.prototype.dispose = function () {
25+
// This is a bit of a cludge and focus management needs to be better
26+
// integrated with Gesture. The intent is to move focus at the end of a drag.
27+
if (this.isDragging()) {
28+
this.creatorWorkspace?.getSvgGroup().focus();
5829
}
30+
oldDispose.call(this);
5931
};

src/index.ts

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as Blockly from 'blockly/core';
88
import {NavigationController} from './navigation_controller';
99
import {CursorOptions, LineCursor} from './line_cursor';
10+
import {getFlyoutElement, getToolboxElement} from './workspace_utilities';
1011

1112
/** Options object for KeyboardNavigation instances. */
1213
export type NavigationOptions = {
@@ -24,7 +25,7 @@ export class KeyboardNavigation {
2425
protected workspace: Blockly.WorkspaceSvg;
2526

2627
/** Event handler run when the workspace gains focus. */
27-
private focusListener: () => void;
28+
private focusListener: (e: Event) => void;
2829

2930
/** Event handler run when the workspace loses focus. */
3031
private blurListener: () => void;
@@ -33,7 +34,13 @@ export class KeyboardNavigation {
3334
private toolboxFocusListener: () => void;
3435

3536
/** Event handler run when the toolbox loses focus. */
36-
private toolboxBlurListener: () => void;
37+
private toolboxBlurListener: (e: Event) => void;
38+
39+
/** Event handler run when the flyout gains focus. */
40+
private flyoutFocusListener: () => void;
41+
42+
/** Event handler run when the flyout loses focus. */
43+
private flyoutBlurListener: (e: Event) => void;
3744

3845
/** Keyboard navigation controller instance for the workspace. */
3946
private navigationController: NavigationController;
@@ -86,31 +93,79 @@ export class KeyboardNavigation {
8693
// We add a focus listener below so use -1 so it doesn't become focusable.
8794
workspace.getParentSvg().setAttribute('tabindex', '-1');
8895

89-
this.focusListener = () => {
90-
this.navigationController.updateWorkspaceFocus(workspace, true);
96+
// Move the flyout for logical tab order.
97+
const flyoutElement = getFlyoutElement(workspace);
98+
flyoutElement?.parentElement?.insertBefore(
99+
flyoutElement,
100+
workspace.getParentSvg(),
101+
);
102+
// Allow tab to the flyout only when there's no toolbox.
103+
if (workspace.getToolbox() && flyoutElement) {
104+
flyoutElement.tabIndex = -1;
105+
}
106+
107+
this.focusListener = (e: Event) => {
108+
if (e.currentTarget === this.workspace.getParentSvg()) {
109+
// Starting a gesture unconditionally calls markFocused on the parent SVG
110+
// but we really don't want to move to the workspace (and close the
111+
// flyout) if all you did was click in a flyout, potentially on a
112+
// button. See also `gesture_monkey_patch.js`.
113+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
114+
const gestureInternals = this.workspace.currentGesture_ as any;
115+
const gestureFlyout = gestureInternals?.flyout;
116+
const gestureFlyoutAutoClose = gestureFlyout?.autoClose;
117+
const gestureOnBlock = gestureInternals?.startBlock;
118+
if (
119+
// When clicking on flyout that cannot close.
120+
(gestureFlyout && !gestureFlyoutAutoClose) ||
121+
// When clicking on a block in a flyout that can close.
122+
(gestureFlyout && gestureFlyoutAutoClose && !gestureOnBlock)
123+
) {
124+
this.navigationController.focusFlyout(workspace);
125+
} else {
126+
this.navigationController.focusWorkspace(workspace);
127+
}
128+
} else {
129+
this.navigationController.handleFocusWorkspace(workspace);
130+
}
91131
};
92132
this.blurListener = () => {
93-
this.navigationController.updateWorkspaceFocus(workspace, false);
133+
this.navigationController.handleBlurWorkspace(workspace);
94134
};
95135

96136
workspace.getSvgGroup().addEventListener('focus', this.focusListener);
97137
workspace.getSvgGroup().addEventListener('blur', this.blurListener);
98138

139+
const toolboxElement = getToolboxElement(workspace);
99140
this.toolboxFocusListener = () => {
100-
this.navigationController.updateToolboxFocus(workspace, true);
141+
this.navigationController.handleFocusToolbox(workspace);
101142
};
102-
this.toolboxBlurListener = () => {
103-
this.navigationController.updateToolboxFocus(workspace, false);
143+
this.toolboxBlurListener = (e: Event) => {
144+
this.navigationController.handleBlurToolbox(
145+
workspace,
146+
this.shouldCloseFlyoutOnBlur(
147+
(e as FocusEvent).relatedTarget,
148+
flyoutElement,
149+
),
150+
);
104151
};
152+
toolboxElement?.addEventListener('focus', this.toolboxFocusListener);
153+
toolboxElement?.addEventListener('blur', this.toolboxBlurListener);
105154

106-
const toolbox = workspace.getToolbox();
107-
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
108-
const contentsDiv = toolbox.HtmlDiv?.querySelector(
109-
'.blocklyToolboxContents',
155+
this.flyoutFocusListener = () => {
156+
this.navigationController.handleFocusFlyout(workspace);
157+
};
158+
this.flyoutBlurListener = (e: Event) => {
159+
this.navigationController.handleBlurFlyout(
160+
workspace,
161+
this.shouldCloseFlyoutOnBlur(
162+
(e as FocusEvent).relatedTarget,
163+
toolboxElement,
164+
),
110165
);
111-
contentsDiv?.addEventListener('focus', this.toolboxFocusListener);
112-
contentsDiv?.addEventListener('blur', this.toolboxBlurListener);
113-
}
166+
};
167+
flyoutElement?.addEventListener('focus', this.flyoutFocusListener);
168+
flyoutElement?.addEventListener('blur', this.flyoutBlurListener);
114169

115170
// Temporary workaround for #136.
116171
// TODO(#136): fix in core.
@@ -136,14 +191,13 @@ export class KeyboardNavigation {
136191
.getSvgGroup()
137192
.removeEventListener('focus', this.focusListener);
138193

139-
const toolbox = this.workspace.getToolbox();
140-
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
141-
const contentsDiv = toolbox.HtmlDiv?.querySelector(
142-
'.blocklyToolboxContents',
143-
);
144-
contentsDiv?.removeEventListener('focus', this.toolboxFocusListener);
145-
contentsDiv?.removeEventListener('blur', this.toolboxBlurListener);
146-
}
194+
const toolboxElement = getToolboxElement(this.workspace);
195+
toolboxElement?.removeEventListener('focus', this.toolboxFocusListener);
196+
toolboxElement?.removeEventListener('blur', this.toolboxBlurListener);
197+
198+
const flyoutElement = getFlyoutElement(this.workspace);
199+
flyoutElement?.removeEventListener('focus', this.flyoutFocusListener);
200+
flyoutElement?.removeEventListener('blur', this.flyoutBlurListener);
147201

148202
if (this.workspaceParentTabIndex) {
149203
this.workspace
@@ -189,4 +243,32 @@ export class KeyboardNavigation {
189243
});
190244
this.workspace.setTheme(newTheme);
191245
}
246+
247+
/**
248+
* Identify whether we should close the flyout when the toolbox or flyout
249+
* blurs. If a gesture is in progerss or we're moving from one the other
250+
* then we leave it open.
251+
*
252+
* @param relatedTarget The related target from the event on the flyout or toolbox.
253+
* @param container The other element of flyout or toolbox (opposite to the event).
254+
* @returns true if the flyout should be closed, false otherwise.
255+
*/
256+
private shouldCloseFlyoutOnBlur(
257+
relatedTarget: EventTarget | null,
258+
container: Element | null,
259+
) {
260+
if (Blockly.Gesture.inProgress()) {
261+
return false;
262+
}
263+
if (!relatedTarget) {
264+
return false;
265+
}
266+
if (
267+
relatedTarget instanceof Node &&
268+
container?.contains(relatedTarget as Node)
269+
) {
270+
return false;
271+
}
272+
return true;
273+
}
192274
}

0 commit comments

Comments
 (0)