feat: 增强纹理图集工具交互体验

新增 ZIP 打包导出、精灵选择高亮、点击拾取等交互功能

- 新增 JSZip 依赖,支持一键打包下载图集图片和元数据文件
- CanvasPreview 新增精灵选择功能,支持点击/多选选择,带脉冲动画高亮效果
- 新增图片缓存机制,优化重绘性能
- FileListPanel 新增选中项自动滚动到可视区域
- 优化防抖延迟和加载状态视觉效果

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 22:16:22 +08:00
parent 140608845a
commit c26d6eaada
6 changed files with 279 additions and 27 deletions

87
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -25,7 +25,7 @@ export default function TextureAtlasPage() {
const timer = setTimeout(() => {
pack();
}, 300);
}, 500);
return () => clearTimeout(timer);
}, [sprites, config, pack]);

View File

@@ -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();
const info = getMetadataInfo();
if (!info) return;
// Small delay then download metadata
setTimeout(() => {
downloadMetadata();
}, 100);
}, [result, downloadImage, downloadMetadata]);
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";

View File

@@ -37,8 +37,10 @@ export function CanvasPreview() {
const { t } = useSafeTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(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() {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm"
className={`absolute inset-0 flex flex-col items-center justify-center backdrop-blur-[2px] ${
result ? "bg-black/20" : "bg-black/60"
}`}
>
<div className="mb-4 h-10 w-10 animate-spin rounded-full border-3 border-primary border-t-transparent" />
<p className="mb-2 text-sm font-medium">
<p className="mb-2 text-sm font-medium shadow-sm">
{status === "packing" && (t("atlas.packing") || "打包中...")}
{status === "rendering" && (t("atlas.rendering") || "渲染中...")}
{status === "loading" && (t("common.loading") || "加载中...")}
</p>
{progress > 0 && (
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-white/10">
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-white/10 shadow-inner">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Folder,
@@ -277,6 +277,19 @@ export function FileListPanel() {
setFolderName("");
}, [clearSprites, setFolderName]);
/**
* Scroll selected item into view
*/
useEffect(() => {
if (selectedSpriteIds.length === 1) {
const selectedId = selectedSpriteIds[0];
const element = document.getElementById(`sprite-${selectedId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
}, [selectedSpriteIds]);
return (
<div
ref={dropZoneRef}
@@ -340,6 +353,7 @@ export function FileListPanel() {
{sprites.map((sprite, index) => (
<motion.div
key={sprite.id}
id={`sprite-${sprite.id}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}