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