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

241 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Modal } from "antd";
import { Switch } from "@/components/ui/base/switch";
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,
extractingVideoFrames: false,
extractingAudio: false,
trimmingVideo: 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="sm" 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;
}