Skip to content

feat(image-editor): Adjustments menu — 10 PS-style adjustment layers#51

Open
lyfuci wants to merge 1 commit intomainfrom
feat/image-editor-adjustments
Open

feat(image-editor): Adjustments menu — 10 PS-style adjustment layers#51
lyfuci wants to merge 1 commit intomainfrom
feat/image-editor-adjustments

Conversation

@lyfuci
Copy link
Copy Markdown
Owner

@lyfuci lyfuci commented May 6, 2026

Summary

Introduces the Image > Adjustments menu with 10 PS-aligned non-destructive adjustment layers — the missing third leg of the editor's color/tone toolkit. Stack them, reorder them, toggle visibility, blend with selection clips, undo/redo all the way through.

What's in the menu

Adjustment Knobs
Brightness/Contrast brightness, contrast (centred at 128 with a non-linear contrast curve so 100% doesn't clamp to black/white)
Levels ⌘L input black/white, gamma, output black/white
Curves ⌘M interactive Catmull-Rom RGB tone curve editor — click-to-add, drag-to-move, Alt-click to remove interior points
Exposure log stops, offset, gamma
Vibrance vibrance (boost protected by current saturation), saturation (flat)
Hue/Saturation ⌘U hue rotate (-180..180°), saturation, lightness
Color Balance ⌘B Cyan↔Red, Magenta↔Green, Yellow↔Blue
Invert ⌘I (no params)
Posterize N levels (2..32)
Threshold 0..255 (BT.601 luma)

Each opens a modal dialog with sliders (Curves gets its own canvas editor). Live preview updates the canvas on every input change via a new extraPreviewLayer slot. Apply commits as a real layer through the regular commitLayer flow (so selection clipping from #48 + history work for free); Cancel discards.

Architecture

  • AdjustmentLayer joins the Layer union — kind: 'adjustment' carries params: AdjustmentParams (discriminated union over the 10 kinds).
  • applyAdjustment(data, params) in lib/image-editor/adjustments.ts dispatches to per-kind pure-JS pixel transforms. LUT-based adjustments (Levels, Curves, Posterize, B/C, Exposure, Invert) precompute a 256-entry lookup once per layer and share a single applyLut runner — orders of magnitude faster than per-pixel math. HSL-based ones (Hue/Sat, Vibrance) go pixel by pixel through RGB↔HSL.
  • applyAdjustmentLayer in render.ts: snapshots the accumulated canvas to a temp, applies the transform via getImageData/putImageData, then composites the temp back through the layer's clip + opacity (which naturally blends adjusted-vs-original via globalAlpha).
  • applyLayerClip widens to accept any { clipRect?, clipPath? }-bearing layer so the same selection-clip mechanism works for adjustments and annotations alike.

The renderer applies adjustments at their position in the layer stack (PS-style: affects everything below). Stack two and they compose; toggle visibility to disable.

Files

New:

  • lib/image-editor/adjustments.ts — pure-JS pixel transforms + defaults + dispatcher.
  • components/image-editor/AdjustmentDialog.tsx — modal with per-kind forms (inline sub-components).
  • components/image-editor/CurvesEditor.tsx — interactive Catmull-Rom curve editor on a 256×256 canvas.

Modified:

  • lib/image-editor/types.tsAdjustmentLayer + AdjustmentParams discriminated union + AdjustmentKind alias.
  • lib/image-editor/render.ts — adjustment-layer render block + applyAdjustmentLayer helper; clip helper widened to { clipRect?, clipPath? }.
  • lib/image-editor/transform.tstranslateLayer/resizeLayer handle the new layer kind.
  • lib/image-editor/hit.ts — bbox + handles for adjustment layers (no-op spatial geometry; selection through the layers panel).
  • components/image-editor/MenuBar.tsx — Image menu gets adjustment items as 4 sections (B/C+Levels+Curves+Exposure / Vibrance+H/S+ColorBalance / Invert+Posterize+Threshold).
  • components/image-editor/Canvas.tsx — new extraPreviewLayer prop routed through drawingPreview.
  • components/image-editor/LayersPanel.tsx — label dispatch for adjustment layers.
  • pages/ImageEditor.tsx — dialog state + handlers + preview wiring.
  • i18n/{en,zh-CN}.json — 24 adjustment-related strings.

Backward compat

AdjustmentLayer is additive — old projects load fine (no adjustment layers in their JSON). All pre-existing helpers learned about the new layer kind in this PR.

Test plan

  • Open an image, Image > Adjustments > Brightness/Contrast — sliders update canvas live; Apply commits a layer; Cancel reverts.
  • Levels: slider input black to 100, watch shadows clip; gamma to 2 brightens midtones; output white to 200 dims highlights.
  • Curves: drag the diagonal — watch the LUT build a smooth Catmull-Rom curve. Click on the diagonal to add a point; drag to make an S-curve. Alt-click an interior point to remove. Endpoints lock to x=0 and x=255.
  • Exposure: +1 EV doubles brightness; offset adds black-point shift; gamma reshapes midtones.
  • Vibrance: +100 boosts dull colors but leaves already-saturated reds alone; saturation -100 desaturates everything.
  • Hue/Saturation: hue +60 rotates the image's color wheel; sat -100 grays out; lightness +50 lightens.
  • Color Balance: positive cyanRed adds red cast; positive yellowBlue adds blue.
  • Invert: opens dialog with hint, Apply commits an inverted-image layer.
  • Posterize at 4 levels: visible color banding; at 32 nearly identical to original.
  • Threshold: 128 default produces ~50% black/white split; 64 produces high-contrast white-dominant; 200 black-dominant.
  • Stack: Levels then Curves layers — both apply in order; reorder via the layers panel and watch the result change.
  • Active selection: open any adjustment, Apply — committed layer is clipped to the selection.
  • Toggle layer visibility in the panel — adjustment turns off cleanly.
  • Save project, reload — adjustment layers render identically.
  • Export at full resolution — adjustments apply to the export canvas.
  • Undo across an Apply — adjustment layer disappears; redo brings it back.
  • Slider drag is smooth on a 900px preview (no jank).

Note

Author did not browser-verify (no browser available in this session). pnpm typecheck, pnpm lint, and pnpm build are all green; ImageEditor bundle +14 KB.

🤖 Generated with Claude Code

Introduces the Image > Adjustments menu with 10 non-destructive adjustment
layers:

- Brightness/Contrast — centred-around-128 contrast curve + brightness shift
- Levels — input/output black + white points + gamma (LUT)
- Curves — interactive Catmull-Rom RGB tone curve editor (canvas-based,
  click-to-add, drag-to-move, Alt-click to remove interior points)
- Exposure — log-stop exposure + offset + gamma
- Vibrance — saturation that protects already-saturated pixels + flat sat
- Hue/Saturation — full HSL shift (hue / sat / lightness)
- Color Balance — additive shift along Cyan↔Red, Magenta↔Green, Yellow↔Blue
- Invert — per-channel 255-x (no params)
- Posterize — N-level quantization (LUT)
- Threshold — binary B&W via BT.601 luma (LUT)

Each opens a modal dialog with sliders (Curves gets its own canvas editor)
and live preview through a new `extraPreviewLayer` slot the parent can pass
to Canvas. On Apply, the draft commits via the regular `commitLayer` flow
(so selection clipping from #48 + history work for free); Cancel discards.

- `AdjustmentLayer` joins the `Layer` union — `kind: 'adjustment'` carries
  `params: AdjustmentParams` (discriminated union over the 10 kinds).
- `applyAdjustment(data, params)` in `lib/image-editor/adjustments.ts`
  dispatches to per-kind pure-JS pixel functions. LUT-based adjustments
  precompute a 256-entry lookup once per layer; HSL-based ones go pixel by
  pixel through RGB↔HSL.
- `applyAdjustmentLayer` in `render.ts`: snapshots the accumulated canvas
  to a temp, applies the transform via getImageData/putImageData, then
  composites the temp back through the layer's clip + opacity (which
  naturally blends adjusted-vs-original via globalAlpha).
- `applyLayerClip` widens to accept any `{ clipRect?, clipPath? }`-bearing
  layer so the same selection-clip mechanism works for adjustments and
  annotations alike.

The renderer applies adjustments at their position in the layer stack
(PS-style: an adjustment affects everything below it). Stack two
adjustments and they compose; toggle visibility on either to disable. All
selection-clipping, undo/redo, and project save/load come through the
existing layer plumbing — no special cases.

`getImageData` + the per-layer LUT walk on a 900×900 preview takes ~5ms;
fine for live preview. LUT-based kinds (Levels / Curves / Posterize /
Brightness-Contrast / Exposure / Invert) all share a single `applyLut`
runner. HSL kinds (Hue/Sat / Vibrance) cost ~3× more due to RGB↔HSL —
still smooth at slider-drag cadence.

`AdjustmentLayer` is additive — old projects load fine (no adjustment
layers in their JSON). A handful of pre-existing helpers needed to learn
about the new layer kind (`getLayerBBox`, `getHandles`, `translateLayer`,
`resizeLayer`, `LayersPanel` label dispatch) — all return sensible no-ops
for adjustments since they have no spatial geometry beyond the canvas.

Author did not browser-verify (no browser at hand); typecheck + lint +
build all green. ImageEditor bundle +14 KB for the adjustments module +
dialog + Curves editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lyfuci lyfuci force-pushed the feat/image-editor-adjustments branch from 5a7a1a8 to 4836a63 Compare May 7, 2026 05:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant