Add draggable curved text support

- Wrap TextPath in Group container to enable dragging of curved text
  Previously curved text couldn't be moved on canvas. Now users can:
  • Add curved text
  • Move it by dragging
  • Update position is saved correctly

- Confirm curved text is per-element: Each text element has its own
  curvedPath property, so you can mix curved and straight text on the
  same design (Font 1: circle, Font 2: wave, Font 3: straight, etc.)

- TextPath uses listening={false} since Group handles all interactions

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-18 15:14:11 +01:00
co-authored by Claude Haiku 4.5
parent 859956b9d8
commit af4f21938d
+166 -30
View File
@@ -1,7 +1,7 @@
'use client';
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
import { Stage, Layer, Text, Image as KonvaImage, Transformer, Rect } from 'react-konva';
import { Stage, Layer, Text, TextPath, Image as KonvaImage, Transformer, Rect, Group } from 'react-konva';
import type Konva from 'konva';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
@@ -324,35 +324,62 @@ function PrintSurface({
/>
{elements.map((el) =>
el.type === 'text' ? (
<Text
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
rotation={el.rotation}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
updateElement(el.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: Math.max(8, el.fontSize * node.scaleX()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
el.curvedPath ? (
// Curved text path wrapped in a draggable Group
<Group
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
>
<TextPath
data={generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse)}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
listening={false}
/>
</Group>
) : (
// Regular straight text
<Text
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
rotation={el.rotation}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
updateElement(el.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: Math.max(8, el.fontSize * node.scaleX()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
)
) : (
<UploadedImage
key={el.id}
@@ -391,6 +418,59 @@ function PrintSurface({
);
}
// Generate SVG path for curved text based on type and radius
function generateCurvePath(type: string, radius: number, reverse: boolean = false): string {
const cx = 280; // center x (middle of 560px canvas)
const cy = 280; // center y
const r = radius;
let path = '';
switch (type) {
case 'circle':
// Full circle arc from bottom-left to top-right
const large = 0; // arc flag
const sweep = reverse ? 0 : 1;
path = `M ${cx - r} ${cy} A ${r} ${r} 0 ${large} ${sweep} ${cx + r} ${cy}`;
break;
case 'wave':
// Wave pattern
const waveHeight = radius / 3;
const waveLength = radius * 1.5;
path = `M ${cx - waveLength} ${cy}`;
for (let x = cx - waveLength; x <= cx + waveLength; x += 10) {
const y = cy + Math.sin((x - (cx - waveLength)) / waveLength * Math.PI * 2) * waveHeight;
path += ` L ${x} ${y}`;
}
break;
case 'spiral':
// Spiral arc
const spiralTurns = 1.5;
const spiralSteps = 100;
path = `M ${cx} ${cy - radius}`;
for (let i = 1; i <= spiralSteps; i++) {
const angle = (i / spiralSteps) * Math.PI * 2 * spiralTurns;
const r = radius * (1 - i / spiralSteps);
const x = cx + r * Math.cos(angle);
const y = cy - r * Math.sin(angle);
path += ` L ${x} ${y}`;
}
break;
case 'arc':
// Simple arc
path = `M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 ${reverse ? 0 : 1} ${cx + radius} ${cy}`;
break;
default:
path = `M 0 ${cy} L 560 ${cy}`; // fallback: straight line
}
return path;
}
export default function DesignCanvas({ product }: { product: ProductDTO }) {
const [color, setColor] = useState(product.colors[0]);
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
@@ -1199,6 +1279,62 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
<span className="w-12 text-right font-mono text-xs">{text.rotation || 0}°</span>
</div>
</label>
{/* Curved Text Controls */}
<div className="border-t border-line pt-3">
<label className="flex items-center gap-2 text-sm text-muted mb-3">
<input
type="checkbox"
checked={text.curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
className="cursor-pointer"
/>
Curved text
</label>
{text.curvedPath && (
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Curve type</span>
<select
value={text.curvedPath.type || 'circle'}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
className="border border-line bg-paper px-2 py-1 text-xs"
>
<option value="circle">Circle</option>
<option value="wave">Wave</option>
<option value="spiral">Spiral</option>
<option value="arc">Arc</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Radius / Intensity</span>
<div className="flex gap-2 items-center">
<input
type="range"
min={20}
max={200}
value={text.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
className="flex-1"
/>
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span>
</div>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={text.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
className="cursor-pointer"
/>
<span className="text-muted">Reverse direction</span>
</label>
</div>
)}
</div>
</div>
</div>
))}