refactor: extract image node adjustment panel
This commit is contained in:
parent
cc52f414af
commit
f0a7cbe2ac
@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Blend,
|
|
||||||
Box,
|
Box,
|
||||||
Camera,
|
Camera,
|
||||||
CircleDot,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@ -50,6 +48,16 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution
|
|||||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
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 { ImageNodeAdjustOverlays } from "@/components/canvas/nodes/image-node/image-node-adjust-overlays";
|
||||||
import {
|
import {
|
||||||
ImageNodeDeferredLoadState,
|
ImageNodeDeferredLoadState,
|
||||||
@ -186,7 +194,6 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
|||||||
|
|
||||||
type AngleEditPanelMode = "subject" | "camera";
|
type AngleEditPanelMode = "subject" | "camera";
|
||||||
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
||||||
type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects";
|
|
||||||
type CropPanelGroupKey =
|
type CropPanelGroupKey =
|
||||||
| "common"
|
| "common"
|
||||||
| "instagram"
|
| "instagram"
|
||||||
@ -204,28 +211,6 @@ type CropInteractionHandle =
|
|||||||
| "sw"
|
| "sw"
|
||||||
| "se";
|
| "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 = {
|
type CropPresetOption = {
|
||||||
id: string;
|
id: string;
|
||||||
label: 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 ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
|
||||||
const DEFAULT_CROP_PRESET_ID = "1:1";
|
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[] = [
|
const CROP_PRESET_GROUPS: CropPresetGroup[] = [
|
||||||
{
|
{
|
||||||
key: "common",
|
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) => {
|
const normalizeAnglePanelHorizontalAngle = (value: number) => {
|
||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
return value < 0 ? 360 + value : value;
|
return value < 0 ? 360 + value : value;
|
||||||
@ -7600,76 +7441,6 @@ export function ImageNode({
|
|||||||
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
||||||
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
||||||
const anglePanelPosition = getAnglePanelPosition();
|
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
|
const boxCutoutHintText = boxCutoutSelection
|
||||||
? "确认后将仅抠出框选区域"
|
? "确认后将仅抠出框选区域"
|
||||||
: isBoxCutoutDrawing
|
: isBoxCutoutDrawing
|
||||||
@ -9506,97 +9277,20 @@ export function ImageNode({
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSelected &&
|
<ImageAdjustFloatingPanel
|
||||||
imageAdjustPanelOpen &&
|
draft={imageAdjustmentsDraft}
|
||||||
data.src &&
|
hasImageSource={Boolean(data.src)}
|
||||||
typeof document !== "undefined" &&
|
isSelected={isSelected}
|
||||||
createPortal(
|
label={imageAdjustItem.label}
|
||||||
<div
|
onClose={() => setImageAdjustPanelOpen(false)}
|
||||||
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)]"
|
onDraftChange={setImageAdjustmentsDraft}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onReset={handleResetImageAdjustments}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onTabChange={setImageAdjustTab}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
open={imageAdjustPanelOpen}
|
||||||
style={{
|
position={imageAdjustPanelPosition}
|
||||||
top: imageAdjustPanelPosition.top,
|
scale={IMAGE_EDIT_PANEL_SCALE}
|
||||||
left: imageAdjustPanelPosition.left,
|
tab={imageAdjustTab}
|
||||||
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
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSelected &&
|
{isSelected &&
|
||||||
flipRotatePanelOpen &&
|
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