BaekRyang c18f3fffb5 Refactor: Update import paths with alias
- `@/types` 경로를 사용하여 타입 관련 import 경로를 수정했습니다.
- `@/engine` 경로를 사용하여 엔진 관련 import 경로를 수정했습니다.
- `@/editor` 경로를 사용하여 에디터 관련 import 경로를 수정했습니다.
- `@/components` 경로를 사용하여 컴포넌트 관련 import 경로를 수정했습니다.
- `@/hooks` 경로를 사용하여 훅 관련 import 경로를 수정했습니다.
- `@/utils` 경로를 사용하여 유틸리티 관련 import 경로를 수정했습니다.
2025-11-06 09:41:12 +09:00

438 lines
14 KiB
TypeScript

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<DistortionArea>) => void;
draggingPointIndex: number | null;
onStartDragging: (pointIndex: number) => void;
onStopDragging: () => void;
/** 에디터 캔버스 스타일 커스터마이징 */
style?: EditorCanvasStyle;
/** 에디터 UI 표시 여부 (기본값: true) */
showEditor?: boolean;
}
export const EditorCanvas: React.FC<EditorCanvasProps> = ({
areas,
selectedAreaId,
imageSrc,
width,
height,
onUpdatePoint,
onUpdateArea,
draggingPointIndex,
onStartDragging,
onStopDragging,
style: customStyle,
showEditor = true,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({width: 0, height: 0});
const [isDraggingArea, setIsDraggingArea] = useState(false);
const [dragStartPos, setDragStartPos] = useState<Point | null>(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 (
<div
ref={containerRef}
className="editor-canvas"
style={{
width,
height,
position: 'relative',
cursor: showEditor ? getCursorStyle() : 'default',
pointerEvents: showEditor ? 'auto' : 'none',
touchAction: 'none', // 터치 시 모든 브라우저 동작 비활성화 (스크롤, 줌 등)
}}
onMouseDown={showEditor ? handleCanvasDown : undefined}
onMouseMove={showEditor ? handleMove : undefined}
onTouchStart={showEditor ? handleCanvasDown : undefined}
onTouchMove={showEditor ? handleMove : undefined}
>
{/* ImageDistortion 컴포넌트 */}
<ImageDistortion imageSrc={imageSrc} areas={areas}/>
{/* 오버레이 SVG - 에디터 모드일 때만 표시 */}
{showEditor && (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
{/* 모든 영역의 사각형 표시 */}
{areas.map((area) => {
const isSelected = area.id === selectedAreaId;
const points = area.basePoints;
const outlineStyle = editorStyle.areaOutline || {};
return (
<g key={area.id}>
{/* 사각형 배경 및 경계선 */}
<polygon
points={points
.map((p) => `${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}
/>
</g>
);
})}
</svg>
)}
{/* 선택된 영역의 타원 가이드 (Canvas로 그리기) - 에디터 모드일 때만 표시 */}
{showEditor && selectedArea && canvasSize.width > 0 && (
<canvas
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
width={canvasSize.width}
height={canvasSize.height}
ref={(canvas) => {
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 (
<div
key={index}
className={`point-handle ${draggingPointIndex === index ? 'dragging' : ''}`}
style={{
position: 'absolute',
left: `${point.x * 100}%`,
top: `${point.y * 100}%`,
transform: 'translate(-50%, -50%)',
width: handleStyle.size || 16,
height: handleStyle.size || 16,
borderRadius: '50%',
backgroundColor: handleStyle.fillColor || '#00aaff',
border: `${handleStyle.strokeWidth || 2}px solid ${handleStyle.strokeColor || 'white'}`,
cursor: 'grab',
pointerEvents: 'auto',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
onMouseDown={handlePointDown(index)}
onTouchStart={handlePointDown(index)}
>
<div
style={{
position: 'absolute',
top: -24,
left: '50%',
transform: 'translateX(-50%)',
fontSize: handleStyle.labelFontSize || 11,
color: handleStyle.labelColor || '#00aaff',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
whiteSpace: 'nowrap',
}}
>
P{index + 1}
</div>
</div>
);
})}
</div>
);
};