feat: Added text-specific property controls (content, font family, size…
- "app/src/components/canvas/ShapeProperties.tsx" - "app/src/views/DesignCanvas.tsx" GSD-Task: S03/T03
This commit is contained in:
parent
24fb28d622
commit
868b444595
2 changed files with 197 additions and 3 deletions
|
|
@ -3,10 +3,13 @@
|
|||
*
|
||||
* Shows stroke color, stroke weight, fill color (with toggle), dimensions.
|
||||
* For line objects: line style dropdown (solid, dashed, dotted).
|
||||
* For text objects: text content, font family, font size, letter spacing,
|
||||
* line height, and a "Convert to Paths" action button.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { CanvasObject, LineStyle } from '../../types/canvas';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { CanvasObject, ImageObject, LineStyle, TextObject } from '../../types/canvas';
|
||||
import { getAvailableFonts, textToPathData } from '../../utils/fontService';
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
|
|
@ -57,6 +60,8 @@ const DASH_PRESETS: Record<LineStyle, number[]> = {
|
|||
export interface ShapePropertiesProps {
|
||||
object: CanvasObject;
|
||||
onUpdate: (id: string, changes: Partial<CanvasObject>) => void;
|
||||
/** Called to replace a text object with an image object (convert-to-paths). */
|
||||
onConvertToPath?: (textObjectId: string, imageObject: ImageObject) => void;
|
||||
}
|
||||
|
||||
// -- Component ----------------------------------------------------------------
|
||||
|
|
@ -64,10 +69,14 @@ export interface ShapePropertiesProps {
|
|||
export default function ShapeProperties({
|
||||
object,
|
||||
onUpdate,
|
||||
onConvertToPath,
|
||||
}: ShapePropertiesProps) {
|
||||
const hasStroke = object.type !== 'image';
|
||||
const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse' || object.type === 'text';
|
||||
const isLine = object.type === 'line';
|
||||
const isText = object.type === 'text';
|
||||
|
||||
const [converting, setConverting] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(changes: Partial<CanvasObject>) => {
|
||||
|
|
@ -76,6 +85,60 @@ export default function ShapeProperties({
|
|||
[object.id, onUpdate],
|
||||
);
|
||||
|
||||
/** Convert the current text object to an SVG image via fontService. */
|
||||
const handleConvertToPath = useCallback(async () => {
|
||||
if (object.type !== 'text' || !onConvertToPath) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Convert text to paths? This replaces the editable text with a vector image and cannot be undone.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const textObj = object as TextObject;
|
||||
setConverting(true);
|
||||
|
||||
try {
|
||||
const result = await textToPathData(
|
||||
textObj.text,
|
||||
textObj.fontFamily,
|
||||
textObj.fontSize,
|
||||
textObj.letterSpacing,
|
||||
);
|
||||
|
||||
// Build an SVG string from the path data
|
||||
const svgString = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${result.width} ${result.height}" width="${result.width}" height="${result.height}">`,
|
||||
`<path d="${result.pathData}" fill="${textObj.fill}" stroke="${textObj.stroke}" stroke-width="${textObj.strokeWidth}"/>`,
|
||||
`</svg>`,
|
||||
].join('');
|
||||
|
||||
// Create a Blob URL for the SVG
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||
const src = URL.createObjectURL(blob);
|
||||
|
||||
const imageObj: ImageObject = {
|
||||
type: 'image',
|
||||
id: `path-${Date.now()}`,
|
||||
name: `${textObj.name} (paths)`,
|
||||
x: textObj.x,
|
||||
y: textObj.y,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
rotation: textObj.rotation,
|
||||
visible: textObj.visible,
|
||||
locked: textObj.locked,
|
||||
opacity: textObj.opacity,
|
||||
src,
|
||||
};
|
||||
|
||||
onConvertToPath(textObj.id, imageObj);
|
||||
} catch (err) {
|
||||
console.error('Convert to paths failed:', err);
|
||||
} finally {
|
||||
setConverting(false);
|
||||
}
|
||||
}, [object, onConvertToPath]);
|
||||
|
||||
return (
|
||||
<div className="shape-properties" data-testid="shape-properties">
|
||||
<div className="shape-properties-header">Properties</div>
|
||||
|
|
@ -98,6 +161,110 @@ export default function ShapeProperties({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Text-specific controls ---- */}
|
||||
{isText && object.type === 'text' && (
|
||||
<>
|
||||
{/* Text content */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="text-content">
|
||||
Text
|
||||
</label>
|
||||
<textarea
|
||||
id="text-content"
|
||||
className="shape-prop-textarea"
|
||||
rows={3}
|
||||
value={object.text}
|
||||
onChange={(e) =>
|
||||
handleChange({ text: e.target.value } as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="text-content-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font family */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="font-family">
|
||||
Font Family
|
||||
</label>
|
||||
<select
|
||||
id="font-family"
|
||||
className="shape-prop-select"
|
||||
value={object.fontFamily}
|
||||
onChange={(e) =>
|
||||
handleChange({ fontFamily: e.target.value } as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="font-family-select"
|
||||
>
|
||||
{getAvailableFonts().map((f) => (
|
||||
<option key={f.family} value={f.family}>
|
||||
{f.family}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="font-size">
|
||||
Font Size
|
||||
</label>
|
||||
<input
|
||||
id="font-size"
|
||||
type="number"
|
||||
className="shape-prop-number-input"
|
||||
min={8}
|
||||
max={200}
|
||||
step={1}
|
||||
value={object.fontSize}
|
||||
onChange={(e) =>
|
||||
handleChange({ fontSize: Number(e.target.value) } as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="font-size-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Letter spacing */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="letter-spacing">
|
||||
Letter Spacing
|
||||
</label>
|
||||
<input
|
||||
id="letter-spacing"
|
||||
type="number"
|
||||
className="shape-prop-number-input"
|
||||
min={-5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
value={object.letterSpacing}
|
||||
onChange={(e) =>
|
||||
handleChange({ letterSpacing: Number(e.target.value) } as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="letter-spacing-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line height */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="line-height">
|
||||
Line Height
|
||||
</label>
|
||||
<input
|
||||
id="line-height"
|
||||
type="number"
|
||||
className="shape-prop-number-input"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={object.lineHeight}
|
||||
onChange={(e) =>
|
||||
handleChange({ lineHeight: Number(e.target.value) } as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="line-height-input"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stroke color */}
|
||||
{hasStroke && (
|
||||
<div className="shape-prop-section">
|
||||
|
|
@ -231,6 +398,21 @@ export default function ShapeProperties({
|
|||
<span>{Math.round(object.rotation)}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Convert to Paths (text objects only) */}
|
||||
{isText && onConvertToPath && (
|
||||
<div className="shape-prop-section">
|
||||
<button
|
||||
type="button"
|
||||
className="shape-prop-convert-btn"
|
||||
onClick={handleConvertToPath}
|
||||
disabled={converting}
|
||||
data-testid="convert-to-paths-btn"
|
||||
>
|
||||
{converting ? 'Converting…' : 'Convert to Paths'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type Konva from 'konva';
|
||||
import type { TraceMetadata } from '../types/engine';
|
||||
import type { ArtboardConfig, CanvasObject } from '../types/canvas';
|
||||
import type { ArtboardConfig, CanvasObject, ImageObject } from '../types/canvas';
|
||||
import { useCanvasState } from '../hooks/useCanvasState';
|
||||
import ArtboardSetup from '../components/canvas/ArtboardSetup';
|
||||
import KonvaStage from '../components/canvas/KonvaStage';
|
||||
|
|
@ -223,6 +223,17 @@ export default function DesignCanvas({
|
|||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [undo, redo, removeObject, selectObjects, deselectAll, state.objects, state.selectedIds]);
|
||||
|
||||
// -- Convert text to paths ------------------------------------------------
|
||||
|
||||
const handleConvertToPath = useCallback(
|
||||
(textObjectId: string, imageObject: ImageObject) => {
|
||||
removeObject(textObjectId);
|
||||
addObject(imageObject);
|
||||
selectObjects([imageObject.id]);
|
||||
},
|
||||
[removeObject, addObject, selectObjects],
|
||||
);
|
||||
|
||||
// -- Selected object for properties panel ---------------------------------
|
||||
|
||||
const selectedObject = useMemo(() => {
|
||||
|
|
@ -305,6 +316,7 @@ export default function DesignCanvas({
|
|||
<ShapeProperties
|
||||
object={selectedObject}
|
||||
onUpdate={updateObject}
|
||||
onConvertToPath={handleConvertToPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue