import React, {useRef, useEffect, useState, useCallback, useMemo} from 'react'; import {DistortionArea, Point} from '@/types'; import {ImageDistortion} from '@/components/ImageDistortion'; import {EditorCanvasStyle} from '../types'; import {DEFAULT_EDITOR_CANVAS_STYLE} from '@/editor'; interface EditorCanvasProps { areas: DistortionArea[]; selectedAreaId: string | null; imageSrc: string; width: number; height: number; onUpdatePoint: (areaId: string, pointIndex: number, point: Point) => void; onUpdateArea: (areaId: string, updates: Partial) => void; draggingPointIndex: number | null; onStartDragging: (pointIndex: number) => void; onStopDragging: () => void; /** 에디터 캔버스 스타일 커스터마이징 */ style?: EditorCanvasStyle; /** 에디터 UI 표시 여부 (기본값: true) */ showEditor?: boolean; } export const EditorCanvas: React.FC = ({ areas, selectedAreaId, imageSrc, width, height, onUpdatePoint, onUpdateArea, draggingPointIndex, onStartDragging, onStopDragging, style: customStyle, showEditor = true, }) => { const containerRef = useRef(null); const [canvasSize, setCanvasSize] = useState({width: 0, height: 0}); const [isDraggingArea, setIsDraggingArea] = useState(false); const [dragStartPos, setDragStartPos] = useState(null); // 스타일 병합 (커스텀 스타일 우선) const editorStyle = useMemo(() => ({ ...DEFAULT_EDITOR_CANVAS_STYLE, ...customStyle, circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels, centerPoint: { ...DEFAULT_EDITOR_CANVAS_STYLE.centerPoint, ...customStyle?.centerPoint, }, pointHandle: { ...DEFAULT_EDITOR_CANVAS_STYLE.pointHandle, ...customStyle?.pointHandle, }, areaOutline: { ...DEFAULT_EDITOR_CANVAS_STYLE.areaOutline, ...customStyle?.areaOutline, }, }), [customStyle]); // 컨테이너 크기 측정 useEffect(() => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); setCanvasSize({width: rect.width, height: rect.height}); }, [width, height]); // 선택된 영역 찾기 const selectedArea = areas.find((a) => a.id === selectedAreaId); // 점이 사각형 내부에 있는지 확인 (Point-in-Polygon test) const isPointInPolygon = useCallback((point: Point, polygon: Point[]): boolean => { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x, yi = polygon[i].y; const xj = polygon[j].x, yj = polygon[j].y; const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi); if (intersect) inside = !inside; } return inside; }, []); // 포인트 핸들 클릭/터치 핸들러 const handlePointDown = useCallback( (pointIndex: number) => (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); e.stopPropagation(); onStartDragging(pointIndex); }, [onStartDragging] ); // 캔버스 다운 (마우스/터치 공통) const handleCanvasDown = useCallback( (e: React.MouseEvent | React.TouchEvent) => { // 에디터가 숨겨진 상태면 동작하지 않음 if (!showEditor || !selectedArea || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); // 마우스 또는 터치 좌표 추출 let clientX: number, clientY: number; if ('touches' in e) { if (e.touches.length === 0) return; clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { clientX = e.clientX; clientY = e.clientY; } const x = (clientX - rect.left) / rect.width; const y = (clientY - rect.top) / rect.height; const clickPoint = { x, y }; // 사각형 내부를 클릭했는지 확인 if (isPointInPolygon(clickPoint, selectedArea.basePoints)) { setIsDraggingArea(true); setDragStartPos(clickPoint); e.preventDefault(); } }, [showEditor, selectedArea, isPointInPolygon] ); // 이동 (마우스/터치 공통) const handleMove = useCallback( (e: React.MouseEvent | React.TouchEvent) => { // 에디터가 숨겨진 상태면 동작하지 않음 if (!showEditor || !selectedArea || !containerRef.current) return; // 터치 이벤트면 스크롤 방지 if ('touches' in e && (draggingPointIndex !== null || isDraggingArea)) { e.preventDefault(); } const rect = containerRef.current.getBoundingClientRect(); // 마우스 또는 터치 좌표 추출 let clientX: number, clientY: number; if ('touches' in e) { if (e.touches.length === 0) return; clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { clientX = e.clientX; clientY = e.clientY; } const x = (clientX - rect.left) / rect.width; const y = (clientY - rect.top) / rect.height; // 포인트 드래그 중 if (draggingPointIndex !== null) { const clampedX = Math.max(0, Math.min(1, x)); const clampedY = Math.max(0, Math.min(1, y)); onUpdatePoint(selectedArea.id, draggingPointIndex, {x: clampedX, y: clampedY}); } // 사각형 전체 드래그 중 else if (isDraggingArea && dragStartPos) { const deltaX = x - dragStartPos.x; const deltaY = y - dragStartPos.y; // 모든 포인트를 delta만큼 이동 const newPoints = selectedArea.basePoints.map((point) => ({ x: Math.max(0, Math.min(1, point.x + deltaX)), y: Math.max(0, Math.min(1, point.y + deltaY)), })) as [Point, Point, Point, Point]; onUpdateArea(selectedArea.id, { basePoints: newPoints }); setDragStartPos({ x, y }); // 다음 프레임을 위해 시작 위치 업데이트 } }, [showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea] ); // 업 (마우스/터치 공통) const handleUp = useCallback(() => { if (draggingPointIndex !== null) { onStopDragging(); } if (isDraggingArea) { setIsDraggingArea(false); setDragStartPos(null); } }, [draggingPointIndex, isDraggingArea, onStopDragging]); // 전역 업 이벤트 (마우스/터치) useEffect(() => { if (draggingPointIndex !== null || isDraggingArea) { window.addEventListener('mouseup', handleUp); window.addEventListener('touchend', handleUp); window.addEventListener('touchcancel', handleUp); return () => { window.removeEventListener('mouseup', handleUp); window.removeEventListener('touchend', handleUp); window.removeEventListener('touchcancel', handleUp); }; } }, [draggingPointIndex, isDraggingArea, handleUp]); // UV 좌표를 픽셀 좌표로 변환 (셰이더와 동일한 bilinear interpolation) const uvToPixel = ( u: number, v: number, points: [Point, Point, Point, Point], canvasWidth: number, canvasHeight: number ): { x: number; y: number } => { // p0=좌상, p1=우상, p2=우하, p3=좌하 const [p0, p1, p2, p3] = points; // 셰이더 computeUV와 동일한 순서로 bilinear interpolation // left = mix(p0, p1, u) -> 상단 가장자리 // right = mix(p3, p2, u) -> 하단 가장자리 // position = mix(left, right, v) const leftX = p0.x * (1 - u) + p1.x * u; const leftY = p0.y * (1 - u) + p1.y * u; const rightX = p3.x * (1 - u) + p2.x * u; const rightY = p3.y * (1 - u) + p2.y * u; const posX = leftX * (1 - v) + rightX * v; const posY = leftY * (1 - v) + rightY * v; return { x: posX * canvasWidth, y: posY * canvasHeight, }; }; // UV 좌표계의 원을 정확히 그리기 (찌그러진 원 형태) const drawDistortionCircle = useCallback(( ctx: CanvasRenderingContext2D, points: [Point, Point, Point, Point], canvasWidth: number, canvasHeight: number ) => { const segments = 128; // 원을 128개 세그먼트로 촘촘히 분할 const centerU = 0.5; const centerV = 0.5; const circleLevels = editorStyle.circleLevels || []; // 원 레벨별로 그리기 (외부 -> 내부 순) circleLevels.forEach((level, index) => { const levelPoints: { x: number; y: number }[] = []; for (let i = 0; i <= segments; i++) { const theta = (i / segments) * 2 * Math.PI; const u = centerU - level.radius * Math.sin(theta); const v = centerV + level.radius * Math.cos(theta); const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight); levelPoints.push(pixelPos); } ctx.beginPath(); ctx.moveTo(levelPoints[0].x, levelPoints[0].y); for (let i = 1; i < levelPoints.length; i++) { ctx.lineTo(levelPoints[i].x, levelPoints[i].y); } ctx.closePath(); // 원 테두리 const baseColor = level.color || 'rgba(255, 200, 0, 1)'; // baseColor에서 RGB 추출하고 opacity 적용 const colorWithOpacity = baseColor.replace(/rgba?\(([^)]+)\)/, (_, rgb) => { const parts = rgb.split(',').map((p: string) => p.trim()); return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${level.opacity})`; }); ctx.strokeStyle = colorWithOpacity; ctx.lineWidth = level.lineWidth; if (level.dashPattern) { ctx.setLineDash(level.dashPattern); } ctx.stroke(); ctx.setLineDash([]); // 가장 외부 원만 내부 채우기 if (index === 0 && editorStyle.circleFillColor) { ctx.fillStyle = editorStyle.circleFillColor; ctx.fill(); } }); // 중심점 표시 const centerPointStyle = editorStyle.centerPoint || {}; const centerPixel = uvToPixel(centerU, centerV, points, canvasWidth, canvasHeight); ctx.beginPath(); ctx.arc(centerPixel.x, centerPixel.y, centerPointStyle.radius || 5, 0, 2 * Math.PI); if (centerPointStyle.fillColor) { ctx.fillStyle = centerPointStyle.fillColor; ctx.fill(); } if (centerPointStyle.strokeColor) { ctx.strokeStyle = centerPointStyle.strokeColor; ctx.lineWidth = centerPointStyle.strokeWidth || 2; ctx.stroke(); } }, [editorStyle]); // 커서 스타일 결정 const getCursorStyle = () => { if (draggingPointIndex !== null) return 'grabbing'; if (isDraggingArea) return 'grabbing'; return 'default'; }; return (
{/* ImageDistortion 컴포넌트 */} {/* 오버레이 SVG - 에디터 모드일 때만 표시 */} {showEditor && ( {/* 모든 영역의 사각형 표시 */} {areas.map((area) => { const isSelected = area.id === selectedAreaId; const points = area.basePoints; const outlineStyle = editorStyle.areaOutline || {}; return ( {/* 사각형 배경 및 경계선 */} `${p.x * canvasSize.width},${p.y * canvasSize.height}`) .join(' ')} fill={isSelected ? (outlineStyle.selectedFillColor || 'rgba(0, 170, 255, 0.08)') : (outlineStyle.unselectedFillColor || 'rgba(136, 136, 136, 0.03)')} stroke={isSelected ? (outlineStyle.selectedColor || '#00aaff') : (outlineStyle.unselectedColor || '#888')} strokeWidth={isSelected ? (outlineStyle.selectedWidth || 2) : (outlineStyle.unselectedWidth || 1)} strokeDasharray={isSelected ? '0' : (outlineStyle.unselectedDashPattern?.join(',') || '5,5')} opacity={isSelected ? 1 : 0.5} /> ); })} )} {/* 선택된 영역의 타원 가이드 (Canvas로 그리기) - 에디터 모드일 때만 표시 */} {showEditor && selectedArea && canvasSize.width > 0 && ( { if (canvas) { const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvasSize.width, canvasSize.height); drawDistortionCircle(ctx, selectedArea.basePoints, canvasSize.width, canvasSize.height); } } }} /> )} {/* 선택된 영역의 포인트 핸들 - 에디터 모드일 때만 표시 */} {showEditor && selectedArea && selectedArea.basePoints.map((point, index) => { const handleStyle = editorStyle.pointHandle || {}; return (
P{index + 1}
); })}
); };