Files
perler-beads/src/components/FocusCanvas.tsx
zihanjian acc154abaf 去背景
2025-06-25 15:51:36 +08:00

424 lines
14 KiB
TypeScript

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<string>;
recommendedCell: { row: number; col: number } | null;
recommendedRegion: { row: number; col: number }[] | null;
canvasScale: number;
canvasOffset: { x: number; y: number };
gridSectionInterval: number;
showSectionLines: boolean;
sectionLineColor: string;
onCellClick: (row: number, col: number) => void;
onScaleChange: (scale: number) => void;
onOffsetChange: (offset: { x: number; y: number }) => void;
highlightColor?: string | null;
editMode?: 'focus' | 'preview' | 'edit';
selectedCells?: Set<string> | null;
onCellHover?: (row: number, col: number) => void;
onSelectionEnd?: () => void;
}
const FocusCanvas: React.FC<FocusCanvasProps> = ({
mappedPixelData,
gridDimensions,
currentColor,
completedCells,
recommendedCell,
recommendedRegion,
canvasScale,
canvasOffset,
gridSectionInterval,
showSectionLines,
sectionLineColor,
onCellClick,
onScaleChange,
onOffsetChange,
highlightColor,
editMode = 'focus',
selectedCells,
onCellHover,
onSelectionEnd
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [lastPanPoint, setLastPanPoint] = useState<{ x: number; y: number } | null>(null);
const [lastPinchDistance, setLastPinchDistance] = useState<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}`;
// 跳过外部或透明像素
if (pixel.isExternal || pixel.key === 'transparent') continue;
// 确定格子颜色
let fillColor = pixel.color;
// 专心模式:如果不是当前颜色,显示为灰度
if (editMode === 'focus' && 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})`;
}
// 预览模式:高亮显示选中的颜色
if (editMode === 'preview' && highlightColor && pixel.color === highlightColor) {
// 保持原色,稍后会添加高亮效果
}
// 绘制格子背景
ctx.fillStyle = fillColor;
ctx.fillRect(x, y, cellSize, cellSize);
// 预览模式:添加高亮效果
if (editMode === 'preview' && highlightColor && pixel.color === highlightColor) {
ctx.fillStyle = 'rgba(255, 255, 0, 0.4)'; // 黄色高亮
ctx.fillRect(x, y, cellSize, cellSize);
// 添加闪烁边框
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, cellSize - 2, cellSize - 2);
}
// 编辑模式:显示选中的单元格
if (editMode === 'edit' && selectedCells && selectedCells.has(cellKey)) {
ctx.fillStyle = 'rgba(0, 123, 255, 0.3)'; // 蓝色半透明
ctx.fillRect(x, y, cellSize, cellSize);
// 添加选中边框
ctx.strokeStyle = '#007bff';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, cellSize - 2, cellSize - 2);
}
// 如果是已完成的格子且是当前颜色,添加勾选标记(仅专心模式)
if (editMode === 'focus' && completedCells.has(cellKey) && pixel.color === currentColor) {
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();
}
}
}
// 绘制分区线(在所有格子绘制完成后)
if (showSectionLines) {
ctx.strokeStyle = sectionLineColor;
ctx.lineWidth = 2;
// 绘制竖直分区线
for (let col = gridSectionInterval; col < gridDimensions.N; col += gridSectionInterval) {
const x = col * cellSize;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvasHeight);
ctx.stroke();
}
// 绘制水平分区线
for (let row = gridSectionInterval; row < gridDimensions.M; row += gridSectionInterval) {
const y = row * cellSize;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvasWidth, y);
ctx.stroke();
}
}
}, [mappedPixelData, gridDimensions, cellSize, currentColor, completedCells, recommendedCell, recommendedRegion, gridSectionInterval, showSectionLines, sectionLineColor, highlightColor, editMode, selectedCells, canvasScale]);
// 处理触摸/鼠标事件
const getEventPosition = useCallback((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
};
}, [canvasScale]);
const getGridPosition = useCallback((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;
}, [cellSize, gridDimensions]);
// 计算两指间距离
const getTouchDistance = (touches: React.TouchList) => {
if (touches.length < 2) return 0;
const touch1 = touches[0];
const touch2 = touches[1];
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
};
// 处理点击
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, getEventPosition, getGridPosition]);
// 处理缩放
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
});
setLastPinchDistance(null);
} else if (event.touches.length === 2) {
// 双指缩放开始
event.preventDefault();
setIsDragging(false);
setLastPanPoint(null);
setLastPinchDistance(getTouchDistance(event.touches));
}
}, []);
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 && lastPinchDistance !== null) {
// 双指缩放处理
const currentDistance = getTouchDistance(event.touches);
const scaleRatio = currentDistance / lastPinchDistance;
// 限制缩放范围并应用缩放
const newScale = Math.max(0.3, Math.min(3, canvasScale * scaleRatio));
onScaleChange(newScale);
// 更新距离记录
setLastPinchDistance(currentDistance);
}
}, [isDragging, lastPanPoint, canvasOffset, onOffsetChange, lastPinchDistance, canvasScale, onScaleChange]);
const handleTouchEnd = useCallback((event: React.TouchEvent) => {
if (event.touches.length === 0) {
setIsDragging(false);
setLastPanPoint(null);
setLastPinchDistance(null);
// 如果没有移动太多,视为点击
if (!isDragging) {
handleClick(event);
}
} else if (event.touches.length === 1) {
// 从双指缩放切换到单指拖拽
setLastPinchDistance(null);
setIsDragging(true);
setLastPanPoint({
x: event.touches[0].clientX,
y: event.touches[0].clientY
});
}
}, [isDragging, handleClick]);
// 鼠标拖拽处理
const handleMouseDown = useCallback((event: React.MouseEvent) => {
setIsDragging(true);
setLastPanPoint({
x: event.clientX,
y: event.clientY
});
}, []);
const handleMouseMove = useCallback((event: React.MouseEvent) => {
// 编辑模式下的悬停
if (editMode === 'edit' && onCellHover) {
const pos = getEventPosition(event);
if (pos) {
const gridPos = getGridPosition(pos.x, pos.y);
if (gridPos) {
onCellHover(gridPos.row, gridPos.col);
}
}
}
// 拖拽
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, editMode, onCellHover, getEventPosition, getGridPosition]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setLastPanPoint(null);
// 编辑模式下结束选择
if (editMode === 'edit' && onSelectionEnd) {
onSelectionEnd();
}
}, [editMode, onSelectionEnd]);
// 渲染画布
useEffect(() => {
renderCanvas();
}, [renderCanvas]);
return (
<div
ref={containerRef}
className="w-full h-full flex items-center justify-center overflow-hidden"
style={{
touchAction: 'none',
backgroundImage: `repeating-conic-gradient(#f0f0f0 0% 25%, white 0% 50%)`,
backgroundSize: '20px 20px',
backgroundPosition: '0 0, 10px 10px'
}}
>
<div
style={{
transform: `scale(${canvasScale}) translate(${canvasOffset.x}px, ${canvasOffset.y}px)`,
transformOrigin: 'center center'
}}
>
<canvas
ref={canvasRef}
className="cursor-crosshair border border-gray-300"
style={{ backgroundColor: 'transparent' }}
onClick={handleClick}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
</div>
);
};
export default FocusCanvas;