Files
ttstd-infinite-canvas/web/src/components/canvas/toolbars/toolbar-settings-modal.tsx
T

238 lines
11 KiB
TypeScript

import { Modal, Switch } from "antd";
import { GripVertical, RotateCcw, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, getToolbarTools, persistToolbarPrefs, readToolbarPrefs, type ToolbarId, type ToolbarPrefs, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
import { useThemeStore } from "@/stores/use-theme-store";
type ToolbarSettingsModalProps = {
open: boolean;
onClose: () => void;
toolbar: ToolbarId;
};
/** 设置面板用的最小化上下文——仅用于解析工具的 label/icon */
const settingsMockContext: ToolContext = {
selectedCount: 0,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool: "move",
workspaceMode: "professional",
isProjectLinked: false,
canUndo: false,
canRedo: false,
extractingVideoFrame: false,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
settingsPanelOpen: false,
handlers: {} as ToolContext["handlers"],
};
type SettingsItem = {
id: string;
label: string;
icon: React.ReactNode;
visible: boolean;
};
export function ToolbarSettingsModal({ open, onClose, toolbar }: ToolbarSettingsModalProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const [items, setItems] = useState<SettingsItem[]>([]);
const [toolbarId, setToolbarId] = useState<ToolbarId>(toolbar);
const draggedItemIdRef = useRef<string | null>(null);
const dragTargetIdRef = useRef<string | null>(null);
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
const visibleCount = items.filter((item) => item.visible).length;
// 当 modal 打开或 toolbar 变化时,加载工具列表与偏好
useEffect(() => {
if (!open) return;
setToolbarId(toolbar);
const tools = getToolbarTools(toolbar);
const prefs = readToolbarPrefs(toolbar) ?? defaultToolbarPrefs(toolbar);
const hiddenSet = new Set(prefs.hidden);
const orderIndex = new Map(prefs.order.map((id, index) => [id, index]));
const sorted = [...tools].sort((a, b) => {
const ai = orderIndex.has(a.id) ? orderIndex.get(a.id)! : Number.MAX_SAFE_INTEGER;
const bi = orderIndex.has(b.id) ? orderIndex.get(b.id)! : Number.MAX_SAFE_INTEGER;
if (ai !== bi) return ai - bi;
return a.defaultOrder - b.defaultOrder;
});
setItems(sorted.map((tool) => ({
id: tool.id,
label: resolveLabel(tool, settingsMockContext),
icon: resolveIcon(tool, settingsMockContext),
visible: !hiddenSet.has(tool.id),
})));
}, [open, toolbar]);
const handleDragStart = (id: string) => {
draggedItemIdRef.current = id;
dragTargetIdRef.current = id;
setDraggedItemId(id);
};
const handleDragEnter = (targetId: string) => {
const sourceId = draggedItemIdRef.current;
if (!sourceId || dragTargetIdRef.current === targetId) return;
dragTargetIdRef.current = targetId;
setItems((current) => {
const sourceIndex = current.findIndex((item) => item.id === sourceId);
const targetIndex = current.findIndex((item) => item.id === targetId);
if (sourceIndex < 0 || targetIndex < 0) return current;
const next = [...current];
const [movedItem] = next.splice(sourceIndex, 1);
next.splice(targetIndex, 0, movedItem);
persistCurrent(next);
return next;
});
};
const handleDragEnd = () => {
draggedItemIdRef.current = null;
dragTargetIdRef.current = null;
setDraggedItemId(null);
};
const handleToggleVisible = (id: string, visible: boolean) => {
setItems((prev) => {
const next = prev.map((item) => item.id === id ? { ...item, visible } : item);
persistCurrent(next);
return next;
});
};
const handleReset = () => {
const defaults = defaultToolbarPrefs(toolbarId);
const tools = getToolbarTools(toolbarId);
const hiddenSet = new Set(defaults.hidden);
setItems(tools.map((tool) => ({
id: tool.id,
label: resolveLabel(tool, settingsMockContext),
icon: resolveIcon(tool, settingsMockContext),
visible: !hiddenSet.has(tool.id),
})));
persistToolbarPrefs(toolbarId, defaults);
};
const persistCurrent = (currentItems: SettingsItem[]) => {
const prefs: ToolbarPrefs = {
order: currentItems.map((item) => item.id),
hidden: currentItems.filter((item) => !item.visible).map((item) => item.id),
};
persistToolbarPrefs(toolbarId, prefs);
};
return (
<Modal
className="canvas-toolbar-settings-modal"
open={open}
onCancel={onClose}
footer={null}
closable={false}
width={720}
centered
destroyOnClose
styles={{
container: { padding: 0, background: theme.spatial.elevated, border: 0, boxShadow: "none" },
body: { padding: 0, background: theme.spatial.elevated },
}}
>
<div className="flex items-start justify-between gap-4 px-5 pb-3 pt-5">
<div className="min-w-0">
<h2 className="text-[var(--fs-heading)] font-semibold leading-none">工具栏设置</h2>
<p className="mt-2 text-[var(--fs-caption)] leading-none" style={{ color: theme.node.muted }}>拖动调整顺序,关闭不常用入口</p>
</div>
<button
type="button"
onClick={onClose}
className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] outline-none transition-colors hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/8"
style={{ color: theme.node.muted, outlineColor: theme.accent.primary }}
aria-label="关闭工具栏设置"
>
<X className="size-4" />
</button>
</div>
<div className="flex items-center justify-between gap-3 px-5 pb-2 pt-1">
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>已显示 {visibleCount}/{items.length}</span>
<button
type="button"
onClick={handleReset}
className="inline-flex h-7 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-tiny)] font-medium outline-none transition-colors hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/8"
style={{ color: theme.node.muted, outlineColor: theme.accent.primary }}
aria-label="恢复默认工具栏设置"
>
<RotateCcw className="size-3" />
恢复默认
</button>
</div>
<div className="grid grid-cols-2 gap-2 px-5 pb-5 pt-2 md:grid-cols-5" aria-label="主工具栏顺序">
{items.map((item) => (
<ToolbarSettingsItem
key={item.id}
item={item}
reducedMotion={Boolean(reducedMotion)}
theme={theme}
dragging={draggedItemId === item.id}
onToggleVisible={handleToggleVisible}
onDragStart={handleDragStart}
onDragEnter={handleDragEnter}
onDragEnd={handleDragEnd}
/>
))}
</div>
</Modal>
);
}
function ToolbarSettingsItem({ item, reducedMotion, theme, dragging, onToggleVisible, onDragStart, onDragEnter, onDragEnd }: { item: SettingsItem; reducedMotion: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; dragging: boolean; onToggleVisible: (id: string, visible: boolean) => void; onDragStart: (id: string) => void; onDragEnter: (id: string) => void; onDragEnd: () => void }) {
return (
<motion.div
layout={!reducedMotion}
transition={reducedMotion ? { duration: 0 } : undefined}
className={`canvas-toolbar-settings-card flex h-24 min-w-0 flex-col rounded-[var(--r-md)] p-3 ${item.visible ? "" : "is-hidden"} ${dragging ? "is-dragging" : ""}`}
style={{ color: theme.node.text }}
onDragEnter={() => onDragEnter(item.id)}
onDragOver={(event) => event.preventDefault()}
>
<div className="flex items-center justify-between gap-2">
<button
type="button"
draggable
className="grid size-6 touch-none cursor-grab place-items-center rounded-[var(--r-sm)] outline-none opacity-35 transition-opacity hover:opacity-70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 active:cursor-grabbing"
style={{ color: theme.node.muted, outlineColor: theme.accent.primary }}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
onDragStart(item.id);
}}
onDragEnd={onDragEnd}
aria-label={`拖动调整${item.label}顺序`}
>
<GripVertical className="size-4" />
</button>
<Switch size="small" checked={item.visible} onChange={(checked) => onToggleVisible(item.id, checked)} aria-label={`${item.visible ? "隐藏" : "显示"}${item.label}`} />
</div>
<div className="canvas-toolbar-settings-card-content mt-auto flex min-w-0 flex-col items-center gap-1">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
<span className="grid size-4 place-items-center [&_svg]:size-4">{item.icon}</span>
</span>
<span className="max-w-full whitespace-nowrap text-[var(--fs-caption)] font-medium leading-5" title={item.label}>{item.label}</span>
</div>
</motion.div>
);
}
function resolveLabel(tool: ToolDefinition, ctx: ToolContext): string {
return typeof tool.label === "function" ? tool.label(ctx) : tool.label;
}
function resolveIcon(tool: ToolDefinition, ctx: ToolContext): React.ReactNode {
return typeof tool.icon === "function" ? tool.icon(ctx) : tool.icon;
}