feat: Built useDebouncedTrace hook with AbortController cancellation, P…

- "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
This commit is contained in:
jlightner 2026-03-26 05:15:43 +00:00
parent 35bc542aad
commit c3783e1680
7 changed files with 793 additions and 21 deletions

View file

@ -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;

View file

@ -10,9 +10,9 @@ function App() {
const [_svgResult, setSvgResult] = useState<string | null>(null);
const [_traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
const handleUseThis = (svgOutput: string, metadata: unknown) => {
const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => {
setSvgResult(svgOutput);
setTraceMetadata(metadata as TraceMetadata);
setTraceMetadata(metadata);
setView('canvas');
};

View file

@ -0,0 +1,135 @@
import { useCallback } from 'react';
import type { PresetConfig } from '../types/engine';
interface ParameterSlidersProps {
presetConfig: PresetConfig | null;
params: Record<string, unknown>;
onChange: (params: Record<string, unknown>) => 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 (
<div className="parameter-sliders">
<h3 className="parameter-heading">Parameters</h3>
{sliders.map((slider) => {
const value =
typeof params[slider.key] === 'number'
? (params[slider.key] as number)
: slider.defaultValue;
return (
<div key={slider.key} className="slider-row">
<label className="slider-label" htmlFor={`slider-${slider.key}`}>
{slider.label}
</label>
<input
id={`slider-${slider.key}`}
type="range"
className="slider-input"
min={slider.min}
max={slider.max}
step={slider.step}
value={value}
onChange={(e) =>
handleChange(slider.key, parseFloat(e.target.value))
}
/>
<span className="slider-value">{value}</span>
</div>
);
})}
</div>
);
}

View file

@ -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 (
<div className="svg-preview svg-preview--error">
<p className="svg-preview-error"> {error}</p>
<p className="svg-preview-hint">
Try adjusting parameters or selecting a different preset
</p>
</div>
);
}
if (isLoading) {
return (
<div className="svg-preview svg-preview--loading">
<div className="svg-preview-spinner" />
<p className="svg-preview-status">Vectorizing</p>
</div>
);
}
if (!svgOutput) {
return (
<div className="svg-preview svg-preview--empty">
<p className="svg-preview-placeholder">
Upload an image to begin vectorization
</p>
</div>
);
}
const responsiveSvg = makeResponsiveSvg(svgOutput);
return (
<div className="svg-preview svg-preview--ready">
<div
className="svg-preview-container"
dangerouslySetInnerHTML={{ __html: responsiveSvg }}
/>
{metadata && (
<div className="svg-preview-meta">
<span>
{metadata.path_count} paths · {metadata.node_count_total} nodes
</span>
<span>{metadata.processing_ms}ms</span>
{metadata.warnings.length > 0 && (
<span className="svg-preview-warnings">
{metadata.warnings.join(', ')}
</span>
)}
</div>
)}
</div>
);
}

View file

@ -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: '<svg viewBox="0 0 100 100"><path d="M0,0"/></svg>',
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<string, unknown> } },
);
// 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<string, unknown> } },
);
// 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(['<svg></svg>'], '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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});

View file

@ -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<string, unknown>,
debounceMs: number = 300,
): UseDebouncedTraceResult {
const [svgOutput, setSvgOutput] = useState<string | null>(null);
const [metadata, setMetadata] = useState<TraceMetadata | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown> = 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 };
}

View file

@ -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<string, unknown> {
const mode = config.vectorization.mode;
const params: Record<string, unknown> = {
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<File | null>(null);
const [_isSvgMode, setIsSvgMode] = useState(false);
const [isSvgMode, setIsSvgMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState('sign');
const [_presetConfig, setPresetConfig] = useState<PresetConfig | null>(null);
const [presetConfig, setPresetConfig] = useState<PresetConfig | null>(null);
const [currentParams, setCurrentParams] = useState<Record<string, unknown>>({
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<string, unknown>) => {
setCurrentParams(params);
},
[],
);
const handleUseThis = useCallback(() => {
if (svgOutput && metadata) {
onUseThis(svgOutput, metadata);
}
}, [svgOutput, metadata, onUseThis]);
return (
<div className={styles.container}>
@ -32,13 +88,28 @@ export default function ImportConvert({ onUseThis: _onUseThis }: ImportConvertPr
selectedPreset={selectedPreset}
onPresetSelect={handlePresetSelect}
/>
<ParameterSliders
presetConfig={presetConfig}
params={currentParams}
onChange={handleParamsChange}
/>
{svgOutput && (
<button
type="button"
className="use-this-btn"
onClick={handleUseThis}
>
Use This
</button>
)}
</div>
<div className={styles.rightPanel}>
<div className={styles.previewPlaceholder}>
{selectedFile
? 'Preview will appear here after tracing (T03)'
: 'Upload an image to begin vectorization'}
</div>
<SvgPreview
svgOutput={svgOutput}
isLoading={isLoading}
error={error}
metadata={metadata}
/>
</div>
</div>
);