diff --git a/src/app/page.tsx b/src/app/page.tsx index 193a606..1e04265 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useRef, ChangeEvent, DragEvent, useEffect, useMemo } from 'react'; +import React, { useState, useRef, ChangeEvent, DragEvent, TouchEvent, useEffect, useMemo } from 'react'; // Image component from next/image might not be strictly needed if you only use canvas and basic elements, // but keep it if you plan to add other images later or use the SVG icon below. // Removed unused Image import @@ -142,6 +142,7 @@ export default function Home() { 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 originalCanvasRef = useRef(null); const pixelatedCanvasRef = useRef(null); const fileInputRef = useRef(null); @@ -149,47 +150,55 @@ export default function Home() { 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); + + // ++ Refs for touch handling ++ + const longPressTimerRef = useRef(null); + 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}`); + 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'.`); - setSelectedPaletteKeySet('all'); // Reset to default if key is invalid - return fullBeadPalette; + 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)); - - // Ensure T1 (white) is always included if it exists in the full data + let 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')) { - filteredPalette.push(t1Color); - console.log("Added T1 to the active palette as it was missing."); + 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. Fallback for empty cells might use another white or the first palette color."); + console.warn("T1 color key not found in full beadPaletteData.json."); } - - - if (filteredPalette.length === 0) { - console.warn(`Palette '${selectedPaletteKeySet}' resulted in an empty set (even after T1 check). Falling back to all colors.`); - return fullBeadPalette; // Fallback if still empty + 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 ${filteredPalette.length} colors.`); - return filteredPalette; - }, [selectedPaletteKeySet]); + console.log(`Active palette has ${finalPalette.length} colors after exclusions.`); + return finalPalette; + }, [selectedPaletteKeySet, excludedColorKeys]); // Handle file selection const handleFileChange = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { + setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++ processFile(file); } }; @@ -201,6 +210,7 @@ export default function Home() { if (event.dataTransfer.files && event.dataTransfer.files[0]) { const file = event.dataTransfer.files[0]; if (file.type.startsWith('image/')) { + setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++ processFile(file); } else { alert("请拖放图片文件 (JPG, PNG)"); @@ -223,7 +233,8 @@ export default function Home() { setMappedPixelData(null); setGridDimensions(null); setColorCounts(null); - setTotalBeadCount(0); // ++ 重置总数 ++ + setTotalBeadCount(0); + // Exclusions are reset in handleFileChange/handleDrop before calling this }; reader.onerror = () => { console.error("文件读取失败"); @@ -241,9 +252,9 @@ export default function Home() { // Handle palette selection change const handlePaletteChange = (event: ChangeEvent) => { const newKey = event.target.value as PaletteOptionKey; - // Basic validation if needed, though useMemo handles fallback if (paletteOptions[newKey]) { setSelectedPaletteKeySet(newKey); + setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++ } else { console.warn(`Attempted to select invalid palette key: ${newKey}. Keeping current selection.`); } @@ -267,16 +278,22 @@ export default function Home() { console.log("Canvas contexts obtained."); if (currentPalette.length === 0) { - console.error("Cannot pixelate: The selected color palette is empty."); - alert("错误:选定的颜色板为空,无法处理图像。"); - return; + console.error("Cannot pixelate: The selected color palette is empty (likely due to exclusions)."); + alert("错误:当前可用颜色板为空(可能所有颜色都被排除了),无法处理图像。请尝试恢复部分颜色。"); + // Clear previous results visually + pixelatedCtx.clearRect(0, 0, pixelatedCanvas.width, pixelatedCanvas.height); + setMappedPixelData(null); + setGridDimensions(null); + // Keep colorCounts potentially showing the last valid counts? Or clear them too? + // setColorCounts(null); // Decide if clearing counts is desired when palette is empty + // setTotalBeadCount(0); + return; // Stop processing } const t1FallbackColor = currentPalette.find(p => p.key === 'T1') || currentPalette.find(p => p.hex.toUpperCase() === '#FFFFFF') - || currentPalette[0]; + || currentPalette[0]; // Use the first available color as fallback console.log("Using fallback color for empty cells:", t1FallbackColor); - const img = new window.Image(); img.onload = () => { console.log("Image loaded successfully."); @@ -374,10 +391,10 @@ export default function Home() { const startRgb = keyToRgbMap.get(startCellData.key); if (!startRgb) { - console.warn(`RGB not found for key ${startCellData.key} at (${j},${i}) during merging. Skipping cell.`); + console.warn(`RGB not found for key ${startCellData.key} at (${j},${i}) during merging (might be excluded?). Using fallback for this cell.`); visited[j][i] = true; - mergedData[j][i] = { ...startCellData, isExternal: false}; - continue; + mergedData[j][i] = { key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false }; + continue; // Skip BFS starting from this invalid cell } const currentRegionCells: { r: number; c: number }[] = []; @@ -438,7 +455,7 @@ export default function Home() { mergedData[r][c] = { key: dominantKey, color: dominantColorHex, isExternal: false }; }); } else { - console.warn(`Dominant key "${dominantKey}" determined but not found in palette. Using fallback.`); + console.warn(`Dominant key "${dominantKey}" determined but not found in *active* palette during merge. Using fallback.`); currentRegionCells.forEach(({ r, c }) => { mergedData[r][c] = { key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false }; }); @@ -544,15 +561,32 @@ export default function Home() { if (originalImageSrc && activeBeadPalette.length > 0) { const timeoutId = setTimeout(() => { if (originalImageSrc && originalCanvasRef.current && pixelatedCanvasRef.current && activeBeadPalette.length > 0) { - console.log("useEffect triggered: Processing image due to src, granularity, threshold, or palette change."); + console.log("useEffect triggered: Processing image due to src, granularity, threshold, palette, or exclusion change."); pixelateImage(originalImageSrc, granularity, similarityThreshold, activeBeadPalette); } else { - console.warn("useEffect check failed: Refs or palette not ready."); + console.warn("useEffect check failed inside timeout: Refs or palette not ready/empty."); } }, 50); return () => clearTimeout(timeoutId); + } else if (originalImageSrc && activeBeadPalette.length === 0) { + console.warn("Image selected, but the active palette is empty after exclusions. Cannot process. Clearing preview."); + const pixelatedCanvas = pixelatedCanvasRef.current; + const pixelatedCtx = pixelatedCanvas?.getContext('2d'); + if (pixelatedCtx && pixelatedCanvas) { + pixelatedCtx.clearRect(0, 0, pixelatedCanvas.width, pixelatedCanvas.height); + // Draw a message on the canvas? + pixelatedCtx.fillStyle = '#6b7280'; // gray-500 + pixelatedCtx.font = '16px sans-serif'; + pixelatedCtx.textAlign = 'center'; + pixelatedCtx.fillText('无可用颜色,请恢复部分排除的颜色', pixelatedCanvas.width / 2, pixelatedCanvas.height / 2); + } + setMappedPixelData(null); + setGridDimensions(null); + // Keep colorCounts to allow user to un-exclude colors + // setColorCounts(null); + // setTotalBeadCount(0); } - }, [originalImageSrc, granularity, similarityThreshold, activeBeadPalette]); + }, [originalImageSrc, granularity, similarityThreshold, activeBeadPalette, excludedColorKeys]); // --- Download function (ensure filename includes palette) --- const handleDownloadImage = () => { @@ -652,6 +686,150 @@ export default function Home() { } catch (e) { console.error("下载统计图失败:", e); alert("无法生成统计图下载链接。"); } }; + // --- Handler to toggle color exclusion --- + const handleToggleExcludeColor = (key: string) => { + // Add a check: Don't allow excluding if it's the *very last* color shown in the counts + const currentUsedKeys = colorCounts ? Object.keys(colorCounts) : []; + const nonExcludedUsedKeys = currentUsedKeys.filter(k => !excludedColorKeys.has(k)); + + if (nonExcludedUsedKeys.length === 1 && nonExcludedUsedKeys[0] === key && !excludedColorKeys.has(key)) { + alert(`不能排除最后一个在图中使用的颜色 (${key})。`); + return; + } + + setExcludedColorKeys(prev => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); // Re-include + } else { + newSet.add(key); // Exclude + } + console.log("Updated excluded keys:", newSet); + return newSet; + }); + }; + + // --- Tooltip Logic --- + + // Function to calculate cell and update tooltip + const updateTooltip = (clientX: number, clientY: number, pageX: number, pageY: number) => { + const canvas = pixelatedCanvasRef.current; + if (!canvas || !mappedPixelData || !gridDimensions) { + setTooltipData(null); + return false; // Indicate failure or no action + } + + 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]; + if (cellData && !cellData.isExternal && cellData.key) { + setTooltipData({ + x: pageX, // Use page coordinates for positioning + y: pageY, + key: cellData.key, + color: cellData.color, + }); + return true; // Indicate success + } + } + + setTooltipData(null); // Hide if outside bounds or on background + return false; + }; + + // Clear any active long press timer and hide tooltip + const clearLongPress = () => { + if (longPressTimerRef.current) { + 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 ++ + const handleCanvasMouseMove = (event: React.MouseEvent) => { + // Prevent mouse events from interfering during touch interactions + if (touchStartPosRef.current) return; + clearLongPress(); // Clear any potential lingering timer + updateTooltip(event.clientX, event.clientY, event.pageX, event.pageY); + }; + + // ++ Updated: Mouse leave handler ++ + const handleCanvasMouseLeave = () => { + // Prevent mouse events from interfering during touch interactions + if (touchStartPosRef.current) return; + clearLongPress(); + setTooltipData(null); + }; + + // ++ 新增: Touch start handler ++ + const handleTouchStart = (event: TouchEvent) => { + clearLongPress(); // Clear previous timer just in case + setTooltipData(null); // Hide any existing tooltip immediately on new touch + 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 + }; + + // ++ 新增: Touch move handler ++ + const handleTouchMove = (event: TouchEvent) => { + if (!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); + // } + }; + + // ++ 新增: Touch end handler ++ + 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); + } + touchStartPosRef.current = null; // Clear touch start position + touchMovedRef.current = false; + }; return (
@@ -660,7 +838,7 @@ export default function Home() {

上传图片,选择色板,生成带色号的图纸和统计

-
+
{/* 添加 relative 定位 */} {/* Drop Zone */}
-

图纸预览(边缘背景已移除)

-
- +

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

+
+
)} - {/* Color Counts Display */} - {colorCounts && Object.keys(colorCounts).length > 0 && ( + {/* ++ Combined Color Counts and Exclusion Area ++ */} + {originalImageSrc && colorCounts && Object.keys(colorCounts).length > 0 && (
- {/* Display selected palette name and total count in the title */} -

- 颜色统计 ({paletteOptions[selectedPaletteKeySet]?.name || '未知色板'}) - 总计: {totalBeadCount} 颗 +

+ 颜色统计与排除 ({paletteOptions[selectedPaletteKeySet]?.name || '未知色板'})

-
    - {Object.keys(colorCounts).sort(sortColorKeys).map((key) => ( -
  • -
    - - {key} -
    - {colorCounts[key].count} 颗 -
  • - ))} +

    点击下方列表中的颜色可将其从可用列表中排除/恢复。总计: {totalBeadCount} 颗

    +
      + {Object.keys(colorCounts) + .sort(sortColorKeys) + .map((key) => { + const isExcluded = excludedColorKeys.has(key); + const count = colorCounts[key].count; + const colorHex = colorCounts[key].color; // Get color from counts data + + 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 + : 'hover:bg-gray-100' + }`} + title={isExcluded ? `点击恢复 ${key}` : `点击排除 ${key}`} + > +
      + + {key} +
      + {count} 颗 +
    • + ); + })}
    + {excludedColorKeys.size > 0 && ( + + )}
)} + {/* Message if palette becomes empty */} + {originalImageSrc && activeBeadPalette.length === 0 && excludedColorKeys.size > 0 && ( +
+ 当前所有颜色均被排除或所选色板为空。请在上方统计列表中点击恢复部分颜色,或更换色板。 + {excludedColorKeys.size > 0 && ( + + )} +
+ )} {/* Download Buttons */} {originalImageSrc && mappedPixelData && ( -
- - -
+
+ + +
)} + + {/* Tooltip Display (remains the same) */} + {tooltipData && ( +
+ + {tooltipData.key} +
+ )} +