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%
Canvas.tsx 614 lines (18 KB)
'use client';

import React, { useRef, useEffect, useCallback, useState } from 'react';
import { Stage, Layer, Rect, Group, Transformer, Line, Text } from 'react-konva';
import type Konva from 'konva';
import { useEditorStore } from '@/lib/store/editor-store';
import { DeviceLayer } from './DeviceLayer';
import { TextLayer } from './TextLayer';
import { ShapeLayer } from './ShapeLayer';
import { ImageLayer } from './ImageLayer';
import { EffectLayer } from './EffectLayer';
import { calculateAlignmentGuides, type AlignmentGuide, type CustomGuideInput, type DistanceIndicator } from '@/lib/hooks/useAlignmentGuides';
import { Rulers, RULER_SIZE_EXPORT, type CustomGuide } from './Rulers';
import type { Layer as LayerType } from '@/types';

export function Canvas() {
  const stageRef = useRef<Konva.Stage>(null);
  const transformerRef = useRef<Konva.Transformer>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const [stageSize, setStageSize] = React.useState({ width: 800, height: 600 });
  const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
  const [distanceIndicators, setDistanceIndicators] = useState<DistanceIndicator[]>([]);
  const [isDragging, setIsDragging] = useState(false);
  const [isModifierPressed, setIsModifierPressed] = useState(false);
  const [customGuides, setCustomGuides] = useState<CustomGuide[]>([]);

  const {
    canvas,
    layers,
    selectedLayerIds,
    activeTool,
    showGrid,
    showGuides,
    snapToGrid,
    setZoom,
    setPan,
    selectLayer,
    deselectAll,
    updateLayer,
    saveToHistory,
  } = useEditorStore();

  // Update stage size on mount and resize
  useEffect(() => {
    const updateSize = () => {
      if (containerRef.current) {
        setStageSize({
          width: containerRef.current.clientWidth,
          height: containerRef.current.clientHeight,
        });
      }
    };

    updateSize();
    window.addEventListener('resize', updateSize);
    return () => window.removeEventListener('resize', updateSize);
  }, []);

  // Track modifier key for bypassing snap
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Control' || e.key === 'Meta') {
        setIsModifierPressed(true);
      }
    };
    const handleKeyUp = (e: KeyboardEvent) => {
      if (e.key === 'Control' || e.key === 'Meta') {
        setIsModifierPressed(false);
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    window.addEventListener('keyup', handleKeyUp);
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
      window.removeEventListener('keyup', handleKeyUp);
    };
  }, []);

  // Update transformer when selection changes
  useEffect(() => {
    if (!transformerRef.current || !stageRef.current) return;

    const selectedNodes = selectedLayerIds
      .map((id) => stageRef.current?.findOne(`#layer-${id}`))
      .filter(Boolean) as Konva.Node[];

    transformerRef.current.nodes(selectedNodes);
    transformerRef.current.getLayer()?.batchDraw();
  }, [selectedLayerIds]);

  // Handle wheel zoom
  const handleWheel = useCallback(
    (e: Konva.KonvaEventObject<WheelEvent>) => {
      e.evt.preventDefault();

      const stage = stageRef.current;
      if (!stage) return;

      const oldScale = canvas.zoom;
      const pointer = stage.getPointerPosition();
      if (!pointer) return;

      const mousePointTo = {
        x: (pointer.x - canvas.pan.x) / oldScale,
        y: (pointer.y - canvas.pan.y) / oldScale,
      };

      const direction = e.evt.deltaY > 0 ? -1 : 1;
      const newScale = direction > 0 ? oldScale * 1.1 : oldScale / 1.1;
      const clampedScale = Math.max(0.1, Math.min(3, newScale));

      setZoom(clampedScale);
      setPan(
        pointer.x - mousePointTo.x * clampedScale,
        pointer.y - mousePointTo.y * clampedScale
      );
    },
    [canvas.zoom, canvas.pan, setZoom, setPan]
  );

  // Handle stage click for deselection
  const handleStageClick = useCallback(
    (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
      if (e.target === stageRef.current) {
        deselectAll();
      }
    },
    [deselectAll]
  );

  // Handle layer selection
  const handleLayerClick = useCallback(
    (layerId: string, e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => {
      e.cancelBubble = true;
      selectLayer(layerId, e.evt.shiftKey);
    },
    [selectLayer]
  );

  // Handle transform end
  const handleTransformEnd = useCallback(
    (layerId: string, node: Konva.Node) => {
      const scaleX = node.scaleX();
      const scaleY = node.scaleY();

      updateLayer(layerId, {
        position: { x: node.x(), y: node.y() },
        size: {
          width: Math.max(5, node.width() * scaleX),
          height: Math.max(5, node.height() * scaleY),
        },
        rotation: node.rotation(),
      });

      node.scaleX(1);
      node.scaleY(1);
      saveToHistory('Transformed layer');
    },
    [updateLayer, saveToHistory]
  );

  // Handle drag start
  const handleDragStart = useCallback(() => {
    setIsDragging(true);
  }, []);

  // Handle drag move for alignment guides
  const handleDragMove = useCallback(
    (layerId: string, node: Konva.Node) => {
      // If modifier key is pressed, bypass snapping entirely
      if (isModifierPressed) {
        setAlignmentGuides([]);
        setDistanceIndicators([]);
        return;
      }

      if (!showGuides) {
        setAlignmentGuides([]);
        setDistanceIndicators([]);
        return;
      }

      const layer = layers.find((l) => l.id === layerId);
      if (!layer) return;

      const currentPosition = {
        ...layer,
        position: { x: node.x(), y: node.y() },
      };

      const otherLayers = layers.filter((l) => l.id !== layerId);

      // Convert custom guides to the format expected by calculateAlignmentGuides
      const customGuideInputs: CustomGuideInput[] = customGuides.map((g) => ({
        type: g.type,
        position: g.position,
      }));

      const result = calculateAlignmentGuides(
        currentPosition,
        otherLayers,
        canvas.width,
        canvas.height,
        snapToGrid,
        customGuideInputs
      );

      setAlignmentGuides(result.guides);
      setDistanceIndicators(result.distanceIndicators);

      // Apply snapping if enabled (and modifier not pressed)
      if (snapToGrid && !isModifierPressed && (result.x !== node.x() || result.y !== node.y())) {
        node.x(result.x);
        node.y(result.y);
      }
    },
    [layers, canvas.width, canvas.height, showGuides, snapToGrid, isModifierPressed, customGuides]
  );

  // Handle drag end
  const handleDragEnd = useCallback(
    (layerId: string, node: Konva.Node) => {
      setIsDragging(false);
      setAlignmentGuides([]);
      setDistanceIndicators([]);
      updateLayer(layerId, {
        position: { x: node.x(), y: node.y() },
      });
      saveToHistory('Moved layer');
    },
    [updateLayer, saveToHistory]
  );

  // Custom guide handlers
  const handleAddGuide = useCallback((guide: CustomGuide) => {
    setCustomGuides((prev) => [...prev, guide]);
  }, []);

  const handleUpdateGuide = useCallback((id: string, position: number) => {
    setCustomGuides((prev) =>
      prev.map((g) => (g.id === id ? { ...g, position } : g))
    );
  }, []);

  const handleDeleteGuide = useCallback((id: string) => {
    setCustomGuides((prev) => prev.filter((g) => g.id !== id));
  }, []);

  // Render layer based on type
  const renderLayer = (layer: LayerType) => {
    if (!layer.visible) return null;

    const commonProps = {
      id: `layer-${layer.id}`,
      layer,
      isSelected: selectedLayerIds.includes(layer.id),
      onClick: (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => handleLayerClick(layer.id, e),
      onTransformEnd: (node: Konva.Node) => handleTransformEnd(layer.id, node),
      onDragStart: handleDragStart,
      onDragMove: (node: Konva.Node) => handleDragMove(layer.id, node),
      onDragEnd: (node: Konva.Node) => handleDragEnd(layer.id, node),
    };

    switch (layer.type) {
      case 'device':
        return <DeviceLayer key={layer.id} {...commonProps} />;
      case 'text':
        return <TextLayer key={layer.id} {...commonProps} />;
      case 'shape':
        return <ShapeLayer key={layer.id} {...commonProps} />;
      case 'image':
      case 'screenshot':
        return <ImageLayer key={layer.id} {...commonProps} />;
      case 'effect':
        return <EffectLayer key={layer.id} {...commonProps} />;
      default:
        return null;
    }
  };

  // Calculate background gradient if needed
  const renderBackground = () => {
    const { backgroundColor } = canvas;

    if (typeof backgroundColor === 'string') {
      return (
        <Rect
          x={0}
          y={0}
          width={canvas.width}
          height={canvas.height}
          fill={backgroundColor}
          listening={false}
        />
      );
    }

    // Gradient background
    const { type, stops, angle = 0 } = backgroundColor;
    const gradientStops = stops.flatMap((stop) => [stop.offset, stop.color]);

    if (type === 'linear') {
      const rad = (angle * Math.PI) / 180;
      const x1 = canvas.width / 2 - (Math.cos(rad) * canvas.width) / 2;
      const y1 = canvas.height / 2 - (Math.sin(rad) * canvas.height) / 2;
      const x2 = canvas.width / 2 + (Math.cos(rad) * canvas.width) / 2;
      const y2 = canvas.height / 2 + (Math.sin(rad) * canvas.height) / 2;

      return (
        <Rect
          x={0}
          y={0}
          width={canvas.width}
          height={canvas.height}
          fillLinearGradientStartPoint={{ x: x1, y: y1 }}
          fillLinearGradientEndPoint={{ x: x2, y: y2 }}
          fillLinearGradientColorStops={gradientStops}
          listening={false}
        />
      );
    }

    return (
      <Rect
        x={0}
        y={0}
        width={canvas.width}
        height={canvas.height}
        fillRadialGradientStartPoint={{ x: canvas.width / 2, y: canvas.height / 2 }}
        fillRadialGradientEndPoint={{ x: canvas.width / 2, y: canvas.height / 2 }}
        fillRadialGradientStartRadius={0}
        fillRadialGradientEndRadius={Math.max(canvas.width, canvas.height) / 2}
        fillRadialGradientColorStops={gradientStops}
        listening={false}
      />
    );
  };

  // Grid pattern
  const renderGrid = () => {
    if (!showGrid) return null;

    const gridSize = 50;
    const lines = [];

    for (let i = 0; i <= canvas.width; i += gridSize) {
      lines.push(
        <Rect
          key={`v-${i}`}
          x={i}
          y={0}
          width={1}
          height={canvas.height}
          fill="rgba(255, 255, 255, 0.1)"
          listening={false}
        />
      );
    }

    for (let i = 0; i <= canvas.height; i += gridSize) {
      lines.push(
        <Rect
          key={`h-${i}`}
          x={0}
          y={i}
          width={canvas.width}
          height={1}
          fill="rgba(255, 255, 255, 0.1)"
          listening={false}
        />
      );
    }

    return <>{lines}</>;
  };

  return (
    <div
      ref={containerRef}
      className="flex-1 overflow-hidden bg-neutral-900 relative canvas-container"
      style={{ cursor: activeTool === 'move' ? 'grab' : 'default' }}
    >
      {/* Rulers */}
      <Rulers
        containerWidth={stageSize.width}
        containerHeight={stageSize.height}
        customGuides={customGuides}
        onAddGuide={handleAddGuide}
        onUpdateGuide={handleUpdateGuide}
        onDeleteGuide={handleDeleteGuide}
      />

      {/* Checkerboard pattern for transparency */}
      <div
        className="absolute opacity-20"
        style={{
          left: RULER_SIZE_EXPORT,
          top: RULER_SIZE_EXPORT,
          right: 0,
          bottom: 0,
          backgroundImage: `
            linear-gradient(45deg, #333 25%, transparent 25%),
            linear-gradient(-45deg, #333 25%, transparent 25%),
            linear-gradient(45deg, transparent 75%, #333 75%),
            linear-gradient(-45deg, transparent 75%, #333 75%)
          `,
          backgroundSize: '20px 20px',
          backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
        }}
      />

      <Stage
        ref={stageRef}
        width={stageSize.width - RULER_SIZE_EXPORT}
        height={stageSize.height - RULER_SIZE_EXPORT}
        scaleX={canvas.zoom}
        scaleY={canvas.zoom}
        x={canvas.pan.x}
        y={canvas.pan.y}
        onWheel={handleWheel}
        onClick={handleStageClick}
        draggable={activeTool === 'move'}
        onDragEnd={() => {
          if (stageRef.current) {
            setPan(stageRef.current.x(), stageRef.current.y());
          }
        }}
        style={{
          position: 'absolute',
          left: RULER_SIZE_EXPORT,
          top: RULER_SIZE_EXPORT,
        }}
      >
        <Layer>
          {/* Canvas background */}
          <Group>
            {renderBackground()}
            {renderGrid()}
          </Group>

          {/* Canvas border for visibility */}
          <Rect
            x={0}
            y={0}
            width={canvas.width}
            height={canvas.height}
            stroke="rgba(255, 255, 255, 0.2)"
            strokeWidth={2 / canvas.zoom}
            listening={false}
          />

          {/* Render all layers */}
          {layers.map(renderLayer)}

          {/* Alignment guides */}
          {showGuides && alignmentGuides.map((guide, index) => (
            <Line
              key={`guide-${index}`}
              points={
                guide.type === 'vertical'
                  ? [guide.position, guide.start, guide.position, guide.end]
                  : [guide.start, guide.position, guide.end, guide.position]
              }
              stroke="#f0abfc"
              strokeWidth={1 / canvas.zoom}
              dash={[4 / canvas.zoom, 4 / canvas.zoom]}
              listening={false}
            />
          ))}

          {/* Distance indicators */}
          {showGuides && distanceIndicators.map((indicator, index) => {
            const midX = (indicator.start.x + indicator.end.x) / 2;
            const midY = (indicator.start.y + indicator.end.y) / 2;
            const labelPadding = 4 / canvas.zoom;
            const fontSize = 11 / canvas.zoom;

            return (
              <Group key={`distance-${index}`}>
                {/* Distance line */}
                <Line
                  points={[
                    indicator.start.x,
                    indicator.start.y,
                    indicator.end.x,
                    indicator.end.y,
                  ]}
                  stroke="#ef4444"
                  strokeWidth={1 / canvas.zoom}
                  listening={false}
                />

                {/* End caps */}
                {indicator.type === 'horizontal' ? (
                  <>
                    <Line
                      points={[
                        indicator.start.x,
                        indicator.start.y - 4 / canvas.zoom,
                        indicator.start.x,
                        indicator.start.y + 4 / canvas.zoom,
                      ]}
                      stroke="#ef4444"
                      strokeWidth={1 / canvas.zoom}
                      listening={false}
                    />
                    <Line
                      points={[
                        indicator.end.x,
                        indicator.end.y - 4 / canvas.zoom,
                        indicator.end.x,
                        indicator.end.y + 4 / canvas.zoom,
                      ]}
                      stroke="#ef4444"
                      strokeWidth={1 / canvas.zoom}
                      listening={false}
                    />
                  </>
                ) : (
                  <>
                    <Line
                      points={[
                        indicator.start.x - 4 / canvas.zoom,
                        indicator.start.y,
                        indicator.start.x + 4 / canvas.zoom,
                        indicator.start.y,
                      ]}
                      stroke="#ef4444"
                      strokeWidth={1 / canvas.zoom}
                      listening={false}
                    />
                    <Line
                      points={[
                        indicator.end.x - 4 / canvas.zoom,
                        indicator.end.y,
                        indicator.end.x + 4 / canvas.zoom,
                        indicator.end.y,
                      ]}
                      stroke="#ef4444"
                      strokeWidth={1 / canvas.zoom}
                      listening={false}
                    />
                  </>
                )}

                {/* Distance label background */}
                <Rect
                  x={midX - 20 / canvas.zoom}
                  y={midY - fontSize / 2 - labelPadding}
                  width={40 / canvas.zoom}
                  height={fontSize + labelPadding * 2}
                  fill="#ef4444"
                  cornerRadius={2 / canvas.zoom}
                  listening={false}
                />

                {/* Distance label text */}
                <Text
                  x={midX - 20 / canvas.zoom}
                  y={midY - fontSize / 2}
                  width={40 / canvas.zoom}
                  height={fontSize + labelPadding}
                  text={`${indicator.distance}px`}
                  fontSize={fontSize}
                  fontFamily="Inter, system-ui, sans-serif"
                  fill="#ffffff"
                  align="center"
                  verticalAlign="middle"
                  listening={false}
                />
              </Group>
            );
          })}

          {/* Transformer for selected elements */}
          <Transformer
            ref={transformerRef}
            boundBoxFunc={(oldBox, newBox) => {
              // Limit resize
              if (newBox.width < 5 || newBox.height < 5) {
                return oldBox;
              }
              return newBox;
            }}
            rotateEnabled={true}
            enabledAnchors={[
              'top-left',
              'top-right',
              'bottom-left',
              'bottom-right',
              'middle-left',
              'middle-right',
              'top-center',
              'bottom-center',
            ]}
            anchorStroke="#4f46e5"
            anchorFill="#fff"
            anchorSize={10}
            anchorCornerRadius={2}
            borderStroke="#4f46e5"
            borderStrokeWidth={2}
          />
        </Layer>
      </Stage>

      {/* Zoom indicator */}
      <div className="absolute bottom-4 right-4 bg-black/50 backdrop-blur px-3 py-1.5 rounded-lg text-sm text-white/80">
        {Math.round(canvas.zoom * 100)}%
      </div>
    </div>
  );
}

About

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

0 stars
0 forks