Enhance canvas functionality by adding media preview capabilities and image upload handling. Introduce compressed image previews during uploads, improve media library integration, and implement retry logic for bridge edge creation. Update dashboard to display media previews and optimize image node handling.
This commit is contained in:
133
components/canvas/__tests__/canvas-delete-handlers.test.tsx
Normal file
133
components/canvas/__tests__/canvas-delete-handlers.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act, useEffect, useRef, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
|
||||
const toastInfoMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
warning: vi.fn(),
|
||||
info: toastInfoMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { useCanvasDeleteHandlers } from "@/components/canvas/canvas-delete-handlers";
|
||||
|
||||
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
|
||||
const latestHandlersRef: {
|
||||
current: ReturnType<typeof useCanvasDeleteHandlers> | null;
|
||||
} = { current: null };
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HookHarnessProps = {
|
||||
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
|
||||
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function HookHarness({
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}: HookHarnessProps) {
|
||||
const [nodes] = useState<RFNode[]>([
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-middle", type: "note", position: { x: 120, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "text", position: { x: 240, y: 0 }, data: {} },
|
||||
]);
|
||||
const [edges] = useState<RFEdge[]>([
|
||||
{ id: "edge-source-middle", source: "node-source", target: "node-middle" },
|
||||
{ id: "edge-middle-target", source: "node-middle", target: "node-target" },
|
||||
]);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
const deletingNodeIds = useRef(new Set<string>());
|
||||
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
|
||||
|
||||
const handlers = useCanvasDeleteHandlers({
|
||||
t: ((key: string) => key) as never,
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes,
|
||||
edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
deletingNodeIds,
|
||||
setAssetBrowserTargetNodeId,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
runRemoveEdgeMutation: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
latestHandlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCanvasDeleteHandlers", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("retries bridge edge creation when the first create fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runBatchRemoveNodesMutation = vi.fn(async () => undefined);
|
||||
const runCreateEdgeMutation = vi
|
||||
.fn(async () => undefined)
|
||||
.mockRejectedValueOnce(new Error("incoming limit reached"));
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
runBatchRemoveNodesMutation={runBatchRemoveNodesMutation}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([
|
||||
{
|
||||
id: "node-middle",
|
||||
type: "note",
|
||||
position: { x: 120, y: 0 },
|
||||
data: {},
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runBatchRemoveNodesMutation).toHaveBeenCalledTimes(1);
|
||||
expect(runCreateEdgeMutation).toHaveBeenCalledTimes(2);
|
||||
expect(toastInfoMock).toHaveBeenCalledWith("canvas.nodesRemoved");
|
||||
});
|
||||
});
|
||||
76
components/canvas/__tests__/canvas-media-utils.test.ts
Normal file
76
components/canvas/__tests__/canvas-media-utils.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCompressedImagePreview } from "@/components/canvas/canvas-media-utils";
|
||||
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => {
|
||||
this.naturalWidth = 4000;
|
||||
this.naturalHeight = 2000;
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("createCompressedImagePreview", () => {
|
||||
const originalImage = globalThis.Image;
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
const drawImage = vi.fn();
|
||||
const toBlob = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("Image", MockImage);
|
||||
URL.createObjectURL = vi.fn(() => "blob:preview") as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = vi.fn() as typeof URL.revokeObjectURL;
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
||||
drawImage,
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(
|
||||
(callback) => {
|
||||
toBlob();
|
||||
callback(new Blob(["preview"], { type: "image/webp" }));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
drawImage.mockReset();
|
||||
toBlob.mockReset();
|
||||
vi.stubGlobal("Image", originalImage);
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
});
|
||||
|
||||
it("clamps dimensions to the configured max edge", async () => {
|
||||
const file = new File(["bytes"], "photo.jpg", { type: "image/jpeg" });
|
||||
|
||||
const preview = await createCompressedImagePreview(file);
|
||||
|
||||
expect(preview.width).toBe(640);
|
||||
expect(preview.height).toBe(320);
|
||||
expect(preview.blob.type).toBe("image/webp");
|
||||
expect(drawImage).toHaveBeenCalledTimes(1);
|
||||
expect(toBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns fallback mime type when encoder does not produce webp", async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback) => {
|
||||
callback(new Blob(["preview"], { type: "image/png" }));
|
||||
});
|
||||
|
||||
const file = new File(["bytes"], "photo.jpg", { type: "image/jpeg" });
|
||||
|
||||
const preview = await createCompressedImagePreview(file);
|
||||
|
||||
expect(preview.blob.type).toBe("image/png");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
|
||||
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
|
||||
import { createCompressedImagePreview } from "@/components/canvas/canvas-media-utils";
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
@@ -20,6 +21,11 @@ vi.mock("@/lib/toast", () => ({
|
||||
|
||||
vi.mock("@/components/canvas/canvas-media-utils", () => ({
|
||||
getImageDimensions: vi.fn(async () => ({ width: 1600, height: 900 })),
|
||||
createCompressedImagePreview: vi.fn(async () => ({
|
||||
blob: new Blob(["preview"], { type: "image/webp" }),
|
||||
width: 640,
|
||||
height: 360,
|
||||
})),
|
||||
}));
|
||||
|
||||
const latestHandlersRef: {
|
||||
@@ -33,6 +39,7 @@ const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
|
||||
type HookHarnessProps = {
|
||||
isSyncOnline?: boolean;
|
||||
generateUploadUrl?: ReturnType<typeof vi.fn>;
|
||||
registerUploadedImageMedia?: ReturnType<typeof vi.fn>;
|
||||
runCreateNodeOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||
runCreateNodeWithEdgeSplitOnlineOnly?: ReturnType<typeof vi.fn>;
|
||||
notifyOfflineUnsupported?: ReturnType<typeof vi.fn>;
|
||||
@@ -44,6 +51,7 @@ type HookHarnessProps = {
|
||||
function HookHarness({
|
||||
isSyncOnline = true,
|
||||
generateUploadUrl = vi.fn(async () => "https://upload.test"),
|
||||
registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const })),
|
||||
runCreateNodeOnlineOnly = vi.fn(async () => "node-1"),
|
||||
runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-1"),
|
||||
notifyOfflineUnsupported = vi.fn(),
|
||||
@@ -58,6 +66,7 @@ function HookHarness({
|
||||
edges,
|
||||
screenToFlowPosition,
|
||||
generateUploadUrl,
|
||||
registerUploadedImageMedia,
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
@@ -78,10 +87,19 @@ describe("useCanvasDrop", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ storageId: "storage-1" }),
|
||||
})));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ storageId: "storage-1" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ storageId: "preview-storage-1" }),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("crypto", {
|
||||
randomUUID: vi.fn(() => "req-1"),
|
||||
});
|
||||
@@ -151,6 +169,7 @@ describe("useCanvasDrop", () => {
|
||||
|
||||
it("creates an image node from a dropped image file", async () => {
|
||||
const generateUploadUrl = vi.fn(async () => "https://upload.test");
|
||||
const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const }));
|
||||
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
|
||||
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
|
||||
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
|
||||
@@ -163,6 +182,7 @@ describe("useCanvasDrop", () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
generateUploadUrl={generateUploadUrl}
|
||||
registerUploadedImageMedia={registerUploadedImageMedia}
|
||||
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
|
||||
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
|
||||
/>,
|
||||
@@ -181,12 +201,17 @@ describe("useCanvasDrop", () => {
|
||||
} as unknown as React.DragEvent);
|
||||
});
|
||||
|
||||
expect(generateUploadUrl).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith("https://upload.test", {
|
||||
expect(generateUploadUrl).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, "https://upload.test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "image/png" },
|
||||
body: file,
|
||||
});
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, "https://upload.test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "image/webp" },
|
||||
body: expect.any(Blob),
|
||||
});
|
||||
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
type: "image",
|
||||
@@ -196,10 +221,13 @@ describe("useCanvasDrop", () => {
|
||||
height: NODE_DEFAULTS.image.height,
|
||||
data: {
|
||||
storageId: "storage-1",
|
||||
previewStorageId: "preview-storage-1",
|
||||
filename: "photo.png",
|
||||
mimeType: "image/png",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
previewWidth: 640,
|
||||
previewHeight: 360,
|
||||
canvasId: "canvas-1",
|
||||
},
|
||||
clientRequestId: "req-1",
|
||||
@@ -208,6 +236,15 @@ describe("useCanvasDrop", () => {
|
||||
"req-1",
|
||||
"node-image",
|
||||
);
|
||||
expect(registerUploadedImageMedia).toHaveBeenCalledWith({
|
||||
canvasId: "canvas-1",
|
||||
nodeId: "node-image",
|
||||
storageId: "storage-1",
|
||||
filename: "photo.png",
|
||||
mimeType: "image/png",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a node from a JSON payload drop", async () => {
|
||||
@@ -267,6 +304,58 @@ describe("useCanvasDrop", () => {
|
||||
expect(syncPendingMoveForClientRequest).toHaveBeenCalledWith("req-1", "node-video");
|
||||
});
|
||||
|
||||
it("continues with original upload when preview generation fails", async () => {
|
||||
vi.mocked(createCompressedImagePreview).mockRejectedValueOnce(
|
||||
new Error("preview failed"),
|
||||
);
|
||||
|
||||
const generateUploadUrl = vi.fn(async () => "https://upload.test");
|
||||
const runCreateNodeOnlineOnly = vi.fn(async () => "node-image");
|
||||
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
generateUploadUrl={generateUploadUrl}
|
||||
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latestHandlersRef.current?.onDrop({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 20,
|
||||
clientY: 10,
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => ""),
|
||||
files: [file],
|
||||
},
|
||||
} as unknown as React.DragEvent);
|
||||
});
|
||||
|
||||
expect(generateUploadUrl).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
storageId: "storage-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runCreateNodeOnlineOnly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.not.objectContaining({
|
||||
previewStorageId: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("splits an intersected persisted edge for sidebar node drops", async () => {
|
||||
const runCreateNodeOnlineOnly = vi.fn(async () => "node-note");
|
||||
const runCreateNodeWithEdgeSplitOnlineOnly = vi.fn(async () => "node-note");
|
||||
|
||||
@@ -18,6 +18,50 @@ import { validateCanvasConnection } from "./canvas-connection-validation";
|
||||
|
||||
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
||||
|
||||
const BRIDGE_CREATE_MAX_ATTEMPTS = 4;
|
||||
const BRIDGE_CREATE_INITIAL_BACKOFF_MS = 40;
|
||||
|
||||
function waitFor(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isRetryableBridgeCreateError(error: unknown): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
if (
|
||||
message.includes("unauthorized") ||
|
||||
message.includes("forbidden") ||
|
||||
message.includes("not authenticated")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("limit") ||
|
||||
message.includes("duplicate") ||
|
||||
message.includes("already exists") ||
|
||||
message.includes("conflict") ||
|
||||
message.includes("concurrent") ||
|
||||
message.includes("tempor") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("try again") ||
|
||||
message.includes("retry") ||
|
||||
message.includes("stale")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type UseCanvasDeleteHandlersParams = {
|
||||
t: ToastTranslations;
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -136,13 +180,37 @@ export function useCanvasDeleteHandlers({
|
||||
liveNodes,
|
||||
liveEdges,
|
||||
);
|
||||
const connectedDeletedEdges = getConnectedEdges(deletedNodes, liveEdges);
|
||||
const remainingNodes = liveNodes.filter(
|
||||
(node) => !removedTargetSet.has(node.id),
|
||||
);
|
||||
let remainingEdges = liveEdges.filter(
|
||||
(edge) => !connectedDeletedEdges.includes(edge) && edge.className !== "temp",
|
||||
);
|
||||
const bridgeEdgesCreatedInThisRun: RFEdge[] = [];
|
||||
|
||||
const getRemainingNodes = () =>
|
||||
nodesRef.current.filter((node) => !removedTargetSet.has(node.id));
|
||||
|
||||
const getRemainingEdges = () => {
|
||||
const fromRefs = edgesRef.current.filter((edge) => {
|
||||
if (edge.className === "temp") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (removedTargetSet.has(edge.source) || removedTargetSet.has(edge.target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const deduped = [...fromRefs];
|
||||
const dedupedKeys = new Set(fromRefs.map((edge) => edgeKey(edge)));
|
||||
for (const createdEdge of bridgeEdgesCreatedInThisRun) {
|
||||
const key = edgeKey(createdEdge);
|
||||
if (dedupedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
deduped.push(createdEdge);
|
||||
dedupedKeys.add(key);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
};
|
||||
|
||||
if (bridgeCreates.length > 0) {
|
||||
console.info("[Canvas] computed bridge edges for delete", {
|
||||
@@ -168,70 +236,111 @@ export function useCanvasDeleteHandlers({
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
});
|
||||
if (remainingEdges.some((edge) => edgeKey(edge) === bridgeKey)) {
|
||||
console.info("[Canvas] skipped duplicate bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const validationError = validateCanvasConnection(
|
||||
{
|
||||
source: bridgeCreate.sourceNodeId,
|
||||
target: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle ?? null,
|
||||
targetHandle: bridgeCreate.targetHandle ?? null,
|
||||
},
|
||||
remainingNodes,
|
||||
remainingEdges,
|
||||
undefined,
|
||||
{ includeOptimisticEdges: true },
|
||||
);
|
||||
let created = false;
|
||||
|
||||
if (validationError) {
|
||||
console.info("[Canvas] skipped invalid bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
validationError,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= BRIDGE_CREATE_MAX_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
const remainingNodes = getRemainingNodes();
|
||||
const remainingEdges = getRemainingEdges();
|
||||
|
||||
try {
|
||||
console.info("[Canvas] creating bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
});
|
||||
if (remainingEdges.some((edge) => edgeKey(edge) === bridgeKey)) {
|
||||
console.info("[Canvas] skipped duplicate bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||
targetNodeId: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
});
|
||||
remainingEdges = [
|
||||
...remainingEdges,
|
||||
const validationError = validateCanvasConnection(
|
||||
{
|
||||
id: `bridge-${bridgeCreate.sourceNodeId}-${bridgeCreate.targetNodeId}-${remainingEdges.length}`,
|
||||
source: bridgeCreate.sourceNodeId,
|
||||
target: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle ?? null,
|
||||
targetHandle: bridgeCreate.targetHandle ?? null,
|
||||
},
|
||||
remainingNodes,
|
||||
remainingEdges,
|
||||
undefined,
|
||||
{ includeOptimisticEdges: true },
|
||||
);
|
||||
|
||||
if (validationError) {
|
||||
console.info("[Canvas] skipped invalid bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
validationError,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
console.info("[Canvas] creating bridge edge after delete", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
attempt,
|
||||
});
|
||||
|
||||
await runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||
targetNodeId: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
});
|
||||
|
||||
bridgeEdgesCreatedInThisRun.push({
|
||||
id: `bridge-${bridgeCreate.sourceNodeId}-${bridgeCreate.targetNodeId}-${bridgeEdgesCreatedInThisRun.length}`,
|
||||
source: bridgeCreate.sourceNodeId,
|
||||
target: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
},
|
||||
];
|
||||
} catch (error: unknown) {
|
||||
console.error("[Canvas] bridge edge create failed", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
created = true;
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const retryable = isRetryableBridgeCreateError(error);
|
||||
const isLastAttempt = attempt >= BRIDGE_CREATE_MAX_ATTEMPTS;
|
||||
|
||||
if (!retryable || isLastAttempt) {
|
||||
console.error("[Canvas] bridge edge create failed", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
attempt,
|
||||
maxAttempts: BRIDGE_CREATE_MAX_ATTEMPTS,
|
||||
retryable,
|
||||
error: errorMessage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const backoffMs =
|
||||
BRIDGE_CREATE_INITIAL_BACKOFF_MS * 2 ** (attempt - 1);
|
||||
|
||||
console.warn("[Canvas] bridge edge create retry scheduled", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
bridgeCreate,
|
||||
attempt,
|
||||
nextAttempt: attempt + 1,
|
||||
backoffMs,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
await waitFor(backoffMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -240,7 +349,11 @@ export function useCanvasDeleteHandlers({
|
||||
// Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält.
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("[Canvas] batch remove failed", error);
|
||||
console.error("[Canvas] batch remove failed", {
|
||||
canvasId,
|
||||
deletedNodeIds: idsToDelete,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
for (const id of idsToDelete) {
|
||||
deletingNodeIds.current.delete(id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,74 @@
|
||||
export async function getImageDimensions(
|
||||
file: File,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
const image = await decodeImageFile(file);
|
||||
return { width: image.naturalWidth, height: image.naturalHeight };
|
||||
}
|
||||
|
||||
export type ImagePreviewOptions = {
|
||||
maxEdge?: number;
|
||||
format?: string;
|
||||
quality?: number;
|
||||
};
|
||||
|
||||
export type CompressedImagePreview = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export async function createCompressedImagePreview(
|
||||
file: File,
|
||||
options: ImagePreviewOptions = {},
|
||||
): Promise<CompressedImagePreview> {
|
||||
const maxEdge = options.maxEdge ?? 640;
|
||||
const format = options.format ?? "image/webp";
|
||||
const quality = options.quality ?? 0.75;
|
||||
const image = await decodeImageFile(file);
|
||||
const sourceWidth = image.naturalWidth;
|
||||
const sourceHeight = image.naturalHeight;
|
||||
|
||||
if (!sourceWidth || !sourceHeight) {
|
||||
throw new Error("Could not read image dimensions");
|
||||
}
|
||||
|
||||
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
|
||||
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Could not create canvas context");
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => {
|
||||
if (!result) {
|
||||
reject(new Error("Could not encode preview image"));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
format,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
blob,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
async function decodeImageFile(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const image = new window.Image();
|
||||
@@ -15,7 +83,7 @@ export async function getImageDimensions(
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ width, height });
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
|
||||
@@ -99,6 +99,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
|
||||
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>());
|
||||
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
||||
string | null
|
||||
@@ -516,6 +517,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
edges,
|
||||
screenToFlowPosition,
|
||||
generateUploadUrl,
|
||||
registerUploadedImageMedia,
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
|
||||
@@ -15,6 +15,7 @@ import ColorAdjustNode from "./nodes/color-adjust-node";
|
||||
import LightAdjustNode from "./nodes/light-adjust-node";
|
||||
import DetailAdjustNode from "./nodes/detail-adjust-node";
|
||||
import RenderNode from "./nodes/render-node";
|
||||
import CropNode from "./nodes/crop-node";
|
||||
|
||||
/**
|
||||
* Node-Type-Map für React Flow.
|
||||
@@ -40,5 +41,6 @@ export const nodeTypes = {
|
||||
"color-adjust": ColorAdjustNode,
|
||||
"light-adjust": LightAdjustNode,
|
||||
"detail-adjust": DetailAdjustNode,
|
||||
crop: CropNode,
|
||||
render: RenderNode,
|
||||
} as const;
|
||||
|
||||
@@ -43,6 +43,7 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
||||
"color-adjust": { minWidth: 300, minHeight: 760 },
|
||||
"light-adjust": { minWidth: 300, minHeight: 860 },
|
||||
"detail-adjust": { minWidth: 300, minHeight: 820 },
|
||||
crop: { minWidth: 320, minHeight: 520 },
|
||||
render: { minWidth: 260, minHeight: 300, keepAspectRatio: true },
|
||||
text: { minWidth: 220, minHeight: 90 },
|
||||
note: { minWidth: 200, minHeight: 90 },
|
||||
|
||||
740
components/canvas/nodes/crop-node.tsx
Normal file
740
components/canvas/nodes/crop-node.tsx
Normal file
@@ -0,0 +1,740 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { Crop } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
||||
import {
|
||||
collectPipelineFromGraph,
|
||||
getSourceImageFromGraph,
|
||||
shouldFastPathPreviewPipeline,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import {
|
||||
normalizeCropNodeData,
|
||||
type CropFitMode,
|
||||
type CropNodeData,
|
||||
type CropResizeMode,
|
||||
} from "@/lib/image-pipeline/crop-node-data";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
type CropNodeViewData = CropNodeData & {
|
||||
_status?: string;
|
||||
_statusMessage?: string;
|
||||
};
|
||||
|
||||
export type CropNodeType = Node<CropNodeViewData, "crop">;
|
||||
|
||||
const PREVIEW_PIPELINE_TYPES = new Set([
|
||||
"curves",
|
||||
"color-adjust",
|
||||
"light-adjust",
|
||||
"detail-adjust",
|
||||
"crop",
|
||||
]);
|
||||
|
||||
const CUSTOM_DIMENSION_FALLBACK = 1024;
|
||||
const CROP_MIN_SIZE = 0.01;
|
||||
|
||||
type CropHandle = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
||||
|
||||
type CropInteractionState = {
|
||||
pointerId: number;
|
||||
mode: "move" | "resize";
|
||||
handle?: CropHandle;
|
||||
startX: number;
|
||||
startY: number;
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
startCrop: CropNodeData["crop"];
|
||||
keepAspect: boolean;
|
||||
aspectRatio: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function parseNumberInput(value: string): number | null {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function clampCropRect(rect: CropNodeData["crop"]): CropNodeData["crop"] {
|
||||
const width = clamp(rect.width, CROP_MIN_SIZE, 1);
|
||||
const height = clamp(rect.height, CROP_MIN_SIZE, 1);
|
||||
const x = clamp(rect.x, 0, Math.max(0, 1 - width));
|
||||
const y = clamp(rect.y, 0, Math.max(0, 1 - height));
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function resizeCropRect(
|
||||
start: CropNodeData["crop"],
|
||||
handle: CropHandle,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
keepAspect: boolean,
|
||||
aspectRatio: number,
|
||||
): CropNodeData["crop"] {
|
||||
const startRight = start.x + start.width;
|
||||
const startBottom = start.y + start.height;
|
||||
|
||||
if (!keepAspect) {
|
||||
let left = start.x;
|
||||
let top = start.y;
|
||||
let right = startRight;
|
||||
let bottom = startBottom;
|
||||
|
||||
if (handle.includes("w")) {
|
||||
left = clamp(start.x + deltaX, 0, startRight - CROP_MIN_SIZE);
|
||||
}
|
||||
if (handle.includes("e")) {
|
||||
right = clamp(startRight + deltaX, start.x + CROP_MIN_SIZE, 1);
|
||||
}
|
||||
if (handle.includes("n")) {
|
||||
top = clamp(start.y + deltaY, 0, startBottom - CROP_MIN_SIZE);
|
||||
}
|
||||
if (handle.includes("s")) {
|
||||
bottom = clamp(startBottom + deltaY, start.y + CROP_MIN_SIZE, 1);
|
||||
}
|
||||
|
||||
return clampCropRect({
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
});
|
||||
}
|
||||
|
||||
const aspect = Math.max(CROP_MIN_SIZE, aspectRatio);
|
||||
|
||||
if (handle === "e" || handle === "w") {
|
||||
const centerY = start.y + start.height / 2;
|
||||
const maxWidth = handle === "e" ? 1 - start.x : startRight;
|
||||
const minWidth = Math.max(CROP_MIN_SIZE, CROP_MIN_SIZE * aspect);
|
||||
const rawWidth = handle === "e" ? start.width + deltaX : start.width - deltaX;
|
||||
const width = clamp(rawWidth, minWidth, Math.max(minWidth, maxWidth));
|
||||
const height = width / aspect;
|
||||
const y = clamp(centerY - height / 2, 0, Math.max(0, 1 - height));
|
||||
const x = handle === "e" ? start.x : startRight - width;
|
||||
return clampCropRect({ x, y, width, height });
|
||||
}
|
||||
|
||||
if (handle === "n" || handle === "s") {
|
||||
const centerX = start.x + start.width / 2;
|
||||
const maxHeight = handle === "s" ? 1 - start.y : startBottom;
|
||||
const minHeight = Math.max(CROP_MIN_SIZE, CROP_MIN_SIZE / aspect);
|
||||
const rawHeight = handle === "s" ? start.height + deltaY : start.height - deltaY;
|
||||
const height = clamp(rawHeight, minHeight, Math.max(minHeight, maxHeight));
|
||||
const width = height * aspect;
|
||||
const x = clamp(centerX - width / 2, 0, Math.max(0, 1 - width));
|
||||
const y = handle === "s" ? start.y : startBottom - height;
|
||||
return clampCropRect({ x, y, width, height });
|
||||
}
|
||||
|
||||
const movesRight = handle.includes("e");
|
||||
const movesDown = handle.includes("s");
|
||||
const rawWidth = start.width + (movesRight ? deltaX : -deltaX);
|
||||
const rawHeight = start.height + (movesDown ? deltaY : -deltaY);
|
||||
|
||||
const widthByHeight = rawHeight * aspect;
|
||||
const heightByWidth = rawWidth / aspect;
|
||||
const useWidth = Math.abs(rawWidth - start.width) >= Math.abs(rawHeight - start.height);
|
||||
let width = useWidth ? rawWidth : widthByHeight;
|
||||
let height = useWidth ? heightByWidth : rawHeight;
|
||||
|
||||
const anchorX = movesRight ? start.x : startRight;
|
||||
const anchorY = movesDown ? start.y : startBottom;
|
||||
const maxWidth = movesRight ? 1 - anchorX : anchorX;
|
||||
const maxHeight = movesDown ? 1 - anchorY : anchorY;
|
||||
const maxScaleByWidth = maxWidth / Math.max(CROP_MIN_SIZE, width);
|
||||
const maxScaleByHeight = maxHeight / Math.max(CROP_MIN_SIZE, height);
|
||||
const maxScale = Math.min(1, maxScaleByWidth, maxScaleByHeight);
|
||||
width *= maxScale;
|
||||
height *= maxScale;
|
||||
|
||||
const minScaleByWidth = Math.max(1, CROP_MIN_SIZE / Math.max(CROP_MIN_SIZE, width));
|
||||
const minScaleByHeight = Math.max(1, CROP_MIN_SIZE / Math.max(CROP_MIN_SIZE, height));
|
||||
const minScale = Math.max(minScaleByWidth, minScaleByHeight);
|
||||
width *= minScale;
|
||||
height *= minScale;
|
||||
|
||||
const x = movesRight ? anchorX : anchorX - width;
|
||||
const y = movesDown ? anchorY : anchorY - height;
|
||||
return clampCropRect({ x, y, width, height });
|
||||
}
|
||||
|
||||
export default function CropNode({ id, data, selected, width }: NodeProps<CropNodeType>) {
|
||||
const tNodes = useTranslations("nodes");
|
||||
const { queueNodeDataUpdate } = useCanvasSync();
|
||||
const graph = useCanvasGraph();
|
||||
|
||||
const normalizeData = useCallback((value: unknown) => normalizeCropNodeData(value), []);
|
||||
const previewAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const interactionRef = useRef<CropInteractionState | null>(null);
|
||||
const { localData, updateLocalData } = useNodeLocalData<CropNodeData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 40,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "crop",
|
||||
});
|
||||
|
||||
const sourceUrl = useMemo(
|
||||
() =>
|
||||
getSourceImageFromGraph(graph, {
|
||||
nodeId: id,
|
||||
isSourceNode: (node) =>
|
||||
node.type === "image" ||
|
||||
node.type === "ai-image" ||
|
||||
node.type === "asset" ||
|
||||
node.type === "video" ||
|
||||
node.type === "ai-video",
|
||||
getSourceImageFromNode: (node) => {
|
||||
const sourceData = (node.data ?? {}) as Record<string, unknown>;
|
||||
const directUrl = typeof sourceData.url === "string" ? sourceData.url : null;
|
||||
if (directUrl && directUrl.length > 0) {
|
||||
return directUrl;
|
||||
}
|
||||
const previewUrl =
|
||||
typeof sourceData.previewUrl === "string" ? sourceData.previewUrl : null;
|
||||
return previewUrl && previewUrl.length > 0 ? previewUrl : null;
|
||||
},
|
||||
}),
|
||||
[graph, id],
|
||||
);
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const collected = collectPipelineFromGraph(graph, {
|
||||
nodeId: id,
|
||||
isPipelineNode: (node) => PREVIEW_PIPELINE_TYPES.has(node.type ?? ""),
|
||||
});
|
||||
|
||||
return collected.map((step) => {
|
||||
if (step.nodeId === id && step.type === "crop") {
|
||||
return {
|
||||
...step,
|
||||
params: localData,
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
}, [graph, id, localData]);
|
||||
|
||||
const previewDebounceMs = shouldFastPathPreviewPipeline(steps, graph.previewNodeDataOverrides)
|
||||
? 16
|
||||
: undefined;
|
||||
|
||||
const { canvasRef, hasSource, isRendering, previewAspectRatio, error } = usePipelinePreview({
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth: Math.max(250, Math.round(width ?? 300)),
|
||||
includeHistogram: false,
|
||||
debounceMs: previewDebounceMs,
|
||||
previewScale: 0.5,
|
||||
maxPreviewWidth: 720,
|
||||
maxDevicePixelRatio: 1.25,
|
||||
});
|
||||
|
||||
const outputResolutionLabel =
|
||||
localData.resize.mode === "custom"
|
||||
? `${localData.resize.width ?? CUSTOM_DIMENSION_FALLBACK} x ${localData.resize.height ?? CUSTOM_DIMENSION_FALLBACK}`
|
||||
: tNodes("adjustments.crop.sourceResolution");
|
||||
|
||||
const updateCropField = (field: keyof CropNodeData["crop"], value: number) => {
|
||||
updateLocalData((current) =>
|
||||
normalizeCropNodeData({
|
||||
...current,
|
||||
crop: {
|
||||
...current.crop,
|
||||
[field]: value,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const updateResize = (next: Partial<CropNodeData["resize"]>) => {
|
||||
updateLocalData((current) =>
|
||||
normalizeCropNodeData({
|
||||
...current,
|
||||
resize: {
|
||||
...current.resize,
|
||||
...next,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const beginCropInteraction = useCallback(
|
||||
(event: ReactPointerEvent<HTMLElement>, mode: "move" | "resize", handle?: CropHandle) => {
|
||||
if (!hasSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewElement = previewAreaRef.current;
|
||||
if (!previewElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = previewElement.getBoundingClientRect();
|
||||
if (bounds.width <= 0 || bounds.height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const pointerId = Number.isFinite(event.pointerId) ? event.pointerId : 1;
|
||||
event.currentTarget.setPointerCapture?.(pointerId);
|
||||
|
||||
interactionRef.current = {
|
||||
pointerId,
|
||||
mode,
|
||||
handle,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
previewWidth: bounds.width,
|
||||
previewHeight: bounds.height,
|
||||
startCrop: localData.crop,
|
||||
keepAspect: localData.resize.keepAspect,
|
||||
aspectRatio: localData.crop.width / Math.max(CROP_MIN_SIZE, localData.crop.height),
|
||||
};
|
||||
},
|
||||
[hasSource, localData.crop, localData.resize.keepAspect],
|
||||
);
|
||||
|
||||
const updateCropInteraction = useCallback(
|
||||
(event: ReactPointerEvent<HTMLElement>) => {
|
||||
const activeInteraction = interactionRef.current;
|
||||
if (!activeInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = Number.isFinite(event.pointerId) ? event.pointerId : 1;
|
||||
if (pointerId !== activeInteraction.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const deltaX = (event.clientX - activeInteraction.startX) / activeInteraction.previewWidth;
|
||||
const deltaY = (event.clientY - activeInteraction.startY) / activeInteraction.previewHeight;
|
||||
const nextCrop =
|
||||
activeInteraction.mode === "move"
|
||||
? clampCropRect({
|
||||
...activeInteraction.startCrop,
|
||||
x: activeInteraction.startCrop.x + deltaX,
|
||||
y: activeInteraction.startCrop.y + deltaY,
|
||||
})
|
||||
: resizeCropRect(
|
||||
activeInteraction.startCrop,
|
||||
activeInteraction.handle ?? "se",
|
||||
deltaX,
|
||||
deltaY,
|
||||
activeInteraction.keepAspect,
|
||||
activeInteraction.aspectRatio,
|
||||
);
|
||||
|
||||
updateLocalData((current) =>
|
||||
normalizeCropNodeData({
|
||||
...current,
|
||||
crop: nextCrop,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[updateLocalData],
|
||||
);
|
||||
|
||||
const endCropInteraction = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
const activeInteraction = interactionRef.current;
|
||||
if (!activeInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = Number.isFinite(event.pointerId) ? event.pointerId : 1;
|
||||
if (pointerId !== activeInteraction.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.releasePointerCapture?.(pointerId);
|
||||
interactionRef.current = null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
nodeType="crop"
|
||||
selected={selected}
|
||||
status={data._status}
|
||||
statusMessage={data._statusMessage}
|
||||
className="min-w-[320px] border-violet-500/30"
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
||||
/>
|
||||
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-700 dark:text-violet-400">
|
||||
<Crop className="h-3.5 w-3.5" />
|
||||
{tNodes("adjustments.crop.title")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
ref={previewAreaRef}
|
||||
data-testid="crop-preview-area"
|
||||
className="relative overflow-hidden rounded-md border border-border bg-muted/30"
|
||||
style={{ aspectRatio: `${Math.max(0.25, previewAspectRatio)}` }}
|
||||
>
|
||||
{!hasSource ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-3 text-center text-[11px] text-muted-foreground">
|
||||
{tNodes("adjustments.crop.previewHint")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasSource ? <canvas ref={canvasRef} className="h-full w-full" /> : null}
|
||||
|
||||
{hasSource ? (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div
|
||||
data-testid="crop-overlay"
|
||||
className="nodrag pointer-events-auto absolute cursor-move border border-violet-300 bg-violet-500/10"
|
||||
style={{
|
||||
left: `${localData.crop.x * 100}%`,
|
||||
top: `${localData.crop.y * 100}%`,
|
||||
width: `${localData.crop.width * 100}%`,
|
||||
height: `${localData.crop.height * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "move")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
>
|
||||
<div className="pointer-events-none absolute left-1/3 top-0 h-full w-px bg-violet-200/70" />
|
||||
<div className="pointer-events-none absolute left-2/3 top-0 h-full w-px bg-violet-200/70" />
|
||||
<div className="pointer-events-none absolute left-0 top-1/3 h-px w-full bg-violet-200/70" />
|
||||
<div className="pointer-events-none absolute left-0 top-2/3 h-px w-full bg-violet-200/70" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-nw"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize rounded-full border border-background bg-violet-500"
|
||||
style={{ left: `${localData.crop.x * 100}%`, top: `${localData.crop.y * 100}%` }}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "nw")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-n"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${(localData.crop.x + localData.crop.width / 2) * 100}%`,
|
||||
top: `${localData.crop.y * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "n")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-ne"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nesw-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${(localData.crop.x + localData.crop.width) * 100}%`,
|
||||
top: `${localData.crop.y * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "ne")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-e"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${(localData.crop.x + localData.crop.width) * 100}%`,
|
||||
top: `${(localData.crop.y + localData.crop.height / 2) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "e")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-se"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${(localData.crop.x + localData.crop.width) * 100}%`,
|
||||
top: `${(localData.crop.y + localData.crop.height) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "se")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-s"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${(localData.crop.x + localData.crop.width / 2) * 100}%`,
|
||||
top: `${(localData.crop.y + localData.crop.height) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "s")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-sw"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nesw-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${localData.crop.x * 100}%`,
|
||||
top: `${(localData.crop.y + localData.crop.height) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "sw")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="crop-handle-w"
|
||||
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-full border border-background bg-violet-500"
|
||||
style={{
|
||||
left: `${localData.crop.x * 100}%`,
|
||||
top: `${(localData.crop.y + localData.crop.height / 2) * 100}%`,
|
||||
}}
|
||||
onPointerDown={(event) => beginCropInteraction(event, "resize", "w")}
|
||||
onPointerMove={updateCropInteraction}
|
||||
onPointerUp={endCropInteraction}
|
||||
onPointerCancel={endCropInteraction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isRendering ? (
|
||||
<div className="absolute right-1 top-1 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{tNodes("adjustments.crop.previewRendering")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.outputResolutionLabel")}</span>
|
||||
<span className="font-medium text-foreground">{outputResolutionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.fields.x")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localData.crop.x}
|
||||
onChange={(event) => {
|
||||
const parsed = parseNumberInput(event.target.value);
|
||||
if (parsed === null) return;
|
||||
updateCropField("x", clamp(parsed, 0, 1));
|
||||
}}
|
||||
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.fields.y")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localData.crop.y}
|
||||
onChange={(event) => {
|
||||
const parsed = parseNumberInput(event.target.value);
|
||||
if (parsed === null) return;
|
||||
updateCropField("y", clamp(parsed, 0, 1));
|
||||
}}
|
||||
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.fields.width")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.01}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localData.crop.width}
|
||||
onChange={(event) => {
|
||||
const parsed = parseNumberInput(event.target.value);
|
||||
if (parsed === null) return;
|
||||
updateCropField("width", clamp(parsed, 0.01, 1));
|
||||
}}
|
||||
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.fields.height")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0.01}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localData.crop.height}
|
||||
onChange={(event) => {
|
||||
const parsed = parseNumberInput(event.target.value);
|
||||
if (parsed === null) return;
|
||||
updateCropField("height", clamp(parsed, 0.01, 1));
|
||||
}}
|
||||
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground">{tNodes("adjustments.crop.resizeMode")}</div>
|
||||
<Select
|
||||
value={localData.resize.mode}
|
||||
onValueChange={(value: CropResizeMode) => {
|
||||
updateResize({ mode: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="nodrag">
|
||||
<SelectItem value="source">{tNodes("adjustments.crop.resizeModes.source")}</SelectItem>
|
||||
<SelectItem value="custom">{tNodes("adjustments.crop.resizeModes.custom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground">{tNodes("adjustments.crop.fitMode")}</div>
|
||||
<Select
|
||||
value={localData.resize.fit}
|
||||
onValueChange={(value: CropFitMode) => {
|
||||
updateResize({ fit: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="nodrag">
|
||||
<SelectItem value="cover">{tNodes("adjustments.crop.fitModes.cover")}</SelectItem>
|
||||
<SelectItem value="contain">{tNodes("adjustments.crop.fitModes.contain")}</SelectItem>
|
||||
<SelectItem value="fill">{tNodes("adjustments.crop.fitModes.fill")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localData.resize.mode === "custom" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.fields.outputWidth")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16384}
|
||||
step={1}
|
||||
value={localData.resize.width ?? CUSTOM_DIMENSION_FALLBACK}
|
||||
onChange={(event) => {
|
||||
const parsed = parseNumberInput(event.target.value);
|
||||
if (parsed === null) return;
|
||||
updateResize({ width: Math.round(clamp(parsed, 1, 16384)) });
|
||||
}}
|
||||
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{tNodes("adjustments.crop.fields.outputHeight")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16384}
|
||||
step={1}
|
||||
value={localData.resize.height ?? CUSTOM_DIMENSION_FALLBACK}
|
||||
onChange={(event) => {
|
||||
const parsed = parseNumberInput(event.target.value);
|
||||
if (parsed === null) return;
|
||||
updateResize({ height: Math.round(clamp(parsed, 1, 16384)) });
|
||||
}}
|
||||
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localData.resize.keepAspect}
|
||||
onChange={(event) => updateResize({ keepAspect: event.target.checked })}
|
||||
className="nodrag h-3.5 w-3.5 rounded border-input"
|
||||
/>
|
||||
{tNodes("adjustments.crop.keepAspect")}
|
||||
</label>
|
||||
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{tNodes("adjustments.crop.cropSummary", {
|
||||
x: formatPercent(localData.crop.x),
|
||||
y: formatPercent(localData.crop.y),
|
||||
width: formatPercent(localData.crop.width),
|
||||
height: formatPercent(localData.crop.height),
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-[11px] text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import { useTranslations } from "next-intl";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import {
|
||||
MediaLibraryDialog,
|
||||
type MediaLibraryItem,
|
||||
} from "@/components/media/media-library-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,9 +25,17 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||
import {
|
||||
emitDashboardSnapshotCacheInvalidationSignal,
|
||||
invalidateDashboardSnapshotForLastSignedInUser,
|
||||
} from "@/lib/dashboard-snapshot-cache";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import { useMutation } from "convex/react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
createCompressedImagePreview,
|
||||
getImageDimensions,
|
||||
} from "@/components/canvas/canvas-media-utils";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = new Set([
|
||||
"image/png",
|
||||
@@ -34,45 +46,22 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||
|
||||
type ImageNodeData = {
|
||||
canvasId?: string;
|
||||
storageId?: string;
|
||||
previewStorageId?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
_status?: string;
|
||||
_statusMessage?: string;
|
||||
};
|
||||
|
||||
export type ImageNode = Node<ImageNodeData, "image">;
|
||||
|
||||
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const image = new window.Image();
|
||||
|
||||
image.onload = () => {
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
if (!width || !height) {
|
||||
reject(new Error("Could not read image dimensions"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ width, height });
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error("Could not decode image"));
|
||||
};
|
||||
|
||||
image.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export default function ImageNode({
|
||||
id,
|
||||
data,
|
||||
@@ -82,6 +71,7 @@ export default function ImageNode({
|
||||
}: NodeProps<ImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia);
|
||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadPhase, setUploadPhase] = useState<"idle" | "uploading" | "syncing">("idle");
|
||||
@@ -89,9 +79,48 @@ export default function ImageNode({
|
||||
const [pendingUploadStorageId, setPendingUploadStorageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [mediaLibraryPhase, setMediaLibraryPhase] = useState<
|
||||
"idle" | "applying" | "syncing"
|
||||
>("idle");
|
||||
const [pendingMediaLibraryStorageId, setPendingMediaLibraryStorageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const [isMediaLibraryOpen, setIsMediaLibraryOpen] = useState(false);
|
||||
const hasAutoSizedRef = useRef(false);
|
||||
const canvasId = data.canvasId as Id<"canvases"> | undefined;
|
||||
const isOptimisticNodeId =
|
||||
typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX);
|
||||
const isNodeStable = !isOptimisticNodeId;
|
||||
|
||||
const registerUploadInMediaLibrary = useCallback(
|
||||
(args: {
|
||||
storageId: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
nodeId?: Id<"nodes">;
|
||||
}) => {
|
||||
if (!canvasId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void registerUploadedImageMedia({
|
||||
canvasId,
|
||||
storageId: args.storageId as Id<"_storage">,
|
||||
nodeId: args.nodeId,
|
||||
filename: args.filename,
|
||||
mimeType: args.mimeType,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
}).catch((error: unknown) => {
|
||||
console.warn("[ImageNode] registerUploadedImageMedia failed", error);
|
||||
});
|
||||
},
|
||||
[canvasId, registerUploadedImageMedia],
|
||||
);
|
||||
|
||||
const isPendingUploadSynced =
|
||||
pendingUploadStorageId !== null &&
|
||||
@@ -99,7 +128,35 @@ export default function ImageNode({
|
||||
typeof data.url === "string" &&
|
||||
data.url.length > 0;
|
||||
const isWaitingForCanvasSync = pendingUploadStorageId !== null && !isPendingUploadSynced;
|
||||
const isPendingMediaLibrarySynced =
|
||||
pendingMediaLibraryStorageId !== null &&
|
||||
data.storageId === pendingMediaLibraryStorageId &&
|
||||
typeof data.url === "string" &&
|
||||
data.url.length > 0;
|
||||
const isWaitingForMediaLibrarySync =
|
||||
pendingMediaLibraryStorageId !== null && !isPendingMediaLibrarySynced;
|
||||
const isUploading = uploadPhase !== "idle" || isWaitingForCanvasSync;
|
||||
const isApplyingMediaLibrary =
|
||||
mediaLibraryPhase !== "idle" || isWaitingForMediaLibrarySync;
|
||||
const isNodeLoading = isUploading || isApplyingMediaLibrary;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingUploadSynced) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingUploadStorageId(null);
|
||||
setUploadPhase("idle");
|
||||
}, [isPendingUploadSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingMediaLibrarySynced) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingMediaLibraryStorageId(null);
|
||||
setMediaLibraryPhase("idle");
|
||||
}, [isPendingMediaLibrarySynced]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof id === "string" && id.startsWith(OPTIMISTIC_NODE_PREFIX)) {
|
||||
@@ -170,6 +227,13 @@ export default function ImageNode({
|
||||
|
||||
try {
|
||||
let dimensions: { width: number; height: number } | undefined;
|
||||
let previewUpload:
|
||||
| {
|
||||
previewStorageId: string;
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
dimensions = await getImageDimensions(file);
|
||||
} catch {
|
||||
@@ -208,6 +272,30 @@ export default function ImageNode({
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const preview = await createCompressedImagePreview(file);
|
||||
const previewUploadUrl = await generateUploadUrl();
|
||||
const previewUploadResult = await fetch(previewUploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": preview.blob.type || "image/webp" },
|
||||
body: preview.blob,
|
||||
});
|
||||
|
||||
if (!previewUploadResult.ok) {
|
||||
throw new Error(`Preview upload failed: ${previewUploadResult.status}`);
|
||||
}
|
||||
|
||||
const { storageId: previewStorageId } =
|
||||
(await previewUploadResult.json()) as { storageId: string };
|
||||
previewUpload = {
|
||||
previewStorageId,
|
||||
previewWidth: preview.width,
|
||||
previewHeight: preview.height,
|
||||
};
|
||||
} catch (previewError) {
|
||||
console.warn("[ImageNode] preview generation/upload failed", previewError);
|
||||
}
|
||||
|
||||
setUploadProgress(100);
|
||||
setPendingUploadStorageId(storageId);
|
||||
setUploadPhase("syncing");
|
||||
@@ -216,6 +304,7 @@ export default function ImageNode({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
storageId,
|
||||
...(previewUpload ?? {}),
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
|
||||
@@ -235,6 +324,22 @@ export default function ImageNode({
|
||||
});
|
||||
}
|
||||
|
||||
const nodeIdForRegistration =
|
||||
typeof id === "string" && !id.startsWith(OPTIMISTIC_NODE_PREFIX)
|
||||
? (id as Id<"nodes">)
|
||||
: undefined;
|
||||
|
||||
registerUploadInMediaLibrary({
|
||||
storageId,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
nodeId: nodeIdForRegistration,
|
||||
});
|
||||
invalidateDashboardSnapshotForLastSignedInUser();
|
||||
emitDashboardSnapshotCacheInvalidationSignal();
|
||||
|
||||
toast.success(t('canvas.imageUploaded'));
|
||||
setUploadPhase("idle");
|
||||
} catch (err) {
|
||||
@@ -254,16 +359,69 @@ export default function ImageNode({
|
||||
isUploading,
|
||||
queueNodeDataUpdate,
|
||||
queueNodeResize,
|
||||
registerUploadInMediaLibrary,
|
||||
status.isOffline,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePickFromMediaLibrary = useCallback(
|
||||
async (item: MediaLibraryItem) => {
|
||||
if (isNodeLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMediaLibraryPhase("applying");
|
||||
setPendingMediaLibraryStorageId(item.storageId);
|
||||
|
||||
try {
|
||||
await queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
storageId: item.storageId,
|
||||
previewStorageId: item.previewStorageId,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
previewWidth: item.previewWidth,
|
||||
previewHeight: item.previewHeight,
|
||||
},
|
||||
});
|
||||
setMediaLibraryPhase("syncing");
|
||||
|
||||
if (typeof item.width === "number" && typeof item.height === "number") {
|
||||
const targetSize = computeMediaNodeSize("image", {
|
||||
intrinsicWidth: item.width,
|
||||
intrinsicHeight: item.height,
|
||||
});
|
||||
|
||||
await queueNodeResize({
|
||||
nodeId: id as Id<"nodes">,
|
||||
width: targetSize.width,
|
||||
height: targetSize.height,
|
||||
});
|
||||
}
|
||||
|
||||
setIsMediaLibraryOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to apply media library image", error);
|
||||
setPendingMediaLibraryStorageId(null);
|
||||
setMediaLibraryPhase("idle");
|
||||
toast.error(
|
||||
t('canvas.uploadFailed'),
|
||||
error instanceof Error ? error.message : undefined,
|
||||
);
|
||||
}
|
||||
},
|
||||
[id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!data.url && !isUploading) {
|
||||
if (!data.url && !isNodeLoading) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [data.url, isUploading]);
|
||||
}, [data.url, isNodeLoading]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -294,26 +452,31 @@ export default function ImageNode({
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (isUploading) return;
|
||||
if (isNodeLoading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
uploadFile(file);
|
||||
}
|
||||
},
|
||||
[isUploading, uploadFile]
|
||||
[isNodeLoading, uploadFile]
|
||||
);
|
||||
|
||||
const handleReplace = useCallback(() => {
|
||||
if (isUploading) return;
|
||||
if (isNodeLoading) return;
|
||||
fileInputRef.current?.click();
|
||||
}, [isUploading]);
|
||||
}, [isNodeLoading]);
|
||||
|
||||
const showFilename = Boolean(data.filename && data.url);
|
||||
const effectiveUploadProgress = isWaitingForCanvasSync ? 100 : uploadProgress;
|
||||
const uploadingLabel =
|
||||
isWaitingForCanvasSync
|
||||
const effectiveUploadProgress = isUploading
|
||||
? isWaitingForCanvasSync
|
||||
? 100
|
||||
: uploadProgress
|
||||
: 100;
|
||||
const uploadingLabel = isUploading
|
||||
? isWaitingForCanvasSync
|
||||
? "100% — wird synchronisiert…"
|
||||
: "Wird hochgeladen…";
|
||||
: "Wird hochgeladen…"
|
||||
: "Bild wird uebernommen…";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -347,18 +510,18 @@ export default function ImageNode({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||
{data.url && (
|
||||
<button
|
||||
onClick={handleReplace}
|
||||
disabled={isUploading}
|
||||
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Ersetzen
|
||||
<button
|
||||
onClick={handleReplace}
|
||||
disabled={isNodeLoading}
|
||||
className="nodrag text-xs text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Ersetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
|
||||
{isUploading ? (
|
||||
{isNodeLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{uploadingLabel}</span>
|
||||
@@ -397,6 +560,21 @@ export default function ImageNode({
|
||||
<span className="mb-1 text-lg">📁</span>
|
||||
<span>Klicken oder hierhin ziehen</span>
|
||||
<span className="mt-0.5 text-xs">PNG, JPG, WebP</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isNodeStable) {
|
||||
return;
|
||||
}
|
||||
setIsMediaLibraryOpen(true);
|
||||
}}
|
||||
disabled={isNodeLoading || !isNodeStable}
|
||||
className="nodrag mt-3 inline-flex items-center rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isNodeStable ? "Aus Mediathek" : "Mediathek wird vorbereitet..."}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -410,7 +588,7 @@ export default function ImageNode({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isUploading}
|
||||
disabled={isNodeLoading}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -453,6 +631,13 @@ export default function ImageNode({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<MediaLibraryDialog
|
||||
open={isMediaLibraryOpen}
|
||||
onOpenChange={setIsMediaLibraryOpen}
|
||||
onPick={handlePickFromMediaLibrary}
|
||||
pickCtaLabel="Uebernehmen"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
CANVAS_NODE_DND_MIME,
|
||||
} from "@/lib/canvas-connection-policy";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
import {
|
||||
emitDashboardSnapshotCacheInvalidationSignal,
|
||||
invalidateDashboardSnapshotForLastSignedInUser,
|
||||
} from "@/lib/dashboard-snapshot-cache";
|
||||
import {
|
||||
isCanvasNodeType,
|
||||
type CanvasNodeType,
|
||||
@@ -18,7 +22,10 @@ import {
|
||||
logCanvasConnectionDebug,
|
||||
normalizeHandle,
|
||||
} from "./canvas-helpers";
|
||||
import { getImageDimensions } from "./canvas-media-utils";
|
||||
import {
|
||||
createCompressedImagePreview,
|
||||
getImageDimensions,
|
||||
} from "./canvas-media-utils";
|
||||
|
||||
type UseCanvasDropParams = {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -34,6 +41,15 @@ type UseCanvasDropParams = {
|
||||
}>;
|
||||
screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number };
|
||||
generateUploadUrl: () => Promise<string>;
|
||||
registerUploadedImageMedia?: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
nodeId?: Id<"nodes">;
|
||||
storageId: Id<"_storage">;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) => Promise<{ ok: true }>;
|
||||
runCreateNodeOnlineOnly: (args: {
|
||||
canvasId: Id<"canvases">;
|
||||
type: CanvasNodeType;
|
||||
@@ -99,6 +115,7 @@ export function useCanvasDrop({
|
||||
edges,
|
||||
screenToFlowPosition,
|
||||
generateUploadUrl,
|
||||
registerUploadedImageMedia,
|
||||
runCreateNodeOnlineOnly,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
notifyOfflineUnsupported,
|
||||
@@ -127,6 +144,13 @@ export function useCanvasDrop({
|
||||
if (file.type.startsWith("image/")) {
|
||||
try {
|
||||
let dimensions: { width: number; height: number } | undefined;
|
||||
let previewUpload:
|
||||
| {
|
||||
previewStorageId: string;
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
dimensions = await getImageDimensions(file);
|
||||
} catch {
|
||||
@@ -145,13 +169,38 @@ export function useCanvasDrop({
|
||||
}
|
||||
|
||||
const { storageId } = (await result.json()) as { storageId: string };
|
||||
|
||||
try {
|
||||
const preview = await createCompressedImagePreview(file);
|
||||
const previewUploadUrl = await generateUploadUrl();
|
||||
const previewUploadResult = await fetch(previewUploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": preview.blob.type || "image/webp" },
|
||||
body: preview.blob,
|
||||
});
|
||||
|
||||
if (!previewUploadResult.ok) {
|
||||
throw new Error("Preview upload failed");
|
||||
}
|
||||
|
||||
const { storageId: previewStorageId } =
|
||||
(await previewUploadResult.json()) as { storageId: string };
|
||||
previewUpload = {
|
||||
previewStorageId,
|
||||
previewWidth: preview.width,
|
||||
previewHeight: preview.height,
|
||||
};
|
||||
} catch (previewError) {
|
||||
console.warn("[Canvas] dropped image preview generation/upload failed", previewError);
|
||||
}
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
|
||||
void runCreateNodeOnlineOnly({
|
||||
const createNodePromise = runCreateNodeOnlineOnly({
|
||||
canvasId,
|
||||
type: "image",
|
||||
positionX: position.x,
|
||||
@@ -160,18 +209,60 @@ export function useCanvasDrop({
|
||||
height: NODE_DEFAULTS.image.height,
|
||||
data: {
|
||||
storageId,
|
||||
...(previewUpload ?? {}),
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
|
||||
canvasId,
|
||||
},
|
||||
clientRequestId,
|
||||
}).then((realId) => {
|
||||
});
|
||||
|
||||
void createNodePromise.then((realId) => {
|
||||
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
|
||||
(error: unknown) => {
|
||||
console.error("[Canvas] drop createNode syncPendingMove failed", error);
|
||||
},
|
||||
);
|
||||
|
||||
invalidateDashboardSnapshotForLastSignedInUser();
|
||||
emitDashboardSnapshotCacheInvalidationSignal();
|
||||
|
||||
if (!registerUploadedImageMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
void registerUploadedImageMedia({
|
||||
canvasId,
|
||||
nodeId: realId,
|
||||
storageId: storageId as Id<"_storage">,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
}).catch((error: unknown) => {
|
||||
console.warn("[Canvas] dropped image media registration failed", error);
|
||||
});
|
||||
}, () => {
|
||||
if (!registerUploadedImageMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
void registerUploadedImageMedia({
|
||||
canvasId,
|
||||
storageId: storageId as Id<"_storage">,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
width: dimensions?.width,
|
||||
height: dimensions?.height,
|
||||
})
|
||||
.then(() => {
|
||||
invalidateDashboardSnapshotForLastSignedInUser();
|
||||
emitDashboardSnapshotCacheInvalidationSignal();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn("[Canvas] dropped image media registration failed", error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to upload dropped file:", error);
|
||||
@@ -298,6 +389,7 @@ export function useCanvasDrop({
|
||||
canvasId,
|
||||
edges,
|
||||
generateUploadUrl,
|
||||
registerUploadedImageMedia,
|
||||
isSyncOnline,
|
||||
notifyOfflineUnsupported,
|
||||
runCreateNodeWithEdgeSplitOnlineOnly,
|
||||
|
||||
38
components/media/__tests__/media-preview-utils.test.ts
Normal file
38
components/media/__tests__/media-preview-utils.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
collectMediaStorageIdsForResolution,
|
||||
resolveMediaPreviewUrl,
|
||||
} from "@/components/media/media-preview-utils";
|
||||
|
||||
describe("media-preview-utils", () => {
|
||||
it("collects preview ids first and includes original ids as fallback", () => {
|
||||
const ids = collectMediaStorageIdsForResolution([
|
||||
{ storageId: "orig-1", previewStorageId: "preview-1" },
|
||||
{ storageId: "orig-2" },
|
||||
]);
|
||||
|
||||
expect(ids).toEqual(["preview-1", "orig-1", "orig-2"]);
|
||||
});
|
||||
|
||||
it("resolves preview url first and falls back to original url", () => {
|
||||
const previewFirst = resolveMediaPreviewUrl(
|
||||
{ storageId: "orig-1", previewStorageId: "preview-1" },
|
||||
{
|
||||
"preview-1": "https://cdn.example.com/preview.webp",
|
||||
"orig-1": "https://cdn.example.com/original.jpg",
|
||||
},
|
||||
);
|
||||
|
||||
expect(previewFirst).toBe("https://cdn.example.com/preview.webp");
|
||||
|
||||
const fallbackToOriginal = resolveMediaPreviewUrl(
|
||||
{ storageId: "orig-1", previewStorageId: "preview-1" },
|
||||
{
|
||||
"orig-1": "https://cdn.example.com/original.jpg",
|
||||
},
|
||||
);
|
||||
|
||||
expect(fallbackToOriginal).toBe("https://cdn.example.com/original.jpg");
|
||||
});
|
||||
});
|
||||
272
components/media/media-library-dialog.tsx
Normal file
272
components/media/media-library-dialog.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { AlertCircle, ImageIcon, Loader2 } from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
collectMediaStorageIdsForResolution,
|
||||
resolveMediaPreviewUrl,
|
||||
} from "@/components/media/media-preview-utils";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export type MediaLibraryMetadataItem = {
|
||||
storageId: Id<"_storage">;
|
||||
previewStorageId?: Id<"_storage">;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
sourceCanvasId: Id<"canvases">;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type MediaLibraryItem = MediaLibraryMetadataItem & {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type MediaLibraryDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPick?: (item: MediaLibraryItem) => void | Promise<void>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
limit?: number;
|
||||
pickCtaLabel?: string;
|
||||
};
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
if (typeof limit !== "number" || !Number.isFinite(limit)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(limit)));
|
||||
}
|
||||
|
||||
function formatDimensions(width: number | undefined, height: number | undefined): string | null {
|
||||
if (typeof width !== "number" || typeof height !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${width} x ${height}px`;
|
||||
}
|
||||
|
||||
export function MediaLibraryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onPick,
|
||||
title = "Mediathek",
|
||||
description = "Waehle ein Bild aus deiner LemonSpace-Mediathek.",
|
||||
limit,
|
||||
pickCtaLabel = "Auswaehlen",
|
||||
}: MediaLibraryDialogProps) {
|
||||
const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]);
|
||||
const metadata = useAuthQuery(
|
||||
api.dashboard.listMediaLibrary,
|
||||
open ? { limit: normalizedLimit } : "skip",
|
||||
);
|
||||
const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia);
|
||||
|
||||
const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({});
|
||||
const [isResolvingUrls, setIsResolvingUrls] = useState(false);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const [pendingPickStorageId, setPendingPickStorageId] = useState<Id<"_storage"> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
async function run() {
|
||||
if (!open) {
|
||||
setUrlMap({});
|
||||
setUrlError(null);
|
||||
setIsResolvingUrls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageIds = collectMediaStorageIdsForResolution(metadata);
|
||||
if (storageIds.length === 0) {
|
||||
setUrlMap({});
|
||||
setUrlError(null);
|
||||
setIsResolvingUrls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResolvingUrls(true);
|
||||
setUrlError(null);
|
||||
|
||||
try {
|
||||
const resolved = await resolveUrls({ storageIds });
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
setUrlMap(resolved);
|
||||
} catch (error) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
setUrlMap({});
|
||||
setUrlError(error instanceof Error ? error.message : "URLs konnten nicht geladen werden.");
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsResolvingUrls(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [metadata, open, resolveUrls]);
|
||||
|
||||
const items: MediaLibraryItem[] = useMemo(() => {
|
||||
if (!metadata) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return metadata.map((item) => ({
|
||||
...item,
|
||||
url: resolveMediaPreviewUrl(item, urlMap),
|
||||
}));
|
||||
}, [metadata, urlMap]);
|
||||
|
||||
const isMetadataLoading = open && metadata === undefined;
|
||||
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
||||
const isPreviewMode = typeof onPick !== "function";
|
||||
|
||||
async function handlePick(item: MediaLibraryItem): Promise<void> {
|
||||
if (!onPick || pendingPickStorageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingPickStorageId(item.storageId);
|
||||
try {
|
||||
await onPick(item);
|
||||
} finally {
|
||||
setPendingPickStorageId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[320px] overflow-y-auto pr-1">
|
||||
{isInitialLoading ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div key={index} className="overflow-hidden rounded-lg border">
|
||||
<div className="aspect-square animate-pulse bg-muted" />
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : urlError ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm font-medium">Mediathek konnte nicht geladen werden</p>
|
||||
<p className="max-w-md text-xs text-muted-foreground">{urlError}</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Keine Medien vorhanden</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sobald du Bilder hochlaedst oder generierst, erscheinen sie hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const dimensions = formatDimensions(item.width, item.height);
|
||||
const isPickingThis = pendingPickStorageId === item.storageId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.storageId}
|
||||
className="group flex flex-col overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted/50">
|
||||
{item.url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.filename ?? "Mediathek-Bild"}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 p-2">
|
||||
<p className="truncate text-xs font-medium" title={item.filename}>
|
||||
{item.filename ?? "Unbenanntes Bild"}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{dimensions ?? "Groesse unbekannt"}
|
||||
</p>
|
||||
|
||||
{isPreviewMode ? (
|
||||
<p className="mt-auto text-[11px] text-muted-foreground">Nur Vorschau</p>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-2 h-7"
|
||||
onClick={() => void handlePick(item)}
|
||||
disabled={Boolean(pendingPickStorageId)}
|
||||
>
|
||||
{isPickingThis ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Wird uebernommen...
|
||||
</>
|
||||
) : (
|
||||
pickCtaLabel
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
36
components/media/media-preview-utils.ts
Normal file
36
components/media/media-preview-utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
type MediaPreviewReference<TStorageId extends string = string> = {
|
||||
storageId: TStorageId;
|
||||
previewStorageId?: TStorageId;
|
||||
};
|
||||
|
||||
export function collectMediaStorageIdsForResolution<TStorageId extends string>(
|
||||
items: readonly MediaPreviewReference<TStorageId>[],
|
||||
): TStorageId[] {
|
||||
const ordered = new Set<TStorageId>();
|
||||
|
||||
for (const item of items) {
|
||||
const preferredId = item.previewStorageId ?? item.storageId;
|
||||
if (preferredId) {
|
||||
ordered.add(preferredId);
|
||||
}
|
||||
if (item.storageId) {
|
||||
ordered.add(item.storageId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ordered];
|
||||
}
|
||||
|
||||
export function resolveMediaPreviewUrl(
|
||||
item: MediaPreviewReference,
|
||||
urlMap: Record<string, string | undefined>,
|
||||
): string | undefined {
|
||||
if (item.previewStorageId) {
|
||||
const previewUrl = urlMap[item.previewStorageId];
|
||||
if (previewUrl) {
|
||||
return previewUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return urlMap[item.storageId];
|
||||
}
|
||||
Reference in New Issue
Block a user