feat: Add interactive distortion area editor
- Add EditorCanvas component with visual distortion area editing - Point-in-polygon detection for area selection - Individual point dragging with visual handles - Entire area dragging by clicking inside polygon - UV-space distortion circle visualization - Add AreaList component for managing multiple distortion areas - Add ParameterPanel for editing distortion properties - Base points (normalized coordinates) - Drag vectors and distortion strength - Animation duration and easing - Add DistortionEditor main component with sidebar layout - Add useDistortionEditor hook for state management - Add editor types and interfaces 사각형 내부를 클릭하여 전체 영역을 드래그할 수 있는 기능 포함
This commit is contained in:
parent
ef992b5525
commit
63e7bac3c7
108
src/editor/DistortionEditor.tsx
Normal file
108
src/editor/DistortionEditor.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { DistortionArea } from '../types/area';
|
||||||
|
import { DistortionEditorProps } from './types';
|
||||||
|
import { useDistortionEditor } from './hooks/useDistortionEditor';
|
||||||
|
import { EditorCanvas } from './components/EditorCanvas';
|
||||||
|
import { AreaList } from './components/AreaList';
|
||||||
|
import { ParameterPanel } from './components/ParameterPanel';
|
||||||
|
import { DEFAULT_AREA } from '../utils/constants';
|
||||||
|
|
||||||
|
export const DistortionEditor: React.FC<DistortionEditorProps> = ({
|
||||||
|
initialAreas = [],
|
||||||
|
imageSrc,
|
||||||
|
onAreasChange,
|
||||||
|
onSelectedAreaChange,
|
||||||
|
width = 800,
|
||||||
|
height = 600,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
selectArea,
|
||||||
|
addArea,
|
||||||
|
removeArea,
|
||||||
|
updateArea,
|
||||||
|
updatePoint,
|
||||||
|
startDragging,
|
||||||
|
stopDragging,
|
||||||
|
getSelectedArea,
|
||||||
|
} = useDistortionEditor(initialAreas);
|
||||||
|
|
||||||
|
// 영역 변경 시 콜백 호출
|
||||||
|
useEffect(() => {
|
||||||
|
onAreasChange?.(state.areas);
|
||||||
|
}, [state.areas, onAreasChange]);
|
||||||
|
|
||||||
|
// 선택된 영역 변경 시 콜백 호출
|
||||||
|
useEffect(() => {
|
||||||
|
onSelectedAreaChange?.(state.selectedAreaId);
|
||||||
|
}, [state.selectedAreaId, onSelectedAreaChange]);
|
||||||
|
|
||||||
|
// 새 영역 추가 핸들러
|
||||||
|
const handleAddArea = () => {
|
||||||
|
const newArea: DistortionArea = {
|
||||||
|
id: `area-${Date.now()}`,
|
||||||
|
basePoints: [
|
||||||
|
{ x: 0.3, y: 0.3 },
|
||||||
|
{ x: 0.7, y: 0.3 },
|
||||||
|
{ x: 0.7, y: 0.7 },
|
||||||
|
{ x: 0.3, y: 0.7 },
|
||||||
|
],
|
||||||
|
movement: {
|
||||||
|
vectorA: { x: DEFAULT_AREA.VECTOR_A.x, y: DEFAULT_AREA.VECTOR_A.y },
|
||||||
|
vectorB: { x: DEFAULT_AREA.VECTOR_B.x, y: DEFAULT_AREA.VECTOR_B.y },
|
||||||
|
duration: DEFAULT_AREA.DURATION,
|
||||||
|
easing: DEFAULT_AREA.EASING as any,
|
||||||
|
},
|
||||||
|
distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH,
|
||||||
|
progress: 0,
|
||||||
|
dragVector: { x: 0, y: 0 },
|
||||||
|
};
|
||||||
|
addArea(newArea);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파라미터 업데이트 핸들러
|
||||||
|
const handleUpdateArea = (updates: Partial<DistortionArea>) => {
|
||||||
|
if (state.selectedAreaId) {
|
||||||
|
updateArea(state.selectedAreaId, updates);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedArea = getSelectedArea();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="distortion-editor">
|
||||||
|
<div className="editor-main">
|
||||||
|
{/* 왼쪽: 캔버스 */}
|
||||||
|
<div className="editor-canvas-container">
|
||||||
|
<EditorCanvas
|
||||||
|
areas={state.areas}
|
||||||
|
selectedAreaId={state.selectedAreaId}
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
onUpdatePoint={updatePoint}
|
||||||
|
onUpdateArea={updateArea}
|
||||||
|
draggingPointIndex={state.draggingPointIndex}
|
||||||
|
onStartDragging={startDragging}
|
||||||
|
onStopDragging={stopDragging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 사이드바 */}
|
||||||
|
<div className="editor-sidebar">
|
||||||
|
{/* 영역 목록 */}
|
||||||
|
<AreaList
|
||||||
|
areas={state.areas}
|
||||||
|
selectedAreaId={state.selectedAreaId}
|
||||||
|
onSelectArea={selectArea}
|
||||||
|
onRemoveArea={removeArea}
|
||||||
|
onAddArea={handleAddArea}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 파라미터 패널 */}
|
||||||
|
<ParameterPanel area={selectedArea} onUpdateArea={handleUpdateArea} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
src/editor/components/AreaList.tsx
Normal file
62
src/editor/components/AreaList.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DistortionArea } from '../../types/area';
|
||||||
|
|
||||||
|
interface AreaListProps {
|
||||||
|
areas: DistortionArea[];
|
||||||
|
selectedAreaId: string | null;
|
||||||
|
onSelectArea: (areaId: string) => void;
|
||||||
|
onRemoveArea: (areaId: string) => void;
|
||||||
|
onAddArea: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AreaList: React.FC<AreaListProps> = ({
|
||||||
|
areas,
|
||||||
|
selectedAreaId,
|
||||||
|
onSelectArea,
|
||||||
|
onRemoveArea,
|
||||||
|
onAddArea,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="area-list">
|
||||||
|
<div className="area-list-header">
|
||||||
|
<h3>왜곡 영역</h3>
|
||||||
|
<button
|
||||||
|
onClick={onAddArea}
|
||||||
|
disabled={areas.length >= 8}
|
||||||
|
className="btn-add"
|
||||||
|
title={areas.length >= 8 ? '최대 8개 영역까지 지원' : '새 영역 추가'}
|
||||||
|
>
|
||||||
|
+ 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="area-list-items">
|
||||||
|
{areas.length === 0 ? (
|
||||||
|
<div className="area-list-empty">영역이 없습니다. + 추가 버튼을 눌러주세요.</div>
|
||||||
|
) : (
|
||||||
|
areas.map((area, index) => (
|
||||||
|
<div
|
||||||
|
key={area.id}
|
||||||
|
className={`area-item ${selectedAreaId === area.id ? 'selected' : ''}`}
|
||||||
|
onClick={() => onSelectArea(area.id)}
|
||||||
|
>
|
||||||
|
<div className="area-item-info">
|
||||||
|
<span className="area-item-name">영역 {index + 1}</span>
|
||||||
|
<span className="area-item-strength">강도: {(area.distortionStrength * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveArea(area.id);
|
||||||
|
}}
|
||||||
|
className="btn-remove"
|
||||||
|
title="영역 삭제"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
365
src/editor/components/EditorCanvas.tsx
Normal file
365
src/editor/components/EditorCanvas.tsx
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
import React, {useRef, useEffect, useState, useCallback} from 'react';
|
||||||
|
import {DistortionArea, Point} from '../../types/area';
|
||||||
|
import {ImageDistortion} from '../../components/ImageDistortion';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||||
|
areas,
|
||||||
|
selectedAreaId,
|
||||||
|
imageSrc,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onUpdatePoint,
|
||||||
|
onUpdateArea,
|
||||||
|
draggingPointIndex,
|
||||||
|
onStartDragging,
|
||||||
|
onStopDragging,
|
||||||
|
}) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 컨테이너 크기 측정
|
||||||
|
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 handleMouseDown = useCallback(
|
||||||
|
(pointIndex: number) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onStartDragging(pointIndex);
|
||||||
|
},
|
||||||
|
[onStartDragging]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 캔버스 클릭 (사각형 내부 클릭 감지)
|
||||||
|
const handleCanvasMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!selectedArea || !containerRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / rect.width;
|
||||||
|
const y = (e.clientY - rect.top) / rect.height;
|
||||||
|
const clickPoint = { x, y };
|
||||||
|
|
||||||
|
// 사각형 내부를 클릭했는지 확인
|
||||||
|
if (isPointInPolygon(clickPoint, selectedArea.basePoints)) {
|
||||||
|
setIsDraggingArea(true);
|
||||||
|
setDragStartPos(clickPoint);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedArea, isPointInPolygon]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!selectedArea || !containerRef.current) return;
|
||||||
|
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / rect.width;
|
||||||
|
const y = (e.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 }); // 다음 프레임을 위해 시작 위치 업데이트
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (draggingPointIndex !== null) {
|
||||||
|
onStopDragging();
|
||||||
|
}
|
||||||
|
if (isDraggingArea) {
|
||||||
|
setIsDraggingArea(false);
|
||||||
|
setDragStartPos(null);
|
||||||
|
}
|
||||||
|
}, [draggingPointIndex, isDraggingArea, onStopDragging]);
|
||||||
|
|
||||||
|
// 전역 마우스 업 이벤트
|
||||||
|
useEffect(() => {
|
||||||
|
if (draggingPointIndex !== null || isDraggingArea) {
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
}, [draggingPointIndex, isDraggingArea, handleMouseUp]);
|
||||||
|
|
||||||
|
// 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 = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
points: [Point, Point, Point, Point],
|
||||||
|
canvasWidth: number,
|
||||||
|
canvasHeight: number
|
||||||
|
) => {
|
||||||
|
const segments = 128; // 원을 128개 세그먼트로 촘촘히 분할
|
||||||
|
const centerU = 0.5;
|
||||||
|
const centerV = 0.5;
|
||||||
|
const maxRadius = 0.5; // UV 좌표계에서 최대 반지름 0.5 (셰이더의 maxUvRadius)
|
||||||
|
|
||||||
|
// 원 위의 점들을 UV 좌표로 샘플링 후 픽셀 좌표로 변환
|
||||||
|
// 4가지 조합을 모두 테스트 (사용자가 이미지에서 P1-P3 대각선으로 늘렸을 때 왜곡도 같은 방향이어야 함)
|
||||||
|
const circlePoints: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const theta = (i / segments) * 2 * Math.PI;
|
||||||
|
|
||||||
|
// 테스트: u=-sin, v=cos (-90도 회전, P1-P3 방향에 맞춤)
|
||||||
|
const u = centerU - maxRadius * Math.sin(theta);
|
||||||
|
const v = centerV + maxRadius * Math.cos(theta);
|
||||||
|
|
||||||
|
const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight);
|
||||||
|
circlePoints.push(pixelPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 찌그러진 원 그리기 (실제 왜곡 적용 경계)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(circlePoints[0].x, circlePoints[0].y);
|
||||||
|
for (let i = 1; i < circlePoints.length; i++) {
|
||||||
|
ctx.lineTo(circlePoints[i].x, circlePoints[i].y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 200, 0, 0.9)';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.setLineDash([8, 4]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// 내부를 반투명하게 채우기
|
||||||
|
ctx.fillStyle = 'rgba(255, 200, 0, 0.12)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 영향력 그라디언트를 나타내는 추가 원들 (0.25, 0.375 반지름)
|
||||||
|
for (const r of [0.25, 0.375]) {
|
||||||
|
const gradientPoints: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const theta = (i / segments) * 2 * Math.PI;
|
||||||
|
const u = centerU - r * Math.sin(theta);
|
||||||
|
const v = centerV + r * Math.cos(theta);
|
||||||
|
const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight);
|
||||||
|
gradientPoints.push(pixelPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gradientPoints[0].x, gradientPoints[0].y);
|
||||||
|
for (let i = 1; i < gradientPoints.length; i++) {
|
||||||
|
ctx.lineTo(gradientPoints[i].x, gradientPoints[i].y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
const alpha = r / maxRadius;
|
||||||
|
ctx.strokeStyle = `rgba(255, 220, 100, ${0.3 * alpha})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중심점 표시
|
||||||
|
const centerPixel = uvToPixel(centerU, centerV, points, canvasWidth, canvasHeight);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerPixel.x, centerPixel.y, 5, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = 'rgba(255, 200, 0, 1)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 커서 스타일 결정
|
||||||
|
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: getCursorStyle()}}
|
||||||
|
onMouseDown={handleCanvasMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
{/* ImageDistortion 컴포넌트 */}
|
||||||
|
<ImageDistortion imageSrc={imageSrc} areas={areas} width={width} height={height}/>
|
||||||
|
|
||||||
|
{/* 오버레이 SVG */}
|
||||||
|
<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;
|
||||||
|
return (
|
||||||
|
<g key={area.id}>
|
||||||
|
{/* 사각형 경계선 */}
|
||||||
|
<polygon
|
||||||
|
points={points
|
||||||
|
.map((p) => `${p.x * canvasSize.width},${p.y * canvasSize.height}`)
|
||||||
|
.join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke={isSelected ? '#00aaff' : '#888'}
|
||||||
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
|
strokeDasharray={isSelected ? '0' : '5,5'}
|
||||||
|
opacity={isSelected ? 1 : 0.5}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 선택된 영역의 타원 가이드 (Canvas로 그리기) */}
|
||||||
|
{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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 영역의 포인트 핸들 */}
|
||||||
|
{selectedArea &&
|
||||||
|
selectedArea.basePoints.map((point, index) => (
|
||||||
|
<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: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#00aaff',
|
||||||
|
border: '2px solid white',
|
||||||
|
cursor: 'grab',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown(index)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -24,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#00aaff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
P{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
146
src/editor/components/ParameterPanel.tsx
Normal file
146
src/editor/components/ParameterPanel.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DistortionArea, EasingFunction } from '../../types/area';
|
||||||
|
|
||||||
|
interface ParameterPanelProps {
|
||||||
|
area: DistortionArea | null;
|
||||||
|
onUpdateArea: (updates: Partial<DistortionArea>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EASING_OPTIONS: { value: EasingFunction; label: string }[] = [
|
||||||
|
{ value: 'linear', label: '선형 (Linear)' },
|
||||||
|
{ value: 'easeIn', label: '가속 (Ease In)' },
|
||||||
|
{ value: 'easeOut', label: '감속 (Ease Out)' },
|
||||||
|
{ value: 'easeInOut', label: '가감속 (Ease In Out)' },
|
||||||
|
{ value: 'easeInQuad', label: '가속² (Ease In Quad)' },
|
||||||
|
{ value: 'easeOutQuad', label: '감속² (Ease Out Quad)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ParameterPanel: React.FC<ParameterPanelProps> = ({ area, onUpdateArea }) => {
|
||||||
|
if (!area) {
|
||||||
|
return (
|
||||||
|
<div className="parameter-panel">
|
||||||
|
<div className="parameter-panel-empty">영역을 선택해주세요</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="parameter-panel">
|
||||||
|
<h3>파라미터 편집</h3>
|
||||||
|
|
||||||
|
{/* 왜곡 강도 */}
|
||||||
|
<div className="parameter-group">
|
||||||
|
<label>
|
||||||
|
왜곡 강도: {(area.distortionStrength * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={area.distortionStrength}
|
||||||
|
onChange={(e) => onUpdateArea({ distortionStrength: parseFloat(e.target.value) })}
|
||||||
|
className="slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 애니메이션 지속 시간 */}
|
||||||
|
<div className="parameter-group">
|
||||||
|
<label>
|
||||||
|
지속 시간: {area.movement.duration.toFixed(1)}초
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={area.movement.duration}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateArea({
|
||||||
|
movement: { ...area.movement, duration: parseFloat(e.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="input-number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이징 함수 */}
|
||||||
|
<div className="parameter-group">
|
||||||
|
<label>이징 함수</label>
|
||||||
|
<select
|
||||||
|
value={area.movement.easing}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateArea({
|
||||||
|
movement: { ...area.movement, easing: e.target.value as EasingFunction },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="select"
|
||||||
|
>
|
||||||
|
{EASING_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 벡터 A (X) */}
|
||||||
|
<div className="parameter-group">
|
||||||
|
<label>
|
||||||
|
벡터 X: {area.movement.vectorA.x.toFixed(2)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-1"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={area.movement.vectorA.x}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateArea({
|
||||||
|
movement: {
|
||||||
|
...area.movement,
|
||||||
|
vectorA: { ...area.movement.vectorA, x: parseFloat(e.target.value) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 벡터 A (Y) */}
|
||||||
|
<div className="parameter-group">
|
||||||
|
<label>
|
||||||
|
벡터 Y: {area.movement.vectorA.y.toFixed(2)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-1"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={area.movement.vectorA.y}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateArea({
|
||||||
|
movement: {
|
||||||
|
...area.movement,
|
||||||
|
vectorA: { ...area.movement.vectorA, y: parseFloat(e.target.value) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포인트 좌표 (읽기 전용 표시) */}
|
||||||
|
<div className="parameter-group">
|
||||||
|
<label>포인트 좌표 (캔버스에서 드래그)</label>
|
||||||
|
<div className="points-display">
|
||||||
|
{area.basePoints.map((point, idx) => (
|
||||||
|
<div key={idx} className="point-coord">
|
||||||
|
P{idx + 1}: ({point.x.toFixed(3)}, {point.y.toFixed(3)})
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
345
src/editor/editor.css
Normal file
345
src/editor/editor.css
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
/* Distortion Editor 메인 레이아웃 */
|
||||||
|
.distortion-editor {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Area List */
|
||||||
|
.area-list {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #00aaff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover:not(:disabled) {
|
||||||
|
background: #0088cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:disabled {
|
||||||
|
background: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #383838;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-item:hover {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-item.selected {
|
||||||
|
background: #2d5a7a;
|
||||||
|
border-color: #00aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-item-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-item-strength {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #ff4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parameter Panel */
|
||||||
|
.parameter-panel {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-panel h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-panel-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parameter-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #444;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00aaff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #0088cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00aaff;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb:hover {
|
||||||
|
background: #0088cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-number {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
background: #383838;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-number:focus {
|
||||||
|
border-color: #00aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
background: #383838;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
border-color: #00aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-display {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-coord {
|
||||||
|
padding: 8px;
|
||||||
|
background: #383838;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Point Handle */
|
||||||
|
.point-handle {
|
||||||
|
z-index: 10;
|
||||||
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-handle:hover {
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 170, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-handle.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: translate(-50%, -50%) scale(1.3);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 170, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 스크롤바 스타일 */
|
||||||
|
.area-list-items::-webkit-scrollbar,
|
||||||
|
.parameter-panel::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-items::-webkit-scrollbar-track,
|
||||||
|
.parameter-panel::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-items::-webkit-scrollbar-thumb,
|
||||||
|
.parameter-panel::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list-items::-webkit-scrollbar-thumb:hover,
|
||||||
|
.parameter-panel::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.editor-main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-list,
|
||||||
|
.parameter-panel {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editor-sidebar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-display {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/editor/hooks/useDistortionEditor.ts
Normal file
95
src/editor/hooks/useDistortionEditor.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { DistortionArea, Point } from '../../types/area';
|
||||||
|
import { EditorState } from '../types';
|
||||||
|
|
||||||
|
export const useDistortionEditor = (initialAreas: DistortionArea[] = []) => {
|
||||||
|
const [state, setState] = useState<EditorState>({
|
||||||
|
selectedAreaId: initialAreas[0]?.id || null,
|
||||||
|
areas: initialAreas,
|
||||||
|
editMode: 'normal',
|
||||||
|
draggingPointIndex: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 영역 선택 */
|
||||||
|
const selectArea = useCallback((areaId: string | null) => {
|
||||||
|
setState((prev) => ({ ...prev, selectedAreaId: areaId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 영역 추가 */
|
||||||
|
const addArea = useCallback((area: DistortionArea) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
areas: [...prev.areas, area],
|
||||||
|
selectedAreaId: area.id,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 영역 삭제 */
|
||||||
|
const removeArea = useCallback((areaId: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const newAreas = prev.areas.filter((a) => a.id !== areaId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
areas: newAreas,
|
||||||
|
selectedAreaId:
|
||||||
|
prev.selectedAreaId === areaId ? newAreas[0]?.id || null : prev.selectedAreaId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 영역 업데이트 */
|
||||||
|
const updateArea = useCallback((areaId: string, updates: Partial<DistortionArea>) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
areas: prev.areas.map((area) => (area.id === areaId ? { ...area, ...updates } : area)),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 포인트 업데이트 */
|
||||||
|
const updatePoint = useCallback((areaId: string, pointIndex: number, point: Point) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
areas: prev.areas.map((area) => {
|
||||||
|
if (area.id === areaId) {
|
||||||
|
const newPoints = [...area.basePoints] as [Point, Point, Point, Point];
|
||||||
|
newPoints[pointIndex] = point;
|
||||||
|
return { ...area, basePoints: newPoints };
|
||||||
|
}
|
||||||
|
return area;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 드래그 시작 */
|
||||||
|
const startDragging = useCallback((pointIndex: number) => {
|
||||||
|
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 드래그 종료 */
|
||||||
|
const stopDragging = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, draggingPointIndex: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 편집 모드 변경 */
|
||||||
|
const setEditMode = useCallback((mode: EditorState['editMode']) => {
|
||||||
|
setState((prev) => ({ ...prev, editMode: mode }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 선택된 영역 가져오기 */
|
||||||
|
const getSelectedArea = useCallback(() => {
|
||||||
|
return state.areas.find((a) => a.id === state.selectedAreaId) || null;
|
||||||
|
}, [state.areas, state.selectedAreaId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
selectArea,
|
||||||
|
addArea,
|
||||||
|
removeArea,
|
||||||
|
updateArea,
|
||||||
|
updatePoint,
|
||||||
|
startDragging,
|
||||||
|
stopDragging,
|
||||||
|
setEditMode,
|
||||||
|
getSelectedArea,
|
||||||
|
};
|
||||||
|
};
|
||||||
4
src/editor/index.ts
Normal file
4
src/editor/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { DistortionEditor } from './DistortionEditor';
|
||||||
|
export type { DistortionEditorProps, EditorState, EditMode } from './types';
|
||||||
|
export { useDistortionEditor } from './hooks/useDistortionEditor';
|
||||||
|
import './editor.css';
|
||||||
51
src/editor/types.ts
Normal file
51
src/editor/types.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { DistortionArea } from '../types/area';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에디터 편집 모드
|
||||||
|
*/
|
||||||
|
export type EditMode = 'normal' | 'point-edit' | 'parameter-edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에디터 상태
|
||||||
|
*/
|
||||||
|
export interface EditorState {
|
||||||
|
/** 현재 선택된 영역 ID */
|
||||||
|
selectedAreaId: string | null;
|
||||||
|
/** 모든 왜곡 영역 */
|
||||||
|
areas: DistortionArea[];
|
||||||
|
/** 현재 편집 모드 */
|
||||||
|
editMode: EditMode;
|
||||||
|
/** 드래그 중인 포인트 인덱스 (0-3) */
|
||||||
|
draggingPointIndex: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포인트 핸들 정보
|
||||||
|
*/
|
||||||
|
export interface PointHandle {
|
||||||
|
/** 포인트 인덱스 (0-3) */
|
||||||
|
index: number;
|
||||||
|
/** 화면 좌표 (픽셀) */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** 포인트 레이블 */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에디터 Props
|
||||||
|
*/
|
||||||
|
export interface DistortionEditorProps {
|
||||||
|
/** 초기 영역 배열 */
|
||||||
|
initialAreas?: DistortionArea[];
|
||||||
|
/** 이미지 소스 */
|
||||||
|
imageSrc: string;
|
||||||
|
/** 영역 변경 콜백 */
|
||||||
|
onAreasChange?: (areas: DistortionArea[]) => void;
|
||||||
|
/** 선택된 영역 변경 콜백 */
|
||||||
|
onSelectedAreaChange?: (areaId: string | null) => void;
|
||||||
|
/** 캔버스 너비 */
|
||||||
|
width?: number;
|
||||||
|
/** 캔버스 높이 */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user