feat: add four-direction quick-add controls to selected nodes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user