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:
jlightner 2026-03-26 05:58:10 +00:00
parent 24fb28d622
commit 868b444595
2 changed files with 197 additions and 3 deletions

View file

@ -3,10 +3,13 @@
* *
* Shows stroke color, stroke weight, fill color (with toggle), dimensions. * Shows stroke color, stroke weight, fill color (with toggle), dimensions.
* For line objects: line style dropdown (solid, dashed, dotted). * 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 { useCallback, useState } from 'react';
import type { CanvasObject, LineStyle } from '../../types/canvas'; import type { CanvasObject, ImageObject, LineStyle, TextObject } from '../../types/canvas';
import { getAvailableFonts, textToPathData } from '../../utils/fontService';
// -- Helpers ------------------------------------------------------------------ // -- Helpers ------------------------------------------------------------------
@ -57,6 +60,8 @@ const DASH_PRESETS: Record<LineStyle, number[]> = {
export interface ShapePropertiesProps { export interface ShapePropertiesProps {
object: CanvasObject; object: CanvasObject;
onUpdate: (id: string, changes: Partial<CanvasObject>) => void; 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 ---------------------------------------------------------------- // -- Component ----------------------------------------------------------------
@ -64,10 +69,14 @@ export interface ShapePropertiesProps {
export default function ShapeProperties({ export default function ShapeProperties({
object, object,
onUpdate, onUpdate,
onConvertToPath,
}: ShapePropertiesProps) { }: ShapePropertiesProps) {
const hasStroke = object.type !== 'image'; const hasStroke = object.type !== 'image';
const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse' || object.type === 'text'; const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse' || object.type === 'text';
const isLine = object.type === 'line'; const isLine = object.type === 'line';
const isText = object.type === 'text';
const [converting, setConverting] = useState(false);
const handleChange = useCallback( const handleChange = useCallback(
(changes: Partial<CanvasObject>) => { (changes: Partial<CanvasObject>) => {
@ -76,6 +85,60 @@ export default function ShapeProperties({
[object.id, onUpdate], [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 ( return (
<div className="shape-properties" data-testid="shape-properties"> <div className="shape-properties" data-testid="shape-properties">
<div className="shape-properties-header">Properties</div> <div className="shape-properties-header">Properties</div>
@ -98,6 +161,110 @@ export default function ShapeProperties({
</div> </div>
</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 */} {/* Stroke color */}
{hasStroke && ( {hasStroke && (
<div className="shape-prop-section"> <div className="shape-prop-section">
@ -231,6 +398,21 @@ export default function ShapeProperties({
<span>{Math.round(object.rotation)}°</span> <span>{Math.round(object.rotation)}°</span>
</div> </div>
</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> </div>
); );
} }

View file

@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type Konva from 'konva'; import type Konva from 'konva';
import type { TraceMetadata } from '../types/engine'; 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 { useCanvasState } from '../hooks/useCanvasState';
import ArtboardSetup from '../components/canvas/ArtboardSetup'; import ArtboardSetup from '../components/canvas/ArtboardSetup';
import KonvaStage from '../components/canvas/KonvaStage'; import KonvaStage from '../components/canvas/KonvaStage';
@ -223,6 +223,17 @@ export default function DesignCanvas({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo, removeObject, selectObjects, deselectAll, state.objects, state.selectedIds]); }, [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 --------------------------------- // -- Selected object for properties panel ---------------------------------
const selectedObject = useMemo(() => { const selectedObject = useMemo(() => {
@ -305,6 +316,7 @@ export default function DesignCanvas({
<ShapeProperties <ShapeProperties
object={selectedObject} object={selectedObject}
onUpdate={updateObject} onUpdate={updateObject}
onConvertToPath={handleConvertToPath}
/> />
)} )}
</div> </div>