A web app to help you design things, local, offline, on device. In your browser.
TypeScript
53.1%
JSON
45.6%
CSS
0.9%
Markdown
0.3%
JavaScript
0.1%
'use client';
import React, { useState, useRef, useEffect } from 'react';
import {
MousePointer2,
Move,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Grid3X3,
Ruler,
Magnet,
Save,
FolderOpen,
ChevronDown,
Maximize2,
} from 'lucide-react';
import { useEditorStore } from '@/lib/store/editor-store';
import { EXPORT_PRESETS, type ExportPreset } from '@/types';
const ZOOM_PRESETS = [
{ label: 'Fit', value: 'fit' },
{ label: '25%', value: 0.25 },
{ label: '50%', value: 0.5 },
{ label: '75%', value: 0.75 },
{ label: '100%', value: 1 },
{ label: '150%', value: 1.5 },
{ label: '200%', value: 2 },
] as const;
const tools = [
{ id: 'select', label: 'Select', icon: MousePointer2, shortcut: 'V' },
{ id: 'move', label: 'Pan', icon: Move, shortcut: 'H' },
] as const;
export function Toolbar() {
const [showZoomDropdown, setShowZoomDropdown] = useState(false);
const zoomDropdownRef = useRef<HTMLDivElement>(null);
const {
activeTool,
setActiveTool,
canvas,
setZoom,
setPreset,
showGrid,
showGuides,
snapToGrid,
toggleGrid,
toggleGuides,
toggleSnap,
undo,
redo,
history,
historyIndex,
} = useEditorStore();
const canUndo = historyIndex > 0;
const canRedo = historyIndex < history.length - 1;
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (zoomDropdownRef.current && !zoomDropdownRef.current.contains(e.target as Node)) {
setShowZoomDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Calculate fit zoom
const calculateFitZoom = () => {
// Estimate canvas container size (rough approximation)
const containerWidth = window.innerWidth - 320 - 256 - 100; // sidebar + properties + padding
const containerHeight = window.innerHeight - 56 - 48 - 100; // header + toolbar + padding
const fitZoom = Math.min(
containerWidth / canvas.width,
containerHeight / canvas.height,
1 // Don't zoom past 100% for fit
);
return Math.max(0.1, fitZoom);
};
const handleZoomPreset = (preset: typeof ZOOM_PRESETS[number]) => {
if (preset.value === 'fit') {
setZoom(calculateFitZoom());
} else {
setZoom(preset.value);
}
setShowZoomDropdown(false);
};
return (
<div className="h-12 bg-neutral-900 border-b border-neutral-800 flex items-center justify-between px-4">
{/* Left section - Tools */}
<div className="flex items-center gap-1">
{/* Tool selection */}
<div className="flex bg-neutral-800 rounded-lg p-0.5">
{tools.map((tool) => {
const Icon = tool.icon;
const isActive = activeTool === tool.id;
return (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
className={`
p-2 rounded-md transition-colors relative group
${isActive ? 'bg-indigo-500 text-white' : 'text-neutral-400 hover:text-white'}
`}
title={`${tool.label} (${tool.shortcut})`}
>
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
<div className="w-px h-6 bg-neutral-700 mx-2" />
{/* Undo/Redo */}
<button
onClick={undo}
disabled={!canUndo}
className={`
p-2 rounded-lg transition-colors
${canUndo ? 'text-neutral-400 hover:text-white hover:bg-neutral-800' : 'text-neutral-600 cursor-not-allowed'}
`}
title="Undo (Cmd+Z)"
>
<Undo2 className="w-4 h-4" />
</button>
<button
onClick={redo}
disabled={!canRedo}
className={`
p-2 rounded-lg transition-colors
${canRedo ? 'text-neutral-400 hover:text-white hover:bg-neutral-800' : 'text-neutral-600 cursor-not-allowed'}
`}
title="Redo (Cmd+Shift+Z)"
>
<Redo2 className="w-4 h-4" />
</button>
</div>
{/* Center section - Preset/Size */}
<div className="flex items-center gap-2">
<div className="relative group">
<button className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 transition-colors">
<span className="text-sm text-white">
{canvas.width} x {canvas.height}
</span>
<ChevronDown className="w-4 h-4 text-neutral-400" />
</button>
{/* Dropdown */}
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-64 bg-neutral-800 rounded-xl border border-neutral-700 shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
<div className="p-2 max-h-80 overflow-y-auto">
{Object.entries(EXPORT_PRESETS).map(([key, config]) => (
<button
key={key}
onClick={() => setPreset(key as ExportPreset)}
className="
w-full flex items-center justify-between p-2 rounded-lg
hover:bg-neutral-700 transition-colors text-left
"
>
<span className="text-sm text-white">{config.name}</span>
<span className="text-xs text-neutral-500">
{config.width}x{config.height}
</span>
</button>
))}
</div>
</div>
</div>
</div>
{/* Right section - View options & Zoom */}
<div className="flex items-center gap-1">
{/* View toggles */}
<button
onClick={toggleGrid}
className={`
p-2 rounded-lg transition-colors
${showGrid ? 'text-indigo-400 bg-indigo-500/20' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'}
`}
title="Toggle Grid"
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={toggleGuides}
className={`
p-2 rounded-lg transition-colors
${showGuides ? 'text-indigo-400 bg-indigo-500/20' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'}
`}
title="Toggle Guides"
>
<Ruler className="w-4 h-4" />
</button>
<button
onClick={toggleSnap}
className={`
p-2 rounded-lg transition-colors
${snapToGrid ? 'text-indigo-400 bg-indigo-500/20' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'}
`}
title="Toggle Snap"
>
<Magnet className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-neutral-700 mx-2" />
{/* Zoom controls */}
<button
onClick={() => setZoom(canvas.zoom - 0.1)}
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Zoom Out (Cmd+-)"
>
<ZoomOut className="w-4 h-4" />
</button>
{/* Zoom percentage with dropdown */}
<div className="relative" ref={zoomDropdownRef}>
<button
onClick={() => setShowZoomDropdown(!showZoomDropdown)}
className="w-20 px-2 py-1 rounded-lg bg-neutral-800 hover:bg-neutral-700 transition-colors flex items-center justify-center gap-1"
>
<span className="text-sm text-white">{Math.round(canvas.zoom * 100)}%</span>
<ChevronDown className="w-3 h-3 text-neutral-400" />
</button>
{showZoomDropdown && (
<div className="absolute top-full right-0 mt-1 w-32 bg-neutral-800 rounded-lg border border-neutral-700 shadow-xl z-50 overflow-hidden">
{ZOOM_PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() => handleZoomPreset(preset)}
className={`
w-full px-3 py-2 text-left text-sm hover:bg-neutral-700 transition-colors flex items-center justify-between
${preset.value === 'fit' ? 'border-b border-neutral-700' : ''}
${typeof preset.value === 'number' && Math.abs(canvas.zoom - preset.value) < 0.01 ? 'text-indigo-400' : 'text-white'}
`}
>
<span>{preset.label}</span>
{preset.value === 'fit' && <Maximize2 className="w-3 h-3 text-neutral-400" />}
</button>
))}
</div>
)}
</div>
<button
onClick={() => setZoom(canvas.zoom + 0.1)}
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Zoom In (Cmd++)"
>
<ZoomIn className="w-4 h-4" />
</button>
{/* Fit to screen button */}
<button
onClick={() => setZoom(calculateFitZoom())}
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Fit to Screen (Cmd+0)"
>
<Maximize2 className="w-4 h-4" />
</button>
<div className="w-px h-6 bg-neutral-700 mx-2" />
{/* Save/Load */}
<button
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Save Project"
>
<Save className="w-4 h-4" />
</button>
<button
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Open Project"
>
<FolderOpen className="w-4 h-4" />
</button>
</div>
</div>
);
}
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks