glb-studio / src /components /Toolbar.jsx
varunm2004's picture
deploy: 2-stage Dockerfile, npm build on HF
a049ed7 verified
import { useState, useEffect, useRef } from 'react'
import useStore from '../store/useStore'
function Btn({ icon, label, active, onClick, color, title, shortcut, danger }) {
const [h, setH] = useState(false)
const acc = color || (active ? 'var(--accent)' : null)
return (
<button onClick={onClick} title={`${title||label}${shortcut?' ['+shortcut+']':''}`}
onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
style={{
display:'flex', alignItems:'center', gap:5,
padding:'4px 9px', borderRadius:'var(--radius-sm)', cursor:'pointer',
border:`1px solid ${active?`${acc}44`:h?'var(--border-hi)':'transparent'}`,
background: active?`${acc}18`:h?'var(--bg3)':'transparent',
color: danger&&h?'var(--danger)':active?acc:h?'var(--text0)':'var(--text1)',
fontSize:12, fontWeight: active?600:400,
transition:'all 0.1s', whiteSpace:'nowrap', flexShrink:0,
}}>
<span style={{fontSize:14,lineHeight:1}}>{icon}</span>
{label && <span style={{fontSize:11}}>{label}</span>}
</button>
)
}
function Divider() {
return <div style={{ width:1, height:20, background:'var(--border)', margin:'0 3px', flexShrink:0 }} />
}
export default function Toolbar() {
const {
transformMode, setTransformMode,
snapEnabled, setSnapEnabled,
lightingPreset, setLightingPreset,
isPlaying, setIsPlaying,
loopPlayback, setLoopPlayback,
selectedModelId, addKeyframe, currentFrame,
currentFrame: cf, setCurrentFrame, totalFrames,
undo, redo, undoStack, redoStack,
showGrid, setShowGrid, showGizmo, setShowGizmo,
showCameraObjects, setShowCameraObjects,
projectName, setProjectName, saveProject, loadProject, exportProjectJSON,
duplicateModel, removeModel,
models,
} = useStore()
const [editName, setEditName] = useState(false)
const [nameVal, setNameVal] = useState(projectName)
const importRef = useRef()
// Global keyboard shortcuts
useEffect(() => {
const onKey = (e) => {
// Skip if typing in an input
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return
const k = e.code
if (k==='KeyG' && !e.ctrlKey) { setTransformMode('translate'); return }
if (k==='KeyR' && !e.ctrlKey) { setTransformMode('rotate'); return }
if (k==='KeyS' && !e.ctrlKey) { setTransformMode('scale'); return }
if (k==='Space') { e.preventDefault(); setIsPlaying(!useStore.getState().isPlaying); return }
if (k==='KeyZ' && (e.ctrlKey||e.metaKey) && !e.shiftKey) { e.preventDefault(); undo(); return }
if ((k==='KeyZ' && e.shiftKey && (e.ctrlKey||e.metaKey)) || (k==='KeyY' && (e.ctrlKey||e.metaKey))) { e.preventDefault(); redo(); return }
if (k==='KeyD' && !e.ctrlKey) { e.preventDefault(); const sel=useStore.getState().selectedModelId; if(sel) duplicateModel(sel); return }
if (k==='Delete'||k==='Backspace') { const sel=useStore.getState().selectedModelId; if(sel){ removeModel(sel) }; return }
if (k==='KeyL') { setLoopPlayback(!useStore.getState().loopPlayback); return }
if (k==='Tab') { e.preventDefault(); setSnapEnabled(!useStore.getState().snapEnabled); return }
// Screenshot
if (k==='F12') { e.preventDefault(); takeScreenshot(); return }
// Save
if ((e.ctrlKey||e.metaKey) && k==='KeyS') { e.preventDefault(); const ok=saveProject(); console.log(ok?'Saved':'Save failed'); return }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const takeScreenshot = () => {
const canvas = document.querySelector('canvas')
if (!canvas) return
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href=url; a.download=`screenshot_${Date.now()}.png`; a.click()
}
const handleImport = (e) => {
const file = e.target.files[0]
if (!file) return
useStore.getState().importProjectJSON(file).then(result => {
if (!result?.ok) console.warn('Import failed:', result?.error)
})
e.target.value = ''
}
const tools = [
{ id:'translate', icon:'⊹', label:'Move', short:'G' },
{ id:'rotate', icon:'↻', label:'Rotate', short:'R' },
{ id:'scale', icon:'⤡', label:'Scale', short:'S' },
]
const lights = [
{ id:'studio', icon:'◎' },
{ id:'outdoor', icon:'◉' },
{ id:'dramatic', icon:'◈' },
{ id:'neon', icon:'◆' },
]
const canUndo = undoStack?.length > 0
const canRedo = redoStack?.length > 0
return (
<div style={{
position:'relative', zIndex:300, flexShrink:0,
display:'flex', alignItems:'center', gap:2,
padding:'0 8px', height:44,
background:'var(--bg1)', borderBottom:'1px solid var(--border)',
overflowX:'auto', overflowY:'hidden',
}}>
{/* Brand / Project name */}
<div style={{ display:'flex', alignItems:'center', gap:6, marginRight:4, flexShrink:0 }}>
<span style={{ fontFamily:'var(--font-brand)', fontSize:14, fontWeight:800, color:'var(--accent)', letterSpacing:'-0.01em' }}>GLB</span>
{editName ? (
<input value={nameVal}
onChange={e=>setNameVal(e.target.value)}
onBlur={()=>{ setProjectName(nameVal); setEditName(false) }}
onKeyDown={e=>{ if(e.key==='Enter'||e.key==='Escape'){ setProjectName(nameVal); setEditName(false) }}}
autoFocus
style={{ fontSize:12, fontWeight:600, width:120, padding:'2px 5px' }}
/>
) : (
<span onDoubleClick={()=>{ setNameVal(projectName); setEditName(true) }}
style={{ fontSize:12, fontWeight:600, color:'var(--text1)', cursor:'text',
maxWidth:120, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}
title="Double-click to rename"
>{projectName}</span>
)}
</div>
<Divider />
{/* Project actions */}
<Btn icon="💾" title="Save project" shortcut="Ctrl+S" onClick={()=>{ const ok=saveProject(); if(ok) console.log('Saved') }} />
<Btn icon="📂" title="Load last saved" onClick={()=>loadProject()} />
<Btn icon="⬇" title="Export .glbstudio" onClick={exportProjectJSON} />
<Btn icon="⬆" title="Import .glbstudio" onClick={()=>importRef.current?.click()} />
<input ref={importRef} type="file" accept=".glbstudio,.json" style={{display:'none'}} onChange={handleImport} />
<Divider />
{/* Undo / Redo */}
<Btn icon="↩" title="Undo" shortcut="Ctrl+Z" onClick={undo} color={canUndo?'var(--text0)':null} />
<Btn icon="↪" title="Redo" shortcut="Ctrl+Y" onClick={redo} color={canRedo?'var(--text0)':null} />
<Divider />
{/* Transform */}
{tools.map(t => (
<Btn key={t.id} icon={t.icon} label={t.label} shortcut={t.short}
active={transformMode===t.id} onClick={()=>setTransformMode(t.id)} />
))}
{/* Snap toggle */}
<Btn icon="🧲" title="Grid snap" shortcut="Tab"
active={snapEnabled} onClick={()=>setSnapEnabled(!snapEnabled)}
color="var(--warn)" />
<Divider />
{/* Playback */}
<Btn icon="⏮" title="First frame" onClick={()=>setCurrentFrame(0)} />
<Btn icon="◀" title="Prev frame" onClick={()=>setCurrentFrame(Math.max(0,cf-1))} />
<button onClick={()=>setIsPlaying(!isPlaying)} title={`${isPlaying?'Pause':'Play'} [Space]`}
style={{
width:34,height:34,borderRadius:'var(--radius-sm)',flexShrink:0,
background:isPlaying?'var(--danger)':'var(--accent)',border:'none',color:'#fff',
fontSize:14,cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',
boxShadow:isPlaying?'0 0 12px rgba(239,68,68,0.4)':'0 0 12px rgba(79,142,255,0.4)',
transition:'all 0.15s',
}}>{isPlaying?'⏸':'▶'}</button>
<Btn icon="◀" title="Next frame" onClick={()=>setCurrentFrame(Math.min(totalFrames-1,cf+1))} />
<Btn icon="⏭" title="Last frame" onClick={()=>setCurrentFrame(totalFrames-1)} />
<Btn icon="🔁" title="Loop" shortcut="L" active={loopPlayback} onClick={()=>setLoopPlayback(!loopPlayback)} color="var(--accent3)" />
{/* Frame counter */}
<div style={{ fontFamily:'var(--font-mono)',fontSize:11,color:'var(--text1)',
background:'var(--bg3)',padding:'3px 8px',borderRadius:'var(--radius-sm)',
border:'1px solid var(--border)',flexShrink:0,userSelect:'none' }}>
<span style={{color:'var(--text0)',fontWeight:600}}>{String(cf).padStart(4,'0')}</span>
<span style={{color:'var(--text3)'}}>/{totalFrames}</span>
</div>
<Divider />
{/* Lighting */}
{lights.map(l => (
<button key={l.id} onClick={()=>setLightingPreset(l.id)} title={l.id}
style={{
padding:'3px 7px',borderRadius:'var(--radius-sm)',cursor:'pointer',fontSize:14,
background:lightingPreset===l.id?'var(--bg4)':'transparent',
border:`1px solid ${lightingPreset===l.id?'var(--border-hi)':'transparent'}`,
color:lightingPreset===l.id?'var(--warn)':'var(--text2)',transition:'all 0.1s',
flexShrink:0,
}}>{l.icon}</button>
))}
<div style={{flex:1}}/>
{/* Selected model actions */}
{selectedModelId && <>
<Btn icon="⧉" title="Duplicate [D]" shortcut="D" onClick={()=>duplicateModel(selectedModelId)} />
<Btn icon="◆" title="Add Keyframe" onClick={()=>addKeyframe(currentFrame,selectedModelId)}
color="var(--warn)" active={!!useStore.getState().keyframes[currentFrame]?.[selectedModelId]} />
<Btn icon="🗑" danger title="Delete model [Del]" onClick={()=>{ removeModel(selectedModelId) }} />
</>}
<Divider />
{/* Scene visibility toggles */}
<Btn icon="⊞" title="Toggle grid" active={showGrid} onClick={()=>setShowGrid(!showGrid)} />
<Btn icon="⊕" title="Toggle orientation gizmo" active={showGizmo} onClick={()=>setShowGizmo(!showGizmo)} />
<Btn icon="🎥" title="Toggle camera objects" active={showCameraObjects} onClick={()=>setShowCameraObjects(!showCameraObjects)} />
{/* Screenshot */}
<Btn icon="📷" title="Screenshot [F12]" onClick={takeScreenshot} />
</div>
)
}