diff --git a/app/components/BackButton.tsx b/app/components/BackButton.tsx new file mode 100644 index 0000000..b79a8d0 --- /dev/null +++ b/app/components/BackButton.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +const BackButton = () => { + const router = useRouter(); + + const handleBack = () => { + router.back(); + }; + + return ( + + ); +}; + +export default BackButton; diff --git a/app/components/Stage.tsx b/app/components/Stage.tsx index 2e8d657..20ad457 100644 --- a/app/components/Stage.tsx +++ b/app/components/Stage.tsx @@ -9,12 +9,33 @@ import FigureSettings from '@/app/components/FigureSettings'; import { Pivot } from '@/app/types/figure'; export default function Stage() { - const { project, currentFrameIndex, isPlaying, setCurrentFrameIndex } = useStore(); + const { + project, + currentFrameIndex, + isPlaying, + setCurrentFrameIndex, + editorMode, + builderFigure, + builderTool, + selectedPivotIds, + addBuilderPivot, + togglePivotSelection, + setBuilderPivotType, + setBuilderRootPivot, + moveBuilderPivot, + connectingPivots, + addConnectingPivot, + clearConnectingPivots, + createLineFromConnecting + } = useStore(); const [interpolatedFrame, setInterpolatedFrame] = useState(project.frames[currentFrameIndex]); const svgRef = useRef(null); const wasmRef = useRef(null); const { handleMouseDown, handleMouseMove, handleMouseUp, draggingPivotId, pickerState, setPickerState } = useInteraction(svgRef); + // Builder mode drag state (separate from animation drag) + const [builderDraggingPivotId, setBuilderDraggingPivotId] = useState(null); + const [builderDragOffset, setBuilderDragOffset] = useState({ x: 0, y: 0 }); const { renderFigure } = useFigureRender(); const { isOverDeleteZone, handleMouseMove: handleDeleteMouseMove, handleMouseUp: handleDeleteMouseUp } = useDragToDelete(); @@ -114,13 +135,146 @@ export default function Stage() { const frameToShow = isPlaying ? interpolatedFrame : project.frames[currentFrameIndex]; + // Builder mode click handler + const handleBuilderClick = (e: React.MouseEvent) => { + if (editorMode !== 'figure' || !svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const svgX = ((e.clientX - rect.left) / rect.width) * 1280; + const svgY = ((e.clientY - rect.top) / rect.height) * 720; + + if (builderTool === 'add-pivot') { + addBuilderPivot(svgX, svgY); + } + }; + + // Render builder pivots + const renderBuilderPivots = () => { + if (!builderFigure) return null; + + const allPivots: Array<{ pivot: Pivot; parent: Pivot | null }> = []; + const collectPivots = (pivot: Pivot, parent: Pivot | null = null) => { + allPivots.push({ pivot, parent }); + pivot.children?.forEach((child) => collectPivots(child, pivot)); + }; + // root_pivot is a container; collect its children as roots + builderFigure.root_pivot.children.forEach((child) => collectPivots(child, builderFigure.root_pivot)); + + return ( + + {/* Render shapes first */} + {builderFigure.shapes.map((shape, idx) => { + if (shape.type === 'line' && shape.pivotIds.length >= 2) { + const findPivot = (id: string): Pivot | undefined => { + let found: Pivot | undefined; + const search = (p: Pivot) => { + if (p.id === id) { found = p; return; } + p.children?.forEach(search); + }; + search(builderFigure.root_pivot); + return found; + }; + + const p1 = findPivot(shape.pivotIds[0]); + const p2 = findPivot(shape.pivotIds[1]); + + if (p1 && p2) { + return ( + + ); + } + } + return null; + })} + + {/* Render pivots last for proper click handling */} + {allPivots.map(({ pivot, parent }) => { + const isRoot = parent?.id === builderFigure.root_pivot.id; + const isSelected = selectedPivotIds.includes(pivot.id); + + let fillColor = '#666'; + if (isRoot) fillColor = '#3b82f6'; // Blue for root + else if (pivot.type === 'joint') fillColor = '#f97316'; // Orange for joint + else if (pivot.type === 'fixed') fillColor = '#6b7280'; // Gray for fixed + + if (isSelected) fillColor = '#8b5cf6'; // Purple for selected + + return ( + { + e.stopPropagation(); + if (builderTool === 'select') { + if (svgRef.current) { + const CTM = svgRef.current.getScreenCTM(); + if (CTM) { + const mouseX = (e.clientX - CTM.e) / CTM.a; + const mouseY = (e.clientY - CTM.f) / CTM.d; + setBuilderDragOffset({ x: mouseX - pivot.x, y: mouseY - pivot.y }); + setBuilderDraggingPivotId(pivot.id); + } + } + } + }} + onClick={(e) => { + e.stopPropagation(); + if (builderTool === 'select') { + togglePivotSelection(pivot.id); + } else if (builderTool === 'connect') { + // Add to connecting sequence + if (!connectingPivots.includes(pivot.id)) { + addConnectingPivot(pivot.id); + // Auto-create line if 2 pivots selected + if (connectingPivots.length === 1) { + createLineFromConnecting(); + } + } + } else if (builderTool === 'set-root') { + setBuilderRootPivot(pivot.id); + } else if (builderTool === 'set-joint') { + setBuilderPivotType(pivot.id, 'joint'); + } else if (builderTool === 'set-fixed') { + setBuilderPivotType(pivot.id, 'fixed'); + } + }} + /> + ); + })} + + ); + }; + return ( <> { - if (svgRef.current && draggingPivotId) { + if (editorMode === 'figure' && builderDraggingPivotId && svgRef.current) { + const CTM = svgRef.current.getScreenCTM(); + if (CTM) { + const mouseX = (e.clientX - CTM.e) / CTM.a; + const mouseY = (e.clientY - CTM.f) / CTM.d; + moveBuilderPivot(builderDraggingPivotId, mouseX - builderDragOffset.x, mouseY - builderDragOffset.y); + } + } else if (svgRef.current && draggingPivotId) { const rect = svgRef.current.getBoundingClientRect(); const svgX = ((e.clientX - rect.left) / rect.width) * 1280; const svgY = ((e.clientY - rect.top) / rect.height) * 720; @@ -131,7 +285,9 @@ export default function Stage() { } }} onMouseUp={(e) => { - if (svgRef.current && draggingPivotId) { + if (editorMode === 'figure' && builderDraggingPivotId) { + setBuilderDraggingPivotId(null); + } else if (svgRef.current && draggingPivotId) { const rect = svgRef.current.getBoundingClientRect(); const svgX = ((e.clientX - rect.left) / rect.width) * 1280; const svgY = ((e.clientY - rect.top) / rect.height) * 720; @@ -153,34 +309,44 @@ export default function Stage() { } }} onMouseLeave={() => { + setBuilderDraggingPivotId(null); handleMouseUp(); handleDeleteMouseUp(0, 0, null); }} className="bg-surface shadow-sm max-h-[calc(100vh-2rem)] mx-auto" > - {/* Delete Zone - Trash Icon */} - {/* Delete Zone - Trash Icon (Material) */} - - - - - - - {/* Onion Skinning: Render previous frame if exists and not playing */} - {!isPlaying && currentFrameIndex > 0 && project.frames[currentFrameIndex - 1] && ( - - {project.frames[currentFrameIndex - 1].figures.map(figure => - renderFigure(figure, null, () => {}) // Non-interactive - )} + {editorMode === 'figure' ? ( + // Builder Mode Rendering + <> + {renderBuilderPivots()} + + ) : ( + // Animation Mode Rendering + <> + {/* Delete Zone - Trash Icon */} + + + + - )} - {/* Current Frame */} - {frameToShow?.figures.map((figure) => ( - - {renderFigure(figure, draggingPivotId, handleMouseDown)} - - ))} + {/* Onion Skinning: Render previous frame if exists and not playing */} + {!isPlaying && currentFrameIndex > 0 && project.frames[currentFrameIndex - 1] && ( + + {project.frames[currentFrameIndex - 1].figures.map(figure => + renderFigure(figure, null, () => {}) // Non-interactive + )} + + )} + + {/* Current Frame */} + {frameToShow?.figures.map((figure) => ( + + {renderFigure(figure, draggingPivotId, handleMouseDown)} + + ))} + + )} {pickerState.isOpen && pickerState.figureId && ( { @@ -5,19 +7,34 @@ const UpdateNotification = () => { const [updateDownloaded, setUpdateDownloaded] = useState(false); useEffect(() => { - if (typeof window !== 'undefined' && window.electron) { - window.electron.ipcRenderer.on('update-available', () => { - setUpdateAvailable(true); - }); - window.electron.ipcRenderer.on('update-downloaded', () => { - setUpdateDownloaded(true); - }); + if (typeof window !== 'undefined') { + // Check if we're in Electron environment + const isElectron = !!(window as any).electronAPI; + + if (isElectron) { + // Listen for update-available event from main process + if ((window as any).electron?.ipcRenderer) { + (window as any).electron.ipcRenderer.on('update-available', () => { + console.log('Update available'); + setUpdateAvailable(true); + }); + (window as any).electron.ipcRenderer.on('update-downloaded', () => { + console.log('Update downloaded'); + setUpdateDownloaded(true); + setUpdateAvailable(false); + }); + + // Check for updates on startup + (window as any).electron.ipcRenderer.invoke('check-for-updates') + .catch((err: any) => console.log('Update check skipped (dev mode)', err)); + } + } } }, []); const handleUpdate = () => { - if (window.electron) { - window.electron.ipcRenderer.invoke('quit-and-install'); + if ((window as any).electron?.ipcRenderer) { + (window as any).electron.ipcRenderer.invoke('quit-and-install'); } }; @@ -54,3 +71,4 @@ const UpdateNotification = () => { }; export default UpdateNotification; + diff --git a/app/components/containers/BodyContainer.tsx b/app/components/containers/BodyContainer.tsx index f918a04..1dc510a 100644 --- a/app/components/containers/BodyContainer.tsx +++ b/app/components/containers/BodyContainer.tsx @@ -1,3 +1,12 @@ +import UpdateNotification from '@/app/components/UpdateNotification'; + interface MainContainerProps { children?: React.ReactNode } -const BodyContainer: React.FC = ({children}) => {children} +const BodyContainer: React.FC = ({children}) => ( + <> + + {children} + + + +) export default BodyContainer; \ No newline at end of file diff --git a/app/editor/components/layouts/BuilderSidebar.tsx b/app/editor/components/layouts/BuilderSidebar.tsx new file mode 100644 index 0000000..222460c --- /dev/null +++ b/app/editor/components/layouts/BuilderSidebar.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useStore } from '@/app/store/useStore'; +import { Pivot } from '@/app/types'; + +const Trash2 = ({ size = 16 }: { size?: number }) => ( + + + +); + +const AlertTriangle = ({ size = 18 }: { size?: number }) => ( + + + +); + +const CheckCircle = ({ size = 18 }: { size?: number }) => ( + + + +); + +export default function BuilderSidebar() { + const { + builderFigure, + selectedPivotIds, + validationErrors, + togglePivotSelection, + removeBuilderPivot, + removeBuilderShape, + setBuilderPivotType, + addBuilderShape, + builderShapeType + } = useStore(); + + if (!builderFigure) return null; + + // Collect all pivots from hierarchy + const allPivots: Pivot[] = []; + const collectPivots = (pivot: Pivot) => { + allPivots.push(pivot); + pivot.children.forEach(collectPivots); + }; + collectPivots(builderFigure.root_pivot); + + return ( +
+ {/* Header */} +
+

Figure Builder

+

ID: {builderFigure.id}

+
+ +
+ {/* Validation Errors */} + {validationErrors.length > 0 && ( +
+
+ + Validation Errors +
+
    + {validationErrors.map((error, idx) => ( +
  • + + {error.message} +
  • + ))} +
+
+ )} + + {validationErrors.length === 0 && allPivots.length > 0 && ( +
+
+ + 유효한 피규어 +
+
+ )} + + {/* Pivots List */} +
+

+ 피봇 ({allPivots.length}) + {selectedPivotIds.length > 0 && ( + + {selectedPivotIds.length}개 선택 + + )} +

+
+ {allPivots.map((pivot) => { + const isRoot = pivot.id === builderFigure.root_pivot.id; + const isSelected = selectedPivotIds.includes(pivot.id); + + return ( +
+
+ +
+ {!isRoot && ( + <> + + + + )} +
+
+
+ ); + })} +
+
+ + {/* Shapes List */} +
+

+ 선 ({builderFigure.shapes.length}) +

+
+ {builderFigure.shapes.length === 0 && ( +

선이 없습니다

+ )} + {builderFigure.shapes.map((shape, idx) => ( +
+
+
+ {shape.type === 'line' ? '선' : shape.type} +
+
+ {shape.pivotIds.length}개 피봇 +
+
+ +
+ ))} +
+
+ + {/* Figure Properties */} +
+

속성

+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/app/editor/components/layouts/BuilderToolbar.tsx b/app/editor/components/layouts/BuilderToolbar.tsx new file mode 100644 index 0000000..899d51c --- /dev/null +++ b/app/editor/components/layouts/BuilderToolbar.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useStore } from '@/app/store/useStore'; +import { useRouter } from 'next/navigation'; + +// Simple icon components +const Crosshair = ({ size = 20 }: { size?: number }) => ( + + + + +); + +const Link = ({ size = 20 }: { size?: number }) => ( + + + + +); + +const Target = ({ size = 20 }: { size?: number }) => ( + + + + + +); + +const Move = ({ size = 20 }: { size?: number }) => ( + + + +); + +const Anchor = ({ size = 20 }: { size?: number }) => ( + + + + +); + +const Activity = ({ size = 20 }: { size?: number }) => ( + + + +); + +const CheckCircle = ({ size = 20 }: { size?: number }) => ( + + + +); + +const Save = ({ size = 20 }: { size?: number }) => ( + + + + +); + +const Download = ({ size = 20 }: { size?: number }) => ( + + + +); + +const Home = ({ size = 20 }: { size?: number }) => ( + + + + +); + +export default function BuilderToolbar() { + const { + builderTool, + setBuilderTool, + validateBuilderFigure, + validationErrors, + saveBuilderFigure, + exportBuilderFigure, + builderFigure, + connectingPivots, + clearConnectingPivots, + createLineFromConnecting + } = useStore(); + + const router = useRouter(); + + const tools = [ + { id: 'select' as const, icon: Move, label: '이동' }, + { id: 'add-pivot' as const, icon: Crosshair, label: '피봇 추가' }, + { id: 'connect' as const, icon: Link, label: '피봇 연결' }, + { id: 'set-root' as const, icon: Target, label: '루트 설정' }, + { id: 'set-joint' as const, icon: Activity, label: '관절 설정' }, + { id: 'set-fixed' as const, icon: Anchor, label: '고정 설정' }, + ]; + + const handleValidate = () => { + validateBuilderFigure(); + }; + + const handleSave = () => { + const name = prompt('피봇 이름 입력:'); + if (name) { + saveBuilderFigure(name); + alert('피봇이 라이브러리에 저장되었습니다!'); + } + }; + + const handleExport = () => { + const name = prompt('파일 이름 입력:'); + if (name) { + exportBuilderFigure(name); + } + }; + + const handleBackToHome = () => { + if (confirm('피봇 만들기를 나가실까요? 저장되지 않은 변경사항은 삭제됩니다.')) { + router.push('/'); + } + }; + + return ( +
+ {/* Tools */} +
+ {tools.map((tool) => { + const Icon = tool.icon; + return ( + + ); + })} +
+ + {/* Connect Mode Controls */} + {builderTool === 'connect' && connectingPivots.length >= 2 && ( +
+ + +
+ )} + + {/* Validation */} +
+ +
+ + {/* Actions */} +
+ + +
+ + {/* Navigation */} + +
+ ); +} diff --git a/app/editor/components/layouts/Header.tsx b/app/editor/components/layouts/Header.tsx index a0ce6d5..9349b5c 100644 --- a/app/editor/components/layouts/Header.tsx +++ b/app/editor/components/layouts/Header.tsx @@ -1,16 +1,27 @@ 'use client'; import React from 'react'; +import { useRouter } from 'next/navigation'; import { useStore } from '@/app/store/useStore'; import { useModal } from '@/app/editor/store/useModal'; const EditorHeader: React.FC = () => { + const router = useRouter(); const { addFrame, togglePlay, isPlaying } = useStore(); const { openModalType, closeModal } = useModal(); return (
-
- Pivot Animator +
+ + PivotStation