- `@/types` 경로를 사용하여 타입 관련 import 경로를 수정했습니다. - `@/engine` 경로를 사용하여 엔진 관련 import 경로를 수정했습니다. - `@/editor` 경로를 사용하여 에디터 관련 import 경로를 수정했습니다. - `@/components` 경로를 사용하여 컴포넌트 관련 import 경로를 수정했습니다. - `@/hooks` 경로를 사용하여 훅 관련 import 경로를 수정했습니다. - `@/utils` 경로를 사용하여 유틸리티 관련 import 경로를 수정했습니다.
438 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|