docs: add interactive crop tool design spec

This commit is contained in:
Siddharth Kumar Sah
2026-03-23 19:08:14 +08:00
parent 80756d1591
commit b9de028fd8
@@ -0,0 +1,197 @@
# Interactive Crop Tool Design
## Overview
Replace the current numbers-only crop UI with a visual, interactive crop tool featuring a draggable rectangle overlay on the image (Photoshop-style), aspect ratio presets, bidirectional pixel inputs, rule-of-thirds grid, and keyboard controls.
## Approach
**`react-image-crop`** (~5KB, zero deps) provides the core overlay with 8 drag handles, aspect ratio locking, and dimmed excluded area. Custom enhancements: rule-of-thirds grid (SVG), bidirectional pixel inputs, aspect ratio preset buttons, keyboard nudging. Actual cropping remains server-side via Sharp.
## Interaction Model
### Pre-crop (image loaded, not yet processed)
- Right panel shows `CropCanvas` component instead of `ImageViewer`
- Image fills available space (maintaining aspect ratio)
- Crop rectangle overlays the image via `react-image-crop`
- Rectangle starts covering the full image; user drags inward
- Area outside rectangle dimmed at ~50% opacity
- 8 drag handles: 4 corners + 4 edge midpoints
### Post-crop (after clicking "Crop")
- Switches to existing `SideBySideComparison` view showing before/after
- Download button appears in settings panel
### Flow
1. Drop image -> crop canvas appears with full-image crop selection
2. Adjust crop rectangle (drag handles, move, keyboard, or type pixel values)
3. Click "Crop" -> processes via Sharp backend -> shows before/after comparison
4. Download or undo to re-crop
## Settings Panel (Left Side)
### Aspect Ratio
- "Free" button (default, selected state) -- unconstrained dragging
- Preset buttons in a wrapped grid: `1:1`, `4:3`, `3:2`, `16:9`, `2:3`, `4:5`, `9:16`
- Swap button next to active preset to flip landscape/portrait (e.g. 16:9 -> 9:16)
- When a preset is selected, crop rectangle snaps to that ratio and drag handles maintain it
### Position & Size (pixel inputs)
- 2x2 grid: X (left), Y (top), Width, Height
- Bidirectionally synced with visual crop overlay -- dragging updates numbers, typing updates rectangle
- Shows original image dimensions as reference (e.g. "of 1920" hint text)
- Values clamped to valid ranges
### Grid Overlay
- Toggle: "Rule of Thirds" (on by default)
- Renders 3x3 grid inside crop area as thin semi-transparent lines
### Process Section
- "Crop" button (or "Crop (N files)" for batch)
- Progress card during processing
- Download button after completion
## CropCanvas Component
New file: `apps/web/src/components/tools/crop-canvas.tsx`
- Wraps uploaded image with `ReactCrop` from `react-image-crop`
- Uses percentage-based crop coordinates internally (overlay works at any display size)
- Converts to absolute pixels when syncing with settings inputs and submitting to API
- Image rendered with `object-fit: contain` to fill available space
### Dimension Badge
- Small floating label near bottom-right of crop area
- Shows resulting dimensions in real-time (e.g. "640 x 480")
### Rule of Thirds Grid
- SVG overlay inside crop area
- 4 lines (2 horizontal, 2 vertical) at 1/3 and 2/3 positions
- Thin white lines at ~40% opacity
### Keyboard Controls
- Arrow keys: nudge crop box by 1px
- Shift+Arrow: nudge by 10px
- Enter: apply crop (submit form)
- Escape: reset crop to full image
### Touch Support
- Handled by `react-image-crop` out of the box
## State Management
Crop state is owned by `tool-page.tsx` and passed **bidirectionally** to both `CropSettings` and `CropCanvas`. This differs from the rotate tool's one-way `onPreviewTransform` callback — crop requires both components to read and write the same state.
State shape:
```typescript
interface CropState {
crop: Crop; // react-image-crop's Crop type (percentage-based)
aspect: number | undefined; // locked aspect ratio or undefined for free
showGrid: boolean; // rule of thirds toggle
imgDimensions: { width: number; height: number } | null; // natural image dimensions
}
```
`tool-page.tsx` holds `[cropState, setCropState] = useState<CropState>(...)` and passes:
- To `CropCanvas`: `cropState`, `onCropChange`, `imageSrc` (from `originalBlobUrl`), `onImageLoad` (to capture natural dimensions)
- To `CropSettings`: `cropState`, `onCropChange`, `onAspectChange`, `onGridToggle`
### CropSettings Prop Interface
```typescript
interface CropSettingsProps {
cropState: CropState;
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
```
`CropSettings` continues to use `useToolProcessor("crop")` internally for submission. The pixel input fields convert between percentage-based `Crop` and absolute pixels using `cropState.imgDimensions`.
### CropCanvas Prop Interface
```typescript
interface CropCanvasProps {
imageSrc: string;
cropState: CropState;
onCropChange: (crop: Crop) => void;
onImageLoad: (dims: { width: number; height: number }) => void;
}
```
`CropCanvas` reads `imageSrc` as a prop (sourced from `originalBlobUrl` in the file store). It reports natural image dimensions via `onImageLoad` when the `<img>` fires its load event.
### Keyboard Focus
`CropCanvas` container has `tabIndex={0}` and captures focus on mount. Arrow key handlers call `e.preventDefault()` to suppress page scrolling. The component uses a `keydown` event listener on its container div.
## Rendering Path in tool-page.tsx
Add a new set: `const INTERACTIVE_CROP_TOOLS = new Set(["crop"])`.
The main area rendering logic adds a new branch **before** the existing `SIDE_BY_SIDE_TOOLS` check:
```
if (INTERACTIVE_CROP_TOOLS.has(toolId) && hasFile && !hasProcessed) {
return <CropCanvas ... />;
}
```
- **Pre-crop**: `CropCanvas` renders (interactive overlay on image)
- **Post-crop**: Falls through to `SIDE_BY_SIDE_TOOLS` which already includes `"crop"` -> shows `SideBySideComparison`
- **Undo**: `undoProcessing()` clears `processedUrl`, which causes `hasProcessed` to become false, routing back to `CropCanvas` (not `ImageViewer`)
The `ToolSettingsPanel` routing passes crop props to `CropSettings`:
```
if (toolId === "crop") return <CropSettings cropState={...} onCropChange={...} ... />;
```
## Batch / Multi-Image Behavior
When multiple files are loaded (`files.length > 1`), the interactive crop canvas is **not shown** — the existing `MultiImageViewer` renders instead (this check comes first in the rendering logic). The crop settings fall back to the pixel-input-only mode (no visual overlay) for batch, since different images may have different dimensions.
Single-image interactive cropping is the primary use case. Batch cropping with identical pixel coordinates is an advanced/power-user flow that works via the numeric inputs alone.
## Data Flow
1. User adjusts crop rectangle -> `react-image-crop` emits percentage-based `Crop` object
2. `CropCanvas` calls `onCropChange(crop)` -> `tool-page.tsx` updates `cropState`
3. `CropSettings` reads `cropState` and converts percentages to absolute pixels using `imgDimensions`
4. User types in pixel inputs -> `CropSettings` converts back to percentages and calls `onCropChange`
5. On submit, `CropSettings` converts final `cropState.crop` to `{ left, top, width, height }` (pixels) and sends to `useToolProcessor("crop")`
6. Backend processes via Sharp `.extract()`, returns `downloadUrl`
7. `tool-page.tsx` rendering falls through to `SideBySideComparison`
## Backend
No changes needed. Existing crop API endpoint accepts `{ left, top, width, height }` in pixels. Note: the backend currently hardcodes output as `image/png` regardless of input format — this is a pre-existing limitation not addressed in this spec.
## Files to Modify
- `apps/web/src/components/tools/crop-settings.tsx` -- redesign with aspect ratio presets, synced pixel inputs
- `apps/web/src/pages/tool-page.tsx` -- add crop canvas rendering path, lift crop state, add `INTERACTIVE_CROP_TOOLS` set
- **New:** `apps/web/src/components/tools/crop-canvas.tsx` -- visual cropper component
- `apps/web/package.json` -- add `react-image-crop` dependency
## Files NOT Modified
- Backend API routes
- Image engine operations
- Shared constants/types
- Docker (just rebuild)
## Dependencies
- `react-image-crop` (~5KB gzipped, zero transitive dependencies)