You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

873 lines
33 KiB

"use client";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react";
import type { LightingDirection, LightingPosition, LightingSettings, LightingViewMode } from "@/types";
import { DEFAULT_LIGHTING_SETTINGS, LIGHTING_PRESETS, normalizeLightingSettings } from "@/utils/lighting";
interface LightingEffectModalProps {
isOpen: boolean;
value?: LightingSettings | null;
sourceImage?: string | null;
onClose: () => void;
onApply: (settings: LightingSettings) => void;
}
const DIRECTIONS: Array<{ id: LightingDirection; label: string }> = [
{ id: "left", label: "左侧" },
{ id: "top", label: "顶部" },
{ id: "right", label: "右侧" },
{ id: "front", label: "前方" },
{ id: "bottom", label: "底部" },
{ id: "back", label: "后方" },
];
const VIEW_MODES: Array<{ id: LightingViewMode; label: string }> = [
{ id: "perspective", label: "透视" },
{ id: "front", label: "正面" },
];
function readImageFile(file: File): Promise<string | null> {
if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (event) => resolve((event.target?.result as string | undefined) ?? null);
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function formatCssNumber(value: number): string {
const rounded = Number(value.toFixed(2));
return Object.is(rounded, -0) ? "0" : `${rounded}`;
}
function normalizeHex(value: string): string | null {
const normalized = value.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return null;
return `#${normalized.toLowerCase()}`;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const normalized = normalizeHex(hex) ?? "#ffffff";
return {
r: Number.parseInt(normalized.slice(1, 3), 16),
g: Number.parseInt(normalized.slice(3, 5), 16),
b: Number.parseInt(normalized.slice(5, 7), 16),
};
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((channel) => clamp(Math.round(channel), 0, 255).toString(16).padStart(2, "0")).join("")}`;
}
function hexToHsv(hex: string): { h: number; s: number; v: number } {
const { r, g, b } = hexToRgb(hex);
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === rn) h = ((gn - bn) / delta) % 6;
else if (max === gn) h = (bn - rn) / delta + 2;
else h = (rn - gn) / delta + 4;
h *= 60;
if (h < 0) h += 360;
}
return {
h,
s: max === 0 ? 0 : delta / max,
v: max,
};
}
function hsvToHex(h: number, s: number, v: number): string {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return rgbToHex((r + m) * 255, (g + m) * 255, (b + m) * 255);
}
function directionToPosition(direction: LightingDirection): LightingPosition {
switch (direction) {
case "left":
return { x: -1, y: 0, z: 0 };
case "top":
return { x: 0, y: -1, z: 0 };
case "right":
return { x: 1, y: 0, z: 0 };
case "bottom":
return { x: 0, y: 1, z: 0 };
case "back":
return { x: 0, y: 0, z: -1 };
case "front":
default:
return { x: 0, y: 0, z: 1 };
}
}
function positionToDirection(position: LightingPosition, fallback: LightingDirection = "front"): LightingDirection {
if (position.z <= -0.95 && Math.abs(position.x) < 0.1 && Math.abs(position.y) < 0.1) return "back";
if (position.z >= 0.95 && Math.abs(position.x) < 0.1 && Math.abs(position.y) < 0.1) return "front";
if (position.y <= -0.95 && Math.abs(position.x) < 0.1) return "top";
if (position.y >= 0.95 && Math.abs(position.x) < 0.1) return "bottom";
if (position.x <= -0.95 && Math.abs(position.y) < 0.1) return "left";
if (position.x >= 0.95 && Math.abs(position.y) < 0.1) return "right";
return fallback;
}
function isCardinalPosition(position: LightingPosition, direction: LightingDirection): boolean {
const target = directionToPosition(direction);
return (
Math.abs(position.x - target.x) < 0.05 &&
Math.abs(position.y - target.y) < 0.05 &&
Math.abs(position.z - target.z) < 0.05
);
}
function normalizePosition(position: LightingPosition): LightingPosition {
const length = Math.hypot(position.x, position.y, position.z);
if (length === 0) return directionToPosition("front");
return { x: position.x / length, y: position.y / length, z: position.z / length };
}
const LIGHT_STOPS: Array<{ id: string; position: LightingPosition }> = [
{ id: "top", position: { x: 0, y: -1, z: 0 } },
{ id: "bottom", position: { x: 0, y: 1, z: 0 } },
{ id: "left", position: { x: -1, y: 0, z: 0 } },
{ id: "right", position: { x: 1, y: 0, z: 0 } },
{ id: "front", position: { x: 0, y: 0, z: 1 } },
{ id: "back", position: { x: 0, y: 0, z: -1 } },
{ id: "left-top-front", position: { x: -0.58, y: -0.58, z: 0.58 } },
{ id: "left-bottom-front", position: { x: -0.58, y: 0.58, z: 0.58 } },
{ id: "left-top-back", position: { x: -0.58, y: -0.58, z: -0.58 } },
{ id: "left-bottom-back", position: { x: -0.58, y: 0.58, z: -0.58 } },
{ id: "right-top-front", position: { x: 0.58, y: -0.58, z: 0.58 } },
{ id: "right-bottom-front", position: { x: 0.58, y: 0.58, z: 0.58 } },
{ id: "right-top-back", position: { x: 0.58, y: -0.58, z: -0.58 } },
{ id: "right-bottom-back", position: { x: 0.58, y: 0.58, z: -0.58 } },
];
function projectLightPosition(position: LightingPosition, viewMode: LightingViewMode): { left: number; top: number } {
if (viewMode === "perspective") {
return {
left: clamp(50 + position.x * 34 - position.z * 43, 7, 93),
top: clamp(50 + position.y * 38 + position.z * 16, 7, 93),
};
}
if (Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2) {
return { left: 50, top: 50 };
}
const distance = Math.hypot(position.x, position.y) || 1;
const x = position.x / distance;
const y = position.y / distance;
return {
left: 50 + x * 49,
top: 50 + y * 49,
};
}
interface PreviewPoint {
left: number;
top: number;
}
function getSubjectProjection(viewMode: LightingViewMode) {
if (viewMode === "front") {
return {
left: 50,
top: 50,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
scale: 1,
width: 42,
};
}
return {
left: 50,
top: 50,
rotateX: -8,
rotateY: -58,
rotateZ: 0,
scale: 0.98,
width: 45,
};
}
function getBeamGeometry(source: PreviewPoint, target: PreviewPoint, viewMode: LightingViewMode) {
const deltaX = target.left - source.left;
const deltaY = target.top - source.top;
const distance = Math.hypot(deltaX, deltaY);
return {
angle: Math.atan2(deltaY, deltaX) * (180 / Math.PI),
length: Math.max(viewMode === "perspective" ? 60 : 54, distance * (viewMode === "perspective" ? 1.8 : 2.2)),
};
}
function getSubjectHighlight(position: LightingPosition): PreviewPoint {
return {
left: clamp(50 - position.x * 28, 18, 82),
top: clamp(50 - position.y * 28, 18, 82),
};
}
interface SpherePointer {
rect: DOMRect;
clientX: number;
clientY: number;
}
function readSpherePointer(event: ReactPointerEvent<HTMLDivElement>): SpherePointer {
return {
rect: event.currentTarget.getBoundingClientRect(),
clientX: event.clientX,
clientY: event.clientY,
};
}
function positionFromPointer(pointer: SpherePointer, currentPosition: LightingPosition): LightingPosition {
const { rect, clientX, clientY } = pointer;
const radius = Math.min(rect.width, rect.height) / 2;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let x = (clientX - centerX) / radius;
let y = (clientY - centerY) / radius;
const distance = Math.hypot(x, y);
if (distance < 0.18) {
return currentPosition.z < -0.25 ? directionToPosition("back") : directionToPosition("front");
}
if (distance > 1) {
x /= distance;
y /= distance;
}
const clampedDistance = Math.min(1, Math.hypot(x, y));
const zMagnitude = Math.sqrt(Math.max(0, 1 - clampedDistance * clampedDistance));
const zSign = currentPosition.z < -0.25 ? -1 : 1;
return normalizePosition({ x, y, z: zMagnitude * zSign });
}
function nearestLightStop(position: LightingPosition): LightingPosition {
if (Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2) {
return position.z < 0 ? directionToPosition("back") : directionToPosition("front");
}
const zSign = position.z < -0.2 ? -1 : 1;
const candidates = LIGHT_STOPS.filter((stop) => {
if (stop.id === "front" || stop.id === "back") return false;
if (stop.position.z > 0.1) return zSign > 0;
if (stop.position.z < -0.1) return zSign < 0;
return true;
});
const projected = projectLightPosition(position, "front");
return candidates.reduce((best, stop) => {
const stopProjected = projectLightPosition(stop.position, "front");
const distance = Math.hypot(projected.left - stopProjected.left, projected.top - stopProjected.top);
return distance < best.distance ? { distance, position: stop.position } : best;
}, { distance: Number.POSITIVE_INFINITY, position: candidates[0].position }).position;
}
function LightingPreview({
settings,
sourceImage,
onLightPositionChange,
}: {
settings: LightingSettings;
sourceImage?: string | null;
onLightPositionChange: (position: LightingPosition) => void;
}) {
const [draftPosition, setDraftPosition] = useState(settings.lightPosition);
const [isDraggingLight, setIsDraggingLight] = useState(false);
const position = isDraggingLight ? draftPosition : settings.lightPosition;
const projected = projectLightPosition(position, settings.viewMode);
const subject = getSubjectProjection(settings.viewMode);
const subjectHighlight = getSubjectHighlight(position);
const beam = getBeamGeometry(projected, subject, settings.viewMode);
const opacity = Math.max(0.2, Math.min(0.9, settings.brightness / 100));
const beamSpread = Math.abs(settings.beamAngle) === 90 ? 30 : 22;
const isPureFrontLight =
settings.viewMode === "front" && position.z > 0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2;
const isPureBackLight = position.z < -0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2;
const isBackLight = position.z < -0.2;
const subjectTransform = [
"perspective(420px)",
"translate(-50%, -50%)",
`rotateX(${formatCssNumber(subject.rotateX)}deg)`,
`rotateY(${formatCssNumber(subject.rotateY)}deg)`,
`rotateZ(${formatCssNumber(subject.rotateZ)}deg)`,
`scale(${formatCssNumber(subject.scale)})`,
].join(" ");
useEffect(() => {
if (!isDraggingLight) {
setDraftPosition(settings.lightPosition);
}
}, [isDraggingLight, settings.lightPosition]);
const updateDraftPosition = (event: ReactPointerEvent<HTMLDivElement>) => {
const pointer = readSpherePointer(event);
setDraftPosition((current) => positionFromPointer(pointer, current));
};
const commitDraftPosition = (event: ReactPointerEvent<HTMLDivElement>) => {
const releasedPosition = positionFromPointer(readSpherePointer(event), draftPosition);
const snappedPosition = nearestLightStop(releasedPosition);
setDraftPosition(snappedPosition);
setIsDraggingLight(false);
onLightPositionChange(snappedPosition);
};
return (
<div className="relative h-56 overflow-hidden rounded-lg bg-neutral-900">
<div
className="absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 touch-none overflow-hidden rounded-full border border-white/10 bg-[radial-gradient(circle_at_35%_28%,rgba(255,255,255,0.2),rgba(255,255,255,0.08)_34%,rgba(255,255,255,0.03)_72%)] shadow-inner"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
const pointer = readSpherePointer(event);
setIsDraggingLight(true);
setDraftPosition(positionFromPointer(pointer, position));
}}
onPointerMove={(event) => {
if (event.buttons !== 1) return;
updateDraftPosition(event);
}}
onPointerUp={commitDraftPosition}
onPointerCancel={commitDraftPosition}
aria-label="拖拽光源控制点"
>
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.08)_1px,transparent_1px)] bg-[size:43px_43px] opacity-35" />
<div className="absolute left-0 top-1/2 h-px w-full bg-white/10" />
<div className="absolute left-1/2 top-0 h-full w-px bg-white/10" />
<div className="absolute left-[12%] top-[23%] h-[54%] w-[76%] rounded-[50%] border border-white/10" />
<div className="absolute left-[6%] top-[36%] h-[28%] w-[88%] rounded-[50%] border border-white/8" />
{isPureFrontLight && (
<div
className="absolute z-[5] h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full blur-sm"
style={{
left: `${projected.left}%`,
top: `${projected.top}%`,
background: `radial-gradient(circle, ${settings.color}dd 0%, ${settings.color}55 42%, transparent 72%)`,
opacity,
}}
/>
)}
{!isPureFrontLight && !isPureBackLight && (
<div
data-testid="lighting-preview-beam"
className="absolute z-[5] origin-left blur-[1px]"
style={{
left: `${projected.left}%`,
top: `${projected.top}%`,
width: `${formatCssNumber(beam.length)}%`,
height: `${beamSpread}%`,
background: `linear-gradient(90deg, ${settings.color}ee, ${settings.color}66 46%, transparent 88%)`,
clipPath: "polygon(0 50%, 100% 8%, 100% 92%)",
opacity: isBackLight ? opacity * 0.55 : opacity,
transform: `translateY(-50%) rotate(${formatCssNumber(beam.angle)}deg)`,
}}
/>
)}
<div
data-testid="lighting-preview-subject"
className={`absolute z-20 aspect-square overflow-hidden rounded-sm border border-white/20 bg-neutral-700 shadow-2xl ${
settings.rimLight ? "ring-2 ring-cyan-200/80" : ""
}`}
style={{
left: `${formatCssNumber(subject.left)}%`,
top: `${formatCssNumber(subject.top)}%`,
width: `${subject.width}%`,
transform: subjectTransform,
transformStyle: "preserve-3d",
}}
>
{sourceImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={sourceImage} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full bg-gradient-to-b from-neutral-500 to-neutral-800" />
)}
<div
className="pointer-events-none absolute inset-0 mix-blend-screen"
style={{
background: `radial-gradient(circle at ${subjectHighlight.left}% ${subjectHighlight.top}%, ${settings.color}aa 0%, ${settings.color}55 34%, transparent 72%)`,
opacity: isBackLight ? opacity * 0.18 : opacity * 0.55,
}}
/>
</div>
{!isPureBackLight && (
<div
className="absolute z-30 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black shadow-[0_0_0_1px_rgba(255,255,255,0.35),0_0_16px_rgba(255,255,255,0.9)]"
style={{
left: `${projected.left}%`,
top: `${projected.top}%`,
}}
/>
)}
</div>
</div>
);
}
function LightingColorPicker({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const hsv = useMemo(() => hexToHsv(value), [value]);
const [hue, setHue] = useState(hsv.h);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (hsv.s > 0) {
setHue(hsv.h);
}
}, [hsv.h, hsv.s]);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && pickerRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
const updateSaturationValue = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const s = clamp((event.clientX - rect.left) / rect.width, 0, 1);
const v = clamp(1 - (event.clientY - rect.top) / rect.height, 0, 1);
onChange(hsvToHex(hue, s, v));
};
const updateHue = (nextHue: number) => {
const normalizedHue = clamp(nextHue, 0, 360);
setHue(normalizedHue);
onChange(hsvToHex(normalizedHue, hsv.s, hsv.v));
};
const updateHueFromPointer = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const ratio = rect.width > 0 ? clamp((event.clientX - rect.left) / rect.width, 0, 1) : 0;
updateHue(ratio * 360);
};
const handleHexChange = (nextValue: string) => {
const normalized = normalizeHex(nextValue);
if (normalized) onChange(normalized);
};
return (
<div ref={pickerRef} className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="h-8 w-12 rounded border border-neutral-700 bg-neutral-900 p-1 transition-colors hover:border-neutral-500"
aria-label="打光颜色"
>
<span className="block h-full w-full rounded-sm" style={{ backgroundColor: value }} />
</button>
{open && (
<div
data-testid="lighting-color-picker-popover"
className="absolute left-0 top-10 z-[70] w-44 rounded-lg border border-neutral-600 bg-neutral-900 p-2 shadow-2xl shadow-black/60"
>
<div
className="relative h-28 cursor-crosshair overflow-hidden rounded-md"
style={{
backgroundColor: hsvToHex(hue, 1, 1),
backgroundImage:
"linear-gradient(90deg, #fff, rgba(255,255,255,0)), linear-gradient(0deg, #000, rgba(0,0,0,0))",
}}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture?.(event.pointerId);
updateSaturationValue(event);
}}
onPointerMove={(event) => {
if (event.buttons !== 1) return;
updateSaturationValue(event);
}}
>
<span
className="absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.7)]"
style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }}
/>
</div>
<div
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hue)}
aria-label="打光颜色色相"
className="relative mt-2 h-3 w-full cursor-pointer rounded-full bg-[linear-gradient(90deg,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)] outline-none focus:ring-2 focus:ring-white/30"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture?.(event.pointerId);
updateHueFromPointer(event);
}}
onPointerMove={(event) => {
if (event.buttons !== 1) return;
updateHueFromPointer(event);
}}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
updateHue(hue - 5);
}
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
updateHue(hue + 5);
}
}}
>
<span
data-testid="lighting-hue-thumb"
className="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
style={{ left: `${(hue / 360) * 100}%` }}
/>
</div>
<input
type="text"
value={value}
onChange={(event) => handleHexChange(event.target.value)}
className="mt-2 h-8 w-full rounded-md border border-neutral-600 bg-neutral-800 px-2 text-xs text-neutral-100 outline-none focus:border-neutral-400"
aria-label="打光颜色十六进制"
/>
</div>
)}
</div>
);
}
export function LightingEffectModal({
isOpen,
value,
sourceImage,
onClose,
onApply,
}: LightingEffectModalProps) {
const [settings, setSettings] = useState<LightingSettings>(() => normalizeLightingSettings(value));
const [modalPosition, setModalPosition] = useState<{ x: number; y: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const referenceInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (isOpen) {
setSettings(normalizeLightingSettings(value));
setModalPosition(null);
setIsDragging(false);
}
}, [isOpen, value]);
const modalWidthClass = settings.smartMode ? "max-w-[760px]" : "max-w-[458px]";
const appliedPreviewImage = settings.referenceImage || sourceImage;
const selectedPreset = useMemo(
() => LIGHTING_PRESETS.find((preset) => preset.id === settings.selectedPresetId),
[settings.selectedPresetId]
);
if (!isOpen) return null;
const patchSettings = (patch: Partial<LightingSettings>) => {
setSettings((current) => ({ ...current, ...patch, enabled: true }));
};
const handleReferenceChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const image = await readImageFile(file);
if (image) patchSettings({ referenceImage: image });
};
const handleDragStart = (event: ReactPointerEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest("button")) return;
const rect = modalRef.current?.getBoundingClientRect();
if (!rect) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragOffsetRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setModalPosition({ x: rect.left, y: rect.top });
setIsDragging(true);
};
const handleDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging) return;
const rect = modalRef.current?.getBoundingClientRect();
const width = rect?.width ?? 460;
const height = rect?.height ?? 420;
setModalPosition({
x: clamp(event.clientX - dragOffsetRef.current.x, 8, window.innerWidth - width - 8),
y: clamp(event.clientY - dragOffsetRef.current.y, 8, window.innerHeight - height - 8),
});
};
const handleDragEnd = () => {
setIsDragging(false);
};
return (
<div className="fixed inset-0 z-50 bg-black/20" role="dialog" aria-modal="true">
<div
ref={modalRef}
className={`fixed w-[calc(100vw-2rem)] ${modalWidthClass} overflow-visible rounded-xl border border-neutral-700 bg-neutral-900 shadow-2xl shadow-black/50`}
style={{
left: modalPosition ? modalPosition.x : "50%",
top: modalPosition ? modalPosition.y : "50%",
transform: modalPosition ? undefined : "translate(-50%, -50%)",
}}
>
<div
className={`flex h-14 cursor-move touch-none items-center justify-between rounded-t-xl border-b border-neutral-800 px-4 ${
isDragging ? "select-none" : ""
}`}
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<h2 className="text-sm font-semibold text-neutral-100"></h2>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-100"
aria-label="关闭打光效果"
>
×
</button>
</div>
<div className={`grid gap-4 p-4 ${settings.smartMode ? "md:grid-cols-[200px_190px_1fr]" : "md:grid-cols-[200px_190px]"}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 rounded-xl border border-neutral-700 bg-neutral-900 p-1">
{VIEW_MODES.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => patchSettings({ viewMode: mode.id })}
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
settings.viewMode === mode.id ? "bg-neutral-700 text-white" : "text-neutral-500 hover:text-neutral-200"
}`}
>
{mode.label}
</button>
))}
</div>
<LightingPreview
settings={settings}
sourceImage={appliedPreviewImage}
onLightPositionChange={(lightPosition) =>
patchSettings({
lightPosition,
mainLightDirection: positionToDirection(lightPosition, settings.mainLightDirection),
})
}
/>
<button
type="button"
onClick={() => setSettings(DEFAULT_LIGHTING_SETTINGS)}
className="text-xs text-neutral-400 transition-colors hover:text-neutral-100"
>
</button>
</div>
<div className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="font-semibold text-neutral-100"></span>
<label className="flex items-center gap-2 text-xs text-neutral-400">
<input
type="checkbox"
checked={settings.smartMode}
onChange={(event) => patchSettings({ smartMode: event.target.checked })}
className="h-4 w-4 accent-neutral-200"
/>
</label>
</div>
<label className="block space-y-2">
<span className="text-neutral-400"></span>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={100}
value={settings.brightness}
onChange={(event) => patchSettings({ brightness: Number(event.target.value) })}
className="w-full accent-neutral-200"
aria-label="打光亮度"
/>
<span className="w-12 rounded-md border border-neutral-700 px-2 py-1 text-center text-xs text-neutral-400">
{settings.brightness}%
</span>
</div>
</label>
<div className="flex items-center justify-between gap-3">
<span className="text-neutral-400"></span>
<LightingColorPicker
value={settings.color}
onChange={(color) => patchSettings({ color })}
/>
</div>
<div className="space-y-2">
<span className="text-neutral-400"></span>
<div className="grid grid-cols-3 gap-2">
{DIRECTIONS.map((direction) => (
<button
key={direction.id}
type="button"
onClick={() =>
patchSettings({
mainLightDirection: direction.id,
lightPosition: directionToPosition(direction.id),
})
}
className={`rounded-lg border px-3 py-2 text-xs transition-colors ${
isCardinalPosition(settings.lightPosition, direction.id)
? "border-neutral-400 bg-neutral-600 text-white"
: "border-neutral-700 bg-neutral-900 text-neutral-400 hover:border-neutral-500 hover:text-neutral-100"
}`}
>
{direction.label}
</button>
))}
</div>
</div>
<label className="flex items-center justify-between gap-3">
<span className="text-neutral-400"></span>
<input
type="checkbox"
checked={settings.rimLight}
onChange={(event) => patchSettings({ rimLight: event.target.checked })}
className="h-4 w-4 accent-neutral-200"
aria-label="轮廓光"
/>
</label>
</div>
{settings.smartMode && (
<div className="space-y-4 border-t border-neutral-800 pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div className="text-sm font-semibold text-neutral-100"></div>
<div className="grid grid-cols-[1fr_64px] gap-3">
<textarea
value={settings.smartPrompt}
onChange={(event) => patchSettings({ smartPrompt: event.target.value })}
placeholder="简单描述你想实现的打光效果,或者情绪风格"
className="h-20 resize-none rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 text-xs leading-5 text-neutral-100 outline-none placeholder:text-neutral-500 focus:border-neutral-500"
/>
<button
type="button"
onClick={() => referenceInputRef.current?.click()}
className="rounded-lg border border-dashed border-neutral-700 bg-neutral-900 px-2 text-xs text-neutral-400 transition-colors hover:border-neutral-500 hover:text-neutral-100"
>
+<br />
</button>
</div>
<input
ref={referenceInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleReferenceChange}
aria-label="上传打光参考图"
/>
<div className="space-y-2">
<div className="text-xs text-neutral-400"></div>
<div className="grid grid-cols-4 gap-2">
{LIGHTING_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() =>
patchSettings({
...preset.settings,
selectedPresetId: preset.id,
})
}
className={`relative h-16 overflow-hidden rounded-lg border text-xs font-semibold text-white shadow-inner transition-transform hover:scale-[1.02] ${
selectedPreset?.id === preset.id ? "border-white" : "border-neutral-700"
}`}
style={{ background: preset.thumbnail }}
>
<span className="absolute inset-x-0 bottom-0 bg-black/45 px-1 py-1">{preset.label}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-neutral-800 px-4 py-3">
<span className="text-xs text-neutral-500"> 14</span>
<button
type="button"
onClick={() => onApply({ ...settings, enabled: true })}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 transition-colors hover:bg-white"
aria-label="应用打光效果"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
</button>
</div>
</div>
</div>
);
}