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 40690e9c80
commit 46169ecf02
7 changed files with 347 additions and 16 deletions

View file

@ -27,3 +27,4 @@
{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S03"},"ts":"2026-03-26T05:48:40.020Z","actor":"agent","hash":"22ad4efa07f9be81","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T01"},"ts":"2026-03-26T05:52:47.302Z","actor":"agent","hash":"c631fa7e62a3f1cc","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T02"},"ts":"2026-03-26T05:55:43.882Z","actor":"agent","hash":"e4c20836b9c0ee25","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T03"},"ts":"2026-03-26T05:58:01.944Z","actor":"agent","hash":"61f3bf5bb0b4c33f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}

View file

@ -36,7 +36,7 @@ This is the riskiest piece of the slice — if opentype.js path extraction doesn
- Estimate: 1h
- Files: app/src/types/canvas.ts, app/src/components/canvas/KonvaStage.tsx, app/src/components/canvas/CanvasToolbar.tsx, app/src/components/canvas/ObjectPanel.tsx, app/src/components/canvas/ShapeProperties.tsx
- Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
- [ ] **T03: Build text properties panel and convert-to-paths action in ShapeProperties** — Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData().
- [x] **T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas** — Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData().
## Text Property Controls (in ShapeProperties)
When the selected object has type 'text', show:

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M002/S03/T02",
"timestamp": 1774504547344,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd app",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 784,
"verdict": "fail"
},
{
"command": "npx vitest run --reporter=verbose",
"exitCode": 1,
"durationMs": 1590,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,78 @@
---
id: T03
parent: S03
milestone: M002
provides: []
requires: []
affects: []
key_files: ["app/src/components/canvas/ShapeProperties.tsx", "app/src/views/DesignCanvas.tsx"]
key_decisions: ["Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement", "Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s."
completed_at: 2026-03-26T05:58:01.901Z
blocker_discovered: false
---
# T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas
> Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas
## What Happened
---
id: T03
parent: S03
milestone: M002
key_files:
- app/src/components/canvas/ShapeProperties.tsx
- app/src/views/DesignCanvas.tsx
key_decisions:
- Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement
- Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline
duration: ""
verification_result: passed
completed_at: 2026-03-26T05:58:01.911Z
blocker_discovered: false
---
# T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas
**Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas**
## What Happened
Extended ShapeProperties with text-specific controls that render when the selected object has type 'text': a textarea for text content, a font family dropdown populated from fontService.getAvailableFonts(), numeric inputs for font size (8200), letter spacing (-520), and line height (0.53). Added a "Convert to Paths" button that calls fontService.textToPathData() to convert the text to SVG path data, wraps it in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject at the same position via a new onConvertToPath callback prop. The button shows a window.confirm dialog before proceeding (destructive action) and displays a "Converting…" disabled state during the async operation. Updated DesignCanvas to pass the onConvertToPath handler that removes the text object, adds the new image object, and selects it.
## Verification
Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2400ms |
| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2200ms |
## Deviations
Used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.
## Known Issues
None.
## Files Created/Modified
- `app/src/components/canvas/ShapeProperties.tsx`
- `app/src/views/DesignCanvas.tsx`
## Deviations
Used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.
## Known Issues
None.

View file

@ -1,6 +1,6 @@
{
"version": 1,
"exported_at": "2026-03-26T05:55:43.881Z",
"exported_at": "2026-03-26T05:58:01.942Z",
"milestones": [
{
"id": "M001",
@ -1384,19 +1384,25 @@
"milestone_id": "M002",
"slice_id": "S03",
"id": "T03",
"title": "Build text properties panel and convert-to-paths action in ShapeProperties",
"status": "pending",
"one_liner": "",
"narrative": "",
"verification_result": "",
"title": "Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas",
"status": "complete",
"one_liner": "Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas",
"narrative": "Extended ShapeProperties with text-specific controls that render when the selected object has type 'text': a textarea for text content, a font family dropdown populated from fontService.getAvailableFonts(), numeric inputs for font size (8200), letter spacing (-520), and line height (0.53). Added a \"Convert to Paths\" button that calls fontService.textToPathData() to convert the text to SVG path data, wraps it in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject at the same position via a new onConvertToPath callback prop. The button shows a window.confirm dialog before proceeding (destructive action) and displays a \"Converting…\" disabled state during the async operation. Updated DesignCanvas to pass the onConvertToPath handler that removes the text object, adds the new image object, and selects it.",
"verification_result": "Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s.",
"duration": "",
"completed_at": null,
"completed_at": "2026-03-26T05:58:01.901Z",
"blocker_discovered": false,
"deviations": "",
"known_issues": "",
"key_files": [],
"key_decisions": [],
"full_summary_md": "",
"deviations": "Used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.",
"known_issues": "None.",
"key_files": [
"app/src/components/canvas/ShapeProperties.tsx",
"app/src/views/DesignCanvas.tsx"
],
"key_decisions": [
"Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement",
"Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline"
],
"full_summary_md": "---\nid: T03\nparent: S03\nmilestone: M002\nkey_files:\n - app/src/components/canvas/ShapeProperties.tsx\n - app/src/views/DesignCanvas.tsx\nkey_decisions:\n - Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement\n - Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:58:01.911Z\nblocker_discovered: false\n---\n\n# T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas\n\n**Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas**\n\n## What Happened\n\nExtended ShapeProperties with text-specific controls that render when the selected object has type 'text': a textarea for text content, a font family dropdown populated from fontService.getAvailableFonts(), numeric inputs for font size (8200), letter spacing (-520), and line height (0.53). Added a \"Convert to Paths\" button that calls fontService.textToPathData() to convert the text to SVG path data, wraps it in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject at the same position via a new onConvertToPath callback prop. The button shows a window.confirm dialog before proceeding (destructive action) and displays a \"Converting…\" disabled state during the async operation. Updated DesignCanvas to pass the onConvertToPath handler that removes the text object, adds the new image object, and selects it.\n\n## Verification\n\nRan `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2400ms |\n| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2200ms |\n\n\n## Deviations\n\nUsed a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/components/canvas/ShapeProperties.tsx`\n- `app/src/views/DesignCanvas.tsx`\n",
"description": "Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData().\n\n## Text Property Controls (in ShapeProperties)\nWhen the selected object has type 'text', show:\n1. **Text content** — textarea input bound to obj.text\n2. **Font family** — dropdown/select populated from fontService.getAvailableFonts(), bound to obj.fontFamily\n3. **Font size** — number input (min 8, max 200, step 1), bound to obj.fontSize\n4. **Letter spacing** — number input (min -5, max 20, step 0.5), bound to obj.letterSpacing\n5. **Line height** — number input (min 0.5, max 3, step 0.1), bound to obj.lineHeight\n6. **Fill color** — color input (reuse existing pattern from hasFill), bound to obj.fill\n7. **Stroke color + weight** — reuse existing stroke controls\n8. **Convert to Paths button** — calls fontService.textToPathData(), then replaces the text object with a new path object (type 'image' with SVG data URL, or a new 'path' type if simpler)\n\n## Convert to Paths Implementation\n- Show confirmation dialog (window.confirm) since this is destructive\n- Call `textToPathData(obj.text, obj.fontFamily, obj.fontSize, obj.letterSpacing)` from fontService\n- Create SVG string from returned path data: `<svg xmlns=... viewBox=...><path d=\"{pathData}\" fill=\"{obj.fill}\" stroke=\"{obj.stroke}\" stroke-width=\"{obj.strokeWidth}\"/></svg>`\n- Convert SVG to Blob URL, create ImageObject replacing the TextObject at same x,y position\n- Use onUpdate to remove old text object and add new image object (or dispatch through a callback prop)\n\n## Integration Notes\n- fontService.ts from T01 provides loadFont(), getAvailableFonts(), textToPathData()\n- The text property controls should only show when object.type === 'text'\n- Existing stroke/fill/opacity/rotation controls still show for text objects\n- ShapeProperties receives onUpdate callback from DesignCanvas — convert-to-paths needs onUpdate + onAddObject + onRemoveObject, so ShapeProperties props may need extending, or the convert action can be handled via a new callback prop\n- DesignCanvas.tsx needs to pass the additional callback(s) to ShapeProperties",
"estimate": "1h",
"files": [
@ -1890,6 +1896,28 @@
"verdict": "✅ pass",
"duration_ms": 2450,
"created_at": "2026-03-26T05:55:43.833Z"
},
{
"id": 36,
"task_id": "T03",
"slice_id": "S03",
"milestone_id": "M002",
"command": "cd app && npx tsc --noEmit",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2400,
"created_at": "2026-03-26T05:58:01.901Z"
},
{
"id": 37,
"task_id": "T03",
"slice_id": "S03",
"milestone_id": "M002",
"command": "cd app && npx vitest run --reporter=verbose",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2200,
"created_at": "2026-03-26T05:58:01.901Z"
}
]
}

View file

@ -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>
);
}

View file

@ -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>