mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 16:14:22 +08:00
refactor(canvas-project): extract CanvasTopBar into its own component
Move CanvasTopBar and its private helpers (MenuLabel, CompactAgentStatus, Shortcut) out of project.tsx into canvas-top-bar.tsx. The component already took all data via explicit props, so the move is mechanical with no behavior change. Drop the imports (DOCS_URL, UserStatusActions, Dropdown, several icons) that were only used by the top bar. project.tsx is now ~3080 lines, down from 3536 at the start of the refactor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { BookOpen, Bot, Home, Images, Menu, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react";
|
||||||
|
import { Button, Dropdown, Modal } from "antd";
|
||||||
|
|
||||||
|
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import { DOCS_URL } from "@/constant/env";
|
||||||
|
|
||||||
|
export function CanvasTopBar({
|
||||||
|
title,
|
||||||
|
titleDraft,
|
||||||
|
isTitleEditing,
|
||||||
|
onTitleDraftChange,
|
||||||
|
onStartTitleEditing,
|
||||||
|
onFinishTitleEditing,
|
||||||
|
onCancelTitleEditing,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onHome,
|
||||||
|
onProjects,
|
||||||
|
onCreateProject,
|
||||||
|
onDeleteProject,
|
||||||
|
onImportImage,
|
||||||
|
onOpenPlugins,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
agentOpen,
|
||||||
|
compactAgentStatus,
|
||||||
|
onToggleAgent,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
titleDraft: string;
|
||||||
|
isTitleEditing: boolean;
|
||||||
|
onTitleDraftChange: (value: string) => void;
|
||||||
|
onStartTitleEditing: () => void;
|
||||||
|
onFinishTitleEditing: () => void;
|
||||||
|
onCancelTitleEditing: () => void;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
onHome: () => void;
|
||||||
|
onProjects: () => void;
|
||||||
|
onCreateProject: () => void;
|
||||||
|
onDeleteProject: () => void;
|
||||||
|
onImportImage: () => void;
|
||||||
|
onOpenPlugins: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
agentOpen: boolean;
|
||||||
|
compactAgentStatus: { connected: boolean; enabled: boolean; activity: string };
|
||||||
|
onToggleAgent: () => void;
|
||||||
|
}) {
|
||||||
|
const colorTheme = useThemeStore((state) => state.theme);
|
||||||
|
const theme = canvasThemes[colorTheme];
|
||||||
|
const titleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTitleEditing) return;
|
||||||
|
const close = (event: PointerEvent) => {
|
||||||
|
if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", close, true);
|
||||||
|
return () => document.removeEventListener("pointerdown", close, true);
|
||||||
|
}, [isTitleEditing, onFinishTitleEditing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
|
||||||
|
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
|
||||||
|
<Dropdown
|
||||||
|
trigger={["click"]}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
|
||||||
|
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
|
||||||
|
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
|
||||||
|
{ type: "divider" },
|
||||||
|
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||||
|
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||||
|
{ type: "divider" },
|
||||||
|
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
||||||
|
{ type: "divider" },
|
||||||
|
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||||
|
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="grid size-9 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<div ref={titleRef} className="flex min-w-0 items-center gap-2">
|
||||||
|
{isTitleEditing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(event) => onTitleDraftChange(event.target.value)}
|
||||||
|
onBlur={onFinishTitleEditing}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") onFinishTitleEditing();
|
||||||
|
if (event.key === "Escape") onCancelTitleEditing();
|
||||||
|
}}
|
||||||
|
className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none"
|
||||||
|
style={{ color: theme.node.text }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
|
||||||
|
onDoubleClick={onStartTitleEditing}
|
||||||
|
title="双击修改画布名称"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||||
|
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} />
|
||||||
|
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="!h-10 !rounded-xl !px-3 !font-medium"
|
||||||
|
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
|
||||||
|
icon={<Bot className="size-4" />}
|
||||||
|
onClick={onToggleAgent}
|
||||||
|
>
|
||||||
|
Agent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
||||||
|
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
|
||||||
|
<Shortcut keys={["拖动画布"]} value="平移视图" />
|
||||||
|
<Shortcut keys={["滚轮"]} value="缩放画布" />
|
||||||
|
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
|
||||||
|
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
|
||||||
|
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
|
||||||
|
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
|
||||||
|
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
|
||||||
|
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
|
||||||
|
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
|
||||||
|
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
|
||||||
|
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
|
||||||
|
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
|
||||||
|
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-36 items-center justify-between gap-8">
|
||||||
|
<span>{text}</span>
|
||||||
|
<span className="text-xs opacity-45">{shortcut}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
|
||||||
|
const colorTheme = useThemeStore((state) => state.theme);
|
||||||
|
const theme = canvasThemes[colorTheme];
|
||||||
|
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
|
||||||
|
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
||||||
|
return (
|
||||||
|
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板">
|
||||||
|
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
||||||
|
<span className="max-w-[140px] truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
|
||||||
|
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
{keys.map((key, index) => (
|
||||||
|
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
|
||||||
|
{index ? <span className="text-xs opacity-35">+</span> : null}
|
||||||
|
<kbd
|
||||||
|
className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]"
|
||||||
|
style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-sm opacity-55">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { BookOpen, Bot, Group, Home, Images, Menu, Plus, Redo2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
import { Group, Video } from "lucide-react";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
|
||||||
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
|
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
|
||||||
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
|
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
|
||||||
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||||
import { DOCS_URL } from "@/constant/env";
|
|
||||||
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
import { defaultConfig, decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||||
import { uploadImage } from "@/services/image-storage";
|
import { uploadImage } from "@/services/image-storage";
|
||||||
import { uploadMediaFile } from "@/services/file-storage";
|
import { uploadMediaFile } from "@/services/file-storage";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||||
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||||
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||||
import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||||
import { App, Button, Dropdown, Modal } from "antd";
|
import { App, Button, Modal } from "antd";
|
||||||
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
|
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
|
||||||
import { ActiveConnectionPath, ConnectionPath } from "@/components/canvas/canvas-connections";
|
import { ActiveConnectionPath, ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||||
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
|
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
|
||||||
@@ -70,6 +68,7 @@ import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
|
|||||||
import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
|
import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes";
|
||||||
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal";
|
||||||
import { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell";
|
import { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell";
|
||||||
|
import { CanvasTopBar } from "@/components/canvas/canvas-top-bar";
|
||||||
import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus";
|
import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus";
|
||||||
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
|
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
|
||||||
import {
|
import {
|
||||||
@@ -3086,193 +3085,3 @@ function InfiniteCanvasPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CanvasTopBar({
|
|
||||||
title,
|
|
||||||
titleDraft,
|
|
||||||
isTitleEditing,
|
|
||||||
onTitleDraftChange,
|
|
||||||
onStartTitleEditing,
|
|
||||||
onFinishTitleEditing,
|
|
||||||
onCancelTitleEditing,
|
|
||||||
canUndo,
|
|
||||||
canRedo,
|
|
||||||
onHome,
|
|
||||||
onProjects,
|
|
||||||
onCreateProject,
|
|
||||||
onDeleteProject,
|
|
||||||
onImportImage,
|
|
||||||
onOpenPlugins,
|
|
||||||
onUndo,
|
|
||||||
onRedo,
|
|
||||||
agentOpen,
|
|
||||||
compactAgentStatus,
|
|
||||||
onToggleAgent,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
titleDraft: string;
|
|
||||||
isTitleEditing: boolean;
|
|
||||||
onTitleDraftChange: (value: string) => void;
|
|
||||||
onStartTitleEditing: () => void;
|
|
||||||
onFinishTitleEditing: () => void;
|
|
||||||
onCancelTitleEditing: () => void;
|
|
||||||
canUndo: boolean;
|
|
||||||
canRedo: boolean;
|
|
||||||
onHome: () => void;
|
|
||||||
onProjects: () => void;
|
|
||||||
onCreateProject: () => void;
|
|
||||||
onDeleteProject: () => void;
|
|
||||||
onImportImage: () => void;
|
|
||||||
onOpenPlugins: () => void;
|
|
||||||
onUndo: () => void;
|
|
||||||
onRedo: () => void;
|
|
||||||
agentOpen: boolean;
|
|
||||||
compactAgentStatus: { connected: boolean; enabled: boolean; activity: string };
|
|
||||||
onToggleAgent: () => void;
|
|
||||||
}) {
|
|
||||||
const colorTheme = useThemeStore((state) => state.theme);
|
|
||||||
const theme = canvasThemes[colorTheme];
|
|
||||||
const titleRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTitleEditing) return;
|
|
||||||
const close = (event: PointerEvent) => {
|
|
||||||
if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing();
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", close, true);
|
|
||||||
return () => document.removeEventListener("pointerdown", close, true);
|
|
||||||
}, [isTitleEditing, onFinishTitleEditing]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
|
|
||||||
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
|
|
||||||
<Dropdown
|
|
||||||
trigger={["click"]}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
|
|
||||||
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
|
|
||||||
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
|
|
||||||
{ type: "divider" },
|
|
||||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
|
||||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
|
||||||
{ type: "divider" },
|
|
||||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
|
|
||||||
{ type: "divider" },
|
|
||||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
|
||||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button type="button" className="grid size-9 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
|
|
||||||
<Menu className="size-5" />
|
|
||||||
</button>
|
|
||||||
</Dropdown>
|
|
||||||
|
|
||||||
<div ref={titleRef} className="flex min-w-0 items-center gap-2">
|
|
||||||
{isTitleEditing ? (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={titleDraft}
|
|
||||||
onChange={(event) => onTitleDraftChange(event.target.value)}
|
|
||||||
onBlur={onFinishTitleEditing}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") onFinishTitleEditing();
|
|
||||||
if (event.key === "Escape") onCancelTitleEditing();
|
|
||||||
}}
|
|
||||||
className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none"
|
|
||||||
style={{ color: theme.node.text }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
|
|
||||||
onDoubleClick={onStartTitleEditing}
|
|
||||||
title="双击修改画布名称"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
|
||||||
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} />
|
|
||||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
className="!h-10 !rounded-xl !px-3 !font-medium"
|
|
||||||
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
|
|
||||||
icon={<Bot className="size-4" />}
|
|
||||||
onClick={onToggleAgent}
|
|
||||||
>
|
|
||||||
Agent
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
|
||||||
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
|
|
||||||
<Shortcut keys={["拖动画布"]} value="平移视图" />
|
|
||||||
<Shortcut keys={["滚轮"]} value="缩放画布" />
|
|
||||||
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
|
|
||||||
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
|
|
||||||
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
|
|
||||||
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
|
|
||||||
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
|
|
||||||
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
|
|
||||||
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
|
|
||||||
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
|
|
||||||
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
|
|
||||||
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
|
|
||||||
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
|
|
||||||
return (
|
|
||||||
<span className="flex min-w-36 items-center justify-between gap-8">
|
|
||||||
<span>{text}</span>
|
|
||||||
<span className="text-xs opacity-45">{shortcut}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
|
|
||||||
const colorTheme = useThemeStore((state) => state.theme);
|
|
||||||
const theme = canvasThemes[colorTheme];
|
|
||||||
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
|
|
||||||
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
|
||||||
return (
|
|
||||||
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板">
|
|
||||||
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
|
||||||
<span className="max-w-[140px] truncate">{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
|
|
||||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
||||||
{keys.map((key, index) => (
|
|
||||||
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
|
|
||||||
{index ? <span className="text-xs opacity-35">+</span> : null}
|
|
||||||
<kbd
|
|
||||||
className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]"
|
|
||||||
style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</kbd>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="text-right text-sm opacity-55">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user