Image editor

This editor adjusts brightness, contrast, saturation and hue while keeping the crop, preview and exported pixels consistent. It replaces the background with a drawn canvas through typed snippets.

Keep adjustments reactive

The editor stores the adjustment values in $state. Each slider binds to the selected adjustment. A derived crop-mode flag enables image movement and scaling and the child stencil’s controls only when cropping is active.

Changing an adjustment redraws the canvas without changing src or resetting the user’s crop. Uploading a different image resets the adjustment controls and releases the previous owned object URL.

Draw the source

AdjustableImage loads the photo through CropperSource with bind:element, then draws it into a canvas. An effect tracks the adjustment values; the source’s load event triggers the first draw. The filter helper uses native canvas filters when supported and a pixel-processing fallback otherwise.

<script lang="ts">
	import type { ClassValue } from 'svelte/elements';
	import { CropperSource, type AttachBackgroundSource } from 'svelte-advanced-cropper';
	import { drawAdjustedImage, type Adjustments } from './filters.ts';

	interface Props extends Partial<Adjustments> {
		src?: string;
		class?: ClassValue;
		crossOrigin?: 'anonymous' | 'use-credentials' | boolean;
		style?: string;
		/** The canvas the adjusted image is drawn into. */
		attachSource?: AttachBackgroundSource;
	}

	let {
		src,
		class: cssClass,
		crossOrigin,
		brightness = 0,
		saturation = 0,
		hue = 0,
		contrast = 0,
		style,
		attachSource
	}: Props = $props();

	let canvas: HTMLCanvasElement | undefined = $state();
	let source: HTMLImageElement | null = $state(null);

	// Each source owns a readiness promise; registration waits for its filtered pixels.
	const readiness = $derived.by(() => {
		void src;
		let resolve: (() => void) | undefined;
		const ready = new Promise<void>((done) => {
			resolve = done;
		});

		return { ready, resolve };
	});

	// Draws the source image into the canvas, with the adjustments applied. It runs as an
	// effect, to redraw whenever an adjustment changes, and once more when the image loads.
	function draw() {
		// Read the adjustments before the readiness check, so the effect always tracks them,
		// even when its first run comes before the image has loaded.
		const adjustments = { brightness, contrast, saturation, hue };
		if (canvas && source?.complete && source.naturalWidth > 0) {
			drawAdjustedImage(canvas, source, adjustments);
			readiness.resolve?.();
		}
	}

	$effect(draw);
</script>

