diff --git a/package-lock.json b/package-lock.json index 38eba4f..3e1a46f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "ffmpeg-static": "^5.2.0", "framer-motion": "^11.15.0", + "jszip": "^3.10.1", "lucide-react": "^0.468.0", "next": "^15.1.6", "react": "^19.0.0", @@ -34,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.16", + "@types/jszip": "^3.4.0", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", @@ -2309,6 +2311,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://mirrors.tencent.com/npm/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/node": { "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", @@ -5384,6 +5396,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://mirrors.tencent.com/npm/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5999,6 +6017,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://mirrors.tencent.com/npm/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/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://mirrors.tencent.com/npm/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/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://mirrors.tencent.com/npm/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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6091,6 +6157,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://mirrors.tencent.com/npm/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7015,6 +7090,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://mirrors.tencent.com/npm/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7922,6 +8003,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://mirrors.tencent.com/npm/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", diff --git a/package.json b/package.json index ea1e810..8abdeff 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "ffmpeg-static": "^5.2.0", "framer-motion": "^11.15.0", + "jszip": "^3.10.1", "lucide-react": "^0.468.0", "next": "^15.1.6", "react": "^19.0.0", @@ -38,6 +39,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.16", + "@types/jszip": "^3.4.0", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/(dashboard)/tools/texture-atlas/page.tsx b/src/app/(dashboard)/tools/texture-atlas/page.tsx index cb55a11..83565c0 100644 --- a/src/app/(dashboard)/tools/texture-atlas/page.tsx +++ b/src/app/(dashboard)/tools/texture-atlas/page.tsx @@ -25,7 +25,7 @@ export default function TextureAtlasPage() { const timer = setTimeout(() => { pack(); - }, 300); + }, 500); return () => clearTimeout(timer); }, [sprites, config, pack]); diff --git a/src/components/tools/atlas/AtlasConfigPanel.tsx b/src/components/tools/atlas/AtlasConfigPanel.tsx index ba1e83a..bc51c18 100644 --- a/src/components/tools/atlas/AtlasConfigPanel.tsx +++ b/src/components/tools/atlas/AtlasConfigPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback } from "react"; +import JSZip from "jszip"; import { Settings2, Box, @@ -171,8 +172,8 @@ export function AtlasConfigPanel() { document.body.removeChild(link); }, [result, config.format]); - const downloadMetadata = useCallback(() => { - if (!result) return; + const getMetadataInfo = useCallback(() => { + if (!result) return null; let content: string; let filename: string; @@ -194,6 +195,14 @@ export function AtlasConfigPanel() { mimeType = "application/json"; } + return { content, filename, mimeType }; + }, [result, config.format, config.outputFormat]); + + const downloadMetadata = useCallback(() => { + const info = getMetadataInfo(); + if (!info) return; + + const { content, filename, mimeType } = info; const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -203,19 +212,39 @@ export function AtlasConfigPanel() { link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); - }, [result, config.format, config.outputFormat]); + }, [getMetadataInfo]); const downloadAll = useCallback(async () => { if (!result?.imageDataUrl) return; - // Download image - downloadImage(); - - // Small delay then download metadata - setTimeout(() => { - downloadMetadata(); - }, 100); - }, [result, downloadImage, downloadMetadata]); + const info = getMetadataInfo(); + if (!info) return; + + try { + const zip = new JSZip(); + const imageFilename = `atlas.${config.format}`; + + // Add image to zip + const base64Data = result.imageDataUrl.split(",")[1]; + zip.file(imageFilename, base64Data, { base64: true }); + + // Add metadata to zip + zip.file(info.filename, info.content); + + // Generate and download zip + const blob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "texture-atlas.zip"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error("Failed to create zip:", error); + } + }, [result, config.format, getMetadataInfo]); // Check if can process const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering"; diff --git a/src/components/tools/atlas/CanvasPreview.tsx b/src/components/tools/atlas/CanvasPreview.tsx index 2287fed..5a0013d 100644 --- a/src/components/tools/atlas/CanvasPreview.tsx +++ b/src/components/tools/atlas/CanvasPreview.tsx @@ -37,8 +37,10 @@ export function CanvasPreview() { const { t } = useSafeTranslation(); const containerRef = useRef(null); const canvasRef = useRef(null); + const imageCacheRef = useRef<{ url: string; image: HTMLImageElement } | null>(null); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const [isPanning, setIsPanning] = useState(false); + const [hasMoved, setHasMoved] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const { @@ -48,8 +50,10 @@ export function CanvasPreview() { progress, previewScale, previewOffset, + selectedSpriteIds, setPreviewScale, setPreviewOffset, + selectSprite, openAnimationDialog, } = useAtlasStore(); @@ -100,11 +104,7 @@ export function CanvasPreview() { ctx.fillRect(0, 0, cw, ch); if (result && result.imageDataUrl) { - // Load and draw the atlas image - const img = new Image(); - img.src = result.imageDataUrl; - - img.onload = () => { + const drawImage = (img: HTMLImageElement) => { ctx.clearRect(0, 0, cw, ch); ctx.fillStyle = "#0f0f11"; ctx.fillRect(0, 0, cw, ch); @@ -130,8 +130,55 @@ export function CanvasPreview() { // Draw atlas image ctx.drawImage(img, centerX, centerY, scaledWidth, scaledHeight); + // Draw selection highlight for all selected sprites + if (selectedSpriteIds.length > 0) { + result.placements.forEach(p => { + if (selectedSpriteIds.includes(p.id)) { + const pw = (p.rotated ? p.height : p.width) * previewScale; + const ph = (p.rotated ? p.width : p.height) * previewScale; + const px = centerX + p.x * previewScale; + const py = centerY + p.y * previewScale; + + // Apple style highlight: outer glow and soft border + ctx.save(); + + // Shadow/Glow + ctx.shadowBlur = 15; + ctx.shadowColor = "rgba(59, 130, 246, 0.6)"; + + // Animated outer border + const time = Date.now() / 1000; + const pulse = Math.sin(time * 3) * 0.2 + 0.8; + + ctx.strokeStyle = `rgba(59, 130, 246, ${pulse})`; + ctx.lineWidth = 2; + + // Rounded rect path + const radius = 4; + ctx.beginPath(); + ctx.moveTo(px + radius, py); + ctx.lineTo(px + pw - radius, py); + ctx.quadraticCurveTo(px + pw, py, px + pw, py + radius); + ctx.lineTo(px + pw, py + ph - radius); + ctx.quadraticCurveTo(px + pw, py + ph, px + pw - radius, py + ph); + ctx.lineTo(px + radius, py + ph); + ctx.quadraticCurveTo(px, py + ph, px, py + ph - radius); + ctx.lineTo(px, py + radius); + ctx.quadraticCurveTo(px, py, px + radius, py); + ctx.closePath(); + ctx.stroke(); + + // Inner fill with very low opacity + ctx.fillStyle = "rgba(59, 130, 246, 0.1)"; + ctx.fill(); + + ctx.restore(); + } + }); + } + // Draw border - ctx.strokeStyle = "rgba(59, 130, 246, 0.5)"; + ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; ctx.lineWidth = 1; ctx.strokeRect(centerX, centerY, scaledWidth, scaledHeight); @@ -145,6 +192,20 @@ export function CanvasPreview() { centerY - 8 ); }; + + // Use cached image if URL matches + if (imageCacheRef.current && imageCacheRef.current.url === result.imageDataUrl) { + drawImage(imageCacheRef.current.image); + } else { + // Load and draw the atlas image + const img = new Image(); + img.src = result.imageDataUrl; + + img.onload = () => { + imageCacheRef.current = { url: result.imageDataUrl!, image: img }; + drawImage(img); + }; + } } else if (sprites.length === 0) { // Empty state ctx.fillStyle = "#71717a"; @@ -159,6 +220,26 @@ export function CanvasPreview() { } }, [containerSize, result, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, t]); + // Render loop for animation (highlights) + useEffect(() => { + let animationFrame: number; + const render = () => { + // Re-trigger the main render useEffect by some means or just call a separate draw function + // For simplicity, we can just use a dummy state to force re-render if needed, + // but here we already have selectedSpriteIds in the dependency array of the main render effect. + // To get smooth pulsing, we can just request another frame. + if (selectedSpriteIds.length > 0) { + // This is a bit hacky but works for a canvas in React + // A better way would be to move drawing logic to a separate function + setContainerSize(s => ({ ...s })); + } + animationFrame = requestAnimationFrame(render); + }; + + animationFrame = requestAnimationFrame(render); + return () => cancelAnimationFrame(animationFrame); + }, [selectedSpriteIds]); + // Handle wheel zoom const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); @@ -166,10 +247,11 @@ export function CanvasPreview() { setPreviewScale(previewScale + delta); }, [previewScale, setPreviewScale]); - // Handle mouse down for panning + // Handle mouse down for panning and picking const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button === 0) { setIsPanning(true); + setHasMoved(false); setPanStart({ x: e.clientX - previewOffset.x, y: e.clientY - previewOffset.y }); } }, [previewOffset]); @@ -177,17 +259,53 @@ export function CanvasPreview() { // Handle mouse move for panning const handleMouseMove = useCallback((e: React.MouseEvent) => { if (isPanning) { + const dx = Math.abs(e.clientX - (panStart.x + previewOffset.x)); + const dy = Math.abs(e.clientY - (panStart.y + previewOffset.y)); + if (dx > 2 || dy > 2) { + setHasMoved(true); + } + setPreviewOffset({ x: e.clientX - panStart.x, y: e.clientY - panStart.y, }); } - }, [isPanning, panStart, setPreviewOffset]); + }, [isPanning, panStart, previewOffset, setPreviewOffset]); - // Handle mouse up - const handleMouseUp = useCallback(() => { + // Handle mouse up (Picking logic) + const handleMouseUp = useCallback((e: React.MouseEvent) => { + if (isPanning && !hasMoved && result && containerRef.current) { + // Pick sprite + const rect = containerRef.current.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const { width: cw, height: ch } = containerSize; + const scaledWidth = atlasWidth * previewScale; + const scaledHeight = atlasHeight * previewScale; + const centerX = (cw - scaledWidth) / 2 + previewOffset.x; + const centerY = (ch - scaledHeight) / 2 + previewOffset.y; + + // Transform mouse to atlas space + const atlasX = (mouseX - centerX) / previewScale; + const atlasY = (mouseY - centerY) / previewScale; + + // Find sprite under cursor + const clickedSprite = result.placements.find(p => { + const pw = p.rotated ? p.height : p.width; + const ph = p.rotated ? p.width : p.height; + return atlasX >= p.x && atlasX <= p.x + pw && + atlasY >= p.y && atlasY <= p.y + ph; + }); + + if (clickedSprite) { + selectSprite(clickedSprite.id, e.ctrlKey || e.metaKey); + } else { + selectSprite("", false); // Deselect if clicked empty area + } + } setIsPanning(false); - }, []); + }, [isPanning, hasMoved, result, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]); // Fit to view const fitToView = useCallback(() => { @@ -326,16 +444,18 @@ export function CanvasPreview() {
-

+

{status === "packing" && (t("atlas.packing") || "打包中...")} {status === "rendering" && (t("atlas.rendering") || "渲染中...")} {status === "loading" && (t("common.loading") || "加载中...")}

{progress > 0 && ( -
+
{ + if (selectedSpriteIds.length === 1) { + const selectedId = selectedSpriteIds[0]; + const element = document.getElementById(`sprite-${selectedId}`); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + }, [selectedSpriteIds]); + return (
(