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.
|
* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue