fix: stabilize video canvas quick connect
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-11 23:14:05 +08:00
parent e054dcabcc
commit 3ad76b5c54
2 changed files with 250 additions and 179 deletions

View File

@ -120,9 +120,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -274,6 +271,8 @@ interface VideoCanvasProjectExportPayload {
interface VideoCanvasInsertMenuState { interface VideoCanvasInsertMenuState {
screenPosition: Position; screenPosition: Position;
canvasPosition: Position; canvasPosition: Position;
sourceNodeId?: string | null;
openLeft?: boolean;
} }
interface VideoCanvasInsertMenuChild { interface VideoCanvasInsertMenuChild {
@ -1430,6 +1429,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
}); });
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] = const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
useState<VideoCanvasInsertMenuState | null>(null); useState<VideoCanvasInsertMenuState | null>(null);
const videoCanvasInsertMenuRef = useRef<HTMLDivElement | null>(null);
const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] = const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] =
useState(false); useState(false);
const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] = const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] =
@ -2708,6 +2708,31 @@ export default function CanvasEditorPage({ params }: PageProps) {
return () => window.removeEventListener("resize", updateViewportWidth); return () => window.removeEventListener("resize", updateViewportWidth);
}, []); }, []);
useEffect(() => {
if (!videoCanvasInsertMenu) return;
const handlePointerDown = (event: PointerEvent) => {
const menu = videoCanvasInsertMenuRef.current;
if (menu && event.target instanceof Node && menu.contains(event.target)) {
return;
}
setVideoCanvasInsertMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setVideoCanvasInsertMenu(null);
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [videoCanvasInsertMenu]);
useEffect(() => { useEffect(() => {
if (isAgentMode) { if (isAgentMode) {
setIsPanelOpen(true); setIsPanelOpen(true);
@ -5714,15 +5739,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
nodeConfig: Parameters<typeof addNode>[0], nodeConfig: Parameters<typeof addNode>[0],
options?: { options?: {
connectFromSelection?: boolean; connectFromSelection?: boolean;
connectFromNodeId?: string | null;
selectNewNode?: boolean; selectNewNode?: boolean;
} }
) => { ) => {
const connectFromSelection = options?.connectFromSelection ?? true; const connectFromSelection = options?.connectFromSelection ?? true;
const selectNewNode = options?.selectNewNode ?? true; const selectNewNode = options?.selectNewNode ?? true;
const explicitSourceNodeId = options?.connectFromNodeId || null;
const nodeId = addNode(nodeConfig); const nodeId = addNode(nodeConfig);
const sourceNodeId = const sourceNodeId =
connectFromSelection && isVideoMode && selectedNodeIds.length === 1 connectFromSelection && isVideoMode
? selectedNodeIds[0] || null ? explicitSourceNodeId ||
(selectedNodeIds.length === 1 ? selectedNodeIds[0] || null : null)
: null; : null;
if (sourceNodeId && sourceNodeId !== nodeId) { if (sourceNodeId && sourceNodeId !== nodeId) {
addEdge({ addEdge({
@ -5745,6 +5773,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
options?: { options?: {
position?: Position; position?: Position;
connectFromSelection?: boolean; connectFromSelection?: boolean;
connectFromNodeId?: string | null;
selectNewNode?: boolean; selectNewNode?: boolean;
} }
) => { ) => {
@ -5754,6 +5783,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
options?.position || getVideoWorkflowPlacement(definition.size); options?.position || getVideoWorkflowPlacement(definition.size);
createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement), { createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement), {
connectFromSelection: options?.connectFromSelection ?? true, connectFromSelection: options?.connectFromSelection ?? true,
connectFromNodeId: options?.connectFromNodeId ?? null,
selectNewNode: options?.selectNewNode ?? true, selectNewNode: options?.selectNewNode ?? true,
}); });
}, },
@ -5773,7 +5803,21 @@ export default function CanvasEditorPage({ params }: PageProps) {
if (!videoCanvasInsertMenu) return; if (!videoCanvasInsertMenu) return;
const definition = getVideoWorkflowNodeDefinition(templateType); const definition = getVideoWorkflowNodeDefinition(templateType);
const anchoredPosition = definition const sourceNode = videoCanvasInsertMenu.sourceNodeId
? nodes.find((node) => node.id === videoCanvasInsertMenu.sourceNodeId)
: null;
const anchoredPosition =
sourceNode && definition
? {
x: videoCanvasInsertMenu.openLeft
? sourceNode.position.x - definition.size.width - 180
: sourceNode.position.x + sourceNode.size.width + 180,
y:
sourceNode.position.y +
sourceNode.size.height / 2 -
definition.size.height / 2,
}
: definition
? { ? {
x: x:
videoCanvasInsertMenu.canvasPosition.x - videoCanvasInsertMenu.canvasPosition.x -
@ -5786,11 +5830,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
handleAddWorkflowNode(templateType, { handleAddWorkflowNode(templateType, {
position: anchoredPosition, position: anchoredPosition,
connectFromSelection: false, connectFromSelection: Boolean(sourceNode),
connectFromNodeId: sourceNode?.id || null,
}); });
setVideoCanvasInsertMenu(null); setVideoCanvasInsertMenu(null);
}, },
[handleAddWorkflowNode, videoCanvasInsertMenu] [handleAddWorkflowNode, nodes, videoCanvasInsertMenu]
); );
const handleCreateSelectedAgentResultBranches = useCallback(async () => { const handleCreateSelectedAgentResultBranches = useCallback(async () => {
@ -8574,125 +8619,120 @@ export default function CanvasEditorPage({ params }: PageProps) {
/> />
</div> </div>
{isVideoMode && videoCanvasInsertMenu ? ( {isVideoMode && videoCanvasInsertMenu
<DropdownMenu ? (() => {
modal={false} const menuWidth = 312;
onOpenChange={(open) => { const menuLeft =
if (!open) { viewportWidth > 0
setVideoCanvasInsertMenu(null); ? Math.max(
} 12,
}} Math.min(
open={Boolean(videoCanvasInsertMenu)} videoCanvasInsertMenu.screenPosition.x,
> Math.max(12, viewportWidth - menuWidth - 12)
<DropdownMenuTrigger asChild> )
<button )
aria-hidden="true" : videoCanvasInsertMenu.screenPosition.x;
className="pointer-events-none fixed h-px w-px opacity-0" const menuTop = Math.max(
style={{ 12,
left: `${videoCanvasInsertMenu.screenPosition.x}px`, videoCanvasInsertMenu.screenPosition.y + 14
top: `${videoCanvasInsertMenu.screenPosition.y}px`, );
}} const renderChildButton = (child: VideoCanvasInsertMenuChild) => (
tabIndex={-1} <button
type="button" className="flex w-full items-center rounded-xl px-3 py-2 text-left font-medium text-[13px] text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-950 dark:text-slate-200 dark:hover:bg-zinc-900"
/> key={child.id}
</DropdownMenuTrigger> onClick={() =>
<DropdownMenuContent handleInsertWorkflowNodeFromMenu(child.templateType)
align="start" }
className="w-[312px] rounded-[28px] border border-slate-200 bg-white p-2.5 text-foreground shadow-[0_28px_80px_-32px_rgba(15,23,42,0.35)] dark:border-zinc-800 dark:bg-zinc-950" type="button"
onCloseAutoFocus={(event) => event.preventDefault()} >
sideOffset={14} {child.label}
> </button>
<div className="px-4 py-3 font-semibold text-[14px] text-slate-500 dark:text-zinc-400"> );
@{siteSettingsData?.siteName || "视频画布"} const renderInsertItem = (
</div> item: VideoCanvasInsertMenuItem,
highlighted = false
{VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) => { ) => {
const Icon = item.icon; const Icon = item.icon;
const textClassName = highlighted
? "text-orange-600 dark:text-orange-300"
: "text-slate-700 dark:text-slate-100";
const iconClassName = highlighted
? "text-orange-500"
: "text-slate-400 dark:text-zinc-500";
if (item.children?.length) { if (item.children?.length) {
return ( return (
<DropdownMenuSub key={item.id}> <div className="rounded-2xl" key={item.id}>
<DropdownMenuSubTrigger className="rounded-2xl px-4 py-3 font-medium text-[15px] text-slate-700 focus:bg-slate-50 data-[state=open]:bg-slate-50 dark:text-slate-100 dark:data-[state=open]:bg-zinc-900 dark:focus:bg-zinc-900"> <div
<Icon className="h-5 w-5 text-slate-400 dark:text-zinc-500" /> className={cn(
<span>{item.label}</span> "flex items-center gap-3 rounded-2xl px-4 py-3 font-semibold text-[15px]",
</DropdownMenuSubTrigger> textClassName
<DropdownMenuSubContent className="min-w-[220px] rounded-2xl border border-slate-200 bg-white p-1.5 shadow-[0_22px_60px_-28px_rgba(15,23,42,0.34)] dark:border-zinc-800 dark:bg-zinc-950"> )}
{item.children.map((child) => ( >
<DropdownMenuItem <Icon className={cn("h-5 w-5", iconClassName)} />
className="rounded-xl px-3 py-2.5 font-medium text-slate-700 text-sm focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900" <span className="flex-1">{item.label}</span>
key={child.id} <ChevronRight className="h-4 w-4 opacity-60" />
onSelect={() => </div>
handleInsertWorkflowNodeFromMenu( <div className="ml-8 mr-2 mb-1 grid gap-0.5 border-slate-100 border-l pl-2 dark:border-zinc-800">
child.templateType {item.children.map(renderChildButton)}
) </div>
} </div>
>
{child.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
); );
} }
return ( return (
<DropdownMenuItem <button
className="rounded-2xl px-4 py-3 font-medium text-[15px] text-slate-700 focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900" className={cn(
"flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left font-medium text-[15px] transition-colors hover:bg-slate-50 dark:hover:bg-zinc-900",
textClassName
)}
key={item.id} key={item.id}
onSelect={() => onClick={() =>
item.templateType && item.templateType &&
handleInsertWorkflowNodeFromMenu(item.templateType) handleInsertWorkflowNodeFromMenu(item.templateType)
} }
type="button"
> >
<Icon className="h-5 w-5 text-slate-400 dark:text-zinc-500" /> <Icon className={cn("h-5 w-5", iconClassName)} />
<span>{item.label}</span> <span>{item.label}</span>
</DropdownMenuItem> </button>
); );
})} };
const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1);
<DropdownMenuSeparator className="mx-3 my-2 bg-slate-200 dark:bg-zinc-800" /> return (
<div
className="fixed z-[70] max-h-[calc(100vh-24px)] overflow-y-auto rounded-[28px] border border-slate-200 bg-white p-2.5 text-foreground shadow-[0_28px_80px_-32px_rgba(15,23,42,0.35)] dark:border-zinc-800 dark:bg-zinc-950"
data-canvas-overlay="true"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={videoCanvasInsertMenuRef}
style={{
left: `${menuLeft}px`,
top: `${menuTop}px`,
width: `${menuWidth}px`,
}}
>
<div className="px-4 py-3 font-semibold text-[14px] text-slate-500 dark:text-zinc-400">
@{siteSettingsData?.siteName || "视频画布"}
</div>
{(() => { <div className="grid gap-0.5">
const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1); {VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) =>
if (!highlightedItem) return null; renderInsertItem(item)
const HighlightIcon = highlightedItem.icon; )}
return highlightedItem.children?.length ? ( </div>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="rounded-2xl px-4 py-3 font-semibold text-[15px] text-orange-600 focus:bg-orange-50 data-[state=open]:bg-orange-50 dark:text-orange-300 dark:data-[state=open]:bg-orange-950/40 dark:focus:bg-orange-950/40"> {highlightedItem ? (
<HighlightIcon className="h-5 w-5 text-orange-500" /> <>
<span>{highlightedItem.label}</span> <div className="mx-3 my-2 h-px bg-slate-200 dark:bg-zinc-800" />
</DropdownMenuSubTrigger> {renderInsertItem(highlightedItem, true)}
<DropdownMenuSubContent className="min-w-[220px] rounded-2xl border border-slate-200 bg-white p-1.5 shadow-[0_22px_60px_-28px_rgba(15,23,42,0.34)] dark:border-zinc-800 dark:bg-zinc-950"> </>
{highlightedItem.children.map((child) => ( ) : null}
<DropdownMenuItem </div>
className="rounded-xl px-3 py-2.5 font-medium text-slate-700 text-sm focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900" );
key={child.id} })()
onSelect={() => : null}
handleInsertWorkflowNodeFromMenu(child.templateType)
}
>
{child.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
className="rounded-2xl px-4 py-3 font-semibold text-[15px] text-orange-600 focus:bg-orange-50 dark:text-orange-300 dark:focus:bg-orange-950/40"
onSelect={() =>
highlightedItem.templateType &&
handleInsertWorkflowNodeFromMenu(
highlightedItem.templateType
)
}
>
<HighlightIcon className="h-5 w-5 text-orange-500" />
<span>{highlightedItem.label}</span>
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{isVideoMode && isVideoModuleSidebarEnabled ? ( {isVideoMode && isVideoModuleSidebarEnabled ? (
<div <div
@ -8930,7 +8970,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
<button <button
aria-label="展开关联模块" aria-label="展开关联模块"
className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute top-0 left-0 flex h-14 w-14 items-center justify-center rounded-[20px] border border-[#d7dff1] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_18px_34px_rgba(22,24,29,0.12)] transition-colors hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100" className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute top-0 left-0 flex h-14 w-14 items-center justify-center rounded-[20px] border border-[#d7dff1] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_18px_34px_rgba(22,24,29,0.12)] transition-colors hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
onClick={() => onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setVideoCanvasInsertMenu({ setVideoCanvasInsertMenu({
screenPosition: { screenPosition: {
x: selectedWorkflowQuickConnectAnchor.x, x: selectedWorkflowQuickConnectAnchor.x,
@ -8944,8 +8986,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedWorkflowQuickConnectSourceNode.position.y + selectedWorkflowQuickConnectSourceNode.position.y +
selectedWorkflowQuickConnectSourceNode.size.height / 2, selectedWorkflowQuickConnectSourceNode.size.height / 2,
}, },
}) sourceNodeId: selectedWorkflowQuickConnectSourceNode.id,
} openLeft: selectedWorkflowQuickConnectAnchor.openLeft,
});
}}
type="button" type="button"
> >
<Plus className="h-8 w-8" /> <Plus className="h-8 w-8" />
@ -8959,12 +9003,37 @@ export default function CanvasEditorPage({ params }: PageProps) {
? -1 ? -1
: 1; : 1;
const Icon = getVideoWorkflowQuickConnectIcon(item.type); const Icon = getVideoWorkflowQuickConnectIcon(item.type);
const definition = getVideoWorkflowNodeDefinition(item.type);
return ( return (
<button <button
aria-label={item.label} aria-label={item.label}
className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dbe2ee] bg-white text-[#566377] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_14px_26px_rgba(22,24,29,0.1)] transition-all hover:scale-[1.03] hover:border-[#cfdcff] hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100" className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dbe2ee] bg-white text-[#566377] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_14px_26px_rgba(22,24,29,0.1)] transition-all hover:scale-[1.03] hover:border-[#cfdcff] hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
key={`quick-connect-node-${item.type}`} key={`quick-connect-node-${item.type}`}
onClick={() => handleAddWorkflowNode(item.type)} onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const position = definition
? {
x: selectedWorkflowQuickConnectAnchor.openLeft
? selectedWorkflowQuickConnectSourceNode.position.x -
definition.size.width -
180
: selectedWorkflowQuickConnectSourceNode.position.x +
selectedWorkflowQuickConnectSourceNode.size.width +
180,
y:
selectedWorkflowQuickConnectSourceNode.position.y +
selectedWorkflowQuickConnectSourceNode.size.height / 2 -
definition.size.height / 2 +
y / viewport.zoom,
}
: null;
handleAddWorkflowNode(item.type, {
connectFromSelection: true,
connectFromNodeId: selectedWorkflowQuickConnectSourceNode.id,
...(position ? { position } : {}),
});
}}
style={{ style={{
left: `${x * direction}px`, left: `${x * direction}px`,
top: `${y}px`, top: `${y}px`,

View File

@ -1380,14 +1380,14 @@ export function DrawnixWorkspace({
src={displayLogo} src={displayLogo}
/> />
) : ( ) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-[#111827] font-bold text-white text-xs shadow-sm"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[#111827] font-bold text-white text-xs shadow-sm">
{siteName.slice(0, 1).toUpperCase()} {siteName.slice(0, 1).toUpperCase()}
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
aria-label="流程白板文件名" aria-label="流程白板文件名"
className="h-7 max-w-[180px] rounded-lg border border-transparent bg-transparent px-2 font-bold text-[#111827] text-sm tracking-wide outline-none transition focus:border-black/10 focus:bg-[#f7f8fb]" className="h-7 w-fit min-w-[120px] max-w-[200px] rounded-lg border border-transparent bg-transparent px-2 font-bold text-[#111827] text-sm tracking-wide outline-none transition focus:border-black/10 focus:bg-[#f7f8fb]"
onBlur={handleTitleCommit} onBlur={handleTitleCommit}
onChange={(event) => setCurrentTitle(event.target.value)} onChange={(event) => setCurrentTitle(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@ -1397,7 +1397,7 @@ export function DrawnixWorkspace({
}} }}
value={currentTitle} value={currentTitle}
/> />
<span className="rounded-md bg-[#f7f8fb] px-1.5 py-0.5 font-medium text-[#7a8297] text-[11px]"> <span className="shrink-0 rounded-md bg-[#f7f8fb] px-1.5 py-0.5 font-medium text-[#7a8297] text-[11px]">
{siteName} {siteName}
</span> </span>
</div> </div>
@ -1405,73 +1405,75 @@ export function DrawnixWorkspace({
<div className="h-5 w-px bg-black/10" /> <div className="h-5 w-px bg-black/10" />
<div className="flex items-center gap-1.5"> <div className="flex flex-1 items-center justify-between gap-2">
<button <div className="flex items-center gap-1">
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]" <button
disabled={isSavingNow} className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={() => flushPendingSave(true)} disabled={isSavingNow}
type="button" onClick={() => flushPendingSave(true)}
> type="button"
{isSavingNow ? ( >
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {isSavingNow ? (
) : ( <Loader2 className="h-3.5 w-3.5 animate-spin" />
<Save className="h-3.5 w-3.5" /> ) : (
)} <Save className="h-3.5 w-3.5" />
保存云端 )}
</button> 保存云端
</button>
<button <button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]" className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleOpenFilePanel} onClick={handleOpenFilePanel}
type="button" type="button"
> >
<FolderOpen className="h-3.5 w-3.5" /> <FolderOpen className="h-3.5 w-3.5" />
云文件 云文件
</button> </button>
<button <button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-50" className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingNow || isImportingMindMap} disabled={isSavingNow || isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()} onClick={() => mindMapFileInputRef.current?.click()}
type="button" type="button"
> >
{isImportingMindMap ? ( {isImportingMindMap ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
)} )}
导入 导入
</button> </button>
<button <button
className={`flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-xs transition-colors ${ className={`flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-xs transition-colors ${
isMarkerPanelOpen isMarkerPanelOpen
? "bg-[#111827] text-white shadow-sm hover:bg-[#1f2937]" ? "bg-[#111827] text-white shadow-sm hover:bg-[#1f2937]"
: "text-[#4f566b] hover:bg-black/5 hover:text-[#111827]" : "text-[#4f566b] hover:bg-black/5 hover:text-[#111827]"
}`} }`}
onClick={() => { onClick={() => {
setIsFilePanelOpen(false); setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open); setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState(); refreshMarkerSelectionState();
}} }}
type="button" type="button"
> >
<Tag className="h-3.5 w-3.5" /> <Tag className="h-3.5 w-3.5" />
标记 标记
</button> </button>
<button <button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]" className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleCreateCloudFile} onClick={handleCreateCloudFile}
type="button" type="button"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
新建 新建
</button> </button>
</div>
<div className="ml-1 flex items-center gap-1.5 px-2 text-[#7a8297]"> <div className="flex shrink-0 items-center gap-1.5 pl-2 text-[#7a8297]">
<Save className="h-3.5 w-3.5" /> <Save className="h-3.5 w-3.5" />
<span className="font-medium text-xs"> <span className="whitespace-nowrap font-medium text-xs">
{formatSavedAt(lastSavedAt)} {formatSavedAt(lastSavedAt)}
</span> </span>
</div> </div>