Skip to content

Commit 4074cee

Browse files
authored
feat!: Make everything ISelectable focusable (#9004)
* feat!: Make bubbles, comments, and icons focusable * feat!: Make ISelectable and ICopyable focusable. * feat: Consolidate selection calls. Now everything is based on focus with selection only being used as a proxy. * feat: Invert responsibility for setSelected(). Now setSelected() is only for quasi-external use. * feat: Push up shadow check to getters. Needed new common-level helper. * chore: Lint fixes. * feat!: Allow IFocusableNode to disable focus. * chore: post-merge lint fixes * fix: Fix tests + text bubble focusing. This fixed then regressed a circular dependency causing the node and advanced compilation steps to fail. This investigation is ongoing. * fix: Clean up & fix imports. This ensures the node and advanced compilation test steps now pass. * fix: Lint fixes + revert commented out logic. * chore: Remove unnecessary cast. Addresses reviewer comment. * fix: Some issues and a bunch of clean-ups. This addresses a bunch of review comments, and fixes selecting workspace comments. * chore: Lint fix. * fix: Remove unnecessary shadow consideration. * chore: Revert import. * chore: Some doc updates & added a warn statement.
1 parent 92cad53 commit 4074cee

34 files changed

+379
-208
lines changed

blocks/procedures.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import type {Block} from '../core/block.js';
1010
import type {BlockSvg} from '../core/block_svg.js';
1111
import type {BlockDefinition} from '../core/blocks.js';
12-
import * as common from '../core/common.js';
1312
import {defineBlocks} from '../core/common.js';
1413
import {config} from '../core/config.js';
1514
import type {Connection} from '../core/connection.js';
@@ -27,6 +26,7 @@ import {FieldCheckbox} from '../core/field_checkbox.js';
2726
import {FieldLabel} from '../core/field_label.js';
2827
import * as fieldRegistry from '../core/field_registry.js';
2928
import {FieldTextInput} from '../core/field_textinput.js';
29+
import {getFocusManager} from '../core/focus_manager.js';
3030
import '../core/icons/comment_icon.js';
3131
import {MutatorIcon as Mutator} from '../core/icons/mutator_icon.js';
3232
import '../core/icons/warning_icon.js';
@@ -1178,7 +1178,7 @@ const PROCEDURE_CALL_COMMON = {
11781178
const def = Procedures.getDefinition(name, workspace);
11791179
if (def) {
11801180
(workspace as WorkspaceSvg).centerOnBlock(def.id);
1181-
common.setSelected(def as BlockSvg);
1181+
getFocusManager().focusNode(def as BlockSvg);
11821182
}
11831183
},
11841184
});

core/block_svg.ts

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -264,20 +264,14 @@ export class BlockSvg
264264

265265
/** Selects this block. Highlights the block visually. */
266266
select() {
267-
if (this.isShadow()) {
268-
this.getParent()?.select();
269-
return;
270-
}
271267
this.addSelect();
268+
common.fireSelectedEvent(this);
272269
}
273270

274271
/** Unselects this block. Unhighlights the block visually. */
275272
unselect() {
276-
if (this.isShadow()) {
277-
this.getParent()?.unselect();
278-
return;
279-
}
280273
this.removeSelect();
274+
common.fireSelectedEvent(null);
281275
}
282276

283277
/**
@@ -860,25 +854,6 @@ export class BlockSvg
860854
blockAnimations.disposeUiEffect(this);
861855
}
862856

863-
// Selecting a shadow block highlights an ancestor block, but that highlight
864-
// should be removed if the shadow block will be deleted. So, before
865-
// deleting blocks and severing the connections between them, check whether
866-
// doing so would delete a selected block and make sure that any associated
867-
// parent is updated.
868-
const selection = common.getSelected();
869-
if (selection instanceof Block) {
870-
let selectionAncestor: Block | null = selection;
871-
while (selectionAncestor !== null) {
872-
if (selectionAncestor === this) {
873-
// The block to be deleted contains the selected block, so remove any
874-
// selection highlight associated with the selected block before
875-
// deleting them.
876-
selection.unselect();
877-
}
878-
selectionAncestor = selectionAncestor.getParent();
879-
}
880-
}
881-
882857
super.dispose(!!healStack);
883858
dom.removeNode(this.svgGroup);
884859
}
@@ -891,8 +866,7 @@ export class BlockSvg
891866
this.disposing = true;
892867
super.disposeInternal();
893868

894-
if (common.getSelected() === this) {
895-
this.unselect();
869+
if (getFocusManager().getFocusedNode() === this) {
896870
this.workspace.cancelCurrentGesture();
897871
}
898872

@@ -1837,14 +1811,17 @@ export class BlockSvg
18371811

18381812
/** See IFocusableNode.onNodeFocus. */
18391813
onNodeFocus(): void {
1840-
common.setSelected(this);
1814+
this.select();
18411815
}
18421816

