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");
|
||||
|
||||
Reference in New Issue
Block a user