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
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>
|
|
);
|
|
}
|
|
|