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:
co-authored by
Claude Haiku 4.5
parent
859956b9d8
commit
af4f21938d
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
|
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 type Konva from 'konva';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import ProductMockup from './ProductMockup';
|
import ProductMockup from './ProductMockup';
|
||||||
@@ -324,6 +324,32 @@ function PrintSurface({
|
|||||||
/>
|
/>
|
||||||
{elements.map((el) =>
|
{elements.map((el) =>
|
||||||
el.type === 'text' ? (
|
el.type === 'text' ? (
|
||||||
|
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
|
<Text
|
||||||
key={el.id}
|
key={el.id}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
@@ -353,6 +379,7 @@ function PrintSurface({
|
|||||||
node.scaleY(1);
|
node.scaleY(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<UploadedImage
|
<UploadedImage
|
||||||
key={el.id}
|
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 }) {
|
export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
||||||
const [color, setColor] = useState(product.colors[0]);
|
const [color, setColor] = useState(product.colors[0]);
|
||||||
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
|
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>
|
<span className="w-12 text-right font-mono text-xs">{text.rotation || 0}°</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user