From c3783e1680528b94bce357d5cc8baf0671c12f77 Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 05:15:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20useDebouncedTrace=20hook=20with?= =?UTF-8?q?=20AbortController=20cancellation,=20P=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "app/src/hooks/useDebouncedTrace.ts" - "app/src/components/ParameterSliders.tsx" - "app/src/components/SvgPreview.tsx" - "app/src/views/ImportConvert.tsx" - "app/src/hooks/__tests__/useDebouncedTrace.test.ts" - "app/src/App.css" - "app/src/App.tsx" GSD-Task: S01/T03 --- app/src/App.css | 163 ++++++++++++++ app/src/App.tsx | 4 +- app/src/components/ParameterSliders.tsx | 135 ++++++++++++ app/src/components/SvgPreview.tsx | 81 +++++++ .../hooks/__tests__/useDebouncedTrace.test.ts | 199 ++++++++++++++++++ app/src/hooks/useDebouncedTrace.ts | 123 +++++++++++ app/src/views/ImportConvert.tsx | 109 ++++++++-- 7 files changed, 793 insertions(+), 21 deletions(-) create mode 100644 app/src/components/ParameterSliders.tsx create mode 100644 app/src/components/SvgPreview.tsx create mode 100644 app/src/hooks/__tests__/useDebouncedTrace.test.ts create mode 100644 app/src/hooks/useDebouncedTrace.ts diff --git a/app/src/App.css b/app/src/App.css index b6c22ed..ebfac2e 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -161,6 +161,169 @@ line-height: 1.3; } +/* Parameter Sliders */ +.parameter-sliders { + display: flex; + flex-direction: column; + gap: 12px; +} + +.parameter-heading { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.slider-row { + display: grid; + grid-template-columns: 120px 1fr 48px; + align-items: center; + gap: 8px; +} + +.slider-label { + font-size: 13px; + color: var(--text-h); + white-space: nowrap; +} + +.slider-input { + width: 100%; + height: 4px; + cursor: pointer; + accent-color: var(--accent); +} + +.slider-value { + font-size: 13px; + font-weight: 500; + color: var(--accent); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* SVG Preview */ +.svg-preview { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 300px; +} + +.svg-preview--loading { + gap: 12px; +} + +.svg-preview-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.svg-preview-status { + font-size: 14px; + color: var(--text); + margin: 0; +} + +.svg-preview--error { + gap: 4px; +} + +.svg-preview-error { + font-size: 14px; + color: #e74c3c; + margin: 0; +} + +.svg-preview-hint { + font-size: 13px; + color: var(--text); + margin: 0; +} + +.svg-preview--empty { + opacity: 0.7; +} + +.svg-preview-placeholder { + font-size: 15px; + color: var(--text); + text-align: center; + margin: 0; +} + +.svg-preview--ready { + justify-content: flex-start; + gap: 12px; +} + +.svg-preview-container { + width: 100%; + max-height: 70vh; + overflow: auto; +} + +.svg-preview-container svg { + width: 100%; + height: auto; + display: block; +} + +.svg-preview-meta { + display: flex; + gap: 16px; + font-size: 12px; + color: var(--text); + padding: 6px 0; + border-top: 1px solid var(--border); + width: 100%; +} + +.svg-preview-warnings { + color: #e67e22; +} + +/* Use This button */ +.use-this-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + 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; +} + +.use-this-btn:hover { + opacity: 0.9; +} + +.use-this-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Placeholder views */ .placeholder-view { display: flex; diff --git a/app/src/App.tsx b/app/src/App.tsx index f1dd640..771b53b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,9 +10,9 @@ function App() { const [_svgResult, setSvgResult] = useState(null); const [_traceMetadata, setTraceMetadata] = useState(null); - const handleUseThis = (svgOutput: string, metadata: unknown) => { + const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => { setSvgResult(svgOutput); - setTraceMetadata(metadata as TraceMetadata); + setTraceMetadata(metadata); setView('canvas'); }; diff --git a/app/src/components/ParameterSliders.tsx b/app/src/components/ParameterSliders.tsx new file mode 100644 index 0000000..a4d0ee7 --- /dev/null +++ b/app/src/components/ParameterSliders.tsx @@ -0,0 +1,135 @@ +import { useCallback } from 'react'; +import type { PresetConfig } from '../types/engine'; + +interface ParameterSlidersProps { + presetConfig: PresetConfig | null; + params: Record; + onChange: (params: Record) => void; +} + +interface SliderDef { + key: string; + label: string; + min: number; + max: number; + step: number; + defaultValue: number; + /** Only shown when this vectorization mode is active */ + modeFilter?: 'potrace' | 'vtracer'; +} + +function getSliderDefs(config: PresetConfig): SliderDef[] { + const mode = config.vectorization.mode; + const potrace = config.vectorization.potrace; + const vtracer = config.vectorization.vtracer; + const post = config.postprocessing; + + const sliders: SliderDef[] = [ + { + key: 'epsilon', + label: 'Detail Level', + min: 0.5, + max: 10, + step: 0.5, + defaultValue: post.epsilon ?? 2.5, + }, + ]; + + if (mode === 'potrace') { + sliders.push( + { + key: 'turdsize', + label: 'Noise Filter', + min: 0, + max: 50, + step: 1, + defaultValue: potrace?.turdsize ?? 10, + modeFilter: 'potrace', + }, + { + key: 'alphamax', + label: 'Smooth Curves', + min: 0, + max: 1.334, + step: 0.1, + defaultValue: potrace?.alphamax ?? 1.0, + modeFilter: 'potrace', + }, + ); + } else { + sliders.push( + { + key: 'filter_speckle', + label: 'Noise Filter', + min: 0, + max: 50, + step: 1, + defaultValue: vtracer?.filter_speckle ?? 20, + modeFilter: 'vtracer', + }, + { + key: 'corner_threshold', + label: 'Corner Threshold', + min: 0, + max: 180, + step: 1, + defaultValue: vtracer?.corner_threshold ?? 60, + modeFilter: 'vtracer', + }, + ); + } + + return sliders; +} + +export default function ParameterSliders({ + presetConfig, + params, + onChange, +}: ParameterSlidersProps) { + const handleChange = useCallback( + (key: string, value: number) => { + onChange({ ...params, [key]: value }); + }, + [params, onChange], + ); + + if (!presetConfig) { + return null; + } + + const sliders = getSliderDefs(presetConfig); + + return ( +
+

Parameters

+ {sliders.map((slider) => { + const value = + typeof params[slider.key] === 'number' + ? (params[slider.key] as number) + : slider.defaultValue; + + return ( +
+ + + handleChange(slider.key, parseFloat(e.target.value)) + } + /> + {value} +
+ ); + })} +
+ ); +} diff --git a/app/src/components/SvgPreview.tsx b/app/src/components/SvgPreview.tsx new file mode 100644 index 0000000..d15ad88 --- /dev/null +++ b/app/src/components/SvgPreview.tsx @@ -0,0 +1,81 @@ +import type { TraceMetadata } from '../types/engine'; + +interface SvgPreviewProps { + svgOutput: string | null; + isLoading: boolean; + error: string | null; + metadata: TraceMetadata | null; +} + +/** + * Strip width/height attributes from an SVG string while keeping viewBox + * for responsive scaling. + */ +function makeResponsiveSvg(svg: string): string { + return svg + .replace(/\s+width="[^"]*"/g, '') + .replace(/\s+height="[^"]*"/g, '') + .replace(/\s+width='[^']*'/g, '') + .replace(/\s+height='[^']*'/g, ''); +} + +export default function SvgPreview({ + svgOutput, + isLoading, + error, + metadata, +}: SvgPreviewProps) { + if (error) { + return ( +
+

⚠ {error}

+

+ Try adjusting parameters or selecting a different preset +

+
+ ); + } + + if (isLoading) { + return ( +
+
+

Vectorizing…

+
+ ); + } + + if (!svgOutput) { + return ( +
+

+ Upload an image to begin vectorization +

+
+ ); + } + + const responsiveSvg = makeResponsiveSvg(svgOutput); + + return ( +
+
+ {metadata && ( +
+ + {metadata.path_count} paths · {metadata.node_count_total} nodes + + {metadata.processing_ms}ms + {metadata.warnings.length > 0 && ( + + ⚠ {metadata.warnings.join(', ')} + + )} +
+ )} +
+ ); +} diff --git a/app/src/hooks/__tests__/useDebouncedTrace.test.ts b/app/src/hooks/__tests__/useDebouncedTrace.test.ts new file mode 100644 index 0000000..9128ff4 --- /dev/null +++ b/app/src/hooks/__tests__/useDebouncedTrace.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useDebouncedTrace } from '../useDebouncedTrace'; + +// ---------- helpers ---------- + +function mockFetchOk(body: unknown) { + return vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); +} + +function mockFetchFail(status: number, detail: string) { + return vi.fn().mockResolvedValue({ + ok: false, + status, + statusText: detail, + text: () => Promise.resolve(detail), + }); +} + +const TRACE_RESPONSE = { + output: '', + format: 'svg', + metadata: { + format: 'svg', + path_count: 1, + node_count_total: 2, + open_paths: 0, + island_count: 0, + warnings: [], + processing_ms: 42, + }, +}; + +// ---------- setup ---------- +// Use real timers with very short debounce (10ms) to avoid fake-timer +// interaction issues with React's async state updates. + +const originalFetch = globalThis.fetch; +const DEBOUNCE_MS = 10; + +beforeEach(() => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('fetch not mocked')); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ---------- tests ---------- + +describe('useDebouncedTrace', () => { + it('returns null output when no file is provided', () => { + const { result } = renderHook(() => + useDebouncedTrace(null, 'sign', {}, DEBOUNCE_MS), + ); + + expect(result.current.svgOutput).toBeNull(); + expect(result.current.metadata).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('debounces — only the last call fires after the delay', async () => { + globalThis.fetch = mockFetchOk(TRACE_RESPONSE); + const file = new File(['pixels'], 'test.png', { type: 'image/png' }); + + const { result, rerender } = renderHook( + ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS), + { initialProps: { params: { epsilon: 1 } as Record } }, + ); + + // Rapid param changes — each restarts the debounce + rerender({ params: { epsilon: 2 } }); + rerender({ params: { epsilon: 3 } }); + + // Wait for debounce + fetch to resolve + await waitFor(() => { + expect(result.current.svgOutput).toBe(TRACE_RESPONSE.output); + }); + + // Only one fetch should have fired (the last debounced one) + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it('passes AbortSignal to fetch and aborts previous requests', async () => { + let firstSignal: AbortSignal | undefined; + let callCount = 0; + globalThis.fetch = vi.fn().mockImplementation((_url: string, opts?: RequestInit) => { + callCount++; + if (callCount === 1) { + firstSignal = opts?.signal; + // First call hangs (never resolves) to simulate in-flight request + return new Promise(() => {}); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(TRACE_RESPONSE), + }); + }); + + const file = new File(['pixels'], 'test.png', { type: 'image/png' }); + + const { result, rerender } = renderHook( + ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS), + { initialProps: { params: { epsilon: 1 } as Record } }, + ); + + // Wait for first fetch to fire + await waitFor(() => { + expect(callCount).toBe(1); + }); + + expect(firstSignal).toBeDefined(); + + // Trigger a new trace — should abort the first + rerender({ params: { epsilon: 5 } }); + + await waitFor(() => { + expect(callCount).toBe(2); + }); + + // The first request's signal should be aborted + expect(firstSignal!.aborted).toBe(true); + }); + + it('routes SVG files to /engine/simplify instead of /engine/trace', async () => { + globalThis.fetch = mockFetchOk(TRACE_RESPONSE); + const svgFile = new File([''], 'logo.svg', { + type: 'image/svg+xml', + }); + + const { result } = renderHook(() => + useDebouncedTrace(svgFile, 'sign', { epsilon: 2 }, DEBOUNCE_MS), + ); + + await waitFor(() => { + expect(result.current.svgOutput).toBeTruthy(); + }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('/engine/simplify'); + }); + + it('calls /engine/trace for raster images', async () => { + globalThis.fetch = mockFetchOk(TRACE_RESPONSE); + const file = new File(['pixels'], 'photo.png', { type: 'image/png' }); + + const { result } = renderHook(() => + useDebouncedTrace(file, 'sign', { epsilon: 2.5 }, DEBOUNCE_MS), + ); + + await waitFor(() => { + expect(result.current.svgOutput).toBeTruthy(); + }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('/engine/trace'); + }); + + it('sets error state on fetch failure', async () => { + globalThis.fetch = mockFetchFail(500, 'Internal Server Error'); + const file = new File(['pixels'], 'test.png', { type: 'image/png' }); + + const { result } = renderHook(() => + useDebouncedTrace(file, 'sign', { epsilon: 2 }, DEBOUNCE_MS), + ); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + }, { timeout: 2000 }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.svgOutput).toBeNull(); + }); + + it('cleans up on unmount (aborts in-flight and clears timeout)', async () => { + globalThis.fetch = mockFetchOk(TRACE_RESPONSE); + const file = new File(['pixels'], 'test.png', { type: 'image/png' }); + + const { unmount } = renderHook(() => + useDebouncedTrace(file, 'sign', { epsilon: 2 }, 500), + ); + + // Unmount before the 500ms debounce fires + unmount(); + + // Give time for any unexpected fetch to fire + await act(async () => { + await new Promise((r) => setTimeout(r, 600)); + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useDebouncedTrace.ts b/app/src/hooks/useDebouncedTrace.ts new file mode 100644 index 0000000..d12f3d0 --- /dev/null +++ b/app/src/hooks/useDebouncedTrace.ts @@ -0,0 +1,123 @@ +import { useEffect, useRef, useState } from 'react'; +import { traceImage, simplifyVector } from '../api/engine'; +import type { TraceMetadata } from '../types/engine'; + +interface UseDebouncedTraceResult { + svgOutput: string | null; + metadata: TraceMetadata | null; + isLoading: boolean; + error: string | null; +} + +function isSvgFile(file: File): boolean { + return ( + file.type === 'image/svg+xml' || + file.name.toLowerCase().endsWith('.svg') + ); +} + +/** + * Custom hook that debounces vectorization requests with AbortController. + * + * On each parameter/file/preset change, it clears any pending timeout, + * aborts any in-flight request, and schedules a new trace after `debounceMs`. + * SVG file uploads route to simplifyVector instead of traceImage. + */ +export function useDebouncedTrace( + file: File | null, + preset: string, + params: Record, + debounceMs: number = 300, +): UseDebouncedTraceResult { + const [svgOutput, setSvgOutput] = useState(null); + const [metadata, setMetadata] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const abortRef = useRef(null); + const timeoutRef = useRef | null>(null); + + // Stabilize params by value to prevent effect re-runs on same-value new objects + const paramsKey = JSON.stringify(params); + + useEffect(() => { + // Clear any pending debounce timeout + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // Abort any in-flight request + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + + if (!file) { + setSvgOutput(null); + setMetadata(null); + setIsLoading(false); + setError(null); + return; + } + + // Parse the stabilized params key back to object + const currentParams: Record = JSON.parse(paramsKey); + + setIsLoading(true); + setError(null); + + timeoutRef.current = setTimeout(async () => { + const controller = new AbortController(); + abortRef.current = controller; + + try { + let response; + if (isSvgFile(file)) { + const epsilon = + typeof currentParams.epsilon === 'number' ? currentParams.epsilon : 2.5; + response = await simplifyVector(file, epsilon, controller.signal); + } else { + response = await traceImage( + file, + preset, + currentParams, + controller.signal, + ); + } + + // Only update state if this request wasn't aborted + if (!controller.signal.aborted) { + setSvgOutput(response.output); + setMetadata(response.metadata); + setIsLoading(false); + } + } catch (err) { + // Ignore abort errors — they're expected during cancellation + if (err instanceof Error && err.name === 'AbortError') { + return; + } + if (!controller.signal.aborted) { + setError( + err instanceof Error ? err.message : 'Vectorization failed', + ); + setIsLoading(false); + } + } + }, debounceMs); + + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [file, preset, paramsKey, debounceMs]); + + return { svgOutput, metadata, isLoading, error }; +} diff --git a/app/src/views/ImportConvert.tsx b/app/src/views/ImportConvert.tsx index 2025f0a..c566b12 100644 --- a/app/src/views/ImportConvert.tsx +++ b/app/src/views/ImportConvert.tsx @@ -1,28 +1,84 @@ -import { useState } from 'react'; -import type { PresetConfig } from '../types/engine'; +import { useCallback, useState } from 'react'; +import type { PresetConfig, TraceMetadata } from '../types/engine'; import FileUpload from '../components/FileUpload'; import PresetSelector from '../components/PresetSelector'; +import ParameterSliders from '../components/ParameterSliders'; +import SvgPreview from '../components/SvgPreview'; +import { useDebouncedTrace } from '../hooks/useDebouncedTrace'; import styles from './ImportConvert.module.css'; interface ImportConvertProps { - onUseThis: (svgOutput: string, metadata: unknown) => void; + onUseThis: (svgOutput: string, metadata: TraceMetadata) => void; } -export default function ImportConvert({ onUseThis: _onUseThis }: ImportConvertProps) { +/** + * Extract default slider parameters from a preset config. + */ +function defaultParamsFromPreset(config: PresetConfig): Record { + const mode = config.vectorization.mode; + const params: Record = { + epsilon: config.postprocessing.epsilon ?? 2.5, + }; + + if (mode === 'potrace') { + params.turdsize = config.vectorization.potrace?.turdsize ?? 10; + params.alphamax = config.vectorization.potrace?.alphamax ?? 1.0; + } else { + params.filter_speckle = config.vectorization.vtracer?.filter_speckle ?? 20; + params.corner_threshold = config.vectorization.vtracer?.corner_threshold ?? 60; + } + + return params; +} + +export default function ImportConvert({ onUseThis }: ImportConvertProps) { const [selectedFile, setSelectedFile] = useState(null); - const [_isSvgMode, setIsSvgMode] = useState(false); + const [isSvgMode, setIsSvgMode] = useState(false); const [selectedPreset, setSelectedPreset] = useState('sign'); - const [_presetConfig, setPresetConfig] = useState(null); + const [presetConfig, setPresetConfig] = useState(null); + const [currentParams, setCurrentParams] = useState>({ + epsilon: 2.5, + turdsize: 10, + alphamax: 1.0, + }); - const handleFileSelect = (file: File, isSvg: boolean) => { - setSelectedFile(file); - setIsSvgMode(isSvg); - }; + // Hook handles params stabilization via JSON.stringify internally + const { svgOutput, metadata, isLoading, error } = useDebouncedTrace( + selectedFile, + selectedPreset, + currentParams, + 300, + ); - const handlePresetSelect = (name: string, config: PresetConfig) => { - setSelectedPreset(name); - setPresetConfig(config); - }; + const handleFileSelect = useCallback( + (file: File, isSvg: boolean) => { + setSelectedFile(file); + setIsSvgMode(isSvg); + }, + [], + ); + + const handlePresetSelect = useCallback( + (name: string, config: PresetConfig) => { + setSelectedPreset(name); + setPresetConfig(config); + setCurrentParams(defaultParamsFromPreset(config)); + }, + [], + ); + + const handleParamsChange = useCallback( + (params: Record) => { + setCurrentParams(params); + }, + [], + ); + + const handleUseThis = useCallback(() => { + if (svgOutput && metadata) { + onUseThis(svgOutput, metadata); + } + }, [svgOutput, metadata, onUseThis]); return (
@@ -32,13 +88,28 @@ export default function ImportConvert({ onUseThis: _onUseThis }: ImportConvertPr selectedPreset={selectedPreset} onPresetSelect={handlePresetSelect} /> + + {svgOutput && ( + + )}
-
- {selectedFile - ? 'Preview will appear here after tracing (T03)' - : 'Upload an image to begin vectorization'} -
+
);