import type React from "react"; import { createPortal } from "react-dom"; import { Blend, Box, CircleDot, SlidersHorizontal, Sparkles, X, } from "lucide-react"; import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; export type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects"; export type ImageAdjustmentState = { light: number; exposure: number; contrast: number; highlights: number; shadows: number; whites: number; blacks: number; vibrance: number; saturation: number; temperature: number; tint: number; sharpen: number; clarity: number; grain: number; vignette: number; softLight: number; glow: number; }; export type ImageAdjustmentKey = keyof ImageAdjustmentState; export const DEFAULT_IMAGE_ADJUSTMENTS: ImageAdjustmentState = { light: 0, exposure: 0, contrast: 0, highlights: 0, shadows: 0, whites: 0, blacks: 0, vibrance: 0, saturation: 0, temperature: 0, tint: 0, sharpen: 0, clarity: 0, grain: 0, vignette: 0, softLight: 0, glow: 0, }; export const IMAGE_ADJUSTMENT_KEYS = Object.keys( DEFAULT_IMAGE_ADJUSTMENTS ) as ImageAdjustmentKey[]; export const IMAGE_ADJUST_SECTIONS: Record< ImageAdjustPanelTab, Array<{ key: ImageAdjustmentKey; label: string; min: number; max: number; trackClassName?: string; }> > = { light: [ { key: "light", label: "光线", min: -100, max: 100 }, { key: "exposure", label: "曝光", min: -100, max: 100 }, { key: "contrast", label: "对比度", min: -100, max: 100 }, { key: "highlights", label: "高光", min: -100, max: 100 }, { key: "shadows", label: "阴影", min: -100, max: 100 }, { key: "whites", label: "白色", min: -100, max: 100 }, { key: "blacks", label: "黑色", min: -100, max: 100 }, ], color: [ { key: "vibrance", label: "自然饱和度", min: -100, max: 100 }, { key: "saturation", label: "饱和度", min: -100, max: 100 }, { key: "temperature", label: "色温", min: -100, max: 100, trackClassName: "h-[4px] bg-[linear-gradient(90deg,#4aa7ff,#f2c23a)]", }, { key: "tint", label: "色调", min: -100, max: 100, trackClassName: "h-[4px] bg-[linear-gradient(90deg,#ff66d7,#41d278)]", }, ], detail: [ { key: "sharpen", label: "锐化", min: 0, max: 100 }, { key: "clarity", label: "清晰度", min: 0, max: 100 }, { key: "grain", label: "颗粒", min: 0, max: 100 }, ], effects: [ { key: "vignette", label: "晕影", min: 0, max: 100 }, { key: "softLight", label: "柔光", min: 0, max: 100 }, { key: "glow", label: "辉光", min: 0, max: 100 }, ], }; const IMAGE_ADJUST_TAB_OPTIONS = [ { id: "light" as const, label: "光线", icon: SlidersHorizontal, }, { id: "color" as const, label: "颜色", icon: Blend, }, { id: "detail" as const, label: "细节", icon: CircleDot, }, { id: "effects" as const, label: "效果", icon: Box, }, ]; const clampImageAdjustmentNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); export function normalizeImageAdjustments(value: unknown): ImageAdjustmentState { const raw = value && typeof value === "object" && !Array.isArray(value) ? (value as Partial>) : {}; return { light: clampImageAdjustmentNumber(Number(raw.light ?? 0), -100, 100), exposure: clampImageAdjustmentNumber(Number(raw.exposure ?? 0), -100, 100), contrast: clampImageAdjustmentNumber(Number(raw.contrast ?? 0), -100, 100), highlights: clampImageAdjustmentNumber(Number(raw.highlights ?? 0), -100, 100), shadows: clampImageAdjustmentNumber(Number(raw.shadows ?? 0), -100, 100), whites: clampImageAdjustmentNumber(Number(raw.whites ?? 0), -100, 100), blacks: clampImageAdjustmentNumber(Number(raw.blacks ?? 0), -100, 100), vibrance: clampImageAdjustmentNumber(Number(raw.vibrance ?? 0), -100, 100), saturation: clampImageAdjustmentNumber(Number(raw.saturation ?? 0), -100, 100), temperature: clampImageAdjustmentNumber(Number(raw.temperature ?? 0), -100, 100), tint: clampImageAdjustmentNumber(Number(raw.tint ?? 0), -100, 100), sharpen: clampImageAdjustmentNumber(Number(raw.sharpen ?? 0), 0, 100), clarity: clampImageAdjustmentNumber(Number(raw.clarity ?? 0), 0, 100), grain: clampImageAdjustmentNumber(Number(raw.grain ?? 0), 0, 100), vignette: clampImageAdjustmentNumber(Number(raw.vignette ?? 0), 0, 100), softLight: clampImageAdjustmentNumber(Number(raw.softLight ?? 0), 0, 100), glow: clampImageAdjustmentNumber(Number(raw.glow ?? 0), 0, 100), }; } export function areImageAdjustmentsEqual( left: ImageAdjustmentState, right: ImageAdjustmentState ) { return IMAGE_ADJUSTMENT_KEYS.every((key) => left[key] === right[key]); } export function isDefaultImageAdjustments(value: ImageAdjustmentState) { return areImageAdjustmentsEqual(value, DEFAULT_IMAGE_ADJUSTMENTS); } export function buildImageAdjustmentFilterStyle( adjustments: ImageAdjustmentState ): React.CSSProperties { const brightnessLift = adjustments.light * 0.34 + adjustments.exposure * 0.52 + adjustments.shadows * 0.2 + adjustments.whites * 0.12 - adjustments.blacks * 0.16 - adjustments.highlights * 0.06; const brightness = clampImageAdjustmentNumber(1 + brightnessLift / 100, 0.35, 2.2); const contrastBoost = adjustments.contrast * 0.78 + adjustments.clarity * 0.18 + adjustments.sharpen * 0.1 + adjustments.highlights * 0.06 - adjustments.shadows * 0.04; const contrast = clampImageAdjustmentNumber(1 + contrastBoost / 100, 0.45, 2.1); const saturationBoost = adjustments.saturation * 0.9 + adjustments.vibrance * 0.55 + adjustments.sharpen * 0.04; const saturate = clampImageAdjustmentNumber(1 + saturationBoost / 100, 0.1, 2.4); const hueRotate = clampImageAdjustmentNumber(adjustments.tint * 0.35, -36, 36); const sepia = clampImageAdjustmentNumber( adjustments.temperature > 0 ? adjustments.temperature * 0.18 : 0, 0, 22 ); return { filter: [ `brightness(${brightness.toFixed(3)})`, `contrast(${contrast.toFixed(3)})`, `saturate(${saturate.toFixed(3)})`, `hue-rotate(${hueRotate.toFixed(1)}deg)`, `sepia(${sepia.toFixed(1)}%)`, ].join(" "), willChange: "filter", }; } export function ImageAdjustPanelSlider({ disabled = false, label, max, min, onChange, trackClassName = "h-[4px] bg-[#e5e5e5]", value, }: { disabled?: boolean; label: string; max: number; min: number; onChange: (value: number) => void; trackClassName?: string; value: number; }) { return (
{label} {Math.round(value)}
onChange(nextValue ?? value)} rangeClassName="bg-transparent" thumbClassName="h-5 w-5 border-[5px] border-[#2d2f33] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.18)]" trackClassName={trackClassName} value={[value]} />
); } export function ImageAdjustFloatingPanel({ disabled = false, draft, hasImageSource, isSelected, label, onClose, onDraftChange, onReset, onTabChange, open, position, scale, tab, }: { disabled?: boolean; draft: ImageAdjustmentState; hasImageSource: boolean; isSelected: boolean; label: string; onClose: () => void; onDraftChange: (updater: (previous: ImageAdjustmentState) => ImageAdjustmentState) => void; onReset: () => void; onTabChange: (tab: ImageAdjustPanelTab) => void; open: boolean; position: { left: number; top: number }; scale: number; tab: ImageAdjustPanelTab; }) { if (!isSelected || !open || !hasImageSource || typeof document === "undefined") { return null; } return createPortal(
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} style={{ top: position.top, left: position.left, transform: `scale(${scale})`, transformOrigin: "top left", }} >

{label}

{IMAGE_ADJUST_TAB_OPTIONS.map((item) => { const active = tab === item.id; const Icon = item.icon; return ( ); })}
{IMAGE_ADJUST_SECTIONS[tab].map((field) => ( onDraftChange((previous) => ({ ...previous, [field.key]: value, })) } trackClassName={field.trackClassName} value={draft[field.key]} /> ))}
, document.body ); }