mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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) => (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user