Spaces:
Sleeping
redesign: complete modern UI overhaul + new features
Browse filesDesign System:
- Full CSS variables design system (bg0-bg4, accent, text0-3, radius, shadows)
- Inter (UI) + JetBrains Mono (numbers/code) + Syne (brand) font stack
- Consistent border, hover, focus, transition styles everywhere
- All global range/input/scrollbar styling in index.css
Layout:
- Blender-style icon rail (48px) on right edge with slide-out panel (260px)
- Panel animates open/close with smooth width transition
- Panel header with title + close button
- Mobile: slide-up drawer (55% height) with tab bar at bottom
- Canvas always fills 100% of remaining viewport
Toolbar:
- Compact, clean — brand / transform tools / transport / lighting / keyframe shortcut
- Active states with accent color background + border
- Styled play button with glow shadow
- Frame counter with monospace font in bg3 chip
ModelsPanel:
- Large drag-and-drop zone with pulse animation on hover
- Grid layout for demo model buttons
- Model list with color dots, anim count badges, hover states
- Inline visibility + remove actions
PropertiesPanel:
- Collapsible sections (Transform / Animations / Keyframes)
- XYZ inputs with colored axis labels, grouped border focus
- Reset Transform button
- Keyframe list with frame navigation
Timeline:
- Diamond-shaped keyframe markers
- Arrow playhead with top indicator
- Settings row for frames/fps presets
- Cleaner ruler with section markers
AnimationPlayer / SkyboxPanel / ExportPanel:
- All fully restyled to match design system
- SkyboxPanel: toggle switch, color grid, upload, URL input
- ExportPanel: stats table, gradient render button, progress bar
- src/App.jsx +209 -227
- src/components/AnimationPlayer.jsx +57 -127
- src/components/ExportPanel.jsx +125 -206
- src/components/ModelsPanel.jsx +169 -160
- src/components/PropertiesPanel.jsx +179 -179
- src/components/SkyboxPanel.jsx +141 -184
- src/components/Timeline.jsx +186 -259
- src/components/Toolbar.jsx +152 -92
- src/index.css +99 -0
- src/main.jsx +1 -0
|
@@ -1,273 +1,255 @@
|
|
| 1 |
import { useRef, useState, Suspense } from 'react'
|
| 2 |
-
import Scene
|
| 3 |
-
import Toolbar
|
| 4 |
-
import Timeline
|
| 5 |
-
import ModelsPanel
|
| 6 |
import PropertiesPanel from './components/PropertiesPanel'
|
| 7 |
-
import ExportPanel
|
| 8 |
import AnimationPlayer from './components/AnimationPlayer'
|
| 9 |
-
import CameraMode
|
| 10 |
-
import SkyboxPanel
|
| 11 |
-
import useStore
|
| 12 |
-
|
| 13 |
-
const
|
| 14 |
-
{ id:
|
| 15 |
-
{ id:
|
| 16 |
-
{ id:
|
| 17 |
-
{ id:
|
| 18 |
-
{ id:
|
| 19 |
-
{ id:
|
| 20 |
]
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
// ──────────────────────────────
|
| 27 |
-
|
| 28 |
-
// ─────────────────────────────────────────────────────────────────────────────
|
| 29 |
-
function SidePanel({ canvasRef, collapsed, onCollapse }) {
|
| 30 |
const { activePanel, setActivePanel } = useStore()
|
|
|
|
| 31 |
|
| 32 |
return (
|
| 33 |
-
<div style={{
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
{/* Tab icons */}
|
| 62 |
<div style={{
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
paddingTop: 2,
|
| 67 |
}}>
|
| 68 |
-
{
|
| 69 |
-
<button
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
title={tab.label}
|
| 73 |
style={{
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
cursor: 'pointer', fontSize: 9,
|
| 81 |
-
fontFamily: 'Space Mono, monospace',
|
| 82 |
-
display: 'flex', flexDirection: 'column',
|
| 83 |
-
alignItems: 'center', gap: 2,
|
| 84 |
-
transition: 'all 0.15s',
|
| 85 |
}}
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
</button>
|
| 90 |
))}
|
| 91 |
</div>
|
| 92 |
-
|
| 93 |
-
{/* Content */}
|
| 94 |
-
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
| 95 |
-
{activePanel === 'models' && <ModelsPanel />}
|
| 96 |
-
{activePanel === 'properties' && <PropertiesPanel />}
|
| 97 |
-
{activePanel === 'animations' && <AnimationPlayer />}
|
| 98 |
-
{activePanel === 'camera' && <CameraMode sceneRef={canvasRef} />}
|
| 99 |
-
{activePanel === 'skybox' && <SkyboxPanel />}
|
| 100 |
-
{activePanel === 'export' && <ExportPanel canvasRef={canvasRef} />}
|
| 101 |
-
</div>
|
| 102 |
-
</>}
|
| 103 |
</div>
|
| 104 |
)
|
| 105 |
}
|
| 106 |
|
| 107 |
-
// ───────────────────────────────────────────────────────────────
|
| 108 |
-
|
| 109 |
-
// ─────────────────────────────────────────────────────────────────────────────
|
| 110 |
-
function MobileBar() {
|
| 111 |
const { activePanel, setActivePanel } = useStore()
|
|
|
|
| 112 |
return (
|
| 113 |
-
<div style={{
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</div>
|
| 141 |
)
|
| 142 |
}
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
if (!activePanel) return null
|
| 147 |
return (
|
| 148 |
-
<div style={{
|
| 149 |
-
|
| 150 |
-
height:
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
zIndex: 180, overflow: 'auto',
|
| 154 |
-
}}>
|
| 155 |
-
{activePanel === 'models' && <ModelsPanel />}
|
| 156 |
-
{activePanel === 'properties' && <PropertiesPanel />}
|
| 157 |
-
{activePanel === 'animations' && <AnimationPlayer />}
|
| 158 |
-
{activePanel === 'camera' && <CameraMode sceneRef={canvasRef} />}
|
| 159 |
-
{activePanel === 'skybox' && <SkyboxPanel />}
|
| 160 |
-
{activePanel === 'export' && <ExportPanel canvasRef={canvasRef} />}
|
| 161 |
</div>
|
| 162 |
)
|
| 163 |
}
|
| 164 |
|
| 165 |
-
// ─────────────────────────────────────────────────────────────────────────
|
| 166 |
-
// Root App
|
| 167 |
-
// ─────────────────────────────────────────────────────────────────────────────
|
| 168 |
export default function App() {
|
| 169 |
const canvasRef = useRef()
|
| 170 |
const showTimeline = useStore(s => s.showTimeline)
|
| 171 |
-
const [
|
| 172 |
-
|
| 173 |
-
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
| 174 |
-
|
| 175 |
-
const tlH = showTimeline ? TIMELINE_H : 28
|
| 176 |
-
const mobBarH = isMobile ? 46 : 0
|
| 177 |
-
const panelW = isMobile ? 0 : (collapsed ? 36 : PANEL_W)
|
| 178 |
|
| 179 |
return (
|
| 180 |
<div style={{
|
| 181 |
-
width:
|
| 182 |
-
display:
|
| 183 |
-
background: '
|
| 184 |
-
fontFamily:
|
| 185 |
-
overflow: 'hidden',
|
| 186 |
}}>
|
| 187 |
-
|
| 188 |
-
<div style={{ flexShrink: 0, zIndex: 300 }}>
|
| 189 |
-
<Toolbar />
|
| 190 |
-
</div>
|
| 191 |
|
| 192 |
-
{/*
|
| 193 |
-
<div style={{
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
overflow: 'hidden',
|
| 197 |
-
// Reserve space for toolbar above and timeline+mobile bar below
|
| 198 |
-
marginBottom: tlH + mobBarH,
|
| 199 |
-
}}>
|
| 200 |
-
|
| 201 |
-
{/* 3D Canvas — takes ALL remaining width */}
|
| 202 |
-
<div
|
| 203 |
-
ref={canvasRef}
|
| 204 |
-
style={{
|
| 205 |
-
flex: 1,
|
| 206 |
-
position: 'relative',
|
| 207 |
-
overflow: 'hidden',
|
| 208 |
-
// Ensure canvas fills entire height of this row
|
| 209 |
-
minHeight: 0,
|
| 210 |
-
}}
|
| 211 |
-
>
|
| 212 |
-
<Suspense fallback={
|
| 213 |
-
<div style={{
|
| 214 |
-
position: 'absolute', inset: 0,
|
| 215 |
-
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 216 |
-
background: '#050508',
|
| 217 |
-
}}>
|
| 218 |
-
<div style={{ textAlign: 'center', color: '#00f5ff', fontFamily: 'Orbitron, monospace' }}>
|
| 219 |
-
<div style={{ fontSize: 36, marginBottom: 10,
|
| 220 |
-
animation: 'spin 1.6s linear infinite' }}>◈</div>
|
| 221 |
-
<div style={{ fontSize: 12, letterSpacing: '0.25em', opacity: 0.7 }}>
|
| 222 |
-
LOADING SCENE
|
| 223 |
-
</div>
|
| 224 |
-
</div>
|
| 225 |
-
</div>
|
| 226 |
-
}>
|
| 227 |
-
<Scene canvasRef={canvasRef} />
|
| 228 |
-
</Suspense>
|
| 229 |
-
|
| 230 |
-
{/* Mobile slide-up drawer sits inside canvas layer */}
|
| 231 |
-
{isMobile && <MobileDrawer canvasRef={canvasRef} />}
|
| 232 |
-
</div>
|
| 233 |
-
|
| 234 |
-
{/* Desktop side panel */}
|
| 235 |
-
{!isMobile && (
|
| 236 |
-
<SidePanel
|
| 237 |
-
canvasRef={canvasRef}
|
| 238 |
-
collapsed={collapsed}
|
| 239 |
-
onCollapse={() => setCollapsed(c => !c)}
|
| 240 |
-
/>
|
| 241 |
-
)}
|
| 242 |
</div>
|
| 243 |
|
| 244 |
-
{/*
|
| 245 |
-
<div style={{
|
| 246 |
-
position: 'fixed',
|
| 247 |
-
bottom: mobBarH,
|
| 248 |
-
left: 0,
|
| 249 |
-
right: panelW,
|
| 250 |
-
zIndex: 200,
|
| 251 |
-
}}>
|
| 252 |
<Timeline />
|
| 253 |
</div>
|
| 254 |
-
|
| 255 |
-
{/* ── Mobile bottom tab bar ── */}
|
| 256 |
-
{isMobile && (
|
| 257 |
-
<div style={{ flexShrink: 0, zIndex: 300 }}>
|
| 258 |
-
<MobileBar />
|
| 259 |
-
</div>
|
| 260 |
-
)}
|
| 261 |
-
|
| 262 |
-
<style>{`
|
| 263 |
-
@keyframes spin { to { transform: rotate(360deg); } }
|
| 264 |
-
* { -webkit-tap-highlight-color: transparent; }
|
| 265 |
-
input[type=range] { accent-color: #00f5ff; }
|
| 266 |
-
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 267 |
-
::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); }
|
| 268 |
-
::-webkit-scrollbar-thumb { background: rgba(0,245,255,0.2); border-radius: 2px; }
|
| 269 |
-
canvas { display: block !important; }
|
| 270 |
-
`}</style>
|
| 271 |
</div>
|
| 272 |
)
|
| 273 |
}
|
|
|
|
| 1 |
import { useRef, useState, Suspense } from 'react'
|
| 2 |
+
import Scene from './components/Scene'
|
| 3 |
+
import Toolbar from './components/Toolbar'
|
| 4 |
+
import Timeline from './components/Timeline'
|
| 5 |
+
import ModelsPanel from './components/ModelsPanel'
|
| 6 |
import PropertiesPanel from './components/PropertiesPanel'
|
| 7 |
+
import ExportPanel from './components/ExportPanel'
|
| 8 |
import AnimationPlayer from './components/AnimationPlayer'
|
| 9 |
+
import CameraMode from './components/CameraMode'
|
| 10 |
+
import SkyboxPanel from './components/SkyboxPanel'
|
| 11 |
+
import useStore from './store/useStore'
|
| 12 |
+
|
| 13 |
+
const TABS = [
|
| 14 |
+
{ id:'models', icon:'📦', label:'Models' },
|
| 15 |
+
{ id:'properties', icon:'⚙', label:'Properties' },
|
| 16 |
+
{ id:'animations', icon:'🎞', label:'Animations' },
|
| 17 |
+
{ id:'camera', icon:'🎥', label:'Camera' },
|
| 18 |
+
{ id:'skybox', icon:'🌐', label:'Skybox' },
|
| 19 |
+
{ id:'export', icon:'▶', label:'Export' },
|
| 20 |
]
|
| 21 |
|
| 22 |
+
// ── Shared panel tab strip ─────────────────────────────────────────────────────
|
| 23 |
+
function TabStrip({ orientation = 'horizontal', onSelect, active }) {
|
| 24 |
+
return (
|
| 25 |
+
<div style={{
|
| 26 |
+
display:'flex',
|
| 27 |
+
flexDirection: orientation === 'vertical' ? 'column' : 'row',
|
| 28 |
+
background:'var(--bg1)',
|
| 29 |
+
borderBottom: orientation === 'horizontal' ? '1px solid var(--border)' : 'none',
|
| 30 |
+
borderRight: orientation === 'vertical' ? '1px solid var(--border)' : 'none',
|
| 31 |
+
overflow: orientation === 'horizontal' ? 'auto hidden' : 'visible',
|
| 32 |
+
}}>
|
| 33 |
+
{TABS.map(t => (
|
| 34 |
+
<button key={t.id}
|
| 35 |
+
onClick={() => onSelect(active === t.id ? null : t.id)}
|
| 36 |
+
title={t.label}
|
| 37 |
+
style={{
|
| 38 |
+
flexShrink: 0,
|
| 39 |
+
padding: orientation === 'vertical' ? '12px 10px' : '8px 14px',
|
| 40 |
+
background: active===t.id ? 'var(--bg2)' : 'transparent',
|
| 41 |
+
border:'none',
|
| 42 |
+
borderLeft: orientation==='vertical' && active===t.id ? '2px solid var(--accent)' : orientation==='vertical' ? '2px solid transparent' : 'none',
|
| 43 |
+
borderBottom: orientation==='horizontal' && active===t.id ? '2px solid var(--accent)' : orientation==='horizontal' ? '2px solid transparent' : 'none',
|
| 44 |
+
color: active===t.id ? 'var(--text0)' : 'var(--text2)',
|
| 45 |
+
cursor:'pointer', fontSize: orientation==='vertical' ? 18 : 12,
|
| 46 |
+
display:'flex', flexDirection: orientation==='vertical' ? 'column' : 'row',
|
| 47 |
+
alignItems:'center', gap: orientation==='vertical' ? 4 : 6,
|
| 48 |
+
transition:'all 0.12s',
|
| 49 |
+
minWidth: orientation==='horizontal' ? 80 : 48,
|
| 50 |
+
}}
|
| 51 |
+
>
|
| 52 |
+
<span style={{ fontSize: orientation==='vertical' ? 16 : 14 }}>{t.icon}</span>
|
| 53 |
+
<span style={{ fontSize:10, fontWeight: active===t.id ? 600 : 400,
|
| 54 |
+
letterSpacing:'0.04em', whiteSpace:'nowrap',
|
| 55 |
+
display: orientation==='vertical' ? 'block' : 'none',
|
| 56 |
+
}}>{t.label}</span>
|
| 57 |
+
</button>
|
| 58 |
+
))}
|
| 59 |
+
</div>
|
| 60 |
+
)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function PanelContent({ id, canvasRef }) {
|
| 64 |
+
return (
|
| 65 |
+
<div style={{ flex:1, overflow:'auto', minHeight:0 }}>
|
| 66 |
+
{id==='models' && <ModelsPanel />}
|
| 67 |
+
{id==='properties' && <PropertiesPanel />}
|
| 68 |
+
{id==='animations' && <AnimationPlayer />}
|
| 69 |
+
{id==='camera' && <CameraMode sceneRef={canvasRef} />}
|
| 70 |
+
{id==='skybox' && <SkyboxPanel />}
|
| 71 |
+
{id==='export' && <ExportPanel canvasRef={canvasRef} />}
|
| 72 |
+
</div>
|
| 73 |
+
)
|
| 74 |
+
}
|
| 75 |
|
| 76 |
+
// ── Desktop layout: icon sidebar + slide-out panel ────────────────────────────
|
| 77 |
+
function DesktopLayout({ canvasRef }) {
|
|
|
|
|
|
|
| 78 |
const { activePanel, setActivePanel } = useStore()
|
| 79 |
+
const PANEL_W = 260
|
| 80 |
|
| 81 |
return (
|
| 82 |
+
<div style={{ display:'flex', flex:1, overflow:'hidden' }}>
|
| 83 |
+
{/* 3D Canvas */}
|
| 84 |
+
<div ref={canvasRef} style={{ flex:1, position:'relative', overflow:'hidden', minWidth:0 }}>
|
| 85 |
+
<Suspense fallback={<Loading />}>
|
| 86 |
+
<Scene canvasRef={canvasRef} />
|
| 87 |
+
</Suspense>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* Vertical icon rail + slide panel */}
|
| 91 |
+
<div style={{ display:'flex', flexShrink:0, zIndex:150 }}>
|
| 92 |
+
{/* Slide-out panel */}
|
| 93 |
+
<div style={{
|
| 94 |
+
width: activePanel ? PANEL_W : 0,
|
| 95 |
+
overflow:'hidden',
|
| 96 |
+
background:'var(--bg1)',
|
| 97 |
+
borderLeft:'1px solid var(--border)',
|
| 98 |
+
display:'flex', flexDirection:'column',
|
| 99 |
+
transition:'width 0.2s ease',
|
| 100 |
+
}}>
|
| 101 |
+
{activePanel && (
|
| 102 |
+
<div style={{ width:PANEL_W, display:'flex', flexDirection:'column', height:'100%', overflow:'hidden' }}>
|
| 103 |
+
{/* Panel header */}
|
| 104 |
+
<div style={{
|
| 105 |
+
padding:'10px 14px', borderBottom:'1px solid var(--border)',
|
| 106 |
+
display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 107 |
+
flexShrink:0,
|
| 108 |
+
}}>
|
| 109 |
+
<span style={{ fontSize:12, fontWeight:700, color:'var(--text0)', letterSpacing:'0.04em' }}>
|
| 110 |
+
{TABS.find(t=>t.id===activePanel)?.icon}
|
| 111 |
+
{TABS.find(t=>t.id===activePanel)?.label}
|
| 112 |
+
</span>
|
| 113 |
+
<button onClick={() => setActivePanel(null)}
|
| 114 |
+
style={{ background:'none', border:'none', color:'var(--text2)', cursor:'pointer',
|
| 115 |
+
fontSize:16, lineHeight:1, padding:2 }}>×</button>
|
| 116 |
+
</div>
|
| 117 |
+
<PanelContent id={activePanel} canvasRef={canvasRef} />
|
| 118 |
+
</div>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
|
| 122 |
+
{/* Icon rail */}
|
|
|
|
| 123 |
<div style={{
|
| 124 |
+
width:48, background:'var(--bg1)',
|
| 125 |
+
borderLeft:'1px solid var(--border)',
|
| 126 |
+
display:'flex', flexDirection:'column',
|
| 127 |
+
alignItems:'center', paddingTop:8, gap:2,
|
| 128 |
}}>
|
| 129 |
+
{TABS.map(t => (
|
| 130 |
+
<button key={t.id}
|
| 131 |
+
onClick={() => setActivePanel(activePanel===t.id ? null : t.id)}
|
| 132 |
+
title={t.label}
|
|
|
|
| 133 |
style={{
|
| 134 |
+
width:36, height:36, borderRadius:'var(--radius-sm)',
|
| 135 |
+
background: activePanel===t.id ? 'rgba(79,142,255,0.15)' : 'transparent',
|
| 136 |
+
border:`1px solid ${activePanel===t.id ? 'rgba(79,142,255,0.3)' : 'transparent'}`,
|
| 137 |
+
color: activePanel===t.id ? 'var(--accent)' : 'var(--text2)',
|
| 138 |
+
cursor:'pointer', fontSize:16, transition:'all 0.12s',
|
| 139 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}}
|
| 141 |
+
onMouseEnter={e => { if(activePanel!==t.id){ e.currentTarget.style.background='var(--bg3)'; e.currentTarget.style.color='var(--text0)' }}}
|
| 142 |
+
onMouseLeave={e => { if(activePanel!==t.id){ e.currentTarget.style.background='transparent'; e.currentTarget.style.color='var(--text2)' }}}
|
| 143 |
+
>{t.icon}</button>
|
|
|
|
| 144 |
))}
|
| 145 |
</div>
|
| 146 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
</div>
|
| 148 |
)
|
| 149 |
}
|
| 150 |
|
| 151 |
+
// ── Mobile layout ─────────────────────────────────────────────────────────────
|
| 152 |
+
function MobileLayout({ canvasRef }) {
|
|
|
|
|
|
|
| 153 |
const { activePanel, setActivePanel } = useStore()
|
| 154 |
+
|
| 155 |
return (
|
| 156 |
+
<div style={{ display:'flex', flexDirection:'column', flex:1, overflow:'hidden' }}>
|
| 157 |
+
{/* Canvas */}
|
| 158 |
+
<div ref={canvasRef} style={{ flex:1, position:'relative', overflow:'hidden', minHeight:0 }}>
|
| 159 |
+
<Suspense fallback={<Loading />}>
|
| 160 |
+
<Scene canvasRef={canvasRef} />
|
| 161 |
+
</Suspense>
|
| 162 |
+
|
| 163 |
+
{/* Slide-up drawer */}
|
| 164 |
+
{activePanel && (
|
| 165 |
+
<div style={{
|
| 166 |
+
position:'absolute', bottom:0, left:0, right:0,
|
| 167 |
+
height:'55%', background:'var(--bg1)',
|
| 168 |
+
borderTop:'1px solid var(--border)',
|
| 169 |
+
display:'flex', flexDirection:'column',
|
| 170 |
+
zIndex:180, animation:'fadeUp 0.18s ease',
|
| 171 |
+
}}>
|
| 172 |
+
{/* Drawer handle */}
|
| 173 |
+
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 174 |
+
padding:'8px 14px', borderBottom:'1px solid var(--border)', flexShrink:0 }}>
|
| 175 |
+
<span style={{ fontSize:12, fontWeight:700, color:'var(--text0)' }}>
|
| 176 |
+
{TABS.find(t=>t.id===activePanel)?.icon}
|
| 177 |
+
{TABS.find(t=>t.id===activePanel)?.label}
|
| 178 |
+
</span>
|
| 179 |
+
<button onClick={() => setActivePanel(null)}
|
| 180 |
+
style={{ background:'none', border:'none', color:'var(--text2)', cursor:'pointer', fontSize:18 }}>×</button>
|
| 181 |
+
</div>
|
| 182 |
+
<PanelContent id={activePanel} canvasRef={canvasRef} />
|
| 183 |
+
</div>
|
| 184 |
+
)}
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
{/* Bottom tab bar */}
|
| 188 |
+
<div style={{
|
| 189 |
+
display:'flex', background:'var(--bg1)',
|
| 190 |
+
borderTop:'1px solid var(--border)', flexShrink:0,
|
| 191 |
+
overflowX:'auto',
|
| 192 |
+
}}>
|
| 193 |
+
{TABS.map(t => (
|
| 194 |
+
<button key={t.id}
|
| 195 |
+
onClick={() => setActivePanel(activePanel===t.id ? null : t.id)}
|
| 196 |
+
style={{
|
| 197 |
+
flex:'0 0 auto', minWidth:52, padding:'8px 6px 6px',
|
| 198 |
+
background: activePanel===t.id ? 'var(--bg2)' : 'transparent',
|
| 199 |
+
border:'none',
|
| 200 |
+
borderTop:`2px solid ${activePanel===t.id ? 'var(--accent)' : 'transparent'}`,
|
| 201 |
+
color: activePanel===t.id ? 'var(--accent)' : 'var(--text2)',
|
| 202 |
+
cursor:'pointer', display:'flex', flexDirection:'column',
|
| 203 |
+
alignItems:'center', gap:2, transition:'all 0.12s',
|
| 204 |
+
}}
|
| 205 |
+
>
|
| 206 |
+
<span style={{ fontSize:18 }}>{t.icon}</span>
|
| 207 |
+
<span style={{ fontSize:9, fontWeight: activePanel===t.id ? 600 : 400 }}>{t.label}</span>
|
| 208 |
+
</button>
|
| 209 |
+
))}
|
| 210 |
+
</div>
|
| 211 |
</div>
|
| 212 |
)
|
| 213 |
}
|
| 214 |
|
| 215 |
+
// ── Loading ────────────────────────────────────────────────────────────────────
|
| 216 |
+
function Loading() {
|
|
|
|
| 217 |
return (
|
| 218 |
+
<div style={{ position:'absolute', inset:0, display:'flex', alignItems:'center', justifyContent:'center',
|
| 219 |
+
background:'var(--bg0)', flexDirection:'column', gap:16 }}>
|
| 220 |
+
<div style={{ width:40, height:40, border:'3px solid var(--bg4)',
|
| 221 |
+
borderTopColor:'var(--accent)', borderRadius:'50%', animation:'spin 0.8s linear infinite' }} />
|
| 222 |
+
<span style={{ fontSize:12, color:'var(--text2)', letterSpacing:'0.1em' }}>LOADING SCENE</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
)
|
| 225 |
}
|
| 226 |
|
| 227 |
+
// ── Root ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 228 |
export default function App() {
|
| 229 |
const canvasRef = useRef()
|
| 230 |
const showTimeline = useStore(s => s.showTimeline)
|
| 231 |
+
const [mobile] = useState(() => window.innerWidth < 640)
|
| 232 |
+
const TL_H = showTimeline ? 148 : 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
return (
|
| 235 |
<div style={{
|
| 236 |
+
width:'100vw', height:'100vh',
|
| 237 |
+
display:'flex', flexDirection:'column',
|
| 238 |
+
background:'var(--bg0)', overflow:'hidden',
|
| 239 |
+
fontFamily:'var(--font-ui)',
|
|
|
|
| 240 |
}}>
|
| 241 |
+
<Toolbar />
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
{/* Main content — height minus toolbar and timeline */}
|
| 244 |
+
<div style={{ flex:1, overflow:'hidden', display:'flex', flexDirection:'column',
|
| 245 |
+
paddingBottom: TL_H }}>
|
| 246 |
+
{mobile ? <MobileLayout canvasRef={canvasRef} /> : <DesktopLayout canvasRef={canvasRef} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
</div>
|
| 248 |
|
| 249 |
+
{/* Timeline — fixed at bottom */}
|
| 250 |
+
<div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:200 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
<Timeline />
|
| 252 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
</div>
|
| 254 |
)
|
| 255 |
}
|
|
@@ -1,163 +1,93 @@
|
|
| 1 |
-
|
| 2 |
-
* AnimationPlayer.jsx
|
| 3 |
-
* Detects all animations in loaded GLB models and provides
|
| 4 |
-
* per-model animation control: play/pause/loop/speed/blend.
|
| 5 |
-
* Drop-in component — no changes to existing files required.
|
| 6 |
-
*/
|
| 7 |
-
import { useEffect, useRef, useState } from 'react'
|
| 8 |
import useStore from '../store/useStore'
|
| 9 |
|
| 10 |
-
const COLORS = ['#
|
| 11 |
-
|
| 12 |
-
function AnimBar({ modelId, modelName, colorIdx }) {
|
| 13 |
-
const models = useStore(s => s.models)
|
| 14 |
-
const model = models.find(m => m.id === modelId)
|
| 15 |
-
const {
|
| 16 |
-
setModelActiveAnimation,
|
| 17 |
-
setModelAnimSpeed,
|
| 18 |
-
} = useStore.getState()
|
| 19 |
-
|
| 20 |
-
const [playing, setPlaying] = useState(true)
|
| 21 |
-
const [loop, setLoop] = useState(true)
|
| 22 |
-
const [blend, setBlend] = useState(0) // 0-1 crossfade weight
|
| 23 |
-
const [prevAnim,setPrevAnim] = useState(null)
|
| 24 |
-
|
| 25 |
-
if (!model || model.animations.length === 0) return null
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const c = COLORS[colorIdx % COLORS.length]
|
| 28 |
-
const anims = model.animations
|
| 29 |
|
| 30 |
-
|
| 31 |
-
setPrevAnim(model.activeAnimation)
|
| 32 |
-
setModelActiveAnimation(modelId, name)
|
| 33 |
-
setPlaying(true)
|
| 34 |
-
}
|
| 35 |
|
| 36 |
return (
|
| 37 |
-
<div style={{
|
| 38 |
-
background: 'rgba(255,255,255,0.03)',
|
| 39 |
-
border: `1px solid ${c}22`,
|
| 40 |
-
borderRadius: 8,
|
| 41 |
-
padding: '10px 12px',
|
| 42 |
-
marginBottom: 8,
|
| 43 |
-
}}>
|
| 44 |
{/* Model name */}
|
| 45 |
-
<div style={{ display:'flex', alignItems:'center', gap:
|
| 46 |
-
<div style={{ width:
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
<span style={{ fontSize:10, color:'
|
| 51 |
-
{
|
| 52 |
-
</span>
|
| 53 |
</div>
|
| 54 |
|
| 55 |
-
{/*
|
| 56 |
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:8 }}>
|
| 57 |
-
{
|
| 58 |
-
<button
|
| 59 |
-
key={anim}
|
| 60 |
-
onClick={() => switchAnim(anim)}
|
| 61 |
style={{
|
| 62 |
-
padding:'4px
|
| 63 |
-
background: model.activeAnimation===anim ? `${c}
|
| 64 |
-
border:
|
| 65 |
-
color: model.activeAnimation===anim ? c : '
|
| 66 |
-
|
| 67 |
-
fontSize:10, fontFamily:'Space Mono,monospace',
|
| 68 |
-
whiteSpace:'nowrap',
|
| 69 |
-
transition:'all 0.15s',
|
| 70 |
}}
|
| 71 |
-
>
|
| 72 |
-
{model.activeAnimation===anim && playing ? '▶ ' : ''}{anim}
|
| 73 |
-
</button>
|
| 74 |
))}
|
| 75 |
</div>
|
| 76 |
|
| 77 |
-
{/* Controls
|
| 78 |
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
| 79 |
-
<button
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
color: playing ? '#ff4060' : '#40ff80',
|
| 86 |
-
borderRadius:4, cursor:'pointer', fontSize:12,
|
| 87 |
-
}}
|
| 88 |
-
>{playing ? '⏸' : '▶'}</button>
|
| 89 |
|
| 90 |
-
<button
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
color: loop ? '#00f5ff' : '#555',
|
| 97 |
-
borderRadius:4, cursor:'pointer', fontSize:11,
|
| 98 |
-
fontFamily:'Space Mono',
|
| 99 |
-
}}
|
| 100 |
-
>⟳ LOOP</button>
|
| 101 |
|
| 102 |
-
{
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
<
|
| 106 |
-
|
| 107 |
-
value={model.animationSpeed}
|
| 108 |
-
onChange={e => setModelAnimSpeed(modelId, parseFloat(e.target.value))}
|
| 109 |
-
style={{ flex:1, accentColor: c }}
|
| 110 |
-
/>
|
| 111 |
-
<span style={{ fontSize:10, color:c, minWidth:28 }}>
|
| 112 |
-
{model.animationSpeed.toFixed(1)}x
|
| 113 |
</span>
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
-
|
| 117 |
-
{/* Crossfade blend — only show when there are 2+ anims */}
|
| 118 |
-
{anims.length > 1 && prevAnim && prevAnim !== model.activeAnimation && (
|
| 119 |
-
<div style={{ display:'flex', alignItems:'center', gap:6, marginTop:6 }}>
|
| 120 |
-
<span style={{ fontSize:9, color:'#444', whiteSpace:'nowrap' }}>BLEND</span>
|
| 121 |
-
<input
|
| 122 |
-
type="range" min={0} max={1} step={0.01}
|
| 123 |
-
value={blend}
|
| 124 |
-
onChange={e => setBlend(parseFloat(e.target.value))}
|
| 125 |
-
style={{ flex:1, accentColor:'#ffaa00' }}
|
| 126 |
-
/>
|
| 127 |
-
<span style={{ fontSize:10, color:'#ffaa00', minWidth:28 }}>
|
| 128 |
-
{Math.round(blend*100)}%
|
| 129 |
-
</span>
|
| 130 |
-
</div>
|
| 131 |
-
)}
|
| 132 |
</div>
|
| 133 |
)
|
| 134 |
}
|
| 135 |
|
| 136 |
export default function AnimationPlayer() {
|
| 137 |
const models = useStore(s => s.models)
|
| 138 |
-
const
|
| 139 |
|
| 140 |
-
if (
|
| 141 |
-
|
| 142 |
-
<div style={{
|
| 143 |
-
|
| 144 |
-
|
|
|
|
| 145 |
</div>
|
| 146 |
-
|
| 147 |
-
|
| 148 |
|
| 149 |
return (
|
| 150 |
-
<div
|
| 151 |
-
<div style={{
|
| 152 |
-
|
|
|
|
| 153 |
</div>
|
| 154 |
-
{
|
| 155 |
-
<
|
| 156 |
-
key={m.id}
|
| 157 |
-
modelId={m.id}
|
| 158 |
-
modelName={m.name}
|
| 159 |
-
colorIdx={models.indexOf(m)}
|
| 160 |
-
/>
|
| 161 |
))}
|
| 162 |
</div>
|
| 163 |
)
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
+
const COLORS = ['#4f8eff','#ef4444','#22c55e','#f59e0b','#8b5cf6','#f97316']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
function AnimBlock({ model, colorIdx }) {
|
| 7 |
+
const [playing, setPlaying] = useState(true)
|
| 8 |
+
const [loop, setLoop] = useState(true)
|
| 9 |
+
const { setModelActiveAnimation, setModelAnimSpeed } = useStore.getState()
|
| 10 |
const c = COLORS[colorIdx % COLORS.length]
|
|
|
|
| 11 |
|
| 12 |
+
if (!model.animations.length) return null
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
return (
|
| 15 |
+
<div style={{ padding:'10px 12px', borderBottom:'1px solid var(--border)' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
{/* Model name */}
|
| 17 |
+
<div style={{ display:'flex', alignItems:'center', gap:7, marginBottom:8 }}>
|
| 18 |
+
<div style={{ width:7, height:7, borderRadius:2, rotate:'45deg',
|
| 19 |
+
background:c, boxShadow:`0 0 6px ${c}` }} />
|
| 20 |
+
<span style={{ fontSize:12, fontWeight:600, color:'var(--text0)', flex:1,
|
| 21 |
+
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{model.name}</span>
|
| 22 |
+
<span style={{ fontSize:10, color:'var(--text3)', background:'var(--bg3)',
|
| 23 |
+
padding:'2px 6px', borderRadius:3 }}>{model.animations.length} clips</span>
|
|
|
|
| 24 |
</div>
|
| 25 |
|
| 26 |
+
{/* Clip buttons */}
|
| 27 |
<div style={{ display:'flex', flexWrap:'wrap', gap:4, marginBottom:8 }}>
|
| 28 |
+
{model.animations.map(anim => (
|
| 29 |
+
<button key={anim} onClick={() => { setModelActiveAnimation(model.id, anim); setPlaying(true) }}
|
|
|
|
|
|
|
| 30 |
style={{
|
| 31 |
+
padding:'4px 9px', borderRadius:4, cursor:'pointer', fontSize:10, fontWeight:500,
|
| 32 |
+
background: model.activeAnimation===anim ? `${c}18` : 'var(--bg3)',
|
| 33 |
+
border:`1px solid ${model.activeAnimation===anim ? `${c}44` : 'var(--border)'}`,
|
| 34 |
+
color: model.activeAnimation===anim ? c : 'var(--text1)',
|
| 35 |
+
transition:'all 0.12s', maxWidth:120, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
|
|
|
|
|
|
|
|
|
|
| 36 |
}}
|
| 37 |
+
>{model.activeAnimation===anim && playing ? '▶ ' : ''}{anim}</button>
|
|
|
|
|
|
|
| 38 |
))}
|
| 39 |
</div>
|
| 40 |
|
| 41 |
+
{/* Controls */}
|
| 42 |
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
| 43 |
+
<button onClick={() => setPlaying(!playing)} style={{
|
| 44 |
+
padding:'4px 10px', borderRadius:4, fontSize:12, cursor:'pointer',
|
| 45 |
+
background: playing ? 'rgba(239,68,68,0.1)' : 'rgba(34,197,94,0.1)',
|
| 46 |
+
border:`1px solid ${playing ? 'rgba(239,68,68,0.3)' : 'rgba(34,197,94,0.3)'}`,
|
| 47 |
+
color: playing ? 'var(--danger)' : '#22c55e',
|
| 48 |
+
}}>{playing ? '⏸' : '▶'}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
<button onClick={() => setLoop(!loop)} style={{
|
| 51 |
+
padding:'4px 10px', borderRadius:4, fontSize:10, cursor:'pointer',
|
| 52 |
+
background: loop ? 'rgba(79,142,255,0.1)' : 'var(--bg3)',
|
| 53 |
+
border:`1px solid ${loop ? 'rgba(79,142,255,0.3)' : 'var(--border)'}`,
|
| 54 |
+
color: loop ? 'var(--accent)' : 'var(--text2)',
|
| 55 |
+
}}>⟳ Loop</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
<div style={{ display:'flex', alignItems:'center', gap:6, flex:1 }}>
|
| 58 |
+
<input type="range" min={0.1} max={3} step={0.05} value={model.animationSpeed}
|
| 59 |
+
onChange={e => setModelAnimSpeed(model.id, +e.target.value)} />
|
| 60 |
+
<span style={{ fontSize:10, fontFamily:'var(--font-mono)', color:c, minWidth:30 }}>
|
| 61 |
+
{model.animationSpeed.toFixed(1)}×
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
</span>
|
| 63 |
</div>
|
| 64 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
)
|
| 67 |
}
|
| 68 |
|
| 69 |
export default function AnimationPlayer() {
|
| 70 |
const models = useStore(s => s.models)
|
| 71 |
+
const animated = models.filter(m => m.animations.length > 0)
|
| 72 |
|
| 73 |
+
if (!animated.length) return (
|
| 74 |
+
<div style={{ padding:24, textAlign:'center' }}>
|
| 75 |
+
<div style={{ fontSize:32, opacity:0.15, marginBottom:10 }}>🎞</div>
|
| 76 |
+
<div style={{ fontSize:12, color:'var(--text2)' }}>No animations detected</div>
|
| 77 |
+
<div style={{ fontSize:11, color:'var(--text3)', marginTop:4 }}>
|
| 78 |
+
Load a GLB with built-in animation clips
|
| 79 |
</div>
|
| 80 |
+
</div>
|
| 81 |
+
)
|
| 82 |
|
| 83 |
return (
|
| 84 |
+
<div>
|
| 85 |
+
<div style={{ padding:'8px 12px', borderBottom:'1px solid var(--border)',
|
| 86 |
+
fontSize:11, color:'var(--text2)' }}>
|
| 87 |
+
{animated.length} animated model{animated.length>1?'s':''} · click a clip to switch
|
| 88 |
</div>
|
| 89 |
+
{animated.map((m) => (
|
| 90 |
+
<AnimBlock key={m.id} model={m} colorIdx={models.indexOf(m)} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
))}
|
| 92 |
</div>
|
| 93 |
)
|
|
@@ -1,261 +1,180 @@
|
|
| 1 |
import { useState, useRef } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export default function ExportPanel({ canvasRef }) {
|
| 5 |
-
const {
|
| 6 |
-
totalFrames, fps, setCurrentFrame, setIsPlaying,
|
| 7 |
isExporting, setIsExporting, exportProgress, setExportProgress,
|
| 8 |
-
exportedVideoUrl, setExportedVideoUrl
|
| 9 |
-
clearRecordedFrames,
|
| 10 |
-
} = useStore()
|
| 11 |
|
| 12 |
-
const [quality, setQuality] = useState(0.
|
| 13 |
-
const [outFps,
|
| 14 |
-
const [
|
| 15 |
-
const
|
|
|
|
| 16 |
const framesRef = useRef([])
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
const captureFrame = () => {
|
| 19 |
-
const
|
| 20 |
-
|
| 21 |
-
// Find the actual canvas element inside the wrapper
|
| 22 |
-
const c = canvas.tagName === 'CANVAS' ? canvas : canvas.querySelector('canvas')
|
| 23 |
-
if (!c) return null
|
| 24 |
-
return c.toDataURL('image/jpeg', quality)
|
| 25 |
}
|
| 26 |
|
| 27 |
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
| 28 |
|
| 29 |
const startExport = async () => {
|
| 30 |
if (isExporting) return
|
| 31 |
-
setIsExporting(true)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
captureRef.current = true
|
| 35 |
-
|
| 36 |
-
setStatus('Capturing frames...')
|
| 37 |
const store = useStore.getState()
|
| 38 |
|
| 39 |
-
for (let
|
| 40 |
-
if (
|
| 41 |
-
store.setCurrentFrame(
|
| 42 |
-
await sleep(1000
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
setExportProgress(Math.round((frame / totalFrames) * 80))
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
if (!captureRef.current) {
|
| 50 |
-
setIsExporting(false)
|
| 51 |
-
setStatus('Cancelled')
|
| 52 |
-
return
|
| 53 |
}
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
setExportedVideoUrl(url)
|
| 63 |
-
setExportProgress(100)
|
| 64 |
-
setStatus('Done! Video ready.')
|
| 65 |
-
} catch (err) {
|
| 66 |
-
setStatus(`Error: ${err.message}`)
|
| 67 |
-
console.error(err)
|
| 68 |
}
|
| 69 |
-
|
| 70 |
-
setIsExporting(false)
|
| 71 |
-
store.setCurrentFrame(0)
|
| 72 |
}
|
| 73 |
|
| 74 |
-
const
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
const
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
resolve(new Blob(chunks, { type: 'video/webm' }))
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
recorder.start()
|
| 100 |
-
|
| 101 |
-
let i = 0
|
| 102 |
-
const interval = setInterval(() => {
|
| 103 |
-
if (i >= frames.length) {
|
| 104 |
-
clearInterval(interval)
|
| 105 |
-
recorder.stop()
|
| 106 |
-
return
|
| 107 |
-
}
|
| 108 |
-
const frame = new Image()
|
| 109 |
-
frame.onload = () => ctx.drawImage(frame, 0, 0)
|
| 110 |
-
frame.src = frames[i++]
|
| 111 |
-
setExportProgress(85 + Math.round((i / frames.length) * 14))
|
| 112 |
-
}, 1000 / fps)
|
| 113 |
-
}
|
| 114 |
-
img.onerror = reject
|
| 115 |
-
img.src = frames[0]
|
| 116 |
-
})
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
const cancel = () => {
|
| 120 |
-
captureRef.current = false
|
| 121 |
-
setIsExporting(false)
|
| 122 |
-
setStatus('Cancelled')
|
| 123 |
-
setExportProgress(0)
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
const duration = (totalFrames / fps).toFixed(1)
|
| 127 |
-
const estimatedSize = Math.round((totalFrames / fps) * outFps * 0.3)
|
| 128 |
|
| 129 |
return (
|
| 130 |
-
<div style={{ padding:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<div style={{ background:
|
| 134 |
-
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
</div>
|
| 140 |
</div>
|
| 141 |
|
| 142 |
{/* Quality */}
|
| 143 |
-
<div
|
| 144 |
-
<div style={{
|
| 145 |
-
|
| 146 |
-
<
|
| 147 |
-
value={quality}
|
| 148 |
-
onChange={e => setQuality(parseFloat(e.target.value))}
|
| 149 |
-
style={{ flex: 1 }}
|
| 150 |
-
/>
|
| 151 |
-
<span style={{ color: '#00f5ff', fontSize: 11 }}>{Math.round(quality * 100)}%</span>
|
| 152 |
</div>
|
|
|
|
|
|
|
| 153 |
</div>
|
| 154 |
|
| 155 |
{/* FPS */}
|
| 156 |
-
<div
|
| 157 |
-
<div style={{ fontSize:
|
| 158 |
-
<div style={{ display:
|
| 159 |
-
{[15,
|
| 160 |
-
<button
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
borderRadius: 4, cursor: 'pointer',
|
| 169 |
-
fontSize: 11, fontFamily: 'Space Mono',
|
| 170 |
-
}}
|
| 171 |
-
>{f}</button>
|
| 172 |
))}
|
| 173 |
</div>
|
| 174 |
</div>
|
| 175 |
|
| 176 |
{/* Progress */}
|
| 177 |
{isExporting && (
|
| 178 |
-
<div
|
| 179 |
-
<div style={{ display:
|
| 180 |
-
<span style={{ fontSize:
|
| 181 |
-
<span style={{ fontSize:
|
| 182 |
</div>
|
| 183 |
-
<div style={{ height:
|
| 184 |
-
<div style={{
|
| 185 |
-
|
| 186 |
-
background: 'linear-gradient(90deg, #00f5ff, #0080ff)',
|
| 187 |
-
borderRadius: 2, transition: 'width 0.3s',
|
| 188 |
-
}} />
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
)}
|
| 192 |
|
|
|
|
| 193 |
{status && !isExporting && (
|
| 194 |
<div style={{
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
</div>
|
| 202 |
)}
|
| 203 |
|
| 204 |
{/* Buttons */}
|
| 205 |
{!isExporting ? (
|
| 206 |
-
<button
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
fontFamily: 'Space Mono', fontWeight: 'bold',
|
| 215 |
-
letterSpacing: '0.1em',
|
| 216 |
-
}}
|
| 217 |
-
>
|
| 218 |
-
▶ RENDER & EXPORT
|
| 219 |
-
</button>
|
| 220 |
) : (
|
| 221 |
-
<button
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
border: '1px solid rgba(255,64,96,0.3)',
|
| 227 |
-
color: '#ff4060', borderRadius: 6,
|
| 228 |
-
cursor: 'pointer', fontSize: 12,
|
| 229 |
-
fontFamily: 'Space Mono',
|
| 230 |
-
}}
|
| 231 |
-
>
|
| 232 |
-
⏹ CANCEL
|
| 233 |
-
</button>
|
| 234 |
)}
|
| 235 |
|
| 236 |
-
{/*
|
| 237 |
{exportedVideoUrl && (
|
| 238 |
-
<div style={{
|
| 239 |
-
<
|
| 240 |
-
<
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
<a
|
| 246 |
-
href={exportedVideoUrl}
|
| 247 |
-
download={`animation_${Date.now()}.webm`}
|
| 248 |
-
style={{
|
| 249 |
-
display: 'block', width: '100%', padding: '8px 0',
|
| 250 |
-
background: 'rgba(64,255,128,0.1)',
|
| 251 |
-
border: '1px solid rgba(64,255,128,0.3)',
|
| 252 |
-
color: '#40ff80', borderRadius: 6,
|
| 253 |
-
textAlign: 'center', textDecoration: 'none',
|
| 254 |
-
fontSize: 11, fontFamily: 'Space Mono',
|
| 255 |
-
}}
|
| 256 |
-
>
|
| 257 |
-
⬇ DOWNLOAD VIDEO
|
| 258 |
-
</a>
|
| 259 |
</div>
|
| 260 |
)}
|
| 261 |
</div>
|
|
|
|
| 1 |
import { useState, useRef } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
+
function Stat({ label, value }) {
|
| 5 |
+
return (
|
| 6 |
+
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
|
| 7 |
+
padding:'5px 0', borderBottom:'1px solid var(--border)' }}>
|
| 8 |
+
<span style={{ fontSize:11, color:'var(--text2)' }}>{label}</span>
|
| 9 |
+
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--text0)', fontWeight:600 }}>{value}</span>
|
| 10 |
+
</div>
|
| 11 |
+
)
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
export default function ExportPanel({ canvasRef }) {
|
| 15 |
+
const { totalFrames, fps, setCurrentFrame, setIsPlaying,
|
|
|
|
| 16 |
isExporting, setIsExporting, exportProgress, setExportProgress,
|
| 17 |
+
exportedVideoUrl, setExportedVideoUrl } = useStore()
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
const [quality, setQuality] = useState(0.9)
|
| 20 |
+
const [outFps, setOutFps] = useState(30)
|
| 21 |
+
const [format, setFormat] = useState('webm')
|
| 22 |
+
const [status, setStatus] = useState('')
|
| 23 |
+
const cancelRef = useRef(false)
|
| 24 |
const framesRef = useRef([])
|
| 25 |
|
| 26 |
+
const duration = (totalFrames / fps).toFixed(1)
|
| 27 |
+
const estSize = Math.round((totalFrames / fps) * outFps * 0.25)
|
| 28 |
+
|
| 29 |
const captureFrame = () => {
|
| 30 |
+
const c = document.querySelector('canvas')
|
| 31 |
+
return c ? c.toDataURL('image/jpeg', quality) : null
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
| 35 |
|
| 36 |
const startExport = async () => {
|
| 37 |
if (isExporting) return
|
| 38 |
+
setIsExporting(true); setExportedVideoUrl(null)
|
| 39 |
+
cancelRef.current = false; framesRef.current = []
|
| 40 |
+
setStatus('Capturing frames…')
|
|
|
|
|
|
|
|
|
|
| 41 |
const store = useStore.getState()
|
| 42 |
|
| 43 |
+
for (let f = 0; f < totalFrames; f++) {
|
| 44 |
+
if (cancelRef.current) break
|
| 45 |
+
store.setCurrentFrame(f)
|
| 46 |
+
await sleep(1000/fps + 16)
|
| 47 |
+
const d = captureFrame()
|
| 48 |
+
if (d) framesRef.current.push(d)
|
| 49 |
+
setExportProgress(Math.round((f/totalFrames)*78))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
+
if (!cancelRef.current && framesRef.current.length > 0) {
|
| 53 |
+
setStatus('Encoding video…'); setExportProgress(82)
|
| 54 |
+
try {
|
| 55 |
+
const blob = await encodeVideo(framesRef.current, outFps)
|
| 56 |
+
setExportedVideoUrl(URL.createObjectURL(blob))
|
| 57 |
+
setExportProgress(100); setStatus('Done!')
|
| 58 |
+
} catch(e) { setStatus('Error: ' + e.message) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
+
setIsExporting(false); store.setCurrentFrame(0)
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
+
const encodeVideo = (frames, fps) => new Promise((res, rej) => {
|
| 64 |
+
if (!frames.length) { rej(new Error('No frames')); return }
|
| 65 |
+
const img = new Image()
|
| 66 |
+
img.onload = () => {
|
| 67 |
+
const canvas = document.createElement('canvas')
|
| 68 |
+
canvas.width = img.width; canvas.height = img.height
|
| 69 |
+
const ctx = canvas.getContext('2d')
|
| 70 |
+
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm'
|
| 71 |
+
const rec = new MediaRecorder(canvas.captureStream(fps), { mimeType:mime, videoBitsPerSecond:8_000_000 })
|
| 72 |
+
const chunks = []
|
| 73 |
+
rec.ondataavailable = e => chunks.push(e.data)
|
| 74 |
+
rec.onstop = () => res(new Blob(chunks, { type:'video/webm' }))
|
| 75 |
+
rec.start()
|
| 76 |
+
let i = 0
|
| 77 |
+
const iv = setInterval(() => {
|
| 78 |
+
if (i >= frames.length) { clearInterval(iv); rec.stop(); return }
|
| 79 |
+
const fi = new Image(); fi.onload = () => ctx.drawImage(fi,0,0); fi.src = frames[i++]
|
| 80 |
+
setExportProgress(82 + Math.round((i/frames.length)*17))
|
| 81 |
+
}, 1000/fps)
|
| 82 |
+
}
|
| 83 |
+
img.onerror = rej; img.src = frames[0]
|
| 84 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
return (
|
| 87 |
+
<div style={{ padding:12, display:'flex', flexDirection:'column', gap:12 }}>
|
| 88 |
+
|
| 89 |
+
{/* Stats */}
|
| 90 |
+
<div style={{ background:'var(--bg2)', borderRadius:'var(--radius)', padding:'10px 12px',
|
| 91 |
+
border:'1px solid var(--border)' }}>
|
| 92 |
+
<Stat label="Frames" value={totalFrames} />
|
| 93 |
+
<Stat label="Duration" value={`${duration}s`} />
|
| 94 |
+
<Stat label="Timeline" value={`${fps} fps`} />
|
| 95 |
+
<Stat label="Format" value="WebM VP9" />
|
|
|
|
| 96 |
</div>
|
| 97 |
|
| 98 |
{/* Quality */}
|
| 99 |
+
<div>
|
| 100 |
+
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:5 }}>
|
| 101 |
+
<span style={{ fontSize:11, color:'var(--text2)', fontWeight:500 }}>Frame Quality</span>
|
| 102 |
+
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}>{Math.round(quality*100)}%</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
+
<input type="range" min={0.5} max={1} step={0.01} value={quality}
|
| 105 |
+
onChange={e => setQuality(+e.target.value)} />
|
| 106 |
</div>
|
| 107 |
|
| 108 |
{/* FPS */}
|
| 109 |
+
<div>
|
| 110 |
+
<div style={{ fontSize:11, color:'var(--text2)', fontWeight:500, marginBottom:6 }}>Output FPS</div>
|
| 111 |
+
<div style={{ display:'flex', gap:5 }}>
|
| 112 |
+
{[15,24,30,60].map(f => (
|
| 113 |
+
<button key={f} onClick={() => setOutFps(f)} style={{
|
| 114 |
+
flex:1, padding:'6px 0', borderRadius:'var(--radius-sm)',
|
| 115 |
+
background: outFps===f ? 'rgba(79,142,255,0.15)' : 'var(--bg2)',
|
| 116 |
+
border:`1px solid ${outFps===f ? 'rgba(79,142,255,0.4)' : 'var(--border)'}`,
|
| 117 |
+
color: outFps===f ? 'var(--accent)' : 'var(--text1)',
|
| 118 |
+
fontSize:11, fontWeight: outFps===f ? 700 : 400, cursor:'pointer',
|
| 119 |
+
transition:'all 0.12s',
|
| 120 |
+
}}>{f}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
))}
|
| 122 |
</div>
|
| 123 |
</div>
|
| 124 |
|
| 125 |
{/* Progress */}
|
| 126 |
{isExporting && (
|
| 127 |
+
<div>
|
| 128 |
+
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:5 }}>
|
| 129 |
+
<span style={{ fontSize:11, color:'var(--text2)' }}>{status}</span>
|
| 130 |
+
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}>{exportProgress}%</span>
|
| 131 |
</div>
|
| 132 |
+
<div style={{ height:4, background:'var(--bg3)', borderRadius:2 }}>
|
| 133 |
+
<div style={{ height:'100%', borderRadius:2, transition:'width 0.3s',
|
| 134 |
+
width:`${exportProgress}%`, background:'linear-gradient(90deg,var(--accent),var(--accent2))' }} />
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
)}
|
| 138 |
|
| 139 |
+
{/* Status message */}
|
| 140 |
{status && !isExporting && (
|
| 141 |
<div style={{
|
| 142 |
+
padding:'8px 10px', borderRadius:'var(--radius-sm)',
|
| 143 |
+
background: status.includes('Error') ? 'rgba(239,68,68,0.08)' : 'rgba(6,214,160,0.08)',
|
| 144 |
+
border:`1px solid ${status.includes('Error') ? 'rgba(239,68,68,0.2)' : 'rgba(6,214,160,0.2)'}`,
|
| 145 |
+
color: status.includes('Error') ? 'var(--danger)' : 'var(--accent3)',
|
| 146 |
+
fontSize:11,
|
| 147 |
+
}}>{status}</div>
|
|
|
|
| 148 |
)}
|
| 149 |
|
| 150 |
{/* Buttons */}
|
| 151 |
{!isExporting ? (
|
| 152 |
+
<button onClick={startExport} style={{
|
| 153 |
+
padding:'11px 0', borderRadius:'var(--radius)',
|
| 154 |
+
background:'linear-gradient(135deg,var(--accent),var(--accent2))',
|
| 155 |
+
border:'none', color:'#fff', fontSize:13, fontWeight:700,
|
| 156 |
+
cursor:'pointer', letterSpacing:'0.04em',
|
| 157 |
+
boxShadow:'0 4px 20px rgba(79,142,255,0.35)',
|
| 158 |
+
transition:'opacity 0.15s',
|
| 159 |
+
}}>▶ Render & Export</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
) : (
|
| 161 |
+
<button onClick={() => { cancelRef.current = true }} style={{
|
| 162 |
+
padding:'10px 0', borderRadius:'var(--radius)',
|
| 163 |
+
background:'rgba(239,68,68,0.1)', border:'1px solid rgba(239,68,68,0.3)',
|
| 164 |
+
color:'var(--danger)', fontSize:12, fontWeight:600, cursor:'pointer',
|
| 165 |
+
}}>⏹ Cancel</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
)}
|
| 167 |
|
| 168 |
+
{/* Result */}
|
| 169 |
{exportedVideoUrl && (
|
| 170 |
+
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
| 171 |
+
<video src={exportedVideoUrl} controls style={{ width:'100%', borderRadius:'var(--radius)', border:'1px solid var(--border)' }} />
|
| 172 |
+
<a href={exportedVideoUrl} download={`animation_${Date.now()}.webm`} style={{
|
| 173 |
+
display:'block', padding:'9px 0', borderRadius:'var(--radius)',
|
| 174 |
+
background:'rgba(6,214,160,0.1)', border:'1px solid rgba(6,214,160,0.3)',
|
| 175 |
+
color:'var(--accent3)', textAlign:'center', textDecoration:'none',
|
| 176 |
+
fontSize:12, fontWeight:600,
|
| 177 |
+
}}>⬇ Download Video</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</div>
|
| 179 |
)}
|
| 180 |
</div>
|
|
@@ -1,195 +1,204 @@
|
|
| 1 |
import { useState, useRef } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
-
const
|
| 5 |
-
{ name: 'Fox',
|
| 6 |
-
{ name: '
|
|
|
|
| 7 |
{ name: 'Flamingo', url: 'https://threejs.org/examples/models/gltf/Flamingo.glb' },
|
| 8 |
-
{ name: '
|
| 9 |
-
{ name: 'Parrot',
|
| 10 |
-
{ name: 'Robot', url: 'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb' },
|
| 11 |
-
{ name: 'Soldier', url: 'https://threejs.org/examples/models/gltf/Soldier.glb' },
|
| 12 |
]
|
| 13 |
|
|
|
|
|
|
|
| 14 |
export default function ModelsPanel() {
|
| 15 |
const { models, selectedModelId, addModel, removeModel, selectModel, toggleModelVisibility } = useStore()
|
| 16 |
-
const [
|
| 17 |
-
const [
|
| 18 |
-
const [
|
| 19 |
-
const [
|
| 20 |
const fileRef = useRef()
|
| 21 |
|
| 22 |
-
const
|
| 23 |
-
if (!
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
setUrlInput('')
|
| 27 |
-
setNameInput('')
|
| 28 |
-
setTimeout(() => setLoading(false), 1500)
|
| 29 |
}
|
| 30 |
|
| 31 |
-
const
|
| 32 |
const file = e.target.files[0]
|
| 33 |
if (!file) return
|
| 34 |
-
|
| 35 |
-
addModel(url, file.name.replace(/\.[^.]+$/, ''))
|
| 36 |
}
|
| 37 |
|
| 38 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
return (
|
| 41 |
-
<div style={{ padding:
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
📁 FILE
|
| 64 |
-
</button>
|
| 65 |
-
<button
|
| 66 |
-
onClick={() => setShowSamples(!showSamples)}
|
| 67 |
-
style={{ ...secondaryBtn, color: showSamples ? '#00f5ff' : '#888' }}
|
| 68 |
-
>
|
| 69 |
-
◈ DEMO
|
| 70 |
-
</button>
|
| 71 |
</div>
|
| 72 |
-
<
|
|
|
|
| 73 |
</div>
|
| 74 |
|
| 75 |
-
{/*
|
| 76 |
-
{
|
| 77 |
-
<
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
fontSize: 11, textAlign: 'left',
|
| 90 |
-
fontFamily: 'Space Mono',
|
| 91 |
-
}}
|
| 92 |
-
>
|
| 93 |
-
◈ {sm.name}
|
| 94 |
-
</button>
|
| 95 |
-
))}
|
| 96 |
-
</div>
|
| 97 |
</div>
|
| 98 |
-
)}
|
| 99 |
-
|
| 100 |
-
<div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
|
| 101 |
-
|
| 102 |
-
{/* Model list */}
|
| 103 |
-
<div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>
|
| 104 |
-
MODELS ({models.length})
|
| 105 |
</div>
|
| 106 |
|
| 107 |
-
{
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
{
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
display: 'flex', alignItems: 'center', gap: 8,
|
| 122 |
-
padding: '8px 8px',
|
| 123 |
-
marginBottom: 4,
|
| 124 |
-
background: isSelected ? `${color}11` : 'rgba(255,255,255,0.03)',
|
| 125 |
-
border: `1px solid ${isSelected ? color + '44' : 'rgba(255,255,255,0.06)'}`,
|
| 126 |
-
borderRadius: 6, cursor: 'pointer',
|
| 127 |
-
transition: 'all 0.15s',
|
| 128 |
-
}}
|
| 129 |
-
>
|
| 130 |
-
<div style={{
|
| 131 |
-
width: 8, height: 8, borderRadius: '50%',
|
| 132 |
-
background: m.visible ? color : '#333',
|
| 133 |
-
boxShadow: m.visible ? `0 0 6px ${color}` : 'none',
|
| 134 |
-
flexShrink: 0,
|
| 135 |
-
}} />
|
| 136 |
-
<span style={{
|
| 137 |
-
flex: 1, fontSize: 11, color: isSelected ? '#fff' : '#aaa',
|
| 138 |
-
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
| 139 |
-
}}>
|
| 140 |
-
{m.name}
|
| 141 |
-
</span>
|
| 142 |
-
{/* Visibility toggle */}
|
| 143 |
-
<button
|
| 144 |
-
onClick={e => { e.stopPropagation(); toggleModelVisibility(m.id) }}
|
| 145 |
style={{
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
| 149 |
}}
|
| 150 |
-
|
|
|
|
| 151 |
>
|
| 152 |
-
{
|
| 153 |
</button>
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
style={{
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}}
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
}
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
outline: 'none',
|
| 176 |
-
display: 'block',
|
| 177 |
-
}
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
}
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
}
|
|
|
|
| 1 |
import { useState, useRef } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
+
const SAMPLES = [
|
| 5 |
+
{ name: 'Fox', url: 'https://threejs.org/examples/models/gltf/Fox/glTF/Fox.gltf' },
|
| 6 |
+
{ name: 'Robot', url: 'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb' },
|
| 7 |
+
{ name: 'Soldier', url: 'https://threejs.org/examples/models/gltf/Soldier.glb' },
|
| 8 |
{ name: 'Flamingo', url: 'https://threejs.org/examples/models/gltf/Flamingo.glb' },
|
| 9 |
+
{ name: 'Horse', url: 'https://threejs.org/examples/models/gltf/Horse.glb' },
|
| 10 |
+
{ name: 'Parrot', url: 'https://threejs.org/examples/models/gltf/Parrot.glb' },
|
|
|
|
|
|
|
| 11 |
]
|
| 12 |
|
| 13 |
+
const COLORS = ['#4f8eff','#ef4444','#06d6a0','#f59e0b','#8b5cf6','#f97316']
|
| 14 |
+
|
| 15 |
export default function ModelsPanel() {
|
| 16 |
const { models, selectedModelId, addModel, removeModel, selectModel, toggleModelVisibility } = useStore()
|
| 17 |
+
const [url, setUrl] = useState('')
|
| 18 |
+
const [name, setName] = useState('')
|
| 19 |
+
const [samples, setSamples] = useState(false)
|
| 20 |
+
const [dragging, setDragging] = useState(false)
|
| 21 |
const fileRef = useRef()
|
| 22 |
|
| 23 |
+
const handleAdd = () => {
|
| 24 |
+
if (!url.trim()) return
|
| 25 |
+
addModel(url.trim(), name.trim() || null)
|
| 26 |
+
setUrl(''); setName('')
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
+
const handleFile = (e) => {
|
| 30 |
const file = e.target.files[0]
|
| 31 |
if (!file) return
|
| 32 |
+
addModel(URL.createObjectURL(file), file.name.replace(/\.[^.]+$/, ''))
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
+
const handleDrop = (e) => {
|
| 36 |
+
e.preventDefault(); setDragging(false)
|
| 37 |
+
const file = e.dataTransfer.files[0]
|
| 38 |
+
if (file && (file.name.endsWith('.glb') || file.name.endsWith('.gltf'))) {
|
| 39 |
+
addModel(URL.createObjectURL(file), file.name.replace(/\.[^.]+$/, ''))
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
|
| 43 |
return (
|
| 44 |
+
<div style={{ padding: 12, display:'flex', flexDirection:'column', gap:10 }}>
|
| 45 |
+
|
| 46 |
+
{/* Drop zone */}
|
| 47 |
+
<div
|
| 48 |
+
onDrop={handleDrop}
|
| 49 |
+
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
| 50 |
+
onDragLeave={() => setDragging(false)}
|
| 51 |
+
onClick={() => fileRef.current?.click()}
|
| 52 |
+
style={{
|
| 53 |
+
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border-hi)'}`,
|
| 54 |
+
borderRadius: 'var(--radius)',
|
| 55 |
+
padding: '20px 12px',
|
| 56 |
+
textAlign: 'center',
|
| 57 |
+
cursor: 'pointer',
|
| 58 |
+
background: dragging ? 'rgba(79,142,255,0.06)' : 'var(--bg2)',
|
| 59 |
+
transition: 'all 0.15s',
|
| 60 |
+
animation: dragging ? 'pulse 1s ease infinite' : 'none',
|
| 61 |
+
}}
|
| 62 |
+
>
|
| 63 |
+
<div style={{ fontSize: 28, marginBottom: 6 }}>📦</div>
|
| 64 |
+
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text1)' }}>
|
| 65 |
+
{dragging ? 'Drop to load' : 'Drop GLB / GLTF here'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
+
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 3 }}>or click to browse</div>
|
| 68 |
+
<input ref={fileRef} type="file" accept=".glb,.gltf" style={{ display:'none' }} onChange={handleFile} />
|
| 69 |
</div>
|
| 70 |
|
| 71 |
+
{/* URL input */}
|
| 72 |
+
<div style={{ display:'flex', flexDirection:'column', gap:5 }}>
|
| 73 |
+
<input value={name} onChange={e=>setName(e.target.value)}
|
| 74 |
+
placeholder="Name (optional)" style={{}} />
|
| 75 |
+
<div style={{ display:'flex', gap:5 }}>
|
| 76 |
+
<input value={url} onChange={e=>setUrl(e.target.value)}
|
| 77 |
+
onKeyDown={e=>e.key==='Enter'&&handleAdd()}
|
| 78 |
+
placeholder="Paste GLB / GLTF URL…" style={{ flex:1 }} />
|
| 79 |
+
<button onClick={handleAdd} style={{
|
| 80 |
+
padding:'5px 12px', borderRadius:'var(--radius-sm)',
|
| 81 |
+
background:'var(--accent)', border:'none', color:'#fff',
|
| 82 |
+
fontSize:11, fontWeight:600, cursor:'pointer', flexShrink:0,
|
| 83 |
+
transition:'opacity 0.15s',
|
| 84 |
+
}}>Add</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
| 87 |
|
| 88 |
+
{/* Sample models */}
|
| 89 |
+
<button onClick={() => setSamples(!samples)} style={{
|
| 90 |
+
padding:'6px 10px', borderRadius:'var(--radius-sm)',
|
| 91 |
+
background: samples ? 'var(--bg4)' : 'var(--bg2)',
|
| 92 |
+
border:'1px solid var(--border-hi)',
|
| 93 |
+
color:'var(--text1)', fontSize:11, cursor:'pointer',
|
| 94 |
+
display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 95 |
+
}}>
|
| 96 |
+
<span>⚡ Demo models</span>
|
| 97 |
+
<span style={{ color:'var(--text2)' }}>{samples ? '▲' : '▼'}</span>
|
| 98 |
+
</button>
|
| 99 |
|
| 100 |
+
{samples && (
|
| 101 |
+
<div style={{
|
| 102 |
+
display:'grid', gridTemplateColumns:'1fr 1fr',
|
| 103 |
+
gap:5, animation:'fadeUp 0.15s ease',
|
| 104 |
+
}}>
|
| 105 |
+
{SAMPLES.map(s => (
|
| 106 |
+
<button key={s.url}
|
| 107 |
+
onClick={() => { addModel(s.url, s.name); setSamples(false) }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
style={{
|
| 109 |
+
padding:'8px 10px', borderRadius:'var(--radius-sm)',
|
| 110 |
+
background:'var(--bg3)', border:'1px solid var(--border)',
|
| 111 |
+
color:'var(--text1)', fontSize:11, cursor:'pointer',
|
| 112 |
+
textAlign:'left', transition:'all 0.12s',
|
| 113 |
}}
|
| 114 |
+
onMouseEnter={e => { e.currentTarget.style.background='var(--bg4)'; e.currentTarget.style.color='var(--text0)' }}
|
| 115 |
+
onMouseLeave={e => { e.currentTarget.style.background='var(--bg3)'; e.currentTarget.style.color='var(--text1)' }}
|
| 116 |
>
|
| 117 |
+
📦 {s.name}
|
| 118 |
</button>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
)}
|
| 122 |
+
|
| 123 |
+
{/* Divider + count */}
|
| 124 |
+
{models.length > 0 && (
|
| 125 |
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
| 126 |
+
<div style={{ flex:1, height:1, background:'var(--border)' }} />
|
| 127 |
+
<span style={{ fontSize:10, color:'var(--text3)', fontWeight:600 }}>
|
| 128 |
+
{models.length} MODEL{models.length>1?'S':''}
|
| 129 |
+
</span>
|
| 130 |
+
<div style={{ flex:1, height:1, background:'var(--border)' }} />
|
| 131 |
+
</div>
|
| 132 |
+
)}
|
| 133 |
+
|
| 134 |
+
{/* Model list */}
|
| 135 |
+
<div style={{ display:'flex', flexDirection:'column', gap:3 }}>
|
| 136 |
+
{models.map((m, i) => {
|
| 137 |
+
const sel = m.id === selectedModelId
|
| 138 |
+
const c = COLORS[i % COLORS.length]
|
| 139 |
+
return (
|
| 140 |
+
<div
|
| 141 |
+
key={m.id}
|
| 142 |
+
onClick={() => selectModel(m.id)}
|
| 143 |
style={{
|
| 144 |
+
display:'flex', alignItems:'center', gap:8,
|
| 145 |
+
padding:'8px 10px',
|
| 146 |
+
borderRadius:'var(--radius-sm)',
|
| 147 |
+
background: sel ? 'rgba(79,142,255,0.1)' : 'var(--bg2)',
|
| 148 |
+
border:`1px solid ${sel ? 'rgba(79,142,255,0.3)' : 'var(--border)'}`,
|
| 149 |
+
cursor:'pointer', transition:'all 0.12s',
|
| 150 |
}}
|
| 151 |
+
onMouseEnter={e => { if (!sel) e.currentTarget.style.background='var(--bg3)' }}
|
| 152 |
+
onMouseLeave={e => { if (!sel) e.currentTarget.style.background='var(--bg2)' }}
|
| 153 |
+
>
|
| 154 |
+
{/* Color dot */}
|
| 155 |
+
<div style={{
|
| 156 |
+
width:8, height:8, borderRadius:'50%', flexShrink:0,
|
| 157 |
+
background: m.visible ? c : 'var(--text3)',
|
| 158 |
+
boxShadow: m.visible ? `0 0 6px ${c}88` : 'none',
|
| 159 |
+
transition:'all 0.15s',
|
| 160 |
+
}} />
|
| 161 |
|
| 162 |
+
{/* Name */}
|
| 163 |
+
<span style={{
|
| 164 |
+
flex:1, fontSize:12, fontWeight: sel ? 600 : 400,
|
| 165 |
+
color: sel ? 'var(--text0)' : 'var(--text1)',
|
| 166 |
+
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
|
| 167 |
+
}}>{m.name}</span>
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
{/* Anims badge */}
|
| 170 |
+
{m.animations.length > 0 && (
|
| 171 |
+
<span style={{
|
| 172 |
+
fontSize:9, padding:'2px 5px', borderRadius:3,
|
| 173 |
+
background:'rgba(6,214,160,0.12)', color:'var(--accent3)',
|
| 174 |
+
border:'1px solid rgba(6,214,160,0.2)', flexShrink:0,
|
| 175 |
+
}}>{m.animations.length}</span>
|
| 176 |
+
)}
|
| 177 |
|
| 178 |
+
{/* Actions */}
|
| 179 |
+
<button
|
| 180 |
+
onClick={e => { e.stopPropagation(); toggleModelVisibility(m.id) }}
|
| 181 |
+
title="Toggle visibility"
|
| 182 |
+
style={{ background:'none', border:'none', color: m.visible ? 'var(--text2)' : 'var(--text3)', fontSize:13, cursor:'pointer', flexShrink:0, padding:2 }}
|
| 183 |
+
>{m.visible ? '👁' : '🙈'}</button>
|
| 184 |
+
|
| 185 |
+
<button
|
| 186 |
+
onClick={e => { e.stopPropagation(); removeModel(m.id) }}
|
| 187 |
+
title="Remove model"
|
| 188 |
+
style={{ background:'none', border:'none', color:'var(--text3)', fontSize:12, cursor:'pointer', flexShrink:0, padding:2, transition:'color 0.12s' }}
|
| 189 |
+
onMouseEnter={e => e.currentTarget.style.color='var(--danger)'}
|
| 190 |
+
onMouseLeave={e => e.currentTarget.style.color='var(--text3)'}
|
| 191 |
+
>✕</button>
|
| 192 |
+
</div>
|
| 193 |
+
)
|
| 194 |
+
})}
|
| 195 |
+
|
| 196 |
+
{models.length === 0 && (
|
| 197 |
+
<div style={{ textAlign:'center', color:'var(--text3)', fontSize:12, padding:'12px 0' }}>
|
| 198 |
+
No models loaded yet
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
)
|
| 204 |
}
|
|
@@ -1,35 +1,71 @@
|
|
|
|
|
| 1 |
import useStore from '../store/useStore'
|
| 2 |
|
| 3 |
const DEG = 180 / Math.PI
|
| 4 |
|
| 5 |
-
function
|
| 6 |
-
const
|
| 7 |
return (
|
| 8 |
-
<div style={{
|
| 9 |
-
<
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</div>
|
| 34 |
))}
|
| 35 |
</div>
|
|
@@ -39,194 +75,158 @@ function VecInput({ label, value, onChange, step = 0.01, decimals = 3, scale = 1
|
|
| 39 |
|
| 40 |
export default function PropertiesPanel() {
|
| 41 |
const {
|
| 42 |
-
models, selectedModelId,
|
| 43 |
-
setModelActiveAnimation, setModelAnimSpeed,
|
| 44 |
-
currentFrame, addKeyframe, removeKeyframe,
|
| 45 |
-
|
| 46 |
-
selectModel, removeModel
|
| 47 |
} = useStore()
|
| 48 |
|
| 49 |
const model = models.find(m => m.id === selectedModelId)
|
| 50 |
|
| 51 |
-
if (!model)
|
| 52 |
-
|
| 53 |
-
<div style={{
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
}}>
|
| 58 |
-
<div style={{ fontSize: 24, marginBottom: 8, opacity: 0.3 }}>◎</div>
|
| 59 |
-
<div>Tap a model in the scene</div>
|
| 60 |
-
<div>to select it</div>
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
-
|
| 64 |
|
| 65 |
-
const
|
| 66 |
-
const
|
| 67 |
|
| 68 |
return (
|
| 69 |
-
<div
|
| 70 |
-
{/*
|
| 71 |
-
<div style={{
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
}}>
|
|
|
|
| 77 |
{model.name}
|
| 78 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
|
| 81 |
-
<div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
|
| 82 |
-
|
| 83 |
{/* Transform */}
|
| 84 |
-
<
|
| 85 |
-
label="
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
{/* Animation */}
|
| 110 |
{model.animations.length > 0 && (
|
| 111 |
-
<
|
| 112 |
-
<div style={{
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
| 127 |
))}
|
| 128 |
-
</
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
<input
|
| 133 |
-
type="range" min={0.1} max={3} step={0.1}
|
| 134 |
value={model.animationSpeed}
|
| 135 |
-
onChange={e => setModelAnimSpeed(model.id, parseFloat(e.target.value))}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
<span style={{ color: '#00f5ff', fontSize: 11, minWidth: 30 }}>
|
| 139 |
-
{model.animationSpeed.toFixed(1)}x
|
| 140 |
</span>
|
| 141 |
</div>
|
| 142 |
-
</
|
| 143 |
)}
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
<div style={{ marginBottom: 8 }}>
|
| 149 |
-
<div style={{ fontSize: 10, color: '#666', marginBottom: 6, letterSpacing: '0.1em' }}>
|
| 150 |
-
KEYFRAME @ FRAME {currentFrame}
|
| 151 |
-
</div>
|
| 152 |
-
<div style={{ display: 'flex', gap: 6 }}>
|
| 153 |
<button
|
| 154 |
onClick={() => addKeyframe(currentFrame, model.id)}
|
| 155 |
style={{
|
| 156 |
-
flex:
|
| 157 |
-
background:
|
| 158 |
-
border:
|
| 159 |
-
color:
|
| 160 |
-
borderRadius:
|
| 161 |
-
fontSize: 11, fontFamily: 'Space Mono',
|
| 162 |
}}
|
| 163 |
-
>
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
{hasKfAtFrame && (
|
| 167 |
-
<button
|
| 168 |
-
onClick={() => removeKeyframe(currentFrame, model.id)}
|
| 169 |
style={{
|
| 170 |
-
padding:
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
cursor: 'pointer', fontSize: 11,
|
| 175 |
-
}}
|
| 176 |
-
>✕</button>
|
| 177 |
)}
|
| 178 |
</div>
|
| 179 |
-
</div>
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
<div>
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
<
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
key={frame}
|
| 191 |
style={{
|
| 192 |
-
display:
|
| 193 |
-
padding:
|
| 194 |
-
background: frame
|
| 195 |
-
border:
|
| 196 |
-
|
| 197 |
}}
|
| 198 |
-
onClick={() => useStore.getState().setCurrentFrame(frame)}
|
| 199 |
>
|
| 200 |
-
<span style={{ fontSize:
|
|
|
|
|
|
|
| 201 |
<button
|
| 202 |
onClick={e => { e.stopPropagation(); removeKeyframe(frame, model.id) }}
|
| 203 |
-
style={{
|
| 204 |
-
background: 'none', border: 'none',
|
| 205 |
-
color: '#555', cursor: 'pointer', fontSize: 11,
|
| 206 |
-
}}
|
| 207 |
>✕</button>
|
| 208 |
</div>
|
| 209 |
))}
|
| 210 |
</div>
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
<div style={{ height: 1, background: 'rgba(255,255,255,0.08)', margin: '8px 0' }} />
|
| 215 |
-
|
| 216 |
-
{/* Delete model */}
|
| 217 |
-
<button
|
| 218 |
-
onClick={() => { removeModel(model.id); selectModel(null) }}
|
| 219 |
-
style={{
|
| 220 |
-
width: '100%', padding: '8px 0',
|
| 221 |
-
background: 'rgba(255,64,96,0.08)',
|
| 222 |
-
border: '1px solid rgba(255,64,96,0.3)',
|
| 223 |
-
color: '#ff4060', borderRadius: 6,
|
| 224 |
-
cursor: 'pointer', fontSize: 11,
|
| 225 |
-
fontFamily: 'Space Mono',
|
| 226 |
-
}}
|
| 227 |
-
>
|
| 228 |
-
🗑 REMOVE MODEL
|
| 229 |
-
</button>
|
| 230 |
</div>
|
| 231 |
)
|
| 232 |
}
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
const DEG = 180 / Math.PI
|
| 5 |
|
| 6 |
+
function Section({ title, children, defaultOpen = true }) {
|
| 7 |
+
const [open, setOpen] = useState(defaultOpen)
|
| 8 |
return (
|
| 9 |
+
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
| 10 |
+
<button
|
| 11 |
+
onClick={() => setOpen(!open)}
|
| 12 |
+
style={{
|
| 13 |
+
width:'100%', padding:'8px 12px',
|
| 14 |
+
display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 15 |
+
background:'transparent', border:'none', color:'var(--text1)',
|
| 16 |
+
fontSize:11, fontWeight:600, cursor:'pointer', letterSpacing:'0.08em',
|
| 17 |
+
textTransform:'uppercase',
|
| 18 |
+
}}
|
| 19 |
+
>
|
| 20 |
+
{title}
|
| 21 |
+
<span style={{ color:'var(--text3)', transition:'transform 0.15s',
|
| 22 |
+
display:'inline-block', transform: open?'rotate(0deg)':'rotate(-90deg)' }}>▾</span>
|
| 23 |
+
</button>
|
| 24 |
+
{open && <div style={{ padding:'0 12px 12px' }}>{children}</div>}
|
| 25 |
+
</div>
|
| 26 |
+
)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function Vec3({ label, value, onChange, step=0.01, scale=1, decimals=3, min, max }) {
|
| 30 |
+
const axes = [
|
| 31 |
+
{ k:'X', color:'#ef4444' },
|
| 32 |
+
{ k:'Y', color:'#22c55e' },
|
| 33 |
+
{ k:'Z', color:'#3b82f6' },
|
| 34 |
+
]
|
| 35 |
+
return (
|
| 36 |
+
<div style={{ marginBottom:8 }}>
|
| 37 |
+
<div style={{ fontSize:10, color:'var(--text2)', marginBottom:5, fontWeight:500, letterSpacing:'0.06em' }}>
|
| 38 |
+
{label}
|
| 39 |
+
</div>
|
| 40 |
+
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:4 }}>
|
| 41 |
+
{axes.map((ax, i) => (
|
| 42 |
+
<div key={ax.k}>
|
| 43 |
+
<div style={{
|
| 44 |
+
display:'flex', alignItems:'center',
|
| 45 |
+
background:'var(--bg1)', border:'1px solid var(--border)',
|
| 46 |
+
borderRadius:'var(--radius-sm)', overflow:'hidden',
|
| 47 |
+
transition:'border-color 0.15s',
|
| 48 |
+
}}
|
| 49 |
+
onFocusCapture={e => e.currentTarget.style.borderColor='var(--accent)'}
|
| 50 |
+
onBlurCapture={e => e.currentTarget.style.borderColor='var(--border)'}
|
| 51 |
+
>
|
| 52 |
+
<span style={{
|
| 53 |
+
padding:'0 5px', fontSize:10, fontWeight:700,
|
| 54 |
+
color: ax.color, background:'var(--bg2)',
|
| 55 |
+
borderRight:'1px solid var(--border)', alignSelf:'stretch',
|
| 56 |
+
display:'flex', alignItems:'center',
|
| 57 |
+
}}>{ax.k}</span>
|
| 58 |
+
<input
|
| 59 |
+
type="number" step={step} min={min} max={max}
|
| 60 |
+
value={(value[i] * scale).toFixed(decimals)}
|
| 61 |
+
onChange={e => {
|
| 62 |
+
const v = parseFloat(e.target.value) || 0
|
| 63 |
+
const arr = [...value]; arr[i] = v / scale; onChange(arr)
|
| 64 |
+
}}
|
| 65 |
+
style={{ border:'none', borderRadius:0, width:'100%', background:'transparent',
|
| 66 |
+
padding:'5px 5px', fontSize:11, fontFamily:'var(--font-mono)' }}
|
| 67 |
+
/>
|
| 68 |
+
</div>
|
| 69 |
</div>
|
| 70 |
))}
|
| 71 |
</div>
|
|
|
|
| 75 |
|
| 76 |
export default function PropertiesPanel() {
|
| 77 |
const {
|
| 78 |
+
models, selectedModelId,
|
| 79 |
+
updateModelTransform, setModelActiveAnimation, setModelAnimSpeed,
|
| 80 |
+
currentFrame, addKeyframe, removeKeyframe, getKeyframesForModel, keyframes,
|
| 81 |
+
removeModel, selectModel,
|
|
|
|
| 82 |
} = useStore()
|
| 83 |
|
| 84 |
const model = models.find(m => m.id === selectedModelId)
|
| 85 |
|
| 86 |
+
if (!model) return (
|
| 87 |
+
<div style={{ padding:24, textAlign:'center' }}>
|
| 88 |
+
<div style={{ fontSize:32, opacity:0.15, marginBottom:10 }}>◎</div>
|
| 89 |
+
<div style={{ fontSize:12, color:'var(--text2)' }}>Select a model in the scene</div>
|
| 90 |
+
<div style={{ fontSize:11, color:'var(--text3)', marginTop:4 }}>
|
| 91 |
+
Click any object to inspect its properties
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
+
</div>
|
| 94 |
+
)
|
| 95 |
|
| 96 |
+
const kfList = getKeyframesForModel(model.id)
|
| 97 |
+
const hasKfNow = keyframes[currentFrame]?.[model.id]
|
| 98 |
|
| 99 |
return (
|
| 100 |
+
<div>
|
| 101 |
+
{/* Header */}
|
| 102 |
+
<div style={{
|
| 103 |
+
padding:'10px 12px 8px',
|
| 104 |
+
borderBottom:'1px solid var(--border)',
|
| 105 |
+
display:'flex', alignItems:'center', gap:8,
|
| 106 |
+
}}>
|
| 107 |
+
<div style={{ width:10, height:10, borderRadius:'50%', background:'var(--accent)', boxShadow:'0 0 8px rgba(79,142,255,0.5)' }} />
|
| 108 |
+
<span style={{ fontSize:13, fontWeight:600, flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
| 109 |
{model.name}
|
| 110 |
+
</span>
|
| 111 |
+
<button
|
| 112 |
+
onClick={() => { removeModel(model.id); selectModel(null) }}
|
| 113 |
+
style={{ background:'none', border:'none', color:'var(--text3)', cursor:'pointer', fontSize:14, transition:'color 0.12s' }}
|
| 114 |
+
onMouseEnter={e => e.currentTarget.style.color='var(--danger)'}
|
| 115 |
+
onMouseLeave={e => e.currentTarget.style.color='var(--text3)'}
|
| 116 |
+
title="Remove model"
|
| 117 |
+
>🗑</button>
|
| 118 |
</div>
|
| 119 |
|
|
|
|
|
|
|
| 120 |
{/* Transform */}
|
| 121 |
+
<Section title="Transform">
|
| 122 |
+
<Vec3 label="Position" value={model.position} step={0.1} decimals={2}
|
| 123 |
+
onChange={v => updateModelTransform(model.id, 'position', v)} />
|
| 124 |
+
<Vec3 label="Rotation (deg)" value={model.rotation} step={1} decimals={1} scale={DEG}
|
| 125 |
+
onChange={v => updateModelTransform(model.id, 'rotation', v)} />
|
| 126 |
+
<Vec3 label="Scale" value={model.scale} step={0.05} decimals={2}
|
| 127 |
+
onChange={v => updateModelTransform(model.id, 'scale', v)} />
|
| 128 |
+
<button
|
| 129 |
+
onClick={() => {
|
| 130 |
+
updateModelTransform(model.id, 'position', [0,0,0])
|
| 131 |
+
updateModelTransform(model.id, 'rotation', [0,0,0])
|
| 132 |
+
updateModelTransform(model.id, 'scale', [1,1,1])
|
| 133 |
+
}}
|
| 134 |
+
style={{
|
| 135 |
+
width:'100%', marginTop:4, padding:'5px 0',
|
| 136 |
+
background:'var(--bg2)', border:'1px solid var(--border)',
|
| 137 |
+
borderRadius:'var(--radius-sm)', color:'var(--text2)',
|
| 138 |
+
fontSize:11, cursor:'pointer', transition:'all 0.12s',
|
| 139 |
+
}}
|
| 140 |
+
onMouseEnter={e => { e.currentTarget.style.background='var(--bg3)'; e.currentTarget.style.color='var(--text0)' }}
|
| 141 |
+
onMouseLeave={e => { e.currentTarget.style.background='var(--bg2)'; e.currentTarget.style.color='var(--text2)' }}
|
| 142 |
+
>↺ Reset Transform</button>
|
| 143 |
+
</Section>
|
| 144 |
+
|
| 145 |
+
{/* Animations */}
|
|
|
|
| 146 |
{model.animations.length > 0 && (
|
| 147 |
+
<Section title="Animations">
|
| 148 |
+
<div style={{ display:'flex', flexDirection:'column', gap:4 }}>
|
| 149 |
+
{model.animations.map(anim => (
|
| 150 |
+
<button key={anim}
|
| 151 |
+
onClick={() => setModelActiveAnimation(model.id, anim)}
|
| 152 |
+
style={{
|
| 153 |
+
padding:'7px 10px', borderRadius:'var(--radius-sm)',
|
| 154 |
+
background: model.activeAnimation===anim ? 'rgba(6,214,160,0.1)' : 'var(--bg2)',
|
| 155 |
+
border:`1px solid ${model.activeAnimation===anim ? 'rgba(6,214,160,0.3)' : 'var(--border)'}`,
|
| 156 |
+
color: model.activeAnimation===anim ? 'var(--accent3)' : 'var(--text1)',
|
| 157 |
+
fontSize:11, textAlign:'left', cursor:'pointer', transition:'all 0.12s',
|
| 158 |
+
display:'flex', alignItems:'center', gap:6,
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
<span>{model.activeAnimation===anim ? '▶' : '○'}</span>
|
| 162 |
+
{anim}
|
| 163 |
+
</button>
|
| 164 |
))}
|
| 165 |
+
</div>
|
| 166 |
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginTop:8 }}>
|
| 167 |
+
<span style={{ fontSize:10, color:'var(--text2)' }}>Speed</span>
|
| 168 |
+
<input type="range" min={0.1} max={3} step={0.05}
|
|
|
|
|
|
|
| 169 |
value={model.animationSpeed}
|
| 170 |
+
onChange={e => setModelAnimSpeed(model.id, parseFloat(e.target.value))} />
|
| 171 |
+
<span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)', minWidth:32 }}>
|
| 172 |
+
{model.animationSpeed.toFixed(1)}×
|
|
|
|
|
|
|
| 173 |
</span>
|
| 174 |
</div>
|
| 175 |
+
</Section>
|
| 176 |
)}
|
| 177 |
|
| 178 |
+
{/* Keyframes */}
|
| 179 |
+
<Section title="Keyframes">
|
| 180 |
+
<div style={{ display:'flex', gap:5, marginBottom:8 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
<button
|
| 182 |
onClick={() => addKeyframe(currentFrame, model.id)}
|
| 183 |
style={{
|
| 184 |
+
flex:1, padding:'7px 0',
|
| 185 |
+
background: hasKfNow ? 'rgba(245,158,11,0.15)' : 'rgba(79,142,255,0.12)',
|
| 186 |
+
border:`1px solid ${hasKfNow ? 'rgba(245,158,11,0.4)' : 'rgba(79,142,255,0.3)'}`,
|
| 187 |
+
color: hasKfNow ? 'var(--warn)' : 'var(--accent)',
|
| 188 |
+
borderRadius:'var(--radius-sm)', cursor:'pointer', fontSize:11, fontWeight:600,
|
|
|
|
| 189 |
}}
|
| 190 |
+
>{hasKfNow ? '◆ Update' : '◆ Add Keyframe'}</button>
|
| 191 |
+
{hasKfNow && (
|
| 192 |
+
<button onClick={() => removeKeyframe(currentFrame, model.id)}
|
|
|
|
|
|
|
|
|
|
| 193 |
style={{
|
| 194 |
+
padding:'7px 10px', background:'rgba(239,68,68,0.1)',
|
| 195 |
+
border:'1px solid rgba(239,68,68,0.3)', color:'var(--danger)',
|
| 196 |
+
borderRadius:'var(--radius-sm)', cursor:'pointer', fontSize:12,
|
| 197 |
+
}}>✕</button>
|
|
|
|
|
|
|
|
|
|
| 198 |
)}
|
| 199 |
</div>
|
|
|
|
| 200 |
|
| 201 |
+
<div style={{ fontSize:11, color:'var(--text2)', marginBottom:5 }}>
|
| 202 |
+
Frame {currentFrame} {hasKfNow ? '— keyframe set ◆' : '— no keyframe'}
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
{kfList.length > 0 && (
|
| 206 |
+
<div style={{ maxHeight:120, overflow:'auto', display:'flex', flexDirection:'column', gap:2 }}>
|
| 207 |
+
{kfList.map(({ frame }) => (
|
| 208 |
+
<div key={frame}
|
| 209 |
+
onClick={() => useStore.getState().setCurrentFrame(frame)}
|
|
|
|
| 210 |
style={{
|
| 211 |
+
display:'flex', justifyContent:'space-between', alignItems:'center',
|
| 212 |
+
padding:'4px 8px', borderRadius:'var(--radius-sm)',
|
| 213 |
+
background: frame===currentFrame ? 'rgba(245,158,11,0.1)' : 'var(--bg2)',
|
| 214 |
+
border:`1px solid ${frame===currentFrame ? 'rgba(245,158,11,0.25)' : 'var(--border)'}`,
|
| 215 |
+
cursor:'pointer',
|
| 216 |
}}
|
|
|
|
| 217 |
>
|
| 218 |
+
<span style={{ fontSize:11, color: frame===currentFrame ? 'var(--warn)' : 'var(--text1)' }}>
|
| 219 |
+
Frame {frame}
|
| 220 |
+
</span>
|
| 221 |
<button
|
| 222 |
onClick={e => { e.stopPropagation(); removeKeyframe(frame, model.id) }}
|
| 223 |
+
style={{ background:'none', border:'none', color:'var(--text3)', cursor:'pointer', fontSize:11 }}
|
|
|
|
|
|
|
|
|
|
| 224 |
>✕</button>
|
| 225 |
</div>
|
| 226 |
))}
|
| 227 |
</div>
|
| 228 |
+
)}
|
| 229 |
+
</Section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
)
|
| 232 |
}
|
|
@@ -1,219 +1,176 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* SkyboxPanel.jsx
|
| 3 |
-
* Lets users set the scene background / skybox:
|
| 4 |
-
* - Preset environments (synced with lighting)
|
| 5 |
-
* - Solid color picker
|
| 6 |
-
* - Upload an image (JPG/PNG equirectangular)
|
| 7 |
-
* - Paste an HDR/image URL
|
| 8 |
-
* Drop-in component — no other files changed.
|
| 9 |
-
*/
|
| 10 |
import { useRef, useState } from 'react'
|
| 11 |
import useStore from '../store/useStore'
|
| 12 |
|
| 13 |
const PRESETS = [
|
| 14 |
-
{ id:
|
| 15 |
-
{ id:
|
| 16 |
-
{ id:
|
| 17 |
-
{ id:
|
| 18 |
]
|
| 19 |
|
| 20 |
-
const
|
| 21 |
-
'#080810','#000000','#ffffff','#1a0a2e',
|
| 22 |
-
'#0a1a2e','#1a2e0a','#2e0a0a','#2e2a0a',
|
| 23 |
-
]
|
| 24 |
|
| 25 |
export default function SkyboxPanel() {
|
| 26 |
-
const skybox
|
| 27 |
-
const lightingPreset
|
| 28 |
const { setSkybox, setLightingPreset } = useStore.getState()
|
| 29 |
-
|
| 30 |
-
const [
|
| 31 |
-
const [urlType, setUrlType] = useState('image') // 'image' | 'hdr'
|
| 32 |
const fileRef = useRef()
|
| 33 |
|
| 34 |
-
const applyPreset = (id) => {
|
| 35 |
-
setLightingPreset(id)
|
| 36 |
-
setSkybox({ type: 'preset', value: null, showBg: skybox.showBg })
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
const applyColor = (col) => {
|
| 40 |
-
setSkybox({ type: 'color', bgColor: col, showBg: true })
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
const applyUrl = () => {
|
| 44 |
-
if (!urlInput.trim()) return
|
| 45 |
-
const isHdr = urlInput.toLowerCase().includes('.hdr') ||
|
| 46 |
-
urlInput.toLowerCase().includes('.exr')
|
| 47 |
-
setSkybox({ type: isHdr ? 'hdr' : 'image', value: urlInput.trim(), showBg: true })
|
| 48 |
-
setUrlInput('')
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
const handleFile = (e) => {
|
| 52 |
-
const
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
const isHdr = file.name.toLowerCase().endsWith('.hdr') ||
|
| 56 |
-
file.name.toLowerCase().endsWith('.exr')
|
| 57 |
-
setSkybox({ type: isHdr ? 'hdr' : 'image', value: url, showBg: true })
|
| 58 |
}
|
| 59 |
|
| 60 |
-
const
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
return (
|
| 65 |
-
<div style={{ padding:
|
| 66 |
|
| 67 |
{/* Show background toggle */}
|
| 68 |
-
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
style={{
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
>{skybox.showBg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</div>
|
| 81 |
|
| 82 |
-
{/*
|
| 83 |
-
<div
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
</div>
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</div>
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
SOLID COLOR
|
| 130 |
-
</div>
|
| 131 |
-
<div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:6 }}>
|
| 132 |
-
{BG_COLORS.map(col => (
|
| 133 |
-
<div key={col} onClick={() => applyColor(col)} style={{
|
| 134 |
-
width:28, height:28, borderRadius:5,
|
| 135 |
-
background: col,
|
| 136 |
-
border: `2px solid ${skybox.type==='color' && skybox.bgColor===col
|
| 137 |
-
? '#00f5ff' : 'rgba(255,255,255,0.15)'}`,
|
| 138 |
-
cursor:'pointer',
|
| 139 |
-
boxShadow: skybox.type==='color' && skybox.bgColor===col
|
| 140 |
-
? '0 0 8px #00f5ff' : 'none',
|
| 141 |
-
transition:'all 0.15s',
|
| 142 |
-
}} />
|
| 143 |
-
))}
|
| 144 |
-
{/* Custom color picker */}
|
| 145 |
-
<label style={{ width:28, height:28, borderRadius:5, overflow:'hidden', cursor:'pointer',
|
| 146 |
-
border:'1px dashed rgba(255,255,255,0.2)', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
| 147 |
-
<span style={{ fontSize:16, pointerEvents:'none' }}>+</span>
|
| 148 |
-
<input type="color" defaultValue="#080810"
|
| 149 |
-
onChange={e => applyColor(e.target.value)}
|
| 150 |
-
style={{ position:'absolute', opacity:0, width:0, height:0 }} />
|
| 151 |
-
</label>
|
| 152 |
-
</div>
|
| 153 |
-
|
| 154 |
-
<hr style={{ border:'none', borderTop:'1px solid rgba(255,255,255,0.07)', margin:'8px 0 10px' }} />
|
| 155 |
-
|
| 156 |
-
{/* ── Upload image / HDR ── */}
|
| 157 |
-
<div style={{ fontSize:10, color:'#555', letterSpacing:'0.1em', marginBottom:6 }}>
|
| 158 |
-
UPLOAD SKYBOX IMAGE / HDR
|
| 159 |
-
</div>
|
| 160 |
-
<div style={{ display:'flex', gap:5, marginBottom:8 }}>
|
| 161 |
<button onClick={() => fileRef.current?.click()} style={{
|
| 162 |
-
|
| 163 |
-
background:'
|
| 164 |
-
color:'
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
</button>
|
| 169 |
-
<input ref={fileRef} type="file" accept=".jpg,.jpeg,.png,.hdr,.exr"
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
-
<div style={{ fontSize:10, color:'#444', marginBottom:4 }}>Accepts: JPG, PNG (equirectangular), HDR, EXR</div>
|
| 173 |
-
|
| 174 |
-
<hr style={{ border:'none', borderTop:'1px solid rgba(255,255,255,0.07)', margin:'8px 0 10px' }} />
|
| 175 |
|
| 176 |
-
{/*
|
| 177 |
-
<div
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
</div>
|
| 180 |
-
<div style={{ display:'flex', gap:5, marginBottom:4 }}>
|
| 181 |
-
{['image','hdr'].map(t => (
|
| 182 |
-
<button key={t} onClick={() => setUrlType(t)} style={{
|
| 183 |
-
flex:1, padding:'5px 0',
|
| 184 |
-
background: urlType===t ? 'rgba(255,170,0,0.12)' : 'rgba(255,255,255,0.04)',
|
| 185 |
-
border: `1px solid ${urlType===t ? 'rgba(255,170,0,0.4)' : 'rgba(255,255,255,0.1)'}`,
|
| 186 |
-
color: urlType===t ? '#ffaa00' : '#555',
|
| 187 |
-
borderRadius:4, cursor:'pointer', fontSize:10, fontFamily:'Space Mono',
|
| 188 |
-
}}>{t.toUpperCase()}</button>
|
| 189 |
-
))}
|
| 190 |
-
</div>
|
| 191 |
-
<input
|
| 192 |
-
value={urlInput}
|
| 193 |
-
onChange={e => setUrlInput(e.target.value)}
|
| 194 |
-
onKeyDown={e => e.key==='Enter' && applyUrl()}
|
| 195 |
-
placeholder="https://...equirectangular.jpg"
|
| 196 |
-
style={{
|
| 197 |
-
width:'100%', padding:'7px 8px', marginBottom:6,
|
| 198 |
-
background:'rgba(0,10,30,0.7)', border:'1px solid rgba(0,245,255,0.18)',
|
| 199 |
-
borderRadius:6, color:'#d0e8ff', fontSize:11,
|
| 200 |
-
fontFamily:'Space Mono', outline:'none', display:'block',
|
| 201 |
-
}}
|
| 202 |
-
/>
|
| 203 |
-
<button onClick={applyUrl} style={{
|
| 204 |
-
width:'100%', padding:'8px 0',
|
| 205 |
-
background:'rgba(255,170,0,0.1)', border:'1px solid rgba(255,170,0,0.3)',
|
| 206 |
-
color:'#ffaa00', borderRadius:6, cursor:'pointer',
|
| 207 |
-
fontSize:11, fontFamily:'Space Mono',
|
| 208 |
-
}}>APPLY URL</button>
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
| 213 |
<a href="https://polyhaven.com/hdris" target="_blank"
|
| 214 |
-
style={{ color:'
|
| 215 |
-
Right-click → Copy image address → paste above
|
| 216 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
</div>
|
| 218 |
)
|
| 219 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useRef, useState } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
const PRESETS = [
|
| 5 |
+
{ id:'studio', label:'Studio', icon:'◎' },
|
| 6 |
+
{ id:'outdoor', label:'Outdoor', icon:'◉' },
|
| 7 |
+
{ id:'dramatic', label:'Dramatic', icon:'◈' },
|
| 8 |
+
{ id:'neon', label:'Neon', icon:'◆' },
|
| 9 |
]
|
| 10 |
|
| 11 |
+
const COLORS = ['#0c0c10','#000000','#0a0a1a','#0d1a0a','#1a0a0a','#ffffff','#87ceeb','#1a0a2e']
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
export default function SkyboxPanel() {
|
| 14 |
+
const skybox = useStore(s => s.skybox)
|
| 15 |
+
const lightingPreset = useStore(s => s.lightingPreset)
|
| 16 |
const { setSkybox, setLightingPreset } = useStore.getState()
|
| 17 |
+
const [url, setUrl] = useState('')
|
| 18 |
+
const [urlType, setUrlType] = useState('image')
|
|
|
|
| 19 |
const fileRef = useRef()
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const handleFile = (e) => {
|
| 22 |
+
const f = e.target.files[0]; if(!f) return
|
| 23 |
+
const isHdr = /\.(hdr|exr)$/i.test(f.name)
|
| 24 |
+
setSkybox({ type: isHdr?'hdr':'image', value: URL.createObjectURL(f), showBg:true })
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
+
const applyUrl = () => {
|
| 28 |
+
if(!url.trim()) return
|
| 29 |
+
const isHdr = /\.(hdr|exr)/i.test(url)
|
| 30 |
+
setSkybox({ type:isHdr?'hdr':'image', value:url.trim(), showBg:true })
|
| 31 |
+
setUrl('')
|
| 32 |
}
|
| 33 |
|
| 34 |
return (
|
| 35 |
+
<div style={{ padding:12, display:'flex', flexDirection:'column', gap:12 }}>
|
| 36 |
|
| 37 |
{/* Show background toggle */}
|
| 38 |
+
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 39 |
+
padding:'8px 10px', background:'var(--bg2)', borderRadius:'var(--radius)',
|
| 40 |
+
border:'1px solid var(--border)' }}>
|
| 41 |
+
<div>
|
| 42 |
+
<div style={{ fontSize:12, fontWeight:600, color:'var(--text0)' }}>Show Background</div>
|
| 43 |
+
<div style={{ fontSize:10, color:'var(--text2)', marginTop:2 }}>
|
| 44 |
+
{skybox.type==='preset' ? `Preset — ${lightingPreset}` :
|
| 45 |
+
skybox.type==='color' ? `Color — ${skybox.bgColor}` :
|
| 46 |
+
skybox.type==='image' ? 'Custom Image' : 'HDR'}
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
<button onClick={() => setSkybox({ showBg:!skybox.showBg })} style={{
|
| 50 |
+
width:40, height:22, borderRadius:11,
|
| 51 |
+
background: skybox.showBg ? 'var(--accent)' : 'var(--bg4)',
|
| 52 |
+
border:'none', cursor:'pointer', position:'relative', transition:'background 0.2s',
|
| 53 |
+
boxShadow: skybox.showBg ? '0 0 8px rgba(79,142,255,0.4)' : 'none',
|
| 54 |
+
}}>
|
| 55 |
+
<div style={{
|
| 56 |
+
position:'absolute', top:3, left: skybox.showBg ? 20 : 3,
|
| 57 |
+
width:16, height:16, borderRadius:8, background:'#fff',
|
| 58 |
+
transition:'left 0.2s', boxShadow:'0 1px 3px rgba(0,0,0,0.4)',
|
| 59 |
+
}}/>
|
| 60 |
+
</button>
|
| 61 |
</div>
|
| 62 |
|
| 63 |
+
{/* Preset environments */}
|
| 64 |
+
<div>
|
| 65 |
+
<div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
|
| 66 |
+
marginBottom:6, textTransform:'uppercase' }}>Preset Environments</div>
|
| 67 |
+
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:5 }}>
|
| 68 |
+
{PRESETS.map(p => (
|
| 69 |
+
<button key={p.id}
|
| 70 |
+
onClick={() => { setLightingPreset(p.id); setSkybox({ type:'preset', value:null }) }}
|
| 71 |
+
style={{
|
| 72 |
+
padding:'9px 8px', borderRadius:'var(--radius-sm)',
|
| 73 |
+
background: skybox.type==='preset' && lightingPreset===p.id ? 'rgba(79,142,255,0.12)' : 'var(--bg2)',
|
| 74 |
+
border:`1px solid ${skybox.type==='preset' && lightingPreset===p.id ? 'rgba(79,142,255,0.3)' : 'var(--border)'}`,
|
| 75 |
+
color: skybox.type==='preset' && lightingPreset===p.id ? 'var(--accent)' : 'var(--text1)',
|
| 76 |
+
fontSize:12, cursor:'pointer', textAlign:'left', transition:'all 0.12s',
|
| 77 |
+
display:'flex', alignItems:'center', gap:6,
|
| 78 |
+
}}
|
| 79 |
+
><span style={{ opacity:0.7 }}>{p.icon}</span> {p.label}</button>
|
| 80 |
+
))}
|
| 81 |
+
</div>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
+
{/* Solid color */}
|
| 85 |
+
<div>
|
| 86 |
+
<div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
|
| 87 |
+
marginBottom:6, textTransform:'uppercase' }}>Solid Color</div>
|
| 88 |
+
<div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
|
| 89 |
+
{COLORS.map(col => (
|
| 90 |
+
<div key={col} onClick={() => setSkybox({ type:'color', bgColor:col, showBg:true })}
|
| 91 |
+
style={{
|
| 92 |
+
width:30, height:30, borderRadius:'var(--radius-sm)',
|
| 93 |
+
background:col, cursor:'pointer', transition:'transform 0.1s',
|
| 94 |
+
border:`2px solid ${skybox.type==='color'&&skybox.bgColor===col ? 'var(--accent)' : 'rgba(255,255,255,0.1)'}`,
|
| 95 |
+
boxShadow: skybox.type==='color'&&skybox.bgColor===col ? '0 0 8px rgba(79,142,255,0.5)' : 'none',
|
| 96 |
+
}}
|
| 97 |
+
onMouseEnter={e=>e.currentTarget.style.transform='scale(1.1)'}
|
| 98 |
+
onMouseLeave={e=>e.currentTarget.style.transform='scale(1)'}
|
| 99 |
+
/>
|
| 100 |
+
))}
|
| 101 |
+
{/* Color picker */}
|
| 102 |
+
<label style={{ width:30, height:30, borderRadius:'var(--radius-sm)', cursor:'pointer',
|
| 103 |
+
border:'1px dashed var(--border-hi)', display:'flex', alignItems:'center', justifyContent:'center',
|
| 104 |
+
color:'var(--text2)', fontSize:18, overflow:'hidden' }}>
|
| 105 |
+
+
|
| 106 |
+
<input type="color" onChange={e=>setSkybox({type:'color',bgColor:e.target.value,showBg:true})}
|
| 107 |
+
style={{ position:'absolute', opacity:0, width:0, height:0 }} />
|
| 108 |
+
</label>
|
| 109 |
+
</div>
|
| 110 |
</div>
|
| 111 |
|
| 112 |
+
{/* Upload */}
|
| 113 |
+
<div>
|
| 114 |
+
<div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
|
| 115 |
+
marginBottom:6, textTransform:'uppercase' }}>Upload Skybox</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<button onClick={() => fileRef.current?.click()} style={{
|
| 117 |
+
width:'100%', padding:'10px', borderRadius:'var(--radius)',
|
| 118 |
+
background:'var(--bg2)', border:'2px dashed var(--border-hi)',
|
| 119 |
+
color:'var(--text1)', fontSize:12, cursor:'pointer', transition:'all 0.15s',
|
| 120 |
+
}}
|
| 121 |
+
onMouseEnter={e=>{e.currentTarget.style.borderColor='var(--accent)';e.currentTarget.style.color='var(--text0)'}}
|
| 122 |
+
onMouseLeave={e=>{e.currentTarget.style.borderColor='var(--border-hi)';e.currentTarget.style.color='var(--text1)'}}
|
| 123 |
+
>📁 Upload Image / HDR / EXR</button>
|
| 124 |
+
<input ref={fileRef} type="file" accept=".jpg,.jpeg,.png,.hdr,.exr" style={{display:'none'}} onChange={handleFile} />
|
| 125 |
+
<div style={{ fontSize:10, color:'var(--text3)', marginTop:4 }}>
|
| 126 |
+
JPG/PNG equirectangular · HDR · EXR
|
| 127 |
+
</div>
|
| 128 |
</div>
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
+
{/* URL */}
|
| 131 |
+
<div>
|
| 132 |
+
<div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, letterSpacing:'0.08em',
|
| 133 |
+
marginBottom:6, textTransform:'uppercase' }}>Paste URL</div>
|
| 134 |
+
<div style={{ display:'flex', gap:5, marginBottom:4 }}>
|
| 135 |
+
{['image','hdr'].map(t=>(
|
| 136 |
+
<button key={t} onClick={()=>setUrlType(t)} style={{
|
| 137 |
+
flex:1, padding:'4px 0', borderRadius:'var(--radius-sm)',
|
| 138 |
+
background:urlType===t?'rgba(79,142,255,0.12)':'var(--bg2)',
|
| 139 |
+
border:`1px solid ${urlType===t?'rgba(79,142,255,0.3)':'var(--border)'}`,
|
| 140 |
+
color:urlType===t?'var(--accent)':'var(--text1)',
|
| 141 |
+
fontSize:10, fontWeight:600, cursor:'pointer',
|
| 142 |
+
}}>{t.toUpperCase()}</button>
|
| 143 |
+
))}
|
| 144 |
+
</div>
|
| 145 |
+
<div style={{ display:'flex', gap:5 }}>
|
| 146 |
+
<input value={url} onChange={e=>setUrl(e.target.value)}
|
| 147 |
+
onKeyDown={e=>e.key==='Enter'&&applyUrl()}
|
| 148 |
+
placeholder="https://…equirectangular.jpg" style={{ flex:1 }} />
|
| 149 |
+
<button onClick={applyUrl} style={{
|
| 150 |
+
padding:'5px 10px', borderRadius:'var(--radius-sm)',
|
| 151 |
+
background:'var(--accent)', border:'none', color:'#fff',
|
| 152 |
+
fontSize:11, fontWeight:600, cursor:'pointer', flexShrink:0,
|
| 153 |
+
}}>Apply</button>
|
| 154 |
+
</div>
|
| 155 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
+
{/* Free HDR link */}
|
| 158 |
+
<div style={{ padding:'10px', background:'var(--bg2)', borderRadius:'var(--radius)',
|
| 159 |
+
border:'1px solid var(--border)', fontSize:11, color:'var(--text2)', lineHeight:1.6 }}>
|
| 160 |
+
💡 Free HDRIs at{' '}
|
| 161 |
<a href="https://polyhaven.com/hdris" target="_blank"
|
| 162 |
+
style={{ color:'var(--accent)', textDecoration:'none' }}>polyhaven.com ↗</a>
|
| 163 |
+
<br/>Right-click 1K JPEG → Copy image address → paste above
|
| 164 |
</div>
|
| 165 |
+
|
| 166 |
+
{/* Clear */}
|
| 167 |
+
{(skybox.type==='image'||skybox.type==='hdr') && (
|
| 168 |
+
<button onClick={() => setSkybox({type:'preset',value:null,showBg:false,bgColor:'#0c0c10'})} style={{
|
| 169 |
+
padding:'7px 0', borderRadius:'var(--radius-sm)',
|
| 170 |
+
background:'rgba(239,68,68,0.08)', border:'1px solid rgba(239,68,68,0.2)',
|
| 171 |
+
color:'var(--danger)', fontSize:11, cursor:'pointer',
|
| 172 |
+
}}>✕ Clear Custom Skybox</button>
|
| 173 |
+
)}
|
| 174 |
</div>
|
| 175 |
)
|
| 176 |
}
|
|
@@ -1,313 +1,244 @@
|
|
| 1 |
-
import { useRef, useState, useCallback } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
-
const
|
| 5 |
-
const
|
| 6 |
|
| 7 |
-
function KeyframeDot({ frame, modelId,
|
| 8 |
-
const
|
| 9 |
-
const
|
| 10 |
-
const dragging = useRef(false)
|
| 11 |
-
const startX = useRef(0)
|
| 12 |
-
const startFrame = useRef(frame)
|
| 13 |
|
| 14 |
-
const
|
| 15 |
-
|
| 16 |
-
const handlePointerDown = (e) => {
|
| 17 |
e.stopPropagation()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
if (!dragging.current) return
|
| 24 |
-
const dx = me.clientX - startX.current
|
| 25 |
-
const dFrames = Math.round((dx / timelineWidth) * totalFrames)
|
| 26 |
-
const newFrame = Math.max(0, Math.min(totalFrames - 1, startFrame.current + dFrames))
|
| 27 |
-
if (newFrame !== frame) {
|
| 28 |
-
moveKeyframe(frame, newFrame, modelId)
|
| 29 |
-
startX.current = me.clientX
|
| 30 |
-
startFrame.current = newFrame
|
| 31 |
-
}
|
| 32 |
}
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
window.removeEventListener('pointermove', handleMove)
|
| 37 |
-
window.removeEventListener('pointerup', handleUp)
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
window.addEventListener('pointermove', handleMove)
|
| 41 |
-
window.addEventListener('pointerup', handleUp)
|
| 42 |
}
|
| 43 |
|
| 44 |
return (
|
| 45 |
<div
|
|
|
|
|
|
|
|
|
|
| 46 |
style={{
|
| 47 |
-
position:
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
border: '1px solid rgba(255,255,255,0.5)',
|
| 55 |
-
borderRadius: '50%',
|
| 56 |
-
cursor: 'grab',
|
| 57 |
-
zIndex: 10,
|
| 58 |
-
boxShadow: `0 0 6px ${modelColor}`,
|
| 59 |
}}
|
| 60 |
-
onPointerDown={handlePointerDown}
|
| 61 |
-
onDoubleClick={(e) => { e.stopPropagation(); removeKeyframe(frame, modelId) }}
|
| 62 |
-
title={`Frame ${frame} — double click to delete`}
|
| 63 |
/>
|
| 64 |
)
|
| 65 |
}
|
| 66 |
|
| 67 |
-
function TimelineTrack({ model, timelineWidth }) {
|
| 68 |
-
const totalFrames = useStore(s => s.totalFrames)
|
| 69 |
-
const keyframes = useStore(s => s.keyframes)
|
| 70 |
-
const selectedModelId = useStore(s => s.selectedModelId)
|
| 71 |
-
const selectModel = useStore(s => s.selectModel)
|
| 72 |
-
|
| 73 |
-
const modelKeyframes = Object.entries(keyframes)
|
| 74 |
-
.filter(([, kf]) => kf[model.id])
|
| 75 |
-
.map(([f]) => parseInt(f))
|
| 76 |
-
|
| 77 |
-
const colors = ['#00f5ff', '#ff4080', '#40ff80', '#ffaa00', '#aa40ff', '#ff8040']
|
| 78 |
-
const colorIndex = useStore(s => s.models).findIndex(m => m.id === model.id) % colors.length
|
| 79 |
-
const color = colors[colorIndex]
|
| 80 |
-
|
| 81 |
-
const isSelected = selectedModelId === model.id
|
| 82 |
-
|
| 83 |
-
return (
|
| 84 |
-
<div
|
| 85 |
-
style={{
|
| 86 |
-
position: 'relative',
|
| 87 |
-
height: TRACK_HEIGHT,
|
| 88 |
-
width: timelineWidth,
|
| 89 |
-
background: isSelected ? 'rgba(0,245,255,0.04)' : 'rgba(255,255,255,0.02)',
|
| 90 |
-
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
| 91 |
-
cursor: 'pointer',
|
| 92 |
-
}}
|
| 93 |
-
onClick={() => selectModel(model.id)}
|
| 94 |
-
>
|
| 95 |
-
{/* Track bar */}
|
| 96 |
-
<div style={{
|
| 97 |
-
position: 'absolute', top: '45%', left: 0, right: 0,
|
| 98 |
-
height: 2, background: 'rgba(255,255,255,0.08)'
|
| 99 |
-
}} />
|
| 100 |
-
|
| 101 |
-
{modelKeyframes.map(f => (
|
| 102 |
-
<KeyframeDot
|
| 103 |
-
key={f} frame={f} modelId={model.id}
|
| 104 |
-
modelColor={color} timelineWidth={timelineWidth}
|
| 105 |
-
totalFrames={totalFrames}
|
| 106 |
-
/>
|
| 107 |
-
))}
|
| 108 |
-
</div>
|
| 109 |
-
)
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
export default function Timeline() {
|
| 113 |
const {
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
showTimeline, setShowTimeline,
|
| 118 |
-
keyframes
|
| 119 |
} = useStore()
|
| 120 |
|
| 121 |
-
const
|
| 122 |
-
const [
|
| 123 |
-
const
|
| 124 |
|
| 125 |
-
// Observe container width
|
| 126 |
const measuredRef = useCallback(node => {
|
| 127 |
if (!node) return
|
| 128 |
-
const ro = new ResizeObserver(
|
| 129 |
-
setTimelineWidth(entries[0].contentRect.width)
|
| 130 |
-
})
|
| 131 |
ro.observe(node)
|
| 132 |
return () => ro.disconnect()
|
| 133 |
}, [])
|
| 134 |
|
| 135 |
-
const scrub = (
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
const x =
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
}
|
| 142 |
|
| 143 |
-
const
|
| 144 |
-
scrub(e)
|
| 145 |
-
const move =
|
| 146 |
-
const up = () => {
|
| 147 |
-
window.removeEventListener('pointermove', move)
|
| 148 |
-
window.removeEventListener('pointerup', up)
|
| 149 |
-
}
|
| 150 |
window.addEventListener('pointermove', move)
|
| 151 |
-
window.addEventListener('pointerup',
|
| 152 |
}
|
| 153 |
|
| 154 |
-
const
|
| 155 |
-
const
|
| 156 |
|
| 157 |
-
if (!showTimeline)
|
| 158 |
-
|
| 159 |
-
<button
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
color: '#00f5ff', padding: '4px 16px',
|
| 166 |
-
borderRadius: 8, cursor: 'pointer', fontSize: 11,
|
| 167 |
-
fontFamily: 'Space Mono, monospace'
|
| 168 |
-
}}
|
| 169 |
-
>
|
| 170 |
-
SHOW TIMELINE
|
| 171 |
-
</button>
|
| 172 |
-
)
|
| 173 |
-
}
|
| 174 |
|
| 175 |
return (
|
| 176 |
<div style={{
|
| 177 |
-
position:
|
| 178 |
-
background:
|
| 179 |
-
borderTop:
|
| 180 |
-
zIndex:
|
| 181 |
-
userSelect: 'none',
|
| 182 |
}}>
|
| 183 |
-
{/*
|
| 184 |
<div style={{
|
| 185 |
-
display:
|
| 186 |
-
padding:
|
| 187 |
-
borderBottom:
|
| 188 |
-
background: 'rgba(0,0,20,0.5)',
|
| 189 |
}}>
|
| 190 |
-
{
|
| 191 |
-
<button onClick={() => setCurrentFrame(0)} style={
|
| 192 |
-
<button
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
>
|
| 200 |
-
|
| 201 |
-
<
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
style={
|
| 205 |
-
|
| 206 |
-
<
|
| 207 |
-
|
| 208 |
-
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.15)' }} />
|
| 209 |
-
|
| 210 |
-
{/* Frame counter */}
|
| 211 |
-
<span style={{ color: '#00f5ff', fontSize: 12, fontFamily: 'Space Mono', minWidth: 80 }}>
|
| 212 |
-
{String(currentFrame).padStart(4, '0')} / {totalFrames}
|
| 213 |
-
</span>
|
| 214 |
|
| 215 |
-
<span style={{
|
| 216 |
|
| 217 |
-
<div style={{ width:
|
| 218 |
|
| 219 |
-
{/* Add keyframe button */}
|
| 220 |
{selectedModelId && (
|
| 221 |
<button
|
| 222 |
onClick={() => addKeyframe(currentFrame, selectedModelId)}
|
| 223 |
style={{
|
| 224 |
-
...
|
| 225 |
-
background:
|
| 226 |
-
borderColor:
|
| 227 |
-
color:
|
| 228 |
}}
|
| 229 |
-
|
| 230 |
-
>
|
| 231 |
-
◆ ADD KF
|
| 232 |
-
</button>
|
| 233 |
)}
|
| 234 |
|
| 235 |
-
<div style={{ flex:
|
| 236 |
-
|
|
|
|
|
|
|
| 237 |
</div>
|
| 238 |
|
| 239 |
-
{/*
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
</div>
|
| 263 |
|
| 264 |
-
{/* Scrollable
|
| 265 |
-
<div style={{ flex:
|
| 266 |
-
{/*
|
| 267 |
-
<div
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
onPointerDown={
|
|
|
|
| 271 |
>
|
| 272 |
-
{Array.from({ length: Math.ceil(totalFrames
|
| 273 |
-
const f = i
|
| 274 |
-
const x = (f / totalFrames) * timelineWidth
|
| 275 |
return (
|
| 276 |
-
<div key={f} style={{
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
borderLeft: '1px solid rgba(255,255,255,0.1)',
|
| 280 |
-
paddingLeft: 2,
|
| 281 |
-
}}>
|
| 282 |
-
<span style={{ fontSize: 8, color: '#444', lineHeight: '20px' }}>{f}</span>
|
| 283 |
</div>
|
| 284 |
)
|
| 285 |
})}
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
boxShadow: '0 0 8px #00f5ff',
|
| 292 |
-
pointerEvents: 'none', zIndex: 20,
|
| 293 |
-
}} />
|
| 294 |
</div>
|
| 295 |
|
| 296 |
{/* Tracks */}
|
| 297 |
-
<div
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
</div>
|
| 312 |
</div>
|
| 313 |
</div>
|
|
@@ -315,13 +246,9 @@ export default function Timeline() {
|
|
| 315 |
)
|
| 316 |
}
|
| 317 |
|
| 318 |
-
const
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
color: '
|
| 322 |
-
|
| 323 |
-
padding: '3px 8px',
|
| 324 |
-
cursor: 'pointer',
|
| 325 |
-
fontSize: 12,
|
| 326 |
-
fontFamily: 'Space Mono, monospace',
|
| 327 |
}
|
|
|
|
| 1 |
+
import { useRef, useState, useCallback, useEffect } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
+
const TRACK_H = 28
|
| 5 |
+
const COLORS = ['#4f8eff','#ef4444','#22c55e','#f59e0b','#8b5cf6','#f97316']
|
| 6 |
|
| 7 |
+
function KeyframeDot({ frame, modelId, color, trackW, totalFrames }) {
|
| 8 |
+
const { moveKeyframe, removeKeyframe } = useStore.getState()
|
| 9 |
+
const x = (frame / totalFrames) * trackW
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
const onPointerDown = (e) => {
|
|
|
|
|
|
|
| 12 |
e.stopPropagation()
|
| 13 |
+
const startX = e.clientX, startF = frame
|
| 14 |
+
const move = me => {
|
| 15 |
+
const dx = me.clientX - startX
|
| 16 |
+
const newF = Math.max(0, Math.min(totalFrames-1, Math.round(startF + (dx/trackW)*totalFrames)))
|
| 17 |
+
if (newF !== frame) moveKeyframe(frame, newF, modelId)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
| 20 |
+
window.addEventListener('pointermove', move)
|
| 21 |
+
window.addEventListener('pointerup', up)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
return (
|
| 25 |
<div
|
| 26 |
+
onPointerDown={onPointerDown}
|
| 27 |
+
onDoubleClick={e => { e.stopPropagation(); removeKeyframe(frame, modelId) }}
|
| 28 |
+
title={`Frame ${frame} — drag to move, dbl-click to delete`}
|
| 29 |
style={{
|
| 30 |
+
position:'absolute', left: x - 5, top:'50%', transform:'translateY(-50%)',
|
| 31 |
+
width:10, height:10, borderRadius:2,
|
| 32 |
+
background: color, cursor:'ew-resize', zIndex:10,
|
| 33 |
+
boxShadow:`0 0 6px ${color}88`,
|
| 34 |
+
border:'1px solid rgba(255,255,255,0.3)',
|
| 35 |
+
rotate:'45deg',
|
| 36 |
+
transition:'transform 0.1s',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}}
|
|
|
|
|
|
|
|
|
|
| 38 |
/>
|
| 39 |
)
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
export default function Timeline() {
|
| 43 |
const {
|
| 44 |
+
models, keyframes, currentFrame, totalFrames, fps, isPlaying,
|
| 45 |
+
setCurrentFrame, setIsPlaying, addKeyframe, selectedModelId,
|
| 46 |
+
showTimeline, setShowTimeline, setTotalFrames, setFps,
|
|
|
|
|
|
|
| 47 |
} = useStore()
|
| 48 |
|
| 49 |
+
const rulerRef = useRef()
|
| 50 |
+
const [trackW, setTrackW] = useState(600)
|
| 51 |
+
const [settings, setSettings] = useState(false)
|
| 52 |
|
|
|
|
| 53 |
const measuredRef = useCallback(node => {
|
| 54 |
if (!node) return
|
| 55 |
+
const ro = new ResizeObserver(e => setTrackW(e[0].contentRect.width))
|
|
|
|
|
|
|
| 56 |
ro.observe(node)
|
| 57 |
return () => ro.disconnect()
|
| 58 |
}, [])
|
| 59 |
|
| 60 |
+
const scrub = useCallback((clientX) => {
|
| 61 |
+
const rect = rulerRef.current?.getBoundingClientRect()
|
| 62 |
+
if (!rect) return
|
| 63 |
+
const x = clientX - rect.left
|
| 64 |
+
setCurrentFrame(Math.round(Math.max(0, Math.min(totalFrames-1, (x/trackW)*totalFrames))))
|
| 65 |
+
}, [trackW, totalFrames])
|
|
|
|
| 66 |
|
| 67 |
+
const handlePointerDown = (e) => {
|
| 68 |
+
scrub(e.touches ? e.touches[0].clientX : e.clientX)
|
| 69 |
+
const move = me => scrub(me.touches ? me.touches[0].clientX : me.clientX)
|
| 70 |
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
|
|
|
|
|
|
|
|
|
| 71 |
window.addEventListener('pointermove', move)
|
| 72 |
+
window.addEventListener('pointerup', up)
|
| 73 |
}
|
| 74 |
|
| 75 |
+
const playheadX = (currentFrame / totalFrames) * trackW
|
| 76 |
+
const duration = (totalFrames / fps).toFixed(1)
|
| 77 |
|
| 78 |
+
if (!showTimeline) return (
|
| 79 |
+
<div style={{ position:'absolute', bottom:8, left:'50%', transform:'translateX(-50%)', zIndex:200 }}>
|
| 80 |
+
<button onClick={() => setShowTimeline(true)} style={{
|
| 81 |
+
padding:'5px 16px', borderRadius:20, border:'1px solid var(--border-hi)',
|
| 82 |
+
background:'var(--bg2)', color:'var(--text1)', fontSize:11, cursor:'pointer',
|
| 83 |
+
}}>Show Timeline</button>
|
| 84 |
+
</div>
|
| 85 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
return (
|
| 88 |
<div style={{
|
| 89 |
+
position:'absolute', bottom:0, left:0, right:0,
|
| 90 |
+
background:'var(--bg1)',
|
| 91 |
+
borderTop:'1px solid var(--border)',
|
| 92 |
+
zIndex:200, userSelect:'none',
|
|
|
|
| 93 |
}}>
|
| 94 |
+
{/* Transport bar */}
|
| 95 |
<div style={{
|
| 96 |
+
display:'flex', alignItems:'center', gap:6,
|
| 97 |
+
padding:'5px 10px',
|
| 98 |
+
borderBottom:'1px solid var(--border)',
|
|
|
|
| 99 |
}}>
|
| 100 |
+
<button onClick={() => setCurrentFrame(0)} style={tbtn} title="First frame">⏮</button>
|
| 101 |
+
<button onClick={() => setCurrentFrame(Math.max(0,currentFrame-1))} style={tbtn}>◀</button>
|
| 102 |
+
<button onClick={() => setIsPlaying(!isPlaying)} style={{
|
| 103 |
+
...tbtn,
|
| 104 |
+
background: isPlaying ? 'var(--danger)' : 'var(--accent)',
|
| 105 |
+
color:'#fff', minWidth:32,
|
| 106 |
+
boxShadow: isPlaying ? '0 0 8px rgba(239,68,68,0.4)' : '0 0 8px rgba(79,142,255,0.4)',
|
| 107 |
+
}}>{isPlaying ? '⏸' : '▶'}</button>
|
| 108 |
+
<button onClick={() => setCurrentFrame(Math.min(totalFrames-1,currentFrame+1))} style={tbtn}>▶</button>
|
| 109 |
+
<button onClick={() => setCurrentFrame(totalFrames-1)} style={tbtn}>⏭</button>
|
| 110 |
+
|
| 111 |
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--text1)',
|
| 112 |
+
background:'var(--bg3)', padding:'3px 8px', borderRadius:'var(--radius-sm)',
|
| 113 |
+
border:'1px solid var(--border)', flexShrink:0 }}>
|
| 114 |
+
<span style={{ color:'var(--text0)', fontWeight:600 }}>{String(currentFrame).padStart(4,'0')}</span>
|
| 115 |
+
<span style={{ color:'var(--text3)' }}>/{totalFrames}</span>
|
| 116 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
<span style={{ fontSize:10, color:'var(--text3)' }}>{duration}s</span>
|
| 119 |
|
| 120 |
+
<div style={{ width:1, height:16, background:'var(--border)', margin:'0 2px' }} />
|
| 121 |
|
|
|
|
| 122 |
{selectedModelId && (
|
| 123 |
<button
|
| 124 |
onClick={() => addKeyframe(currentFrame, selectedModelId)}
|
| 125 |
style={{
|
| 126 |
+
...tbtn,
|
| 127 |
+
background:'rgba(245,158,11,0.12)',
|
| 128 |
+
borderColor:'rgba(245,158,11,0.3)',
|
| 129 |
+
color:'var(--warn)', fontWeight:600,
|
| 130 |
}}
|
| 131 |
+
>◆ Key</button>
|
|
|
|
|
|
|
|
|
|
| 132 |
)}
|
| 133 |
|
| 134 |
+
<div style={{ flex:1 }} />
|
| 135 |
+
|
| 136 |
+
<button onClick={() => setSettings(!settings)} style={{ ...tbtn, opacity: settings ? 1 : 0.5 }} title="Timeline settings">⚙</button>
|
| 137 |
+
<button onClick={() => setShowTimeline(false)} style={tbtn} title="Hide timeline">✕</button>
|
| 138 |
</div>
|
| 139 |
|
| 140 |
+
{/* Settings row */}
|
| 141 |
+
{settings && (
|
| 142 |
+
<div style={{
|
| 143 |
+
display:'flex', gap:12, padding:'6px 12px',
|
| 144 |
+
borderBottom:'1px solid var(--border)',
|
| 145 |
+
background:'var(--bg2)', fontSize:11, color:'var(--text1)',
|
| 146 |
+
alignItems:'center',
|
| 147 |
+
}}>
|
| 148 |
+
<span>Frames:</span>
|
| 149 |
+
{[120,200,300,500].map(f => (
|
| 150 |
+
<button key={f} onClick={() => setTotalFrames(f)} style={{
|
| 151 |
+
...tbtn, padding:'2px 8px', fontSize:10,
|
| 152 |
+
background: totalFrames===f ? 'rgba(79,142,255,0.15)' : 'var(--bg3)',
|
| 153 |
+
borderColor: totalFrames===f ? 'rgba(79,142,255,0.4)' : 'var(--border)',
|
| 154 |
+
color: totalFrames===f ? 'var(--accent)' : 'var(--text1)',
|
| 155 |
+
}}>{f}</button>
|
| 156 |
+
))}
|
| 157 |
+
<span style={{ marginLeft:8 }}>FPS:</span>
|
| 158 |
+
{[24,30,60].map(f => (
|
| 159 |
+
<button key={f} onClick={() => setFps(f)} style={{
|
| 160 |
+
...tbtn, padding:'2px 8px', fontSize:10,
|
| 161 |
+
background: fps===f ? 'rgba(79,142,255,0.15)' : 'var(--bg3)',
|
| 162 |
+
borderColor: fps===f ? 'rgba(79,142,255,0.4)' : 'var(--border)',
|
| 163 |
+
color: fps===f ? 'var(--accent)' : 'var(--text1)',
|
| 164 |
+
}}>{f}</button>
|
| 165 |
+
))}
|
| 166 |
+
</div>
|
| 167 |
+
)}
|
| 168 |
+
|
| 169 |
+
{/* Track area */}
|
| 170 |
+
<div style={{ display:'flex', maxHeight:160, overflow:'hidden' }}>
|
| 171 |
+
{/* Labels */}
|
| 172 |
+
<div style={{ width:100, flexShrink:0, borderRight:'1px solid var(--border)' }}>
|
| 173 |
+
<div style={{ height:20, display:'flex', alignItems:'center', padding:'0 8px',
|
| 174 |
+
borderBottom:'1px solid var(--border)', fontSize:9, color:'var(--text3)', fontWeight:600, letterSpacing:'0.1em' }}>
|
| 175 |
+
LAYERS
|
| 176 |
+
</div>
|
| 177 |
+
{models.map((m,i) => (
|
| 178 |
+
<div key={m.id} style={{
|
| 179 |
+
height:TRACK_H, display:'flex', alignItems:'center',
|
| 180 |
+
padding:'0 8px', gap:6, fontSize:11,
|
| 181 |
+
color: COLORS[i%COLORS.length],
|
| 182 |
+
borderBottom:'1px solid var(--border)',
|
| 183 |
+
overflow:'hidden',
|
| 184 |
+
}}>
|
| 185 |
+
<div style={{ width:6, height:6, borderRadius:1, background:COLORS[i%COLORS.length], flexShrink:0, rotate:'45deg' }} />
|
| 186 |
+
<span style={{ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontSize:10 }}>
|
| 187 |
+
{m.name.substring(0,10)}
|
| 188 |
+
</span>
|
| 189 |
+
</div>
|
| 190 |
+
))}
|
| 191 |
</div>
|
| 192 |
|
| 193 |
+
{/* Scrollable ruler + tracks */}
|
| 194 |
+
<div style={{ flex:1, overflow:'auto hidden' }}>
|
| 195 |
+
{/* Ruler */}
|
| 196 |
+
<div ref={el => { measuredRef(el); rulerRef.current = el }}
|
| 197 |
+
style={{ position:'relative', height:20, borderBottom:'1px solid var(--border)',
|
| 198 |
+
cursor:'crosshair', background:'var(--bg2)' }}
|
| 199 |
+
onPointerDown={handlePointerDown}
|
| 200 |
+
onTouchStart={e => handlePointerDown(e)}
|
| 201 |
>
|
| 202 |
+
{Array.from({ length: Math.ceil(totalFrames/10) }, (_,i) => {
|
| 203 |
+
const f = i*10, x = (f/totalFrames)*trackW
|
|
|
|
| 204 |
return (
|
| 205 |
+
<div key={f} style={{ position:'absolute', left:x, top:0, bottom:0,
|
| 206 |
+
borderLeft:`1px solid ${f%50===0 ? 'var(--border-hi)' : 'var(--border)'}` }}>
|
| 207 |
+
{f%10===0 && <span style={{ fontSize:8, color:'var(--text3)', paddingLeft:2, lineHeight:'20px', pointerEvents:'none' }}>{f}</span>}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
</div>
|
| 209 |
)
|
| 210 |
})}
|
| 211 |
+
<div style={{ position:'absolute', left:playheadX, top:0, bottom:0, width:2,
|
| 212 |
+
background:'var(--accent)', boxShadow:'0 0 6px rgba(79,142,255,0.6)', pointerEvents:'none', zIndex:20 }}>
|
| 213 |
+
<div style={{ position:'absolute', top:-1, left:-3, width:8, height:8,
|
| 214 |
+
background:'var(--accent)', borderRadius:'0 0 3px 3px', clipPath:'polygon(50% 0,100% 100%,0 100%)' }} />
|
| 215 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 216 |
</div>
|
| 217 |
|
| 218 |
{/* Tracks */}
|
| 219 |
+
<div style={{ position:'relative' }}>
|
| 220 |
+
{models.map((m,i) => {
|
| 221 |
+
const c = COLORS[i%COLORS.length]
|
| 222 |
+
const mKfs = Object.entries(keyframes).filter(([,kf])=>kf[m.id]).map(([f])=>parseInt(f))
|
| 223 |
+
return (
|
| 224 |
+
<div key={m.id} style={{
|
| 225 |
+
position:'relative', height:TRACK_H,
|
| 226 |
+
background: m.id===selectedModelId ? `${c}08` : 'transparent',
|
| 227 |
+
borderBottom:'1px solid var(--border)',
|
| 228 |
+
}}>
|
| 229 |
+
{/* Track line */}
|
| 230 |
+
<div style={{ position:'absolute', top:'50%', left:0, right:0,
|
| 231 |
+
height:1, background:'var(--border-hi)', opacity:0.5 }} />
|
| 232 |
+
{mKfs.map(f => (
|
| 233 |
+
<KeyframeDot key={f} frame={f} modelId={m.id}
|
| 234 |
+
color={c} trackW={trackW} totalFrames={totalFrames} />
|
| 235 |
+
))}
|
| 236 |
+
</div>
|
| 237 |
+
)
|
| 238 |
+
})}
|
| 239 |
+
{/* Playhead */}
|
| 240 |
+
<div style={{ position:'absolute', left:playheadX, top:0, bottom:0,
|
| 241 |
+
width:1, background:'rgba(79,142,255,0.35)', pointerEvents:'none', zIndex:5 }} />
|
| 242 |
</div>
|
| 243 |
</div>
|
| 244 |
</div>
|
|
|
|
| 246 |
)
|
| 247 |
}
|
| 248 |
|
| 249 |
+
const tbtn = {
|
| 250 |
+
padding:'4px 8px', borderRadius:'var(--radius-sm)',
|
| 251 |
+
background:'var(--bg3)', border:'1px solid var(--border)',
|
| 252 |
+
color:'var(--text1)', fontSize:12, cursor:'pointer', flexShrink:0,
|
| 253 |
+
transition:'all 0.12s',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
|
@@ -1,129 +1,189 @@
|
|
|
|
|
| 1 |
import useStore from '../store/useStore'
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
export default function Toolbar() {
|
| 4 |
const {
|
| 5 |
transformMode, setTransformMode,
|
| 6 |
lightingPreset, setLightingPreset,
|
| 7 |
-
selectedModelId, addKeyframe, currentFrame,
|
| 8 |
isPlaying, setIsPlaying,
|
|
|
|
|
|
|
| 9 |
} = useStore()
|
| 10 |
|
| 11 |
-
const
|
| 12 |
-
{ id: 'translate', icon: '
|
| 13 |
-
{ id: 'rotate',
|
| 14 |
-
{ id: 'scale',
|
| 15 |
]
|
| 16 |
|
| 17 |
const lights = [
|
| 18 |
-
{ id: 'studio',
|
| 19 |
-
{ id: 'outdoor',
|
| 20 |
-
{ id: 'dramatic', icon: '
|
| 21 |
-
{ id: 'neon',
|
| 22 |
]
|
| 23 |
|
| 24 |
return (
|
| 25 |
-
<div style={
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
{
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
marginRight: 6,
|
| 44 |
-
}}>
|
| 45 |
-
GLB<span style={{ color: '#ff4080' }}>STUDIO</span>
|
| 46 |
</div>
|
| 47 |
|
| 48 |
-
<div style={
|
| 49 |
|
| 50 |
-
{/*
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
<button
|
| 53 |
-
|
| 54 |
-
onClick={() => setTransformMode(m.id)}
|
| 55 |
-
title={m.label}
|
| 56 |
style={{
|
| 57 |
-
|
| 58 |
-
background:
|
| 59 |
-
border:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
lineHeight: 1,
|
| 64 |
transition: 'all 0.15s',
|
| 65 |
-
minWidth: 36,
|
| 66 |
-
textAlign: 'center',
|
| 67 |
}}
|
| 68 |
-
>
|
| 69 |
-
|
| 70 |
-
</
|
| 71 |
-
))}
|
| 72 |
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
{
|
| 76 |
-
<button
|
| 77 |
-
onClick={() => setIsPlaying(!isPlaying)}
|
| 78 |
-
style={{
|
| 79 |
-
padding: '5px 12px',
|
| 80 |
-
background: isPlaying ? 'rgba(255,64,96,0.15)' : 'rgba(64,255,128,0.12)',
|
| 81 |
-
border: `1px solid ${isPlaying ? '#ff4060' : '#40ff80'}`,
|
| 82 |
-
color: isPlaying ? '#ff4060' : '#40ff80',
|
| 83 |
-
borderRadius: 5, cursor: 'pointer',
|
| 84 |
-
fontSize: 13,
|
| 85 |
-
}}
|
| 86 |
-
>
|
| 87 |
-
{isPlaying ? '⏸' : '▶'}
|
| 88 |
-
</button>
|
| 89 |
-
|
| 90 |
-
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.12)' }} />
|
| 91 |
|
| 92 |
{/* Lighting */}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
key={l.id}
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
))}
|
| 109 |
|
| 110 |
<div style={{ flex: 1 }} />
|
| 111 |
|
| 112 |
-
{/*
|
| 113 |
{selectedModelId && (
|
| 114 |
<button
|
| 115 |
onClick={() => addKeyframe(currentFrame, selectedModelId)}
|
| 116 |
style={{
|
| 117 |
-
padding: '5px 12px',
|
| 118 |
-
background: 'rgba(
|
| 119 |
-
border: '1px solid rgba(
|
| 120 |
-
color: '
|
| 121 |
-
cursor: 'pointer',
|
| 122 |
-
fontFamily: 'Space Mono', whiteSpace: 'nowrap',
|
| 123 |
}}
|
| 124 |
-
>
|
| 125 |
-
◆ KEY
|
| 126 |
-
</button>
|
| 127 |
)}
|
| 128 |
</div>
|
| 129 |
)
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
import useStore from '../store/useStore'
|
| 3 |
|
| 4 |
+
const S = {
|
| 5 |
+
bar: {
|
| 6 |
+
position: 'relative', zIndex: 300,
|
| 7 |
+
display: 'flex', alignItems: 'center', gap: 2,
|
| 8 |
+
padding: '0 10px',
|
| 9 |
+
height: 46,
|
| 10 |
+
background: 'var(--bg1)',
|
| 11 |
+
borderBottom: '1px solid var(--border)',
|
| 12 |
+
boxShadow: '0 1px 0 rgba(255,255,255,0.04)',
|
| 13 |
+
overflowX: 'auto', overflowY: 'hidden',
|
| 14 |
+
flexShrink: 0,
|
| 15 |
+
},
|
| 16 |
+
brand: {
|
| 17 |
+
fontFamily: 'var(--font-brand)',
|
| 18 |
+
fontSize: 15, fontWeight: 800,
|
| 19 |
+
letterSpacing: '-0.01em',
|
| 20 |
+
color: 'var(--text0)',
|
| 21 |
+
marginRight: 8,
|
| 22 |
+
whiteSpace: 'nowrap',
|
| 23 |
+
userSelect: 'none',
|
| 24 |
+
},
|
| 25 |
+
div: {
|
| 26 |
+
width: 1, height: 20,
|
| 27 |
+
background: 'var(--border)',
|
| 28 |
+
margin: '0 6px', flexShrink: 0,
|
| 29 |
+
},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function ToolBtn({ icon, label, active, onClick, color, shortcut }) {
|
| 33 |
+
const [hover, setHover] = useState(false)
|
| 34 |
+
return (
|
| 35 |
+
<button
|
| 36 |
+
onClick={onClick}
|
| 37 |
+
onMouseEnter={() => setHover(true)}
|
| 38 |
+
onMouseLeave={() => setHover(false)}
|
| 39 |
+
title={shortcut ? `${label} [${shortcut}]` : label}
|
| 40 |
+
style={{
|
| 41 |
+
display: 'flex', alignItems: 'center', gap: 5,
|
| 42 |
+
padding: '5px 10px',
|
| 43 |
+
borderRadius: 'var(--radius-sm)',
|
| 44 |
+
border: active
|
| 45 |
+
? `1px solid ${color || 'var(--accent)'}44`
|
| 46 |
+
: `1px solid ${hover ? 'var(--border-hi)' : 'transparent'}`,
|
| 47 |
+
background: active
|
| 48 |
+
? `${color || 'var(--accent)'}18`
|
| 49 |
+
: hover ? 'var(--bg3)' : 'transparent',
|
| 50 |
+
color: active ? (color || 'var(--accent)') : hover ? 'var(--text0)' : 'var(--text1)',
|
| 51 |
+
fontSize: 12, fontWeight: 500,
|
| 52 |
+
transition: 'all 0.12s',
|
| 53 |
+
whiteSpace: 'nowrap', flexShrink: 0,
|
| 54 |
+
}}
|
| 55 |
+
>
|
| 56 |
+
<span style={{ fontSize: 14, lineHeight: 1 }}>{icon}</span>
|
| 57 |
+
<span style={{ fontSize: 11 }}>{label}</span>
|
| 58 |
+
</button>
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function IconBtn({ icon, active, onClick, title, color }) {
|
| 63 |
+
const [h, setH] = useState(false)
|
| 64 |
+
return (
|
| 65 |
+
<button onClick={onClick} title={title}
|
| 66 |
+
onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
|
| 67 |
+
style={{
|
| 68 |
+
width: 32, height: 32, borderRadius: 'var(--radius-sm)',
|
| 69 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 70 |
+
border: active ? `1px solid ${color||'var(--accent)'}44` : `1px solid ${h?'var(--border-hi)':'transparent'}`,
|
| 71 |
+
background: active ? `${color||'var(--accent)'}18` : h ? 'var(--bg3)' : 'transparent',
|
| 72 |
+
color: active ? (color||'var(--accent)') : h ? 'var(--text0)' : 'var(--text1)',
|
| 73 |
+
fontSize: 15, transition: 'all 0.12s', flexShrink: 0,
|
| 74 |
+
}}
|
| 75 |
+
>{icon}</button>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
export default function Toolbar() {
|
| 80 |
const {
|
| 81 |
transformMode, setTransformMode,
|
| 82 |
lightingPreset, setLightingPreset,
|
|
|
|
| 83 |
isPlaying, setIsPlaying,
|
| 84 |
+
selectedModelId, addKeyframe, currentFrame,
|
| 85 |
+
currentFrame: cf, setCurrentFrame, totalFrames,
|
| 86 |
} = useStore()
|
| 87 |
|
| 88 |
+
const tools = [
|
| 89 |
+
{ id: 'translate', icon: '⊹', label: 'Move', short: 'G' },
|
| 90 |
+
{ id: 'rotate', icon: '↻', label: 'Rotate', short: 'R' },
|
| 91 |
+
{ id: 'scale', icon: '⤡', label: 'Scale', short: 'S' },
|
| 92 |
]
|
| 93 |
|
| 94 |
const lights = [
|
| 95 |
+
{ id: 'studio', icon: '◎', label: 'Studio' },
|
| 96 |
+
{ id: 'outdoor', icon: '◉', label: 'Outdoor' },
|
| 97 |
+
{ id: 'dramatic', icon: '◈', label: 'Dramatic' },
|
| 98 |
+
{ id: 'neon', icon: '◆', label: 'Neon' },
|
| 99 |
]
|
| 100 |
|
| 101 |
return (
|
| 102 |
+
<div style={S.bar}>
|
| 103 |
+
{/* Brand */}
|
| 104 |
+
<div style={S.brand}>
|
| 105 |
+
<span style={{ color: 'var(--accent)' }}>GLB</span>
|
| 106 |
+
<span style={{ color: 'var(--text1)', fontWeight: 400 }}>Studio</span>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div style={S.div} />
|
| 110 |
+
|
| 111 |
+
{/* Transform tools */}
|
| 112 |
+
<div style={{ display:'flex', gap:2, flexShrink:0 }}>
|
| 113 |
+
{tools.map(t => (
|
| 114 |
+
<ToolBtn key={t.id}
|
| 115 |
+
icon={t.icon} label={t.label} shortcut={t.short}
|
| 116 |
+
active={transformMode === t.id}
|
| 117 |
+
onClick={() => setTransformMode(t.id)}
|
| 118 |
+
/>
|
| 119 |
+
))}
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
|
| 122 |
+
<div style={S.div} />
|
| 123 |
|
| 124 |
+
{/* Playback */}
|
| 125 |
+
<div style={{ display:'flex', gap:2, alignItems:'center', flexShrink:0 }}>
|
| 126 |
+
<IconBtn icon="⏮" title="First frame" onClick={() => setCurrentFrame(0)} />
|
| 127 |
+
<IconBtn icon="◀" title="Prev frame" onClick={() => setCurrentFrame(Math.max(0, cf-1))} />
|
| 128 |
<button
|
| 129 |
+
onClick={() => setIsPlaying(!isPlaying)}
|
|
|
|
|
|
|
| 130 |
style={{
|
| 131 |
+
width: 34, height: 34, borderRadius: 'var(--radius-sm)',
|
| 132 |
+
background: isPlaying ? 'var(--danger)' : 'var(--accent)',
|
| 133 |
+
border: 'none', color: '#fff', fontSize: 14,
|
| 134 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
| 135 |
+
flexShrink: 0, cursor: 'pointer',
|
| 136 |
+
boxShadow: isPlaying ? '0 0 12px rgba(239,68,68,0.4)' : '0 0 12px rgba(79,142,255,0.4)',
|
|
|
|
| 137 |
transition: 'all 0.15s',
|
|
|
|
|
|
|
| 138 |
}}
|
| 139 |
+
>{isPlaying ? '⏸' : '▶'}</button>
|
| 140 |
+
<IconBtn icon="▶" title="Next frame" onClick={() => setCurrentFrame(Math.min(totalFrames-1,cf+1))} />
|
| 141 |
+
<IconBtn icon="⏭" title="Last frame" onClick={() => setCurrentFrame(totalFrames-1)} />
|
|
|
|
| 142 |
|
| 143 |
+
<div style={{
|
| 144 |
+
fontFamily: 'var(--font-mono)', fontSize: 11,
|
| 145 |
+
color: 'var(--text1)', marginLeft: 6, flexShrink: 0,
|
| 146 |
+
background: 'var(--bg3)', padding: '4px 8px',
|
| 147 |
+
borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
|
| 148 |
+
}}>
|
| 149 |
+
<span style={{ color: 'var(--text0)' }}>{String(cf).padStart(4,'0')}</span>
|
| 150 |
+
<span style={{ color: 'var(--text3)' }}>/{totalFrames}</span>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
|
| 154 |
+
<div style={S.div} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
{/* Lighting */}
|
| 157 |
+
<div style={{ display:'flex', gap:2, flexShrink:0 }}>
|
| 158 |
+
{lights.map(l => (
|
| 159 |
+
<button key={l.id}
|
| 160 |
+
onClick={() => setLightingPreset(l.id)}
|
| 161 |
+
title={l.label}
|
| 162 |
+
style={{
|
| 163 |
+
padding: '4px 7px', borderRadius: 'var(--radius-sm)',
|
| 164 |
+
background: lightingPreset===l.id ? 'var(--bg4)' : 'transparent',
|
| 165 |
+
border: `1px solid ${lightingPreset===l.id ? 'var(--border-hi)' : 'transparent'}`,
|
| 166 |
+
color: lightingPreset===l.id ? 'var(--warn)' : 'var(--text2)',
|
| 167 |
+
fontSize: 13, cursor:'pointer', transition:'all 0.12s',
|
| 168 |
+
}}
|
| 169 |
+
>{l.icon}</button>
|
| 170 |
+
))}
|
| 171 |
+
</div>
|
|
|
|
| 172 |
|
| 173 |
<div style={{ flex: 1 }} />
|
| 174 |
|
| 175 |
+
{/* Keyframe shortcut */}
|
| 176 |
{selectedModelId && (
|
| 177 |
<button
|
| 178 |
onClick={() => addKeyframe(currentFrame, selectedModelId)}
|
| 179 |
style={{
|
| 180 |
+
padding: '5px 12px', borderRadius: 'var(--radius-sm)',
|
| 181 |
+
background: 'rgba(245,158,11,0.12)',
|
| 182 |
+
border: '1px solid rgba(245,158,11,0.3)',
|
| 183 |
+
color: 'var(--warn)', fontSize: 11, fontWeight: 600,
|
| 184 |
+
cursor: 'pointer', flexShrink: 0, transition: 'all 0.12s',
|
|
|
|
| 185 |
}}
|
| 186 |
+
>◆ Keyframe</button>
|
|
|
|
|
|
|
| 187 |
)}
|
| 188 |
</div>
|
| 189 |
)
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@700;800&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg0: #0c0c10;
|
| 5 |
+
--bg1: #111116;
|
| 6 |
+
--bg2: #17171e;
|
| 7 |
+
--bg3: #1e1e28;
|
| 8 |
+
--bg4: #252532;
|
| 9 |
+
--border: rgba(255,255,255,0.07);
|
| 10 |
+
--border-hi: rgba(255,255,255,0.14);
|
| 11 |
+
--accent: #4f8eff;
|
| 12 |
+
--accent2: #7c3aed;
|
| 13 |
+
--accent3: #06d6a0;
|
| 14 |
+
--warn: #f59e0b;
|
| 15 |
+
--danger: #ef4444;
|
| 16 |
+
--text0: #f0f0f8;
|
| 17 |
+
--text1: #a0a0b8;
|
| 18 |
+
--text2: #606078;
|
| 19 |
+
--text3: #383848;
|
| 20 |
+
--radius-sm: 4px;
|
| 21 |
+
--radius: 8px;
|
| 22 |
+
--radius-lg: 12px;
|
| 23 |
+
--font-ui: 'Inter', system-ui, sans-serif;
|
| 24 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 25 |
+
--font-brand: 'Syne', sans-serif;
|
| 26 |
+
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
|
| 27 |
+
--shadow: 0 4px 16px rgba(0,0,0,0.5);
|
| 28 |
+
--shadow-lg: 0 8px 32px rgba(0,0,0,0.7);
|
| 29 |
+
--glow: 0 0 20px rgba(79,142,255,0.25);
|
| 30 |
+
--glow-green: 0 0 20px rgba(6,214,160,0.25);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 34 |
+
|
| 35 |
+
html, body, #root {
|
| 36 |
+
width: 100%; height: 100%;
|
| 37 |
+
overflow: hidden;
|
| 38 |
+
background: var(--bg0);
|
| 39 |
+
color: var(--text0);
|
| 40 |
+
font-family: var(--font-ui);
|
| 41 |
+
font-size: 13px;
|
| 42 |
+
-webkit-font-smoothing: antialiased;
|
| 43 |
+
-moz-osx-font-smoothing: grayscale;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* ── Scrollbar ─────────────────────────────────────────────────────── */
|
| 47 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 48 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 49 |
+
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 2px; }
|
| 50 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text3); }
|
| 51 |
+
|
| 52 |
+
/* ── Global inputs ─────────────────────────────────────────────────── */
|
| 53 |
+
input[type=range] {
|
| 54 |
+
-webkit-appearance: none;
|
| 55 |
+
height: 3px;
|
| 56 |
+
background: var(--bg4);
|
| 57 |
+
border-radius: 2px;
|
| 58 |
+
cursor: pointer;
|
| 59 |
+
width: 100%;
|
| 60 |
+
}
|
| 61 |
+
input[type=range]::-webkit-slider-thumb {
|
| 62 |
+
-webkit-appearance: none;
|
| 63 |
+
width: 13px; height: 13px;
|
| 64 |
+
background: var(--accent);
|
| 65 |
+
border-radius: 50%;
|
| 66 |
+
box-shadow: 0 0 0 3px rgba(79,142,255,0.2);
|
| 67 |
+
transition: transform 0.15s;
|
| 68 |
+
}
|
| 69 |
+
input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); }
|
| 70 |
+
input[type=range]:focus { outline: none; }
|
| 71 |
+
|
| 72 |
+
input[type=number], input[type=text], select, textarea {
|
| 73 |
+
background: var(--bg1);
|
| 74 |
+
border: 1px solid var(--border);
|
| 75 |
+
border-radius: var(--radius-sm);
|
| 76 |
+
color: var(--text0);
|
| 77 |
+
font-family: var(--font-mono);
|
| 78 |
+
font-size: 11px;
|
| 79 |
+
padding: 5px 7px;
|
| 80 |
+
outline: none;
|
| 81 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 82 |
+
width: 100%;
|
| 83 |
+
}
|
| 84 |
+
input[type=number]:focus,
|
| 85 |
+
input[type=text]:focus,
|
| 86 |
+
select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79,142,255,0.15); }
|
| 87 |
+
|
| 88 |
+
button { font-family: var(--font-ui); cursor: pointer; outline: none; border: none; }
|
| 89 |
+
|
| 90 |
+
* { -webkit-tap-highlight-color: transparent; }
|
| 91 |
+
|
| 92 |
+
/* ── Animations ────────────────────────────────────────────────────── */
|
| 93 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 94 |
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
| 95 |
+
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
|
| 96 |
+
@keyframes shimmer {
|
| 97 |
+
0% { background-position: -200% 0; }
|
| 98 |
+
100% { background-position: 200% 0; }
|
| 99 |
+
}
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import React from 'react'
|
| 2 |
import ReactDOM from 'react-dom/client'
|
|
|
|
| 3 |
import App from './App'
|
| 4 |
|
| 5 |
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
|
|
| 1 |
import React from 'react'
|
| 2 |
import ReactDOM from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
import App from './App'
|
| 5 |
|
| 6 |
ReactDOM.createRoot(document.getElementById('root')).render(
|