feat(editor): alt-click to cycle the selection through overlapping elements (#12237)

* feat(editor): alt-click to cycle selection through overlapping elements

- alt+click on the selected element (or group) selects the one below it
  under the pointer, wrapping around to the topmost
- inside an edited group, cycles through that group's elements only
- alt+drag duplicates only past a 10px threshold, so alt-clicks don't
- alt+double-click no longer creates/edits text (except with autoshape)

Claude Opus 5.5 (xhigh)

* refactor(editor): move the selection tool out of App into AppSelectionTool (app.selectionTool)

- handleSelectionOnPointerDown, clearSelection(IfNotUsingSelection),
  isASelectedElement, isHittingCommonBoundingBoxOfSelectedElements and the
  alt-click cycle target move as is
- the click selection of the pointer up handler (alt-click cycle, shift
  toggle, click select, bbox-only deselect) becomes
  handleSelectionOnPointerUp
- the selection's transform handles, maybeHandleResize / maybeHandleCrop,
  move as is
- tests for alt-click cycling, and for cropping by dragging a crop handle

Claude Opus 5.5 (1M), xhigh effort

* feat(editor): alt-click cycles the selection on its resize handles too

- alt-resizing starts only past the 10px drag threshold, as alt-drag
  duplicating does, so an alt-click on a resize handle can cycle
- an alt-click on the selected element's resize handle (over the element)
  cycles the selection, but not once a resize happened

Claude Opus 5.5 (1M), xhigh effort

* feat(editor): hint at alt-click cycling while Alt is held

- holding Alt over a single selected element (or group) with the selection
  or lasso tool shows "Alt-click to cycle selection, or drag to duplicate"
- app.modifiers tracks the held Alt, Shift and Ctrl/Cmd outside appState,
  synced from key and pointer events and reset on blur;
  app.modifiers.useHeld(modifier?) re-renders only on the given one
- the hover refresh after a pointer up replays the held modifiers, so it no
  longer releases them (the hint flickered on every alt-click)
- selectionTool.canCycleSelection() shares the cycle rules between the
  hint and the click

Claude Opus 5.5 (1M), xhigh effort
This commit is contained in:
David Luzar
2026-10-06 17:31:23 +02:00
committed by GitHub
parent ed10ac7dca
commit 53973c3a42
11 changed files with 1617 additions and 994 deletions
@@ -136,6 +136,34 @@ describe("Crop an image", () => {
expect(image.height).toBeLessThan(initialHeight);
});
it("Dragging a crop handle in the crop editor crops the image", () => {
const image = API.createElement({
type: "image",
width: 200,
height: 100,
fileId: "file",
});
API.setElements([image]);
// the file loaded, at twice the element's size
const htmlImage = new Image();
Object.defineProperties(htmlImage, {
naturalWidth: { value: 400 },
naturalHeight: { value: 200 },
});
h.app.imageCache.set(image.fileId!, {
image: htmlImage,
mimeType: "image/png",
});
API.setSelectedElements([image]);
Keyboard.keyDown(KEYS.ENTER);
expect(h.state.croppingElementId).toBe(image.id);
UI.resize(image, "w", [50, 0]);
expect(API.getElement(image)).toMatchObject({ x: 50, width: 150 });
expect(API.getElement(image).crop).toMatchObject({ x: 100, width: 300 });
});
it("Cropping has minimal sizes", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
+2 -2
View File
@@ -634,7 +634,7 @@ describe("duplication z-order", () => {
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 10, rectangle1.y + 10);
});
assertElements(h.elements, [
@@ -700,7 +700,7 @@ describe("duplication z-order", () => {
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 10, rectangle1.y + 10);
});
assertElements(h.elements, [
+6 -1
View File
@@ -1,4 +1,4 @@
import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
import { CURSOR_TYPE, KEYS, MIME_TYPES, THEME } from "@excalidraw/common";
import { isHandToolActive, isEraserActive } from "../appState";
@@ -185,12 +185,17 @@ export class AppCursor {
const EventConstructor =
typeof PointerEvent !== "undefined" ? PointerEvent : MouseEvent;
// with the modifiers held now, which the move would otherwise release
const modifiers = this.app.modifiers.get();
this.canvas.dispatchEvent(
new EventConstructor("pointermove", {
bubbles: true,
cancelable: true,
clientX: lastEvent.clientX,
clientY: lastEvent.clientY,
altKey: modifiers.alt,
shiftKey: modifiers.shift,
[KEYS.CTRL_OR_CMD]: modifiers.ctrlOrCmd,
}),
);
};
@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { Emitter, KEYS } from "@excalidraw/common";
export type HeldModifiers = Readonly<{
alt: boolean;
shift: boolean;
/** Ctrl, or Cmd on macOS */
ctrlOrCmd: boolean;
}>;
const NONE_HELD: HeldModifiers = {
alt: false,
shift: false,
ctrlOrCmd: false,
};
const pick = (held: HeldModifiers, modifier?: keyof HeldModifiers) =>
modifier ? held[modifier] : held;
/**
* The modifier keys held over the editor, synced from the flags of its
* keyboard and pointer events. Kept out of appState so that pressing one
* re-renders only its subscribers (e.g. the hint).
*/
export class AppModifiers {
private held = NONE_HELD;
/** fires with a new snapshot whenever a modifier is pressed or released */
readonly onChange = new Emitter<[held: HeldModifiers]>();
get = (): HeldModifiers => this.held;
/* eslint-disable react-hooks/rules-of-hooks -- called from function
components; the lint takes any class method for a class component */
/**
* A hook re-rendering on the held modifiers: all of them, or with
* `modifier`, only on that one being pressed or released.
*/
useHeld(): HeldModifiers;
useHeld(modifier: keyof HeldModifiers): boolean;
useHeld(modifier?: keyof HeldModifiers) {
const [held, setHeld] = useState(() => pick(this.held, modifier));
useEffect(() => {
// (setting a boolean that didn't change doesn't re-render)
const update = () => setHeld(pick(this.held, modifier));
// catches a change between the render and subscribing
update();
return this.onChange.on(update);
}, [modifier]);
return held;
}
/* eslint-enable react-hooks/rules-of-hooks */
sync = (event: {
altKey: boolean;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}) => {
this.set({
alt: event.altKey,
shift: event.shiftKey,
ctrlOrCmd: event[KEYS.CTRL_OR_CMD],
});
};
/** releases them all — e.g. on blur, as Alt+Tab never delivers the keyup */
reset = () => {
this.set(NONE_HELD);
};
private set(held: HeldModifiers) {
if (
held.alt !== this.held.alt ||
held.shift !== this.held.shift ||
held.ctrlOrCmd !== this.held.ctrlOrCmd
) {
this.held = held;
this.onChange.trigger(held);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -1,4 +1,8 @@
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
isSelectionLikeTool,
} from "@excalidraw/common";
import {
isArrowElement,
@@ -41,7 +45,8 @@ const getHints = ({
isMobile,
editorInterface,
app,
}: HintViewerProps): null | string | string[] => {
altHeld,
}: HintViewerProps & { altHeld: boolean }): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@@ -152,6 +157,23 @@ const getHints = ({
});
}
if (
// as the alt-click that cycles it (Shift and Ctrl/Cmd don't re-render
// the hint, they're read as they are when Alt does)
altHeld &&
!app.modifiers.get().shift &&
!app.modifiers.get().ctrlOrCmd &&
!isMobile &&
isSelectionLikeTool(activeTool.type) &&
!appState.editingTextElement &&
!appState.selectedElementsAreBeingDragged &&
app.selectionTool.canCycleSelection()
) {
return t("hints.cycleSelection", {
shortcut: getTaggedShortcutKey("Alt"),
});
}
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected", {
shortcut: getTaggedShortcutKey("Enter"),
@@ -273,6 +295,8 @@ export const HintViewer = ({
editorInterface,
app,
}: HintViewerProps) => {
const altHeld = app.modifiers.useHeld("alt");
if (!appState.showHints) {
return null;
}
@@ -282,6 +306,7 @@ export const HintViewer = ({
isMobile,
editorInterface,
app,
altHeld,
});
if (!hints) {
+1
View File
@@ -403,6 +403,7 @@
"bindTextToElement": "{{shortcut}} to add text",
"createFlowchart": "{{shortcut}} to create a flowchart",
"deepBoxSelect": "Hold {{shortcut}} to deep select, and to prevent dragging",
"cycleSelection": "{{shortcut}}-click to cycle selection, or drag to duplicate",
"eraserRevert": "Hold {{shortcut}} to revert the elements marked for deletion",
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
"disableSnapping": "Hold {{shortcut}} to disable snapping",
+1 -2
View File
@@ -153,8 +153,7 @@ export class API {
static clearSelection = () => {
act(() => {
// @ts-ignore
h.app.clearSelection(null);
h.app.selectionTool.clearSelection(null);
});
expect(API.getSelectedElements().length).toBe(0);
};
@@ -21,6 +21,7 @@ import {
act,
render,
fireEvent,
GlobalTestState,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
assertSelectedElements,
@@ -201,6 +202,192 @@ describe("lasso reselection", () => {
});
});
describe("alt-click cycling", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
// bottom to top, all three overlapping at (50, 50)
const createStack = (groupIds: { bottom?: string[]; middle?: string[] }) => {
const [bottom, middle, top] = (["bottom", "middle", "top"] as const).map(
(name, index) =>
API.createElement({
type: "rectangle",
x: index * 10,
y: index * 10,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
groupIds: name === "top" ? [] : groupIds[name] ?? [],
}),
);
API.setElements([bottom, middle, top]);
return { bottom, middle, top };
};
it("selects the element below the selected one, wrapping around to the topmost", () => {
const { bottom, middle, top } = createStack({});
mouse.clickAt(50, 50);
assertSelectedElements([top.id]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.clickAt(50, 50);
assertSelectedElements([middle.id]);
mouse.clickAt(50, 50);
assertSelectedElements([bottom.id]);
mouse.clickAt(50, 50);
assertSelectedElements([top.id]);
});
});
it("cycles through the edited group's elements only", () => {
const { bottom, middle } = createStack({
bottom: ["group"],
middle: ["group"],
});
// deep select the middle one where the top one isn't
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.clickAt(15, 15);
});
assertSelectedElements([middle.id]);
expect(h.state.editingGroupId).toBe("group");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.clickAt(50, 50);
assertSelectedElements([bottom.id]);
mouse.clickAt(50, 50);
assertSelectedElements([middle.id]);
});
expect(h.state.editingGroupId).toBe("group");
});
it("cycles on the selected element's resize handle, alt-resizing only past the drag threshold", () => {
const { middle, top } = createStack({});
// the middle one's right resize handle, over the top one (a bit of a
// drag still being a click)
API.setSelectedElements([middle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(113, 60);
mouse.moveTo(118, 60);
mouse.upAt();
});
assertSelectedElements([top.id]);
expect(API.getElement(middle).width).toBe(100);
API.setSelectedElements([middle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(113, 60);
mouse.moveTo(153, 60);
mouse.upAt();
});
assertSelectedElements([middle.id]);
// (resized from its center)
expect(API.getElement(middle)).toMatchObject({ x: -30, width: 180 });
});
it("sets up the line editor of an arrow it selects", () => {
const rectangle = API.createElement({
type: "rectangle",
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
const arrow = API.createElement({
type: "arrow",
x: 20,
y: 50,
width: 60,
height: 0,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(60, 0)],
});
API.setElements([rectangle, arrow]);
// (off the arrow's midpoint knob)
mouse.clickAt(35, 50);
assertSelectedElements([arrow.id]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.clickAt(35, 50);
assertSelectedElements([rectangle.id]);
mouse.clickAt(35, 50);
});
assertSelectedElements([arrow.id]);
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
});
it("alt+double-click doesn't create or edit text", () => {
createStack({});
mouse.clickAt(50, 50);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.doubleClickAt(50, 50);
});
expect(h.state.editingTextElement).toBe(null);
expect(h.elements.length).toBe(3);
});
it("hints at cycling while Alt is held over a single selection", () => {
const { middle, top } = createStack({});
const hint = () =>
h.app.ownerDocument.querySelector(".HintViewer")?.textContent ?? "";
const cycleHint = "to cycle selection";
const press = (modifiers: { alt?: boolean; ctrl?: boolean }) =>
Keyboard.withModifierKeys(modifiers, () => {
Keyboard.keyDown(KEYS.ALT, GlobalTestState.interactiveCanvas);
});
const release = () =>
Keyboard.keyUp(KEYS.ALT, GlobalTestState.interactiveCanvas);
mouse.clickAt(50, 50);
expect(hint()).not.toContain(cycleHint);
press({ alt: true });
expect(hint()).toContain(cycleHint);
// (the hover refresh after the click doesn't release Alt)
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.clickAt(50, 50);
});
assertSelectedElements([middle.id]);
expect(hint()).toContain(cycleHint);
release();
expect(hint()).not.toContain(cycleHint);
// Alt+Tab never delivers the keyup
press({ alt: true });
fireEvent.blur(window);
expect(hint()).not.toContain(cycleHint);
// (AltGr on Windows) alt-clicks with Ctrl don't cycle
press({ alt: true, ctrl: true });
expect(hint()).not.toContain(cycleHint);
release();
API.setSelectedElements([middle, top]);
press({ alt: true });
expect(hint()).not.toContain(cycleHint);
release();
});
it("alt-drag duplicates only past the drag threshold", () => {
const { top } = createStack({});
mouse.clickAt(50, 50);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(50, 50);
mouse.moveTo(55, 55);
mouse.upAt();
});
expect(h.elements.length).toBe(3);
expect(API.getElement(top)).toMatchObject({ x: top.x, y: top.y });
});
});
describe("box-selection overlap mode", () => {
const boxSelect = (
startX: number,
+6
View File
@@ -1225,6 +1225,8 @@ export type AppClassProperties = {
duplicate: App["duplicate"];
toolDrag: App["toolDrag"];
activeResizeHandle: App["activeResizeHandle"];
selectionTool: App["selectionTool"];
modifiers: App["modifiers"];
isToolLocked: App["isToolLocked"];
getEffectiveGridSize: App["getEffectiveGridSize"];
setPlugins: App["setPlugins"];
@@ -1284,6 +1286,10 @@ export type PointerDownState = Readonly<{
allHitElements: NonDeleted<ExcalidrawElement>[];
// This is determined on the initial pointer down event
wasAddedToSelection: boolean;
// The element an alt-click selects instead: the one below the selected
// one, cycling through the elements under the pointer (determined on the
// initial pointer down event)
cycleTarget: NonDeleted<ExcalidrawElement> | null;
// Whether selected element(s) were duplicated, might change during the
// pointer interaction
hasBeenDuplicated: boolean;