feat(canvas): 左侧面板可调宽收起 + 元素多选导出与跳转动画

- 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),状态与宽度持久化
- 顶栏菜单左侧新增面板开关按钮,并缩小菜单/开关按钮
- 顶栏菜单新增「导出当前画布」为压缩包
- 画布元素列表支持多选并批量导出:图片/视频/音频为文件、文本为 txt、其余各自为 json
- 画布/资产切换改为滑动下划线动画,点击元素跳转带缓动动画
- 移除文本/配置/视频等非图片元素图标的灰色底色,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
HouYunFei
2026-07-17 16:21:26 +08:00
parent a3411cf9d9
commit 219b3e84db
8 changed files with 303 additions and 25 deletions
+1
View File
@@ -51,6 +51,7 @@
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill``#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
+6 -2
View File
@@ -2,10 +2,14 @@
## Unreleased
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图,不再是「图片1」文字编号
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃(React #185 最大更新深度超限);Agent 面板改为细粒度订阅,画布拖拽/缩放时不再随每帧重渲染,交互更流畅
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
## v0.8.2 - 2026-07-16
@@ -5,6 +5,10 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 左侧画布面板:支持拖拽右边缘调整宽度(记忆到本地)、通过顶栏菜单左侧的开关按钮展开/收起,展开收起有动画;需验证宽度拖拽、动画、刷新后保持展开/收起状态与宽度。
- 左侧画布面板:「画布/资产」切换改为带滑动下划线的动画;点击「画布元素」列表项跳转到对应节点时带缓动动画(视图平滑移动缩放而非瞬移);文本/配置/视频/音频等非图片元素的图标去掉灰色底色;需验证视觉与交互。
- 左侧画布面板:画布元素列表点击「选择」进入多选模式,可勾选/全选节点后「导出选中」为压缩包;压缩包为扁平结构——图片/视频/音频直接是对应文件(按节点名称命名、重名自动加序号),文本节点为 `.txt`,其余无法识别的节点各自导出为同名 `.json`;需验证多选、全选、导出内容与命名正确。
- 顶栏菜单:新增「导出当前画布」,导出当前画布为压缩包(与项目列表页单个导出格式一致);需验证导出文件名与内容。
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件。
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线)。
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 `{{input}}` 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互。
+144 -16
View File
@@ -1,16 +1,27 @@
import { useMemo, useState } from "react";
import { Empty, Input, Select, Tag } from "antd";
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
import { useMemo, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Empty, Input, Select, Tag } from "antd";
import { Check, ChevronRight, Download, FileText, Image as ImageIcon, ListChecks, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import {
CANVAS_SIDE_PANEL_MAX_WIDTH,
CANVAS_SIDE_PANEL_MIN_WIDTH,
CANVAS_SIDE_PANEL_MOTION_MS,
useCanvasSidePanelStore,
} from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { InsertAssetPayload } from "./asset-picker-modal";
const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000;
const PANEL_EASE = [0.22, 1, 0.36, 1] as const;
type PanelTab = "canvas" | "assets";
type Props = {
@@ -39,22 +50,67 @@ const STATUS_COLOR: Record<string, string> = {
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width);
const panelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
const panelMounted = useCanvasSidePanelStore((state) => state.panelMounted);
const panelClosing = useCanvasSidePanelStore((state) => state.panelClosing);
const setWidth = useCanvasSidePanelStore((state) => state.setWidth);
const [resizing, setResizing] = useState(false);
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, startWidth + moveEvent.clientX - startX));
setWidth(nextWidth);
};
const onUp = () => {
localStorage.setItem("canvas-side-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
if (!panelMounted) return null;
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom>
<div className="flex items-center gap-1 px-3 pt-3">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
</aside>
<motion.div
className="relative z-[60] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col overflow-hidden border-r"
initial={{ x: -48 }}
animate={{ x: panelClosing ? -28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
style={{ width, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
data-canvas-no-zoom
>
<div className="flex items-center gap-5 px-4 pt-3.5">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" />
</motion.aside>
</motion.div>
);
}
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}>
<button type="button" onClick={onClick} className="relative pb-1.5 text-sm font-semibold transition-opacity" style={{ color: theme.node.text, opacity: active ? 1 : 0.45 }}>
{label}
{active ? <motion.span layoutId="sidePanelTabIndicator" className="absolute inset-x-0 -bottom-px h-0.5 rounded-full" style={{ background: theme.toolbar.activeText }} transition={{ type: "spring", stiffness: 500, damping: 34 }} /> : null}
</button>
);
}
@@ -79,20 +135,64 @@ function nodePreviewText(node: CanvasNodeData) {
}
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectMode, setSelectMode] = useState(false);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
}, [nodes, keyword, typeFilter]);
const exitSelect = () => {
setSelectMode(false);
setChecked(new Set());
};
const toggleChecked = (id: string) =>
setChecked((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const allChecked = filtered.length > 0 && filtered.every((node) => checked.has(node.id));
const toggleAll = () => setChecked(allChecked ? new Set() : new Set(filtered.map((node) => node.id)));
const handleExport = async () => {
const targets = nodes.filter((node) => checked.has(node.id));
if (!targets.length) return;
setExporting(true);
const hide = message.loading("正在导出选中元素…", 0);
try {
await exportCanvasNodes(targets, `画布元素-${targets.length}`);
message.success(`已导出 ${targets.length} 个元素`);
exitSelect();
} catch (error) {
console.error(error);
message.error("导出失败,请重试");
} finally {
hide();
setExporting(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />
<button
type="button"
onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
>
<ListChecks className="size-3.5" />
{selectMode ? "取消" : "选择"}
</button>
{selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />}
</div>
<div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
@@ -103,17 +203,19 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
{filtered.map((node) => {
const Icon = NODE_TYPE_ICON[node.type] || FileText;
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
const active = selectedNodeIds.has(node.id);
const isChecked = checked.has(node.id);
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => onFocusNode(node.id)}
onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))}
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
style={active ? { background: theme.toolbar.activeBg } : undefined}
>
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}>
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />}
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
@@ -128,10 +230,36 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
<div className="pt-16 text-center text-sm opacity-40"></div>
)}
</div>
{selectMode ? (
<div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}>
<button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10">
{allChecked ? "取消全选" : "全选"}
</button>
<span className="text-xs opacity-45"> {checked.size}</span>
<button
type="button"
onClick={() => void handleExport()}
disabled={!checked.size || exporting}
className="ml-auto flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
style={{ background: theme.toolbar.activeBg, color: theme.toolbar.activeText }}
>
<Download className="size-3.5" />
</button>
</div>
) : null}
</div>
);
}
function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) {
return (
<span className="grid size-4 shrink-0 place-items-center rounded border transition" style={{ borderColor: checked ? theme.toolbar.activeText : theme.node.stroke, background: checked ? theme.toolbar.activeText : "transparent" }}>
{checked ? <Check className="size-3 text-white" /> : null}
</span>
);
}
// ---------------------------------------------------------------------------
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// ---------------------------------------------------------------------------
+23 -6
View File
@@ -1,9 +1,10 @@
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 { BookOpen, Bot, Download, Home, Images, Menu, PanelLeftClose, PanelLeftOpen, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { canvasThemes } from "@/lib/canvas-theme";
import { useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { DOCS_URL } from "@/constant/env";
@@ -21,6 +22,7 @@ export function CanvasTopBar({
onProjects,
onCreateProject,
onDeleteProject,
onExportProject,
onImportImage,
onOpenPlugins,
onUndo,
@@ -42,6 +44,7 @@ export function CanvasTopBar({
onProjects: () => void;
onCreateProject: () => void;
onDeleteProject: () => void;
onExportProject: () => void;
onImportImage: () => void;
onOpenPlugins: () => void;
onUndo: () => void;
@@ -54,6 +57,8 @@ export function CanvasTopBar({
const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const sidePanelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
const toggleSidePanel = useCanvasSidePanelStore((state) => state.togglePanel);
useEffect(() => {
if (!isTitleEditing) return;
@@ -66,8 +71,19 @@ export function CanvasTopBar({
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">
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between pl-1 pr-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
<Tooltip title={sidePanelOpen ? "收起面板" : "展开面板"}>
<button
type="button"
onClick={toggleSidePanel}
aria-label={sidePanelOpen ? "收起面板" : "展开面板"}
className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
{sidePanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
</Tooltip>
<Dropdown
trigger={["click"]}
menu={{
@@ -80,14 +96,15 @@ export function CanvasTopBar({
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
{ key: "export", icon: <Download className="size-4" />, label: "导出当前画布", onClick: onExportProject },
{ 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 type="button" className="grid size-7 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-4" />
</button>
</Dropdown>
+37
View File
@@ -5,6 +5,7 @@ import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
const zipFiles: { name: string; data: BlobPart }[] = [];
@@ -29,6 +30,39 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
export async function exportCanvasNodes(nodes: CanvasNodeData[], fileName = "画布元素") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const used = new Set<string>();
const uniqueName = (base: string, ext: string) => {
const safe = safeFileName(base) || "元素";
let name = `${safe}.${ext}`;
for (let i = 1; used.has(name); i += 1) name = `${safe}-${i}.${ext}`;
used.add(name);
return name;
};
await Promise.all(
nodes.map(async (node) => {
const title = node.title || node.type;
const storageKey = node.metadata?.storageKey || "";
if (storageKey) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (blob) return void zipFiles.push({ name: uniqueName(title, fileExtension(blob.type, storageKey)), data: blob });
}
if (node.type === CanvasNodeType.Text) return void zipFiles.push({ name: uniqueName(title, "txt"), data: node.metadata?.content || node.metadata?.prompt || "" });
const content = node.metadata?.content;
if (content && content.startsWith("data:")) {
const blob = await (await fetch(content)).blob();
return void zipFiles.push({ name: uniqueName(title, fileExtension(blob.type, storageKey)), data: blob });
}
zipFiles.push({ name: uniqueName(title, "json"), data: JSON.stringify(node, null, 2) });
}),
);
const zip = await createZip(zipFiles);
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
@@ -47,5 +81,8 @@ function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("ogg")) return "ogg";
return storageKey.startsWith("image:") ? "png" : "bin";
}
+35 -1
View File
@@ -43,6 +43,7 @@ import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useAgentBridge } from "@/pages/canvas/hooks/use-agent-bridge";
import { usePluginHost } from "@/pages/canvas/hooks/use-plugin-host";
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory";
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
import {
@@ -246,6 +247,7 @@ function InfiniteCanvasPage() {
const connectionsRef = useRef(connections);
const selectedNodeIdsRef = useRef(selectedNodeIds);
const viewportRef = useRef(viewport);
const focusAnimRef = useRef<number | null>(null);
const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise<void>) | null>(null);
const connectingParamsRef = useRef(connectingParams);
const connectionTargetNodeIdRef = useRef(connectionTargetNodeId);
@@ -901,14 +903,30 @@ function InfiniteCanvasPage() {
const worldX = node.position.x + node.width / 2;
const worldY = node.position.y + node.height / 2;
const k = Math.min(Math.max(Math.min((size.width * 0.6) / node.width, (size.height * 0.6) / node.height), 0.05), 1.5);
setViewport({ x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k });
const target = { x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k };
setSelectedNodeIds(new Set([nodeId]));
setSelectedConnectionId(null);
setContextMenu(null);
if (focusAnimRef.current) cancelAnimationFrame(focusAnimRef.current);
const start = { ...viewportRef.current };
const duration = 450;
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
let startTime: number | null = null;
const step = (now: number) => {
if (startTime === null) startTime = now;
const progress = Math.min((now - startTime) / duration, 1);
const t = easeOutCubic(progress);
setViewport({ x: start.x + (target.x - start.x) * t, y: start.y + (target.y - start.y) * t, k: start.k + (target.k - start.k) * t });
focusAnimRef.current = progress < 1 ? requestAnimationFrame(step) : null;
};
focusAnimRef.current = requestAnimationFrame(step);
},
[size.height, size.width],
);
useEffect(() => () => void (focusAnimRef.current && cancelAnimationFrame(focusAnimRef.current)), []);
const setZoomScale = useCallback(
(scale: number) => {
const nextScale = Math.min(Math.max(scale, 0.05), 5);
@@ -971,6 +989,21 @@ function InfiniteCanvasPage() {
navigate("/canvas");
}, [cleanupAssetImages, deleteProjects, navigate, projectId]);
const exportCurrentProject = useCallback(async () => {
const project = useCanvasStore.getState().projects.find((item) => item.id === projectId);
if (!project) return message.error("未找到当前画布");
const hide = message.loading("正在导出当前画布…", 0);
try {
await exportCanvasProjects([project], project.title || "无限画布");
message.success("已导出当前画布");
} catch (error) {
console.error(error);
message.error("导出失败,请重试");
} finally {
hide();
}
}, [message, projectId]);
const handleCanvasMouseDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
setContextMenu(null);
@@ -2689,6 +2722,7 @@ function InfiniteCanvasPage() {
onProjects={() => navigate("/canvas")}
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onExportProject={exportCurrentProject}
onImportImage={() => handleUploadRequest()}
onOpenPlugins={() => setPluginManagerOpen(true)}
onUndo={undoCanvas}
@@ -0,0 +1,53 @@
import { create } from "zustand";
export const CANVAS_SIDE_PANEL_MOTION_MS = 500;
export const CANVAS_SIDE_PANEL_MIN_WIDTH = 220;
export const CANVAS_SIDE_PANEL_MAX_WIDTH = 480;
export const CANVAS_SIDE_PANEL_DEFAULT_WIDTH = 280;
const WIDTH_KEY = "canvas-side-panel-width";
const OPEN_KEY = "canvas-side-panel-open";
function initialWidth() {
if (typeof window === "undefined") return CANVAS_SIDE_PANEL_DEFAULT_WIDTH;
const stored = Number(localStorage.getItem(WIDTH_KEY));
if (!stored) return CANVAS_SIDE_PANEL_DEFAULT_WIDTH;
return Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, stored));
}
function initialOpen() {
if (typeof window === "undefined") return true;
return localStorage.getItem(OPEN_KEY) !== "0";
}
type CanvasSidePanelStore = {
width: number;
panelOpen: boolean;
panelMounted: boolean;
panelClosing: boolean;
setWidth: (width: number) => void;
openPanel: () => void;
closePanel: () => void;
togglePanel: () => void;
};
export const useCanvasSidePanelStore = create<CanvasSidePanelStore>((set, get) => ({
width: initialWidth(),
panelOpen: initialOpen(),
panelMounted: initialOpen(),
panelClosing: false,
setWidth: (width) => set({ width }),
openPanel: () => {
if (typeof window !== "undefined") localStorage.setItem(OPEN_KEY, "1");
set({ panelOpen: true, panelMounted: true, panelClosing: false });
},
closePanel: () => {
if (!get().panelMounted || get().panelClosing) return;
if (typeof window !== "undefined") localStorage.setItem(OPEN_KEY, "0");
set({ panelOpen: false, panelClosing: true });
setTimeout(() => {
if (get().panelClosing) set({ panelMounted: false, panelClosing: false });
}, CANVAS_SIDE_PANEL_MOTION_MS);
},
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
}));