399 lines
17 KiB
TypeScript
399 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCcw, X } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CadamSlider } from "@/components/ui/cadam-slider";
|
|
import { Input } from "@/components/ui/input";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type EditableParameter = Record<string, unknown>;
|
|
|
|
type NormalizedParameter = {
|
|
id: string;
|
|
name: string;
|
|
displayName: string;
|
|
group: string;
|
|
groupDisplayName: string;
|
|
value: number;
|
|
defaultValue: number;
|
|
min: number | null;
|
|
max: number | null;
|
|
step: number;
|
|
precision: number;
|
|
unit: string;
|
|
editable: boolean;
|
|
editState: string;
|
|
};
|
|
|
|
type DownloadFormat = {
|
|
label: string;
|
|
description: string;
|
|
url: string;
|
|
};
|
|
|
|
function numberValue(value: unknown) {
|
|
const numeric = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(numeric) ? numeric : null;
|
|
}
|
|
|
|
function parameterToView(parameter: EditableParameter): NormalizedParameter | null {
|
|
const id = String(parameter.id || parameter.name || "").trim();
|
|
const name = String(parameter.name || id).trim();
|
|
const value = numberValue(parameter.value);
|
|
const declaredRange = Array.isArray(parameter.range) ? parameter.range : [];
|
|
const min = numberValue(parameter.min ?? parameter.minimum ?? declaredRange[0]);
|
|
const max = numberValue(parameter.max ?? parameter.maximum ?? declaredRange[1]);
|
|
if (!name || value === null) return null;
|
|
return {
|
|
id,
|
|
name,
|
|
displayName: String(parameter.display_name || parameter.displayName || parameter.label || name),
|
|
group: String(parameter.group || "dimensions"),
|
|
groupDisplayName: String(parameter.group_display_name || parameter.groupDisplayName || "尺寸"),
|
|
value,
|
|
defaultValue: numberValue(parameter.default_value ?? parameter.defaultValue) ?? value,
|
|
min,
|
|
max,
|
|
step: numberValue(parameter.step) ?? 1,
|
|
precision: Math.max(0, numberValue(parameter.precision) ?? 2),
|
|
unit: String(parameter.unit || ""),
|
|
editable: parameter.editable === true || parameter.edit_state === "declared_unvalidated",
|
|
editState: String(parameter.edit_state || "unvalidated"),
|
|
};
|
|
}
|
|
|
|
function formatValue(value: number, precision: number) {
|
|
if (!Number.isFinite(value)) return "";
|
|
if (Number.isInteger(value) && precision === 0) return String(value);
|
|
return Number(value.toFixed(precision)).toString();
|
|
}
|
|
|
|
function clampValue(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function visualRange(parameter: NormalizedParameter) {
|
|
if (parameter.min !== null && parameter.max !== null && parameter.max > parameter.min) {
|
|
return { min: parameter.min, max: parameter.max };
|
|
}
|
|
const span = Math.max(Math.abs(parameter.value), 1);
|
|
return {
|
|
min: Math.max(0, parameter.value - span),
|
|
max: parameter.value + span,
|
|
};
|
|
}
|
|
|
|
function sectionDisplayName(group: { id: string; displayName: string }) {
|
|
return group.id === "dimensions" || group.displayName === "尺寸"
|
|
? "Dimensions"
|
|
: group.displayName;
|
|
}
|
|
|
|
export function ParameterPanel({
|
|
parameters,
|
|
downloads = [],
|
|
pendingParameter,
|
|
error,
|
|
onClose,
|
|
onCommit,
|
|
onReset,
|
|
}: {
|
|
parameters: EditableParameter[];
|
|
downloads?: DownloadFormat[];
|
|
pendingParameter?: string;
|
|
error?: string;
|
|
onClose: () => void;
|
|
onCommit: (parameter: string, value: number) => void;
|
|
onReset: (values: Record<string, number>) => void;
|
|
}) {
|
|
const normalizedParameters = useMemo(
|
|
() => parameters.map(parameterToView).filter((value): value is NormalizedParameter => Boolean(value)),
|
|
[parameters],
|
|
);
|
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
const [selectedDownload, setSelectedDownload] = useState(downloads[0]?.label || "");
|
|
|
|
useEffect(() => {
|
|
setDrafts(Object.fromEntries(
|
|
normalizedParameters.map((parameter) => [
|
|
parameter.name,
|
|
formatValue(parameter.value, parameter.precision),
|
|
]),
|
|
));
|
|
setOpenGroups((current) => {
|
|
const next = { ...current };
|
|
for (const parameter of normalizedParameters) {
|
|
if (next[parameter.group] === undefined) next[parameter.group] = true;
|
|
}
|
|
return next;
|
|
});
|
|
}, [normalizedParameters]);
|
|
|
|
useEffect(() => {
|
|
if (!downloads.some((download) => download.label === selectedDownload)) {
|
|
setSelectedDownload(downloads[0]?.label || "");
|
|
}
|
|
}, [downloads, selectedDownload]);
|
|
|
|
const grouped = useMemo(() => {
|
|
const groups = new Map<string, { id: string; displayName: string; parameters: NormalizedParameter[] }>();
|
|
for (const parameter of normalizedParameters) {
|
|
const group = groups.get(parameter.group) || {
|
|
id: parameter.group,
|
|
displayName: parameter.groupDisplayName,
|
|
parameters: [],
|
|
};
|
|
group.parameters.push(parameter);
|
|
groups.set(parameter.group, group);
|
|
}
|
|
return Array.from(groups.values());
|
|
}, [normalizedParameters]);
|
|
|
|
const resetParameters = () => {
|
|
const values = Object.fromEntries(
|
|
normalizedParameters
|
|
.filter((parameter) => parameter.editable && parameter.value !== parameter.defaultValue)
|
|
.map((parameter) => [parameter.id, parameter.defaultValue]),
|
|
);
|
|
if (Object.keys(values).length) {
|
|
onReset(values);
|
|
return;
|
|
}
|
|
setDrafts(Object.fromEntries(
|
|
normalizedParameters.map((parameter) => [
|
|
parameter.name,
|
|
formatValue(parameter.defaultValue, parameter.precision),
|
|
]),
|
|
));
|
|
};
|
|
|
|
const selectedDownloadItem = downloads.find((download) => download.label === selectedDownload) || downloads[0] || null;
|
|
|
|
const commitValue = (parameter: NormalizedParameter, rawValue: string | number) => {
|
|
const numeric = numberValue(rawValue);
|
|
if (!parameter.editable) {
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
|
}));
|
|
return;
|
|
}
|
|
if (numeric === null) {
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
|
}));
|
|
return;
|
|
}
|
|
const clamped = parameter.min !== null && parameter.max !== null
|
|
? clampValue(numeric, parameter.min, parameter.max)
|
|
: numeric;
|
|
const formatted = formatValue(clamped, parameter.precision);
|
|
setDrafts((current) => ({ ...current, [parameter.name]: formatted }));
|
|
if (Number(formatted) !== parameter.value) {
|
|
onCommit(parameter.id, Number(formatted));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l border-[var(--ui-border)] bg-[var(--ui-panel)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-panel)]"
|
|
data-viewer-interaction-overlay="true"
|
|
role="dialog"
|
|
aria-label="可编辑参数"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--ui-border-strong)] bg-[var(--ui-panel)] px-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="text-lg font-semibold tracking-tight text-[var(--ui-text-strong)]">Parameters</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
aria-label="恢复全部默认参数"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
|
|
disabled={Boolean(pendingParameter) || !normalizedParameters.length}
|
|
onClick={resetParameters}
|
|
>
|
|
<RefreshCcw className="size-4" />
|
|
</Button>
|
|
<Button
|
|
aria-label="隐藏参数面板"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
|
|
onClick={onClose}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-auto px-6 py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
{error ? (
|
|
<div className="mb-3 rounded border border-[var(--ui-error-border)] bg-[var(--ui-error-bg)] px-3 py-2 text-[11px] leading-4 text-[var(--ui-error-text)]">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
{normalizedParameters.length ? (
|
|
<div className="flex flex-col gap-3">
|
|
{grouped.map((group) => (
|
|
<Collapsible.Root
|
|
key={group.id}
|
|
open={openGroups[group.id] ?? true}
|
|
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.id]: open }))}
|
|
>
|
|
<Collapsible.Trigger className="group flex w-full items-center justify-between gap-2 rounded-md py-1 text-left text-xs font-semibold text-[var(--ui-text-strong)] transition-colors focus:outline-none">
|
|
<span className="flex items-center gap-2">
|
|
{sectionDisplayName(group)}
|
|
<span className="text-[10px] text-[var(--ui-text-subtle)]">{group.parameters.length}</span>
|
|
</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
"size-3.5 text-[var(--ui-text-subtle)] transition-all duration-200 group-hover:text-[var(--ui-text-strong)]",
|
|
openGroups[group.id] !== false && "rotate-180",
|
|
)}
|
|
/>
|
|
</Collapsible.Trigger>
|
|
<Collapsible.Content className="mt-3 flex flex-col gap-3">
|
|
{group.parameters.map((parameter) => {
|
|
const draft = drafts[parameter.name] ?? formatValue(parameter.value, parameter.precision);
|
|
const numericDraft = numberValue(draft) ?? parameter.value;
|
|
const disabled = Boolean(pendingParameter) || !parameter.editable;
|
|
const range = visualRange(parameter);
|
|
const pending = pendingParameter === parameter.id;
|
|
return (
|
|
<div
|
|
className="grid w-full grid-cols-[80px_minmax(0,1fr)] items-center gap-3"
|
|
key={parameter.name}
|
|
>
|
|
<label
|
|
className="min-w-0 overflow-hidden text-ellipsis text-xs font-normal leading-4 text-[var(--ui-text-muted)]"
|
|
htmlFor={`parameter-${parameter.name}`}
|
|
title={parameter.displayName}
|
|
>
|
|
<span className="block truncate">{parameter.displayName}</span>
|
|
</label>
|
|
<div className="flex w-full min-w-0 items-center gap-3">
|
|
<CadamSlider
|
|
id={`${parameter.name}-slider`}
|
|
name={parameter.name}
|
|
min={range.min}
|
|
max={range.max}
|
|
step={parameter.step}
|
|
value={[clampValue(numericDraft, range.min, range.max)]}
|
|
defaultValue={[clampValue(parameter.defaultValue, range.min, range.max)]}
|
|
disabled={disabled}
|
|
visualOnly={false}
|
|
defaultMarkerStyle="line"
|
|
onValueChange={([nextValue]) => {
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[parameter.name]: formatValue(nextValue, parameter.precision),
|
|
}));
|
|
}}
|
|
onValueCommit={([nextValue]) => commitValue(parameter, nextValue)}
|
|
/>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<div className="relative">
|
|
<Input
|
|
id={`parameter-${parameter.name}`}
|
|
autoComplete="off"
|
|
className="h-6 w-14 rounded-lg border-0 bg-[var(--ui-control-bg)] px-2 pr-2 text-left text-xs text-[var(--ui-text-strong)] transition-colors selection:bg-[var(--ui-selection-bg)] selection:text-[var(--ui-text-inverse)] focus-visible:ring-0 hover:bg-[var(--ui-control-hover)]"
|
|
disabled={disabled}
|
|
inputMode="decimal"
|
|
max={range.max}
|
|
min={range.min}
|
|
step={parameter.step}
|
|
type="number"
|
|
value={draft}
|
|
onBlur={() => commitValue(parameter, draft)}
|
|
onChange={(event) => setDrafts((current) => ({
|
|
...current,
|
|
[parameter.name]: event.target.value,
|
|
}))}
|
|
onFocus={(event) => event.target.select()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.currentTarget.blur();
|
|
}
|
|
if (event.key === "Escape") {
|
|
setDrafts((current) => ({
|
|
...current,
|
|
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
{pending ? (
|
|
<Loader2 className="absolute right-1.5 top-1.5 size-3 animate-spin text-[var(--ui-accent)]" />
|
|
) : null}
|
|
</div>
|
|
<span className="ml-1 w-6 text-left text-xs text-[var(--ui-text-muted)]">
|
|
{parameter.unit}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Collapsible.Content>
|
|
</Collapsible.Root>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded border border-[var(--ui-border-strong)] bg-[var(--ui-panel-raised)] px-3 py-3 text-xs leading-5 text-[var(--ui-text-muted)]">
|
|
当前模型没有可直接编辑的参数。
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 flex-col gap-4 border-t border-[var(--ui-border-strong)] px-6 py-6">
|
|
<div className="flex">
|
|
<a
|
|
aria-disabled={!selectedDownloadItem}
|
|
className={cn(
|
|
"inline-flex h-12 flex-1 items-center justify-center rounded-l-lg rounded-r-none bg-[var(--ui-accent)] text-sm font-semibold text-[var(--ui-accent-contrast)] transition-colors hover:bg-[var(--ui-accent-hover)]",
|
|
!selectedDownloadItem && "pointer-events-none opacity-50",
|
|
)}
|
|
href={selectedDownloadItem?.url || "#"}
|
|
download
|
|
>
|
|
<Download className="mr-2 size-4" />
|
|
{selectedDownloadItem?.label || "STEP"}
|
|
</a>
|
|
<DropdownMenu.Root>
|
|
<DropdownMenu.Trigger asChild>
|
|
<Button
|
|
aria-label="选择下载格式"
|
|
className="h-12 w-12 rounded-l-none rounded-r-lg border-l border-[var(--ui-border-muted)] bg-[var(--ui-accent)] p-0 text-[var(--ui-accent-contrast)] hover:bg-[var(--ui-accent-hover)]"
|
|
disabled={!downloads.length}
|
|
>
|
|
<ChevronUp className="size-4" />
|
|
</Button>
|
|
</DropdownMenu.Trigger>
|
|
<DropdownMenu.Content
|
|
align="end"
|
|
className="z-50 w-64 rounded-md border border-[var(--ui-border)] bg-[var(--ui-popover)] p-1 shadow-[var(--ui-shadow-popover)]"
|
|
>
|
|
{downloads.map((download) => (
|
|
<DropdownMenu.Item
|
|
key={download.label}
|
|
className="flex cursor-pointer items-center rounded px-3 py-2 text-[var(--ui-text-strong)] outline-none hover:bg-[var(--ui-control-hover)]"
|
|
onSelect={() => setSelectedDownload(download.label)}
|
|
>
|
|
<span className="text-sm">.{download.label}</span>
|
|
<span className="ml-3 text-xs text-[var(--ui-text-muted)]">{download.description}</span>
|
|
</DropdownMenu.Item>
|
|
))}
|
|
</DropdownMenu.Content>
|
|
</DropdownMenu.Root>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|