mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(canvas): 左侧面板可调宽收起 + 元素多选导出与跳转动画
- 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),状态与宽度持久化 - 顶栏菜单左侧新增面板开关按钮,并缩小菜单/开关按钮 - 顶栏菜单新增「导出当前画布」为压缩包 - 画布元素列表支持多选并批量导出:图片/视频/音频为文件、文本为 txt、其余各自为 json - 画布/资产切换改为滑动下划线动画,点击元素跳转带缓动动画 - 移除文本/配置/视频等非图片元素图标的灰色底色,并写入 AGENTS.md 规范 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@
|
||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
||||
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
|
||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||
|
||||
|
||||
+6
-2
@@ -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` 后验证创建、渲染、编辑及与上游节点的交互。
|
||||
|
||||
@@ -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 —— 按类型折叠分组 + 标签筛选,点击插入画布
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}));
|
||||
Reference in New Issue
Block a user