From c60dd59c01c9360a24024cbde67f33475467ea0c Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 06:29:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20complete=20ExportView=20with=20?= =?UTF-8?q?DXF/SVG/PNG=20format=20selector,=20valid=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "app/src/views/ExportView.tsx" - "app/src/views/ExportView.module.css" - "app/src/App.tsx" GSD-Task: S01/T04 --- .gsd/event-log.jsonl | 1 + .gsd/milestones/M003/slices/S01/S01-PLAN.md | 2 +- .../M003/slices/S01/tasks/T03-VERIFY.json | 30 ++ .../M003/slices/S01/tasks/T04-SUMMARY.md | 82 +++++ .gsd/state-manifest.json | 65 +++- app/src/App.tsx | 15 +- app/src/views/ExportView.module.css | 284 ++++++++++++++++ app/src/views/ExportView.tsx | 305 ++++++++++++++++++ 8 files changed, 763 insertions(+), 21 deletions(-) create mode 100644 .gsd/milestones/M003/slices/S01/tasks/T03-VERIFY.json create mode 100644 .gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md create mode 100644 app/src/views/ExportView.module.css create mode 100644 app/src/views/ExportView.tsx diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index b9e04ab..335a421 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -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":"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":"T04"},"ts":"2026-03-26T06:29:08.965Z","actor":"agent","hash":"c8adae40d118a764","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} diff --git a/.gsd/milestones/M003/slices/S01/S01-PLAN.md b/.gsd/milestones/M003/slices/S01/S01-PLAN.md index 3f06042..49d4768 100644 --- a/.gsd/milestones/M003/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M003/slices/S01/S01-PLAN.md @@ -40,7 +40,7 @@ Unit tests cover: SVG composition with known objects produces correct SVG elemen - 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 - 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: - Header with "Export" title and a "← Back to Design" button that navigates back to View 2 diff --git a/.gsd/milestones/M003/slices/S01/tasks/T03-VERIFY.json b/.gsd/milestones/M003/slices/S01/tasks/T03-VERIFY.json new file mode 100644 index 0000000..c6feab1 --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T03-VERIFY.json @@ -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 +} diff --git a/.gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md b/.gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md new file mode 100644 index 0000000..55020cc --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T04-SUMMARY.md @@ -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. diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index 9577ee6..0636028 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "exported_at": "2026-03-26T06:26:04.605Z", + "exported_at": "2026-03-26T06:29:08.963Z", "milestones": [ { "id": "M001", @@ -1574,19 +1574,27 @@ "milestone_id": "M003", "slice_id": "S01", "id": "T04", - "title": "Build Export view UI with format selection, validation panel, and download wiring", - "status": "pending", - "one_liner": "", - "narrative": "", - "verification_result": "", + "title": "Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring", + "status": "complete", + "one_liner": "Built complete ExportView with DXF/SVG/PNG format selector, validation panel, unit selector, and download wiring", + "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": "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": "", - "completed_at": null, + "completed_at": "2026-03-26T06:29:08.918Z", "blocker_discovered": false, - "deviations": "", - "known_issues": "", - "key_files": [], - "key_decisions": [], - "full_summary_md": "", + "deviations": "None.", + "known_issues": "None.", + "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" + ], + "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.", "estimate": "2h", "files": [ @@ -2218,6 +2226,39 @@ "verdict": "✅ pass", "duration_ms": 6000, "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" } ] } \ No newline at end of file diff --git a/app/src/App.tsx b/app/src/App.tsx index 8569544..55543a0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,6 +4,7 @@ import type { TraceMetadata } from './types/engine'; import { useCanvasState } from './hooks/useCanvasState'; import ImportConvert from './views/ImportConvert'; import DesignCanvas from './views/DesignCanvas'; +import ExportView from './views/ExportView'; import './App.css'; type ViewState = 'import' | 'canvas' | 'export'; @@ -51,14 +52,12 @@ function App() { /> )} {view === 'export' && ( -
-

View 3: Export (placeholder)

-

PNG preview captured: {pngDataUrl ? 'Yes' : 'No'}

-

Objects: {canvasState.state.objects.length}

- -
+ )} ); diff --git a/app/src/views/ExportView.module.css b/app/src/views/ExportView.module.css new file mode 100644 index 0000000..7ef3b22 --- /dev/null +++ b/app/src/views/ExportView.module.css @@ -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); + } +} diff --git a/app/src/views/ExportView.tsx b/app/src/views/ExportView.tsx new file mode 100644 index 0000000..70a24d6 --- /dev/null +++ b/app/src/views/ExportView.tsx @@ -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 = { + inches: 1 / 96, + mm: 25.4 / 96, +}; + +// -- Component ---------------------------------------------------------------- + +export default function ExportView({ + objects, + artboard, + pngDataUrl, + onBack, +}: ExportViewProps) { + const [selectedFormat, setSelectedFormat] = useState('dxf'); + const [selectedUnit, setSelectedUnit] = useState( + artboard?.unit ?? 'inches', + ); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(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 ( +
+ {/* Header */} +
+

Export

+ +
+ +
+ {/* Main panel — preview */} +
+
+ Preview + {pngDataUrl ? ( + Design preview + ) : ( +
+ No preview available +
+ )} +
+
+ + {/* Side panel — format, units, validation, download */} + +
+
+ ); +}