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) => (
+20 -2
View File
@@ -67,17 +67,25 @@ const COLOR_TOOL_IDS = new Set([
// Tools that don't need a file dropzone (they generate content or have custom UI)
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate"]);
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
const LIVE_PREVIEW_TOOLS = new Set([
"rotate",
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
]);
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
function ToolSettingsPanel({
toolId,
onPreviewTransform,
onPreviewFilter,
cropProps,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
onPreviewFilter?: (filter: string) => void;
cropProps?: {
cropState: {
crop: Crop;
@@ -97,7 +105,8 @@ function ToolSettingsPanel({
if (toolId === "convert") return <ConvertSettings />;
if (toolId === "compress") return <CompressSettings />;
if (toolId === "strip-metadata") return <StripMetadataSettings />;
if (COLOR_TOOL_IDS.has(toolId)) return <ColorSettings toolId={toolId} />;
if (COLOR_TOOL_IDS.has(toolId))
return <ColorSettings toolId={toolId} onPreviewFilter={onPreviewFilter} />;
// Phase 3: Watermark & Overlay
if (toolId === "watermark-text") return <WatermarkTextSettings />;
if (toolId === "watermark-image") return <WatermarkImageSettings />;
@@ -229,6 +238,7 @@ export function ToolPage() {
);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>("");
const [cropCrop, setCropCrop] = useState<Crop>({
unit: "%",
@@ -364,6 +374,7 @@ export function ToolPage() {
onPreviewTransform={
LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined
}
onPreviewFilter={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewFilter : undefined}
cropProps={
INTERACTIVE_CROP_TOOLS.has(tool.id)
? {
@@ -465,6 +476,9 @@ export function ToolPage() {
cssFlipV: previewTransform.flipV,
}
: {})}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewFilter
? { cssFilter: previewFilter }
: {})}
/>
) : (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
@@ -533,6 +547,7 @@ export function ToolPage() {
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
onPreviewFilter={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewFilter : undefined}
cropProps={
INTERACTIVE_CROP_TOOLS.has(tool.id)
? {
@@ -648,6 +663,9 @@ export function ToolPage() {
cssFlipV: previewTransform.flipV,
}
: {})}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewFilter
? { cssFilter: previewFilter }
: {})}
/>
) : (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />