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:
parent
75217ea6cb
commit
c60dd59c01
8 changed files with 763 additions and 21 deletions
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
30
.gsd/milestones/M003/slices/S01/tasks/T03-VERIFY.json
Normal file
30
.gsd/milestones/M003/slices/S01/tasks/T03-VERIFY.json
Normal 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
|
||||||
|
}
|
||||||
82
.gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md
Normal file
82
.gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md
Normal 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 T01–T03. 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.
|
||||||
|
|
@ -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 T01–T03. 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 T01–T03. 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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
284
app/src/views/ExportView.module.css
Normal file
284
app/src/views/ExportView.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
app/src/views/ExportView.tsx
Normal file
305
app/src/views/ExportView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue