diff --git a/commands/transform.test.ts b/commands/transform.test.ts index b411822..47cd891 100644 --- a/commands/transform.test.ts +++ b/commands/transform.test.ts @@ -31,6 +31,36 @@ describe("transform commands", () => { expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 110, h: 100 }); }); + test("shift constrains body moves to the dominant axis", () => { + const started = transformBeginCommand.execute( + { state: artboardState() }, + { target: { type: "artboard", id: "a1" }, handle: "body", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } }, + ); + const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 10, y: 20 }, shiftKey: true }); + + expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 20, w: 100, h: 80 }); + }); + + test("shift constrains corner resizes to the original proportions", () => { + const started = transformBeginCommand.execute( + { state: artboardState() }, + { target: { type: "artboard", id: "a1" }, handle: "se", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } }, + ); + const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 50, y: 1 }, shiftKey: true }); + + expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 150, h: 120 }); + }); + + test("shift keeps the opposite corner anchored during proportional resize", () => { + const started = transformBeginCommand.execute( + { state: artboardState() }, + { target: { type: "artboard", id: "a1" }, handle: "nw", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } }, + ); + const updated = transformUpdateCommand.execute({ state: started }, { point: { x: -50, y: -1 }, shiftKey: true }); + + expect(updated.document.artboards[0]?.bounds).toEqual({ x: -50, y: -40, w: 150, h: 120 }); + }); + test("ends transform session", () => { const started = transformBeginCommand.execute( { state: artboardState() }, diff --git a/commands/transform.ts b/commands/transform.ts index 4c35f81..2d80738 100644 --- a/commands/transform.ts +++ b/commands/transform.ts @@ -13,6 +13,7 @@ export type TransformBeginPayload = { export type TransformUpdatePayload = { point: Vec2D; + shiftKey?: boolean; }; export const transformBeginCommand: Command = { @@ -41,10 +42,15 @@ export const transformUpdateCommand: Command = { const session = state.editor.transformSession; if (!session) return state; - const nextBounds = transformBounds(session.initialBounds, session.handle, { - x: payload.point.x - session.startPoint.x, - y: payload.point.y - session.startPoint.y, - }); + const nextBounds = transformBounds( + session.initialBounds, + session.handle, + { + x: payload.point.x - session.startPoint.x, + y: payload.point.y - session.startPoint.y, + }, + payload.shiftKey === true, + ); return { ...state, @@ -71,8 +77,11 @@ export const transformEndCommand: Command = { export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command[]; -function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D): Rect { - if (handle === "body") return { ...bounds, x: bounds.x + delta.x, y: bounds.y + delta.y }; +function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect { + if (handle === "body") { + const constrainedDelta = constrained ? constrainMoveDelta(delta) : delta; + return { ...bounds, x: bounds.x + constrainedDelta.x, y: bounds.y + constrainedDelta.y }; + } let x = bounds.x; let y = bounds.y; @@ -90,7 +99,31 @@ function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D): R } if (handle.includes("s")) h = bounds.h + delta.y; - return normalizeRect({ x, y, w, h }); + const resized = normalizeRect({ x, y, w, h }); + if (!constrained || !isCornerHandle(handle)) return resized; + + return proportionalCornerResize(bounds, handle, resized); +} + +function constrainMoveDelta(delta: Vec2D): Vec2D { + return Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y }; +} + +function isCornerHandle(handle: TransformHandle) { + return handle.length === 2; +} + +function proportionalCornerResize(initial: Rect, handle: TransformHandle, resized: Rect): Rect { + const aspectRatio = initial.w / initial.h; + const widthScale = resized.w / initial.w; + const heightScale = resized.h / initial.h; + const scale = Math.abs(widthScale - 1) >= Math.abs(heightScale - 1) ? widthScale : heightScale; + const w = Math.max(1, initial.w * scale); + const h = Math.max(1, w / aspectRatio); + const x = handle.includes("w") ? initial.x + initial.w - w : initial.x; + const y = handle.includes("n") ? initial.y + initial.h - h : initial.y; + + return { x, y, w, h }; } function normalizeRect(rect: Rect): Rect { diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts index 006740d..49fc3d7 100644 --- a/input/transform-controls.test.ts +++ b/input/transform-controls.test.ts @@ -41,13 +41,14 @@ describe("transform controls input", () => { }); expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(true); - expect(controller.pointerMove(pointerEvent({ position: { x: 110, y: 120 }, buttons: 1 }))).toBe(true); + expect(controller.pointerMove(pointerEvent({ position: { x: 110, y: 120 }, buttons: 1, shiftKey: true }))).toBe(true); expect(controller.pointerUp(pointerEvent({ position: { x: 110, y: 120 }, buttons: 0 }))).toBe(true); expect(dispatched.map((event) => (event as { commandId: string }).commandId)).toEqual([ commandIds.transformBegin, commandIds.transformUpdate, commandIds.transformEnd, ]); + expect((dispatched[1] as { payload: { shiftKey: boolean } }).payload.shiftKey).toBe(true); }); }); diff --git a/input/transform-controls.ts b/input/transform-controls.ts index aea87a7..dc97134 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -47,7 +47,7 @@ export function createTransformControlsInputController(options: { const editor = options.getEditor(); if (!editor.transformSession) return false; - options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport) }); + options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey }); return true; }, pointerUp() { diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index c44e2cd..21ca4ee 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -2,7 +2,8 @@ import type { Asset } from "@core/asset"; import type { ScreenRect, WebGlRendererContext } from "./types"; export type ImageTextureRenderer = { - render(asset: Asset, rect: ScreenRect): boolean; + syncAssets(assets: readonly Asset[]): void; + render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean; dispose(): void; }; @@ -20,6 +21,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali const positionBuffer = gl.createBuffer(); const texCoordBuffer = gl.createBuffer(); const textures = new Map(); + let disposed = false; if (!positionBuffer || !texCoordBuffer || !samplerLocation) throw new Error("Failed to create image texture renderer"); @@ -27,11 +29,24 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); return { - render(asset, rect) { - const entry = getTextureEntry(context, textures, asset, invalidate); + syncAssets(assets) { + const activeKeys = new Set(assets.map(textureKey)); + for (const [key, entry] of textures) { + if (!activeKeys.has(key)) { + disposeEntry(gl, entry); + textures.delete(key); + } + } + }, + render(asset, rect, clipRect) { + const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; + if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; + + const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); if (entry.status !== "ready") return false; - gl.disable(gl.SCISSOR_TEST); + gl.enable(gl.SCISSOR_TEST); + gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.useProgram(program); @@ -51,13 +66,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.drawArrays(gl.TRIANGLES, 0, 6); gl.disable(gl.BLEND); - gl.enable(gl.SCISSOR_TEST); return true; }, dispose() { - for (const entry of textures.values()) { - if (entry.status === "ready") gl.deleteTexture(entry.texture); - } + disposed = true; + for (const entry of textures.values()) disposeEntry(gl, entry); gl.deleteBuffer(positionBuffer); gl.deleteBuffer(texCoordBuffer); gl.deleteProgram(program); @@ -70,19 +83,24 @@ function getTextureEntry( textures: Map, asset: Asset, invalidate: () => void, + isDisposed: () => boolean, ): TextureEntry { - const cached = textures.get(asset.id); + const key = textureKey(asset); + const cached = textures.get(key); if (cached) return cached; const image = new Image(); - textures.set(asset.id, { status: "loading", image }); + textures.set(key, { status: "loading", image }); image.onload = () => { + if (isDisposed()) return; const texture = createTexture(context.gl, image); - textures.set(asset.id, { status: "ready", texture }); + disposeEntry(context.gl, textures.get(key)); + textures.set(key, { status: "ready", texture }); invalidate(); }; image.onerror = () => { - textures.set(asset.id, { status: "error" }); + if (isDisposed()) return; + textures.set(key, { status: "error" }); invalidate(); }; image.src = asset.source; @@ -90,6 +108,32 @@ function getTextureEntry( return { status: "loading", image }; } +function textureKey(asset: Asset) { + return `${asset.id}:${asset.source}`; +} + +function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry | undefined) { + if (!entry) return; + if (entry.status === "ready") { + gl.deleteTexture(entry.texture); + return; + } + if (entry.status === "loading") { + entry.image.onload = null; + entry.image.onerror = null; + } +} + +function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.w, b.x + b.w); + const y2 = Math.min(a.y + a.h, b.y + b.h); + if (x2 <= x1 || y2 <= y1) return undefined; + + return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; +} + function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) { const texture = gl.createTexture(); if (!texture) throw new Error("Failed to create image texture"); diff --git a/renderer/layers.ts b/renderer/layers.ts index 6d6e05c..dc33cb1 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -5,22 +5,30 @@ import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { clearScreenRect } from "./clear-rect"; import type { ImageTextureRenderer } from "./image-textures"; import { documentRectToScreenRect } from "./screen-rect"; -import type { RgbaColor, WebGlRendererContext } from "./types"; +import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) { for (const artboard of document.artboards) { - for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer); + const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport); + for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect); } } -function renderLayer(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, layer: Layer, imageTextureRenderer: ImageTextureRenderer) { +function renderLayer( + context: WebGlRendererContext, + document: ImageDocument, + viewport: ViewportState, + layer: Layer, + imageTextureRenderer: ImageTextureRenderer, + clipRect: ScreenRect, +) { if (!layer.visible) return; if (layer.type === "group") { - for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer); + for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer, clipRect); return; } @@ -29,8 +37,21 @@ function renderLayer(context: WebGlRendererContext, document: ImageDocument, vie const rect = documentRectToScreenRect(context.canvas, bounds, viewport); const asset = document.assets.find((candidate) => candidate.id === layer.assetId); - if (asset && imageTextureRenderer.render(asset, rect)) return; + if (asset && imageTextureRenderer.render(asset, rect, clipRect)) return; - clearScreenRect(context, rect, imageLayerColor); - clearScreenRect(context, { x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, imageLayerInsetColor); + const fallbackRect = intersectScreenRects(rect, clipRect); + if (!fallbackRect) return; + clearScreenRect(context, fallbackRect, imageLayerColor); + const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, clipRect); + if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); +} + +function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.w, b.x + b.w); + const y2 = Math.min(a.y + a.h, b.y + b.h); + if (x2 <= x1 || y2 <= y1) return undefined; + + return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; } diff --git a/renderer/renderer.ts b/renderer/renderer.ts index c47033b..050fe8f 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -58,6 +58,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke for (const artboard of frame.document.artboards) { renderArtboard(rendererContext, artboard, frame.editor.viewport); } + imageTextureRenderer.syncAssets(frame.document.assets); renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer); renderSelectionOverlay(rendererContext, frame.document, frame.editor);