From 71e97388da7b52222b6d26049d9dd7e04255b564 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 16:20:15 +0200 Subject: [PATCH] feat(input): select image layers on canvas --- input/selection.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++ input/selection.ts | 33 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/input/selection.test.ts b/input/selection.test.ts index 10b5dc7..5a5e192 100644 --- a/input/selection.test.ts +++ b/input/selection.test.ts @@ -35,6 +35,54 @@ describe("selection input", () => { expect(dispatched).toEqual([{ commandId: commandIds.selectionSet, payload: { artboardId: "a1", layerIds: [] } }]); }); + test("selects topmost image layer before artboard", () => { + const state = { + ...createInitialAppState("Test"), + document: { + ...createInitialAppState("Test").document, + assets: [{ id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 50, h: 50 } }], + artboards: [ + { + id: "a1", + name: "Artboard", + bounds: { x: -100, y: -100, w: 200, h: 200 }, + backgroundColor: "transparent", + layers: [ + { + id: "l1", + type: "image", + name: "Image", + visible: true, + locked: false, + opacity: 1, + assetId: "asset-1", + transform: { position: { x: -25, y: -25 }, scale: { x: 1, y: 1 }, rotation: 0 }, + }, + ], + }, + ], + }, + 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: ["l1"] } }]); + }); + test("clears selection when clicking outside artboards", () => { const state = createInitialAppState("Test"); const dispatched: unknown[] = []; diff --git a/input/selection.ts b/input/selection.ts index d516ee9..4a62dc4 100644 --- a/input/selection.ts +++ b/input/selection.ts @@ -1,6 +1,8 @@ import { commandIds } from "@commands/ids"; import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; +import { resolveTransformTargetBounds } from "@editor/transform-targets"; import type { ViewportState } from "@editor/state"; import type { PointerInputEvent } from "./pointer"; @@ -13,6 +15,12 @@ export function handleArtboardSelection(options: { if (options.event.pointerType !== "mouse" || (options.event.buttons & 1) !== 1) return false; const point = viewportPointToDocumentPoint(options.event.position, options.viewport); + const layerHit = findTopmostLayerAtPoint(options.document, point); + if (layerHit) { + options.dispatch(commandIds.selectionSet, { artboardId: layerHit.artboardId, layerIds: [layerHit.layerId] }); + return true; + } + 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; @@ -27,6 +35,31 @@ export function handleArtboardSelection(options: { return true; } +function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) { + for (const artboard of [...document.artboards].reverse()) { + const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point); + if (layerId) return { artboardId: artboard.id, layerId }; + } + + return undefined; +} + +function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined { + for (const layer of layers) { + if (layer.type === "group") { + const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point); + if (childId) return childId; + } + + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + if (bounds && point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h) { + return layer.id; + } + } + + return undefined; +} + function viewportPointToDocumentPoint(point: PointerInputEvent["position"], viewport: ViewportState) { return { x: viewport.center.x + (point.x - viewport.size.w / 2) / viewport.zoom,