mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat(canvas): add double-click functionality to create nodes from blank canvas area
This commit is contained in:
+8
-7
@@ -2,15 +2,16 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗,直接运行 Agent 不受影响。
|
||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
||||
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升长回复与 Markdown 内容展示效果。
|
||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具,支持读取配置、触发生成与新增素材。
|
||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
||||
+ [新增] Agent 新增 `site_navigate` 工具,支持从对话中直接跳转首页、画布、工作台、素材和配置等页面。
|
||||
+ [新增] Agent 对话运行中支持一键停止,发送按钮切换为红色停止按钮并中断当前 Codex turn。
|
||||
+ [调整] Codex 会话改为站点级连续线程,不再按画布 ID 分隔,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口的在线画布助手旧逻辑,统一走 MCP + 本地 Codex 链路。
|
||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||
+ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。
|
||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||
|
||||
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
|
||||
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
|
||||
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
|
||||
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
|
||||
|
||||
@@ -11,12 +11,13 @@ type InfiniteCanvasProps = {
|
||||
onViewportChange: (viewport: ViewportTransform) => void;
|
||||
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onCanvasDeselect?: () => void;
|
||||
onCanvasDoubleClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
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 panState = useRef({
|
||||
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(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
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"
|
||||
style={{ background: theme.canvas.background }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { 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 { 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() {
|
||||
const { message, modal } = App.useApp();
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -281,6 +308,7 @@ function InfiniteCanvasPage() {
|
||||
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [nodeCreatePosition, setNodeCreatePosition] = useState<Position | null>(null);
|
||||
const [runningNodeId, setRunningNodeId] = useState<string | null>(null);
|
||||
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
|
||||
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
|
||||
@@ -1033,6 +1061,7 @@ function InfiniteCanvasPage() {
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
setContextMenu(null);
|
||||
setNodeCreatePosition(null);
|
||||
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
|
||||
if (event.button !== 0) return;
|
||||
|
||||
@@ -1433,6 +1462,7 @@ function InfiniteCanvasPage() {
|
||||
setSelectedNodeIds(new Set());
|
||||
setSelectedConnectionId(null);
|
||||
setContextMenu(null);
|
||||
setNodeCreatePosition(null);
|
||||
setSelectionBox(null);
|
||||
setConnecting(null);
|
||||
setHoveredNodeId(null);
|
||||
@@ -2505,6 +2535,10 @@ function InfiniteCanvasPage() {
|
||||
}}
|
||||
onCanvasMouseDown={handleCanvasMouseDown}
|
||||
onCanvasDeselect={deselectCanvas}
|
||||
onCanvasDoubleClick={(event) => {
|
||||
setContextMenu(null);
|
||||
setNodeCreatePosition(screenToCanvas(event.clientX, event.clientY));
|
||||
}}
|
||||
onContextMenu={preventCanvasContextMenu}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
@@ -2643,6 +2677,16 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : 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>
|
||||
|
||||
<CanvasNodeHoverToolbar
|
||||
|
||||
Reference in New Issue
Block a user