Files
blockly/core/widgetdiv.ts
Ben Henning bbd97eab67 fix: Synchronize gestures and focus (#8981)
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes #8952
Fixes #8950
Fixes #8971

Fixes a bunch of other keyboard + mouse synchronization issues found during the testing discussed in https://github.com/google/blockly-keyboard-experimentation/pull/482#issuecomment-2846341307.

### Proposed Changes

This introduces a number of changes to:
- Ensure that gestures which change selection state also change focus.
- Ensure that ephemeral focus is more robust against certain classes of failures.

### Reason for Changes

There are some ephemeral focus issues that can come up with certain actions (like clicking or dragging) don't properly change focus. Beyond that, some users will likely mix clicking and keyboard navigation, so it's essential that focus and selection state stay in sync when switching between these two types of navigation modalities.

Other changes:
- Drop-down div was actually incorrectly releasing ephemeral focus before animated closes finish which could reset focus afterwards, breaking focus state.
- Both drop-down and widget divs have been updated to only return focus _after_ marking the workspace as focused since the last focused node should always be the thing returned.
- In a number of gesture cases selection has been removed. This is due to `BlockSvg` self-managing its selection state based on focus, so focusing is sufficient. I've also centralized some of the focusing calls (such as putting one in `bringBlockToFront` to ensure focusing happens after potential DOM changes).
- Similarly, `BlockSvg`'s own `bringToFront` has been updated to automatically restore focus after the operation completes. Since `bringToFront` can always result in focus loss, this provides robustness to ensure focus is restored.
- Block pasting now ensures that focus is properly set, however a delay is needed due to additional rendering changes that need to complete (I didn't dig deeply into the _why_ of this).
- Dragging has been updated to always focus the moved block if it's not in the process of being deleted.
- There was some selection resetting logic removed from gesture's `doWorkspaceClick` function. As far as I can tell, this won't be needed anymore since blocks self-regulate their selection state now.
- `FocusManager` has a new extra check for ephemeral focus to catch a specific class of failure where the browser takes away focus immediately after it's returned. This can happen if there are delay timing situations (like animations) which result in a focused node being deleted (which then results in the document body receiving focus). The robustness check is possibly not needed, but it help discover the animation issue with drop-down div and shows some promise for helping to fix the variables-closing-flyout problem.

Some caveats:
- Some undo/redo steps can still result in focus being lost. This may introduce some regressions for selection state, and definitely introduces some annoyances with keyboard navigation. More work will be needed to understand how to better redirect focus (and to what) in cases when blocks disappear.
- There are many other places where focus is being forced or selection state being overwritten that could, in theory, cause issues with focus state. These may need to be fixed in the future.
- There's a lot of redundancy with `focusNode` being called more than it needs to be. `FocusManager` does avoid calling `focus()` more than once for the same node, but it's possible for focus state to switch between multiple nodes or elements even for a single gesture (for example, due to bringing the block to the front causing a DOM refresh). While the eventual consistency nature of the manager means this isn't a real problem, it may cause problems with screen reader output in the future and warrant another pass at reducing `focusNode` calls (particularly for gestures and the click event pipeline).

### Test Coverage

This PR is largely relying on existing tests for regression verification, though no new tests have been added for the specific regression cases.

#8910 is tracking improving `FocusManager` tests which could include some cases for the new ephemeral focus improvements.

#8915 is tracking general accessibility testing which could include adding tests for these specific regression cases.

### Documentation

No new documentation is expected to be needed here.

### Additional Information

These changes originate from both #8875 and from a branch @rachel-fenichel created to investigate some of the failures this PR addresses. These changes have also been verified against both Core's playground and the keyboard navigation plugin's test environment.
2025-05-05 10:29:20 -07:00

332 lines
10 KiB
TypeScript

/**
* @license
* Copyright 2013 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.WidgetDiv
import * as common from './common.js';
import {Field} from './field.js';
import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js';
import * as dom from './utils/dom.js';
import type {Rect} from './utils/rect.js';
import type {Size} from './utils/size.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/** The object currently using this container. */
let owner: unknown = null;
/** The workspace associated with the owner currently using this container. */
let ownerWorkspace: WorkspaceSvg | null = null;
/** Optional cleanup function set by whichever object uses the widget. */
let dispose: (() => void) | null = null;
/** A class name representing the current owner's workspace container. */
const containerClassName = 'blocklyWidgetDiv';
/** A class name representing the current owner's workspace renderer. */
let rendererClassName = '';
/** A class name representing the current owner's workspace theme. */
let themeClassName = '';
/** The HTML container for popup overlays (e.g. editor widgets). */
let containerDiv: HTMLDivElement | null;
/** Callback to FocusManager to return ephemeral focus when the div closes. */
let returnEphemeralFocus: ReturnEphemeralFocus | null = null;
/**
* Returns the HTML container for editor widgets.
*
* @returns The editor widget container.
*/
export function getDiv(): HTMLDivElement | null {
return containerDiv;
}
/**
* Allows unit tests to reset the div. Do not use outside of tests.
*
* @param newDiv The new value for the DIV field.
* @internal
*/
export function testOnly_setDiv(newDiv: HTMLDivElement | null) {
containerDiv = newDiv;
if (newDiv === null) {
document.querySelector('.' + containerClassName)?.remove();
}
}
/**
* Create the widget div and inject it onto the page.
*/
export function createDom() {
const container = common.getParentContainer() || document.body;
if (document.querySelector('.' + containerClassName)) {
containerDiv = document.querySelector('.' + containerClassName);
} else {
containerDiv = document.createElement('div') as HTMLDivElement;
containerDiv.className = containerClassName;
}
container.appendChild(containerDiv!);
}
/**
* Initialize and display the widget div. Close the old one if needed.
*
* @param newOwner The object that will be using this container.
* @param rtl Right-to-left (true) or left-to-right (false).
* @param newDispose Optional cleanup function to be run when the widget is
* closed.
* @param workspace The workspace associated with the widget owner.
*/
export function show(
newOwner: unknown,
rtl: boolean,
newDispose: () => void,
workspace?: WorkspaceSvg | null,
) {
hide();
owner = newOwner;
dispose = newDispose;
const div = containerDiv;
if (!div) return;
div.style.direction = rtl ? 'rtl' : 'ltr';
div.style.display = 'block';
if (!workspace && newOwner instanceof Field) {
// For backward compatibility with plugin fields that do not provide a
// workspace to this function, attempt to derive it from the field.
workspace = (newOwner as Field).getSourceBlock()?.workspace as WorkspaceSvg;
}
ownerWorkspace = workspace ?? null;
const rendererWorkspace =
workspace ?? (common.getMainWorkspace() as WorkspaceSvg);
rendererClassName = rendererWorkspace.getRenderer().getClassName();
themeClassName = rendererWorkspace.getTheme().getClassName();
if (rendererClassName) {
dom.addClass(div, rendererClassName);
}
if (themeClassName) {
dom.addClass(div, themeClassName);
}
returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div);
}
/**
* Destroy the widget and hide the div.
*/
export function hide() {
if (!isVisible()) {
return;
}
owner = null;
const div = containerDiv;
if (!div) return;
div.style.display = 'none';
div.style.left = '';
div.style.top = '';
if (dispose) {
dispose();
dispose = null;
}
div.textContent = '';
if (rendererClassName) {
dom.removeClass(div, rendererClassName);
rendererClassName = '';
}
if (themeClassName) {
dom.removeClass(div, themeClassName);
themeClassName = '';
}
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
if (returnEphemeralFocus) {
returnEphemeralFocus();
returnEphemeralFocus = null;
}
}
/**
* Is the container visible?
*
* @returns True if visible.
*/
export function isVisible(): boolean {
return !!owner;
}
/**
* Destroy the widget and hide the div if it is being used by the specified
* object.
*
* @param oldOwner The object that was using this container.
*/
export function hideIfOwner(oldOwner: unknown) {
if (owner === oldOwner) {
hide();
}
}
/**
* Destroy the widget and hide the div if it is being used by an object in the
* specified workspace, or if it is used by an unknown workspace.
*
* @param workspace The workspace that was using this container.
*/
export function hideIfOwnerIsInWorkspace(workspace: WorkspaceSvg) {
let ownerIsInWorkspace = ownerWorkspace === null;
// Check if the given workspace is a parent workspace of the one containing
// our owner.
let currentWorkspace: WorkspaceSvg | null = workspace;
while (!ownerIsInWorkspace && currentWorkspace) {
if (currentWorkspace === workspace) {
ownerIsInWorkspace = true;
break;
}
currentWorkspace = workspace.options.parentWorkspace;
}
if (ownerIsInWorkspace) {
hide();
}
}
/**
* Set the widget div's position and height. This function does nothing clever:
* it will not ensure that your widget div ends up in the visible window.
*
* @param x Horizontal location (window coordinates, not body).
* @param y Vertical location (window coordinates, not body).
* @param height The height of the widget div (pixels).
*/
function positionInternal(x: number, y: number, height: number) {
containerDiv!.style.left = x + 'px';
containerDiv!.style.top = y + 'px';
containerDiv!.style.height = height + 'px';
}
/**
* Position the widget div based on an anchor rectangle.
* The widget should be placed adjacent to but not overlapping the anchor
* rectangle. The preferred position is directly below and aligned to the left
* (LTR) or right (RTL) side of the anchor.
*
* @param viewportBBox The bounding rectangle of the current viewport, in window
* coordinates.
* @param anchorBBox The bounding rectangle of the anchor, in window
* coordinates.
* @param widgetSize The size of the widget that is inside the widget div, in
* window coordinates.
* @param rtl Whether the workspace is in RTL mode. This determines horizontal
* alignment.
* @internal
*/
export function positionWithAnchor(
viewportBBox: Rect,
anchorBBox: Rect,
widgetSize: Size,
rtl: boolean,
) {
const y = calculateY(viewportBBox, anchorBBox, widgetSize);
const x = calculateX(viewportBBox, anchorBBox, widgetSize, rtl);
if (y < 0) {
positionInternal(x, 0, widgetSize.height + y);
} else {
positionInternal(x, y, widgetSize.height);
}
}
/**
* Calculate an x position (in window coordinates) such that the widget will not
* be offscreen on the right or left.
*
* @param viewportBBox The bounding rectangle of the current viewport, in window
* coordinates.
* @param anchorBBox The bounding rectangle of the anchor, in window
* coordinates.
* @param widgetSize The dimensions of the widget inside the widget div.
* @param rtl Whether the Blockly workspace is in RTL mode.
* @returns A valid x-coordinate for the top left corner of the widget div, in
* window coordinates.
*/
function calculateX(
viewportBBox: Rect,
anchorBBox: Rect,
widgetSize: Size,
rtl: boolean,
): number {
if (rtl) {
// Try to align the right side of the field and the right side of widget.
const widgetLeft = anchorBBox.right - widgetSize.width;
// Don't go offscreen left.
const x = Math.max(widgetLeft, viewportBBox.left);
// But really don't go offscreen right:
return Math.min(x, viewportBBox.right - widgetSize.width);
} else {
// Try to align the left side of the field and the left side of widget.
// Don't go offscreen right.
const x = Math.min(anchorBBox.left, viewportBBox.right - widgetSize.width);
// But left is more important, because that's where the text is.
return Math.max(x, viewportBBox.left);
}
}
/**
* Calculate a y position (in window coordinates) such that the widget will not
* be offscreen on the top or bottom.
*
* @param viewportBBox The bounding rectangle of the current viewport, in window
* coordinates.
* @param anchorBBox The bounding rectangle of the anchor, in window
* coordinates.
* @param widgetSize The dimensions of the widget inside the widget div.
* @returns A valid y-coordinate for the top left corner of the widget div, in
* window coordinates.
*/
function calculateY(
viewportBBox: Rect,
anchorBBox: Rect,
widgetSize: Size,
): number {
// Flip the widget vertically if off the bottom.
// The widget could go off the top of the window, but it would also go off
// the bottom. The window is just too small.
if (anchorBBox.bottom + widgetSize.height >= viewportBBox.bottom) {
// The bottom of the widget is at the top of the field.
return anchorBBox.top - widgetSize.height;
} else {
// The top of the widget is at the bottom of the field.
return anchorBBox.bottom;
}
}
/**
* Determine if the owner is a field for purposes of repositioning.
* We can't simply check `instanceof Field` as that would introduce a circular
* dependency.
*/
function isRepositionable(item: any): item is Field {
return !!item?.repositionForWindowResize;
}
/**
* Reposition the widget div if the owner of it says to.
* If the owner isn't a field, just give up and hide it.
*/
export function repositionForWindowResize(): void {
if (!isRepositionable(owner) || !owner.repositionForWindowResize()) {
// If the owner is not a Field, or if the owner returns false from the
// reposition method, we should hide the widget div. Otherwise, we'll assume
// the owner handled any needed resize.
hide();
}
}