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 */} + +
+
+ ); +}