diff --git a/AGENTS.md b/AGENTS.md index b2f51e9..09eb7be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ - 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。 - 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。 - 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。 +- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。 - 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。 - 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd93cc..050638d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ ## Unreleased ++ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。 ++ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。 ++ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。 ++ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。 + [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。 -+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图,不再是「图片1」文字编号。 ++ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。 + [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。 -+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃(React #185 最大更新深度超限);Agent 面板改为细粒度订阅,画布拖拽/缩放时不再随每帧重渲染,交互更流畅。 ++ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。 ## v0.8.2 - 2026-07-16 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 94f5698..8b479f2 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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` 后验证创建、渲染、编辑及与上游节点的交互。 diff --git a/web/src/components/canvas/canvas-side-panel.tsx b/web/src/components/canvas/canvas-side-panel.tsx index 215f01b..6817b2c 100644 --- a/web/src/components/canvas/canvas-side-panel.tsx +++ b/web/src/components/canvas/canvas-side-panel.tsx @@ -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 = { export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [tab, setTab] = useState("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) => { + 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 ( - + + +
+ setTab("canvas")} /> + setTab("assets")} /> +
+
{tab === "canvas" ? : }
+ ); } @@ -79,20 +135,64 @@ function nodePreviewText(node: CanvasNodeData) { } function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) { + const { message } = App.useApp(); const [keyword, setKeyword] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); + const [selectMode, setSelectMode] = useState(false); + const [checked, setChecked] = useState>(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 (
画布元素 {filtered.length ? {filtered.length} : null} - }
} 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 (
+ {selectMode ? ( +
+ + 已选 {checked.size} + +
+ ) : null}
); } +function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) { + return ( + + {checked ? : null} + + ); +} + // --------------------------------------------------------------------------- // 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布 // --------------------------------------------------------------------------- diff --git a/web/src/components/canvas/canvas-top-bar.tsx b/web/src/components/canvas/canvas-top-bar.tsx index 50b2162..e171ba3 100644 --- a/web/src/components/canvas/canvas-top-bar.tsx +++ b/web/src/components/canvas/canvas-top-bar.tsx @@ -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(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 ( <> -
-
+
+
+ + + , label: "删除当前画布", onClick: onDeleteProject }, { type: "divider" }, { key: "import", icon: , label: "导入资产", onClick: onImportImage }, + { key: "export", icon: , label: "导出当前画布", onClick: onExportProject }, { type: "divider" }, { key: "undo", disabled: !canUndo, icon: , label: , onClick: onUndo }, { key: "redo", disabled: !canRedo, icon: , label: , onClick: onRedo }, ], }} > - diff --git a/web/src/lib/canvas/canvas-export.ts b/web/src/lib/canvas/canvas-export.ts index f4c41a5..d481192 100644 --- a/web/src/lib/canvas/canvas-export.ts +++ b/web/src/lib/canvas/canvas-export.ts @@ -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(); + 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()) { 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"; } diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index ba2f25f..97963e5 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -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(null); const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise) | 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) => { setContextMenu(null); @@ -2689,6 +2722,7 @@ function InfiniteCanvasPage() { onProjects={() => navigate("/canvas")} onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} + onExportProject={exportCurrentProject} onImportImage={() => handleUploadRequest()} onOpenPlugins={() => setPluginManagerOpen(true)} onUndo={undoCanvas} diff --git a/web/src/stores/use-canvas-side-panel-store.ts b/web/src/stores/use-canvas-side-panel-store.ts new file mode 100644 index 0000000..1bb9399 --- /dev/null +++ b/web/src/stores/use-canvas-side-panel-store.ts @@ -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((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()), +}));