Skip to content

Commit 7067e1c

Browse files
feat: consistent focus handling; remove markedNode (#326)
* 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 * fix: take care making a selection when focusing the workspace This can interfere with in-progress gestures causing them to act in unexpected ways. We now clear the current node when focus moves away. Avoid altering the selection in cursor.draw to prevent similar poorly timed selections. If the cursor position is lost, e.g. by click in the workspace, the arrow keys now default it if needed. Tabbing to the workspace always shows a cursor. Fixes: - Click in workspace blank space now doesn't select a block on mouse down - The mini-workspace for "if" and similar now behaves correctly again (no keyboard nav still though) - Also fixes #287 * fix: cope with null node * fix: remove unintended clearing of current node * fix: flyout-only paste fix * fix: selection in paste in flyout case * chore: clarify case this applies
1 parent dba0e86 commit 7067e1c

File tree

11 files changed

+495
-424
lines changed

11 files changed

+495
-424
lines changed

src/actions/arrow_navigation.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ export class ArrowNavigation {
6464
case Constants.STATE.WORKSPACE:
6565
isHandled = this.fieldShortcutHandler(workspace, shortcut);
6666
if (!isHandled && workspace) {
67-
workspace.getCursor()?.in();
67+
if (!this.navigation.defaultCursorPositionIfNeeded(workspace)) {
68+
workspace.getCursor()?.in();
69+
}
6870
isHandled = true;
6971
}
7072
return isHandled;
@@ -95,7 +97,9 @@ export class ArrowNavigation {
9597
case Constants.STATE.WORKSPACE:
9698
isHandled = this.fieldShortcutHandler(workspace, shortcut);
9799
if (!isHandled && workspace) {
98-
workspace.getCursor()?.out();
100+
if (!this.navigation.defaultCursorPositionIfNeeded(workspace)) {
101+
workspace.getCursor()?.out();
102+
}
99103
isHandled = true;
100104
}
101105
return isHandled;
@@ -125,7 +129,9 @@ export class ArrowNavigation {
125129
case Constants.STATE.WORKSPACE:
126130
isHandled = this.fieldShortcutHandler(workspace, shortcut);
127131
if (!isHandled && workspace) {
128-
workspace.getCursor()?.next();
132+
if (!this.navigation.defaultCursorPositionIfNeeded(workspace)) {
133+
workspace.getCursor()?.next();
134+
}
129135
isHandled = true;
130136
}
131137
return isHandled;
@@ -158,7 +164,14 @@ export class ArrowNavigation {
158164
case Constants.STATE.WORKSPACE:
159165
isHandled = this.fieldShortcutHandler(workspace, shortcut);
160166
if (!isHandled) {
161-
workspace.getCursor()?.prev();
167+
if (
168+
!this.navigation.defaultCursorPositionIfNeeded(
169+
workspace,
170+
'last',
171+
)
172+
) {
173+
workspace.getCursor()?.prev();
174+
}
162175
isHandled = true;
163176
}
164177
return isHandled;

src/actions/clipboard.ts

Lines changed: 9 additions & 5 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
/**
@@ -360,8 +363,10 @@ export class Clipboard {
360363
? workspace
361364
: this.copyWorkspace;
362365

363-
// Do this before clipoard.paste due to cursor/focus workaround in getCurNode.
364-
const targetNode = pasteWorkspace.getCursor()?.getCurNode();
366+
const targetNode = this.navigation.getStationaryNode(pasteWorkspace);
367+
// If we're pasting in the flyout it still targets the workspace. Focus first
368+
// so ensure correct selection handling.
369+
this.navigation.focusWorkspace(workspace);
365370

366371
Events.setGroup(true);
367372
const block = clipboard.paste(this.copyData, pasteWorkspace) as BlockSvg;
@@ -373,7 +378,6 @@ export class Clipboard {
373378
ASTNode.createBlockNode(block)!,
374379
);
375380
}
376-
this.navigation.removeMark(pasteWorkspace);
377381
Events.setGroup(false);
378382
return true;
379383
}

src/actions/enter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export class EnterAction {
9393
const cursor = workspace.getCursor();
9494
if (!cursor) return;
9595
const curNode = cursor.getCurNode();
96+
if (!curNode) return;
9697
const nodeType = curNode.getType();
9798
if (nodeType === ASTNode.types.FIELD) {
9899
(curNode.getLocation() as Field).showEditor();
@@ -125,7 +126,7 @@ export class EnterAction {
125126
* the block will be placed on.
126127
*/
127128
private insertFromFlyout(workspace: WorkspaceSvg) {
128-
const stationaryNode = workspace.getCursor()?.getCurNode();
129+
const stationaryNode = this.navigation.getStationaryNode(workspace);
129130
const newBlock = this.createNewBlock(workspace);
130131
if (!newBlock) return;
131132
if (stationaryNode) {
@@ -144,7 +145,6 @@ export class EnterAction {
144145

145146
this.navigation.focusWorkspace(workspace);
146147
workspace.getCursor()!.setCurNode(ASTNode.createBlockNode(newBlock)!);
147-
this.navigation.removeMark(workspace);
148148
}
149149

150150
/**

src/gesture_monkey_patch.js

Lines changed: 13 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,28 @@
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 [email protected] (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
27+
// a drag from a non-auto-closing flyout.
28+
if (this.isDragging()) {
29+
this.creatorWorkspace?.getSvgGroup().focus();
5830
}
31+
oldDispose.call(this);
5932
};

src/index.ts

Lines changed: 113 additions & 25 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,16 +25,22 @@ 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. */
30-
private blurListener: () => void;
31+
private blurListener: (e: Event) => void;
3132

3233
/** Event handler run when the toolbox gains focus. */
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,85 @@ 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
};
92-
this.blurListener = () => {
93-
this.navigationController.updateWorkspaceFocus(workspace, false);
132+
this.blurListener = (e: Event) => {
133+
const relatedTarget = (e as FocusEvent).relatedTarget;
134+
if (
135+
relatedTarget !== this.workspace.getParentSvg() &&
136+
relatedTarget !== this.workspace.getSvgGroup()
137+
) {
138+
this.navigationController.handleBlurWorkspace(workspace);
139+
}
94140
};
95141

96142
workspace.getSvgGroup().addEventListener('focus', this.focusListener);
97143
workspace.getSvgGroup().addEventListener('blur', this.blurListener);
98144

145+
const toolboxElement = getToolboxElement(workspace);
99146
this.toolboxFocusListener = () => {
100-
this.navigationController.updateToolboxFocus(workspace, true);
147+
this.navigationController.handleFocusToolbox(workspace);
101148
};
102-
this.toolboxBlurListener = () => {
103-
this.navigationController.updateToolboxFocus(workspace, false);
149+
this.toolboxBlurListener = (e: Event) => {
150+
this.navigationController.handleBlurToolbox(
151+
workspace,
152+
this.shouldCloseFlyoutOnBlur(
153+
(e as FocusEvent).relatedTarget,
154+
flyoutElement,
155+
),
156+
);
104157
};
158+
toolboxElement?.addEventListener('focus', this.toolboxFocusListener);
159+
toolboxElement?.addEventListener('blur', this.toolboxBlurListener);
105160

106-
const toolbox = workspace.getToolbox();
107-
if (toolbox != null && toolbox instanceof Blockly.Toolbox) {
108-
const contentsDiv = toolbox.HtmlDiv?.querySelector(
109-
'.blocklyToolboxContents',
161+
this.flyoutFocusListener = () => {
162+
this.navigationController.handleFocusFlyout(workspace);
163+
};
164+
this.flyoutBlurListener = (e: Event) => {
165+
this.navigationController.handleBlurFlyout(
166+
workspace,
167+
this.shouldCloseFlyoutOnBlur(
168+
(e as FocusEvent).relatedTarget,
169+
toolboxElement,
170+
),
110171
);
111-
contentsDiv?.addEventListener('focus', this.toolboxFocusListener);
112-
contentsDiv?.addEventListener('blur', this.toolboxBlurListener);
113-
}
172+
};
173+
flyoutElement?.addEventListener('focus', this.flyoutFocusListener);
174+
flyoutElement?.addEventListener('blur', this.flyoutBlurListener);
114175

115176
// Temporary workaround for #136.
116177
// TODO(#136): fix in core.
@@ -136,14 +197,13 @@ export class KeyboardNavigation {
136197
.getSvgGroup()
137198
.removeEventListener('focus', this.focusListener);
138199

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-
}
200+
const toolboxElement = getToolboxElement(this.workspace);
201+
toolboxElement?.removeEventListener('focus', this.toolboxFocusListener);
202+
toolboxElement?.removeEventListener('blur', this.toolboxBlurListener);
203+
204+
const flyoutElement = getFlyoutElement(this.workspace);
205+
flyoutElement?.removeEventListener('focus', this.flyoutFocusListener);
206+
flyoutElement?.removeEventListener('blur', this.flyoutBlurListener);
147207

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

0 commit comments

Comments
 (0)