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%
ImageLayer.tsx 261 lines (7 KB)
'use client';

import React, { useRef, useEffect } from 'react';
import { Group, Image as KonvaImage, Rect } from 'react-konva';
import useImage from 'use-image';
import Konva from 'konva';
import type { Layer, ImageLayerData, ScreenshotLayerData, CornerRadius, ImageFilters } from '@/types';

interface ImageLayerProps {
  id: string;
  layer: Layer;
  isSelected: boolean;
  onClick: (e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) => void;
  onTransformEnd: (node: Konva.Node) => void;
  onDragStart?: () => void;
  onDragMove?: (node: Konva.Node) => void;
  onDragEnd: (node: Konva.Node) => void;
}

export function ImageLayer({
  id,
  layer,
  isSelected,
  onClick,
  onTransformEnd,
  onDragStart,
  onDragMove,
  onDragEnd,
}: ImageLayerProps) {
  const groupRef = useRef<Konva.Group>(null);
  const imageRef = useRef<Konva.Image>(null);

  const data = layer.data as ImageLayerData | ScreenshotLayerData;
  const [image, status] = useImage(data.src, 'anonymous');

  // Calculate image dimensions based on fit mode
  const getImageDimensions = () => {
    if (!image) return { width: layer.size.width, height: layer.size.height, x: 0, y: 0 };

    const containerRatio = layer.size.width / layer.size.height;
    const imageRatio = image.width / image.height;

    const fit = 'fit' in data ? data.fit : 'cover';

    if (fit === 'fill') {
      return {
        width: layer.size.width,
        height: layer.size.height,
        x: 0,
        y: 0,
      };
    }

    if (fit === 'contain') {
      if (containerRatio > imageRatio) {
        const width = layer.size.height * imageRatio;
        return {
          width,
          height: layer.size.height,
          x: (layer.size.width - width) / 2,
          y: 0,
        };
      } else {
        const height = layer.size.width / imageRatio;
        return {
          width: layer.size.width,
          height,
          x: 0,
          y: (layer.size.height - height) / 2,
        };
      }
    }

    // Cover (default)
    if (containerRatio > imageRatio) {
      const height = layer.size.width / imageRatio;
      return {
        width: layer.size.width,
        height,
        x: 0,
        y: (layer.size.height - height) / 2,
      };
    } else {
      const width = layer.size.height * imageRatio;
      return {
        width,
        height: layer.size.height,
        x: (layer.size.width - width) / 2,
        y: 0,
      };
    }
  };

  const imageDims = getImageDimensions();

  // Get corner radius values
  const getCornerRadius = (): { tl: number; tr: number; br: number; bl: number } => {
    const imageData = data as ImageLayerData;
    if (!imageData.cornerRadius) {
      return { tl: 0, tr: 0, br: 0, bl: 0 };
    }
    if (typeof imageData.cornerRadius === 'number') {
      return {
        tl: imageData.cornerRadius,
        tr: imageData.cornerRadius,
        br: imageData.cornerRadius,
        bl: imageData.cornerRadius,
      };
    }
    const cr = imageData.cornerRadius as CornerRadius;
    return {
      tl: cr.topLeft,
      tr: cr.topRight,
      br: cr.bottomRight,
      bl: cr.bottomLeft,
    };
  };

  const corners = getCornerRadius();
  const hasRoundedCorners = corners.tl > 0 || corners.tr > 0 || corners.br > 0 || corners.bl > 0;

  // Get image filters
  const getFilters = (): ImageFilters => {
    const imageData = data as ImageLayerData;
    return imageData.filters || { brightness: 0, contrast: 0, saturation: 0, blur: 0 };
  };

  const filters = getFilters();
  const hasFilters = filters.brightness !== 0 || filters.contrast !== 0 ||
    filters.saturation !== 0 || filters.blur !== 0;

  // Apply Konva filters when image loads or filters change
  useEffect(() => {
    if (!imageRef.current || !image) return;

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const konvaFilters: any[] = [];

    if (filters.brightness !== 0) {
      konvaFilters.push(Konva.Filters.Brighten);
      imageRef.current.brightness(filters.brightness / 100);
    }

    if (filters.contrast !== 0) {
      konvaFilters.push(Konva.Filters.Contrast);
      imageRef.current.contrast(filters.contrast);
    }

    if (filters.saturation !== 0) {
      konvaFilters.push(Konva.Filters.HSL);
      imageRef.current.saturation(filters.saturation / 50);
    }

    if (filters.blur !== 0) {
      konvaFilters.push(Konva.Filters.Blur);
      imageRef.current.blurRadius(filters.blur);
    }

    if (konvaFilters.length > 0) {
      imageRef.current.cache();
      imageRef.current.filters(konvaFilters);
    } else {
      imageRef.current.clearCache();
      imageRef.current.filters([]);
    }
  }, [image, filters.brightness, filters.contrast, filters.saturation, filters.blur]);

  // Create rounded rectangle clip path
  const createRoundedRectPath = (
    ctx: CanvasRenderingContext2D,
    width: number,
    height: number,
    { tl, tr, br, bl }: { tl: number; tr: number; br: number; bl: number }
  ) => {
    ctx.beginPath();
    ctx.moveTo(tl, 0);
    ctx.lineTo(width - tr, 0);
    ctx.quadraticCurveTo(width, 0, width, tr);
    ctx.lineTo(width, height - br);
    ctx.quadraticCurveTo(width, height, width - br, height);
    ctx.lineTo(bl, height);
    ctx.quadraticCurveTo(0, height, 0, height - bl);
    ctx.lineTo(0, tl);
    ctx.quadraticCurveTo(0, 0, tl, 0);
    ctx.closePath();
  };

  return (
    <Group
      ref={groupRef}
      id={id}
      x={layer.position.x}
      y={layer.position.y}
      width={layer.size.width}
      height={layer.size.height}
      rotation={layer.rotation}
      opacity={layer.opacity}
      draggable={!layer.locked}
      onClick={onClick}
      onTap={onClick}
      onDragStart={onDragStart}
      onDragMove={(e) => onDragMove?.(e.target)}
      onDragEnd={(e) => onDragEnd(e.target)}
      onTransformEnd={(e) => onTransformEnd(e.target)}
      clipFunc={(ctx) => {
        if (hasRoundedCorners) {
          createRoundedRectPath(ctx as unknown as CanvasRenderingContext2D, layer.size.width, layer.size.height, corners);
        } else {
          ctx.rect(0, 0, layer.size.width, layer.size.height);
        }
      }}
    >
      {/* Loading state */}
      {status === 'loading' && (
        <Rect
          width={layer.size.width}
          height={layer.size.height}
          fill="rgba(255, 255, 255, 0.05)"
        />
      )}

      {/* Error state */}
      {status === 'failed' && (
        <Group>
          <Rect
            width={layer.size.width}
            height={layer.size.height}
            fill="rgba(239, 68, 68, 0.1)"
            stroke="rgba(239, 68, 68, 0.5)"
            strokeWidth={2}
            dash={[8, 4]}
          />
        </Group>
      )}

      {/* Image */}
      {image && (
        <KonvaImage
          ref={imageRef}
          image={image}
          x={imageDims.x}
          y={imageDims.y}
          width={imageDims.width}
          height={imageDims.height}
        />
      )}

      {/* Selection border */}
      {isSelected && (
        <Rect
          width={layer.size.width}
          height={layer.size.height}
          stroke="#4f46e5"
          strokeWidth={2}
          listening={false}
        />
      )}
    </Group>
  );
}

About

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

0 stars
0 forks