去背景

This commit is contained in:
zihanjian
2025-06-25 15:51:36 +08:00
parent abd2df7860
commit acc154abaf
8 changed files with 131 additions and 1492 deletions

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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>
);
}

View File

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

View File

@@ -169,7 +169,6 @@ const PreviewToolbar: React.FC<PreviewToolbarProps> = ({
<button
onClick={() => {
onRemoveBackgroundChange(!removeBackground);
onRegenerate();
}}
className="w-full h-8 relative"
>

View File

@@ -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>
);
}

View File

@@ -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),
};
}

View File

@@ -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,
};
}