|
|
@ -2,7 +2,8 @@ |
|
|
|
|
|
|
|
|
import { |
|
|
import { |
|
|
useCallback, |
|
|
useCallback, |
|
|
type MouseEvent, |
|
|
useRef, |
|
|
|
|
|
useState, |
|
|
type PointerEvent, |
|
|
type PointerEvent, |
|
|
type ReactNode, |
|
|
type ReactNode, |
|
|
} from "react"; |
|
|
} from "react"; |
|
|
@ -41,6 +42,7 @@ export const MULTI_ANGLE_ZOOM_MAX = 10; |
|
|
export const FISHEYE_MULTI_ANGLE_PROMPT = |
|
|
export const FISHEYE_MULTI_ANGLE_PROMPT = |
|
|
"Ultra-close fisheye lens, strong ultra-wide perspective, circular frame edge, dark vignette, visible barrel distortion, enlarged subject center, stretched curved edges"; |
|
|
"Ultra-close fisheye lens, strong ultra-wide perspective, circular frame edge, dark vignette, visible barrel distortion, enlarged subject center, stretched curved edges"; |
|
|
export const TILT_MULTI_ANGLE_PROMPT = "Dutch angle, tilted frame"; |
|
|
export const TILT_MULTI_ANGLE_PROMPT = "Dutch angle, tilted frame"; |
|
|
|
|
|
const CUBE_DRAG_DEGREES_PER_PIXEL = 0.8; |
|
|
|
|
|
|
|
|
const MULTI_ANGLE_PRESET_LABEL_KEYS: Record<MultiAnglePresetId, TranslationKey> = { |
|
|
const MULTI_ANGLE_PRESET_LABEL_KEYS: Record<MultiAnglePresetId, TranslationKey> = { |
|
|
custom: "multiAngle.preset.custom", |
|
|
custom: "multiAngle.preset.custom", |
|
|
@ -124,6 +126,10 @@ function roundToStep(value: number, precision = 1): number { |
|
|
return Math.round(value * factor) / factor; |
|
|
return Math.round(value * factor) / factor; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function normalizeHorizontalDragAngle(value: number): number { |
|
|
|
|
|
return Math.round(((value % 360) + 360) % 360); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
function getNextMultiAngleVertical(current: number, direction: -1 | 1): number { |
|
|
function getNextMultiAngleVertical(current: number, direction: -1 | 1): number { |
|
|
return Math.round(clamp(current + direction, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX)); |
|
|
return Math.round(clamp(current + direction, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX)); |
|
|
} |
|
|
} |
|
|
@ -178,6 +184,14 @@ export function MultiAngleEditor({ |
|
|
actions?: ReactNode; |
|
|
actions?: ReactNode; |
|
|
}) { |
|
|
}) { |
|
|
const { t } = useI18n(); |
|
|
const { t } = useI18n(); |
|
|
|
|
|
const [isCubeDragging, setIsCubeDragging] = useState(false); |
|
|
|
|
|
const cubeDragRef = useRef<{ |
|
|
|
|
|
pointerId: number; |
|
|
|
|
|
startX: number; |
|
|
|
|
|
startY: number; |
|
|
|
|
|
startHorizontal: number; |
|
|
|
|
|
startVertical: number; |
|
|
|
|
|
} | null>(null); |
|
|
const previewScale = 0.58 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.64; |
|
|
const previewScale = 0.58 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.64; |
|
|
const cubeRotateX = -settings.vertical; |
|
|
const cubeRotateX = -settings.vertical; |
|
|
const cubeRotateY = settings.horizontal; |
|
|
const cubeRotateY = settings.horizontal; |
|
|
@ -198,72 +212,62 @@ export function MultiAngleEditor({ |
|
|
onChange({ zoom: roundToStep(clamp(value, MULTI_ANGLE_ZOOM_MIN, MULTI_ANGLE_ZOOM_MAX), 1) }); |
|
|
onChange({ zoom: roundToStep(clamp(value, MULTI_ANGLE_ZOOM_MIN, MULTI_ANGLE_ZOOM_MAX), 1) }); |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
const updateFromGlobePoint = useCallback( |
|
|
|
|
|
(target: HTMLDivElement, clientX: number, clientY: number) => { |
|
|
|
|
|
const rect = target.getBoundingClientRect(); |
|
|
|
|
|
if (rect.width <= 0 || rect.height <= 0) return; |
|
|
|
|
|
|
|
|
|
|
|
const pointerY = clamp(1 - (clientY - rect.top) / rect.height, 0, 1); |
|
|
|
|
|
const centerX = rect.left + rect.width / 2; |
|
|
|
|
|
const centerY = rect.top + rect.height / 2; |
|
|
|
|
|
const rawAngle = (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI; |
|
|
|
|
|
const normalizedAngle = (90 - rawAngle + 360) % 360; |
|
|
|
|
|
const horizontal = Math.round(clamp(normalizedAngle, MULTI_ANGLE_HORIZONTAL_MIN, MULTI_ANGLE_HORIZONTAL_MAX)); |
|
|
|
|
|
|
|
|
|
|
|
onChange({ |
|
|
|
|
|
horizontal, |
|
|
|
|
|
vertical: Math.round(MULTI_ANGLE_VERTICAL_MIN + pointerY * (MULTI_ANGLE_VERTICAL_MAX - MULTI_ANGLE_VERTICAL_MIN)), |
|
|
|
|
|
}); |
|
|
|
|
|
}, |
|
|
|
|
|
[onChange] |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const handleGlobePointerDown = useCallback( |
|
|
const handleGlobePointerDown = useCallback( |
|
|
(event: PointerEvent<HTMLDivElement>) => { |
|
|
(event: PointerEvent<HTMLDivElement>) => { |
|
|
event.preventDefault(); |
|
|
event.preventDefault(); |
|
|
event.stopPropagation(); |
|
|
event.stopPropagation(); |
|
|
event.currentTarget.setPointerCapture?.(event.pointerId); |
|
|
event.currentTarget.setPointerCapture?.(event.pointerId); |
|
|
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); |
|
|
cubeDragRef.current = { |
|
|
|
|
|
pointerId: event.pointerId, |
|
|
|
|
|
startX: event.clientX, |
|
|
|
|
|
startY: event.clientY, |
|
|
|
|
|
startHorizontal: settings.horizontal, |
|
|
|
|
|
startVertical: settings.vertical, |
|
|
|
|
|
}; |
|
|
|
|
|
setIsCubeDragging(true); |
|
|
}, |
|
|
}, |
|
|
[updateFromGlobePoint] |
|
|
[settings.horizontal, settings.vertical] |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const handleGlobePointerMove = useCallback( |
|
|
const handleGlobePointerMove = useCallback( |
|
|
(event: PointerEvent<HTMLDivElement>) => { |
|
|
(event: PointerEvent<HTMLDivElement>) => { |
|
|
if ((event.buttons & 1) !== 1) return; |
|
|
const dragState = cubeDragRef.current; |
|
|
|
|
|
if (!dragState || dragState.pointerId !== event.pointerId) return; |
|
|
|
|
|
if (event.pointerType === "mouse" && (event.buttons & 1) !== 1) return; |
|
|
event.preventDefault(); |
|
|
event.preventDefault(); |
|
|
event.stopPropagation(); |
|
|
event.stopPropagation(); |
|
|
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); |
|
|
const deltaX = event.clientX - dragState.startX; |
|
|
|
|
|
const deltaY = event.clientY - dragState.startY; |
|
|
|
|
|
|
|
|
|
|
|
onChange({ |
|
|
|
|
|
horizontal: normalizeHorizontalDragAngle(dragState.startHorizontal + deltaX * CUBE_DRAG_DEGREES_PER_PIXEL), |
|
|
|
|
|
vertical: Math.round( |
|
|
|
|
|
clamp( |
|
|
|
|
|
dragState.startVertical - deltaY * CUBE_DRAG_DEGREES_PER_PIXEL, |
|
|
|
|
|
MULTI_ANGLE_VERTICAL_MIN, |
|
|
|
|
|
MULTI_ANGLE_VERTICAL_MAX |
|
|
|
|
|
) |
|
|
|
|
|
), |
|
|
|
|
|
}); |
|
|
}, |
|
|
}, |
|
|
[updateFromGlobePoint] |
|
|
[onChange] |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const handleGlobePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => { |
|
|
const handleGlobePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => { |
|
|
event.stopPropagation(); |
|
|
event.stopPropagation(); |
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId); |
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId); |
|
|
|
|
|
if (cubeDragRef.current?.pointerId === event.pointerId) { |
|
|
|
|
|
cubeDragRef.current = null; |
|
|
|
|
|
setIsCubeDragging(false); |
|
|
|
|
|
} |
|
|
}, []); |
|
|
}, []); |
|
|
|
|
|
|
|
|
const handleGlobeMouseDown = useCallback( |
|
|
const handleGlobePointerCancel = useCallback((event: PointerEvent<HTMLDivElement>) => { |
|
|
(event: MouseEvent<HTMLDivElement>) => { |
|
|
|
|
|
event.preventDefault(); |
|
|
|
|
|
event.stopPropagation(); |
|
|
|
|
|
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); |
|
|
|
|
|
}, |
|
|
|
|
|
[updateFromGlobePoint] |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const handleGlobeMouseMove = useCallback( |
|
|
|
|
|
(event: MouseEvent<HTMLDivElement>) => { |
|
|
|
|
|
if ((event.buttons & 1) !== 1) return; |
|
|
|
|
|
event.preventDefault(); |
|
|
|
|
|
event.stopPropagation(); |
|
|
|
|
|
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); |
|
|
|
|
|
}, |
|
|
|
|
|
[updateFromGlobePoint] |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const handleGlobeMouseUp = useCallback((event: MouseEvent<HTMLDivElement>) => { |
|
|
|
|
|
event.stopPropagation(); |
|
|
event.stopPropagation(); |
|
|
|
|
|
if (cubeDragRef.current?.pointerId === event.pointerId) { |
|
|
|
|
|
cubeDragRef.current = null; |
|
|
|
|
|
setIsCubeDragging(false); |
|
|
|
|
|
} |
|
|
}, []); |
|
|
}, []); |
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
@ -303,9 +307,8 @@ export function MultiAngleEditor({ |
|
|
onPointerDown={handleGlobePointerDown} |
|
|
onPointerDown={handleGlobePointerDown} |
|
|
onPointerMove={handleGlobePointerMove} |
|
|
onPointerMove={handleGlobePointerMove} |
|
|
onPointerUp={handleGlobePointerUp} |
|
|
onPointerUp={handleGlobePointerUp} |
|
|
onMouseDown={handleGlobeMouseDown} |
|
|
onPointerCancel={handleGlobePointerCancel} |
|
|
onMouseMove={handleGlobeMouseMove} |
|
|
onLostPointerCapture={handleGlobePointerCancel} |
|
|
onMouseUp={handleGlobeMouseUp} |
|
|
|
|
|
> |
|
|
> |
|
|
<button |
|
|
<button |
|
|
type="button" |
|
|
type="button" |
|
|
@ -350,12 +353,14 @@ export function MultiAngleEditor({ |
|
|
|
|
|
|
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center px-8 py-10 [perspective:720px]"> |
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center px-8 py-10 [perspective:720px]"> |
|
|
<div |
|
|
<div |
|
|
className="relative h-[104px] w-[104px] transition-transform duration-150 [transform-style:preserve-3d]" |
|
|
className={`relative h-[120px] w-[120px] [transform-style:preserve-3d] ${ |
|
|
|
|
|
isCubeDragging ? "" : "transition-transform duration-150" |
|
|
|
|
|
}`}
|
|
|
style={{ |
|
|
style={{ |
|
|
transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`, |
|
|
transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`, |
|
|
}} |
|
|
}} |
|
|
> |
|
|
> |
|
|
<div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[0_18px_42px_rgba(0,0,0,0.42)] [transform:translateZ(52px)]`}> |
|
|
<div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[0_18px_42px_rgba(0,0,0,0.42)] [transform:translateZ(60px)]`}> |
|
|
{previewImage ? ( |
|
|
{previewImage ? ( |
|
|
<img |
|
|
<img |
|
|
src={previewImage} |
|
|
src={previewImage} |
|
|
@ -368,19 +373,19 @@ export function MultiAngleEditor({ |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/34 [transform:rotateY(90deg)_translateZ(52px)]`}> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/34 [transform:rotateY(90deg)_translateZ(60px)]`}> |
|
|
右 |
|
|
右 |
|
|
</div> |
|
|
</div> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/32 [transform:rotateY(-90deg)_translateZ(52px)]`}> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/32 [transform:rotateY(-90deg)_translateZ(60px)]`}> |
|
|
左 |
|
|
左 |
|
|
</div> |
|
|
</div> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/30 [transform:rotateX(90deg)_translateZ(52px)]`}> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/30 [transform:rotateX(90deg)_translateZ(60px)]`}> |
|
|
上 |
|
|
上 |
|
|
</div> |
|
|
</div> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/26 [transform:rotateX(-90deg)_translateZ(52px)]`}> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/26 [transform:rotateX(-90deg)_translateZ(60px)]`}> |
|
|
下 |
|
|
下 |
|
|
</div> |
|
|
</div> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-2)]/28 [transform:rotateY(180deg)_translateZ(52px)]`}> |
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-2)]/28 [transform:rotateY(180deg)_translateZ(60px)]`}> |
|
|
背 |
|
|
背 |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|