feat: Built complete ExportView with DXF/SVG/PNG format selector, valid…

- "app/src/views/ExportView.tsx"
- "app/src/views/ExportView.module.css"
- "app/src/App.tsx"

GSD-Task: S01/T04
This commit is contained in:
jlightner 2026-03-26 06:29:21 +00:00
parent 75217ea6cb
commit c60dd59c01
8 changed files with 763 additions and 21 deletions

View file

@ -34,3 +34,4 @@
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T06:16:59.236Z","actor":"agent","hash":"f6bd52e1fbbe7e7f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T06:16:59.236Z","actor":"agent","hash":"f6bd52e1fbbe7e7f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T06:19:28.695Z","actor":"agent","hash":"20e62f4b5af835c3","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T06:19:28.695Z","actor":"agent","hash":"20e62f4b5af835c3","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T06:26:04.608Z","actor":"agent","hash":"b3de5441cc811cf7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T06:26:04.608Z","actor":"agent","hash":"b3de5441cc811cf7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T04"},"ts":"2026-03-26T06:29:08.965Z","actor":"agent","hash":"c8adae40d118a764","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}

View file

@ -40,7 +40,7 @@ Unit tests cover: SVG composition with known objects produces correct SVG elemen
- Estimate: 2h - Estimate: 2h
- Files: app/src/utils/exportService.ts, app/src/utils/__tests__/exportService.test.ts, app/src/api/engine.ts, app/src/api/__tests__/engine.test.ts - Files: app/src/utils/exportService.ts, app/src/utils/__tests__/exportService.test.ts, app/src/api/engine.ts, app/src/api/__tests__/engine.test.ts
- Verify: cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts && npx tsc -b --noEmit - Verify: cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts && npx tsc -b --noEmit
- [ ] **T04: Build Export view UI with format selection, validation panel, and download wiring** — This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads. - [x] **T04: Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring** — This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads.
ExportView layout: ExportView layout:
- Header with "Export" title and a "← Back to Design" button that navigates back to View 2 - Header with "Export" title and a "← Back to Design" button that navigates back to View 2

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T03",
"unitId": "M003/S01/T03",
"timestamp": 1774506369509,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd app",
"exitCode": 0,
"durationMs": 6,
"verdict": "pass"
},
{
"command": "npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts",
"exitCode": 1,
"durationMs": 1260,
"verdict": "fail"
},
{
"command": "npx tsc -b --noEmit",
"exitCode": 1,
"durationMs": 725,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,82 @@
---
id: T04
parent: S01
milestone: M003
provides: []
requires: []
affects: []
key_files: ["app/src/views/ExportView.tsx", "app/src/views/ExportView.module.css", "app/src/App.tsx"]
key_decisions: ["PNG export skips vector validation — only requires pngDataUrl", "Unit selector shown only for DXF/SVG formats", "Data URL to Blob conversion for PNG uses fetch(dataUrl).blob() pattern"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "cd app && npx tsc -b --noEmit — exit 0. cd app && npx vitest run — 120/120 pass. cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass."
completed_at: 2026-03-26T06:29:08.918Z
blocker_discovered: false
---
# T04: Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring
> Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring
## What Happened
---
id: T04
parent: S01
milestone: M003
key_files:
- app/src/views/ExportView.tsx
- app/src/views/ExportView.module.css
- app/src/App.tsx
key_decisions:
- PNG export skips vector validation — only requires pngDataUrl
- Unit selector shown only for DXF/SVG formats
- Data URL to Blob conversion for PNG uses fetch(dataUrl).blob() pattern
duration: ""
verification_result: passed
completed_at: 2026-03-26T06:29:08.930Z
blocker_discovered: false
---
# T04: Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring
**Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring**
## What Happened
Created ExportView component (View 3) that wires together all export pieces from T01T03. Split layout with canvas preview in main panel and controls in side panel: format selection cards (DXF/SVG/PNG), unit selector (inches/mm for vector formats), reactive validation panel using validateForExport(), and download button. DXF flow calls composeCanvasSVG() → exportAsDxf() → triggerDownload(). SVG creates blob from composed SVG. PNG converts captured data URL to blob. Updated App.tsx to replace placeholder with real ExportView component.
## Verification
cd app && npx tsc -b --noEmit — exit 0. cd app && npx vitest run — 120/120 pass. cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd app && npx tsc -b --noEmit` | 0 | ✅ pass | 2500ms |
| 2 | `cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts` | 0 | ✅ pass (34/34) | 882ms |
| 3 | `cd app && npx vitest run` | 0 | ✅ pass (120/120) | 2250ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `app/src/views/ExportView.tsx`
- `app/src/views/ExportView.module.css`
- `app/src/App.tsx`
## Deviations
None.
## Known Issues
None.

View file

@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"exported_at": "2026-03-26T06:26:04.605Z", "exported_at": "2026-03-26T06:29:08.963Z",
"milestones": [ "milestones": [
{ {
"id": "M001", "id": "M001",
@ -1574,19 +1574,27 @@
"milestone_id": "M003", "milestone_id": "M003",
"slice_id": "S01", "slice_id": "S01",
"id": "T04", "id": "T04",
"title": "Build Export view UI with format selection, validation panel, and download wiring", "title": "Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring",
"status": "pending", "status": "complete",
"one_liner": "", "one_liner": "Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring",
"narrative": "", "narrative": "Created ExportView component (View 3) that wires together all export pieces from T01T03. Split layout with canvas preview in main panel and controls in side panel: format selection cards (DXF/SVG/PNG), unit selector (inches/mm for vector formats), reactive validation panel using validateForExport(), and download button. DXF flow calls composeCanvasSVG() → exportAsDxf() → triggerDownload(). SVG creates blob from composed SVG. PNG converts captured data URL to blob. Updated App.tsx to replace placeholder with real ExportView component.",
"verification_result": "", "verification_result": "cd app && npx tsc -b --noEmit — exit 0. cd app && npx vitest run — 120/120 pass. cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass.",
"duration": "", "duration": "",
"completed_at": null, "completed_at": "2026-03-26T06:29:08.918Z",
"blocker_discovered": false, "blocker_discovered": false,
"deviations": "", "deviations": "None.",
"known_issues": "", "known_issues": "None.",
"key_files": [], "key_files": [
"key_decisions": [], "app/src/views/ExportView.tsx",
"full_summary_md": "", "app/src/views/ExportView.module.css",
"app/src/App.tsx"
],
"key_decisions": [
"PNG export skips vector validation — only requires pngDataUrl",
"Unit selector shown only for DXF/SVG formats",
"Data URL to Blob conversion for PNG uses fetch(dataUrl).blob() pattern"
],
"full_summary_md": "---\nid: T04\nparent: S01\nmilestone: M003\nkey_files:\n - app/src/views/ExportView.tsx\n - app/src/views/ExportView.module.css\n - app/src/App.tsx\nkey_decisions:\n - PNG export skips vector validation — only requires pngDataUrl\n - Unit selector shown only for DXF/SVG formats\n - Data URL to Blob conversion for PNG uses fetch(dataUrl).blob() pattern\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T06:29:08.930Z\nblocker_discovered: false\n---\n\n# T04: Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring\n\n**Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring**\n\n## What Happened\n\nCreated ExportView component (View 3) that wires together all export pieces from T01T03. Split layout with canvas preview in main panel and controls in side panel: format selection cards (DXF/SVG/PNG), unit selector (inches/mm for vector formats), reactive validation panel using validateForExport(), and download button. DXF flow calls composeCanvasSVG() → exportAsDxf() → triggerDownload(). SVG creates blob from composed SVG. PNG converts captured data URL to blob. Updated App.tsx to replace placeholder with real ExportView component.\n\n## Verification\n\ncd app && npx tsc -b --noEmit — exit 0. cd app && npx vitest run — 120/120 pass. cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx tsc -b --noEmit` | 0 | ✅ pass | 2500ms |\n| 2 | `cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts` | 0 | ✅ pass (34/34) | 882ms |\n| 3 | `cd app && npx vitest run` | 0 | ✅ pass (120/120) | 2250ms |\n\n\n## Deviations\n\nNone.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/views/ExportView.tsx`\n- `app/src/views/ExportView.module.css`\n- `app/src/App.tsx`\n",
"description": "This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads.\n\nExportView layout:\n- Header with \"Export\" title and a \"← Back to Design\" button that navigates back to View 2\n- Format selector: three cards/buttons for DXF, SVG, PNG with descriptions\n- Unit selector (DXF/SVG only): inches or mm radio buttons, defaulting to the artboard's unit\n- Validation panel: shows blocking errors (red, disables export) and warnings (yellow, allows export). Runs `validateForExport()` on mount and when objects change\n- Canvas preview: a small thumbnail of the current design (use Konva `stage.toDataURL()` from the stageRef passed through App.tsx, captured before navigating to export view)\n- Download button: disabled when blocking errors exist; triggers the appropriate export flow\n\nExport flows by format:\n- **DXF**: Call `composeCanvasSVG()` → call `exportAsDxf()` with units and scale_factor (1/96 for inches, 25.4/96 for mm) → `triggerDownload()` with the returned blob and filename `export.dxf`\n- **SVG**: Call `composeCanvasSVG()` → create blob from SVG string → `triggerDownload()` with filename `export.svg`\n- **PNG**: Use the preview data URL (captured from Konva stage before navigating) or re-render. Since stageRef may not be mounted in View 3, capture PNG data URL before transitioning to export view and pass it as a prop. Convert data URL to blob → `triggerDownload()` with filename `export.png`\n\nPNG capture strategy: When user clicks 'Export' in View 2, capture `stageRef.current.toDataURL({ pixelRatio: 2 })` and store in App.tsx state, then navigate to export view. This avoids needing the Konva stage mounted in View 3.\n\nApp.tsx updates:\n- Add `pngDataUrl` state, set it in the onExport handler before view transition\n- Pass `pngDataUrl` to ExportView\n- Wire ExportView's onBack to navigate back to canvas view\n\nCSS: New `ExportView.module.css` with the view layout. Follow existing patterns from DesignCanvas.module.css and App.css.", "description": "This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads.\n\nExportView layout:\n- Header with \"Export\" title and a \"← Back to Design\" button that navigates back to View 2\n- Format selector: three cards/buttons for DXF, SVG, PNG with descriptions\n- Unit selector (DXF/SVG only): inches or mm radio buttons, defaulting to the artboard's unit\n- Validation panel: shows blocking errors (red, disables export) and warnings (yellow, allows export). Runs `validateForExport()` on mount and when objects change\n- Canvas preview: a small thumbnail of the current design (use Konva `stage.toDataURL()` from the stageRef passed through App.tsx, captured before navigating to export view)\n- Download button: disabled when blocking errors exist; triggers the appropriate export flow\n\nExport flows by format:\n- **DXF**: Call `composeCanvasSVG()` → call `exportAsDxf()` with units and scale_factor (1/96 for inches, 25.4/96 for mm) → `triggerDownload()` with the returned blob and filename `export.dxf`\n- **SVG**: Call `composeCanvasSVG()` → create blob from SVG string → `triggerDownload()` with filename `export.svg`\n- **PNG**: Use the preview data URL (captured from Konva stage before navigating) or re-render. Since stageRef may not be mounted in View 3, capture PNG data URL before transitioning to export view and pass it as a prop. Convert data URL to blob → `triggerDownload()` with filename `export.png`\n\nPNG capture strategy: When user clicks 'Export' in View 2, capture `stageRef.current.toDataURL({ pixelRatio: 2 })` and store in App.tsx state, then navigate to export view. This avoids needing the Konva stage mounted in View 3.\n\nApp.tsx updates:\n- Add `pngDataUrl` state, set it in the onExport handler before view transition\n- Pass `pngDataUrl` to ExportView\n- Wire ExportView's onBack to navigate back to canvas view\n\nCSS: New `ExportView.module.css` with the view layout. Follow existing patterns from DesignCanvas.module.css and App.css.",
"estimate": "2h", "estimate": "2h",
"files": [ "files": [
@ -2218,6 +2226,39 @@
"verdict": "✅ pass", "verdict": "✅ pass",
"duration_ms": 6000, "duration_ms": 6000,
"created_at": "2026-03-26T06:26:04.560Z" "created_at": "2026-03-26T06:26:04.560Z"
},
{
"id": 46,
"task_id": "T04",
"slice_id": "S01",
"milestone_id": "M003",
"command": "cd app && npx tsc -b --noEmit",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2500,
"created_at": "2026-03-26T06:29:08.918Z"
},
{
"id": 47,
"task_id": "T04",
"slice_id": "S01",
"milestone_id": "M003",
"command": "cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts",
"exit_code": 0,
"verdict": "✅ pass (34/34)",
"duration_ms": 882,
"created_at": "2026-03-26T06:29:08.918Z"
},
{
"id": 48,
"task_id": "T04",
"slice_id": "S01",
"milestone_id": "M003",
"command": "cd app && npx vitest run",
"exit_code": 0,
"verdict": "✅ pass (120/120)",
"duration_ms": 2250,
"created_at": "2026-03-26T06:29:08.918Z"
} }
] ]
} }

