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%
TextLayer.tsx 188 lines (5 KB)
'use client';

import React, { useRef, useState } from 'react';
import { Group, Text, Rect } from 'react-konva';
import type Konva from 'konva';
import type { Layer, TextLayerData } from '@/types';
import { useEditorStore } from '@/lib/store/editor-store';

interface TextLayerProps {
  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 TextLayer({
  id,
  layer,
  isSelected,
  onClick,
  onTransformEnd,
  onDragStart,
  onDragMove,
  onDragEnd,
}: TextLayerProps) {
  const groupRef = useRef<Konva.Group>(null);
  const textRef = useRef<Konva.Text>(null);
  const [isEditing, setIsEditing] = useState(false);

  const { updateLayerData, saveToHistory } = useEditorStore();

  const data = layer.data as TextLayerData;

  // Handle double-click for text editing
  const handleDblClick = () => {
    if (layer.locked) return;
    setIsEditing(true);

    const textNode = textRef.current;
    const stage = textNode?.getStage();
    if (!textNode || !stage) return;

    // Create editable textarea
    const textPosition = textNode.absolutePosition();
    const stageContainer = stage.container();
    const containerRect = stageContainer.getBoundingClientRect();
    const scale = stage.scaleX();

    const textarea = document.createElement('textarea');
    document.body.appendChild(textarea);

    const originalText = data.text;
    textarea.value = data.text;
    textarea.style.position = 'absolute';
    textarea.style.top = `${containerRect.top + textPosition.y * scale}px`;
    textarea.style.left = `${containerRect.left + textPosition.x * scale}px`;
    textarea.style.width = `${layer.size.width * scale}px`;
    textarea.style.height = `${layer.size.height * scale}px`;
    textarea.style.fontSize = `${data.fontSize * scale}px`;
    textarea.style.fontFamily = data.fontFamily;
    textarea.style.fontWeight = String(data.fontWeight);
    textarea.style.color = data.color;
    textarea.style.background = 'rgba(0, 0, 0, 0.8)';
    textarea.style.border = '2px solid #4f46e5';
    textarea.style.borderRadius = '4px';
    textarea.style.outline = 'none';
    textarea.style.resize = 'none';
    textarea.style.lineHeight = String(data.lineHeight);
    textarea.style.letterSpacing = `${data.letterSpacing * scale}px`;
    textarea.style.textAlign = data.align;
    textarea.style.zIndex = '1000';
    textarea.style.padding = '4px';
    textarea.style.overflow = 'hidden';
    textarea.style.transformOrigin = 'top left';

    textarea.focus();
    textarea.select();

    const saveAndClose = () => {
      const newText = textarea.value;
      if (newText !== originalText) {
        updateLayerData(layer.id, { text: newText });
        saveToHistory('Edited text');
      }
      textarea.remove();
      setIsEditing(false);
    };

    const cancelAndClose = () => {
      textarea.remove();
      setIsEditing(false);
    };

    textarea.addEventListener('blur', saveAndClose);
    textarea.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        cancelAndClose();
      }
      // Enter without shift saves, Shift+Enter adds newline
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        saveAndClose();
      }
    });
  };

  // Build shadow config
  const shadowConfig = data.shadow
    ? {
        shadowColor: data.shadow.color,
        shadowBlur: data.shadow.blur,
        shadowOffsetX: data.shadow.offsetX,
        shadowOffsetY: data.shadow.offsetY,
      }
    : {};

  return (
    <Group
      ref={groupRef}
      id={id}
      x={layer.position.x}
      y={layer.position.y}
      rotation={layer.rotation}
      opacity={layer.opacity}
      draggable={!layer.locked}
      onClick={onClick}
      onTap={onClick}
      onDblClick={handleDblClick}
      onDblTap={handleDblClick}
      onDragStart={onDragStart}
      onDragMove={(e) => onDragMove?.(e.target)}
      onDragEnd={(e) => onDragEnd(e.target)}
      onTransformEnd={(e) => onTransformEnd(e.target)}
    >
      {/* Background rect for selection */}
      {isSelected && !isEditing && (
        <Rect
          x={-4}
          y={-4}
          width={layer.size.width + 8}
          height={layer.size.height + 8}
          fill="transparent"
          stroke="rgba(79, 70, 229, 0.3)"
          strokeWidth={1}
          dash={[4, 4]}
          listening={false}
        />
      )}

      <Text
        ref={textRef}
        text={data.text}
        width={layer.size.width}
        height={layer.size.height}
        fontFamily={data.fontFamily}
        fontSize={data.fontSize}
        fontStyle={data.fontWeight >= 600 ? 'bold' : 'normal'}
        fill={data.gradient ? undefined : data.color}
        fillLinearGradientStartPoint={
          data.gradient?.type === 'linear' ? { x: 0, y: 0 } : undefined
        }
        fillLinearGradientEndPoint={
          data.gradient?.type === 'linear'
            ? { x: layer.size.width, y: 0 }
            : undefined
        }
        fillLinearGradientColorStops={
          data.gradient?.type === 'linear'
            ? data.gradient.stops.flatMap((s) => [s.offset, s.color])
            : undefined
        }
        align={data.align}
        lineHeight={data.lineHeight}
        letterSpacing={data.letterSpacing}
        wrap="word"
        ellipsis={true}
        visible={!isEditing}
        {...shadowConfig}
      />
    </Group>
  );
}

About

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

0 stars
0 forks