collective

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

diff --git a/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.spec.ts b/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.spec.ts@@ -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;+}diff --git a/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.ts b/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.ts--- a/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.ts+++ b/frontend/libs/components/moodboard/src/lib/canvas/interactions/usePasteHandler.ts@@ -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 });diff --git a/frontend/libs/components/moodboard/src/lib/canvas/useMoodboardImageEntry.tsx b/frontend/libs/components/moodboard/src/lib/canvas/useMoodboardImageEntry.tsx--- a/frontend/libs/components/moodboard/src/lib/canvas/useMoodboardImageEntry.tsx+++ b/frontend/libs/components/moodboard/src/lib/canvas/useMoodboardImageEntry.tsx@@ -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(),