feat(canvas): add double-click functionality to create nodes from blank canvas area

This commit is contained in:
HouYunFei
2026-07-13 10:43:37 +08:00
parent ebd8ae2de7
commit 301fbce472
4 changed files with 63 additions and 9 deletions
+8 -7
View File
@@ -2,15 +2,16 @@
## Unreleased ## Unreleased
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗,直接运行 Agent 不受影响 + [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。 + [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升长回复与 Markdown 内容展示效果。 + [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具,支持读取配置、触发生成与新增素材 + [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。 + [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
+ [新增] Agent 新增 `site_navigate` 工具,支持从对话中直接跳转首页、画布、工作台、素材和配置等页面。 + [新增] Agent 新增 `site_navigate` 工具,支持页面跳转
+ [新增] Agent 对话运行中支持一键停止,发送按钮切换为红色停止按钮并中断当前 Codex turn。 + [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
+ [调整] Codex 会话改为站点级连续线程,不再按画布 ID 分隔,跨页面和跨画布保持同一上下文。 + [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
+ [调整] 移除仅前端调用 OpenAI responses 接口的在线画布助手旧逻辑,统一走 MCP + 本地 Codex 链路。 + [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。 + [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
+ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。 + [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。 + [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试 # 待测试
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。 - 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。 - 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。 - 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
@@ -11,12 +11,13 @@ type InfiniteCanvasProps = {
onViewportChange: (viewport: ViewportTransform) => void; onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void; onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
onCanvasDeselect?: () => void; onCanvasDeselect?: () => void;
onCanvasDoubleClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
onContextMenu?: (event: React.MouseEvent) => void; onContextMenu?: (event: React.MouseEvent) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void; onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
children: React.ReactNode; children: React.ReactNode;
}; };
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) { export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({ const panState = useRef({
isPanning: false, isPanning: false,
@@ -116,6 +117,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
} }
}; };
const handleDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],[data-node-id],[data-connection-id]")) return;
onCanvasDoubleClick?.(event);
};
useEffect(() => { useEffect(() => {
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (!panState.current.isPanning) return; if (!panState.current.isPanning) return;
@@ -171,6 +178,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
className="relative h-full w-full cursor-grab select-none overflow-hidden" className="relative h-full w-full cursor-grab select-none overflow-hidden"
style={{ background: theme.canvas.background }} style={{ background: theme.canvas.background }}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onDoubleClick={handleDoubleClick}
onWheel={handleWheel} onWheel={handleWheel}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
+45 -1
View File
@@ -1,7 +1,7 @@
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, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react"; import { BookOpen, Bot, Group, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video, X } from "lucide-react";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
@@ -212,6 +212,33 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: {
); );
} }
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: CanvasNodeType) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
data-canvas-no-zoom
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}></span>
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
<X className="size-4" />
</button>
</div>
<div className="grid gap-1">
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频" onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="生成配置" onClick={() => onCreate(CanvasNodeType.Config)} />
<ConnectionCreateOption theme={theme} icon={<Group className="size-5" />} title="组" onClick={() => onCreate(CanvasNodeType.Group)} />
</div>
</div>
);
}
function InfiniteCanvasPage() { function InfiniteCanvasPage() {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -281,6 +308,7 @@ function InfiniteCanvasPage() {
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 }); const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null); const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [nodeCreatePosition, setNodeCreatePosition] = useState<Position | null>(null);
const [runningNodeId, setRunningNodeId] = useState<string | null>(null); const [runningNodeId, setRunningNodeId] = useState<string | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines"); const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
@@ -1033,6 +1061,7 @@ function InfiniteCanvasPage() {
const handleCanvasMouseDown = useCallback( const handleCanvasMouseDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => { (event: ReactPointerEvent<HTMLDivElement>) => {
setContextMenu(null); setContextMenu(null);
setNodeCreatePosition(null);
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
if (event.button !== 0) return; if (event.button !== 0) return;
@@ -1433,6 +1462,7 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(new Set()); setSelectedNodeIds(new Set());
setSelectedConnectionId(null); setSelectedConnectionId(null);
setContextMenu(null); setContextMenu(null);
setNodeCreatePosition(null);
setSelectionBox(null); setSelectionBox(null);
setConnecting(null); setConnecting(null);
setHoveredNodeId(null); setHoveredNodeId(null);
@@ -2505,6 +2535,10 @@ function InfiniteCanvasPage() {
}} }}
onCanvasMouseDown={handleCanvasMouseDown} onCanvasMouseDown={handleCanvasMouseDown}
onCanvasDeselect={deselectCanvas} onCanvasDeselect={deselectCanvas}
onCanvasDoubleClick={(event) => {
setContextMenu(null);
setNodeCreatePosition(screenToCanvas(event.clientX, event.clientY));
}}
onContextMenu={preventCanvasContextMenu} onContextMenu={preventCanvasContextMenu}
onDrop={handleDrop} onDrop={handleDrop}
> >
@@ -2643,6 +2677,16 @@ function InfiniteCanvasPage() {
/> />
) : null} ) : null}
{pendingConnectionCreate ? <ConnectionCreateMenu pending={pendingConnectionCreate} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null} {pendingConnectionCreate ? <ConnectionCreateMenu pending={pendingConnectionCreate} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
{nodeCreatePosition ? (
<NodeCreateMenu
position={nodeCreatePosition}
onCreate={(type) => {
createNode(type, nodeCreatePosition);
setNodeCreatePosition(null);
}}
onClose={() => setNodeCreatePosition(null)}
/>
) : null}
</InfiniteCanvas> </InfiniteCanvas>
<CanvasNodeHoverToolbar <CanvasNodeHoverToolbar