diff --git a/commands/transform.ts b/commands/transform.ts index bf633d3..4c35f81 100644 --- a/commands/transform.ts +++ b/commands/transform.ts @@ -1,4 +1,5 @@ import type { Rect, Vec2D } from "@core/geometry"; +import { applyTransformTargetBounds } from "@editor/transform-targets"; import type { TransformHandle, TransformTarget } from "@editor/transform"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -45,19 +46,10 @@ export const transformUpdateCommand: Command = { y: payload.point.y - session.startPoint.y, }); - if (session.target.type === "artboard") { - return { - ...state, - document: { - ...state.document, - artboards: state.document.artboards.map((artboard) => - artboard.id === session.target.id ? { ...artboard, bounds: nextBounds } : artboard, - ), - }, - }; - } - - return state; + return { + ...state, + document: applyTransformTargetBounds(state.document, session.target, nextBounds), + }; }, }; diff --git a/editor/transform-targets.test.ts b/editor/transform-targets.test.ts new file mode 100644 index 0000000..20d6438 --- /dev/null +++ b/editor/transform-targets.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import type { ImageDocument } from "@core/document"; +import { applyTransformTargetBounds, resolveTransformTargetBounds, selectedTransformTarget } from "./transform-targets"; + +const document: ImageDocument = { + id: "d1", + name: "Test", + version: 1, + assets: [], + artboards: [ + { + id: "a1", + name: "Artboard", + bounds: { x: 0, y: 0, w: 100, h: 80 }, + backgroundColor: "transparent", + layers: [], + }, + ], +}; + +describe("transform targets", () => { + test("selects layer target before artboard target", () => { + expect(selectedTransformTarget(document, { artboardId: "a1", layerIds: ["l1"] })).toEqual({ type: "layer", id: "l1" }); + }); + + test("resolves artboard bounds", () => { + expect(resolveTransformTargetBounds(document, { type: "artboard", id: "a1" })).toEqual({ x: 0, y: 0, w: 100, h: 80 }); + }); + + test("applies artboard bounds", () => { + const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 }); + expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 }); + }); +}); diff --git a/editor/transform-targets.ts b/editor/transform-targets.ts new file mode 100644 index 0000000..7210073 --- /dev/null +++ b/editor/transform-targets.ts @@ -0,0 +1,78 @@ +import type { ImageDocument } from "@core/document"; +import type { Rect } from "@core/geometry"; +import type { Layer } from "@core/layer"; +import type { ArtboardId, LayerId } from "@core/id"; +import type { TransformTarget } from "./transform"; + +export function resolveTransformTargetBounds(document: ImageDocument, target: TransformTarget): Rect | undefined { + switch (target.type) { + case "artboard": + return document.artboards.find((artboard) => artboard.id === target.id)?.bounds; + case "layer": { + const layer = findLayer(document, target.id); + return layer ? resolveLayerBounds(layer) : undefined; + } + } +} + +export function applyTransformTargetBounds(document: ImageDocument, target: TransformTarget, bounds: Rect): ImageDocument { + switch (target.type) { + case "artboard": + return { + ...document, + artboards: document.artboards.map((artboard) => (artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard)), + }; + case "layer": + return document; + } +} + +export function selectedTransformTarget(document: ImageDocument, selection: { artboardId?: ArtboardId; layerIds: LayerId[] }): TransformTarget | undefined { + if (selection.layerIds.length === 1 && selection.layerIds[0]) return { type: "layer", id: selection.layerIds[0] }; + if (selection.artboardId) return { type: "artboard", id: selection.artboardId }; + return undefined; +} + +function findLayer(document: ImageDocument, layerId: LayerId): Layer | undefined { + for (const artboard of document.artboards) { + const layer = findLayerInTree(artboard.layers, layerId); + if (layer) return layer; + } + + return undefined; +} + +function findLayerInTree(layers: Layer[], layerId: LayerId): Layer | undefined { + for (const layer of layers) { + if (layer.id === layerId) return layer; + if (layer.type === "group") { + const child = findLayerInTree(layer.children, layerId); + if (child) return child; + } + } + + return undefined; +} + +function resolveLayerBounds(layer: Layer): Rect | undefined { + switch (layer.type) { + case "group": + return unionRects(layer.children.flatMap((child) => { + const bounds = resolveLayerBounds(child); + return bounds ? [bounds] : []; + })); + case "image": + return undefined; + } +} + +function unionRects(rects: Rect[]): Rect | undefined { + if (rects.length === 0) return undefined; + + const minX = Math.min(...rects.map((rect) => rect.x)); + const minY = Math.min(...rects.map((rect) => rect.y)); + const maxX = Math.max(...rects.map((rect) => rect.x + rect.w)); + const maxY = Math.max(...rects.map((rect) => rect.y + rect.h)); + + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} diff --git a/editor/transform.ts b/editor/transform.ts index 4ad77cc..e0b2a0e 100644 --- a/editor/transform.ts +++ b/editor/transform.ts @@ -1,12 +1,17 @@ import type { Rect, Vec2D } from "@core/geometry"; -import type { ArtboardId } from "@core/id"; +import type { ArtboardId, LayerId } from "@core/id"; export type TransformHandle = "body" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw"; -export type TransformTarget = { - type: "artboard"; - id: ArtboardId; -}; +export type TransformTarget = + | { + type: "artboard"; + id: ArtboardId; + } + | { + type: "layer"; + id: LayerId; + }; export type TransformSession = { target: TransformTarget; diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 75f0371..aea87a7 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -3,6 +3,7 @@ import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; import type { Rect, Vec2D } from "@core/geometry"; import type { EditorState } from "@editor/state"; +import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import type { TransformHandle } from "@editor/transform"; import type { PointerInputEvent } from "./pointer"; @@ -22,20 +23,23 @@ export function createTransformControlsInputController(options: { if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false; const editor = options.getEditor(); - const artboardId = editor.selection.artboardId; - if (!artboardId || editor.tools.activeTool !== "select") return false; + if (editor.tools.activeTool !== "select") return false; - const artboard = options.getDocument().artboards.find((candidate) => candidate.id === artboardId); - if (!artboard) return false; + const document = options.getDocument(); + const target = selectedTransformTarget(document, editor.selection); + if (!target) return false; - const handle = hitTestArtboardTransformHandle(event.position, artboard.bounds, editor.viewport); + const bounds = resolveTransformTargetBounds(document, target); + if (!bounds) return false; + + const handle = hitTestArtboardTransformHandle(event.position, bounds, editor.viewport); if (!handle) return false; options.dispatch(commandIds.transformBegin, { - target: { type: "artboard", id: artboard.id }, + target, handle, point: viewportPointToDocumentPoint(event.position, editor.viewport), - initialBounds: artboard.bounds, + initialBounds: bounds, }); return true; }, diff --git a/renderer/selection.ts b/renderer/selection.ts index 6fe712c..84bd2b2 100644 --- a/renderer/selection.ts +++ b/renderer/selection.ts @@ -1,5 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { EditorState } from "@editor/state"; +import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import { clearScreenRect } from "./clear-rect"; import { documentRectToScreenRect } from "./screen-rect"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; @@ -7,13 +8,11 @@ import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; const selectionColor: RgbaColor = [0.1, 0.65, 1, 1]; export function renderSelectionOverlay(context: WebGlRendererContext, document: ImageDocument, editor: EditorState) { - const selectedArtboard = editor.selection.artboardId - ? document.artboards.find((artboard) => artboard.id === editor.selection.artboardId) - : undefined; + const target = selectedTransformTarget(document, editor.selection); + const bounds = target ? resolveTransformTargetBounds(document, target) : undefined; + if (!bounds) return; - if (!selectedArtboard) return; - - const rect = documentRectToScreenRect(context.canvas, selectedArtboard.bounds, editor.viewport); + const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); renderScreenRectStroke(context, rect, 3, selectionColor); } diff --git a/renderer/transform-controls.ts b/renderer/transform-controls.ts index 17833d4..ca3679c 100644 --- a/renderer/transform-controls.ts +++ b/renderer/transform-controls.ts @@ -1,5 +1,6 @@ import type { ImageDocument } from "@core/document"; import type { EditorState } from "@editor/state"; +import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import { clearScreenRect } from "./clear-rect"; import { documentRectToScreenRect } from "./screen-rect"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; @@ -8,13 +9,11 @@ const handleColor: RgbaColor = [1, 1, 1, 1]; const handleBorderColor: RgbaColor = [0.1, 0.65, 1, 1]; export function renderTransformControls(context: WebGlRendererContext, document: ImageDocument, editor: EditorState) { - const selectedArtboard = editor.selection.artboardId - ? document.artboards.find((artboard) => artboard.id === editor.selection.artboardId) - : undefined; + const target = selectedTransformTarget(document, editor.selection); + const bounds = target ? resolveTransformTargetBounds(document, target) : undefined; + if (!bounds) return; - if (!selectedArtboard) return; - - const rect = documentRectToScreenRect(context.canvas, selectedArtboard.bounds, editor.viewport); + const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport); for (const handle of transformHandleRects(rect)) { clearScreenRect(context, handle.border, handleBorderColor); clearScreenRect(context, handle.fill, handleColor); diff --git a/view/App.tsx b/view/App.tsx index a7fc08b..f8f4d6b 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -5,6 +5,7 @@ import { CanvasViewport } from "./CanvasViewport"; import { LayersSheet } from "./LayersSheet"; import { ToolOverlay } from "./ToolOverlay"; import { labelForTool } from "./toolLabels"; +import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { getSelectionSummary } from "./selectionSummary"; import { useAppState } from "./useAppState"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; @@ -20,8 +21,8 @@ export function App({ app }: AppProps) { const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport); const [layersOpen, setLayersOpen] = useState(false); const selectionSummary = getSelectionSummary(state.document, state.editor.selection); - const transformBounds = state.editor.transformSession?.target.type === "artboard" - ? state.document.artboards.find((artboard) => artboard.id === state.editor.transformSession?.target.id)?.bounds + const transformBounds = state.editor.transformSession + ? resolveTransformTargetBounds(state.document, state.editor.transformSession.target) : undefined; return (