Features · 70 of them, from the repo’s own list
Everything the editor does, in plain words.
Annotate, select, enhance, export — all of it runs on your own machine, and the editing tools are free. Pick a group, or just scroll. Each card has a plain line first and the engineering line underneath.
Annotate16
Draw, write and stamp on a photo. Every mark is its own object until you flatten it, so you can move it, recolor it or undo it later.
Paint / Brush
Freehand painting with size, color and opacity.
WASM paint_dab + paint_stroke_to; Create → Brush, with Pen and Blur Brush as sibling sub-tools.
Pen (Vector Paths)
Click for corners, drag for curves, and reshape any point afterwards. Optionally fill the inside.
Bézier pen; Enter closes, Esc finishes open. Committed paths stay editable and round-trip through history, reload and sync as a kind-7 ShapeAnnotation.
Arrows & Diagram Shapes
Arrows, plus thirty flowchart and diagram shapes — terminator, document, database, cloud, callout, block arrows and more — that fill, turn and go hand-drawn like any other shape.
Kinds 11–40, each a short program in the unit square (src/diagram.rs) flattened to one outline plus detail strokes, so stroke, fill, rotation and sloppiness all just work. Create → Arrows & Shapes.
Fast drags land
Draw thirty shapes as fast as you can and you get thirty shapes.
A press is recorded instantly and its moves buffered while the engine hit-tests, then replayed in order (lib/pressQueue.ts). It used to land 19–24 of 30.
Shapes
Rectangles, circles, lines, diamonds, triangles and stars — firm, or hand-drawn with the Sloppiness slider. Pick how many points the star has — arrow keys change the focused Points slider one point at a time — and turn any shape by its handle.
Rendered in WASM; Create → Shapes, with Arrow and Pins as their own sub-tools. Rotation is an angle beside the upright box, so a turned shape stays crisp and keeps its fill; Shift snaps to 15°.
Fill
Every shape with an inside can be filled — rectangle, circle, diamond, triangle, star — as a solid color, a gradient, or the photo underneath pixelated. The line is the one shape without it.
An even-odd point-in-polygon clip against the same vertex list the engine strokes, so the fill and the outline cannot disagree; a star's missed corners stay empty. A filled shape rotates on the warp route, so gradient direction and pixelate grid turn with it.
Text
Click to place text in a real typeface, size, weight and color. Your last 8 texts are one click away.
Liberation Sans, Serif and Mono (SIL OFL 1.1), regular and bold, handed to the engine at runtime (register_font).
Text Shadow
A soft drop shadow behind the words, the box, or both.
Shared color / opacity / offset / blur; offset + box-blurred silhouette rendered in Rust, tile grown so Align sees it.
Emoji Stamp
Drop any emoji onto the photo.
Browser renders to OffscreenCanvas; pixels sent to WASM stamp_pixels() for alpha compositing.
Red Stamps
REJECTED, APPROVED, DRAFT, CONFIDENTIAL, UNDER REVIEW — one click each.
JS renders the label to OffscreenCanvas; Rust scales to brush size via bilinear resize and composites with a "Red Stamp" history entry.
Clone Stamp
Alt-click a source, then paint it somewhere else.
Adjustable size, hardness, opacity, spacing.
Blur Brush
Blur just the part you paint over.
Box blur with stroke-based region masking; configurable radius and intensity.
Stroke Stabilizer
Steadies a shaky hand on every brush — Off, Low, Medium or High.
One pulled-string leash (src/stabilizer.rs) shared by Paint, Eraser, blur, pixelate, redact and clone stamp; a move inside the leash costs zero recomposites.
Placement grid
Snap a text or shape into any ninth of the canvas.
Nine-cell grid in the Text and Shapes panels; Rust centers the bounding box in the cell (align_annotation) as one undo step.
Shape action bar
Apply, Cancel, Duplicate and Connect right under the shape — arrows lay copies, pigtails draw connectors.
Eight ports on the bounding box; copies counted per port from the original (lib/shapePorts.ts), engine-side struct clone; connectors are arrow annotations.
OCR
Read the text out of an image.
Its own sub-tool, Create → OCR, rather than a mode inside the Text panel.
Select & mask5
Pick exactly the pixels you mean — by color, by edge, or by hand — and everything you do next stays inside the marching ants.
Select group
Six ways to pick: magic wand, edge-aware wand, magnetic lasso, color range, rectangle and ellipse.
Flood-fill, mask and delete are Rust (magic_wand_select / delete_selection); Alt+A select all, Alt+D deselect.
Erasers and AI removal
Erase to transparent, or paint over something and have it filled in — locally and free. Pro adds background and object removal.
Eraser and Magic Eraser (PatchMatch) run locally; Background Removal (rembg) and Object Removal (LaMa) run on Replicate.
Copy / Paste regions
Copy a region from one photo and paste it into another, or paste from your clipboard — then move, resize and turn it before it lands.
Cross-photo pixel compositing with alpha blending. The placement box has a rotate handle (Shift snaps to 15°); the preview samples nearest-neighbour and the commit resamples smoothly.
Color Picker
Hover for a magnified look, click to pick a color for brush and text.
Edit → Color Picker; 11×11 magnifier sourced from Rust get_pixel_region.
Color picker history
Recent picked colors, one click to reuse.
Per-session list under Edit → Color Picker; same list UI as guides.
Enhance6
Make the photo look better and weigh less. Sliders, presets and levels preview live on your own picture.
Adjustments
Brightness, contrast, saturation, shadows, highlights, blur and sharpen.
Enhance → Adjustments. The photo updates as you drag and letting go is one undo step. Every slider has its own reset, and it puts back exactly what was there (src/adjust.rs).
Levels
Black point, white point and midtones, each with its own reset, over a live histogram.
Preview from a copy of the layer through a 256-entry LUT so moves never compound; Apply is one undo step (src/levels.rs). No curve chart: the histogram is pinned under the panel.
Presets
Twelve one-click looks — hover to preview on your photo, click to keep.
Five numbers run through filters the engine already had (apply_stack in src/presets.rs). No hue control, so Warm and Cool are saturation looks.
Histogram
A live RGB / luma scope while you work, pinned under Adjustments, Levels and Presets.
Computed in Rust (calculate_histogram) from the composite buffer — no offscreen-canvas sampling.
Resize & Compress
Resize, then Compress, then what the file would weigh against Google's limit for that many pixels.
Resampling fully in WASM and lands on the exact size you type. Image weight is the measured file Apply would write; the WebP · JPEG · PNG · AVIF row is a preview, and Apply keeps the photo's own format.
Perspective / Distort / Skew
Drag four corners to warp a shape, text or the photo itself — and the warped object stays editable.
Corners stored on the object as fractions of its box; homography and resampler in Rust (src/perspective.rs), drag rules in TypeScript.
Edit & layout7
Crop, rotate, line things up, and compare before with after.
Edit group
Crop, transform, canvas size, guides, rulers and grid — each its own tool with its own link.
Crop: SVG overlay with rule-of-thirds guides and 8 handles, committed through Rust. Transform: flip and rotate 90°.
Rulers & Grids
Pixel rulers plus a grid overlay: square spacing, golden ratio, or N×M.
Geometry computed in Rust (grid_lines) as the single source, projected to an SVG overlay.
Blank Canvas
Start from nothing: a size preset and a white, black, hex or transparent background.
FHD / Square / Story / 4×6 / 5×7 / 8×10; the fill is generated in Rust (blank_png → codec::export_png).
Create AI Image
Describe a picture and get a new photo in your gallery. Six models to pick from; Paid, with online features on.
Flux Schnell / Dev / 1.1 Pro / Kontext Pro, Stable Diffusion 3.5 Large and Ideogram v3 Turbo on Replicate; references for the three that read them. Finished jobs release reference uploads unless another record still uses them.
Pan
Hold H or Space to move around; tap H to stay in pan mode.
All tool handlers bypassed during a pan; typing h in a text field does nothing.
A/B Compare
Drag a divider across the photo to see original versus edited, over any tool.
Overlay positioned exactly over the canvas box (ResizeObserver) so layers stay pixel-aligned; disabled in Batch.
Batch Crop
Crop every open photo to the same shape at once. Tick the exceptions and they get a crop of their own.
Batch › Crop. Main and Exceptions each keep their own ratio, and one press crops both. Nine-cell anchor, or drag each photo's own frame on the preview; the other thumbnails shade what they will lose. Shift breaks the ratio, Enter crops them all. Grid previews keep their space while loading, show when an image is unavailable, and keep the previous preview visible while an edit loads.
Layers & history5
Nothing is lost. Every step is in the history, every photo keeps its own session, and layers survive a reload.
Layers
A real layer stack per photo — 8 layers, 16 on Pro. Move, mask, tint, reorder, flatten.
Lives in the Rust engine and is saved with the edit; Review → Layers and Edit → Layers.
History
Undo and redo with labeled steps; jump to any point. 50 steps by default, up to 1000.
Snapshots include dimensions for crop/resize/rotate correctness; status bar shows Undo NN%.
Per-photo edit persistence
Switch photos and come back to the exact same session, undo history included.
Full WASM canvas + undo/redo stack saved to IndexedDB, PNG-encoded per snapshot.
OpenRaster import & export
Save the whole project, every layer, to a file Krita and GIMP open.
Settings → Import / Export writes and reads .ora with the layer stack intact.
Thumbnails
Fast gallery thumbnails without decoding twice.
256px WebP via Rust resize_pixels from the already-decoded pixels; working canvas ≤2048px.
Import, export & share8
Get photos in, get them out in the format you need, and share a link when you want to.
Upload
Drag and drop, browse, or paste from the clipboard.
Modal with file browser and Ctrl+V / paste button.
Multi-photo gallery
Work on many photos at once, with clear loading previews: add, switch, duplicate, multi-select and export together.
Content-addressed, zero-copy duplicates carry edits; originals preserved at full resolution in IndexedDB. A photo still importing shows a placeholder tile, a slow switch says which photo it's loading, and Download All shows its progress and skips a photo it can't read. Docked rows scroll without overlapping; hover reveals only that photo's controls. Welcome Back previews keep their space while loading and show a fallback if an image cannot be displayed.
SVG in, SVG out
An SVG you uploaded can be downloaded as an SVG again, cropped the way you cropped it.
Only the root viewBox is rewritten, so the drawing stays a vector. One image, or a .zip of every SVG. Kept for the session, not across a reload.
Plugins
Add a file format the app doesn't ship, from a file or a link you choose. It stays in your browser on that device, and nothing switches on until you say so.
Settings → Plugins, with a master switch and one per plugin. A format plugin adds a Download tile and an Import / Export pair; importing always makes a new photo. The first one is Photoshop PSD, in its own repository.
Export
Save the finished photo losslessly or compressed — and an SVG you uploaded comes back out as an SVG.
Lossless PNG via the Rust encoder; JPEG and WebP via the browser; AVIF through an encoder that loads on first use. An uploaded SVG is rewritten with its crop, so it stays a vector.
Export dropdown
Pick PNG, JPEG, WebP or AVIF right from the top bar.
Format selector in the top bar.
Security tab
Keep or strip location, time and lens data on every export — your choice, saved once.
EXIF keep/strip moved out of Compress into Settings → Security; applies to every export path.
Privacy, sync & settings8
Your photos stay in your browser. Only your settings travel — across tabs for everyone, across devices when you sign in.
Sign in from Settings
A Sign in button on the right of the Settings footer, not just a small icon in the top bar.
Clerk's SignInButton; signed in, the same spot shows your avatar.
Settings sync
Change a setting in one tab and the others follow. Sign in and your phone follows too. Photos never travel.
BroadcastChannel across tabs; one reactive Convex query across devices; adopt / push / hold / idle as one pure function. See ADR-061.
Beta features
Try unfinished things early — or hand a friend a link that turns one on for them.
Settings → Beta; choices kept in this browser and never sent anywhere; ?beta=none clears them all.
Light / Dark / System theme
Warm dark, warm paper light, or follow the OS.
CSS-variable tokens, matchMedia for System, pre-paint guard in index.html (no FOUC).
Keyboard accessibility
Skip to canvas, named controls, keyboard navigation in Settings, and readable help text in both themes.
Enter or Space works on gallery tiles and their Remove/Select buttons; toggling a selection keeps the current photo open. Up/Down moves through Settings sections, Home/End jumps to the first or last, and Tab reaches the selected section. Escape closes dialogs. Muted help text and shortcut headings meet AA contrast in light and dark themes, alongside the tool / number / Alt shortcuts.
Keyboard shortcut sheet
Alt+/ shows every shortcut, grouped.
Full reference overlay, two columns per group.
Responsive windows
Panels float on narrow screens; phones get a view-and-gallery surface.
One useBreakpoint hook: icon-only bar below ~1000px, overlay drawers below ~900px, phone surface below ~600px.
Diagnostics log
A log of what the engine and storage are doing — in memory, never sent.
Alt+Delete; engine, IndexedDB, Convex, AI and console entries, up to 500.
Under the hood15
The plumbing that keeps the rest fast and consistent.
One dialog header
Back on the left, the title in the middle, close on the right — in every dialog that has a second step.
One PaneHeader, a 1fr auto 1fr grid, so the title stays centered; Back and close are the same 24px button.
Fast integer compositing
Blending without floating point — verified within ±1 of the old result.
blend_pixel / blend_over use integer source-over math.
Five-group toolbar
Enhance · Select · Create · Edit · Batch, owning 38 sub-tools from one registry.
features/tools/toolGroups.ts drives the rail, sub-tool row, digits 1–5, shortcut sheet, dispatch and cursor.
Sub-tool routing
Every tool has its own address, so a link opens the exact tool.
#/create/brush, #/edit/color-picker; 35 older URL shapes redirect, each pinned by a test.
Sub-tool canvas dispatch
The lit sub-tool decides what a drag does and which cursor you get.
A sub-tool with no canvas gesture idles instead of inheriting the clone stamp's behavior.
Review panel
Up to three stacked sections sharing the right-hand panel.
Alt+R; 1 full / 2 halves / 3 thirds, each scrollable.
Animated panels
Panels slide in on open, in order.
TopBar → Sidebar → Gallery, Framer Motion springs; Reduce Motion suppresses them.
State management
Seven small stores instead of one big component.
Zustand: UI, tool, gallery, annotation, guides, perspective, text box; durable prefs to IndexedDB via a write-deduped adapter.
PageSpeed budget (Beta: Settings › Beta)
Compress All aims for each photo's own budget instead of a flat 200 KB: the limit Google's image audit actually uses, bytes ≤ width × height / 6, from TARGET_BYTES_PER_PIXEL_AVIF = 2 * 1/12 in Lighthouse's image-delivery-insight, kept between 28 KB and 1 MB. Resize & Compress shows that limit as Image weight for everyone, switch or not. Compress All never writes a file larger than the one it replaced, also for everyone — the fix for a bug that re-encoded an already-small q60 JPEG up 11% and reported success. Worth knowing: resizing smaller does not improve the ratio (fewer pixels lowers the limit by the same factor as the file), so what fixes a dense photo is a lower quality or a next-gen format
Photo info, handled by the engine (Beta: Settings › Beta)
camera info (EXIF and location) is read, stripped and kept by the Rust engine, checked byte for byte against the JavaScript way on every photo; if the two ever disagree you get the old result and the difference goes to Diagnostics
History forks (Beta: Settings › Beta)
undo a few steps, edit, and the steps you undid are kept as a branch instead of thrown away. A Time Machine list under History shows them; click one to go back to it, and the timeline you left becomes a branch in turn. Branches live in memory only and share the undo budget, and they are the first thing dropped when it runs short (?beta=history-forks, ADR-086)
Pins and Smart Callouts
Create → Pins drops numbered (or lettered) pins that keep their own sequence: delete pin 2 of 1–4 and the rest become 1, 2, 3; set the panel's Next to a number already used (or a letter) and the next pin is inserted there, the ones after it moving up; Renumber closes any gaps an older photo has. Each is one undo step. Only pins on the active layer are one sequence. Drop: Callout makes a pin with a label beside it, joined by a leader: click, type the label, Enter — one undo step. Drag the pin, or the label with the Text tool, and the leader follows; delete the pin and the leader and label go with it. The join is geometry, like Connect's arrows, so nothing new is saved (hooks/usePinSteps.ts, lib/callouts.ts, tests/pin_steps.rs, tests/smart_callout.rs)
Batch Resize & Compress
one size, format and quality for the whole gallery (Batch → Resize & Compress). Pick Width or Long edge, a size (Keep, 1080, 1600, 2048 or your own), a format (Keep, JPEG, WebP, AVIF, PNG) and a quality, and optionally Under N KB: each photo then gets the highest quality that fits. Every photo is done from its stored original at full size, one at a time. A photo already smaller than the size keeps its size and is only re-encoded, and a result bigger than the stored file is never written: the photo stays as it was and its row says so. Main | Exceptions works like Crop, so ticked photos get their own size in the same pass (Resize 4 to 1600 · 2 to 1080). Each photo gets a row when it finishes: 1,240 KB → 286 KB, plus the size and format it came out at. The open photo's resize is a normal undo step
Select by
Select ▾ in the gallery bar ticks photos by a rule: Portrait, Landscape, Square, Larger than… (a file size), Edited, Unedited, or Invert. A rule replaces the ticks; Shift-click adds its photos to them. The ticks are Batch's Exceptions, so Select ▾ → Portrait and then Batch gives the portraits their own crop or size
Export recipes
Download All has a Recipe list: Instagram 1080×1350 (every file exactly 1080×1350 JPEG, cropped from the center), Web 1600 (WebP, long edge 1600) and Email small (JPEG, long edge 1024). Save current as recipe… keeps your own: a size, the format above, a quality and a file name pattern ({name}, {n}, {w}, {h}). A recipe shapes the ZIP only; the photos in the gallery stay as they are. Saved recipes are part of Settings, so they stay on this device when signed out and follow you to your other devices when signed in
70 of them, and the editing ones are all free.