File
Blob: docs/ARCHITECTURE.md
wimp - Architecture
Three layers, deliberately separated. React (with the compiler) owns the UI; the heavy pixel work and encoding live in a Web Worker.
Layer map
Layer 1 Interactive UI (main thread, React 19 + compiler)
- react-easy-crop: crop / zoom / rotation / flip, live CSS-filter color
- toolbar, panels, template picker popover, export panel
|
| ExportRequest (source bytes transferred, not copied)
v
Layer 2 Pixel processing (Web Worker, src/workers/export.worker.ts)
- decode via createImageBitmap (EXIF-aware)
- rotate + flip into the rotation bounding box (OffscreenCanvas)
- crop + resample to target size:
libvips lanczos3 (if downscaling and available) else canvas 'high'
- bake color adjustments; flatten JPEG onto white; apply size cap
|
v
Layer 3 Encoding (same worker)
- jSquash MozJPEG / WebP / OxiPNG / AVIF (lazy per format)
- fallback: OffscreenCanvas.convertToBlob
|
| ExportResult { blob, width, height, bytes, engine, encoder }
v
Main thread: download + result previewData flow
- A file enters via drag/drop, click, or clipboard paste (src/App.tsx, src/components/Dropzone.tsx). lib/image.ts loads it into an object URL and reads its natural size.
- react-easy-crop (src/components/EditorCanvas.tsx) displays it. The editor state (crop, zoom, rotation, flip, aspect, output, adjustments, format, quality, maxEdge, template id, croppedAreaPixels) lives in a reducer (src/hooks/useEditor.ts).
- On export, App reads file.arrayBuffer() and posts an ExportRequest to the worker through lib/exportClient.ts. The ArrayBuffer is transferred (zero copy); a fresh buffer is read for each export so re-export works.
- exportClient correlates requests and responses by an incrementing id and resolves a promise with the ExportResult. The worker is a lazily created singleton; if it crashes, pending promises reject and the next call spins up a new one.
- App triggers a download and shows the result (size, dimensions, which engine and encoder ran, plus a preview thumbnail with Save and Copy actions).
Coordinate model (crop + rotate + flip)
This mirrors react-easy-crop's official getCroppedImg recipe so the export matches the preview exactly:
- croppedAreaPixels (from onCropComplete) is expressed in the space of the rotated image's bounding box.
- The worker rebuilds that exact space: size a canvas to rotateSize(w, h, deg), translate to center, rotate, scale(flip), translate back by -w/2,-h/2, draw.
- Flip is applied identically in the preview (Cropper "transform" prop using rotateY/rotateX 180) and in the export canvas, so the same croppedAreaPixels selects the same content in both.
Shared filter string
src/lib/filter.ts builds one CSS/canvas filter string from the Adjustments (brightness, contrast, saturate, grayscale, sepia, hueRotate). The UI uses it as react-easy-crop mediaStyle.filter; the worker uses it as ctx.filter. One source of truth keeps preview and export identical.
Engines
- Pixel engine: libvips (lanczos3) for meaningful downscales when it initializes and the page is cross-origin isolated; otherwise OffscreenCanvas drawImage with imageSmoothingQuality 'high'. See src/lib/vips.ts for the guarded loader.
- Encoder: jSquash WASM codecs for real quality control; canvas convertToBlob as fallback. PNG is lossless (quality slider disabled).
File map
index.html entry, inline SVG favicon
vite.config.ts react + react-compiler (babel) + tailwind,
COOP/COEP headers, wasm optimizeDeps.exclude
tsconfig.json single config, DOM + WebWorker libs, skipLibCheck
src/
main.tsx React root
index.css Tailwind import + @theme tokens + base styles
types.ts Template, Adjustments, ExportRequest/Result, msgs
templates.ts preset data + helpers (grouped)
hooks/useEditor.ts reducer: all editor state + actions
lib/
image.ts load, filename, download, byte/MP formatting
exportClient.ts main-thread handle to the worker
filter.ts shared CSS/canvas filter builder
vips.ts guarded lazy wasm-vips loader
cn.ts classnames helper
workers/export.worker.ts decode -> transform -> encode
components/
App is at src/App.tsx layout, file handling, shortcuts, export flow
TopBar.tsx header
Dropzone.tsx empty-state loader
EditorCanvas.tsx Cropper wrapper + framing guide + Fit control
TemplatePicker.tsx popover
panels.tsx Crop / Transform / Effects / Adjust / Export
ui.tsx Button, Section, Segmented
Slider.tsx labeled range input
icons.tsx inline SVG icons
docs/ this documentationState shape (reducer)
EditorState {
crop: { x, y } // react-easy-crop pan position
zoom: number // 1..8
rotation: number // degrees, wrapped 0..360
flipH, flipV: boolean
aspect: number | null // null = source ratio
output: { width, height } | null // null = native cropped size
maxEdge: number | null // optional long-edge cap
adjustments: Adjustments
templateId: string // 'custom' when aspect set manually
format: 'jpeg'|'webp'|'png'|'avif'
quality: number // 1..100 (ignored for png)
croppedAreaPixels: PixelRect | null
}