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%
PropertiesPanel.tsx 603 lines (23 KB)
'use client';

import React, { useState } from 'react';
import {
  X,
  Eye,
  EyeOff,
  Lock,
  Unlock,
  Trash2,
  Copy,
  ChevronUp,
  ChevronDown,
  RotateCw,
  Eraser,
  Loader2,
  CornerUpLeft,
  Link2,
  Link2Off,
  AlignLeft,
  AlignCenter,
  AlignRight,
  AlignVerticalJustifyStart,
  AlignVerticalJustifyCenter,
  AlignVerticalJustifyEnd,
  AlignHorizontalSpaceAround,
  AlignVerticalSpaceAround,
} from 'lucide-react';
import { useEditorStore } from '@/lib/store/editor-store';
import { removeImageBackground, type BackgroundRemovalProgress } from '@/lib/ai/background-removal';
import type { ImageLayerData, CornerRadius } from '@/types';

export function PropertiesPanel() {
  const {
    layers,
    selectedLayerIds,
    updateLayer,
    deleteLayer,
    duplicateLayer,
    moveLayer,
    deselectAll,
    alignLayers,
    distributeLayers,
  } = useEditorStore();

  const [bgRemovalProgress, setBgRemovalProgress] = useState<BackgroundRemovalProgress | null>(null);
  const [uniformCorners, setUniformCorners] = useState(true);

  const selectedLayers = selectedLayerIds
    .map((id) => layers.find((l) => l.id === id))
    .filter(Boolean);

  const isImageLayer = selectedLayers.length === 1 && selectedLayers[0]?.type === 'image';

  // Get current corner radius for image layers
  const getImageCornerRadius = (): { uniform: number; individual: CornerRadius } => {
    if (!isImageLayer) return { uniform: 0, individual: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 } };
    const imageData = selectedLayers[0]!.data as ImageLayerData;
    if (!imageData.cornerRadius) {
      return { uniform: 0, individual: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 } };
    }
    if (typeof imageData.cornerRadius === 'number') {
      return {
        uniform: imageData.cornerRadius,
        individual: {
          topLeft: imageData.cornerRadius,
          topRight: imageData.cornerRadius,
          bottomLeft: imageData.cornerRadius,
          bottomRight: imageData.cornerRadius,
        },
      };
    }
    const cr = imageData.cornerRadius;
    const uniform = cr.topLeft === cr.topRight && cr.topRight === cr.bottomLeft && cr.bottomLeft === cr.bottomRight
      ? cr.topLeft : 0;
    return { uniform, individual: cr };
  };

  const handleUniformCornerChange = (value: number) => {
    if (!isImageLayer) return;
    const layer = selectedLayers[0]!;
    updateLayer(layer.id, {
      data: { ...layer.data, cornerRadius: value } as ImageLayerData,
    });
  };

  const handleIndividualCornerChange = (corner: keyof CornerRadius, value: number) => {
    if (!isImageLayer) return;
    const layer = selectedLayers[0]!;
    const current = getImageCornerRadius().individual;
    updateLayer(layer.id, {
      data: {
        ...layer.data,
        cornerRadius: { ...current, [corner]: value },
      } as ImageLayerData,
    });
  };

  const handleRemoveBackground = async () => {
    if (!isImageLayer) return;
    const layer = selectedLayers[0]!;
    const imageData = layer.data as ImageLayerData;

    setBgRemovalProgress({ progress: 0, stage: 'loading', message: 'Starting...' });

    const result = await removeImageBackground(
      imageData.src,
      (progress) => setBgRemovalProgress(progress)
    );

    if (result.success && result.dataUrl) {
      updateLayer(layer.id, {
        data: { ...imageData, src: result.dataUrl },
      });
    }

    // Clear progress after a moment
    setTimeout(() => setBgRemovalProgress(null), 2000);
  };

  if (selectedLayers.length === 0) {
    return (
      <div className="w-64 bg-neutral-900/50 border-l border-neutral-800 p-4">
        <h3 className="text-sm font-medium text-neutral-400 mb-4">Properties</h3>
        <p className="text-sm text-neutral-500">Select a layer to edit its properties</p>
      </div>
    );
  }

  const layer = selectedLayers[0]!;

  return (
    <div className="w-64 bg-neutral-900/50 border-l border-neutral-800 flex flex-col">
      {/* Header */}
      <div className="p-3 border-b border-neutral-800 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <div className="text-sm font-medium text-white truncate">{layer.name}</div>
          <span className="text-xs text-neutral-500 capitalize">{layer.type}</span>
        </div>
        <button
          onClick={deselectAll}
          className="p-1 text-neutral-500 hover:text-white transition-colors"
        >
          <X className="w-4 h-4" />
        </button>
      </div>

      <div className="flex-1 overflow-y-auto p-3 space-y-4">
        {/* Quick actions */}
        <div className="flex gap-1">
          <button
            onClick={() => updateLayer(layer.id, { visible: !layer.visible })}
            className={`
              flex-1 p-2 rounded-lg flex items-center justify-center
              transition-colors
              ${layer.visible ? 'bg-neutral-800 text-white' : 'bg-neutral-800/50 text-neutral-500'}
            `}
            title={layer.visible ? 'Hide' : 'Show'}
          >
            {layer.visible ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
          </button>
          <button
            onClick={() => updateLayer(layer.id, { locked: !layer.locked })}
            className={`
              flex-1 p-2 rounded-lg flex items-center justify-center
              transition-colors
              ${layer.locked ? 'bg-amber-500/20 text-amber-400' : 'bg-neutral-800 text-white'}
            `}
            title={layer.locked ? 'Unlock' : 'Lock'}
          >
            {layer.locked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
          </button>
          <button
            onClick={() => duplicateLayer(layer.id)}
            className="flex-1 p-2 rounded-lg bg-neutral-800 text-white hover:bg-neutral-700 transition-colors"
            title="Duplicate"
          >
            <Copy className="w-4 h-4 mx-auto" />
          </button>
          <button
            onClick={() => deleteLayer(layer.id)}
            className="flex-1 p-2 rounded-lg bg-neutral-800 text-red-400 hover:bg-red-500/20 transition-colors"
            title="Delete"
          >
            <Trash2 className="w-4 h-4 mx-auto" />
          </button>
        </div>

        {/* Alignment */}
        <div>
          <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
            Align {selectedLayerIds.length > 1 ? '(to each other)' : '(to canvas)'}
          </label>
          <div className="grid grid-cols-6 gap-1">
            <button
              onClick={() => alignLayers('left')}
              className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
              title="Align Left"
            >
              <AlignLeft className="w-3.5 h-3.5 mx-auto" />
            </button>
            <button
              onClick={() => alignLayers('center')}
              className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
              title="Align Center"
            >
              <AlignCenter className="w-3.5 h-3.5 mx-auto" />
            </button>
            <button
              onClick={() => alignLayers('right')}
              className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
              title="Align Right"
            >
              <AlignRight className="w-3.5 h-3.5 mx-auto" />
            </button>
            <button
              onClick={() => alignLayers('top')}
              className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
              title="Align Top"
            >
              <AlignVerticalJustifyStart className="w-3.5 h-3.5 mx-auto" />
            </button>
            <button
              onClick={() => alignLayers('middle')}
              className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
              title="Align Middle"
            >
              <AlignVerticalJustifyCenter className="w-3.5 h-3.5 mx-auto" />
            </button>
            <button
              onClick={() => alignLayers('bottom')}
              className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
              title="Align Bottom"
            >
              <AlignVerticalJustifyEnd className="w-3.5 h-3.5 mx-auto" />
            </button>
          </div>
          {/* Distribution - only show when 3+ layers selected */}
          {selectedLayerIds.length >= 3 && (
            <div className="grid grid-cols-2 gap-1 mt-2">
              <button
                onClick={() => distributeLayers('horizontal')}
                className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors flex items-center justify-center gap-1.5 text-xs"
                title="Distribute Horizontally"
              >
                <AlignHorizontalSpaceAround className="w-3.5 h-3.5" />
                Horizontal
              </button>
              <button
                onClick={() => distributeLayers('vertical')}
                className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors flex items-center justify-center gap-1.5 text-xs"
                title="Distribute Vertically"
              >
                <AlignVerticalSpaceAround className="w-3.5 h-3.5" />
                Vertical
              </button>
            </div>
          )}
        </div>

        {/* Position */}
        <div>
          <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
            Position
          </label>
          <div className="grid grid-cols-2 gap-2">
            <div>
              <label className="text-xs text-neutral-400 block mb-1">X</label>
              <input
                type="number"
                value={Math.round(layer.position.x)}
                onChange={(e) =>
                  updateLayer(layer.id, {
                    position: { ...layer.position, x: Number(e.target.value) },
                  })
                }
                className="
                  w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
                  text-white text-sm focus:outline-none focus:border-indigo-500
                "
              />
            </div>
            <div>
              <label className="text-xs text-neutral-400 block mb-1">Y</label>
              <input
                type="number"
                value={Math.round(layer.position.y)}
                onChange={(e) =>
                  updateLayer(layer.id, {
                    position: { ...layer.position, y: Number(e.target.value) },
                  })
                }
                className="
                  w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
                  text-white text-sm focus:outline-none focus:border-indigo-500
                "
              />
            </div>
          </div>
        </div>

        {/* Size */}
        <div>
          <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
            Size
          </label>
          <div className="grid grid-cols-2 gap-2">
            <div>
              <label className="text-xs text-neutral-400 block mb-1">W</label>
              <input
                type="number"
                value={Math.round(layer.size.width)}
                onChange={(e) =>
                  updateLayer(layer.id, {
                    size: { ...layer.size, width: Number(e.target.value) },
                  })
                }
                className="
                  w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
                  text-white text-sm focus:outline-none focus:border-indigo-500
                "
              />
            </div>
            <div>
              <label className="text-xs text-neutral-400 block mb-1">H</label>
              <input
                type="number"
                value={Math.round(layer.size.height)}
                onChange={(e) =>
                  updateLayer(layer.id, {
                    size: { ...layer.size, height: Number(e.target.value) },
                  })
                }
                className="
                  w-full px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
                  text-white text-sm focus:outline-none focus:border-indigo-500
                "
              />
            </div>
          </div>
        </div>

        {/* Rotation */}
        <div>
          <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
            Rotation
          </label>
          <div className="flex items-center gap-2">
            <input
              type="number"
              value={Math.round(layer.rotation)}
              onChange={(e) => updateLayer(layer.id, { rotation: Number(e.target.value) })}
              className="
                flex-1 px-2 py-1.5 rounded bg-neutral-800 border border-neutral-700
                text-white text-sm focus:outline-none focus:border-indigo-500
              "
            />
            <button
              onClick={() => updateLayer(layer.id, { rotation: 0 })}
              className="p-1.5 rounded bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
              title="Reset rotation"
            >
              <RotateCw className="w-4 h-4" />
            </button>
          </div>
        </div>

        {/* Opacity */}
        <div>
          <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
            Opacity
          </label>
          <div className="flex items-center gap-2">
            <input
              type="range"
              min={0}
              max={100}
              value={layer.opacity * 100}
              onChange={(e) => updateLayer(layer.id, { opacity: Number(e.target.value) / 100 })}
              className="flex-1"
            />
            <span className="text-sm text-white w-10 text-right">
              {Math.round(layer.opacity * 100)}%
            </span>
          </div>
        </div>

        {/* Image-specific controls */}
        {isImageLayer && (
          <>
            {/* Corner Radius */}
            <div>
              <div className="flex items-center justify-between mb-2">
                <label className="text-xs text-neutral-500 uppercase tracking-wider">
                  Corner Radius
                </label>
                <button
                  onClick={() => setUniformCorners(!uniformCorners)}
                  className={`p-1 rounded transition-colors ${
                    uniformCorners ? 'text-indigo-400' : 'text-neutral-500 hover:text-white'
                  }`}
                  title={uniformCorners ? 'Uniform corners' : 'Individual corners'}
                >
                  {uniformCorners ? <Link2 className="w-3.5 h-3.5" /> : <Link2Off className="w-3.5 h-3.5" />}
                </button>
              </div>

              {uniformCorners ? (
                <div className="flex items-center gap-2">
                  <input
                    type="range"
                    min={0}
                    max={200}
                    value={getImageCornerRadius().uniform}
                    onChange={(e) => handleUniformCornerChange(Number(e.target.value))}
                    className="flex-1"
                  />
                  <input
                    type="number"
                    min={0}
                    max={200}
                    value={getImageCornerRadius().uniform}
                    onChange={(e) => handleUniformCornerChange(Number(e.target.value))}
                    className="w-14 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm text-center"
                  />
                </div>
              ) : (
                <div className="grid grid-cols-2 gap-2">
                  <div>
                    <label className="text-[10px] text-neutral-500 block mb-1">Top Left</label>
                    <input
                      type="number"
                      min={0}
                      max={200}
                      value={getImageCornerRadius().individual.topLeft}
                      onChange={(e) => handleIndividualCornerChange('topLeft', Number(e.target.value))}
                      className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
                    />
                  </div>
                  <div>
                    <label className="text-[10px] text-neutral-500 block mb-1">Top Right</label>
                    <input
                      type="number"
                      min={0}
                      max={200}
                      value={getImageCornerRadius().individual.topRight}
                      onChange={(e) => handleIndividualCornerChange('topRight', Number(e.target.value))}
                      className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
                    />
                  </div>
                  <div>
                    <label className="text-[10px] text-neutral-500 block mb-1">Bottom Left</label>
                    <input
                      type="number"
                      min={0}
                      max={200}
                      value={getImageCornerRadius().individual.bottomLeft}
                      onChange={(e) => handleIndividualCornerChange('bottomLeft', Number(e.target.value))}
                      className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
                    />
                  </div>
                  <div>
                    <label className="text-[10px] text-neutral-500 block mb-1">Bottom Right</label>
                    <input
                      type="number"
                      min={0}
                      max={200}
                      value={getImageCornerRadius().individual.bottomRight}
                      onChange={(e) => handleIndividualCornerChange('bottomRight', Number(e.target.value))}
                      className="w-full px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-white text-sm"
                    />
                  </div>
                </div>
              )}
            </div>

            {/* Background Removal */}
            <div>
              <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
                AI Tools
              </label>
              <button
                onClick={handleRemoveBackground}
                disabled={bgRemovalProgress !== null && bgRemovalProgress.stage !== 'complete' && bgRemovalProgress.stage !== 'error'}
                className={`
                  w-full py-2 px-3 rounded-lg flex items-center justify-center gap-2
                  transition-colors text-sm font-medium
                  ${bgRemovalProgress?.stage === 'complete'
                    ? 'bg-green-500/20 text-green-400 border border-green-500/30'
                    : bgRemovalProgress?.stage === 'error'
                    ? 'bg-red-500/20 text-red-400 border border-red-500/30'
                    : bgRemovalProgress
                    ? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/30'
                    : 'bg-indigo-500 hover:bg-indigo-600 text-white'
                  }
                  disabled:cursor-not-allowed
                `}
              >
                {bgRemovalProgress ? (
                  bgRemovalProgress.stage === 'complete' ? (
                    <>
                      <CornerUpLeft className="w-4 h-4" />
                      Background Removed!
                    </>
                  ) : bgRemovalProgress.stage === 'error' ? (
                    <>Error: {bgRemovalProgress.message}</>
                  ) : (
                    <>
                      <Loader2 className="w-4 h-4 animate-spin" />
                      {bgRemovalProgress.message}
                    </>
                  )
                ) : (
                  <>
                    <Eraser className="w-4 h-4" />
                    Remove Background
                  </>
                )}
              </button>
              {bgRemovalProgress && bgRemovalProgress.stage === 'processing' && (
                <div className="mt-2 h-1 bg-neutral-800 rounded-full overflow-hidden">
                  <div
                    className="h-full bg-indigo-500 transition-all duration-300"
                    style={{ width: `${bgRemovalProgress.progress}%` }}
                  />
                </div>
              )}
            </div>
          </>
        )}

        {/* Layer order */}
        <div>
          <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
            Layer Order
          </label>
          <div className="flex gap-1">
            <button
              onClick={() => moveLayer(layer.id, 'top')}
              className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors text-xs"
            >
              Top
            </button>
            <button
              onClick={() => moveLayer(layer.id, 'up')}
              className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
            >
              <ChevronUp className="w-4 h-4 mx-auto" />
            </button>
            <button
              onClick={() => moveLayer(layer.id, 'down')}
              className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
            >
              <ChevronDown className="w-4 h-4 mx-auto" />
            </button>
            <button
              onClick={() => moveLayer(layer.id, 'bottom')}
              className="flex-1 p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white transition-colors text-xs"
            >
              Bottom
            </button>
          </div>
        </div>
      </div>

      {/* Layers list */}
      <div className="border-t border-neutral-800 p-3">
        <label className="text-xs text-neutral-500 uppercase tracking-wider block mb-2">
          Layers ({layers.length})
        </label>
        <div className="space-y-1 max-h-40 overflow-y-auto">
          {[...layers].reverse().map((l) => (
            <button
              key={l.id}
              onClick={() => useEditorStore.getState().selectLayer(l.id)}
              className={`
                w-full flex items-center gap-2 p-2 rounded-lg text-left
                transition-colors
                ${
                  selectedLayerIds.includes(l.id)
                    ? 'bg-indigo-500/20 border border-indigo-500/50'
                    : 'hover:bg-neutral-800 border border-transparent'
                }
              `}
            >
              {!l.visible && <EyeOff className="w-3 h-3 text-neutral-500" />}
              {l.locked && <Lock className="w-3 h-3 text-amber-400" />}
              <span
                className={`text-sm truncate flex-1 ${
                  selectedLayerIds.includes(l.id) ? 'text-white' : 'text-neutral-400'
                }`}
              >
                {l.name}
              </span>
              <span className="text-xs text-neutral-600 capitalize">{l.type}</span>
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

About

A web app to help you design things, local, offline, on device. In your browser.

0 stars
0 forks