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%
Toolbar.tsx 292 lines (10 KB)
'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