mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user