View file

@ -4,6 +4,7 @@ import type { TraceMetadata } from './types/engine';
import { useCanvasState } from './hooks/useCanvasState'; import { useCanvasState } from './hooks/useCanvasState';
import ImportConvert from './views/ImportConvert'; import ImportConvert from './views/ImportConvert';
import DesignCanvas from './views/DesignCanvas'; import DesignCanvas from './views/DesignCanvas';
import ExportView from './views/ExportView';
import './App.css'; import './App.css';
type ViewState = 'import' | 'canvas' | 'export'; type ViewState = 'import' | 'canvas' | 'export';
@ -51,14 +52,12 @@ function App() {
/> />
)} )}
{view === 'export' && ( {view === 'export' && (
<div className="placeholder-view" data-testid="export-view"> <ExportView
<p>View 3: Export (placeholder)</p> objects={canvasState.state.objects}
<p>PNG preview captured: {pngDataUrl ? 'Yes' : 'No'}</p> artboard={canvasState.state.artboard}
<p>Objects: {canvasState.state.objects.length}</p> pngDataUrl={pngDataUrl}
<button type="button" onClick={handleBackToCanvas}> onBack={handleBackToCanvas}
Back to Design />
</button>
</div>
)} )}
</div> </div>
); );

View file

@ -0,0 +1,284 @@
/* Export View (View 3) layout */
.container {
display: flex;
flex-direction: column;
width: 100%;
height: 100svh;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.headerTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-h);
margin: 0;
}
.backBtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--text-h);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s;
}
.backBtn:hover {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.body {
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.mainPanel {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 32px 24px;
min-width: 0;
}
.sidePanel {
flex: 0 0 320px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Preview thumbnail */
.previewSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 480px;
}
.previewLabel {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text);
align-self: flex-start;
}
.previewImage {
max-width: 100%;
max-height: 340px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--code-bg);
object-fit: contain;
}
.previewPlaceholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 200px;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
}
/* Format selector */
.sectionLabel {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text);
margin: 0 0 8px;
}
.formatGrid {
display: flex;
flex-direction: column;
gap: 6px;
}
.formatCard {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
text-align: left;
transition: border-color 0.12s, background-color 0.12s;
font-family: inherit;
}
.formatCard:hover {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.formatCardSelected {
border-color: var(--accent);
background: var(--accent-bg);
box-shadow: 0 0 0 1px var(--accent);
}
.formatName {
font-size: 14px;
font-weight: 600;
color: var(--text-h);
}
.formatDesc {
font-size: 12px;
color: var(--text);
line-height: 1.3;
}
/* Unit selector */
.unitSelector {
display: flex;
gap: 16px;
align-items: center;
}
.unitLabel {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-h);
cursor: pointer;
}
.unitLabel input[type="radio"] {
accent-color: var(--accent);
}
/* Validation panel */
.validationPanel {
display: flex;
flex-direction: column;
gap: 6px;
}
.validationItem {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
line-height: 1.4;
}
.validationError {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
border: 1px solid rgba(231, 76, 60, 0.25);
}
.validationWarning {
background: rgba(243, 156, 18, 0.1);
color: #e67e22;
border: 1px solid rgba(243, 156, 18, 0.25);
}
.validationIcon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.4;
}
.validationOk {
font-size: 13px;
color: #27ae60;
padding: 8px 10px;
}
/* Download button */
.downloadBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 24px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
font-family: inherit;
}
.downloadBtn:hover {
opacity: 0.9;
}
.downloadBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.downloadBtnExporting {
opacity: 0.7;
cursor: wait;
}
.exportError {
font-size: 13px;
color: #e74c3c;
padding: 8px 10px;
background: rgba(231, 76, 60, 0.08);
border-radius: 6px;
}
/* Responsive */
@media (max-width: 768px) {
.body {
flex-direction: column;
}
.sidePanel {
flex: none;
border-left: none;
border-top: 1px solid var(--border);
}
}

