We’re working on bringing Naji — the horse behind the name — home to Jacksonville.Bringing Naji home to Jacksonville.Meet Naji

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.

  • 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.