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
+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}