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.
504 lines
20 KiB
504 lines
20 KiB
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
type MouseEvent,
|
|
type PointerEvent,
|
|
} from "react";
|
|
import { useI18n, type TranslationKey } from "@/i18n";
|
|
|
|
export type MultiAnglePresetId = "custom" | "fisheye" | "tilt" | "frontOverhead" | "frontLow" | "panoramicOverhead" | "back";
|
|
export type MultiAngleZoom = 0 | 1 | 2;
|
|
export type MultiAngleVertical = -30 | 0 | 30 | 60;
|
|
|
|
export interface MultiAngleSettings {
|
|
preset: MultiAnglePresetId;
|
|
horizontal: number;
|
|
vertical: MultiAngleVertical;
|
|
zoom: MultiAngleZoom;
|
|
includePrompt: boolean;
|
|
promptText: string;
|
|
}
|
|
|
|
const MULTI_ANGLE_PROMPT_PATTERN = /\n*\u3010多角度\u3011[\s\S]*?\u3010\/多角度\u3011/g;
|
|
|
|
export const MULTI_ANGLE_PRESETS: Array<{ id: MultiAnglePresetId; label: string; enabled: boolean }> = [
|
|
{ id: "custom", label: "自定义", enabled: true },
|
|
{ id: "fisheye", label: "鱼眼视角", enabled: true },
|
|
{ id: "tilt", label: "倾斜视角", enabled: true },
|
|
{ id: "frontOverhead", label: "正面俯拍", enabled: true },
|
|
{ id: "frontLow", label: "正面仰拍", enabled: true },
|
|
{ id: "panoramicOverhead", label: "全景俯拍", enabled: true },
|
|
{ id: "back", label: "背面视角", enabled: true },
|
|
];
|
|
|
|
export const MULTI_ANGLE_HORIZONTAL_MIN = 0;
|
|
export const MULTI_ANGLE_HORIZONTAL_MAX = 315;
|
|
export const MULTI_ANGLE_VERTICAL_OPTIONS: MultiAngleVertical[] = [-30, 0, 30, 60];
|
|
export const FISHEYE_MULTI_ANGLE_PROMPT =
|
|
"超近距离鱼眼镜头,强烈超广角透视,圆形画面边界与黑色暗角,明显桶形畸变,画面中心主体放大,边缘向外弯曲拉伸";
|
|
export const TILT_MULTI_ANGLE_PROMPT = "dutch angle, tilted frame";
|
|
|
|
export const MULTI_ANGLE_ZOOM_LABELS: Record<MultiAngleZoom, string> = {
|
|
0: "全景",
|
|
1: "中景",
|
|
2: "特写",
|
|
};
|
|
|
|
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",
|
|
};
|
|
|
|
const MULTI_ANGLE_ZOOM_LABEL_KEYS: Record<MultiAngleZoom, TranslationKey> = {
|
|
0: "multiAngle.zoom.wide",
|
|
1: "multiAngle.zoom.medium",
|
|
2: "multiAngle.zoom.close",
|
|
};
|
|
|
|
export const DEFAULT_MULTI_ANGLE_SETTINGS: MultiAngleSettings = {
|
|
preset: "custom",
|
|
horizontal: 0,
|
|
vertical: -30,
|
|
zoom: 1,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
};
|
|
|
|
export const FISHEYE_MULTI_ANGLE_SETTINGS: MultiAngleSettings = {
|
|
preset: "fisheye",
|
|
horizontal: 0,
|
|
vertical: 30,
|
|
zoom: 2,
|
|
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: 1,
|
|
includePrompt: true,
|
|
promptText: TILT_MULTI_ANGLE_PROMPT,
|
|
},
|
|
frontOverhead: {
|
|
preset: "frontOverhead",
|
|
horizontal: 0,
|
|
vertical: 60,
|
|
zoom: 1,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
frontLow: {
|
|
preset: "frontLow",
|
|
horizontal: 0,
|
|
vertical: -30,
|
|
zoom: 1,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
panoramicOverhead: {
|
|
preset: "panoramicOverhead",
|
|
horizontal: 45,
|
|
vertical: 30,
|
|
zoom: 0,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
back: {
|
|
preset: "back",
|
|
horizontal: 180,
|
|
vertical: 0,
|
|
zoom: 1,
|
|
includePrompt: false,
|
|
promptText: "",
|
|
},
|
|
};
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function snapMultiAngleVertical(value: number): MultiAngleVertical {
|
|
return MULTI_ANGLE_VERTICAL_OPTIONS.reduce((closest, option) =>
|
|
Math.abs(option - value) < Math.abs(closest - value) ? option : closest
|
|
);
|
|
}
|
|
|
|
function getNextMultiAngleVertical(current: MultiAngleVertical, direction: -1 | 1): MultiAngleVertical {
|
|
const currentIndex = MULTI_ANGLE_VERTICAL_OPTIONS.indexOf(current);
|
|
const nextIndex = Math.max(0, Math.min(MULTI_ANGLE_VERTICAL_OPTIONS.length - 1, currentIndex + direction));
|
|
return MULTI_ANGLE_VERTICAL_OPTIONS[nextIndex] ?? DEFAULT_MULTI_ANGLE_SETTINGS.vertical;
|
|
}
|
|
|
|
function getHorizontalPrompt(horizontal: number): string {
|
|
if (horizontal === 0) return "水平环绕 0°,相机正对主体";
|
|
const visualAngle = 360 - horizontal;
|
|
return `水平环绕 ${horizontal}°,按目标视角等效为主体向右转 ${visualAngle}°,不要生成反向视角`;
|
|
}
|
|
|
|
function getVerticalPrompt(vertical: number): string {
|
|
if (vertical === 0) return "垂直俯仰 0°";
|
|
return vertical > 0
|
|
? `垂直俯仰 ${vertical}°,正面俯拍视角,相机从上方向下看主体`
|
|
: `垂直俯仰 ${vertical}°,正面仰拍视角,相机从下方向上看主体`;
|
|
}
|
|
|
|
export function buildMultiAnglePrompt(settings: MultiAngleSettings): string {
|
|
const customPrompt = settings.includePrompt ? settings.promptText.trim() : "";
|
|
const customPromptText = customPrompt ? `,提示词:${customPrompt}` : "";
|
|
return `【多角度】基于输入图片生成,保持同一主体和画风。使用${getHorizontalPrompt(settings.horizontal)},${getVerticalPrompt(settings.vertical)},景别为${MULTI_ANGLE_ZOOM_LABELS[settings.zoom]}${customPromptText},构图稳定、画面自然。【/多角度】`;
|
|
}
|
|
|
|
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,
|
|
}: {
|
|
previewImage: string | null | undefined;
|
|
settings: MultiAngleSettings;
|
|
onChange: (patch: Partial<MultiAngleSettings>) => void;
|
|
onSelectPreset: (preset: MultiAnglePresetId) => void;
|
|
onTogglePrompt: (enabled: boolean) => void;
|
|
onReset: () => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const selectedPreset = MULTI_ANGLE_PRESETS.find((preset) => preset.id === settings.preset) ?? MULTI_ANGLE_PRESETS[0];
|
|
const selectedPresetLabel = t(MULTI_ANGLE_PRESET_LABEL_KEYS[selectedPreset.id]);
|
|
const currentZoomLabel = t(MULTI_ANGLE_ZOOM_LABEL_KEYS[settings.zoom]);
|
|
const horizontalRadians = (settings.horizontal * Math.PI) / 180;
|
|
const cameraX = 100 + Math.sin(horizontalRadians) * 78;
|
|
const cameraY = 100 - (settings.vertical / 60) * 52;
|
|
const globePitchRotation = -(settings.vertical / 60) * 16;
|
|
const globePitchScaleY = 1 - Math.abs(settings.vertical) / 260;
|
|
const sightTargetY = 100;
|
|
const previewScale = settings.zoom === 0 ? 0.88 : settings.zoom === 1 ? 1 : 1.16;
|
|
|
|
const updateHorizontal = (value: number) => {
|
|
onChange({
|
|
horizontal: Math.round(clamp(value, MULTI_ANGLE_HORIZONTAL_MIN, MULTI_ANGLE_HORIZONTAL_MAX)),
|
|
});
|
|
};
|
|
|
|
const updateVertical = (value: number) => {
|
|
onChange({ vertical: snapMultiAngleVertical(value) });
|
|
};
|
|
|
|
const updateZoom = (value: number) => {
|
|
onChange({ zoom: Math.round(clamp(value, 0, 2)) as MultiAngleZoom });
|
|
};
|
|
|
|
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 = normalizedAngle > MULTI_ANGLE_HORIZONTAL_MAX
|
|
? MULTI_ANGLE_HORIZONTAL_MIN
|
|
: Math.round(normalizedAngle);
|
|
|
|
onChange({
|
|
horizontal,
|
|
vertical: snapMultiAngleVertical(-30 + pointerY * 90),
|
|
});
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleGlobePointerDown = useCallback(
|
|
(event: PointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY);
|
|
},
|
|
[updateFromGlobePoint]
|
|
);
|
|
|
|
const handleGlobePointerMove = useCallback(
|
|
(event: PointerEvent<HTMLDivElement>) => {
|
|
if ((event.buttons & 1) !== 1) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY);
|
|
},
|
|
[updateFromGlobePoint]
|
|
);
|
|
|
|
const handleGlobePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
|
event.stopPropagation();
|
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
}, []);
|
|
|
|
const handleGlobeMouseDown = useCallback(
|
|
(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();
|
|
}, []);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-neutral-700/70 bg-neutral-900/35 p-3">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<span className="text-sm font-medium text-neutral-100">{t("multiAngle.editorTitle")}</span>
|
|
<span className="text-[11px] text-neutral-500">{selectedPresetLabel}</span>
|
|
</div>
|
|
|
|
<div className="nowheel mb-3 flex gap-2 overflow-x-auto pb-1">
|
|
{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-neutral-500 bg-neutral-700 text-neutral-100"
|
|
: "border-neutral-700 bg-neutral-800/70 text-neutral-500 hover:border-neutral-500 hover:bg-neutral-700/80 hover:text-neutral-100"
|
|
} disabled:cursor-not-allowed disabled:opacity-60`}
|
|
>
|
|
{t(MULTI_ANGLE_PRESET_LABEL_KEYS[preset.id])}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
|
<div
|
|
aria-label={t("multiAngle.sightControl")}
|
|
className="relative h-60 touch-none overflow-hidden rounded-xl bg-neutral-800 cursor-grab active:cursor-grabbing"
|
|
data-composer-local-pointer="true"
|
|
data-horizontal={settings.horizontal}
|
|
data-vertical={settings.vertical}
|
|
data-zoom={currentZoomLabel}
|
|
role="application"
|
|
tabIndex={0}
|
|
onPointerDown={handleGlobePointerDown}
|
|
onPointerMove={handleGlobePointerMove}
|
|
onPointerUp={handleGlobePointerUp}
|
|
onMouseDown={handleGlobeMouseDown}
|
|
onMouseMove={handleGlobeMouseMove}
|
|
onMouseUp={handleGlobeMouseUp}
|
|
>
|
|
<svg className="absolute inset-4 z-0 h-[calc(100%-2rem)] w-[calc(100%-2rem)] text-neutral-500/70" viewBox="0 0 200 200" fill="none">
|
|
<defs>
|
|
<radialGradient id="multi-angle-globe-glow" cx="50%" cy="44%" r="52%">
|
|
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.2" />
|
|
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.02" />
|
|
</radialGradient>
|
|
</defs>
|
|
<circle cx="100" cy="100" r="78" fill="url(#multi-angle-globe-glow)" stroke="currentColor" strokeWidth="1" />
|
|
<g transform={`translate(100 100) rotate(${globePitchRotation}) scale(1 ${globePitchScaleY}) translate(-100 -100)`}>
|
|
<ellipse cx="100" cy="100" rx="78" ry="26" stroke="currentColor" strokeWidth="0.8" />
|
|
<ellipse cx="100" cy="100" rx="78" ry="52" stroke="currentColor" strokeWidth="0.8" />
|
|
<ellipse cx="100" cy="100" rx="26" ry="78" stroke="currentColor" strokeWidth="0.8" />
|
|
<ellipse cx="100" cy="100" rx="52" ry="78" stroke="currentColor" strokeWidth="0.8" />
|
|
<path d="M22 100h156M100 22v156" stroke="currentColor" strokeWidth="0.8" />
|
|
</g>
|
|
</svg>
|
|
|
|
<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 -translate-x-1/2 rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-neutral-700 hover:text-neutral-200"
|
|
>
|
|
˄
|
|
</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 -translate-x-1/2 rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-neutral-700 hover:text-neutral-200"
|
|
>
|
|
˅
|
|
</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 -translate-y-1/2 rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-neutral-700 hover:text-neutral-200"
|
|
>
|
|
‹
|
|
</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 -translate-y-1/2 rounded-md px-2 py-1 text-neutral-500 transition-colors hover:bg-neutral-700 hover:text-neutral-200"
|
|
>
|
|
›
|
|
</button>
|
|
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center">
|
|
{previewImage ? (
|
|
<img
|
|
src={previewImage}
|
|
alt=""
|
|
className="h-20 w-20 rounded-md border border-cyan-400/50 object-cover shadow-lg shadow-black/40 transition-transform duration-150"
|
|
style={{ transform: `scale(${previewScale})` }}
|
|
/>
|
|
) : (
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-md border border-dashed border-neutral-600 text-[11px] text-neutral-500">
|
|
{t("multiAngle.noImage")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<svg className="pointer-events-none absolute inset-4 z-30 h-[calc(100%-2rem)] w-[calc(100%-2rem)]" viewBox="0 0 200 200" fill="none">
|
|
<line x1={cameraX} y1={cameraY} x2="100" y2={sightTargetY} stroke="#22d3ee" strokeWidth="2.4" strokeLinecap="round" />
|
|
<circle cx={cameraX} cy={cameraY} r="3.2" fill="#22d3ee" />
|
|
<g transform={`translate(${cameraX - 9} ${cameraY + 6})`}>
|
|
<rect width="18" height="12" rx="2" fill="#e5e5e5" />
|
|
<circle cx="9" cy="6" r="3" fill="#171717" />
|
|
<path d="M3 3h3" stroke="#171717" strokeWidth="1.2" strokeLinecap="round" />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
<div className="min-w-0 space-y-4 py-1">
|
|
<label className="grid grid-cols-[72px_1fr_44px] items-center gap-3 text-xs text-neutral-400">
|
|
<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-neutral-100"
|
|
/>
|
|
<span className="text-right font-medium text-neutral-100">{settings.horizontal}°</span>
|
|
</label>
|
|
|
|
<label className="grid grid-cols-[72px_1fr_44px] items-center gap-3 text-xs text-neutral-400">
|
|
<span>{t("multiAngle.vertical")}</span>
|
|
<input
|
|
aria-label={t("multiAngle.vertical")}
|
|
type="range"
|
|
min="-30"
|
|
max="60"
|
|
step="30"
|
|
value={settings.vertical}
|
|
onChange={(event) => updateVertical(Number(event.target.value))}
|
|
className="h-1.5 w-full accent-neutral-100"
|
|
/>
|
|
<span className="text-right font-medium text-neutral-100">{settings.vertical}°</span>
|
|
</label>
|
|
|
|
<label className="grid grid-cols-[72px_1fr_44px] items-center gap-3 text-xs text-neutral-400">
|
|
<span>{t("multiAngle.zoom")}</span>
|
|
<input
|
|
aria-label={t("multiAngle.zoom")}
|
|
type="range"
|
|
min="0"
|
|
max="2"
|
|
step="1"
|
|
value={settings.zoom}
|
|
onChange={(event) => updateZoom(Number(event.target.value))}
|
|
className="h-1.5 w-full accent-neutral-100"
|
|
/>
|
|
<span className="text-right font-medium text-neutral-100">{currentZoomLabel}</span>
|
|
</label>
|
|
|
|
<div className="grid grid-cols-[72px_1fr_44px] items-center gap-3 text-xs text-neutral-400">
|
|
<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-cyan-500" : "bg-neutral-700"
|
|
}`}
|
|
title={t("multiAngle.enableCustomPrompt")}
|
|
>
|
|
<span
|
|
className={`h-5 w-5 rounded-full bg-neutral-100 transition-transform ${
|
|
settings.includePrompt ? "translate-x-5" : "translate-x-0"
|
|
}`}
|
|
/>
|
|
</button>
|
|
<span />
|
|
</div>
|
|
|
|
{settings.includePrompt && (
|
|
<div role="dialog" aria-label={t("multiAngle.promptDialog")} className="rounded-xl border border-neutral-700 bg-neutral-900/70 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-neutral-700 bg-neutral-950/60 px-3 py-2 text-sm text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-cyan-500"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center 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-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
|
|
>
|
|
<span>↺</span>
|
|
<span>{t("multiAngle.reset")}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|