新增悬浮调色盘和工具栏组件,优化手动上色模式提示信息,提升用户交互体验和界面友好性。
This commit is contained in:
137
src/app/page.tsx
137
src/app/page.tsx
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useRef, ChangeEvent, DragEvent, useEffect, useMemo, useCallback } from 'react';
|
||||
import Script from 'next/script';
|
||||
import ColorPalette from '../components/ColorPalette';
|
||||
|
||||
// 导入像素化工具和类型
|
||||
import {
|
||||
PixelationMode,
|
||||
@@ -79,19 +79,16 @@ const fullBeadPalette: PaletteColor[] = Object.entries(mardToHexMapping)
|
||||
})
|
||||
.filter((color): color is PaletteColor => color !== null);
|
||||
|
||||
// ++ 添加透明键定义 ++
|
||||
const TRANSPARENT_KEY = 'ERASE';
|
||||
|
||||
// ++ 添加透明色数据 ++
|
||||
const transparentColorData: MappedPixel = { key: TRANSPARENT_KEY, color: '#FFFFFF', isExternal: true };
|
||||
|
||||
// ++ Add definition for background color keys ++
|
||||
|
||||
// 1. 导入新组件
|
||||
import PixelatedPreviewCanvas from '../components/PixelatedPreviewCanvas';
|
||||
import GridTooltip from '../components/GridTooltip';
|
||||
import CustomPaletteEditor from '../components/CustomPaletteEditor';
|
||||
import FloatingColorPalette from '../components/FloatingColorPalette';
|
||||
import FloatingToolbar from '../components/FloatingToolbar';
|
||||
import { loadPaletteSelections, savePaletteSelections, presetToSelections, PaletteSelections } from '../utils/localStorageUtils';
|
||||
import { TRANSPARENT_KEY, transparentColorData } from '../utils/pixelEditingUtils';
|
||||
|
||||
// 1. 导入新的 DonationModal 组件
|
||||
import DonationModal from '../components/DonationModal';
|
||||
@@ -161,6 +158,9 @@ export default function Home() {
|
||||
// 新增:组件挂载状态
|
||||
const [isMounted, setIsMounted] = useState<boolean>(false);
|
||||
|
||||
// 新增:悬浮调色盘状态
|
||||
const [isFloatingPaletteOpen, setIsFloatingPaletteOpen] = useState<boolean>(true);
|
||||
|
||||
const originalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pixelatedCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -1810,78 +1810,28 @@ export default function Home() {
|
||||
<div className="w-full md:max-w-2xl">
|
||||
<canvas ref={originalCanvasRef} className="hidden"></canvas>
|
||||
|
||||
{/* ++ RENDER Button/Palette ONLY in manual mode above canvas ++ */}
|
||||
{/* ++ 手动编辑模式提示信息 ++ */}
|
||||
{isManualColoringMode && mappedPixelData && gridDimensions && (
|
||||
// Apply dark mode styles to manual mode container
|
||||
<div className="w-full mb-4 p-4 bg-blue-50 dark:bg-gray-800 rounded-xl shadow-md border border-blue-100 dark:border-gray-700">
|
||||
{/* Finish Manual Coloring Button (already has distinct colors, maybe keep as is) */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsManualColoringMode(false); // Always exit mode here
|
||||
setSelectedColor(null);
|
||||
setTooltipData(null);
|
||||
setIsEraseMode(false); // 重置擦除模式状态
|
||||
}}
|
||||
className={`w-full py-2.5 px-4 text-sm sm:text-base rounded-lg transition-all duration-200 flex items-center justify-center gap-2 bg-red-500 hover:bg-red-600 text-white shadow-sm hover:shadow-md`} // Keep red for contrast?
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg>
|
||||
完成手动编辑
|
||||
</button>
|
||||
{/* Color Palette (only in manual mode) */}
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-center mb-3">
|
||||
{/* Apply dark mode styles to the info box */}
|
||||
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-100 dark:border-gray-600 rounded-lg p-2 flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 text-xs text-gray-600 dark:text-gray-300 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-1 w-full sm:w-auto">
|
||||
{/* Icon color */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
{/* Text color implicitly handled by parent */}
|
||||
<span>选择颜色/橡皮擦/一键擦除,点击画布格子上色</span>
|
||||
</div>
|
||||
{/* Separator color */}
|
||||
<span className="hidden sm:inline text-gray-300 dark:text-gray-500">|</span>
|
||||
<div className="flex items-center gap-1 w-full sm:w-auto">
|
||||
{/* Icon color */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{/* Text color implicitly handled by parent */}
|
||||
<span>为避免误触,推荐使用电脑</span>
|
||||
</div>
|
||||
{/* Separator color */}
|
||||
<span className="hidden sm:inline text-gray-300 dark:text-gray-500">|</span>
|
||||
<div className="flex items-center gap-1 w-full sm:w-auto">
|
||||
{/* Icon color */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{/* Text color implicitly handled by parent */}
|
||||
<span>Ctrl/Cmd+滚轮缩放</span>
|
||||
</div>
|
||||
<div className="w-full mb-4 p-3 bg-blue-50 dark:bg-gray-800 rounded-lg shadow-sm border border-blue-100 dark:border-gray-700">
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-100 dark:border-gray-600 rounded-lg p-2 flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 text-xs text-gray-600 dark:text-gray-300 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-1 w-full sm:w-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<span>使用右上角悬浮调色盘进行上色操作</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300 dark:text-gray-500">|</span>
|
||||
<div className="flex items-center gap-1 w-full sm:w-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Ctrl/Cmd+滚轮缩放</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ColorPalette component will need internal dark mode styles */}
|
||||
<ColorPalette
|
||||
colors={[transparentColorData, ...currentGridColors]}
|
||||
selectedColor={selectedColor}
|
||||
onColorSelect={handleColorSelect}
|
||||
transparentKey={TRANSPARENT_KEY}
|
||||
selectedColorSystem={selectedColorSystem}
|
||||
isEraseMode={isEraseMode}
|
||||
onEraseToggle={handleEraseToggle}
|
||||
onHighlightColor={handleHighlightColor}
|
||||
fullPaletteColors={fullPaletteColors}
|
||||
showFullPalette={showFullPalette}
|
||||
onToggleFullPalette={handleToggleFullPalette}
|
||||
colorReplaceState={colorReplaceState}
|
||||
onColorReplaceToggle={handleColorReplaceToggle}
|
||||
onColorReplace={handleColorReplace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)} {/* ++ End of RENDER Button/Palette ++ */}
|
||||
)}
|
||||
|
||||
{/* Canvas Preview Container */}
|
||||
{/* Apply dark mode styles */}
|
||||
@@ -2109,6 +2059,45 @@ export default function Home() {
|
||||
|
||||
</main>
|
||||
|
||||
{/* 悬浮工具栏 */}
|
||||
<FloatingToolbar
|
||||
isManualColoringMode={isManualColoringMode}
|
||||
isPaletteOpen={isFloatingPaletteOpen}
|
||||
onTogglePalette={() => setIsFloatingPaletteOpen(!isFloatingPaletteOpen)}
|
||||
onExitManualMode={() => {
|
||||
setIsManualColoringMode(false);
|
||||
setSelectedColor(null);
|
||||
setTooltipData(null);
|
||||
setIsEraseMode(false);
|
||||
setColorReplaceState({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
setHighlightColorKey(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 悬浮调色盘 */}
|
||||
{isManualColoringMode && (
|
||||
<FloatingColorPalette
|
||||
colors={currentGridColors}
|
||||
selectedColor={selectedColor}
|
||||
onColorSelect={handleColorSelect}
|
||||
selectedColorSystem={selectedColorSystem}
|
||||
isEraseMode={isEraseMode}
|
||||
onEraseToggle={handleEraseToggle}
|
||||
fullPaletteColors={fullPaletteColors}
|
||||
showFullPalette={showFullPalette}
|
||||
onToggleFullPalette={handleToggleFullPalette}
|
||||
colorReplaceState={colorReplaceState}
|
||||
onColorReplaceToggle={handleColorReplaceToggle}
|
||||
onColorReplace={handleColorReplace}
|
||||
onHighlightColor={handleHighlightColor}
|
||||
isOpen={isFloatingPaletteOpen}
|
||||
onToggleOpen={() => setIsFloatingPaletteOpen(!isFloatingPaletteOpen)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Apply dark mode styles to the Footer */}
|
||||
<footer className="w-full md:max-w-4xl mt-10 mb-6 py-6 text-center text-xs sm:text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-gray-800/50 rounded-lg shadow-inner">
|
||||
|
||||
|
||||
320
src/components/FloatingColorPalette.tsx
Normal file
320
src/components/FloatingColorPalette.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
import { TRANSPARENT_KEY } from '../utils/pixelEditingUtils';
|
||||
import { ColorReplaceState } from '../hooks/useManualEditingState';
|
||||
import { ColorSystem, getColorKeyByHex } from '../utils/colorSystemUtils';
|
||||
|
||||
interface FloatingColorPaletteProps {
|
||||
colors: { key: string; color: string }[];
|
||||
selectedColor: MappedPixel | null;
|
||||
onColorSelect: (colorData: { key: string; color: string; isExternal?: boolean }) => void;
|
||||
selectedColorSystem: ColorSystem;
|
||||
isEraseMode: boolean;
|
||||
onEraseToggle: () => void;
|
||||
fullPaletteColors: { key: string; color: string }[];
|
||||
showFullPalette: boolean;
|
||||
onToggleFullPalette: () => void;
|
||||
colorReplaceState: ColorReplaceState;
|
||||
onColorReplaceToggle: () => void;
|
||||
onColorReplace: (sourceColor: { key: string; color: string }, targetColor: { key: string; color: string }) => void;
|
||||
onHighlightColor: (colorHex: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggleOpen: () => void;
|
||||
}
|
||||
|
||||
const FloatingColorPalette: React.FC<FloatingColorPaletteProps> = ({
|
||||
colors,
|
||||
selectedColor,
|
||||
onColorSelect,
|
||||
selectedColorSystem,
|
||||
isEraseMode,
|
||||
onEraseToggle,
|
||||
fullPaletteColors,
|
||||
showFullPalette,
|
||||
onToggleFullPalette,
|
||||
colorReplaceState,
|
||||
onColorReplaceToggle,
|
||||
onColorReplace,
|
||||
onHighlightColor,
|
||||
isOpen,
|
||||
onToggleOpen
|
||||
}) => {
|
||||
const [position, setPosition] = useState({ x: 20, y: 100 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!paletteRef.current) return;
|
||||
|
||||
const rect = paletteRef.current.getBoundingClientRect();
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// 处理触摸开始
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (!paletteRef.current) return;
|
||||
|
||||
const rect = paletteRef.current.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: touch.clientX - rect.left,
|
||||
y: touch.clientY - rect.top
|
||||
});
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// 处理移动
|
||||
useEffect(() => {
|
||||
const handleMove = (clientX: number, clientY: number) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const newX = Math.max(0, Math.min(window.innerWidth - 300, clientX - dragOffset.x));
|
||||
const newY = Math.max(0, Math.min(window.innerHeight - 400, clientY - dragOffset.y));
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleMove(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length > 0) {
|
||||
handleMove(e.touches[0].clientX, e.touches[0].clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleEnd);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleEnd);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleEnd);
|
||||
};
|
||||
}
|
||||
}, [isDragging, dragOffset]);
|
||||
|
||||
// 响应窗口大小变化,确保调色盘不会超出边界
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setPosition(prev => ({
|
||||
x: Math.min(prev.x, window.innerWidth - 300),
|
||||
y: Math.min(prev.y, window.innerHeight - 400)
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 处理颜色点击
|
||||
const handleColorClick = (colorData: { key: string; color: string }) => {
|
||||
if (colorReplaceState.isActive && colorReplaceState.step === 'select-target' && colorReplaceState.sourceColor) {
|
||||
// 执行颜色替换
|
||||
onColorReplace(colorReplaceState.sourceColor, colorData);
|
||||
} else {
|
||||
// 高亮颜色
|
||||
onHighlightColor(colorData.color);
|
||||
// 选择颜色
|
||||
onColorSelect(colorData);
|
||||
}
|
||||
};
|
||||
|
||||
const displayColors = showFullPalette ? fullPaletteColors : colors;
|
||||
|
||||
// 如果调色盘关闭,完全不渲染
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paletteRef}
|
||||
className="fixed bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-600 z-50 select-none"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: '280px',
|
||||
maxHeight: '400px'
|
||||
}}
|
||||
>
|
||||
{/* 标题栏和控制按钮 */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-t-xl cursor-move"
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">调色盘</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onToggleOpen}
|
||||
className="p-1 hover:bg-white/20 rounded transition-colors"
|
||||
title="关闭调色盘"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="p-3 max-h-80 overflow-y-auto">
|
||||
{/* 模式状态指示器 */}
|
||||
{colorReplaceState.isActive && (
|
||||
<div className="mb-3 p-2 bg-orange-100 dark:bg-orange-900/30 border border-orange-200 dark:border-orange-800 rounded-lg text-xs">
|
||||
<div className="flex items-center gap-1 text-orange-700 dark:text-orange-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
<span>
|
||||
{colorReplaceState.step === 'select-source' ? '点击画布选择要替换的颜色' : '选择目标颜色'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具按钮行 */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
{/* 橡皮擦按钮 */}
|
||||
<button
|
||||
onClick={() => handleColorClick({ key: TRANSPARENT_KEY, color: '#FFFFFF' })}
|
||||
className={`flex-1 p-2 rounded-lg border transition-all duration-200 flex items-center justify-center gap-1 text-xs ${
|
||||
selectedColor?.key === TRANSPARENT_KEY
|
||||
? 'bg-red-500 text-white border-red-500'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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={onEraseToggle}
|
||||
className={`flex-1 p-2 rounded-lg border transition-all duration-200 flex items-center justify-center gap-1 text-xs ${
|
||||
isEraseMode
|
||||
? 'bg-orange-500 text-white border-orange-500'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
区域擦除
|
||||
</button>
|
||||
|
||||
{/* 颜色替换按钮 */}
|
||||
<button
|
||||
onClick={onColorReplaceToggle}
|
||||
className={`flex-1 p-2 rounded-lg border transition-all duration-200 flex items-center justify-center gap-1 text-xs ${
|
||||
colorReplaceState.isActive
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
批量替换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 色板切换 */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={onToggleFullPalette}
|
||||
className="w-full text-xs py-2 px-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{showFullPalette ? `当前色板 (${colors.length})` : `完整色板 (${fullPaletteColors.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 颜色网格 */}
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{displayColors.map((colorData) => {
|
||||
const isSelected = selectedColor?.key === colorData.key && selectedColor?.color === colorData.color;
|
||||
const displayKey = getColorKeyByHex(colorData.color, selectedColorSystem);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${colorData.key}-${colorData.color}`}
|
||||
onClick={() => handleColorClick(colorData)}
|
||||
className={`group relative aspect-square rounded-lg border-2 transition-all duration-200 hover:scale-110 ${
|
||||
isSelected
|
||||
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-200 dark:ring-blue-800 scale-110'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
style={{ backgroundColor: colorData.color }}
|
||||
title={`${displayKey} (${colorData.color})`}
|
||||
>
|
||||
{/* 选中指示器 */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-white rounded-full shadow-lg"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停时显示色号 */}
|
||||
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-gray-800 dark:bg-gray-700 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10">
|
||||
{displayKey}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中颜色信息 */}
|
||||
{selectedColor && selectedColor.key !== TRANSPARENT_KEY && (
|
||||
<div className="mt-3 p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-gray-300 dark:border-gray-500"
|
||||
style={{ backgroundColor: selectedColor.color }}
|
||||
></div>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
当前: {getColorKeyByHex(selectedColor.color, selectedColorSystem)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingColorPalette;
|
||||
51
src/components/FloatingToolbar.tsx
Normal file
51
src/components/FloatingToolbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface FloatingToolbarProps {
|
||||
isManualColoringMode: boolean;
|
||||
isPaletteOpen: boolean;
|
||||
onTogglePalette: () => void;
|
||||
onExitManualMode: () => void;
|
||||
}
|
||||
|
||||
const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
isManualColoringMode,
|
||||
isPaletteOpen,
|
||||
onTogglePalette,
|
||||
onExitManualMode
|
||||
}) => {
|
||||
if (!isManualColoringMode) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-40 flex flex-col gap-2">
|
||||
{/* 调色盘开关按钮 */}
|
||||
<button
|
||||
onClick={onTogglePalette}
|
||||
className={`w-12 h-12 rounded-full shadow-lg transition-all duration-200 flex items-center justify-center ${
|
||||
isPaletteOpen
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
title={isPaletteOpen ? '关闭调色盘' : '打开调色盘'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 退出手动编辑模式按钮 */}
|
||||
<button
|
||||
onClick={onExitManualMode}
|
||||
className="w-12 h-12 rounded-full bg-red-500 text-white shadow-lg hover:bg-red-600 transition-all duration-200 flex items-center justify-center"
|
||||
title="退出手动编辑模式"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingToolbar;
|
||||
173
src/hooks/useManualEditingState.ts
Normal file
173
src/hooks/useManualEditingState.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
|
||||
// 颜色替换状态类型
|
||||
export interface ColorReplaceState {
|
||||
isActive: boolean;
|
||||
step: 'select-source' | 'select-target';
|
||||
sourceColor?: { key: string; color: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动编辑状态管理hook
|
||||
*/
|
||||
export function useManualEditingState() {
|
||||
// 手动上色模式
|
||||
const [isManualColoringMode, setIsManualColoringMode] = useState<boolean>(false);
|
||||
|
||||
// 选中的颜色
|
||||
const [selectedColor, setSelectedColor] = useState<MappedPixel | null>(null);
|
||||
|
||||
// 一键擦除模式
|
||||
const [isEraseMode, setIsEraseMode] = useState<boolean>(false);
|
||||
|
||||
// 颜色替换状态
|
||||
const [colorReplaceState, setColorReplaceState] = useState<ColorReplaceState>({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
|
||||
// 高亮颜色键
|
||||
const [highlightColorKey, setHighlightColorKey] = useState<string | null>(null);
|
||||
|
||||
// 进入手动编辑模式
|
||||
const enterManualMode = useCallback(() => {
|
||||
setIsManualColoringMode(true);
|
||||
setSelectedColor(null);
|
||||
setIsEraseMode(false);
|
||||
setColorReplaceState({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
setHighlightColorKey(null);
|
||||
}, []);
|
||||
|
||||
// 退出手动编辑模式
|
||||
const exitManualMode = useCallback(() => {
|
||||
setIsManualColoringMode(false);
|
||||
setSelectedColor(null);
|
||||
setIsEraseMode(false);
|
||||
setColorReplaceState({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
setHighlightColorKey(null);
|
||||
}, []);
|
||||
|
||||
// 切换擦除模式
|
||||
const toggleEraseMode = useCallback(() => {
|
||||
if (!isManualColoringMode) return;
|
||||
|
||||
if (colorReplaceState.isActive) {
|
||||
setColorReplaceState({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
setHighlightColorKey(null);
|
||||
}
|
||||
|
||||
setIsEraseMode(!isEraseMode);
|
||||
if (!isEraseMode) {
|
||||
setSelectedColor(null);
|
||||
}
|
||||
}, [isManualColoringMode, isEraseMode, colorReplaceState.isActive]);
|
||||
|
||||
// 切换颜色替换模式
|
||||
const toggleColorReplaceMode = useCallback(() => {
|
||||
setColorReplaceState(prev => {
|
||||
if (prev.isActive) {
|
||||
setHighlightColorKey(null);
|
||||
return {
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
};
|
||||
} else {
|
||||
setIsEraseMode(false);
|
||||
setSelectedColor(null);
|
||||
return {
|
||||
isActive: true,
|
||||
step: 'select-source'
|
||||
};
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 选择颜色
|
||||
const selectColor = useCallback((colorData: { key: string; color: string; isExternal?: boolean }) => {
|
||||
const TRANSPARENT_KEY = 'ERASE';
|
||||
|
||||
// 如果选择的是橡皮擦且当前在颜色替换模式,退出替换模式
|
||||
if (colorData.key === TRANSPARENT_KEY && colorReplaceState.isActive) {
|
||||
setColorReplaceState({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
setHighlightColorKey(null);
|
||||
}
|
||||
|
||||
// 选择任何颜色时,都应该退出一键擦除模式
|
||||
if (isEraseMode) {
|
||||
setIsEraseMode(false);
|
||||
}
|
||||
|
||||
setSelectedColor(colorData);
|
||||
}, [isEraseMode, colorReplaceState.isActive]);
|
||||
|
||||
// 从画布选择源颜色(用于颜色替换)
|
||||
const selectSourceColorFromCanvas = useCallback((colorData: { key: string; color: string }) => {
|
||||
if (colorReplaceState.isActive && colorReplaceState.step === 'select-source') {
|
||||
setHighlightColorKey(colorData.color);
|
||||
setColorReplaceState({
|
||||
isActive: true,
|
||||
step: 'select-target',
|
||||
sourceColor: colorData
|
||||
});
|
||||
}
|
||||
}, [colorReplaceState]);
|
||||
|
||||
// 完成颜色替换
|
||||
const completeColorReplace = useCallback(() => {
|
||||
setColorReplaceState({
|
||||
isActive: false,
|
||||
step: 'select-source'
|
||||
});
|
||||
setHighlightColorKey(null);
|
||||
}, []);
|
||||
|
||||
// 设置高亮颜色
|
||||
const setHighlight = useCallback((colorHex: string) => {
|
||||
setHighlightColorKey(colorHex);
|
||||
}, []);
|
||||
|
||||
// 清除高亮
|
||||
const clearHighlight = useCallback(() => {
|
||||
setHighlightColorKey(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isManualColoringMode,
|
||||
selectedColor,
|
||||
isEraseMode,
|
||||
colorReplaceState,
|
||||
highlightColorKey,
|
||||
|
||||
// 操作函数
|
||||
enterManualMode,
|
||||
exitManualMode,
|
||||
toggleEraseMode,
|
||||
toggleColorReplaceMode,
|
||||
selectColor,
|
||||
selectSourceColorFromCanvas,
|
||||
completeColorReplace,
|
||||
setHighlight,
|
||||
clearHighlight,
|
||||
|
||||
// 直接设置函数(用于特殊情况)
|
||||
setIsManualColoringMode,
|
||||
setSelectedColor,
|
||||
setIsEraseMode,
|
||||
setColorReplaceState,
|
||||
setHighlightColorKey
|
||||
};
|
||||
}
|
||||
136
src/hooks/usePixelEditingOperations.ts
Normal file
136
src/hooks/usePixelEditingOperations.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useCallback } from 'react';
|
||||
import { MappedPixel } from '../utils/pixelation';
|
||||
import {
|
||||
floodFillErase,
|
||||
replaceColor,
|
||||
paintSinglePixel,
|
||||
recalculateColorStats,
|
||||
TRANSPARENT_KEY
|
||||
} from '../utils/pixelEditingUtils';
|
||||
|
||||
interface UsePixelEditingOperationsProps {
|
||||
mappedPixelData: MappedPixel[][] | null;
|
||||
gridDimensions: { N: number; M: number } | null;
|
||||
colorCounts: { [key: string]: { count: number; color: string } } | null;
|
||||
totalBeadCount: number;
|
||||
onPixelDataChange: (newData: MappedPixel[][]) => void;
|
||||
onColorCountsChange: (counts: { [key: string]: { count: number; color: string } }) => void;
|
||||
onTotalCountChange: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 像素编辑操作hook
|
||||
*/
|
||||
export function usePixelEditingOperations({
|
||||
mappedPixelData,
|
||||
gridDimensions,
|
||||
colorCounts,
|
||||
totalBeadCount,
|
||||
onPixelDataChange,
|
||||
onColorCountsChange,
|
||||
onTotalCountChange
|
||||
}: UsePixelEditingOperationsProps) {
|
||||
|
||||
// 执行洪水填充擦除
|
||||
const performFloodFillErase = useCallback((
|
||||
startRow: number,
|
||||
startCol: number,
|
||||
targetKey: string
|
||||
) => {
|
||||
if (!mappedPixelData || !gridDimensions) return;
|
||||
|
||||
const newPixelData = floodFillErase(mappedPixelData, gridDimensions, startRow, startCol, targetKey);
|
||||
onPixelDataChange(newPixelData);
|
||||
|
||||
// 重新计算颜色统计
|
||||
const { colorCounts: newColorCounts, totalCount: newTotalCount } = recalculateColorStats(newPixelData);
|
||||
onColorCountsChange(newColorCounts);
|
||||
onTotalCountChange(newTotalCount);
|
||||
}, [mappedPixelData, gridDimensions, onPixelDataChange, onColorCountsChange, onTotalCountChange]);
|
||||
|
||||
// 执行颜色替换
|
||||
const performColorReplace = useCallback((
|
||||
sourceColor: { key: string; color: string },
|
||||
targetColor: { key: string; color: string }
|
||||
) => {
|
||||
if (!mappedPixelData || !gridDimensions) return 0;
|
||||
|
||||
const { newPixelData, replaceCount } = replaceColor(
|
||||
mappedPixelData,
|
||||
gridDimensions,
|
||||
sourceColor,
|
||||
targetColor
|
||||
);
|
||||
|
||||
if (replaceCount > 0) {
|
||||
onPixelDataChange(newPixelData);
|
||||
|
||||
// 重新计算颜色统计
|
||||
const { colorCounts: newColorCounts, totalCount: newTotalCount } = recalculateColorStats(newPixelData);
|
||||
onColorCountsChange(newColorCounts);
|
||||
onTotalCountChange(newTotalCount);
|
||||
|
||||
console.log(`颜色替换完成:将 ${replaceCount} 个 ${sourceColor.key} 替换为 ${targetColor.key}`);
|
||||
}
|
||||
|
||||
return replaceCount;
|
||||
}, [mappedPixelData, gridDimensions, onPixelDataChange, onColorCountsChange, onTotalCountChange]);
|
||||
|
||||
// 执行单像素上色
|
||||
const performSinglePixelPaint = useCallback((
|
||||
row: number,
|
||||
col: number,
|
||||
newColor: MappedPixel
|
||||
) => {
|
||||
if (!mappedPixelData || !colorCounts) return;
|
||||
|
||||
const { newPixelData, previousCell, hasChange } = paintSinglePixel(
|
||||
mappedPixelData,
|
||||
row,
|
||||
col,
|
||||
newColor
|
||||
);
|
||||
|
||||
if (!hasChange || !previousCell) return;
|
||||
|
||||
onPixelDataChange(newPixelData);
|
||||
|
||||
// 更新颜色统计
|
||||
const newColorCounts = { ...colorCounts };
|
||||
let newTotalCount = totalBeadCount;
|
||||
|
||||
// 处理之前颜色的减少(使用hex值)
|
||||
if (!previousCell.isExternal && previousCell.key !== TRANSPARENT_KEY) {
|
||||
const previousHex = previousCell.color?.toUpperCase();
|
||||
if (previousHex && newColorCounts[previousHex]) {
|
||||
newColorCounts[previousHex].count--;
|
||||
if (newColorCounts[previousHex].count <= 0) {
|
||||
delete newColorCounts[previousHex];
|
||||
}
|
||||
newTotalCount--;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理新颜色的增加(使用hex值)
|
||||
if (!newColor.isExternal && newColor.key !== TRANSPARENT_KEY) {
|
||||
const newHex = newColor.color.toUpperCase();
|
||||
if (!newColorCounts[newHex]) {
|
||||
newColorCounts[newHex] = {
|
||||
count: 0,
|
||||
color: newHex
|
||||
};
|
||||
}
|
||||
newColorCounts[newHex].count++;
|
||||
newTotalCount++;
|
||||
}
|
||||
|
||||
onColorCountsChange(newColorCounts);
|
||||
onTotalCountChange(newTotalCount);
|
||||
}, [mappedPixelData, colorCounts, totalBeadCount, onPixelDataChange, onColorCountsChange, onTotalCountChange]);
|
||||
|
||||
return {
|
||||
performFloodFillErase,
|
||||
performColorReplace,
|
||||
performSinglePixelPaint
|
||||
};
|
||||
}
|
||||
55
src/utils/canvasUtils.ts
Normal file
55
src/utils/canvasUtils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 画布坐标计算工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将鼠标/触摸坐标转换为画布内的格子坐标
|
||||
* @param clientX 客户端X坐标
|
||||
* @param clientY 客户端Y坐标
|
||||
* @param canvas 画布元素
|
||||
* @param gridDimensions 网格尺寸
|
||||
* @returns 格子坐标 {i, j} 或 null(如果超出范围)
|
||||
*/
|
||||
export function clientToGridCoords(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
canvas: HTMLCanvasElement,
|
||||
gridDimensions: { N: number; M: number }
|
||||
): { i: number; j: number } | null {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
|
||||
const { N, M } = gridDimensions;
|
||||
const cellWidthOutput = canvas.width / N;
|
||||
const cellHeightOutput = canvas.height / M;
|
||||
|
||||
const i = Math.floor(canvasX / cellWidthOutput);
|
||||
const j = Math.floor(canvasY / cellHeightOutput);
|
||||
|
||||
// 检查是否在有效范围内
|
||||
if (i >= 0 && i < N && j >= 0 && j < M) {
|
||||
return { i, j };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查触摸是否被认为是移动而不是点击
|
||||
* @param startPos 开始位置
|
||||
* @param currentPos 当前位置
|
||||
* @param threshold 移动阈值(像素)
|
||||
* @returns 是否是移动
|
||||
*/
|
||||
export function isTouchMove(
|
||||
startPos: { x: number; y: number },
|
||||
currentPos: { x: number; y: number },
|
||||
threshold: number = 10
|
||||
): boolean {
|
||||
const dx = Math.abs(currentPos.x - startPos.x);
|
||||
const dy = Math.abs(currentPos.y - startPos.y);
|
||||
return dx > threshold || dy > threshold;
|
||||
}
|
||||
190
src/utils/pixelEditingUtils.ts
Normal file
190
src/utils/pixelEditingUtils.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { MappedPixel } from './pixelation';
|
||||
|
||||
// 透明键定义
|
||||
export const TRANSPARENT_KEY = 'ERASE';
|
||||
|
||||
// 透明色数据
|
||||
export const transparentColorData: MappedPixel = {
|
||||
key: TRANSPARENT_KEY,
|
||||
color: '#FFFFFF',
|
||||
isExternal: true
|
||||
};
|
||||
|
||||
/**
|
||||
* 洪水填充擦除算法
|
||||
* @param pixelData 当前像素数据
|
||||
* @param gridDimensions 网格尺寸
|
||||
* @param startRow 起始行
|
||||
* @param startCol 起始列
|
||||
* @param targetKey 目标颜色键
|
||||
* @returns 处理后的像素数据
|
||||
*/
|
||||
export function floodFillErase(
|
||||
pixelData: MappedPixel[][],
|
||||
gridDimensions: { N: number; M: number },
|
||||
startRow: number,
|
||||
startCol: number,
|
||||
targetKey: string
|
||||
): MappedPixel[][] {
|
||||
const { N, M } = gridDimensions;
|
||||
const newPixelData = pixelData.map(row => row.map(cell => ({ ...cell })));
|
||||
const visited = Array(M).fill(null).map(() => Array(N).fill(false));
|
||||
|
||||
// 使用栈实现非递归洪水填充
|
||||
const stack = [{ row: startRow, col: startCol }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { row, col } = stack.pop()!;
|
||||
|
||||
// 检查边界
|
||||
if (row < 0 || row >= M || col < 0 || col >= N || visited[row][col]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentCell = newPixelData[row][col];
|
||||
|
||||
// 检查是否是目标颜色且不是外部区域
|
||||
if (!currentCell || currentCell.isExternal || currentCell.key !== targetKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标记为已访问
|
||||
visited[row][col] = true;
|
||||
|
||||
// 擦除当前像素(设为透明)
|
||||
newPixelData[row][col] = { ...transparentColorData };
|
||||
|
||||
// 添加相邻像素到栈中
|
||||
stack.push(
|
||||
{ row: row - 1, col }, // 上
|
||||
{ row: row + 1, col }, // 下
|
||||
{ row, col: col - 1 }, // 左
|
||||
{ row, col: col + 1 } // 右
|
||||
);
|
||||
}
|
||||
|
||||
return newPixelData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色替换算法
|
||||
* @param pixelData 当前像素数据
|
||||
* @param gridDimensions 网格尺寸
|
||||
* @param sourceColor 源颜色
|
||||
* @param targetColor 目标颜色
|
||||
* @returns 处理后的像素数据和替换数量
|
||||
*/
|
||||
export function replaceColor(
|
||||
pixelData: MappedPixel[][],
|
||||
gridDimensions: { N: number; M: number },
|
||||
sourceColor: { key: string; color: string },
|
||||
targetColor: { key: string; color: string }
|
||||
): { newPixelData: MappedPixel[][]; replaceCount: number } {
|
||||
const { N, M } = gridDimensions;
|
||||
const newPixelData = pixelData.map(row => row.map(cell => ({ ...cell })));
|
||||
let replaceCount = 0;
|
||||
|
||||
// 遍历所有像素,替换匹配的颜色
|
||||
for (let j = 0; j < M; j++) {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const currentCell = newPixelData[j][i];
|
||||
if (currentCell && !currentCell.isExternal &&
|
||||
currentCell.color.toUpperCase() === sourceColor.color.toUpperCase()) {
|
||||
// 替换颜色
|
||||
newPixelData[j][i] = {
|
||||
key: targetColor.key,
|
||||
color: targetColor.color,
|
||||
isExternal: false
|
||||
};
|
||||
replaceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { newPixelData, replaceCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个像素上色
|
||||
* @param pixelData 当前像素数据
|
||||
* @param row 行索引
|
||||
* @param col 列索引
|
||||
* @param newColor 新颜色数据
|
||||
* @returns 处理后的像素数据和变更信息
|
||||
*/
|
||||
export function paintSinglePixel(
|
||||
pixelData: MappedPixel[][],
|
||||
row: number,
|
||||
col: number,
|
||||
newColor: MappedPixel
|
||||
): {
|
||||
newPixelData: MappedPixel[][];
|
||||
previousCell: MappedPixel | null;
|
||||
hasChange: boolean;
|
||||
} {
|
||||
const newPixelData = pixelData.map(row => row.map(cell => ({ ...cell })));
|
||||
const currentCell = newPixelData[row]?.[col];
|
||||
|
||||
if (!currentCell) {
|
||||
return {
|
||||
newPixelData: pixelData,
|
||||
previousCell: null,
|
||||
hasChange: false
|
||||
};
|
||||
}
|
||||
|
||||
const previousKey = currentCell.key;
|
||||
const wasExternal = currentCell.isExternal;
|
||||
|
||||
let newCellData: MappedPixel;
|
||||
|
||||
if (newColor.key === TRANSPARENT_KEY) {
|
||||
newCellData = { ...transparentColorData };
|
||||
} else {
|
||||
newCellData = { ...newColor, isExternal: false };
|
||||
}
|
||||
|
||||
// 检查是否有变化
|
||||
const hasChange = newCellData.key !== previousKey || newCellData.isExternal !== wasExternal;
|
||||
|
||||
if (hasChange) {
|
||||
newPixelData[row][col] = newCellData;
|
||||
}
|
||||
|
||||
return {
|
||||
newPixelData: hasChange ? newPixelData : pixelData,
|
||||
previousCell: currentCell,
|
||||
hasChange
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算颜色统计
|
||||
* @param pixelData 像素数据
|
||||
* @returns 颜色统计对象和总数
|
||||
*/
|
||||
export function recalculateColorStats(
|
||||
pixelData: MappedPixel[][]
|
||||
): {
|
||||
colorCounts: { [hexKey: string]: { count: number; color: string } };
|
||||
totalCount: number;
|
||||
} {
|
||||
const colorCounts: { [hexKey: string]: { count: number; color: string } } = {};
|
||||
let totalCount = 0;
|
||||
|
||||
pixelData.flat().forEach(cell => {
|
||||
if (cell && !cell.isExternal && cell.key !== TRANSPARENT_KEY) {
|
||||
const cellHex = cell.color.toUpperCase();
|
||||
if (!colorCounts[cellHex]) {
|
||||
colorCounts[cellHex] = {
|
||||
count: 0,
|
||||
color: cellHex
|
||||
};
|
||||
}
|
||||
colorCounts[cellHex].count++;
|
||||
totalCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return { colorCounts, totalCount };
|
||||
}
|
||||
Reference in New Issue
Block a user