Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/components/BackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { useRouter } from 'next/navigation';

const BackButton = () => {
const router = useRouter();

const handleBack = () => {
router.back();
};

return (
<button
onClick={handleBack}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="뒤로가기"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>뒤로</span>
</button>
);
};

export default BackButton;
216 changes: 191 additions & 25 deletions app/components/Stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGSVGElement>(null);
const wasmRef = useRef<any>(null);

const { handleMouseDown, handleMouseMove, handleMouseUp, draggingPivotId, pickerState, setPickerState } = useInteraction(svgRef);
// Builder mode drag state (separate from animation drag)
const [builderDraggingPivotId, setBuilderDraggingPivotId] = useState<string | null>(null);
const [builderDragOffset, setBuilderDragOffset] = useState({ x: 0, y: 0 });
const { renderFigure } = useFigureRender();
const { isOverDeleteZone, handleMouseMove: handleDeleteMouseMove, handleMouseUp: handleDeleteMouseUp } = useDragToDelete();

Expand Down Expand Up @@ -114,13 +135,146 @@ export default function Stage() {

const frameToShow = isPlaying ? interpolatedFrame : project.frames[currentFrameIndex];

// Builder mode click handler
const handleBuilderClick = (e: React.MouseEvent<SVGSVGElement>) => {
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 (
<g>
{/* 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 (
<line
key={`shape-${idx}`}
x1={p1.x}
y1={p1.y}
x2={p2.x}
y2={p2.y}
stroke={shape.color || builderFigure.color || '#000'}
strokeWidth={builderFigure.thickness || 4}
strokeLinecap="round"
/>
);
}
}
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 (
<circle
key={pivot.id}
cx={pivot.x}
cy={pivot.y}
r={isRoot ? 6 : 4}
fill={fillColor}
stroke="white"
strokeWidth="1.5"
style={{ cursor: 'pointer' }}
onMouseDown={(e) => {
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');
}
}}
/>
);
})}
</g>
);
};

return (
<>
<svg
ref={svgRef}
viewBox="0 0 1280 720"
viewBox="0 0 1280 720"
onClick={editorMode === 'figure' ? handleBuilderClick : undefined}
onMouseMove={(e) => {
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;
Expand All @@ -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;
Expand All @@ -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) */}
<g opacity={isOverDeleteZone ? 1 : 0.5} style={{ transition: 'opacity 0.2s', filter: isOverDeleteZone ? 'drop-shadow(0 0 8px #ef4444)' : 'none', cursor: 'pointer' }}>
<svg x="1220" y="660" width="36" height="36" viewBox="0 0 24 24" fill="#ef4444">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</g>

{/* Onion Skinning: Render previous frame if exists and not playing */}
{!isPlaying && currentFrameIndex > 0 && project.frames[currentFrameIndex - 1] && (
<g opacity="0.3" style={{ filter: 'grayscale(100%)' }}>
{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 */}
<g opacity={isOverDeleteZone ? 1 : 0.5} style={{ transition: 'opacity 0.2s', filter: isOverDeleteZone ? 'drop-shadow(0 0 8px #ef4444)' : 'none', cursor: 'pointer' }}>
<svg x="1220" y="660" width="36" height="36" viewBox="0 0 24 24" fill="#ef4444">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</g>
)}

{/* Current Frame */}
{frameToShow?.figures.map((figure) => (
<g key={figure.id}>
{renderFigure(figure, draggingPivotId, handleMouseDown)}
</g>
))}
{/* Onion Skinning: Render previous frame if exists and not playing */}
{!isPlaying && currentFrameIndex > 0 && project.frames[currentFrameIndex - 1] && (
<g opacity="0.3" style={{ filter: 'grayscale(100%)' }}>
{project.frames[currentFrameIndex - 1].figures.map(figure =>
renderFigure(figure, null, () => {}) // Non-interactive
)}
</g>
)}

{/* Current Frame */}
{frameToShow?.figures.map((figure) => (
<g key={figure.id}>
{renderFigure(figure, draggingPivotId, handleMouseDown)}
</g>
))}
</>
)}
</svg>
{pickerState.isOpen && pickerState.figureId && (
<FigureSettings
Expand Down
36 changes: 27 additions & 9 deletions app/components/UpdateNotification.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
'use client';

import { useEffect, useState } from 'react';

const UpdateNotification = () => {
const [updateAvailable, setUpdateAvailable] = useState(false);
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');
}
};

Expand Down Expand Up @@ -54,3 +71,4 @@ const UpdateNotification = () => {
};

export default UpdateNotification;

11 changes: 10 additions & 1 deletion app/components/containers/BodyContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import UpdateNotification from '@/app/components/UpdateNotification';

interface MainContainerProps { children?: React.ReactNode }
const BodyContainer: React.FC<MainContainerProps> = ({children}) => <body className="flex flex-col min-h-screen max-h-screen min-w-screen min-w-screen bg-background p-4 gap-4">{children}</body>
const BodyContainer: React.FC<MainContainerProps> = ({children}) => (
<>
<body className="flex flex-col min-h-screen max-h-screen min-w-screen min-w-screen bg-background p-4 gap-4">
{children}
</body>
<UpdateNotification />
</>
)
export default BodyContainer;
Loading