Browse / github.com/storytold/artcraft / patch
fix(moodboard): center pasted images after panning and zooming
fix(moodboard): center pasted images after panning and zooming Fixes pasted images landing off-screen after panning or zooming by converting the stage center to canvas coordinates. Validation: 45 moodboard tests pass, including 7 new tests. Three regression cases fail before the fix. Scoped strict TypeScript, ESLint and Prettier pass. Nx module-boundary checking skipped without a cached graph. No native clipboard or full Tauri run. Separate CI commit includes the conditional Netlify guard from #2477 for the retired docs deployment.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- umairrrkhan (imported from pull request #2478, open)
- Base
7a2935cf9e3922e90e4ed5f3556b1cb107013148- Size
- +130 −5 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f52b4f6c8f71681dde5911cd
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.spec.ts frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.ts frontend/libs/components/moodboard/src/lib/canvas/useMoodboardImageEntry.tsx
new file mode 100644@@ -0,0 +1,127 @@+import { act, cleanup, renderHook } from "@testing-library/react";+import type Konva from "konva";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { useMoodboardStore } from "../MoodboardStore";+import { useMoodboardHistoryStore } from "../MoodboardHistoryStore";+import { usePasteHandler } from "./usePasteHandler";++vi.mock("../../boards/measureMedia", () => ({+ measureImage: vi.fn().mockResolvedValue({ w: 200, h: 100 }),+}));++describe("moodboard clipboard image placement", () => {+ beforeEach(() => {+ useMoodboardStore.setState({+ nodes: {},+ rootOrder: [],+ selectedIds: new Set(),+ });+ useMoodboardHistoryStore.getState().clear();+ vi.stubGlobal(+ "URL",+ class extends URL {+ static createObjectURL() {+ return "blob:clipboard-image";+ }+ },+ );+ });++ afterEach(() => {+ cleanup();+ vi.unstubAllGlobals();+ });++ it.each([+ { name: "default viewport", x: 0, y: 0, zoom: 1 },+ { name: "panned viewport", x: -1200, y: 900, zoom: 1 },+ { name: "zoomed-in viewport", x: 0, y: 0, zoom: 2 },+ { name: "panned and zoomed-out viewport", x: 600, y: -300, zoom: 0.5 },+ ])("centers pasted images in a $name", async ({ x, y, zoom }) => {+ mountPasteHandler({ x, y, zoom });++ const event = await pasteImage();++ expect(event.defaultPrevented).toBe(true);+ const nodes = Object.values(useMoodboardStore.getState().nodes);+ expect(nodes).toHaveLength(1);+ const node = nodes[0];+ // Project the real store node back to screen space: its center should+ // match the visible stage center regardless of the viewport transform.+ expect((node.x + node.width / 2) * zoom + x).toBe(500);+ expect((node.y + node.height / 2) * zoom + y).toBe(300);+ });++ it("retains the fallback position before the stage is mounted", async () => {+ renderHook(() =>+ usePasteHandler({ sendToGeneration: vi.fn() }, true, { current: null }),+ );++ await pasteImage();++ const node = Object.values(useMoodboardStore.getState().nodes)[0];+ expect(node.x + node.width / 2).toBe(400);+ expect(node.y + node.height / 2).toBe(400);+ });++ it("does not intercept paste while inactive", async () => {+ mountPasteHandler({ active: false });++ const event = await pasteImage();++ expect(event.defaultPrevented).toBe(false);+ expect(useMoodboardStore.getState().rootOrder).toEqual([]);+ });++ it("leaves image pastes in text inputs alone", async () => {+ mountPasteHandler({});+ const input = document.createElement("textarea");+ document.body.appendChild(input);+ try {+ const event = await pasteImage(input);+ expect(event.defaultPrevented).toBe(false);+ expect(useMoodboardStore.getState().rootOrder).toEqual([]);+ } finally {+ input.remove();+ }+ });+});++function mountPasteHandler({+ x = 0,+ y = 0,+ zoom = 1,+ active = true,+}: {+ x?: number;+ y?: number;+ zoom?: number;+ active?: boolean;+}) {+ // Only the stage's geometry API is needed; jsdom cannot render Konva.+ const stage = {+ width: () => 1000,+ height: () => 600,+ x: () => x,+ y: () => y,+ scaleX: () => zoom,+ scaleY: () => zoom,+ } as Konva.Stage;+ return renderHook(() =>+ usePasteHandler({ sendToGeneration: vi.fn() }, active, { current: stage }),+ );+}++async function pasteImage(target: EventTarget = window) {+ const file = new File(["image"], "clipboard.png", { type: "image/png" });+ const event = new Event("paste", { bubbles: true, cancelable: true });+ Object.defineProperty(event, "clipboardData", {+ value: {+ items: [{ kind: "file", type: file.type, getAsFile: () => file }],+ },+ });+ await act(async () => {+ target.dispatchEvent(event);+ });+ return event;+}@@ -1,6 +1,7 @@ import { useEffect } from "react"; import Konva from "konva"; import { addCanvasImageFromFile } from "../addCanvasImageFile";+import { stageCenter } from "../useMoodboardImageEntry"; import type { MoodboardAdapter } from "../../adapter"; // Listens for paste events while the moodboard page is mounted. For each@@ -33,10 +34,7 @@ export const usePasteHandler = ( if (fileItems.length === 0) return; e.preventDefault(); - const stage = stageRef.current;- const center = stage- ? { x: stage.width() / 2, y: stage.height() / 2 }- : { x: 400, y: 400 };+ const center = stageCenter(stageRef.current); for (const file of fileItems) { await addCanvasImageFromFile({ file, position: center, adapter });@@ -7,7 +7,7 @@ import { measureImage, measureVideo } from "../boards/measureMedia"; import { addCanvasImageFromFile } from "./addCanvasImageFile"; import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter"; -const stageCenter = (stage: Konva.Stage | null): Vec2 => {+export const stageCenter = (stage: Konva.Stage | null): Vec2 => { if (!stage) return { x: 400, y: 400 }; return { x: (stage.width() / 2 - stage.x()) / stage.scaleX(),