去背景
This commit is contained in:
@@ -1,248 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import CanvasContainer from '../../components/CanvasContainer';
|
||||
|
||||
// 导入像素化工具和类型
|
||||
import {
|
||||
PixelationMode,
|
||||
calculatePixelGrid,
|
||||
PaletteColor,
|
||||
MappedPixel,
|
||||
hexToRgb,
|
||||
} from '../../utils/pixelation';
|
||||
|
||||
import {
|
||||
getColorKeyByHex,
|
||||
getMardToHexMapping,
|
||||
sortColorsByHue,
|
||||
ColorSystem
|
||||
} from '../../utils/colorSystemUtils';
|
||||
|
||||
// 获取完整色板
|
||||
const mardToHexMapping = getMardToHexMapping();
|
||||
const fullBeadPalette: PaletteColor[] = Object.entries(mardToHexMapping)
|
||||
.map(([, hex]) => {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return null;
|
||||
return { key: hex, hex, rgb };
|
||||
})
|
||||
.filter((item): item is PaletteColor => item !== null);
|
||||
|
||||
export default function EditorPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 基础状态
|
||||
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
|
||||
const [granularity, setGranularity] = useState<number>(50);
|
||||
const [pixelationMode, setPixelationMode] = useState<PixelationMode>(PixelationMode.Dominant);
|
||||
const [selectedColorSystem] = useState<ColorSystem>('MARD');
|
||||
const [activeBeadPalette] = useState<PaletteColor[]>(fullBeadPalette);
|
||||
|
||||
// 像素数据
|
||||
const [mappedPixelData, setMappedPixelData] = useState<MappedPixel[][] | null>(null);
|
||||
const [colorCounts, setColorCounts] = useState<{ [key: string]: { count: number; color: string } } | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
// 从 localStorage 加载图片
|
||||
useEffect(() => {
|
||||
const imageSrc = localStorage.getItem('uploadedImage');
|
||||
if (imageSrc) {
|
||||
setOriginalImageSrc(imageSrc);
|
||||
localStorage.removeItem('uploadedImage'); // 清除以避免重复加载
|
||||
} else {
|
||||
// 如果没有图片,返回首页
|
||||
router.push('/');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 生成像素画
|
||||
const generatePixelArt = useCallback(() => {
|
||||
if (!originalImageSrc) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 创建临时 canvas 获取图像数据
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 计算网格尺寸
|
||||
const N = Math.round(img.height / granularity);
|
||||
const M = Math.round(img.width / granularity);
|
||||
|
||||
// 获取备用颜色(第一个颜色)
|
||||
const fallbackColor = activeBeadPalette[0] || { key: '#000000', hex: '#000000', rgb: { r: 0, g: 0, b: 0 } };
|
||||
|
||||
// 调用新的 API
|
||||
const mappedPixelData = calculatePixelGrid(
|
||||
ctx,
|
||||
img.width,
|
||||
img.height,
|
||||
N,
|
||||
M,
|
||||
activeBeadPalette,
|
||||
pixelationMode,
|
||||
fallbackColor
|
||||
);
|
||||
|
||||
// 计算颜色统计
|
||||
const colorCounts: { [key: string]: { count: number; color: string } } = {};
|
||||
|
||||
mappedPixelData.forEach(row => {
|
||||
row.forEach(pixel => {
|
||||
if (pixel.key && pixel.key !== 'transparent' && !pixel.isExternal) {
|
||||
if (!colorCounts[pixel.key]) {
|
||||
colorCounts[pixel.key] = { count: 0, color: pixel.color };
|
||||
}
|
||||
colorCounts[pixel.key].count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setMappedPixelData(mappedPixelData);
|
||||
setColorCounts(colorCounts);
|
||||
setIsProcessing(false);
|
||||
};
|
||||
img.src = originalImageSrc;
|
||||
}, [originalImageSrc, granularity, activeBeadPalette, pixelationMode]);
|
||||
|
||||
// 首次加载时自动生成
|
||||
useEffect(() => {
|
||||
if (originalImageSrc && !mappedPixelData) {
|
||||
generatePixelArt();
|
||||
}
|
||||
}, [originalImageSrc, mappedPixelData, generatePixelArt]);
|
||||
|
||||
// 处理像素网格更新
|
||||
const handlePixelGridUpdate = useCallback((newGrid: MappedPixel[][]) => {
|
||||
setMappedPixelData(newGrid);
|
||||
|
||||
// 重新计算颜色统计
|
||||
const counts: { [key: string]: { count: number; color: string } } = {};
|
||||
|
||||
newGrid.forEach(row => {
|
||||
row.forEach(pixel => {
|
||||
if (pixel.key && pixel.key !== 'transparent' && !pixel.isExternal) {
|
||||
if (!counts[pixel.key]) {
|
||||
counts[pixel.key] = { count: 0, color: pixel.color };
|
||||
}
|
||||
counts[pixel.key].count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setColorCounts(counts);
|
||||
}, []);
|
||||
|
||||
// 计算当前色板
|
||||
const currentColorPalette = useMemo(() => {
|
||||
if (!mappedPixelData) return [];
|
||||
|
||||
const uniqueColors = new Set<string>();
|
||||
mappedPixelData.forEach(row => {
|
||||
row.forEach(pixel => {
|
||||
if (pixel.key && pixel.key !== 'transparent' && !pixel.isExternal) {
|
||||
uniqueColors.add(pixel.key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const colorArray = Array.from(uniqueColors).map(key => {
|
||||
const colorInfo = colorCounts?.[key];
|
||||
return {
|
||||
key,
|
||||
hex: colorInfo?.color || key,
|
||||
color: colorInfo?.color || key,
|
||||
name: getColorKeyByHex(key, selectedColorSystem) || key
|
||||
};
|
||||
});
|
||||
|
||||
return sortColorsByHue(colorArray);
|
||||
}, [mappedPixelData, colorCounts, selectedColorSystem]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-900 flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<header className="bg-black/30 backdrop-blur-xl border-b border-white/10 p-4">
|
||||
<div className="container mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
title="返回首页"
|
||||
>
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl font-bold text-white">拼豆底稿编辑器</h1>
|
||||
</div>
|
||||
|
||||
{/* 参数调整 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-white/80">精细度:</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="200"
|
||||
value={granularity}
|
||||
onChange={(e) => setGranularity(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-sm text-white/60 w-12">{granularity}</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={pixelationMode}
|
||||
onChange={(e) => setPixelationMode(e.target.value as unknown as PixelationMode)}
|
||||
className="px-3 py-1 bg-white/10 border border-white/20 rounded text-white text-sm"
|
||||
>
|
||||
<option value={PixelationMode.Average}>平均色</option>
|
||||
<option value={PixelationMode.Dominant}>主导色</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={generatePixelArt}
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isProcessing ? '生成中...' : '重新生成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 container mx-auto p-4">
|
||||
{mappedPixelData ? (
|
||||
<div className="h-full">
|
||||
<CanvasContainer
|
||||
pixelGrid={mappedPixelData}
|
||||
cellSize={10}
|
||||
onPixelGridUpdate={handlePixelGridUpdate}
|
||||
colorPalette={currentColorPalette}
|
||||
selectedColorSystem={selectedColorSystem}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-white text-xl">加载中...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -757,8 +757,117 @@ export default function FocusMode() {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: 实现去背景功能
|
||||
// if (removeBackground) { ... }
|
||||
// 实现去背景功能
|
||||
if (removeBackground) {
|
||||
const rows = pixelData.length;
|
||||
const cols = pixelData[0]?.length || 0;
|
||||
|
||||
// 统计边缘颜色
|
||||
const edgeColorCounts: { [key: string]: number } = {};
|
||||
|
||||
// 统计上边缘
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const color = pixelData[0][x].color;
|
||||
if (color && color !== 'transparent') {
|
||||
edgeColorCounts[color] = (edgeColorCounts[color] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计下边缘
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const color = pixelData[rows - 1][x].color;
|
||||
if (color && color !== 'transparent') {
|
||||
edgeColorCounts[color] = (edgeColorCounts[color] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计左边缘(排除角落避免重复计数)
|
||||
for (let y = 1; y < rows - 1; y++) {
|
||||
const color = pixelData[y][0].color;
|
||||
if (color && color !== 'transparent') {
|
||||
edgeColorCounts[color] = (edgeColorCounts[color] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计右边缘(排除角落避免重复计数)
|
||||
for (let y = 1; y < rows - 1; y++) {
|
||||
const color = pixelData[y][cols - 1].color;
|
||||
if (color && color !== 'transparent') {
|
||||
edgeColorCounts[color] = (edgeColorCounts[color] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 找出边缘最多的颜色
|
||||
let mostCommonEdgeColor = '';
|
||||
let maxCount = 0;
|
||||
for (const [color, count] of Object.entries(edgeColorCounts)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mostCommonEdgeColor = color;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了最常见的边缘颜色,进行洪水填充
|
||||
if (mostCommonEdgeColor) {
|
||||
// 洪水填充函数
|
||||
const floodFill = (startY: number, startX: number, targetColor: string) => {
|
||||
const visited = new Set<string>();
|
||||
const queue: [number, number][] = [[startY, startX]];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [y, x] = queue.shift()!;
|
||||
const key = `${y},${x}`;
|
||||
|
||||
// 检查边界和是否已访问
|
||||
if (y < 0 || y >= rows || x < 0 || x >= cols || visited.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(key);
|
||||
|
||||
// 检查颜色是否匹配
|
||||
if (pixelData[y][x].color !== targetColor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标记为外部(背景)
|
||||
pixelData[y][x].isExternal = true;
|
||||
|
||||
// 添加相邻像素到队列
|
||||
queue.push([y - 1, x], [y + 1, x], [y, x - 1], [y, x + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// 从所有边缘开始洪水填充
|
||||
// 上边缘
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (pixelData[0][x].color === mostCommonEdgeColor && !pixelData[0][x].isExternal) {
|
||||
floodFill(0, x, mostCommonEdgeColor);
|
||||
}
|
||||
}
|
||||
|
||||
// 下边缘
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (pixelData[rows - 1][x].color === mostCommonEdgeColor && !pixelData[rows - 1][x].isExternal) {
|
||||
floodFill(rows - 1, x, mostCommonEdgeColor);
|
||||
}
|
||||
}
|
||||
|
||||
// 左边缘
|
||||
for (let y = 0; y < rows; y++) {
|
||||
if (pixelData[y][0].color === mostCommonEdgeColor && !pixelData[y][0].isExternal) {
|
||||
floodFill(y, 0, mostCommonEdgeColor);
|
||||
}
|
||||
}
|
||||
|
||||
// 右边缘
|
||||
for (let y = 0; y < rows; y++) {
|
||||
if (pixelData[y][cols - 1].color === mostCommonEdgeColor && !pixelData[y][cols - 1].isExternal) {
|
||||
floodFill(y, cols - 1, mostCommonEdgeColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算颜色统计
|
||||
const counts: { [key: string]: { count: number; color: string } } = {};
|
||||
@@ -919,6 +1028,14 @@ export default function FocusMode() {
|
||||
regeneratePixelArt();
|
||||
}
|
||||
}, [pixelationMode]); // 只监听 pixelationMode 的变化
|
||||
|
||||
// 监听 removeBackground 变化并重新生成
|
||||
useEffect(() => {
|
||||
// 只有在有图片数据时才重新生成
|
||||
if (mappedPixelData && focusState.editMode === 'preview') {
|
||||
regeneratePixelArt();
|
||||
}
|
||||
}, [removeBackground]); // 只监听 removeBackground 的变化
|
||||
|
||||
if (!mappedPixelData || !gridDimensions) {
|
||||
return (
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import UnifiedCanvas, { CanvasMode } from './UnifiedCanvas';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
import { useEditMode } from '../hooks/useEditMode';
|
||||
import { usePreviewMode } from '../hooks/usePreviewMode';
|
||||
import { downloadImage } from '../utils/imageDownloader';
|
||||
import { GridDownloadOptions } from '../types/downloadTypes';
|
||||
import DownloadSettingsModal from './DownloadSettingsModal';
|
||||
import { ColorSystem } from '../utils/colorSystemUtils';
|
||||
|
||||
interface CanvasContainerProps {
|
||||
pixelGrid: MappedPixel[][];
|
||||
cellSize: number;
|
||||
onPixelGridUpdate: (newGrid: MappedPixel[][]) => void;
|
||||
colorPalette: Array<{ key: string; hex: string; name?: string }>;
|
||||
selectedColorSystem: string;
|
||||
}
|
||||
|
||||
export default function CanvasContainer({
|
||||
pixelGrid,
|
||||
cellSize,
|
||||
onPixelGridUpdate,
|
||||
colorPalette,
|
||||
selectedColorSystem,
|
||||
}: CanvasContainerProps) {
|
||||
const [mode, setMode] = useState<CanvasMode>('preview');
|
||||
const [showDownloadModal, setShowDownloadModal] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [downloadOptions, setDownloadOptions] = useState<GridDownloadOptions>({
|
||||
showGrid: true,
|
||||
gridInterval: 10,
|
||||
showCoordinates: true,
|
||||
gridLineColor: '#000000',
|
||||
includeStats: true,
|
||||
exportCsv: false
|
||||
});
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
// 预览模式钩子
|
||||
const {
|
||||
selectedTool,
|
||||
selectedColorKey,
|
||||
highlightColorKey,
|
||||
replaceModeState,
|
||||
setSelectedTool,
|
||||
setSelectedColorKey,
|
||||
handleCellClick,
|
||||
toggleHighlight,
|
||||
resetToolState,
|
||||
} = usePreviewMode(pixelGrid, onPixelGridUpdate);
|
||||
|
||||
// 编辑模式钩子
|
||||
const {
|
||||
currentColorKey,
|
||||
markedCells,
|
||||
recommendedRegion,
|
||||
recommendMode,
|
||||
isTimerRunning,
|
||||
elapsedTime,
|
||||
markCell,
|
||||
switchColor,
|
||||
setRecommendMode,
|
||||
calculateProgress,
|
||||
resetEditMode,
|
||||
toggleTimer,
|
||||
} = useEditMode(pixelGrid);
|
||||
|
||||
// 处理画布准备完成
|
||||
const handleCanvasReady = useCallback((canvas: HTMLCanvasElement) => {
|
||||
canvasRef.current = canvas;
|
||||
}, []);
|
||||
|
||||
// 切换模式
|
||||
const toggleMode = useCallback(() => {
|
||||
if (mode === 'preview') {
|
||||
// 切换到编辑模式
|
||||
resetToolState();
|
||||
setMode('edit');
|
||||
// 自动选择第一个颜色
|
||||
if (colorPalette.length > 0 && !currentColorKey) {
|
||||
switchColor(colorPalette[0].key);
|
||||
}
|
||||
} else {
|
||||
// 切换到预览模式
|
||||
resetEditMode();
|
||||
setMode('preview');
|
||||
}
|
||||
}, [mode, resetToolState, resetEditMode, colorPalette, currentColorKey, switchColor]);
|
||||
|
||||
// 处理下载
|
||||
const handleDownload = useCallback(async (options: GridDownloadOptions) => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
// 计算颜色统计和尺寸
|
||||
const colorCounts: { [key: string]: { count: number; color: string } } = {};
|
||||
let totalBeadCount = 0;
|
||||
|
||||
pixelGrid.forEach(row => {
|
||||
row.forEach(pixel => {
|
||||
if (pixel.key && pixel.key !== 'transparent' && !pixel.isExternal) {
|
||||
if (!colorCounts[pixel.key]) {
|
||||
colorCounts[pixel.key] = { count: 0, color: pixel.color };
|
||||
}
|
||||
colorCounts[pixel.key].count++;
|
||||
totalBeadCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 创建一个简单的色板数组
|
||||
const activeBeadPalette = colorPalette.map(c => ({
|
||||
key: c.key,
|
||||
hex: c.hex,
|
||||
rgb: { r: 0, g: 0, b: 0 } // 简化处理
|
||||
}));
|
||||
|
||||
await downloadImage({
|
||||
mappedPixelData: pixelGrid,
|
||||
gridDimensions: { N: pixelGrid.length, M: pixelGrid[0]?.length || 0 },
|
||||
colorCounts,
|
||||
totalBeadCount,
|
||||
options,
|
||||
activeBeadPalette,
|
||||
selectedColorSystem: selectedColorSystem as ColorSystem,
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setShowDownloadModal(false);
|
||||
}
|
||||
}, [pixelGrid, selectedColorSystem, colorPalette]);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 编辑模式的进度信息
|
||||
const progress = mode === 'edit' ? calculateProgress() : null;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-blue-500 text-white rounded-lg hover:from-purple-600 hover:to-blue-600 transition-all duration-200"
|
||||
>
|
||||
切换到{mode === 'preview' ? '编辑' : '预览'}模式
|
||||
</button>
|
||||
|
||||
{/* 预览模式工具 */}
|
||||
{mode === 'preview' && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedTool('view')}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
selectedTool === 'view'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title="查看"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTool('paint')}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
selectedTool === 'paint'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title="上色"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTool('erase')}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
selectedTool === 'erase'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title="擦除"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTool('replace')}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
selectedTool === 'replace'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
title="替换颜色"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{replaceModeState.isActive && (
|
||||
<div className="px-3 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded-lg text-sm">
|
||||
选择目标颜色进行替换
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 编辑模式信息 */}
|
||||
{mode === 'edit' && progress && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
当前颜色:
|
||||
</span>
|
||||
<div
|
||||
className="w-6 h-6 rounded border-2 border-gray-300"
|
||||
style={{ backgroundColor: currentColorKey }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
进度:{progress.completed}/{progress.total} ({progress.percentage}%)
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-400 to-green-600 transition-all duration-300"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
用时:{formatTime(elapsedTime)}
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleTimer}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title={isTimerRunning ? '暂停' : '继续'}
|
||||
>
|
||||
{isTimerRunning ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={recommendMode}
|
||||
onChange={(e) => setRecommendMode(e.target.value as 'nearest' | 'largest' | 'edge-first')}
|
||||
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="nearest">最近优先</option>
|
||||
<option value="largest">最大优先</option>
|
||||
<option value="edge-first">边缘优先</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<button
|
||||
onClick={() => setShowDownloadModal(true)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-all duration-200"
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? '导出中...' : '导出图片'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画布区域 */}
|
||||
<div className="flex-1 relative">
|
||||
<UnifiedCanvas
|
||||
pixelGrid={pixelGrid}
|
||||
cellSize={cellSize}
|
||||
mode={mode}
|
||||
onCellClick={mode === 'preview' ? handleCellClick : undefined}
|
||||
onCellHover={mode === 'preview' ? () => {/* 处理悬停 */} : undefined}
|
||||
highlightColorKey={mode === 'preview' ? highlightColorKey : undefined}
|
||||
currentColorKey={mode === 'edit' ? currentColorKey : undefined}
|
||||
markedCells={mode === 'edit' ? markedCells : undefined}
|
||||
onMarkCell={mode === 'edit' ? markCell : undefined}
|
||||
recommendedRegion={mode === 'edit' ? recommendedRegion : undefined}
|
||||
onCanvasReady={handleCanvasReady}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部颜色选择器 */}
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{colorPalette.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
onClick={() => {
|
||||
if (mode === 'preview') {
|
||||
setSelectedColorKey(color.key);
|
||||
if (selectedTool === 'view') {
|
||||
toggleHighlight(color.key);
|
||||
}
|
||||
} else {
|
||||
switchColor(color.key);
|
||||
}
|
||||
}}
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-lg border-2 transition-all duration-200 ${
|
||||
(mode === 'preview' && selectedColorKey === color.key) ||
|
||||
(mode === 'edit' && currentColorKey === color.key)
|
||||
? 'border-blue-500 scale-110'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
title={color.name || color.key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下载设置模态框 */}
|
||||
{showDownloadModal && (
|
||||
<DownloadSettingsModal
|
||||
isOpen={showDownloadModal}
|
||||
onClose={() => setShowDownloadModal(false)}
|
||||
options={downloadOptions}
|
||||
onOptionsChange={setDownloadOptions}
|
||||
onDownload={() => handleDownload(downloadOptions)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,9 @@ const FocusCanvas: React.FC<FocusCanvasProps> = ({
|
||||
const y = row * cellSize;
|
||||
const cellKey = `${row},${col}`;
|
||||
|
||||
// 跳过外部或透明像素
|
||||
if (pixel.isExternal || pixel.key === 'transparent') continue;
|
||||
|
||||
// 确定格子颜色
|
||||
let fillColor = pixel.color;
|
||||
|
||||
@@ -191,7 +194,7 @@ const FocusCanvas: React.FC<FocusCanvasProps> = ({
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, [mappedPixelData, gridDimensions, cellSize, currentColor, completedCells, recommendedCell, recommendedRegion, gridSectionInterval, showSectionLines, sectionLineColor, highlightColor, editMode, selectedCells]);
|
||||
}, [mappedPixelData, gridDimensions, cellSize, currentColor, completedCells, recommendedCell, recommendedRegion, gridSectionInterval, showSectionLines, sectionLineColor, highlightColor, editMode, selectedCells, canvasScale]);
|
||||
|
||||
// 处理触摸/鼠标事件
|
||||
const getEventPosition = useCallback((event: React.MouseEvent | React.TouchEvent) => {
|
||||
@@ -385,8 +388,13 @@ const FocusCanvas: React.FC<FocusCanvasProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full flex items-center justify-center overflow-hidden bg-gray-100"
|
||||
style={{ touchAction: 'none' }}
|
||||
className="w-full h-full flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
touchAction: 'none',
|
||||
backgroundImage: `repeating-conic-gradient(#f0f0f0 0% 25%, white 0% 50%)`,
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundPosition: '0 0, 10px 10px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -397,6 +405,7 @@ const FocusCanvas: React.FC<FocusCanvasProps> = ({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="cursor-crosshair border border-gray-300"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onClick={handleClick}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
|
||||
@@ -169,7 +169,6 @@ const PreviewToolbar: React.FC<PreviewToolbarProps> = ({
|
||||
<button
|
||||
onClick={() => {
|
||||
onRemoveBackgroundChange(!removeBackground);
|
||||
onRegenerate();
|
||||
}}
|
||||
className="w-full h-8 relative"
|
||||
>
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
|
||||
export type CanvasMode = 'preview' | 'edit';
|
||||
|
||||
export interface UnifiedCanvasProps {
|
||||
// 基础数据
|
||||
pixelGrid: MappedPixel[][];
|
||||
cellSize: number;
|
||||
|
||||
// 模式控制
|
||||
mode: CanvasMode;
|
||||
|
||||
// 预览模式属性
|
||||
onCellClick?: (cell: MappedPixel, x: number, y: number) => void;
|
||||
onCellHover?: (cell: MappedPixel | null, x: number, y: number) => void;
|
||||
highlightColorKey?: string | null;
|
||||
showGrid?: boolean;
|
||||
|
||||
// 编辑模式属性
|
||||
currentColorKey?: string;
|
||||
markedCells?: Set<string>;
|
||||
onMarkCell?: (x: number, y: number) => void;
|
||||
recommendedRegion?: { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
// 通用属性
|
||||
className?: string;
|
||||
onCanvasReady?: (canvas: HTMLCanvasElement) => void;
|
||||
}
|
||||
|
||||
export default function UnifiedCanvas({
|
||||
pixelGrid,
|
||||
cellSize,
|
||||
mode,
|
||||
onCellClick,
|
||||
onCellHover,
|
||||
highlightColorKey,
|
||||
showGrid = true,
|
||||
currentColorKey,
|
||||
markedCells = new Set(),
|
||||
onMarkCell,
|
||||
recommendedRegion,
|
||||
className = '',
|
||||
onCanvasReady,
|
||||
}: UnifiedCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 });
|
||||
const [hoveredCell, setHoveredCell] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const canvasWidth = pixelGrid[0]?.length || 0;
|
||||
const canvasHeight = pixelGrid.length || 0;
|
||||
|
||||
// 计算画布实际尺寸
|
||||
const actualWidth = canvasWidth * cellSize;
|
||||
const actualHeight = canvasHeight * cellSize;
|
||||
|
||||
// 渲染画布
|
||||
const renderCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 保存当前状态
|
||||
ctx.save();
|
||||
|
||||
// 应用缩放和平移
|
||||
ctx.translate(offset.x, offset.y);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// 绘制像素格子
|
||||
for (let y = 0; y < canvasHeight; y++) {
|
||||
for (let x = 0; x < canvasWidth; x++) {
|
||||
const pixel = pixelGrid[y][x];
|
||||
if (!pixel || pixel.isExternal || pixel.key === 'transparent') continue;
|
||||
|
||||
const cellX = x * cellSize;
|
||||
const cellY = y * cellSize;
|
||||
|
||||
// 根据模式决定颜色
|
||||
let fillColor = pixel.color;
|
||||
let alpha = 1;
|
||||
|
||||
if (mode === 'preview') {
|
||||
// 预览模式:高亮功能
|
||||
if (highlightColorKey && pixel.key !== highlightColorKey) {
|
||||
alpha = 0.3;
|
||||
}
|
||||
} else if (mode === 'edit' && currentColorKey) {
|
||||
// 编辑模式:专心模式效果
|
||||
if (pixel.key !== currentColorKey) {
|
||||
// 非当前颜色显示为灰度
|
||||
// 从 hex 颜色提取 RGB 值
|
||||
const colorHex = pixel.color.replace('#', '');
|
||||
const r = parseInt(colorHex.substr(0, 2), 16);
|
||||
const g = parseInt(colorHex.substr(2, 2), 16);
|
||||
const b = parseInt(colorHex.substr(4, 2), 16);
|
||||
const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||
fillColor = `rgb(${gray}, ${gray}, ${gray})`;
|
||||
alpha = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置透明度
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// 填充格子
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(cellX, cellY, cellSize, cellSize);
|
||||
|
||||
// 编辑模式下的标记
|
||||
if (mode === 'edit' && markedCells.has(`${x},${y}`)) {
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillStyle = '#10b981';
|
||||
ctx.fillRect(cellX, cellY, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// 恢复透明度
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制网格线
|
||||
if (showGrid && scale > 0.5) {
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)';
|
||||
ctx.lineWidth = 1 / scale;
|
||||
|
||||
for (let x = 0; x <= canvasWidth; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * cellSize, 0);
|
||||
ctx.lineTo(x * cellSize, actualHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= canvasHeight; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * cellSize);
|
||||
ctx.lineTo(actualWidth, y * cellSize);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制悬停效果
|
||||
if (hoveredCell && scale > 0.5) {
|
||||
const { x, y } = hoveredCell;
|
||||
ctx.strokeStyle = mode === 'edit' ? '#3b82f6' : '#6366f1';
|
||||
ctx.lineWidth = 2 / scale;
|
||||
ctx.strokeRect(x * cellSize, y * cellSize, cellSize, cellSize);
|
||||
}
|
||||
|
||||
// 绘制推荐区域(编辑模式)
|
||||
if (mode === 'edit' && recommendedRegion) {
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 2 / scale;
|
||||
ctx.setLineDash([5 / scale, 5 / scale]);
|
||||
ctx.strokeRect(
|
||||
recommendedRegion.x * cellSize,
|
||||
recommendedRegion.y * cellSize,
|
||||
recommendedRegion.width * cellSize,
|
||||
recommendedRegion.height * cellSize
|
||||
);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 绘制中心标记
|
||||
const centerX = (recommendedRegion.x + recommendedRegion.width / 2) * cellSize;
|
||||
const centerY = (recommendedRegion.y + recommendedRegion.height / 2) * cellSize;
|
||||
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 3 / scale, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 恢复状态
|
||||
ctx.restore();
|
||||
}, [pixelGrid, cellSize, scale, offset, showGrid, mode, highlightColorKey,
|
||||
currentColorKey, markedCells, hoveredCell, recommendedRegion,
|
||||
canvasWidth, canvasHeight, actualWidth, actualHeight]);
|
||||
|
||||
// 处理鼠标/触摸坐标转换
|
||||
const getCanvasCoordinates = useCallback((clientX: number, clientY: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (clientX - rect.left - offset.x) / scale;
|
||||
const y = (clientY - rect.top - offset.y) / scale;
|
||||
|
||||
const gridX = Math.floor(x / cellSize);
|
||||
const gridY = Math.floor(y / cellSize);
|
||||
|
||||
if (gridX >= 0 && gridX < canvasWidth && gridY >= 0 && gridY < canvasHeight) {
|
||||
return { x: gridX, y: gridY, pixel: pixelGrid[gridY][gridX] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [offset, scale, cellSize, canvasWidth, canvasHeight, pixelGrid]);
|
||||
|
||||
// 处理点击
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const coords = getCanvasCoordinates(e.clientX, e.clientY);
|
||||
if (!coords) return;
|
||||
|
||||
if (mode === 'preview' && onCellClick) {
|
||||
onCellClick(coords.pixel, coords.x, coords.y);
|
||||
} else if (mode === 'edit' && onMarkCell) {
|
||||
onMarkCell(coords.x, coords.y);
|
||||
}
|
||||
}, [mode, getCanvasCoordinates, onCellClick, onMarkCell]);
|
||||
|
||||
// 处理鼠标移动
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (isPanning) {
|
||||
const deltaX = e.clientX - lastPanPoint.x;
|
||||
const deltaY = e.clientY - lastPanPoint.y;
|
||||
|
||||
setOffset(prev => ({
|
||||
x: prev.x + deltaX,
|
||||
y: prev.y + deltaY
|
||||
}));
|
||||
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = getCanvasCoordinates(e.clientX, e.clientY);
|
||||
|
||||
if (coords) {
|
||||
setHoveredCell({ x: coords.x, y: coords.y });
|
||||
if (mode === 'preview' && onCellHover) {
|
||||
onCellHover(coords.pixel, coords.x, coords.y);
|
||||
}
|
||||
} else {
|
||||
setHoveredCell(null);
|
||||
if (mode === 'preview' && onCellHover) {
|
||||
onCellHover(null, 0, 0);
|
||||
}
|
||||
}
|
||||
}, [isPanning, lastPanPoint, getCanvasCoordinates, mode, onCellHover]);
|
||||
|
||||
// 处理滚轮缩放
|
||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newScale = Math.min(Math.max(scale * delta, 0.1), 5);
|
||||
|
||||
// 以鼠标位置为中心缩放
|
||||
const scaleChange = newScale / scale;
|
||||
const newOffsetX = mouseX - (mouseX - offset.x) * scaleChange;
|
||||
const newOffsetY = mouseY - (mouseY - offset.y) * scaleChange;
|
||||
|
||||
setScale(newScale);
|
||||
setOffset({ x: newOffsetX, y: newOffsetY });
|
||||
}, [scale, offset]);
|
||||
|
||||
// 处理平移
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||||
setIsPanning(true);
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY });
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
setHoveredCell(null);
|
||||
if (mode === 'preview' && onCellHover) {
|
||||
onCellHover(null, 0, 0);
|
||||
}
|
||||
}, [mode, onCellHover]);
|
||||
|
||||
// 触摸支持
|
||||
const touchState = useRef<{
|
||||
lastDistance: number;
|
||||
lastCenter: { x: number; y: number };
|
||||
}>({ lastDistance: 0, lastCenter: { x: 0, y: 0 } });
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (e.touches.length === 2) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
const distance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
||||
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
||||
|
||||
touchState.current = {
|
||||
lastDistance: distance,
|
||||
lastCenter: { x: centerX, y: centerY }
|
||||
};
|
||||
} else if (e.touches.length === 1) {
|
||||
const coords = getCanvasCoordinates(e.touches[0].clientX, e.touches[0].clientY);
|
||||
if (coords) {
|
||||
setHoveredCell({ x: coords.x, y: coords.y });
|
||||
}
|
||||
}
|
||||
}, [getCanvasCoordinates]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
const distance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
||||
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
||||
|
||||
// 缩放
|
||||
if (touchState.current.lastDistance > 0) {
|
||||
const scaleDelta = distance / touchState.current.lastDistance;
|
||||
const newScale = Math.min(Math.max(scale * scaleDelta, 0.1), 5);
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const canvasCenterX = centerX - rect.left;
|
||||
const canvasCenterY = centerY - rect.top;
|
||||
|
||||
const scaleChange = newScale / scale;
|
||||
const newOffsetX = canvasCenterX - (canvasCenterX - offset.x) * scaleChange;
|
||||
const newOffsetY = canvasCenterY - (canvasCenterY - offset.y) * scaleChange;
|
||||
|
||||
setScale(newScale);
|
||||
setOffset({ x: newOffsetX, y: newOffsetY });
|
||||
}
|
||||
}
|
||||
|
||||
// 平移
|
||||
const deltaX = centerX - touchState.current.lastCenter.x;
|
||||
const deltaY = centerY - touchState.current.lastCenter.y;
|
||||
|
||||
setOffset(prev => ({
|
||||
x: prev.x + deltaX,
|
||||
y: prev.y + deltaY
|
||||
}));
|
||||
|
||||
touchState.current = {
|
||||
lastDistance: distance,
|
||||
lastCenter: { x: centerX, y: centerY }
|
||||
};
|
||||
}
|
||||
}, [scale, offset]);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (e.touches.length === 0) {
|
||||
setHoveredCell(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置画布尺寸
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
renderCanvas();
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
if (onCanvasReady) {
|
||||
onCanvasReady(canvas);
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('resize', resizeCanvas);
|
||||
}, [renderCanvas, onCanvasReady]);
|
||||
|
||||
// 渲染循环
|
||||
useEffect(() => {
|
||||
renderCanvas();
|
||||
}, [renderCanvas]);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full h-full overflow-hidden ${className}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
|
||||
{/* 缩放控制按钮 */}
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setScale(prev => Math.min(prev * 1.2, 5))}
|
||||
className="p-2 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow-lg hover:bg-white dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="放大"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScale(prev => Math.max(prev / 1.2, 0.1))}
|
||||
className="p-2 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow-lg hover:bg-white dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="缩小"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}}
|
||||
className="p-2 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow-lg hover:bg-white dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="重置视图"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 模式指示器 */}
|
||||
<div className="absolute top-4 left-4 px-3 py-1 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow-lg">
|
||||
<span className="text-sm font-medium">
|
||||
{mode === 'preview' ? '预览模式' : '编辑模式'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
|
||||
export type RecommendMode = 'nearest' | 'largest' | 'edge-first';
|
||||
|
||||
interface Region {
|
||||
cells: Array<{ x: number; y: number }>;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export function useEditMode(pixelGrid: MappedPixel[][]) {
|
||||
const [currentColorKey, setCurrentColorKey] = useState<string>('');
|
||||
const [markedCells, setMarkedCells] = useState<Set<string>>(new Set());
|
||||
const [recommendedRegion, setRecommendedRegion] = useState<Region['bounds'] | null>(null);
|
||||
const [recommendMode, setRecommendMode] = useState<RecommendMode>('nearest');
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [lastMarkedPosition, setLastMarkedPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 启动/停止计时器
|
||||
useEffect(() => {
|
||||
if (isTimerRunning) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsedTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isTimerRunning]);
|
||||
|
||||
// 获取连通区域
|
||||
const getConnectedRegion = useCallback((startX: number, startY: number, targetKey: string): Array<{ x: number; y: number }> => {
|
||||
const visited = new Set<string>();
|
||||
const region: Array<{ x: number; y: number }> = [];
|
||||
const stack = [{ x: startX, y: startY }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { x, y } = stack.pop()!;
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
|
||||
if (
|
||||
x < 0 || x >= pixelGrid[0].length ||
|
||||
y < 0 || y >= pixelGrid.length ||
|
||||
pixelGrid[y][x].key !== targetKey ||
|
||||
pixelGrid[y][x].isExternal
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.push({ x, y });
|
||||
|
||||
// 添加相邻格子
|
||||
stack.push({ x: x + 1, y });
|
||||
stack.push({ x: x - 1, y });
|
||||
stack.push({ x, y: y + 1 });
|
||||
stack.push({ x, y: y - 1 });
|
||||
}
|
||||
|
||||
return region;
|
||||
}, [pixelGrid]);
|
||||
|
||||
// 查找所有未标记的区域
|
||||
const findUnmarkedRegions = useCallback((): Region[] => {
|
||||
const visited = new Set<string>();
|
||||
const regions: Region[] = [];
|
||||
|
||||
for (let y = 0; y < pixelGrid.length; y++) {
|
||||
for (let x = 0; x < pixelGrid[0].length; x++) {
|
||||
const key = `${x},${y}`;
|
||||
const pixel = pixelGrid[y][x];
|
||||
|
||||
if (
|
||||
!visited.has(key) &&
|
||||
pixel.key === currentColorKey &&
|
||||
!pixel.isExternal &&
|
||||
!markedCells.has(key)
|
||||
) {
|
||||
const cells = getConnectedRegion(x, y, currentColorKey);
|
||||
cells.forEach(cell => visited.add(`${cell.x},${cell.y}`));
|
||||
|
||||
if (cells.length > 0) {
|
||||
const bounds = {
|
||||
x: Math.min(...cells.map(c => c.x)),
|
||||
y: Math.min(...cells.map(c => c.y)),
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
bounds.width = Math.max(...cells.map(c => c.x)) - bounds.x + 1;
|
||||
bounds.height = Math.max(...cells.map(c => c.y)) - bounds.y + 1;
|
||||
|
||||
regions.push({ cells, bounds });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return regions;
|
||||
}, [pixelGrid, currentColorKey, markedCells, getConnectedRegion]);
|
||||
|
||||
// 推荐下一个区域
|
||||
const recommendNextRegion = useCallback(() => {
|
||||
const regions = findUnmarkedRegions();
|
||||
if (regions.length === 0) {
|
||||
setRecommendedRegion(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedRegion: Region | null = null;
|
||||
|
||||
switch (recommendMode) {
|
||||
case 'largest':
|
||||
selectedRegion = regions.reduce((largest, region) =>
|
||||
region.cells.length > largest.cells.length ? region : largest
|
||||
);
|
||||
break;
|
||||
|
||||
case 'nearest':
|
||||
if (lastMarkedPosition) {
|
||||
selectedRegion = regions.reduce((nearest, region) => {
|
||||
const nearestDist = Math.min(...nearest.cells.map(cell =>
|
||||
Math.hypot(cell.x - lastMarkedPosition.x, cell.y - lastMarkedPosition.y)
|
||||
));
|
||||
const regionDist = Math.min(...region.cells.map(cell =>
|
||||
Math.hypot(cell.x - lastMarkedPosition.x, cell.y - lastMarkedPosition.y)
|
||||
));
|
||||
return regionDist < nearestDist ? region : nearest;
|
||||
});
|
||||
} else {
|
||||
selectedRegion = regions[0];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'edge-first':
|
||||
selectedRegion = regions.reduce((edgiest, region) => {
|
||||
const edgeCount = region.cells.filter(cell => {
|
||||
const x = cell.x;
|
||||
const y = cell.y;
|
||||
return x === 0 || y === 0 ||
|
||||
x === pixelGrid[0].length - 1 ||
|
||||
y === pixelGrid.length - 1;
|
||||
}).length;
|
||||
const edgiestCount = edgiest.cells.filter(cell => {
|
||||
const x = cell.x;
|
||||
const y = cell.y;
|
||||
return x === 0 || y === 0 ||
|
||||
x === pixelGrid[0].length - 1 ||
|
||||
y === pixelGrid.length - 1;
|
||||
}).length;
|
||||
return edgeCount > edgiestCount ? region : edgiest;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (selectedRegion) {
|
||||
setRecommendedRegion(selectedRegion.bounds);
|
||||
}
|
||||
}, [findUnmarkedRegions, recommendMode, lastMarkedPosition, pixelGrid]);
|
||||
|
||||
// 标记单元格
|
||||
const markCell = useCallback((x: number, y: number) => {
|
||||
const pixel = pixelGrid[y]?.[x];
|
||||
if (!pixel || pixel.key !== currentColorKey || pixel.isExternal) return;
|
||||
|
||||
const region = getConnectedRegion(x, y, currentColorKey);
|
||||
const newMarkedCells = new Set(markedCells);
|
||||
let hasChanges = false;
|
||||
|
||||
region.forEach(cell => {
|
||||
const key = `${cell.x},${cell.y}`;
|
||||
if (!newMarkedCells.has(key)) {
|
||||
newMarkedCells.add(key);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setMarkedCells(newMarkedCells);
|
||||
setLastMarkedPosition({ x, y });
|
||||
|
||||
// 开始计时
|
||||
if (!isTimerRunning) {
|
||||
setIsTimerRunning(true);
|
||||
}
|
||||
}
|
||||
}, [pixelGrid, currentColorKey, markedCells, getConnectedRegion, isTimerRunning]);
|
||||
|
||||
// 计算进度
|
||||
const calculateProgress = useCallback(() => {
|
||||
if (!currentColorKey) return { completed: 0, total: 0, percentage: 0 };
|
||||
|
||||
let total = 0;
|
||||
let completed = 0;
|
||||
|
||||
for (let y = 0; y < pixelGrid.length; y++) {
|
||||
for (let x = 0; x < pixelGrid[0].length; x++) {
|
||||
const pixel = pixelGrid[y][x];
|
||||
if (pixel.key === currentColorKey && !pixel.isExternal) {
|
||||
total++;
|
||||
if (markedCells.has(`${x},${y}`)) {
|
||||
completed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { completed, total, percentage };
|
||||
}, [pixelGrid, currentColorKey, markedCells]);
|
||||
|
||||
// 切换颜色
|
||||
const switchColor = useCallback((colorKey: string) => {
|
||||
setCurrentColorKey(colorKey);
|
||||
setMarkedCells(new Set());
|
||||
setRecommendedRegion(null);
|
||||
setLastMarkedPosition(null);
|
||||
}, []);
|
||||
|
||||
// 重置编辑状态
|
||||
const resetEditMode = useCallback(() => {
|
||||
setCurrentColorKey('');
|
||||
setMarkedCells(new Set());
|
||||
setRecommendedRegion(null);
|
||||
setLastMarkedPosition(null);
|
||||
setElapsedTime(0);
|
||||
setIsTimerRunning(false);
|
||||
}, []);
|
||||
|
||||
// 自动推荐下一个区域
|
||||
useEffect(() => {
|
||||
if (currentColorKey) {
|
||||
recommendNextRegion();
|
||||
}
|
||||
}, [currentColorKey, markedCells, recommendNextRegion]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentColorKey,
|
||||
markedCells,
|
||||
recommendedRegion,
|
||||
recommendMode,
|
||||
isTimerRunning,
|
||||
elapsedTime,
|
||||
|
||||
// 方法
|
||||
markCell,
|
||||
switchColor,
|
||||
setRecommendMode,
|
||||
calculateProgress,
|
||||
resetEditMode,
|
||||
toggleTimer: () => setIsTimerRunning(prev => !prev),
|
||||
};
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
|
||||
export type PreviewTool = 'view' | 'paint' | 'erase' | 'replace';
|
||||
|
||||
export function usePreviewMode(
|
||||
pixelGrid: MappedPixel[][],
|
||||
onPixelGridUpdate: (newGrid: MappedPixel[][]) => void
|
||||
) {
|
||||
const [selectedTool, setSelectedTool] = useState<PreviewTool>('view');
|
||||
const [selectedColorKey, setSelectedColorKey] = useState<string>('');
|
||||
const [highlightColorKey, setHighlightColorKey] = useState<string | null>(null);
|
||||
const [replaceModeState, setReplaceModeState] = useState<{
|
||||
isActive: boolean;
|
||||
sourceColor?: string;
|
||||
}>({ isActive: false });
|
||||
|
||||
// 获取连通区域(用于洪水填充)
|
||||
const getConnectedRegion = useCallback((
|
||||
startX: number,
|
||||
startY: number,
|
||||
targetKey: string
|
||||
): Array<{ x: number; y: number }> => {
|
||||
const visited = new Set<string>();
|
||||
const region: Array<{ x: number; y: number }> = [];
|
||||
const stack = [{ x: startX, y: startY }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { x, y } = stack.pop()!;
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
|
||||
if (
|
||||
x < 0 || x >= pixelGrid[0].length ||
|
||||
y < 0 || y >= pixelGrid.length ||
|
||||
pixelGrid[y][x].key !== targetKey ||
|
||||
pixelGrid[y][x].isExternal
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
region.push({ x, y });
|
||||
|
||||
// 添加相邻格子
|
||||
stack.push({ x: x + 1, y });
|
||||
stack.push({ x: x - 1, y });
|
||||
stack.push({ x, y: y + 1 });
|
||||
stack.push({ x, y: y - 1 });
|
||||
}
|
||||
|
||||
return region;
|
||||
}, [pixelGrid]);
|
||||
|
||||
// 处理单元格点击
|
||||
const handleCellClick = useCallback((pixel: MappedPixel, x: number, y: number) => {
|
||||
if (!pixel || pixel.isExternal) return;
|
||||
|
||||
switch (selectedTool) {
|
||||
case 'view':
|
||||
// 仅查看,不做操作
|
||||
break;
|
||||
|
||||
case 'paint':
|
||||
if (selectedColorKey && pixel.key !== selectedColorKey) {
|
||||
const newGrid = pixelGrid.map(row => [...row]);
|
||||
newGrid[y][x] = {
|
||||
...pixel,
|
||||
key: selectedColorKey,
|
||||
color: selectedColorKey
|
||||
};
|
||||
onPixelGridUpdate(newGrid);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'erase':
|
||||
// 洪水填充擦除
|
||||
const region = getConnectedRegion(x, y, pixel.key);
|
||||
if (region.length > 0) {
|
||||
const newGrid = pixelGrid.map(row => [...row]);
|
||||
region.forEach(cell => {
|
||||
newGrid[cell.y][cell.x] = {
|
||||
...newGrid[cell.y][cell.x],
|
||||
key: 'transparent',
|
||||
color: '#ffffff'
|
||||
};
|
||||
});
|
||||
onPixelGridUpdate(newGrid);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'replace':
|
||||
if (replaceModeState.isActive && replaceModeState.sourceColor) {
|
||||
// 执行颜色替换
|
||||
if (selectedColorKey && pixel.key !== selectedColorKey) {
|
||||
const newGrid = pixelGrid.map(row => [...row]);
|
||||
for (let y = 0; y < newGrid.length; y++) {
|
||||
for (let x = 0; x < newGrid[0].length; x++) {
|
||||
if (newGrid[y][x].key === replaceModeState.sourceColor) {
|
||||
newGrid[y][x] = {
|
||||
...newGrid[y][x],
|
||||
key: selectedColorKey,
|
||||
color: selectedColorKey
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
onPixelGridUpdate(newGrid);
|
||||
setReplaceModeState({ isActive: false });
|
||||
}
|
||||
} else {
|
||||
// 选择源颜色
|
||||
setReplaceModeState({
|
||||
isActive: true,
|
||||
sourceColor: pixel.key
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [selectedTool, selectedColorKey, replaceModeState, pixelGrid, getConnectedRegion, onPixelGridUpdate]);
|
||||
|
||||
// 切换高亮颜色
|
||||
const toggleHighlight = useCallback((colorKey: string) => {
|
||||
setHighlightColorKey(prev => prev === colorKey ? null : colorKey);
|
||||
}, []);
|
||||
|
||||
// 重置工具状态
|
||||
const resetToolState = useCallback(() => {
|
||||
setReplaceModeState({ isActive: false });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
selectedTool,
|
||||
selectedColorKey,
|
||||
highlightColorKey,
|
||||
replaceModeState,
|
||||
|
||||
// 方法
|
||||
setSelectedTool,
|
||||
setSelectedColorKey,
|
||||
handleCellClick,
|
||||
toggleHighlight,
|
||||
resetToolState,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user