feat(input): support artboard selection

This commit is contained in:
syntaxbullet
2026-07-03 16:04:43 +02:00
parent 46aa1b8595
commit 28fcbdceda
4 changed files with 122 additions and 5 deletions

View File

@@ -5,6 +5,7 @@ export {
} from "./dom"; } from "./dom";
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
export { handleKeybind, keybindFromEvent } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard";
export { handleArtboardSelection } from "./selection";
export type { ViewportPanInputController } from "./viewport-pan"; export type { ViewportPanInputController } from "./viewport-pan";
export { createViewportPanInputController } from "./viewport-pan"; export { createViewportPanInputController } from "./viewport-pan";
export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer"; export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";

69
input/selection.test.ts Normal file
View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import { createInitialAppState } from "@editor/initial-state";
import { handleArtboardSelection } from "./selection";
import type { PointerInputEvent } from "./pointer";
const ignoredState = undefined as never;
describe("selection input", () => {
test("selects artboard hit by left click", () => {
const state = {
...createInitialAppState("Test"),
document: {
...createInitialAppState("Test").document,
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }],
},
editor: {
...createInitialAppState("Test").editor,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
},
};
const dispatched: unknown[] = [];
const consumed = handleArtboardSelection({
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
document: state.document,
viewport: state.editor.viewport,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
},
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: [] } }]);
});
test("clears selection when clicking outside artboards", () => {
const state = createInitialAppState("Test");
const dispatched: unknown[] = [];
const consumed = handleArtboardSelection({
event: pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }),
document: state.document,
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
},
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.selectionClear, payload: undefined }]);
});
});
function pointerEvent(overrides: Partial<PointerInputEvent>): PointerInputEvent {
return {
pointerId: 1,
pointerType: "mouse",
position: { x: 0, y: 0 },
buttons: 0,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...overrides,
};
}

35
input/selection.ts Normal file
View File

@@ -0,0 +1,35 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { ImageDocument } from "@core/document";
import type { ViewportState } from "@editor/state";
import type { PointerInputEvent } from "./pointer";
export function handleArtboardSelection(options: {
event: PointerInputEvent;
document: ImageDocument;
viewport: ViewportState;
dispatch: Dispatch;
}): boolean {
if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false;
const point = viewportPointToDocumentPoint(options.event.position, options.viewport);
const artboard = [...options.document.artboards].reverse().find((candidate) => {
const bounds = candidate.bounds;
return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
});
if (!artboard) {
options.dispatch(commandIds.selectionClear, undefined);
return true;
}
options.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [] });
return true;
}
function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) {
return {
x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,
y: viewport.center.y + (point.y - viewport.size.h / 2) / viewport.zoom,
};
}

View File

@@ -4,6 +4,7 @@ import { isPanInteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index"; import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import { import {
createViewportPanInputController, createViewportPanInputController,
handleArtboardSelection,
handleViewportWheel, handleViewportWheel,
keybindEventFromKeyboardEvent, keybindEventFromKeyboardEvent,
pointerInputEventFromPointerEvent, pointerInputEventFromPointerEvent,
@@ -50,12 +51,23 @@ export function useCanvasInput(
}; };
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event)); const inputEvent = pointerInputEventFromPointerEvent(event);
if (!consumed) return; const consumed = panHandler.pointerDown(inputEvent);
if (consumed) {
canvas.setPointerCapture(event.pointerId);
setIsPanning(true);
event.preventDefault();
return;
}
canvas.setPointerCapture(event.pointerId); const state = store.getState();
setIsPanning(true); const selected = state.editor.tools.activeTool === "select" && handleArtboardSelection({
event.preventDefault(); event: inputEvent,
document: state.document,
viewport: state.editor.viewport,
dispatch: store.dispatch,
});
if (selected) event.preventDefault();
}; };
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {