新增悬浮调色盘和工具栏组件,优化手动上色模式提示信息,提升用户交互体验和界面友好性。

This commit is contained in:
zihanjian
2025-06-06 14:11:34 +08:00
parent 74b8e61b8b
commit d420299dd2
7 changed files with 988 additions and 74 deletions

View File

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

View 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;

View 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;

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

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

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