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:
BaekRyang 2025-11-05 11:20:20 +09:00
parent ef992b5525
commit 63e7bac3c7
8 changed files with 1176 additions and 0 deletions

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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;
}
}

View 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
View 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
View 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;
}