18431817
/** See IFocusableNode.onNodeBlur. */
18441818
onNodeBlur(): void {
1845-
if (common.getSelected() === this) {
1846-
common.setSelected(null);
1847-
}
1819+
this.unselect();
1820+
}
1821+
1822+
/** See IFocusableNode.canBeFocused. */
1823+
canBeFocused(): boolean {
1824+
return true;
18481825
}
18491826

18501827
/**

core/bubbles/bubble.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {ISelectable} from '../blockly.js';
87
import * as browserEvents from '../browser_events.js';
98
import * as common from '../common.js';
109
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
10+
import {getFocusManager} from '../focus_manager.js';
1111
import {IBubble} from '../interfaces/i_bubble.js';
12+
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
13+
import {ISelectable} from '../interfaces/i_selectable.js';
1214
import {ContainerRegion} from '../metrics_manager.js';
1315
import {Scrollbar} from '../scrollbar.js';
1416
import {Coordinate} from '../utils/coordinate.js';
@@ -86,17 +88,24 @@ export abstract class Bubble implements IBubble, ISelectable {
8688

8789
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
8890

91+
private focusableElement: SVGElement | HTMLElement;
92+
8993
/**
9094
* @param workspace The workspace this bubble belongs to.
9195
* @param anchor The anchor location of the thing this bubble is attached to.
9296
* The tail of the bubble will point to this location.
9397
* @param ownerRect An optional rect we don't want the bubble to overlap with
9498
* when automatically positioning.
99+
* @param overriddenFocusableElement An optional replacement to the focusable
100+
* element that's represented by this bubble (as a focusable node). This
101+
* element will have its ID and tabindex overwritten. If not provided, the
102+
* focusable element of this node will default to the bubble's SVG root.
95103
*/
96104
constructor(
97105
public readonly workspace: WorkspaceSvg,
98106
protected anchor: Coordinate,
99107
protected ownerRect?: Rect,
108+
overriddenFocusableElement?: SVGElement | HTMLElement,
100109
) {
101110
this.id = idGenerator.getNextUniqueId();
102111
this.svgRoot = dom.createSvgElement(
@@ -127,6 +136,10 @@ export abstract class Bubble implements IBubble, ISelectable {
127136
);
128137
this.contentContainer = dom.createSvgElement(Svg.G, {}, this.svgRoot);
129138

139+
this.focusableElement = overriddenFocusableElement ?? this.svgRoot;
140+
this.focusableElement.setAttribute('id', this.id);
141+
this.focusableElement.setAttribute('tabindex', '-1');
142+
130143
browserEvents.conditionalBind(
131144
this.background,
132145
'pointerdown',
@@ -208,11 +221,13 @@ export abstract class Bubble implements IBubble, ISelectable {
208221
this.background.setAttribute('fill', colour);
209222
}
210223

211-
/** Brings the bubble to the front and passes the pointer event off to the gesture system. */
224+
/**
225+
* Passes the pointer event off to the gesture system and ensures the bubble
226+
* is focused.
227+
*/
212228
private onMouseDown(e: PointerEvent) {
213229
this.workspace.getGesture(e)?.handleBubbleStart(e, this);
214-
this.bringToFront();
215-
common.setSelected(this);
230+
getFocusManager().focusNode(this);
216231
}
217232

218233
/** Positions the bubble relative to its anchor. Does not render its tail. */
@@ -647,9 +662,37 @@ export abstract class Bubble implements IBubble, ISelectable {
647662

648663
select(): void {
649664
// Bubbles don't have any visual for being selected.
665+
common.fireSelectedEvent(this);
650666
}
651667

652668
unselect(): void {
653669
// Bubbles don't have any visual for being selected.
670+
common.fireSelectedEvent(null);
671+
}
672+
673+
/** See IFocusableNode.getFocusableElement. */
674+
getFocusableElement(): HTMLElement | SVGElement {
675+
return this.focusableElement;
676+
}
677+
678+
/** See IFocusableNode.getFocusableTree. */
679+
getFocusableTree(): IFocusableTree {
680+
return this.workspace;
681+
}
682+
683+
/** See IFocusableNode.onNodeFocus. */
684+
onNodeFocus(): void {
685+
this.select();
686+
this.bringToFront();
687+
}
688+
689+
/** See IFocusableNode.onNodeBlur. */
690+
onNodeBlur(): void {
691+
this.unselect();
692+
}
693+
694+
/** See IFocusableNode.canBeFocused. */
695+
canBeFocused(): boolean {
696+
return true;
654697
}
655698
}

core/bubbles/textinput_bubble.ts

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,10 @@ export class TextInputBubble extends Bubble {
8080
protected anchor: Coordinate,
8181
protected ownerRect?: Rect,
8282
) {
83-
super(workspace, anchor, ownerRect);
83+
super(workspace, anchor, ownerRect, TextInputBubble.createTextArea());
8484
dom.addClass(this.svgRoot, 'blocklyTextInputBubble');
85-
({inputRoot: this.inputRoot, textArea: this.textArea} = this.createEditor(
86-
this.contentContainer,
87-
));
85+
this.textArea = this.getFocusableElement() as HTMLTextAreaElement;
86+
this.inputRoot = this.createEditor(this.contentContainer, this.textArea);
8887
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
8988
this.setSize(this.DEFAULT_SIZE, true);
9089
}
@@ -131,11 +130,21 @@ export class TextInputBubble extends Bubble {
131130
this.locationChangeListeners.push(listener);
132131
}
133132

134-
/** Creates the editor UI for this bubble. */
135-
private createEditor(container: SVGGElement): {
136-
inputRoot: SVGForeignObjectElement;
137-
textArea: HTMLTextAreaElement;
138-
} {
133+
/** Creates and returns the editable text area for this bubble's editor. */
134+
private static createTextArea(): HTMLTextAreaElement {
135+
const textArea = document.createElementNS(
136+
dom.HTML_NS,
137+
'textarea',
138+
) as HTMLTextAreaElement;
139+
textArea.className = 'blocklyTextarea blocklyText';
140+
return textArea;
141+
}
142+
143+
/** Creates and returns the UI container element for this bubble's editor. */
144+
private createEditor(
145+
container: SVGGElement,
146+
textArea: HTMLTextAreaElement,
147+
): SVGForeignObjectElement {
139148
const inputRoot = dom.createSvgElement(
140149
Svg.FOREIGNOBJECT,
141150
{
@@ -149,22 +158,13 @@ export class TextInputBubble extends Bubble {
149158
body.setAttribute('xmlns', dom.HTML_NS);
150159
body.className = 'blocklyMinimalBody';
151160

152-
const textArea = document.createElementNS(
153-
dom.HTML_NS,
154-
'textarea',
155-
) as HTMLTextAreaElement;
156-
textArea.className = 'blocklyTextarea blocklyText';
157161
textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
158-
159162
body.appendChild(textArea);
160163
inputRoot.appendChild(body);
161164

162165
this.bindTextAreaEvents(textArea);
163-
setTimeout(() => {
164-
textArea.focus();
165-
}, 0);
166166

167-
return {inputRoot, textArea};
167+
return inputRoot;
168168
}
169169

170170
/** Binds events to the text area element. */
@@ -174,13 +174,6 @@ export class TextInputBubble extends Bubble {
174174
e.stopPropagation();
175175
});
176176

177-
browserEvents.conditionalBind(
178-
textArea,
179-
'focus',
180-
this,
181-
this.onStartEdit,
182-
true,
183-
);
184177
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
185178
}
186179

@@ -314,17 +307,6 @@ export class TextInputBubble extends Bubble {
314307
this.onSizeChange();
315308
}
316309

317-
/**
318-
* Handles starting an edit of the text area. Brings the bubble to the front.
319-
*/
320-
private onStartEdit() {
321-
if (this.bringToFront()) {
322-
// Since the act of moving this node within the DOM causes a loss of
323-
// focus, we need to reapply the focus.
324-
this.textArea.focus();
325-
}
326-
}
327-
328310
/** Handles a text change event for the text area. Calls event listeners. */
329311
private onTextChange() {
330312
this.text = this.textArea.value;

core/clipboard/workspace_comment_paster.ts

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

77
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
8-
import * as common from '../common.js';
98
import {EventType} from '../events/type.js';
109
import * as eventUtils from '../events/utils.js';
10+
import {getFocusManager} from '../focus_manager.js';
1111
import {ICopyData} from '../interfaces/i_copyable.js';
1212
import {IPaster} from '../interfaces/i_paster.js';
1313
import * as commentSerialiation from '../serialization/workspace_comments.js';
@@ -49,7 +49,7 @@ export class WorkspaceCommentPaster
4949
if (eventUtils.isEnabled()) {
5050
eventUtils.fire(new (eventUtils.get(EventType.COMMENT_CREATE))(comment));
5151
}
52-
common.setSelected(comment);
52+
getFocusManager().focusNode(comment);
5353
return comment;
5454
}
5555
}

0 commit comments

Comments
 (0)