feat: add four-direction quick-add controls to selected nodes

This commit is contained in:
2026-11-06 01:47:27 +08:00
parent fc28632aba
commit e28ddb673f
7 changed files with 121 additions and 11 deletions
+2
View File
@@ -104,3 +104,5 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
左上角标题第一次点击选中,选中后再次点击进入编辑;回车或失焦保存,Esc 取消,F2 也可直接编辑。真实图谱改名先读取最新资料和版本,再携带 If-Match 更新名称,保留描述与内容;版本冲突时提示重试。演示模式保存到本地。保存后标题、侧栏列表及导出文件名同步更新。
左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。
选中节点后,上、下、左、右各显示一个加号。点击会在对应方向新增文字节点并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。
+9
View File
@@ -108,3 +108,12 @@
- 右上现有尺寸和间距保持不变;左上与底部采用稍宽的 6px 横向间隙和横向内边距,保持 4px 竖向内边距与 32×32px 按钮。
- 左上在标题前后增加与右上相同的分隔线,去掉标题额外外边距;三处分隔线改为共享样式。
- 类型检查和差异检查通过;本次 CSS 留白试调未重复生产构建或访问浏览器。
## 选中节点四向加号(2026-11-06)
- 节点 selected 时显示上/下/左/右四个加号,未选中时隐藏;加号使用 nodrag/nopan 并阻止指针事件传播,避免触发节点或画布拖动。
- 点击按实际测量的节点尺寸在对应方向新增文字节点并自动连线,重复添加避开已有节点;选择与视图跟随新节点,添加及连线组成一步撤销。
- 四侧使用命名 Handle 与 Loose 连接模式;保留旧无名连接点用于已有连线,导入导出校验并保留新的 sourceHandle/targetHandle。
- 类型检查与 18 项自动测试通过,新增四向位置、连接方向持久化、一步撤销和重复添加避让测试。
- 按用户既定安排不访问浏览器,本次未进行视觉和实际点击验收。
- 隔离 Webpack 生产构建与完整 API/路由/品牌资源冒烟回归均通过。
+8
View File
@@ -30,3 +30,11 @@
.handMode .node textarea, .handMode .node img { pointer-events: none; }
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
.branchButton { position: absolute; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: #fffefb; color: #78523f; box-shadow: 0 2px 6px #32251a0a; cursor: pointer; z-index: 5; }
.branchButton:hover { background: #f1e6df; border-color: #a47764; }
.branch_top { top: -48px; left: 50%; transform: translateX(-50%); }
.branch_right { right: -48px; top: 50%; transform: translateY(-50%); }
.branch_bottom { bottom: -48px; left: 50%; transform: translateX(-50%); }
.branch_left { left: -48px; top: 50%; transform: translateY(-50%); }
+12 -4
View File
@@ -2,14 +2,14 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
import {
Background, BackgroundVariant, ReactFlow, ReactFlowProvider, SelectionMode,
Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
addEdge, useReactFlow, useViewport, type Connection,
} from "@xyflow/react";
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react";
import { useReducedMotion } from "motion/react";
import toolbar from "@/components/ui/toolbar.module.css";
import { IconButton } from "@/components/ui/icon-button";
import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas";
import type { Board } from "@/lib/workspace";
import { MaterialNode, NodeEditing } from "./material-node";
import styles from "./canvas.module.css";
@@ -63,7 +63,15 @@ function CanvasEditor({ board }: { board: Board }) {
const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
const editing = useMemo(() => ({ begin: beginEdit, edit }), [beginEdit, edit]);
const branch = useCallback((id: string, direction: NodeDirection) => {
try {
const document = branchCanvasNode(state.present, id, direction);
dispatch({ type: "replace", document });
const child = document.nodes[document.nodes.length - 1];
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
}, [state.present, flow, duration]);
const editing = useMemo(() => ({ begin: beginEdit, edit, branch }), [beginEdit, edit, branch]);
const center = () => {
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
@@ -156,7 +164,7 @@ function CanvasEditor({ board }: { board: Board }) {
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes}
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
onNodeDragStart={beginEdit} onConnect={connect}
onNodeDragStart={beginEdit} onConnect={connect} connectionMode={ConnectionMode.Loose}
onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })}
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
+17 -5
View File
@@ -2,11 +2,14 @@
import { createContext, useContext } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import type { CanvasNode } from "@/lib/canvas";
import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react";
import { nodeDirections, type CanvasNode, type NodeDirection } from "@/lib/canvas";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {} });
export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {}, branch: (_id: string, _direction: NodeDirection) => {} });
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
const editing = useContext(NodeEditing);
@@ -14,12 +17,21 @@ export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
return (
<article className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
<Handle type="target" position={Position.Left} />
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <img className={styles.image} src={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
<textarea className="nodrag nowheel" aria-label={data.title} placeholder={data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onFocus={editing.begin} onChange={(event) => editing.edit(id, event.target.value)} />
)}
<Handle type="source" position={Position.Right} />
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]} />)}
{selected && nodeDirections.map((direction) => (
<button key={direction} type="button" className={`${styles.branchButton} ${styles["branch_" + direction]} nodrag nopan`}
aria-label={`在${labels[direction]}添加节点`} title={`在${labels[direction]}添加节点并连线`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); editing.branch(id, direction); }}>
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
</button>
))}
</article>
);
}
+36 -1
View File
@@ -7,6 +7,8 @@ export type CanvasNode = Node<{
text: string;
src?: string;
}, "material">;
export type NodeDirection = "top" | "right" | "bottom" | "left";
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Edge[]; viewport: Viewport };
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
export type CanvasAction =
@@ -66,6 +68,34 @@ export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPos
data: { kind, title: kind === "image" ? "视觉参考" : kind === "script" ? "脚本" : "灵感笔记", text: "", ...(src ? { src } : {}) },
};
}
export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection): CanvasDocument {
const source = document.nodes.find((node) => node.id === id);
if (!source) throw new Error("节点不存在。");
if (document.nodes.length >= 500) throw new Error("当前图谱已达到 500 个节点。");
const width = source.measured?.width || 260;
const height = source.measured?.height || 200;
const gap = 80;
const position = {
x: direction === "right" ? source.position.x + width + gap : direction === "left" ? source.position.x - 260 - gap : source.position.x + (width - 260) / 2,
y: direction === "bottom" ? source.position.y + height + gap : direction === "top" ? source.position.y - 200 - gap : source.position.y + (height - 200) / 2,
};
// Keep repeated additions on the same side from covering one another.
for (let attempts = 0; attempts < 500; attempts++) {
const overlap = document.nodes.some((node) => position.x < node.position.x + (node.measured?.width || 260) + 12 &&
position.x + 260 + 12 > node.position.x && position.y < node.position.y + (node.measured?.height || 200) + 12 && position.y + 200 + 12 > node.position.y);
if (!overlap) break;
if (direction === "left" || direction === "right") position.y += 240;
else position.x += 300;
}
const child = { ...makeCanvasNode("text", position), selected: true };
const opposite: Record<NodeDirection, NodeDirection> = { top: "bottom", bottom: "top", left: "right", right: "left" };
return {
...document,
nodes: [...document.nodes.map((node) => ({ ...node, selected: false })), child],
edges: [...document.edges, { id: crypto.randomUUID(), source: id, target: child.id, sourceHandle: direction, targetHandle: opposite[direction] }],
};
}
function validImage(src: unknown): src is string {
if (typeof src !== "string") return false;
if (/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(src)) return src.length < 3_000_000;
@@ -93,8 +123,13 @@ export function decodeCanvas(raw: string): CanvasDocument {
const edges: Edge[] = value.edges.map((edge: Edge) => {
if (!edge || typeof edge.id !== "string" || !/^[\w-]{1,128}$/.test(edge.id) || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target))
throw new Error("图谱连线格式无效。");
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
throw new Error("图谱连接方向无效。");
edgeIds.add(edge.id);
return { id: edge.id, source: edge.source, target: edge.target };
return { id: edge.id, source: edge.source, target: edge.target,
...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
};
});
const viewport = value.viewport;
if (!viewport || ![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom < 0.1 || viewport.zoom > 4)
+37 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
function seeded() {
const document = emptyCanvas();
@@ -87,3 +87,39 @@ test("undo history is bounded and new edits clear the redo stack", () => {
state = canvasReducer(state, { type: "nodes", changes: [{ type: "add", item: makeCanvasNode("text", { x: 0, y: 0 }) }] });
assert.equal(state.future.length, 0);
});
test("selected nodes can branch in all four directions with matching connection handles", () => {
const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" };
for (const direction of Object.keys(opposite)) {
const document = emptyCanvas();
const parent = makeCanvasNode("text", { x: 100, y: 100 });
document.nodes = [parent];
const branched = branchCanvasNode(document, parent.id, direction);
const child = branched.nodes[1];
if (direction === "top") assert(child.position.y < parent.position.y);
if (direction === "bottom") assert(child.position.y > parent.position.y);
if (direction === "left") assert(child.position.x < parent.position.x);
if (direction === "right") assert(child.position.x > parent.position.x);
assert.equal(child.selected, true);
assert.equal(branched.nodes[0].selected, false);
assert.equal(branched.edges[0].source, parent.id);
assert.equal(branched.edges[0].target, child.id);
assert.equal(branched.edges[0].sourceHandle, direction);
assert.equal(branched.edges[0].targetHandle, opposite[direction]);
assert.deepEqual(decodeCanvas(JSON.stringify(branched)).edges, branched.edges);
const history = canvasReducer({ past: [], present: document, future: [] }, { type: "replace", document: branched });
assert.equal(canvasReducer(history, { type: "undo" }).present.nodes.length, 1);
assert.equal(canvasReducer(history, { type: "undo" }).present.edges.length, 0);
}
});
test("repeated additions on the same side avoid existing nodes", () => {
const document = emptyCanvas();
const parent = makeCanvasNode("text", { x: 0, y: 0 });
document.nodes = [parent];
const first = branchCanvasNode(document, parent.id, "right");
const second = branchCanvasNode(first, parent.id, "right");
assert.notDeepEqual(first.nodes[1].position, second.nodes[2].position);
assert(second.nodes[2].position.y >= first.nodes[1].position.y + 200);
});