From 2e42ba01b82db10ef2425a6b1be59a876201095b Mon Sep 17 00:00:00 2001 From: zihanjian Date: Fri, 6 Jun 2025 18:54:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=93=E5=BF=83=E6=8B=BC?= =?UTF-8?q?=E8=B1=86=E6=A8=A1=E5=BC=8F=E5=8F=8A=E7=9B=B8=E5=85=B3=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=BD=93=E9=AA=8C=EF=BC=8C=E6=8F=90=E5=8D=87=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=8F=AF=E7=94=A8=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/focus/page.tsx | 378 +++++++++++++++++++ src/app/page.tsx | 47 ++- src/components/ColorPanel.tsx | 168 +++++++++ src/components/ColorStatusBar.tsx | 55 +++ src/components/FocusCanvas.tsx | 315 ++++++++++++++++ src/components/FocusModePreDownloadModal.tsx | 115 ++++++ src/components/ProgressBar.tsx | 53 +++ src/components/SettingsPanel.tsx | 192 ++++++++++ src/components/ToolBar.tsx | 78 ++++ src/utils/floodFillUtils.ts | 145 +++++++ 10 files changed, 1543 insertions(+), 3 deletions(-) create mode 100644 src/app/focus/page.tsx create mode 100644 src/components/ColorPanel.tsx create mode 100644 src/components/ColorStatusBar.tsx create mode 100644 src/components/FocusCanvas.tsx create mode 100644 src/components/FocusModePreDownloadModal.tsx create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/components/ToolBar.tsx create mode 100644 src/utils/floodFillUtils.ts diff --git a/src/app/focus/page.tsx b/src/app/focus/page.tsx new file mode 100644 index 0000000..c27d4b2 --- /dev/null +++ b/src/app/focus/page.tsx @@ -0,0 +1,378 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { MappedPixel } from '../../utils/pixelation'; +import { + getAllConnectedRegions, + isRegionCompleted, + getRegionCenter, + sortRegionsByDistance, + sortRegionsBySize, + getConnectedRegion +} from '../../utils/floodFillUtils'; +import FocusCanvas from '../../components/FocusCanvas'; +import ColorStatusBar from '../../components/ColorStatusBar'; +import ProgressBar from '../../components/ProgressBar'; +import ToolBar from '../../components/ToolBar'; +import ColorPanel from '../../components/ColorPanel'; +import SettingsPanel from '../../components/SettingsPanel'; + +interface FocusModeState { + // 当前状态 + currentColor: string; + selectedCell: { row: number; col: number } | null; + + // 画布状态 + canvasScale: number; + canvasOffset: { x: number; y: number }; + + // 进度状态 + completedCells: Set; + colorProgress: Record; + + // 引导状态 - 改为区域推荐 + recommendedRegion: { row: number; col: number }[] | null; + recommendedCell: { row: number; col: number } | null; // 保留用于定位显示 + guidanceMode: 'nearest' | 'largest' | 'edge-first'; + + // UI状态 + showColorPanel: boolean; + showSettingsPanel: boolean; + isPaused: boolean; +} + +export default function FocusMode() { + // 从localStorage或URL参数获取像素数据 + const [mappedPixelData, setMappedPixelData] = useState(null); + const [gridDimensions, setGridDimensions] = useState<{ N: number; M: number } | null>(null); + + // 专心模式状态 + const [focusState, setFocusState] = useState({ + currentColor: '', + selectedCell: null, + canvasScale: 1, + canvasOffset: { x: 0, y: 0 }, + completedCells: new Set(), + colorProgress: {}, + recommendedRegion: null, + recommendedCell: null, + guidanceMode: 'nearest', + showColorPanel: false, + showSettingsPanel: false, + isPaused: false + }); + + // 可用颜色列表 + const [availableColors, setAvailableColors] = useState>([]); + + // 从localStorage加载数据 + useEffect(() => { + const savedPixelData = localStorage.getItem('focusMode_pixelData'); + const savedGridDimensions = localStorage.getItem('focusMode_gridDimensions'); + const savedColorCounts = localStorage.getItem('focusMode_colorCounts'); + + if (savedPixelData && savedGridDimensions && savedColorCounts) { + try { + const pixelData = JSON.parse(savedPixelData); + const dimensions = JSON.parse(savedGridDimensions); + const colorCounts = JSON.parse(savedColorCounts); + + setMappedPixelData(pixelData); + setGridDimensions(dimensions); + + // 计算颜色进度 + const colors = Object.entries(colorCounts).map(([colorKey, colorData]) => { + const data = colorData as { color: string; count: number }; + return { + color: data.color, + name: colorKey, // 使用色号作为名称 + total: data.count, + completed: 0 + }; + }); + setAvailableColors(colors); + + // 设置初始当前颜色 + if (colors.length > 0) { + setFocusState(prev => ({ + ...prev, + currentColor: colors[0].color, + colorProgress: colors.reduce((acc, color) => ({ + ...acc, + [color.color]: { completed: 0, total: color.total } + }), {}) + })); + } + } catch (error) { + console.error('Failed to load focus mode data:', error); + // 重定向到主页面 + window.location.href = '/'; + } + } else { + // 没有数据,重定向到主页面 + window.location.href = '/'; + } + }, []); + + // 计算推荐的下一个区域 + const calculateRecommendedRegion = useCallback(() => { + if (!mappedPixelData || !focusState.currentColor) return { region: null, cell: null }; + + // 获取当前颜色的所有连通区域 + const allRegions = getAllConnectedRegions(mappedPixelData, focusState.currentColor); + + // 筛选出未完成的区域 + const incompleteRegions = allRegions.filter(region => + !isRegionCompleted(region, focusState.completedCells) + ); + + if (incompleteRegions.length === 0) { + return { region: null, cell: null }; + } + + let selectedRegion: { row: number; col: number }[]; + + // 根据引导模式选择推荐区域 + switch (focusState.guidanceMode) { + case 'nearest': + // 找最近的区域(相对于上一个完成的格子或中心点) + const referencePoint = focusState.selectedCell ?? { + row: Math.floor(mappedPixelData.length / 2), + col: Math.floor(mappedPixelData[0].length / 2) + }; + + const sortedByDistance = sortRegionsByDistance(incompleteRegions, referencePoint); + selectedRegion = sortedByDistance[0]; + break; + + case 'largest': + // 找最大的连通区域 + const sortedBySize = sortRegionsBySize(incompleteRegions); + selectedRegion = sortedBySize[0]; + break; + + case 'edge-first': + // 优先选择包含边缘格子的区域 + const M = mappedPixelData.length; + const N = mappedPixelData[0].length; + const edgeRegions = incompleteRegions.filter(region => + region.some(cell => + cell.row === 0 || cell.row === M - 1 || + cell.col === 0 || cell.col === N - 1 + ) + ); + + if (edgeRegions.length > 0) { + selectedRegion = edgeRegions[0]; + } else { + selectedRegion = incompleteRegions[0]; + } + break; + + default: + selectedRegion = incompleteRegions[0]; + } + + // 计算区域中心作为推荐显示位置 + const centerCell = getRegionCenter(selectedRegion); + + return { + region: selectedRegion, + cell: centerCell + }; + }, [mappedPixelData, focusState.currentColor, focusState.completedCells, focusState.selectedCell, focusState.guidanceMode]); + + // 更新推荐区域 + useEffect(() => { + const { region, cell } = calculateRecommendedRegion(); + setFocusState(prev => ({ + ...prev, + recommendedRegion: region, + recommendedCell: cell + })); + }, [calculateRecommendedRegion]); + + // 处理格子点击 - 改为区域洪水填充标记 + const handleCellClick = useCallback((row: number, col: number) => { + if (!mappedPixelData) return; + + const cellColor = mappedPixelData[row][col].color; + + // 如果点击的是当前颜色的格子,对整个连通区域进行标记 + if (cellColor === focusState.currentColor) { + // 获取点击位置的连通区域 + const region = getConnectedRegion(mappedPixelData, row, col, focusState.currentColor); + + if (region.length === 0) return; + + const newCompletedCells = new Set(focusState.completedCells); + + // 检查区域是否已完成 + const isCurrentlyCompleted = isRegionCompleted(region, focusState.completedCells); + + if (isCurrentlyCompleted) { + // 如果区域已完成,取消整个区域的完成状态 + region.forEach(({ row: r, col: c }) => { + newCompletedCells.delete(`${r},${c}`); + }); + } else { + // 如果区域未完成,标记整个区域为完成 + region.forEach(({ row: r, col: c }) => { + newCompletedCells.add(`${r},${c}`); + }); + } + + // 更新进度 + const newColorProgress = { ...focusState.colorProgress }; + if (newColorProgress[focusState.currentColor]) { + newColorProgress[focusState.currentColor].completed = Array.from(newCompletedCells) + .filter(key => { + const [r, c] = key.split(',').map(Number); + return mappedPixelData[r]?.[c]?.color === focusState.currentColor; + }).length; + } + + setFocusState(prev => ({ + ...prev, + completedCells: newCompletedCells, + selectedCell: { row, col }, + colorProgress: newColorProgress + })); + + // 更新可用颜色的完成数 + setAvailableColors(prev => prev.map(color => { + if (color.color === focusState.currentColor) { + return { + ...color, + completed: newColorProgress[focusState.currentColor]?.completed || 0 + }; + } + return color; + })); + } + }, [mappedPixelData, focusState.currentColor, focusState.completedCells, focusState.colorProgress]); + + // 处理颜色切换 + const handleColorChange = useCallback((color: string) => { + setFocusState(prev => ({ ...prev, currentColor: color, showColorPanel: false })); + }, []); + + // 处理定位到推荐位置 + const handleLocateRecommended = useCallback(() => { + if (focusState.recommendedCell) { + // 计算需要的偏移量使推荐格子居中 + const { row, col } = focusState.recommendedCell; + // 这里简化处理,实际需要根据画布尺寸计算 + setFocusState(prev => ({ + ...prev, + canvasOffset: { x: -col * 20, y: -row * 20 } // 假设每个格子20px + })); + } + }, [focusState.recommendedCell]); + + if (!mappedPixelData || !gridDimensions) { + return ( +
+
+
+

加载中...

+
+
+ ); + } + + const currentColorInfo = availableColors.find(c => c.color === focusState.currentColor); + const progressPercentage = currentColorInfo ? + Math.round((currentColorInfo.completed / currentColorInfo.total) * 100) : 0; + + return ( +
+ {/* 顶部导航栏 */} +
+ +

专心拼豆(AlphaTest)

+ +
+ + {/* 当前颜色状态栏 */} + + + {/* 主画布区域 */} +
+ setFocusState(prev => ({ ...prev, canvasScale: scale }))} + onOffsetChange={(offset: { x: number; y: number }) => setFocusState(prev => ({ ...prev, canvasOffset: offset }))} + /> +
+ + {/* 快速进度条 */} + + + {/* 底部工具栏 */} + setFocusState(prev => ({ ...prev, showColorPanel: true }))} + onLocate={handleLocateRecommended} + onUndo={() => {/* TODO: 实现撤销功能 */}} + onPause={() => setFocusState(prev => ({ ...prev, isPaused: !prev.isPaused }))} + isPaused={focusState.isPaused} + /> + + {/* 颜色选择面板 */} + {focusState.showColorPanel && ( + setFocusState(prev => ({ ...prev, showColorPanel: false }))} + /> + )} + + {/* 设置面板 */} + {focusState.showSettingsPanel && ( + setFocusState(prev => ({ ...prev, guidanceMode: mode }))} + onClose={() => setFocusState(prev => ({ ...prev, showSettingsPanel: false }))} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 9a5666c..e06bbfc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -94,6 +94,7 @@ import { TRANSPARENT_KEY, transparentColorData } from '../utils/pixelEditingUtil // 1. 导入新的 DonationModal 组件 import DonationModal from '../components/DonationModal'; +import FocusModePreDownloadModal from '../components/FocusModePreDownloadModal'; export default function Home() { const [originalImageSrc, setOriginalImageSrc] = useState(null); @@ -176,6 +177,9 @@ export default function Home() { // 新增:活跃工具层级管理 const [activeFloatingTool, setActiveFloatingTool] = useState<'palette' | 'magnifier' | null>(null); + // 新增:专心拼豆模式进入前下载提醒弹窗 + const [isFocusModePreDownloadModalOpen, setIsFocusModePreDownloadModalOpen] = useState(false); + // 放大镜切换处理函数 const handleToggleMagnifier = () => { const newActiveState = !isMagnifierActive; @@ -373,6 +377,21 @@ export default function Home() { // --- Event Handlers --- + // 专心拼豆模式相关处理函数 + const handleEnterFocusMode = () => { + setIsFocusModePreDownloadModalOpen(true); + }; + + const handleProceedToFocusMode = () => { + // 保存数据到localStorage供专心拼豆模式使用 + localStorage.setItem('focusMode_pixelData', JSON.stringify(mappedPixelData)); + localStorage.setItem('focusMode_gridDimensions', JSON.stringify(gridDimensions)); + localStorage.setItem('focusMode_colorCounts', JSON.stringify(colorCounts)); + + // 跳转到专心拼豆页面 + window.location.href = '/focus'; + }; + // 添加一个安全的文件输入触发函数 const triggerFileInput = useCallback(() => { // 检查组件是否已挂载 @@ -2231,8 +2250,8 @@ export default function Home() { {/* ++ RENDER Enter Manual Mode Button ONLY when NOT in manual mode (before downloads) ++ */} {!isManualColoringMode && originalImageSrc && mappedPixelData && gridDimensions && ( -
{/* Wrapper div */} - {/* Keeping button styles bright for visibility in both modes */} +
{/* Wrapper div */} + {/* Manual Edit Mode Button */} + + {/* Focus Mode Button */} +
)} {/* ++ End of RENDER Enter Manual Mode Button ++ */} @@ -2381,6 +2412,16 @@ export default function Home() { onOptionsChange={setDownloadOptions} onDownload={handleDownloadRequest} /> + + {/* 专心拼豆模式进入前下载提醒弹窗 */} + setIsFocusModePreDownloadModalOpen(false)} + onProceedWithoutDownload={handleProceedToFocusMode} + mappedPixelData={mappedPixelData} + gridDimensions={gridDimensions} + selectedColorSystem={selectedColorSystem} + />
); diff --git a/src/components/ColorPanel.tsx b/src/components/ColorPanel.tsx new file mode 100644 index 0000000..3e293e9 --- /dev/null +++ b/src/components/ColorPanel.tsx @@ -0,0 +1,168 @@ +import React, { useState } from 'react'; + +interface ColorInfo { + color: string; + name: string; + total: number; + completed: number; +} + +interface ColorPanelProps { + colors: ColorInfo[]; + currentColor: string; + onColorSelect: (color: string) => void; + onClose: () => void; +} + +const ColorPanel: React.FC = ({ + colors, + currentColor, + onColorSelect, + onClose +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'progress' | 'name' | 'total'>('progress'); + + // 过滤和排序颜色 + const filteredAndSortedColors = colors + .filter(color => + color.name.toLowerCase().includes(searchTerm.toLowerCase()) || + color.color.toLowerCase().includes(searchTerm.toLowerCase()) + ) + .sort((a, b) => { + switch (sortBy) { + case 'progress': + const progressA = (a.completed / a.total) * 100; + const progressB = (b.completed / b.total) * 100; + return progressA - progressB; // 进度低的在前 + case 'name': + return a.name.localeCompare(b.name); + case 'total': + return b.total - a.total; // 数量多的在前 + default: + return 0; + } + }); + + return ( +
+
+ {/* 拖拽指示条 */} +
+
+
+ + {/* 搜索框 */} +
+
+ setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + + +
+
+ + {/* 排序选项 */} +
+ +
+ + {/* 颜色列表 */} +
+ {filteredAndSortedColors.map((colorInfo) => { + const progressPercentage = Math.round((colorInfo.completed / colorInfo.total) * 100); + const isSelected = colorInfo.color === currentColor; + const isCompleted = progressPercentage === 100; + + return ( + + ); + })} +
+ + {/* 关闭按钮 */} +
+ +
+
+
+ ); +}; + +export default ColorPanel; \ No newline at end of file diff --git a/src/components/ColorStatusBar.tsx b/src/components/ColorStatusBar.tsx new file mode 100644 index 0000000..ff83374 --- /dev/null +++ b/src/components/ColorStatusBar.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +interface ColorStatusBarProps { + currentColor: string; + colorInfo?: { + color: string; + name: string; + total: number; + completed: number; + }; + progressPercentage: number; +} + +const ColorStatusBar: React.FC = ({ + currentColor, + colorInfo, + progressPercentage +}) => { + if (!colorInfo) { + return ( +
+
请选择颜色
+
+ ); + } + + const estimatedTime = Math.ceil((colorInfo.total - colorInfo.completed) * 0.5); // 假设每个格子0.5分钟 + + return ( +
+
+
+
+
+ {colorInfo.completed}/{colorInfo.total} +
+
+ 预计还需 {estimatedTime}分钟 +
+
+
+ +
+
+ {progressPercentage}% +
+
+
+ ); +}; + +export default ColorStatusBar; diff --git a/src/components/FocusCanvas.tsx b/src/components/FocusCanvas.tsx new file mode 100644 index 0000000..26af067 --- /dev/null +++ b/src/components/FocusCanvas.tsx @@ -0,0 +1,315 @@ +import React, { useRef, useEffect, useCallback, useState } from 'react'; +import { MappedPixel } from '../utils/pixelation'; + +interface FocusCanvasProps { + mappedPixelData: MappedPixel[][]; + gridDimensions: { N: number; M: number }; + currentColor: string; + completedCells: Set; + recommendedCell: { row: number; col: number } | null; + recommendedRegion: { row: number; col: number }[] | null; + canvasScale: number; + canvasOffset: { x: number; y: number }; + onCellClick: (row: number, col: number) => void; + onScaleChange: (scale: number) => void; + onOffsetChange: (offset: { x: number; y: number }) => void; +} + +const FocusCanvas: React.FC = ({ + mappedPixelData, + gridDimensions, + currentColor, + completedCells, + recommendedCell, + recommendedRegion, + canvasScale, + canvasOffset, + onCellClick, + onScaleChange, + onOffsetChange +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [lastPanPoint, setLastPanPoint] = useState<{ x: number; y: number } | null>(null); + + // 计算格子大小 + const cellSize = Math.max(15, Math.min(40, 300 / Math.max(gridDimensions.N, gridDimensions.M))); + + // 渲染画布 + const renderCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !mappedPixelData) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // 设置画布尺寸 + const canvasWidth = gridDimensions.N * cellSize; + const canvasHeight = gridDimensions.M * cellSize; + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + canvas.style.width = `${canvasWidth}px`; + canvas.style.height = `${canvasHeight}px`; + + // 清空画布 + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + // 渲染每个格子 + for (let row = 0; row < gridDimensions.M; row++) { + for (let col = 0; col < gridDimensions.N; col++) { + const pixel = mappedPixelData[row][col]; + const x = col * cellSize; + const y = row * cellSize; + const cellKey = `${row},${col}`; + + // 确定格子颜色 + let fillColor = pixel.color; + let isGrayedOut = false; + + // 如果不是当前颜色,显示为灰度 + if (pixel.color !== currentColor) { + // 转换为灰度 + const hex = pixel.color.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + fillColor = `rgb(${gray}, ${gray}, ${gray})`; + isGrayedOut = true; + } + + // 绘制格子背景 + ctx.fillStyle = fillColor; + ctx.fillRect(x, y, cellSize, cellSize); + + // 如果是已完成的格子,添加勾选标记 + if (completedCells.has(cellKey)) { + ctx.fillStyle = 'rgba(0, 255, 0, 0.6)'; + ctx.fillRect(x, y, cellSize, cellSize); + + // 绘制勾选图标 + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x + cellSize * 0.2, y + cellSize * 0.5); + ctx.lineTo(x + cellSize * 0.4, y + cellSize * 0.7); + ctx.lineTo(x + cellSize * 0.8, y + cellSize * 0.3); + ctx.stroke(); + } + + // 如果是推荐区域的一部分,添加高亮边框 + const isInRecommendedRegion = recommendedRegion?.some(cell => + cell.row === row && cell.col === col + ); + if (isInRecommendedRegion) { + ctx.strokeStyle = '#ff4444'; + ctx.lineWidth = 3; + ctx.setLineDash([5, 5]); + ctx.strokeRect(x + 1, y + 1, cellSize - 2, cellSize - 2); + ctx.setLineDash([]); + } + + // 如果是推荐区域的中心点,添加特殊标记 + if (recommendedCell && recommendedCell.row === row && recommendedCell.col === col && isInRecommendedRegion) { + // 绘制中心点标记 + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); + ctx.arc(x + cellSize / 2, y + cellSize / 2, 4, 0, 2 * Math.PI); + ctx.fill(); + } + + // 绘制网格线 + ctx.strokeStyle = isGrayedOut ? '#ccc' : '#e0e0e0'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, cellSize, cellSize); + + // 绘制色号文字(只在格子足够大时) + if (cellSize > 20 && !isGrayedOut) { + ctx.fillStyle = '#333'; + ctx.font = `${Math.max(8, cellSize / 4)}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // 简化色号显示(取色值的后几位) + const colorText = pixel.color.substring(1, 4).toUpperCase(); + ctx.fillText(colorText, x + cellSize / 2, y + cellSize / 2); + } + } + } + }, [mappedPixelData, gridDimensions, cellSize, currentColor, completedCells, recommendedCell, recommendedRegion]); + + // 处理触摸/鼠标事件 + const getEventPosition = (event: React.MouseEvent | React.TouchEvent) => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const rect = canvas.getBoundingClientRect(); + let clientX: number, clientY: number; + + if ('touches' in event) { + if (event.touches.length === 0) return null; + clientX = event.touches[0].clientX; + clientY = event.touches[0].clientY; + } else { + clientX = event.clientX; + clientY = event.clientY; + } + + return { + x: (clientX - rect.left) / canvasScale, + y: (clientY - rect.top) / canvasScale + }; + }; + + const getGridPosition = (x: number, y: number) => { + const col = Math.floor(x / cellSize); + const row = Math.floor(y / cellSize); + + if (row >= 0 && row < gridDimensions.M && col >= 0 && col < gridDimensions.N) { + return { row, col }; + } + return null; + }; + + // 处理点击 + const handleClick = useCallback((event: React.MouseEvent | React.TouchEvent) => { + event.preventDefault(); + + const pos = getEventPosition(event); + if (!pos) return; + + const gridPos = getGridPosition(pos.x, pos.y); + if (gridPos) { + onCellClick(gridPos.row, gridPos.col); + } + }, [onCellClick, canvasScale, cellSize, gridDimensions]); + + // 处理缩放 + const handleWheel = useCallback((event: React.WheelEvent) => { + event.preventDefault(); + + const delta = event.deltaY > 0 ? 0.9 : 1.1; + const newScale = Math.max(0.3, Math.min(3, canvasScale * delta)); + onScaleChange(newScale); + }, [canvasScale, onScaleChange]); + + // 处理双指缩放(触摸) + const handleTouchStart = useCallback((event: React.TouchEvent) => { + if (event.touches.length === 1) { + // 单指拖拽开始 + setIsDragging(true); + setLastPanPoint({ + x: event.touches[0].clientX, + y: event.touches[0].clientY + }); + } else if (event.touches.length === 2) { + // 双指缩放开始 + event.preventDefault(); + setIsDragging(false); + } + }, []); + + const handleTouchMove = useCallback((event: React.TouchEvent) => { + event.preventDefault(); + + if (event.touches.length === 1 && isDragging && lastPanPoint) { + // 单指拖拽 + const deltaX = event.touches[0].clientX - lastPanPoint.x; + const deltaY = event.touches[0].clientY - lastPanPoint.y; + + onOffsetChange({ + x: canvasOffset.x + deltaX, + y: canvasOffset.y + deltaY + }); + + setLastPanPoint({ + x: event.touches[0].clientX, + y: event.touches[0].clientY + }); + } else if (event.touches.length === 2) { + // 双指缩放处理(简化版本) + // 这里可以添加更复杂的双指缩放逻辑 + } + }, [isDragging, lastPanPoint, canvasOffset, onOffsetChange]); + + const handleTouchEnd = useCallback((event: React.TouchEvent) => { + if (event.touches.length === 0) { + setIsDragging(false); + setLastPanPoint(null); + + // 如果没有移动太多,视为点击 + if (!isDragging) { + handleClick(event); + } + } + }, [isDragging, handleClick]); + + // 鼠标拖拽处理 + const handleMouseDown = useCallback((event: React.MouseEvent) => { + setIsDragging(true); + setLastPanPoint({ + x: event.clientX, + y: event.clientY + }); + }, []); + + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (isDragging && lastPanPoint) { + const deltaX = event.clientX - lastPanPoint.x; + const deltaY = event.clientY - lastPanPoint.y; + + onOffsetChange({ + x: canvasOffset.x + deltaX, + y: canvasOffset.y + deltaY + }); + + setLastPanPoint({ + x: event.clientX, + y: event.clientY + }); + } + }, [isDragging, lastPanPoint, canvasOffset, onOffsetChange]); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setLastPanPoint(null); + }, []); + + // 渲染画布 + useEffect(() => { + renderCanvas(); + }, [renderCanvas]); + + return ( +
+
+ +
+
+ ); +}; + +export default FocusCanvas; \ No newline at end of file diff --git a/src/components/FocusModePreDownloadModal.tsx b/src/components/FocusModePreDownloadModal.tsx new file mode 100644 index 0000000..5f1e63a --- /dev/null +++ b/src/components/FocusModePreDownloadModal.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React from 'react'; +import { MappedPixel } from '../utils/pixelation'; +import { ColorSystem } from '../utils/colorSystemUtils'; +import { exportCsvData } from '../utils/imageDownloader'; + +interface FocusModePreDownloadModalProps { + isOpen: boolean; + onClose: () => void; + onProceedWithoutDownload: () => void; + mappedPixelData: MappedPixel[][] | null; + gridDimensions: { N: number; M: number } | null; + selectedColorSystem: ColorSystem; +} + +const FocusModePreDownloadModal: React.FC = ({ + isOpen, + onClose, + onProceedWithoutDownload, + mappedPixelData, + gridDimensions, + selectedColorSystem +}) => { + if (!isOpen) return null; + + const handleDownloadAndProceed = () => { + // 下载CSV数据文件 + exportCsvData({ + mappedPixelData, + gridDimensions, + selectedColorSystem + }); + + // 稍等一下让下载开始,然后进入专心拼豆模式 + setTimeout(() => { + onProceedWithoutDownload(); + }, 500); + }; + + return ( +
+
+ {/* 标题 */} +
+
+ + + + +
+

+ 进入专心拼豆模式 +

+
+ + {/* 提醒内容 */} +
+
+
+ + + +
+

重要提醒

+

+ 进入专心拼豆模式后,您将无法返回到当前的编辑界面。建议您先下载当前的数据文件(CSV格式)保存,以便日后重新导入使用。 +

+
+
+
+ +
+

专心拼豆模式特点:

+
    +
  • • 专为手机优化的拼豆助手
  • +
  • • 提供颜色引导和进度追踪
  • +
  • • 支持触摸操作和缩放查看
  • +
  • • 退出后将丢失当前编辑状态
  • +
+
+
+ + {/* 操作按钮 */} +
+ + + + + +
+
+
+ ); +}; + +export default FocusModePreDownloadModal; \ No newline at end of file diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..bcce2a1 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +interface ProgressBarProps { + progressPercentage: number; + recommendedCell?: { row: number; col: number } | null; + colorInfo?: { + color: string; + name: string; + total: number; + completed: number; + }; +} + +const ProgressBar: React.FC = ({ + progressPercentage, + recommendedCell +}) => { + // 生成7个圆点来表示进度 + const progressDots = Array.from({ length: 7 }, (_, index) => { + const threshold = (index + 1) * (100 / 7); + const isFilled = progressPercentage >= threshold; + + return ( +
+ ); + }); + + return ( +
+
+ {progressDots} + + {progressPercentage}% + +
+ +
+ {recommendedCell ? ( + 下一块 → {recommendedCell.row + 1},{recommendedCell.col + 1} + ) : ( + 已完成当前颜色 + )} +
+
+ ); +}; + +export default ProgressBar; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..899e51f --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,192 @@ +import React from 'react'; + +interface SettingsPanelProps { + guidanceMode: 'nearest' | 'largest' | 'edge-first'; + onGuidanceModeChange: (mode: 'nearest' | 'largest' | 'edge-first') => void; + onClose: () => void; +} + +const SettingsPanel: React.FC = ({ + guidanceMode, + onGuidanceModeChange, + onClose +}) => { + return ( +
+
+ {/* 头部 */} +
+

设置

+ +
+ + {/* 设置内容 */} +
+ {/* 引导设置 */} +
+

智能引导

+
+ + + + + +
+
+ + {/* 显示设置 */} +
+

显示设置

+
+ + + + +
+ + +
+ 透明 + 不透明 +
+
+
+
+ + {/* 反馈设置 */} +
+

反馈设置

+
+ + + +
+
+ + {/* 进度重置 */} +
+

数据管理

+
+ + + +
+
+ + {/* 关于信息 */} +
+

关于

+
+

专心拼豆模式 v1.0

+

专为手机设计的拼豆助手

+
+

💡 提示:长按格子可以快速标记

+

💡 提示:双指缩放可以查看细节

+
+
+
+
+
+
+ ); +}; + +export default SettingsPanel; \ No newline at end of file diff --git a/src/components/ToolBar.tsx b/src/components/ToolBar.tsx new file mode 100644 index 0000000..2a557ef --- /dev/null +++ b/src/components/ToolBar.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +interface ToolBarProps { + onColorSelect: () => void; + onLocate: () => void; + onUndo: () => void; + onPause: () => void; + isPaused: boolean; +} + +const ToolBar: React.FC = ({ + onColorSelect, + onLocate, + onUndo, + onPause, + isPaused +}) => { + return ( +
+ {/* 颜色选择 */} + + + {/* 定位 */} + + + {/* 撤销 */} + + + {/* 暂停/继续 */} + +
+ ); +}; + +export default ToolBar; \ No newline at end of file diff --git a/src/utils/floodFillUtils.ts b/src/utils/floodFillUtils.ts new file mode 100644 index 0000000..3309a76 --- /dev/null +++ b/src/utils/floodFillUtils.ts @@ -0,0 +1,145 @@ +import { MappedPixel } from './pixelation'; + +// 洪水填充获取连通区域 +export function getConnectedRegion( + mappedPixelData: MappedPixel[][], + startRow: number, + startCol: number, + targetColor: string +): { row: number; col: number }[] { + if (!mappedPixelData || !mappedPixelData[startRow] || !mappedPixelData[startRow][startCol]) { + return []; + } + + const M = mappedPixelData.length; + const N = mappedPixelData[0].length; + const visited = Array(M).fill(null).map(() => Array(N).fill(false)); + const region: { row: number; col: number }[] = []; + + // 使用栈实现非递归洪水填充 + 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 = mappedPixelData[row][col]; + + // 检查是否是目标颜色且不是外部区域 + if (!currentCell || currentCell.isExternal || currentCell.color !== targetColor) { + continue; + } + + // 标记为已访问 + visited[row][col] = true; + + // 添加到区域 + region.push({ row, col }); + + // 添加相邻像素到栈中 + stack.push( + { row: row - 1, col }, // 上 + { row: row + 1, col }, // 下 + { row, col: col - 1 }, // 左 + { row, col: col + 1 } // 右 + ); + } + + return region; +} + +// 获取所有同颜色的连通区域 +export function getAllConnectedRegions( + mappedPixelData: MappedPixel[][], + targetColor: string +): { row: number; col: number }[][] { + if (!mappedPixelData || mappedPixelData.length === 0) { + return []; + } + + const M = mappedPixelData.length; + const N = mappedPixelData[0].length; + const visited = Array(M).fill(null).map(() => Array(N).fill(false)); + const regions: { row: number; col: number }[][] = []; + + for (let row = 0; row < M; row++) { + for (let col = 0; col < N; col++) { + if (!visited[row][col]) { + const currentCell = mappedPixelData[row][col]; + + if (currentCell && !currentCell.isExternal && currentCell.color === targetColor) { + const region = getConnectedRegion(mappedPixelData, row, col, targetColor); + + if (region.length > 0) { + regions.push(region); + + // 标记该区域的所有像素为已访问 + region.forEach(({ row: r, col: c }) => { + visited[r][c] = true; + }); + } + } + } + } + } + + return regions; +} + +// 检查区域是否完全已完成 +export function isRegionCompleted( + region: { row: number; col: number }[], + completedCells: Set +): boolean { + return region.every(({ row, col }) => completedCells.has(`${row},${col}`)); +} + +// 检查区域是否部分已完成 +export function isRegionPartiallyCompleted( + region: { row: number; col: number }[], + completedCells: Set +): boolean { + return region.some(({ row, col }) => completedCells.has(`${row},${col}`)); +} + +// 获取区域的中心点(用于定位和显示) +export function getRegionCenter(region: { row: number; col: number }[]): { row: number; col: number } { + if (region.length === 0) { + return { row: 0, col: 0 }; + } + + const totalRow = region.reduce((sum, cell) => sum + cell.row, 0); + const totalCol = region.reduce((sum, cell) => sum + cell.col, 0); + + return { + row: Math.floor(totalRow / region.length), + col: Math.floor(totalCol / region.length) + }; +} + +// 根据距离排序区域(用于最近优先的引导模式) +export function sortRegionsByDistance( + regions: { row: number; col: number }[][], + referencePoint: { row: number; col: number } +): { row: number; col: number }[][] { + return regions.sort((a, b) => { + const centerA = getRegionCenter(a); + const centerB = getRegionCenter(b); + + const distanceA = Math.abs(centerA.row - referencePoint.row) + Math.abs(centerA.col - referencePoint.col); + const distanceB = Math.abs(centerB.row - referencePoint.row) + Math.abs(centerB.col - referencePoint.col); + + return distanceA - distanceB; + }); +} + +// 根据大小排序区域(用于最大优先的引导模式) +export function sortRegionsBySize( + regions: { row: number; col: number }[][] +): { row: number; col: number }[][] { + return regions.sort((a, b) => b.length - a.length); +} \ No newline at end of file