feat(adjustments): add real-time live preview for all color tools

Add CSS filter-based live preview for brightness, contrast, saturation,
color effects (grayscale/sepia/invert), and color channels (via SVG
feColorMatrix). The image in the right panel updates instantly as
sliders are adjusted. Adds cssFilter prop to ImageViewer, onPreviewFilter
callback to ColorSettings, and wires them through the tool page.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 16:01:18 +08:00
parent ddde152545
commit f357b18ced
3 changed files with 65 additions and 6 deletions
@@ -9,6 +9,7 @@ interface ImageViewerProps {
cssRotate?: number;
cssFlipH?: boolean;
cssFlipV?: boolean;
cssFilter?: string;
}
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300];
@@ -21,6 +22,7 @@ export function ImageViewer({
cssRotate,
cssFlipH,
cssFlipV,
cssFilter,
}: ImageViewerProps) {
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
@@ -88,13 +90,15 @@ export function ImageViewer({
objectFit: "contain" as const,
...(previewTransform && {
transform: previewTransform,
transition: "transform 0.25s ease",
transition: "transform 0.25s ease, filter 0.15s ease",
}),
...(cssFilter && { filter: cssFilter, transition: "filter 0.15s ease" }),
}
: {
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transformOrigin: "center center",
...(previewTransform && { transition: "transform 0.25s ease" }),
...(previewTransform && { transition: "transform 0.25s ease, filter 0.15s ease" }),
...(cssFilter && { filter: cssFilter, transition: "filter 0.15s ease" }),
};
return (
@@ -1,5 +1,5 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
@@ -10,9 +10,10 @@ type Effect = "none" | "grayscale" | "sepia" | "invert";
interface ColorSettingsProps {
/** The specific tool ID to use for processing */
toolId: string;
onPreviewFilter?: (filter: string) => void;
}
export function ColorSettings({ toolId }: ColorSettingsProps) {
export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
const { files } = useFileStore();
const {
processFiles,
@@ -44,6 +45,31 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
// Effects
const [effect, setEffect] = useState<Effect>("none");
// Emit CSS filter for live preview
const hasChannelChanges = red !== 100 || green !== 100 || blue !== 100;
useEffect(() => {
if (!onPreviewFilter) return;
const parts: string[] = [];
if (brightness !== 0) parts.push(`brightness(${1 + brightness / 100})`);
if (contrast !== 0) parts.push(`contrast(${1 + contrast / 100})`);
if (saturation !== 0) parts.push(`saturate(${1 + saturation / 100})`);
if (hasChannelChanges) parts.push("url(#stirling-channel-filter)");
if (effect === "grayscale") parts.push("grayscale(1)");
if (effect === "sepia") parts.push("sepia(1)");
if (effect === "invert") parts.push("invert(1)");
onPreviewFilter(parts.join(" "));
}, [
brightness,
contrast,
saturation,
red,
green,
blue,
effect,
hasChannelChanges,
onPreviewFilter,
]);
const handleProcess = () => {
const settings = {
brightness,
@@ -84,6 +110,17 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Hidden SVG filter for color channel preview */}
{hasChannelChanges && (
<svg width="0" height="0" style={{ position: "absolute" }}>
<filter id="stirling-channel-filter" colorInterpolationFilters="sRGB">
<feColorMatrix
type="matrix"
values={`${red / 100} 0 0 0 0 0 ${green / 100} 0 0 0 0 0 ${blue / 100} 0 0 0 0 0 1 0`}
/>
</filter>
</svg>
)}
{/* Tabs */}
<div className="flex gap-1">
{tabs.map((t) => (