feat: add four-direction quick-add controls to selected nodes
This commit is contained in:
@@ -104,3 +104,5 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
|
||||
左上角标题第一次点击选中,选中后再次点击进入编辑;回车或失焦保存,Esc 取消,F2 也可直接编辑。真实图谱改名先读取最新资料和版本,再携带 If-Match 更新名称,保留描述与内容;版本冲突时提示重试。演示模式保存到本地。保存后标题、侧栏列表及导出文件名同步更新。
|
||||
|
||||
左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。
|
||||
|
||||
选中节点后,上、下、左、右各显示一个加号。点击会在对应方向新增文字节点并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。
|
||||
|
||||
@@ -108,3 +108,12 @@
|
||||
- 右上现有尺寸和间距保持不变;左上与底部采用稍宽的 6px 横向间隙和横向内边距,保持 4px 竖向内边距与 32×32px 按钮。
|
||||
- 左上在标题前后增加与右上相同的分隔线,去掉标题额外外边距;三处分隔线改为共享样式。
|
||||
- 类型检查和差异检查通过;本次 CSS 留白试调未重复生产构建或访问浏览器。
|
||||
|
||||
## 选中节点四向加号(2026-11-06)
|
||||
|
||||
- 节点 selected 时显示上/下/左/右四个加号,未选中时隐藏;加号使用 nodrag/nopan 并阻止指针事件传播,避免触发节点或画布拖动。
|
||||
- 点击按实际测量的节点尺寸在对应方向新增文字节点并自动连线,重复添加避开已有节点;选择与视图跟随新节点,添加及连线组成一步撤销。
|
||||
- 四侧使用命名 Handle 与 Loose 连接模式;保留旧无名连接点用于已有连线,导入导出校验并保留新的 sourceHandle/targetHandle。
|
||||
- 类型检查与 18 项自动测试通过,新增四向位置、连接方向持久化、一步撤销和重复添加避让测试。
|
||||
- 按用户既定安排不访问浏览器,本次未进行视觉和实际点击验收。
|
||||
- 隔离 Webpack 生产构建与完整 API/路由/品牌资源冒烟回归均通过。
|
||||
|
||||
@@ -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%); }
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user