refactor: extract image node adjustment panel
This commit is contained in:
parent
cc52f414af
commit
f0a7cbe2ac
@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Blend,
|
||||
Box,
|
||||
Camera,
|
||||
CircleDot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@ -50,6 +48,16 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution
|
||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import {
|
||||
areImageAdjustmentsEqual,
|
||||
buildImageAdjustmentFilterStyle,
|
||||
DEFAULT_IMAGE_ADJUSTMENTS,
|
||||
ImageAdjustFloatingPanel,
|
||||
type ImageAdjustmentState,
|
||||
type ImageAdjustPanelTab,
|
||||
isDefaultImageAdjustments,
|
||||
normalizeImageAdjustments,
|
||||
} from "@/components/canvas/nodes/image-node/image-node-adjustments";
|
||||
import { ImageNodeAdjustOverlays } from "@/components/canvas/nodes/image-node/image-node-adjust-overlays";
|
||||
import {
|
||||
ImageNodeDeferredLoadState,
|
||||
@ -186,7 +194,6 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||||
|
||||
type AngleEditPanelMode = "subject" | "camera";
|
||||
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
||||
type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects";
|
||||
type CropPanelGroupKey =
|
||||
| "common"
|
||||
| "instagram"
|
||||
@ -204,28 +211,6 @@ type CropInteractionHandle =
|
||||
| "sw"
|
||||
| "se";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type ImageAdjustmentKey = keyof ImageAdjustmentState;
|
||||
|
||||
type CropPresetOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
@ -330,28 +315,6 @@ const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [
|
||||
];
|
||||
const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
|
||||
const DEFAULT_CROP_PRESET_ID = "1:1";
|
||||
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,
|
||||
};
|
||||
const IMAGE_ADJUSTMENT_KEYS = Object.keys(
|
||||
DEFAULT_IMAGE_ADJUSTMENTS
|
||||
) as ImageAdjustmentKey[];
|
||||
const CROP_PRESET_GROUPS: CropPresetGroup[] = [
|
||||
{
|
||||
key: "common",
|
||||
@ -398,128 +361,6 @@ const CROP_PRESET_GROUPS: CropPresetGroup[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeImageAdjustments(value: unknown): ImageAdjustmentState {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Partial<Record<ImageAdjustmentKey, unknown>>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
light: clampNumber(Number(raw.light ?? 0), -100, 100),
|
||||
exposure: clampNumber(Number(raw.exposure ?? 0), -100, 100),
|
||||
contrast: clampNumber(Number(raw.contrast ?? 0), -100, 100),
|
||||
highlights: clampNumber(Number(raw.highlights ?? 0), -100, 100),
|
||||
shadows: clampNumber(Number(raw.shadows ?? 0), -100, 100),
|
||||
whites: clampNumber(Number(raw.whites ?? 0), -100, 100),
|
||||
blacks: clampNumber(Number(raw.blacks ?? 0), -100, 100),
|
||||
vibrance: clampNumber(Number(raw.vibrance ?? 0), -100, 100),
|
||||
saturation: clampNumber(Number(raw.saturation ?? 0), -100, 100),
|
||||
temperature: clampNumber(Number(raw.temperature ?? 0), -100, 100),
|
||||
tint: clampNumber(Number(raw.tint ?? 0), -100, 100),
|
||||
sharpen: clampNumber(Number(raw.sharpen ?? 0), 0, 100),
|
||||
clarity: clampNumber(Number(raw.clarity ?? 0), 0, 100),
|
||||
grain: clampNumber(Number(raw.grain ?? 0), 0, 100),
|
||||
vignette: clampNumber(Number(raw.vignette ?? 0), 0, 100),
|
||||
softLight: clampNumber(Number(raw.softLight ?? 0), 0, 100),
|
||||
glow: clampNumber(Number(raw.glow ?? 0), 0, 100),
|
||||
};
|
||||
}
|
||||
|
||||
function areImageAdjustmentsEqual(
|
||||
left: ImageAdjustmentState,
|
||||
right: ImageAdjustmentState
|
||||
) {
|
||||
return IMAGE_ADJUSTMENT_KEYS.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function isDefaultImageAdjustments(value: ImageAdjustmentState) {
|
||||
return areImageAdjustmentsEqual(value, DEFAULT_IMAGE_ADJUSTMENTS);
|
||||
}
|
||||
|
||||
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 = clampNumber(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 = clampNumber(1 + contrastBoost / 100, 0.45, 2.1);
|
||||
const saturationBoost =
|
||||
adjustments.saturation * 0.9 +
|
||||
adjustments.vibrance * 0.55 +
|
||||
adjustments.sharpen * 0.04;
|
||||
const saturate = clampNumber(1 + saturationBoost / 100, 0.1, 2.4);
|
||||
const hueRotate = clampNumber(adjustments.tint * 0.35, -36, 36);
|
||||
const sepia = clampNumber(
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-[18px] font-medium tracking-[0] text-[#1f232a]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[18px] font-medium tabular-nums tracking-[0] text-[#6f747c]">
|
||||
{Math.round(value)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
max={max}
|
||||
min={min}
|
||||
onValueChange={([nextValue]) => 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]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizeAnglePanelHorizontalAngle = (value: number) => {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return value < 0 ? 360 + value : value;
|
||||
@ -7600,76 +7441,6 @@ export function ImageNode({
|
||||
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
||||
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
||||
const anglePanelPosition = getAnglePanelPosition();
|
||||
const imageAdjustTabOptions = [
|
||||
{
|
||||
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 imageAdjustSections: 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 boxCutoutHintText = boxCutoutSelection
|
||||
? "确认后将仅抠出框选区域"
|
||||
: isBoxCutoutDrawing
|
||||
@ -9506,97 +9277,20 @@ export function ImageNode({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{isSelected &&
|
||||
imageAdjustPanelOpen &&
|
||||
data.src &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] w-[520px] overflow-hidden rounded-[30px] border border-[#ebe7df] bg-white shadow-[0_24px_64px_rgba(17,24,39,0.12)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
top: imageAdjustPanelPosition.top,
|
||||
left: imageAdjustPanelPosition.left,
|
||||
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-8 pt-8 pb-5">
|
||||
<h3 className="text-[20px] font-semibold tracking-[0] text-[#18181b]">
|
||||
{imageAdjustItem.label}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-[#8c8d92] transition-colors hover:bg-[#f4f2ed] hover:text-[#2f3136] disabled:cursor-not-allowed disabled:text-[#cbc7c0]"
|
||||
disabled={isDefaultImageAdjustments(imageAdjustmentsDraft)}
|
||||
onClick={handleResetImageAdjustments}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-[#8c8d92] transition-colors hover:bg-[#f4f2ed] hover:text-[#2f3136]"
|
||||
onClick={() => setImageAdjustPanelOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-7 pb-7">
|
||||
<div className="rounded-[20px] bg-[#f3f1ec] p-1">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{imageAdjustTabOptions.map((item) => {
|
||||
const active = imageAdjustTab === item.id;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-14 items-center justify-center rounded-[16px] text-[#7a7a7d] transition-all",
|
||||
active
|
||||
? "bg-white text-[#23262d] shadow-[0_1px_2px_rgba(0,0,0,0.05)]"
|
||||
: "hover:text-[#2d3138]"
|
||||
)}
|
||||
key={item.id}
|
||||
onClick={() => setImageAdjustTab(item.id)}
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 max-h-[600px] overflow-y-auto pr-2">
|
||||
<div className="space-y-7 pb-2">
|
||||
{imageAdjustSections[imageAdjustTab].map((field) => (
|
||||
<ImageAdjustPanelSlider
|
||||
disabled={false}
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onChange={(value) =>
|
||||
setImageAdjustmentsDraft((prev) => ({
|
||||
...prev,
|
||||
[field.key]: value,
|
||||
}))
|
||||
}
|
||||
trackClassName={field.trackClassName}
|
||||
value={imageAdjustmentsDraft[field.key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<ImageAdjustFloatingPanel
|
||||
draft={imageAdjustmentsDraft}
|
||||
hasImageSource={Boolean(data.src)}
|
||||
isSelected={isSelected}
|
||||
label={imageAdjustItem.label}
|
||||
onClose={() => setImageAdjustPanelOpen(false)}
|
||||
onDraftChange={setImageAdjustmentsDraft}
|
||||
onReset={handleResetImageAdjustments}
|
||||
onTabChange={setImageAdjustTab}
|
||||
open={imageAdjustPanelOpen}
|
||||
position={imageAdjustPanelPosition}
|
||||
scale={IMAGE_EDIT_PANEL_SCALE}
|
||||
tab={imageAdjustTab}
|
||||
/>
|
||||
|
||||
{isSelected &&
|
||||
flipRotatePanelOpen &&
|
||||
|
||||
379
components/canvas/nodes/image-node/image-node-adjustments.tsx
Normal file
379
components/canvas/nodes/image-node/image-node-adjustments.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
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<Record<ImageAdjustmentKey, unknown>>)
|
||||
: {};
|
||||
|
||||
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 (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-[18px] font-medium tracking-[0] text-[#1f232a]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[18px] font-medium tabular-nums tracking-[0] text-[#6f747c]">
|
||||
{Math.round(value)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
max={max}
|
||||
min={min}
|
||||
onValueChange={([nextValue]) => 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]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<div
|
||||
className="fixed z-[9999] w-[520px] overflow-hidden rounded-[30px] border border-[#ebe7df] bg-white shadow-[0_24px_64px_rgba(17,24,39,0.12)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-8 pt-8 pb-5">
|
||||
<h3 className="text-[20px] font-semibold tracking-[0] text-[#18181b]">
|
||||
{label}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-[#8c8d92] transition-colors hover:bg-[#f4f2ed] hover:text-[#2f3136] disabled:cursor-not-allowed disabled:text-[#cbc7c0]"
|
||||
disabled={disabled || isDefaultImageAdjustments(draft)}
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-[#8c8d92] transition-colors hover:bg-[#f4f2ed] hover:text-[#2f3136]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-7 pb-7">
|
||||
<div className="rounded-[20px] bg-[#f3f1ec] p-1">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{IMAGE_ADJUST_TAB_OPTIONS.map((item) => {
|
||||
const active = tab === item.id;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-14 items-center justify-center rounded-[16px] text-[#7a7a7d] transition-all",
|
||||
active
|
||||
? "bg-white text-[#23262d] shadow-[0_1px_2px_rgba(0,0,0,0.05)]"
|
||||
: "hover:text-[#2d3138]"
|
||||
)}
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 max-h-[600px] overflow-y-auto pr-2">
|
||||
<div className="space-y-7 pb-2">
|
||||
{IMAGE_ADJUST_SECTIONS[tab].map((field) => (
|
||||
<ImageAdjustPanelSlider
|
||||
disabled={disabled}
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onChange={(value) =>
|
||||
onDraftChange((previous) => ({
|
||||
...previous,
|
||||
[field.key]: value,
|
||||
}))
|
||||
}
|
||||
trackClassName={field.trackClassName}
|
||||
value={draft[field.key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user