diff --git a/src/editor/DistortionEditor.tsx b/src/editor/DistortionEditor.tsx new file mode 100644 index 0000000..281ea73 --- /dev/null +++ b/src/editor/DistortionEditor.tsx @@ -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 = ({ + 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) => { + if (state.selectedAreaId) { + updateArea(state.selectedAreaId, updates); + } + }; + + const selectedArea = getSelectedArea(); + + return ( +
+
+ {/* 왼쪽: 캔버스 */} +
+ +
+ + {/* 오른쪽: 사이드바 */} +
+ {/* 영역 목록 */} + + + {/* 파라미터 패널 */} + +
+
+
+ ); +}; diff --git a/src/editor/components/AreaList.tsx b/src/editor/components/AreaList.tsx new file mode 100644 index 0000000..c0073c8 --- /dev/null +++ b/src/editor/components/AreaList.tsx @@ -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 = ({ + areas, + selectedAreaId, + onSelectArea, + onRemoveArea, + onAddArea, +}) => { + return ( +
+
+

왜곡 영역

+ +
+
+ {areas.length === 0 ? ( +
영역이 없습니다. + 추가 버튼을 눌러주세요.
+ ) : ( + areas.map((area, index) => ( +
onSelectArea(area.id)} + > +
+ 영역 {index + 1} + 강도: {(area.distortionStrength * 100).toFixed(0)}% +
+ +
+ )) + )} +
+
+ ); +}; diff --git a/src/editor/components/EditorCanvas.tsx b/src/editor/components/EditorCanvas.tsx new file mode 100644 index 0000000..4428dd8 --- /dev/null +++ b/src/editor/components/EditorCanvas.tsx @@ -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) => void; + draggingPointIndex: number | null; + onStartDragging: (pointIndex: number) => void; + onStopDragging: () => void; +} + +export const EditorCanvas: React.FC = ({ + areas, + selectedAreaId, + imageSrc, + width, + height, + onUpdatePoint, + onUpdateArea, + draggingPointIndex, + onStartDragging, + onStopDragging, + }) => { + const containerRef = useRef(null); + const [canvasSize, setCanvasSize] = useState({width: 0, height: 0}); + const [isDraggingArea, setIsDraggingArea] = useState(false); + const [dragStartPos, setDragStartPos] = useState(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 ( +
+ {/* ImageDistortion 컴포넌트 */} + + + {/* 오버레이 SVG */} + + {/* 모든 영역의 사각형 표시 */} + {areas.map((area) => { + const isSelected = area.id === selectedAreaId; + const points = area.basePoints; + return ( + + {/* 사각형 경계선 */} + `${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} + /> + + ); + })} + + + {/* 선택된 영역의 타원 가이드 (Canvas로 그리기) */} + {selectedArea && canvasSize.width > 0 && ( + { + 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) => ( +
+
+ P{index + 1} +
+
+ ))} +
+ ); +}; diff --git a/src/editor/components/ParameterPanel.tsx b/src/editor/components/ParameterPanel.tsx new file mode 100644 index 0000000..a7fbb09 --- /dev/null +++ b/src/editor/components/ParameterPanel.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { DistortionArea, EasingFunction } from '../../types/area'; + +interface ParameterPanelProps { + area: DistortionArea | null; + onUpdateArea: (updates: Partial) => 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 = ({ area, onUpdateArea }) => { + if (!area) { + return ( +
+
영역을 선택해주세요
+
+ ); + } + + return ( +
+

파라미터 편집

+ + {/* 왜곡 강도 */} +
+ + onUpdateArea({ distortionStrength: parseFloat(e.target.value) })} + className="slider" + /> +
+ + {/* 애니메이션 지속 시간 */} +
+ + + onUpdateArea({ + movement: { ...area.movement, duration: parseFloat(e.target.value) }, + }) + } + className="input-number" + /> +
+ + {/* 이징 함수 */} +
+ + +
+ + {/* 벡터 A (X) */} +
+ + + onUpdateArea({ + movement: { + ...area.movement, + vectorA: { ...area.movement.vectorA, x: parseFloat(e.target.value) }, + }, + }) + } + className="slider" + /> +
+ + {/* 벡터 A (Y) */} +
+ + + onUpdateArea({ + movement: { + ...area.movement, + vectorA: { ...area.movement.vectorA, y: parseFloat(e.target.value) }, + }, + }) + } + className="slider" + /> +
+ + {/* 포인트 좌표 (읽기 전용 표시) */} +
+ +
+ {area.basePoints.map((point, idx) => ( +
+ P{idx + 1}: ({point.x.toFixed(3)}, {point.y.toFixed(3)}) +
+ ))} +
+
+
+ ); +}; diff --git a/src/editor/editor.css b/src/editor/editor.css new file mode 100644 index 0000000..a9698fd --- /dev/null +++ b/src/editor/editor.css @@ -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; + } +} diff --git a/src/editor/hooks/useDistortionEditor.ts b/src/editor/hooks/useDistortionEditor.ts new file mode 100644 index 0000000..d6c2d6a --- /dev/null +++ b/src/editor/hooks/useDistortionEditor.ts @@ -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({ + 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) => { + 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, + }; +}; diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 0000000..47b08c8 --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1,4 @@ +export { DistortionEditor } from './DistortionEditor'; +export type { DistortionEditorProps, EditorState, EditMode } from './types'; +export { useDistortionEditor } from './hooks/useDistortionEditor'; +import './editor.css'; diff --git a/src/editor/types.ts b/src/editor/types.ts new file mode 100644 index 0000000..d5c02f8 --- /dev/null +++ b/src/editor/types.ts @@ -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; +}