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.
493 lines
20 KiB
493 lines
20 KiB
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useRef,
|
|
useState,
|
|
type PointerEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { DownOutlined, LeftOutlined, RightOutlined, UpOutlined } from "@ant-design/icons";
|
|
import { useI18n, type TranslationKey } from "@/i18n";
|
|
|
|
export type MultiAnglePresetId = "custom" | "fisheye" | "tilt" | "frontOverhead" | "frontLow" | "panoramicOverhead" | "back";
|
|
|
|
export interface MultiAngleSettings {
|
|
preset: MultiAnglePresetId;
|
|
horizontal: number;
|
|
vertical: number;
|
|
zoom: number;
|
|
includePrompt: boolean;
|
|
promptText: string;
|
|
}
|
|
|
|
const MULTI_ANGLE_PROMPT_PATTERN = /\n*\[multi-angle\][\s\S]*?\[\/multi-angle\]/gi;
|
|
|
|
export const MULTI_ANGLE_PRESETS: Array<{ id: MultiAnglePresetId; label: string; enabled: boolean }> = [
|
|
{ id: "custom", label: "Custom", enabled: true },
|
|
{ id: "fisheye", label: "Fisheye", enabled: true },
|
|
{ id: "tilt", label: "Tilt", enabled: true },
|
|
{ id: "frontOverhead", label: "Front overhead", enabled: true },
|
|
{ id: "frontLow", label: "Front low angle", enabled: true },
|
|
{ id: "panoramicOverhead", label: "Panoramic overhead", enabled: true },
|
|
{ id: "back", label: "Back view", enabled: true },
|
|
];
|
|
|
|
export const MULTI_ANGLE_HORIZONTAL_MIN = 0;
|
|
export const MULTI_ANGLE_HORIZONTAL_MAX = 360;
|
|
export const MULTI_ANGLE_VERTICAL_MIN = -30;
|
|
export const MULTI_ANGLE_VERTICAL_MAX = 60;
|
|
export const MULTI_ANGLE_ZOOM_MIN = 0;
|
|
export const MULTI_ANGLE_ZOOM_MAX = 10;
|
|
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";
|
|
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> = {
|
|
custom: "multiAngle.preset.custom",
|
|
fisheye: "multiAngle.preset.fisheye",
|
|
tilt: "multiAngle.preset.tilt",
|
|
frontOverhead: "multiAngle.preset.frontOverhead",
|
|
frontLow: "multiAngle.preset.frontLow",
|
|
panoramicOverhead: "multiAngle.preset.panoramicOverhead",
|
|
back: "multiAngle.preset.back",
|
|
};
|
|
|
|
export const DEFAULT_MULTI_ANGLE_SETTINGS: MultiAngleSettings = {
|
|
preset: "custom",
|
|
horizontal: 0,
|
|
vertical: -30,
|
|
zoom: 5,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
};
|
|
|
|
export const FISHEYE_MULTI_ANGLE_SETTINGS: MultiAngleSettings = {
|
|
preset: "fisheye",
|
|
horizontal: 0,
|
|
vertical: 30,
|
|
zoom: 10,
|
|
includePrompt: true,
|
|
promptText: FISHEYE_MULTI_ANGLE_PROMPT,
|
|
};
|
|
|
|
export const MULTI_ANGLE_PRESET_SETTINGS: Record<MultiAnglePresetId, MultiAngleSettings> = {
|
|
custom: DEFAULT_MULTI_ANGLE_SETTINGS,
|
|
fisheye: FISHEYE_MULTI_ANGLE_SETTINGS,
|
|
tilt: {
|
|
preset: "tilt",
|
|
horizontal: 45,
|
|
vertical: -30,
|
|
zoom: 5,
|
|
includePrompt: true,
|
|
promptText: TILT_MULTI_ANGLE_PROMPT,
|
|
},
|
|
frontOverhead: {
|
|
preset: "frontOverhead",
|
|
horizontal: 0,
|
|
vertical: 60,
|
|
zoom: 5,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
frontLow: {
|
|
preset: "frontLow",
|
|
horizontal: 0,
|
|
vertical: -30,
|
|
zoom: 5,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
panoramicOverhead: {
|
|
preset: "panoramicOverhead",
|
|
horizontal: 45,
|
|
vertical: 30,
|
|
zoom: 0,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
back: {
|
|
preset: "back",
|
|
horizontal: 180,
|
|
vertical: 0,
|
|
zoom: 5,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
};
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function roundToStep(value: number, precision = 1): number {
|
|
const factor = 10 ** precision;
|
|
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 {
|
|
return Math.round(clamp(current + direction, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX));
|
|
}
|
|
|
|
function getHorizontalPrompt(horizontal: number): string {
|
|
if (horizontal === 0) return "horizontal orbit 0 degrees, camera faces the subject";
|
|
return `horizontal orbit ${horizontal} degrees counterclockwise around the subject, preserve the target viewpoint and avoid reversed viewpoint`;
|
|
}
|
|
|
|
function getVerticalPrompt(vertical: number): string {
|
|
if (vertical === 0) return "vertical pitch 0 degrees";
|
|
return vertical > 0
|
|
? `vertical pitch ${vertical} degrees, front overhead view, camera looks down at the subject`
|
|
: `vertical pitch ${vertical} degrees, front low angle view, camera looks up at the subject`;
|
|
}
|
|
|
|
export function buildMultiAnglePrompt(settings: MultiAngleSettings): string {
|
|
const customPrompt = settings.includePrompt ? settings.promptText.trim() : "";
|
|
const customPromptText = customPrompt ? `, prompt: ${customPrompt}` : "";
|
|
return `[multi-angle] Generate a normal 2D image from the input image while preserving the same subject and visual style. Use ${getHorizontalPrompt(settings.horizontal)}, ${getVerticalPrompt(settings.vertical)}, shot scale zoom ${settings.zoom}${customPromptText}. Do not create a 3D model, model sheet, mesh preview, or turntable render. Keep composition stable and natural. [/multi-angle]`;
|
|
}
|
|
|
|
export function stripMultiAnglePrompt(prompt: string): string {
|
|
return prompt.replace(MULTI_ANGLE_PROMPT_PATTERN, "").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
}
|
|
|
|
export function mergeMultiAnglePrompt(prompt: string, settings: MultiAngleSettings): string {
|
|
const basePrompt = stripMultiAnglePrompt(prompt);
|
|
const anglePrompt = buildMultiAnglePrompt(settings);
|
|
return basePrompt ? `${basePrompt}\n\n${anglePrompt}` : anglePrompt;
|
|
}
|
|
|
|
export function MultiAngleEditor({
|
|
previewImage,
|
|
settings,
|
|
onChange,
|
|
onSelectPreset,
|
|
onTogglePrompt,
|
|
onReset,
|
|
compact = false,
|
|
hidePromptControl = false,
|
|
actions,
|
|
}: {
|
|
previewImage: string | null | undefined;
|
|
settings: MultiAngleSettings;
|
|
onChange: (patch: Partial<MultiAngleSettings>) => void;
|
|
onSelectPreset: (preset: MultiAnglePresetId) => void;
|
|
onTogglePrompt: (enabled: boolean) => void;
|
|
onReset: () => void;
|
|
compact?: boolean;
|
|
hidePromptControl?: boolean;
|
|
actions?: ReactNode;
|
|
}) {
|
|
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 cubeRotateX = -settings.vertical;
|
|
const cubeRotateY = settings.horizontal;
|
|
const cubeFaceClass =
|
|
"absolute inset-0 flex items-center justify-center rounded-xl border border-[var(--border-strong)]/55 bg-[var(--surface-2)]/35 text-xs font-medium text-[var(--text-muted)] [backface-visibility:visible]";
|
|
|
|
const updateHorizontal = (value: number) => {
|
|
onChange({
|
|
horizontal: Math.round(clamp(value, MULTI_ANGLE_HORIZONTAL_MIN, MULTI_ANGLE_HORIZONTAL_MAX)),
|
|
});
|
|
};
|
|
|
|
const updateVertical = (value: number) => {
|
|
onChange({ vertical: Math.round(clamp(value, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX)) });
|
|
};
|
|
|
|
const updateZoom = (value: number) => {
|
|
onChange({ zoom: roundToStep(clamp(value, MULTI_ANGLE_ZOOM_MIN, MULTI_ANGLE_ZOOM_MAX), 1) });
|
|
};
|
|
|
|
const handleGlobePointerDown = useCallback(
|
|
(event: PointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
cubeDragRef.current = {
|
|
pointerId: event.pointerId,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
startHorizontal: settings.horizontal,
|
|
startVertical: settings.vertical,
|
|
};
|
|
setIsCubeDragging(true);
|
|
},
|
|
[settings.horizontal, settings.vertical]
|
|
);
|
|
|
|
const handleGlobePointerMove = useCallback(
|
|
(event: PointerEvent<HTMLDivElement>) => {
|
|
const dragState = cubeDragRef.current;
|
|
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
|
if (event.pointerType === "mouse" && (event.buttons & 1) !== 1) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
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
|
|
)
|
|
),
|
|
});
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleGlobePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
event.stopPropagation();
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
if (cubeDragRef.current?.pointerId === event.pointerId) {
|
|
cubeDragRef.current = null;
|
|
setIsCubeDragging(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleGlobePointerCancel = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
event.stopPropagation();
|
|
if (cubeDragRef.current?.pointerId === event.pointerId) {
|
|
cubeDragRef.current = null;
|
|
setIsCubeDragging(false);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`h-full rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] text-[var(--text-primary)] ${compact ? "p-2" : "p-3"}`}>
|
|
<div className={`${compact ? "mb-2" : "mb-3"} flex items-center justify-between gap-3`}>
|
|
<span className="text-sm font-medium text-[var(--text-primary)]">{t("multiAngle.editorTitle")}</span>
|
|
</div>
|
|
|
|
<div className={`nowheel flex gap-2 overflow-x-auto pb-1 ${compact ? "mb-2" : "mb-3"}`}>
|
|
{MULTI_ANGLE_PRESETS.map((preset) => (
|
|
<button
|
|
key={preset.id}
|
|
type="button"
|
|
disabled={!preset.enabled}
|
|
onClick={() => onSelectPreset(preset.id)}
|
|
className={`shrink-0 rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
|
settings.preset === preset.id
|
|
? "border-[var(--border-strong)] bg-[var(--surface-active)] text-[var(--text-primary)]"
|
|
: "border-[var(--border-subtle)] bg-[var(--surface-3)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
} disabled:cursor-not-allowed disabled:opacity-60`}
|
|
>
|
|
{t(MULTI_ANGLE_PRESET_LABEL_KEYS[preset.id])}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className={`grid ${compact ? "gap-3 md:grid-cols-[240px_1fr]" : "gap-4 md:grid-cols-[240px_1fr]"}`}>
|
|
<div
|
|
aria-label={t("multiAngle.sightControl")}
|
|
className={`relative touch-none overflow-hidden rounded-xl bg-[var(--surface-1)] cursor-grab active:cursor-grabbing ${compact ? "h-[250px]" : "h-[280px]"}`}
|
|
data-composer-local-pointer="true"
|
|
data-horizontal={settings.horizontal}
|
|
data-vertical={settings.vertical}
|
|
data-zoom={settings.zoom.toFixed(1)}
|
|
role="application"
|
|
tabIndex={0}
|
|
onPointerDown={handleGlobePointerDown}
|
|
onPointerMove={handleGlobePointerMove}
|
|
onPointerUp={handleGlobePointerUp}
|
|
onPointerCancel={handleGlobePointerCancel}
|
|
onLostPointerCapture={handleGlobePointerCancel}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={t("multiAngle.adjustVerticalUp")}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={() => updateVertical(getNextMultiAngleVertical(settings.vertical, 1))}
|
|
className="absolute left-1/2 top-3 z-40 flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<UpOutlined className="text-[12px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={t("multiAngle.adjustVerticalDown")}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={() => updateVertical(getNextMultiAngleVertical(settings.vertical, -1))}
|
|
className="absolute bottom-3 left-1/2 z-40 flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<DownOutlined className="text-[12px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={t("multiAngle.adjustHorizontalLeft")}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={() => updateHorizontal(settings.horizontal - 1)}
|
|
className="absolute left-3 top-1/2 z-40 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<LeftOutlined className="text-[12px]" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={t("multiAngle.adjustHorizontalRight")}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={() => updateHorizontal(settings.horizontal + 1)}
|
|
className="absolute right-3 top-1/2 z-40 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<RightOutlined className="text-[12px]" />
|
|
</button>
|
|
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center px-8 py-10 [perspective:720px]">
|
|
<div
|
|
className={`relative h-[120px] w-[120px] [transform-style:preserve-3d] ${
|
|
isCubeDragging ? "" : "transition-transform duration-150"
|
|
}`}
|
|
style={{
|
|
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(60px)]`}>
|
|
{previewImage ? (
|
|
<img
|
|
src={previewImage}
|
|
alt=""
|
|
className="h-full w-full rounded-xl object-cover opacity-95"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-strong)] text-[11px] text-[var(--text-muted)]">
|
|
{t("multiAngle.noImage")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/34 [transform:rotateY(90deg)_translateZ(60px)]`}>
|
|
右
|
|
</div>
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/32 [transform:rotateY(-90deg)_translateZ(60px)]`}>
|
|
左
|
|
</div>
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/30 [transform:rotateX(90deg)_translateZ(60px)]`}>
|
|
上
|
|
</div>
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/26 [transform:rotateX(-90deg)_translateZ(60px)]`}>
|
|
下
|
|
</div>
|
|
<div className={`${cubeFaceClass} bg-[var(--surface-2)]/28 [transform:rotateY(180deg)_translateZ(60px)]`}>
|
|
背
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`flex min-w-0 flex-col py-1 ${compact ? "gap-3" : "gap-4"}`}>
|
|
<label className="grid grid-cols-[72px_1fr_56px] items-center gap-3 text-xs text-[var(--text-secondary)]">
|
|
<span>{t("multiAngle.horizontal")}</span>
|
|
<input
|
|
aria-label={t("multiAngle.horizontal")}
|
|
type="range"
|
|
min={MULTI_ANGLE_HORIZONTAL_MIN}
|
|
max={MULTI_ANGLE_HORIZONTAL_MAX}
|
|
step="1"
|
|
value={settings.horizontal}
|
|
onChange={(event) => updateHorizontal(Number(event.target.value))}
|
|
className="h-1.5 w-full accent-[var(--accent)]"
|
|
/>
|
|
<span className="whitespace-nowrap text-right font-medium text-[var(--text-primary)]">{settings.horizontal} deg</span>
|
|
</label>
|
|
|
|
<label className="grid grid-cols-[72px_1fr_56px] items-center gap-3 text-xs text-[var(--text-secondary)]">
|
|
<span>{t("multiAngle.vertical")}</span>
|
|
<input
|
|
aria-label={t("multiAngle.vertical")}
|
|
type="range"
|
|
min="-30"
|
|
max="60"
|
|
step="1"
|
|
value={settings.vertical}
|
|
onChange={(event) => updateVertical(Number(event.target.value))}
|
|
className="h-1.5 w-full accent-[var(--accent)]"
|
|
/>
|
|
<span className="whitespace-nowrap text-right font-medium text-[var(--text-primary)]">{settings.vertical} deg</span>
|
|
</label>
|
|
|
|
<label className="grid grid-cols-[72px_1fr_56px] items-center gap-3 text-xs text-[var(--text-secondary)]">
|
|
<span>{t("multiAngle.zoom")}</span>
|
|
<input
|
|
aria-label={t("multiAngle.zoom")}
|
|
type="range"
|
|
min={MULTI_ANGLE_ZOOM_MIN}
|
|
max={MULTI_ANGLE_ZOOM_MAX}
|
|
step="0.1"
|
|
value={settings.zoom}
|
|
onChange={(event) => updateZoom(Number(event.target.value))}
|
|
className="h-1.5 w-full accent-[var(--accent)]"
|
|
/>
|
|
<span className="whitespace-nowrap text-right font-medium text-[var(--text-primary)]">{settings.zoom.toFixed(1)}</span>
|
|
</label>
|
|
|
|
{!hidePromptControl && (
|
|
<div className="grid grid-cols-[72px_1fr_56px] items-center gap-3 text-xs text-[var(--text-secondary)]">
|
|
<span className="w-[72px]">{t("multiAngle.prompt")}</span>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-label={t("multiAngle.prompt")}
|
|
aria-checked={settings.includePrompt}
|
|
onClick={() => onTogglePrompt(!settings.includePrompt)}
|
|
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-colors ${
|
|
settings.includePrompt ? "bg-[var(--accent)]" : "bg-[var(--surface-active)]"
|
|
}`}
|
|
title={t("multiAngle.enableCustomPrompt")}
|
|
>
|
|
<span
|
|
className={`h-5 w-5 rounded-full bg-[var(--text-on-overlay)] transition-transform ${
|
|
settings.includePrompt ? "translate-x-5" : "translate-x-0"
|
|
}`}
|
|
/>
|
|
</button>
|
|
<span />
|
|
</div>
|
|
)}
|
|
|
|
{!hidePromptControl && settings.includePrompt && (
|
|
<div role="dialog" aria-label={t("multiAngle.promptDialog")} className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-1)] p-3">
|
|
<textarea
|
|
aria-label={t("multiAngle.promptInput")}
|
|
value={settings.promptText}
|
|
onChange={(event) => onChange({ promptText: event.target.value })}
|
|
placeholder={t("multiAngle.promptPlaceholder")}
|
|
rows={3}
|
|
className="nowheel min-h-20 w-full resize-none rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-2)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className={`flex items-center ${actions ? "justify-between" : ""} ${compact ? "mt-auto pt-1" : "pt-3"}`}>
|
|
<button
|
|
type="button"
|
|
aria-label={t("multiAngle.resetAria")}
|
|
onClick={onReset}
|
|
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
|
>
|
|
<span>{t("multiAngle.reset")}</span>
|
|
</button>
|
|
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|