diff --git a/package-lock.json b/package-lock.json index ec7fc5d3..ab3cc8ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tailwindcss/postcss": "^4.1.17", "@xyflow/react": "^12.9.3", "autoprefixer": "^10.4.22", + "jszip": "^3.10.1", "konva": "^10.0.12", "next": "^16.0.6", "postcss": "^8.5.6", @@ -22,6 +23,7 @@ "zustand": "^5.0.9" }, "devDependencies": { + "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -1057,6 +1059,16 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -1072,7 +1084,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1298,7 +1309,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -1369,6 +1379,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1446,7 +1462,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1757,6 +1772,18 @@ "node": ">= 14" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1766,6 +1793,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1826,6 +1859,18 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -1865,8 +1910,16 @@ "url": "https://github.com/sponsors/lavrton" } ], + "license": "MIT" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "license": "MIT", - "peer": true + "dependencies": { + "immediate": "~3.0.5" + } }, "node_modules/lightningcss": { "version": "1.30.2", @@ -2319,6 +2372,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2369,7 +2428,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2385,6 +2443,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -2399,7 +2463,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2453,6 +2516,27 @@ "react": "^19.2.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -2507,6 +2591,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -2594,6 +2684,21 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2798,6 +2903,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index aaacbb19..849139af 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tailwindcss/postcss": "^4.1.17", "@xyflow/react": "^12.9.3", "autoprefixer": "^10.4.22", + "jszip": "^3.10.1", "konva": "^10.0.12", "next": "^16.0.6", "postcss": "^8.5.6", @@ -23,6 +24,7 @@ "zustand": "^5.0.9" }, "devDependencies": { + "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/src/components/MultiSelectToolbar.tsx b/src/components/MultiSelectToolbar.tsx index 7651915e..6cb4fbc1 100644 --- a/src/components/MultiSelectToolbar.tsx +++ b/src/components/MultiSelectToolbar.tsx @@ -2,7 +2,14 @@ import { useReactFlow } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; -import { useMemo } from "react"; +import { useMemo, useCallback } from "react"; +import JSZip from "jszip"; +import type { + ImageInputNodeData, + AnnotationNodeData, + NanoBananaNodeData, + OutputNodeData, +} from "@/types"; const STACK_GAP = 20; @@ -156,6 +163,58 @@ export function MultiSelectToolbar() { removeNodesFromGroup(nodeIds); }; + const handleDownloadImages = useCallback(async () => { + // Extract images from selected nodes based on node type + const images: { data: string; name: string }[] = []; + + selectedNodes.forEach((node, index) => { + let imageData: string | null = null; + + switch (node.type) { + case "imageInput": + imageData = (node.data as ImageInputNodeData).image; + break; + case "annotation": + imageData = (node.data as AnnotationNodeData).outputImage; + break; + case "nanoBanana": + imageData = (node.data as NanoBananaNodeData).outputImage; + break; + case "output": + imageData = (node.data as OutputNodeData).image; + break; + } + + if (imageData) { + images.push({ + data: imageData, + name: `image-${index + 1}.png`, + }); + } + }); + + if (images.length === 0) return; + + // Create ZIP file + const zip = new JSZip(); + images.forEach(({ data, name }) => { + // Remove data URL prefix to get raw base64 + const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); + zip.file(name, base64Data, { base64: true }); + }); + + // Generate and download + const blob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `images-${Date.now()}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [selectedNodes]); + if (!toolbarPosition || selectedNodes.length < 2) return null; return ( @@ -220,6 +279,20 @@ export function MultiSelectToolbar() { )} + + {/* Separator */} +
+ + {/* Download images button */} + ); } diff --git a/src/components/edges/EditableEdge.tsx b/src/components/edges/EditableEdge.tsx index c1b0644d..8e7d0477 100644 --- a/src/components/edges/EditableEdge.tsx +++ b/src/components/edges/EditableEdge.tsx @@ -16,12 +16,12 @@ interface EdgeData extends WorkflowEdgeData { offsetY?: number; } -// Colors for different connection types +// Colors for different connection types (dimmed for softer appearance) const EDGE_COLORS = { - image: "#10b981", // Green for image connections - prompt: "#3b82f6", // Blue for prompt connections - default: "#94a3b8", // Gray for unknown - pause: "#f97316", // Orange for paused edges + image: "#0d9668", // Dimmed green for image connections + prompt: "#2563eb", // Dimmed blue for prompt connections + default: "#64748b", // Dimmed gray for unknown + pause: "#ea580c", // Dimmed orange for paused edges }; export function EditableEdge({ @@ -38,6 +38,7 @@ export function EditableEdge({ data, sourceHandleId, targetHandleId, + source, target, }: EdgeProps) { const { setEdges } = useReactFlow(); @@ -46,6 +47,15 @@ export function EditableEdge({ const nodes = useWorkflowStore((state) => state.nodes); const [isDragging, setIsDragging] = useState(false); + // Check if any node is selected and if this edge is connected to it + const isConnectedToSelection = useMemo(() => { + const selectedNodes = nodes.filter((n) => n.selected); + if (selectedNodes.length === 0) return false; // No selection, show all dimmed + + // Check if this edge connects to any selected node + return selectedNodes.some((n) => n.id === source || n.id === target); + }, [nodes, source, target]); + const edgeData = data as EdgeData | undefined; const offsetX = edgeData?.offsetX ?? 0; const offsetY = edgeData?.offsetY ?? 0; @@ -71,8 +81,12 @@ export function EditableEdge({ return EDGE_COLORS.default; }, [hasPause, sourceHandleId, targetHandleId]); - // Generate a unique gradient ID for this edge - const gradientId = `pulse-gradient-${id}`; + // Generate a unique gradient ID based on edge color and selection state + const gradientId = useMemo(() => { + const colorKey = hasPause ? "pause" : (sourceHandleId || targetHandleId || "default"); + const selectionKey = isConnectedToSelection ? "active" : "dimmed"; + return `edge-gradient-${colorKey}-${selectionKey}-${id}`; + }, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection, id]); // Calculate the path based on edge style const [edgePath, labelX, labelY] = useMemo(() => { @@ -167,13 +181,22 @@ export function EditableEdge({ return ( <> + {/* SVG gradient definition for bright-dim-bright effect */} +You have unsaved changes