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

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();
// 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";

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 }}