{#key src}
	<canvas
		bind:this={canvas}
		{@attach attachSource?.(readiness.ready)}
		class={['adjustable-image-element', cssClass]}
		{style}
	></canvas>
	<CropperSource
		bind:element={source}
		{src}
		{crossOrigin}
		class="adjustable-image-source"
		onload={draw}
	/>
{/key}

<style>
	.adjustable-image-element {
		position: absolute;
		pointer-events: none;
		user-select: none;
		transform-origin: center;
		max-width: none !important;
	}
	:global(.adjustable-image-source) {
		display: none;
	}
</style>
/** Each adjustment is in [-1, 1]; 0 leaves the image unchanged. */
export interface Adjustments {
	brightness: number;
	contrast: number;
	saturation: number;
	hue: number;
}

function getFilter({ brightness, contrast, saturation, hue }: Adjustments): string {
	return [
		`brightness(${100 + brightness * 100}%)`,
		`contrast(${100 + contrast * 100}%)`,
		`saturate(${100 + saturation * 100}%)`,
		`hue-rotate(${hue * 360}deg)`
	].join(' ');
}

// Chrome and Firefox support `CanvasRenderingContext2D.filter`. Safari only has it
// behind a feature flag, so it gets the per-pixel fallback below.
const filterSupported =
	typeof CanvasRenderingContext2D !== 'undefined' && 'filter' in CanvasRenderingContext2D.prototype;

/** Draws `image` into `canvas` at its natural size, with the adjustments applied. */
export function drawAdjustedImage(
	canvas: HTMLCanvasElement,
	image: HTMLImageElement,
	adjustments: Adjustments
) {
	const ctx = canvas.getContext('2d');
	if (!ctx) {
		return;
	}

	canvas.width = image.naturalWidth;
	canvas.height = image.naturalHeight;

	if (filterSupported) {
		ctx.filter = getFilter(adjustments);
		ctx.drawImage(image, 0, 0);
	} else {
		ctx.drawImage(image, 0, 0);
		applyAdjustments(ctx, canvas.width, canvas.height, adjustments);
	}
}

const clamp = (value: number) => Math.min(255, Math.max(0, value));

/** Multiplies a colour by a 3×3 matrix. */
function multiply(m: number[], [r, g, b]: number[]) {
	return [
		clamp(m[0] * r + m[1] * g + m[2] * b),
		clamp(m[3] * r + m[4] * g + m[5] * b),
		clamp(m[6] * r + m[7] * g + m[8] * b)
	];
}

/**
 * The same filters, computed by hand with the formulas from the CSS Filter Effects
 * spec, applied in the same order as `getFilter()`.
 */
function applyAdjustments(
	ctx: CanvasRenderingContext2D,
	width: number,
	height: number,
	{ brightness, contrast, saturation, hue }: Adjustments
) {
	if (!width || !height) {
		return;
	}

	const brightnessFactor = 1 + brightness;
	const contrastFactor = 1 + contrast;
	const saturationFactor = 1 + saturation;
	// prettier-ignore
	const saturate = [
		0.213 + 0.787 * saturationFactor, 0.715 - 0.715 * saturationFactor, 0.072 - 0.072 * saturationFactor,
		0.213 - 0.213 * saturationFactor, 0.715 + 0.285 * saturationFactor, 0.072 - 0.072 * saturationFactor,
		0.213 - 0.213 * saturationFactor, 0.715 - 0.715 * saturationFactor, 0.072 + 0.928 * saturationFactor
	];
	const cos = Math.cos(hue * 2 * Math.PI);
	const sin = Math.sin(hue * 2 * Math.PI);
	// prettier-ignore
	const rotate = [
		0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
		0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.14, 0.072 - cos * 0.072 - sin * 0.283,
		0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
	];

	const imageData = ctx.getImageData(0, 0, width, height);
	const { data } = imageData;
	for (let i = 0; i < data.length; i += 4) {
		let rgb = [data[i], data[i + 1], data[i + 2]].map((value) =>
			clamp((clamp(value * brightnessFactor) - 127.5) * contrastFactor + 127.5)
		);
		rgb = multiply(rotate, multiply(saturate, rgb));
		data[i] = rgb[0];
		data[i + 1] = rgb[1];
		data[i + 2] = rgb[2];
	}

	ctx.putImageData(imageData, 0, 0);
}

Register the filtered export source

CropperBackgroundSnippetProps supplies cropper, crossOrigin, class, style and attachSource. The background computes image placement with getBackgroundStyle and forwards those values to AdjustableImage.

<script lang="ts">
	import {
		getBackgroundStyle,
		styleToString,
		type CropperBackgroundSnippetProps
	} from 'svelte-advanced-cropper';
	import AdjustableImage from './AdjustableImage.svelte';
	import type { Adjustments } from './filters.ts';

	type Props = CropperBackgroundSnippetProps & Partial<Adjustments>;
	let {
		class: cssClass,
		style,
		cropper,
		crossOrigin,
		attachSource,
		brightness,
		contrast,
		saturation,
		hue
	}: Props = $props();
	const state = $derived(cropper.getState());
	const transitions = $derived(cropper.getTransitions());
	const image = $derived(cropper.getImage());
	const imageStyle = $derived(
		image && state ? styleToString(getBackgroundStyle(image, state, transitions)) : ''
	);
</script>

<AdjustableImage
	{brightness}
	{contrast}
	{saturation}
	{hue}
	src={image?.src}
	{crossOrigin}
	class={cssClass}
	style={`${imageStyle};${style ?? ''}`}
	{attachSource}
/>

The actual canvas carries {@attach attachSource?.(readiness.ready)}. Each image generation owns a readiness promise, resolved after its first complete filtered draw. With canvas enabled, cropper onReady waits for that source. Without a current ready source, getCanvas() returns null; it does not silently export the unfiltered photo.

For a production background with asynchronous drawing, reject readiness when loading or drawing fails and surface the error. The demo shows the successful drawing path. Registration cleanup belongs to the attachment; an old canvas cannot unregister its replacement.

Render a matching preview

The preview’s background snippet receives a read-only preview source and its fitted size. getPreviewStyle positions the same adjusted image within that space. It needs no export-source attachment because previews do not export pixels.

<script lang="ts">
	import {
		getPreviewStyle,
		styleToString,
		type CropperPreviewBackgroundSnippetProps
	} from 'svelte-advanced-cropper';
	import AdjustableImage from './AdjustableImage.svelte';
	import type { Adjustments } from './filters.ts';

	type Props = CropperPreviewBackgroundSnippetProps & Partial<Adjustments>;
	let {
		class: cssClass,
		style,
		preview,
		crossOrigin,
		size,
		brightness,
		contrast,
		saturation,
		hue
	}: Props = $props();
	const state = $derived(preview.getState());
	const transitions = $derived(preview.getTransitions());
	const image = $derived(preview.getImage());
	const imageStyle = $derived(
		image && state && size ? styleToString(getPreviewStyle(image, state, size, transitions)) : ''
	);
</script>

<AdjustableImage
	{brightness}
	{contrast}
	{saturation}
	{hue}
	src={image?.src}
	{crossOrigin}
	class={cssClass}
	style={`${imageStyle};${style ?? ''}`}
/>

Passing the bound cropper to CropperPreview keeps geometry reactive. Passing the same adjustments to both backgrounds keeps the pixels consistent. The download action calls getCanvas() on the cropper, so its result comes from the registered filtered canvas.

For bounded Blob encoding and error handling, combine this approach with Preview and export.