diff --git a/src/app/page.tsx b/src/app/page.tsx index 9eb30c2..3959bb2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import React, { useState, useRef, ChangeEvent, DragEvent, TouchEvent, useEffect, // but keep it if you plan to add other images later or use the SVG icon below. // Removed unused Image import import Script from 'next/script'; // ++ 导入 Script 组件 ++ +import ColorPalette from '../components/ColorPalette'; import beadPaletteData from './beadPaletteData.json'; @@ -138,66 +139,69 @@ function sortColorKeys(a: string, b: string): number { return a.localeCompare(b); } +// ++ Interface for mapped pixel data needs updating ++ +interface MappedPixel { + key: string; + color: string; + isExternal?: boolean; // Keep this optional or ensure it's always present +} + export default function Home() { const [originalImageSrc, setOriginalImageSrc] = useState(null); - const [granularity, setGranularity] = useState(50); - const [selectedPaletteKeySet, setSelectedPaletteKeySet] = useState('all'); // Default to 'all' - const [similarityThreshold, setSimilarityThreshold] = useState(35); // ++ Add state for similarity threshold ++ - const [excludedColorKeys, setExcludedColorKeys] = useState>(new Set()); // ++ 新增:用于存储排除的颜色 Key + const [granularity, setGranularity] = useState(50); // Example default + const [similarityThreshold, setSimilarityThreshold] = useState(30); // Example default for merging + const [selectedPaletteKeySet, setSelectedPaletteKeySet] = useState('all'); // Use 'all' or another valid key + const [activeBeadPalette, setActiveBeadPalette] = useState(() => { + const initialKey = 'all'; // Match the key used above + const options = paletteOptions[initialKey]; + if (!options) return fullBeadPalette; // Fallback + const keySet = new Set(options.keys); + return fullBeadPalette.filter(color => keySet.has(color.key)); + }); + const [excludedColorKeys, setExcludedColorKeys] = useState>(new Set()); + const [initialGridColorKeys, setInitialGridColorKeys] = useState | null>(null); + const [mappedPixelData, setMappedPixelData] = useState(null); + const [gridDimensions, setGridDimensions] = useState<{ N: number; M: number } | null>(null); + const [colorCounts, setColorCounts] = useState<{ [key: string]: { count: number; color: string } } | null>(null); + const [totalBeadCount, setTotalBeadCount] = useState(0); + const [tooltipData, setTooltipData] = useState<{ x: number, y: number, key: string, color: string } | null>(null); + const [remapTrigger, setRemapTrigger] = useState(0); + const [isManualColoringMode, setIsManualColoringMode] = useState(false); + const [selectedColor, setSelectedColor] = useState(null); + const originalCanvasRef = useRef(null); const pixelatedCanvasRef = useRef(null); const fileInputRef = useRef(null); - const [mappedPixelData, setMappedPixelData] = useState<{ key: string; color: string; isExternal?: boolean }[][] | null>(null); - const [gridDimensions, setGridDimensions] = useState<{ N: number; M: number } | null>(null); - const [colorCounts, setColorCounts] = useState<{ [key: string]: { count: number; color: string } } | null>(null); - const [totalBeadCount, setTotalBeadCount] = useState(0); // ++ 添加总数状态 ++ - // ++ 新增: Tooltip 状态 ++ - const [tooltipData, setTooltipData] = useState<{ x: number; y: number; key: string; color: string } | null>(null); - const [remapTrigger, setRemapTrigger] = useState(0); // ++ NEW: Trigger for full remap - const [initialGridColorKeys, setInitialGridColorKeys] = useState | null>(null); // ++ 新增:存储初始颜色键 - - // ++ Refs for touch handling ++ const longPressTimerRef = useRef(null); + // ++ Re-add touch refs needed for tooltip logic ++ const touchStartPosRef = useRef<{ x: number; y: number; pageX: number; pageY: number } | null>(null); const touchMovedRef = useRef(false); - // --- Memoize the selected palette --- - const activeBeadPalette = useMemo(() => { - console.log(`Recalculating active palette for: ${selectedPaletteKeySet}, excluding ${excludedColorKeys.size} keys.`); - const selectedOption = paletteOptions[selectedPaletteKeySet]; - if (!selectedOption) { - console.error(`Invalid palette key selected: ${selectedPaletteKeySet}. Falling back to 'all'.`); - const filteredFullPalette = fullBeadPalette.filter(color => !excludedColorKeys.has(color.key)); - return filteredFullPalette.length > 0 ? filteredFullPalette : fullBeadPalette; - } - const selectedKeys = selectedOption.keys; - const keySet = new Set(selectedKeys); - const filteredPalette = fullBeadPalette.filter(color => keySet.has(color.key)); - const t1Color = fullBeadPalette.find(p => p.key === 'T1'); - if (t1Color && !keySet.has('T1')) { - if (!filteredPalette.some(p => p.key === 'T1')) { - console.log("T1 was not in the base palette, but exists. It can be excluded if needed."); - } - } else if (!t1Color) { - console.warn("T1 color key not found in full beadPaletteData.json."); - } - let finalPalette = filteredPalette.filter(color => !excludedColorKeys.has(color.key)); - if (finalPalette.length === 0 && filteredPalette.length > 0) { - console.warn(`Palette '${selectedPaletteKeySet}' became empty after excluding colors. Falling back to the original selected set.`); - finalPalette = filteredPalette; - } else if (finalPalette.length === 0 && filteredPalette.length === 0) { - console.warn(`Palette '${selectedPaletteKeySet}' was empty initially or became empty after exclusions. Falling back to all colors (minus exclusions).`); - finalPalette = fullBeadPalette.filter(color => !excludedColorKeys.has(color.key)); - if (finalPalette.length === 0) { - console.error("All colors including fallbacks seem to be excluded. Using the entire bead palette."); - finalPalette = fullBeadPalette; - } - } - console.log(`Active palette has ${finalPalette.length} colors after exclusions.`); - return finalPalette; - }, [selectedPaletteKeySet, excludedColorKeys]); + // --- Derived State --- + + // Update active palette based on selection and exclusions + useEffect(() => { + // ... existing useEffect for activeBeadPalette ... + }, [selectedPaletteKeySet, excludedColorKeys, remapTrigger]); // ++ 添加 remapTrigger 依赖 ++ + + + // ++ Calculate unique colors currently on the grid for the palette ++ + const currentGridColors = useMemo(() => { + if (!mappedPixelData) return []; + const uniqueColorsMap = new Map(); + mappedPixelData.flat().forEach(cell => { + if (cell && cell.key && !cell.isExternal && !uniqueColorsMap.has(cell.key)) { + // Store the full MappedPixel object to preserve key and color + uniqueColorsMap.set(cell.key, { key: cell.key, color: cell.color }); + } + }); + // Sort colors like the stats list, if desired + return Array.from(uniqueColorsMap.values()).sort((a, b) => sortColorKeys(a.key, b.key)); + }, [mappedPixelData]); // Recalculate when pixel data changes + + + // --- Event Handlers --- - // Handle file selection const handleFileChange = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { @@ -206,7 +210,6 @@ export default function Home() { } }; - // Handle file drop const handleDrop = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); @@ -221,13 +224,11 @@ export default function Home() { } }; - // Handle drag over const handleDragOver = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); }; - // Process file const processFile = (file: File) => { const reader = new FileReader(); reader.onload = (e) => { @@ -246,16 +247,20 @@ export default function Home() { setInitialGridColorKeys(null); // ++ 重置初始键 ++ } reader.readAsDataURL(file); + // ++ Reset manual coloring mode when a new file is processed ++ + setIsManualColoringMode(false); + setSelectedColor(null); }; - // Handle granularity change const handleGranularityChange = (event: ChangeEvent) => { const newGranularity = parseInt(event.target.value, 10); setGranularity(newGranularity); setRemapTrigger(prev => prev + 1); // Trigger full remap + // ++ Exit manual coloring mode if parameters change ++ + setIsManualColoringMode(false); + setSelectedColor(null); }; - // Handle palette selection change const handlePaletteChange = (event: ChangeEvent) => { const newKey = event.target.value as PaletteOptionKey; if (paletteOptions[newKey]) { @@ -265,14 +270,20 @@ export default function Home() { } else { console.warn(`Attempted to select invalid palette key: ${newKey}. Keeping current selection.`); } + // ++ Exit manual coloring mode if palette changes ++ + setIsManualColoringMode(false); + setSelectedColor(null); }; - // ++ Add handler for similarity threshold change ++ const handleSimilarityChange = (event: ChangeEvent) => { setSimilarityThreshold(parseInt(event.target.value, 10)); setRemapTrigger(prev => prev + 1); // Trigger full remap + // ++ Exit manual coloring mode if parameters change ++ + setIsManualColoringMode(false); + setSelectedColor(null); }; + // Core function: Pixelate the image const pixelateImage = (imageSrc: string, detailLevel: number, threshold: number, currentPalette: PaletteColor[]) => { console.log(`Attempting to pixelate with threshold: ${threshold}`); @@ -556,6 +567,9 @@ export default function Home() { }; console.log("Setting image source..."); img.src = imageSrc; + // Ensure manual mode is off after pixelation completes + setIsManualColoringMode(false); + setSelectedColor(null); }; // Use useEffect to trigger pixelation @@ -803,6 +817,9 @@ export default function Home() { setRemapTrigger(prev => prev + 1); // *** KEPT setRemapTrigger here for re-inclusion *** console.log("---------"); // ++ Log End ++ } + // ++ Exit manual mode if colors are excluded/included ++ + setIsManualColoringMode(false); + setSelectedColor(null); }; // --- Tooltip Logic --- @@ -851,8 +868,6 @@ export default function Home() { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } - // Also hide tooltip when clearing, e.g., on touch end or move - // setTooltipData(null); // Let mouseLeave/touchEnd handle hiding specifically }; // ++ Updated: Mouse move handler ++ @@ -871,65 +886,71 @@ export default function Home() { setTooltipData(null); }; - // ++ 新增: Touch start handler ++ + // ++ Add a dedicated click handler for coloring ++ + const handleCanvasClick = (event: React.MouseEvent) => { + if (isManualColoringMode && selectedColor) { + // Use the existing interaction logic, passing isClick as true + handleCanvasInteraction(event.clientX, event.clientY, event.pageX, event.pageY, true); + } + }; + + // ++ Touch start handler - Ensure touch variable is used correctly ++ const handleTouchStart = (event: TouchEvent) => { - clearLongPress(); // Clear previous timer just in case - setTooltipData(null); // Hide any existing tooltip immediately on new touch + const touch = event.touches[0]; + if (!touch) return; + + // Store touch start position for move detection (tooltip logic) + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, pageX: touch.pageX, pageY: touch.pageY }; touchMovedRef.current = false; // Reset move flag - const touch = event.touches[0]; - touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, pageX: touch.pageX, pageY: touch.pageY }; - - // Set timer for long press - longPressTimerRef.current = setTimeout(() => { - // If touch hasn't moved significantly, show tooltip at start position - if (!touchMovedRef.current && touchStartPosRef.current) { - updateTooltip(touchStartPosRef.current.x, touchStartPosRef.current.y, touchStartPosRef.current.pageX, touchStartPosRef.current.pageY); - } - longPressTimerRef.current = null; // Timer has fired - }, 500); // 500ms delay for long press + if (isManualColoringMode && selectedColor) { + // Handle coloring on touch start (like a tap) + handleCanvasInteraction(touch.clientX, touch.clientY, touch.pageX, touch.pageY, true); + } else { + // Original Tooltip Long Press Logic + clearLongPress(); // Clear any previous timer + longPressTimerRef.current = setTimeout(() => { + // Only show tooltip if finger hasn't moved significantly + if (!touchMovedRef.current && touchStartPosRef.current) { + // Use coordinates from touchStartPosRef.current here + handleCanvasInteraction(touchStartPosRef.current.x, touchStartPosRef.current.y, touchStartPosRef.current.pageX, touchStartPosRef.current.pageY); + } + }, 500); // 500ms for long press + } }; - // ++ 新增: Touch move handler ++ + // ++ Touch move handler - Ensure touch variable and refs are used correctly ++ const handleTouchMove = (event: TouchEvent) => { - if (!touchStartPosRef.current) return; + const touch = event.touches[0]; + if (!touch || !touchStartPosRef.current) return; - const touch = event.touches[0]; - const moveThreshold = 10; // Pixels threshold to detect movement - - // Calculate distance moved - const dx = touch.clientX - touchStartPosRef.current.x; - const dy = touch.clientY - touchStartPosRef.current.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > moveThreshold) { - touchMovedRef.current = true; // Mark as moved - clearLongPress(); // Cancel long press if finger moves too much - setTooltipData(null); // Hide tooltip if it was shown by long press - } - - // Optional: Update tooltip while dragging (like mouse move) - // if (distance > moveThreshold) { // Or maybe always update while dragging? - // updateTooltip(touch.clientX, touch.clientY, touch.pageX, touch.pageY); - // } + // Check if touch has moved significantly from the start position + const dx = Math.abs(touch.clientX - touchStartPosRef.current.x); + const dy = Math.abs(touch.clientY - touchStartPosRef.current.y); + if (dx > 5 || dy > 5) { // Threshold to detect movement + touchMovedRef.current = true; + clearLongPress(); // Cancel long press if finger moves + setTooltipData(null); // Hide tooltip during move + } }; - // ++ 新增: Touch end handler ++ + // ++ Touch end handler - Ensure refs are used correctly ++ const handleTouchEnd = () => { clearLongPress(); - // Hide tooltip only if it wasn't triggered by long press *just now* - // If the timer is already null (meaning it fired or was cleared by move), hide tooltip. - if (!longPressTimerRef.current) { - // Add a small delay before hiding to allow user to see info briefly after lifting finger - setTimeout(() => setTooltipData(null), 300); + // Delay hiding tooltip slightly on touch end to avoid flicker if long press just triggered + // and finger didn't move (touchMovedRef is false) + if (!touchMovedRef.current) { + setTimeout(() => setTooltipData(null), 100); + } else { + setTooltipData(null); // Hide immediately if finger moved } - touchStartPosRef.current = null; // Clear touch start position - touchMovedRef.current = false; + touchStartPosRef.current = null; // Clear start position + touchMovedRef.current = false; // Reset move flag }; // ++ 新增:绘制像素化 Canvas 的函数 ++ const drawPixelatedCanvas = ( - dataToDraw: { key: string; color: string; isExternal?: boolean }[][], + dataToDraw: MappedPixel[][], // ++ Update type here ++ canvasRef: React.RefObject, // ++ 修改类型定义 ++ dims: { N: number; M: number } | null ) => { @@ -982,6 +1003,71 @@ export default function Home() { console.log("Pixelated canvas redraw complete."); }; + // --- Canvas Interaction --- + + // ++ Re-introduce the combined interaction handler ++ + const handleCanvasInteraction = (clientX: number, clientY: number, pageX: number, pageY: number, isClick: boolean = false) => { + const canvas = pixelatedCanvasRef.current; + if (!canvas || !mappedPixelData || !gridDimensions) { + setTooltipData(null); + return; + } + + 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) { + const cellData = mappedPixelData[j][i]; + + // Manual Coloring Logic + if (isClick && isManualColoringMode && selectedColor) { + // ++ Allow clicking on ANY cell (internal or external) ++ + // Create a deep copy to ensure state update triggers re-render + const newPixelData = mappedPixelData.map(row => row.map(cell => ({ ...cell }))); + const currentCell = newPixelData[j]?.[i]; // Get current cell data safely + + // Check if the color needs changing OR if it was an external cell being colored + if (!currentCell || currentCell.key !== selectedColor.key || currentCell.isExternal) { + // ++ Apply selected color/key AND ensure isExternal is false ++ + newPixelData[j][i] = { ...selectedColor, isExternal: false }; + setMappedPixelData(newPixelData); + + // Explicitly redraw canvas immediately after state update + if (pixelatedCanvasRef.current) { + drawPixelatedCanvas(newPixelData, pixelatedCanvasRef, gridDimensions); + } + } + // After a click in manual mode, always clear the tooltip + setTooltipData(null); + } + // Tooltip Logic (only show if NOT a manual coloring click) + else if (!isClick || !isManualColoringMode) { + if (cellData && !cellData.isExternal && cellData.key) { + setTooltipData({ + x: pageX, + y: pageY, + key: cellData.key, + color: cellData.color, + }); + } else { + setTooltipData(null); // Hide tooltip if on background or invalid cell + } + } + } else { + setTooltipData(null); // Hide if outside bounds + } + }; + return ( <> {/* ++ 修改:添加 onLoad 回调函数 ++ */} @@ -1054,63 +1140,95 @@ export default function Home() { {/* Controls and Output Area */} {originalImageSrc && (
- {/* Control Row */} -
- {/* Granularity Slider */} -
- - -
-
- {/* ++ Similarity Threshold Slider ++ */} -
-
+ {/* Similarity Threshold Slider */} +
+ + +
+
+ {/* Palette Selector */} +
+ + +
+
+ )} {/* ++ End of HIDE Control Row ++ */} {/* Output Section */}
+ + {/* ++ RENDER Button/Palette ONLY in manual mode above canvas ++ */} + {isManualColoringMode && mappedPixelData && gridDimensions && ( +
+ {/* Finish Manual Coloring Button */} + + {/* Color Palette (only in manual mode) */} +
+

选择颜色后,点击下方画布格子进行填充:

+ +
+
+ )} {/* ++ End of RENDER Button/Palette ++ */} + + {/* Canvas Preview Container */}
-

图纸预览(悬停或长按查看颜色)

+

+ {isManualColoringMode ? "手动上色中... (点击格子填充)" : "图纸预览(悬停或长按查看颜色)"} +

-
+ // This closes the main div started after originalImageSrc check )} - {/* ++ Combined Color Counts and Exclusion Area ++ */} - {originalImageSrc && colorCounts && Object.keys(colorCounts).length > 0 && ( + {/* ++ HIDE Color Counts in manual mode ++ */} + {!isManualColoringMode && originalImageSrc && colorCounts && Object.keys(colorCounts).length > 0 && (

颜色统计与排除 ({paletteOptions[selectedPaletteKeySet]?.name || '未知色板'}) @@ -1122,7 +1240,7 @@ export default function Home() { .map((key) => { const isExcluded = excludedColorKeys.has(key); const count = colorCounts[key].count; - const colorHex = colorCounts[key].color; // Get color from counts data + const colorHex = colorCounts[key].color; return (
  • handleToggleExcludeColor(key)} className={`flex items-center justify-between p-1.5 rounded cursor-pointer transition-colors ${ isExcluded - ? 'bg-red-100 hover:bg-red-200 opacity-60' // Adjusted style for excluded items in this list + ? 'bg-red-100 hover:bg-red-200 opacity-60' : 'hover:bg-gray-100' }`} title={isExcluded ? `点击恢复 ${key}` : `点击排除 ${key}`} @@ -1138,7 +1256,7 @@ export default function Home() {
    {key}
    @@ -1150,10 +1268,9 @@ export default function Home() { {excludedColorKeys.size > 0 && ( )}
  • - )} - {/* Message if palette becomes empty */} - {originalImageSrc && activeBeadPalette.length === 0 && excludedColorKeys.size > 0 && ( + )} {/* ++ End of HIDE Color Counts ++ */} + + {/* Message if palette becomes empty (Also hide in manual mode) */} + {!isManualColoringMode && originalImageSrc && activeBeadPalette.length === 0 && excludedColorKeys.size > 0 && (
    当前可用颜色过少或为空。请在上方统计列表中点击恢复部分颜色,或更换色板。 {excludedColorKeys.size > 0 && (
    )} - {/* Download Buttons */} - {originalImageSrc && mappedPixelData && ( + {/* ++ RENDER Enter Manual Mode Button ONLY when NOT in manual mode (before downloads) ++ */} + {!isManualColoringMode && originalImageSrc && mappedPixelData && gridDimensions && ( +
    {/* Wrapper div */} + +
    + )} {/* ++ End of RENDER Enter Manual Mode Button ++ */} + + {/* ++ HIDE Download Buttons in manual mode ++ */} + {!isManualColoringMode && originalImageSrc && mappedPixelData && (
    + {/* Download Grid Button */} + {/* Download Stats Button */}
    - )} + )} {/* ++ End of HIDE Download Buttons ++ */} {/* Tooltip Display (remains the same) */} {tooltipData && ( @@ -1223,6 +1359,8 @@ export default function Home() { )} + {/* Cleaned up the previously moved/commented out block */} +