View file

@ -0,0 +1,305 @@
/**
* ExportView (View 3) format selection, validation, and download.
*
* Receives canvas state from App.tsx (lifted in T02) and uses the export
* service (T03) to compose SVG, validate, call the engine API, and trigger
* downloads.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ArtboardConfig, ArtboardUnit, CanvasObject } from '../types/canvas';
import {
composeCanvasSVG,
validateForExport,
triggerDownload,
type ValidationResult,
} from '../utils/exportService';
import { exportAsDxf } from '../api/engine';
import styles from './ExportView.module.css';
// -- Types --------------------------------------------------------------------
export type ExportFormat = 'dxf' | 'svg' | 'png';
export interface ExportViewProps {
objects: CanvasObject[];
artboard: ArtboardConfig | null;
pngDataUrl: string | null;
onBack: () => void;
}
interface FormatOption {
id: ExportFormat;
name: string;
description: string;
}
const FORMAT_OPTIONS: FormatOption[] = [
{
id: 'dxf',
name: 'DXF',
description: 'AutoCAD / LightBurn — vector paths with real-world scale',
},
{
id: 'svg',
name: 'SVG',
description: 'Scalable vector — Inkscape, Illustrator, web',
},
{
id: 'png',
name: 'PNG',
description: 'Raster image — print preview, sharing',
},
];
// Scale factors: canvas uses 96 PPI internally
const SCALE_FACTORS: Record<ArtboardUnit, number> = {
inches: 1 / 96,
mm: 25.4 / 96,
};
// -- Component ----------------------------------------------------------------
export default function ExportView({
objects,
artboard,
pngDataUrl,
onBack,
}: ExportViewProps) {
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('dxf');
const [selectedUnit, setSelectedUnit] = useState<ArtboardUnit>(
artboard?.unit ?? 'inches',
);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
// Run validation on mount and when objects/artboard change
const validation: ValidationResult = useMemo(
() => validateForExport(objects, artboard),
[objects, artboard],
);
const hasBlockingErrors = validation.issues.some(
(i) => i.severity === 'error',
);
// Reset unit when artboard changes
useEffect(() => {
if (artboard) {
setSelectedUnit(artboard.unit);
}
}, [artboard]);
// Whether unit selector is relevant for the selected format
const showUnitSelector = selectedFormat === 'dxf' || selectedFormat === 'svg';
// -- Export handlers --------------------------------------------------------
const handleExport = useCallback(async () => {
if (!artboard && selectedFormat !== 'png') return;
setExporting(true);
setExportError(null);
try {
switch (selectedFormat) {
case 'dxf': {
const svgString = composeCanvasSVG(objects, artboard!);
const scaleFactor = SCALE_FACTORS[selectedUnit];
const dxfBlob = await exportAsDxf(
svgString,
selectedUnit,
scaleFactor,
);
triggerDownload(dxfBlob, 'export.dxf');
break;
}
case 'svg': {
const svgString = composeCanvasSVG(objects, artboard!);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml' });
triggerDownload(svgBlob, 'export.svg');
break;
}
case 'png': {
if (!pngDataUrl) {
setExportError('No PNG preview available. Go back and try again.');
break;
}
// Convert data URL to Blob
const response = await fetch(pngDataUrl);
const pngBlob = await response.blob();
triggerDownload(pngBlob, 'export.png');
break;
}
}
} catch (err) {
const message =
err instanceof Error ? err.message : 'Export failed unexpectedly.';
setExportError(message);
console.error('[ExportView] export error:', err);
} finally {
setExporting(false);
}
}, [selectedFormat, selectedUnit, objects, artboard, pngDataUrl]);
// Disable download when there are blocking errors (except PNG which
// doesn't need artboard/vector validation)
const downloadDisabled =
exporting ||
(selectedFormat !== 'png' && hasBlockingErrors) ||
(selectedFormat === 'png' && !pngDataUrl);
// -- Render -----------------------------------------------------------------
return (
<div className={styles.container} data-testid="export-view">
{/* Header */}
<header className={styles.header}>
<h1 className={styles.headerTitle}>Export</h1>
<button
type="button"
className={styles.backBtn}
onClick={onBack}
data-testid="export-back-btn"
>
Back to Design
</button>
</header>
<div className={styles.body}>
{/* Main panel — preview */}
<div className={styles.mainPanel}>
<div className={styles.previewSection}>
<span className={styles.previewLabel}>Preview</span>
{pngDataUrl ? (
<img
className={styles.previewImage}
src={pngDataUrl}
alt="Design preview"
data-testid="export-preview"
/>
) : (
<div className={styles.previewPlaceholder}>
No preview available
</div>
)}
</div>
</div>
{/* Side panel — format, units, validation, download */}
<aside className={styles.sidePanel}>
{/* Format selector */}
<div>
<p className={styles.sectionLabel}>Format</p>
<div className={styles.formatGrid} data-testid="format-selector">
{FORMAT_OPTIONS.map((fmt) => (
<button
key={fmt.id}
type="button"
className={`${styles.formatCard}${
selectedFormat === fmt.id
? ` ${styles.formatCardSelected}`
: ''
}`}
onClick={() => {
setSelectedFormat(fmt.id);
setExportError(null);
}}
data-testid={`format-${fmt.id}`}
>
<span className={styles.formatName}>{fmt.name}</span>
<span className={styles.formatDesc}>{fmt.description}</span>
</button>
))}
</div>
</div>
{/* Unit selector (DXF/SVG only) */}
{showUnitSelector && (
<div>
<p className={styles.sectionLabel}>Units</p>
<div
className={styles.unitSelector}
data-testid="unit-selector"
>
<label className={styles.unitLabel}>
<input
type="radio"
name="export-unit"
value="inches"
checked={selectedUnit === 'inches'}
onChange={() => setSelectedUnit('inches')}
/>
Inches
</label>
<label className={styles.unitLabel}>
<input
type="radio"
name="export-unit"
value="mm"
checked={selectedUnit === 'mm'}
onChange={() => setSelectedUnit('mm')}
/>
Millimeters
</label>
</div>
</div>
)}
{/* Validation panel */}
<div>
<p className={styles.sectionLabel}>Validation</p>
<div
className={styles.validationPanel}
data-testid="validation-panel"
>
{validation.issues.length === 0 ? (
<p className={styles.validationOk}> Ready to export</p>
) : (
validation.issues.map((issue, i) => (
<div
key={`${issue.objectId ?? 'global'}-${i}`}
className={`${styles.validationItem} ${
issue.severity === 'error'
? styles.validationError
: styles.validationWarning
}`}
data-testid={`validation-${issue.severity}`}
>
<span className={styles.validationIcon}>
{issue.severity === 'error' ? '✕' : '⚠'}
</span>
<span>{issue.message}</span>
</div>
))
)}
</div>
</div>
{/* Export error */}
{exportError && (
<div className={styles.exportError} data-testid="export-error">
{exportError}
</div>
)}
{/* Download button */}
<button
type="button"
className={`${styles.downloadBtn}${
exporting ? ` ${styles.downloadBtnExporting}` : ''
}`}
disabled={downloadDisabled}
onClick={handleExport}
data-testid="download-btn"
>
{exporting
? 'Exporting…'
: `Download ${selectedFormat.toUpperCase()}`}
</button>
</aside>
</div>
</div>
);
}