185 lines
18 KiB
TypeScript
185 lines
18 KiB
TypeScript
/**
|
|
* 画布连线显示与贝塞尔链编辑。根据端点生成路径,叠加箭头、选中轮廓和动画,选中时提供曲线点及控制柄。
|
|
* 指针拖动通过租约预览后提交;旧接口保留transient patch兼容路径。组件不自行同步服务端或管理撤销栈。
|
|
*/
|
|
"use client";
|
|
|
|
import { useContext, useEffect, useRef, useState, type PointerEvent } from "react";
|
|
import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
|
|
import { useReducedMotion } from "motion/react";
|
|
import type { ProjectionNode } from "@/lib/canvas-bindings/projection";
|
|
import type { CanvasNode } from "@/lib/canvas";
|
|
import {compatConnector} from "@/lib/canvas-bindings/connectors";
|
|
import { connectorBezier, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
|
|
import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves";
|
|
import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2";
|
|
import type { EditingLease } from "./editing-lease";
|
|
import { EdgeEditing } from "./edge-editing";
|
|
import { EdgeToolbarControls } from "./edge-toolbar";
|
|
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
|
|
import styles from "./canvas.module.css";
|
|
|
|
/** 旧节点用绝对位置替换局部position后,交compatConnector计算旋转/缩放等兼容端点。 */
|
|
function connector(node:InternalNode<CanvasNode>,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);}
|
|
/** 新投影已经给出端点坐标,只需按连接边补朝外法向;避免再应用一遍旧节点几何变换。 */
|
|
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
|
|
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
|
|
return {point:{x,y},normal:normals[side]};
|
|
}
|
|
/** 生成可复用SVG端部标记;none不绘制,开放箭头不填充,其余按filled使用线色或画布底色。
|
|
* userSpaceOnUse使大小不随线宽缩放,auto-start-reverse支持起终两端朝向。 */
|
|
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
|
const type = value?.type || "none";
|
|
if (type === "none") return null;
|
|
return <marker id={id} viewBox="0 0 12 12" markerWidth={value?.size || 12} markerHeight={value?.size || 12} refX={11} refY={6} orient="auto-start-reverse" markerUnits="userSpaceOnUse">
|
|
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
|
|
</marker>;
|
|
}
|
|
/** 读取两端内部节点、视口与编辑上下文;selectedKnot仅是当前控件选择,不进入连线数据。 */
|
|
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
|
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
|
|
const editing = useContext(EdgeEditing);
|
|
const flow = useReactFlow();
|
|
const { zoom } = useViewport();
|
|
const reduce = useReducedMotion();
|
|
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
|
|
// 手势记录控制点id/part与捕获目标;事件监听通过AbortController统一拆除,lease可缺省以兼容旧编辑器。
|
|
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
|
// 原生监听通过ref使用最新move/stop/cancel,避免投影更新后继续用过期path或端点闭包。
|
|
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
|
|
// 只挂载一次的失焦/卸载监听经ref调用最新cancel,释放未完成手势。
|
|
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
|
|
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
|
// 任一端点节点未就绪就不绘制;新版端点使用ReactFlow计算结果,旧版由兼容几何计算。
|
|
if (!source || !target) return null;
|
|
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left");
|
|
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
|
|
// 贝塞尔链生成可绘制path;工具栏锚点取中间一段的t=0.5,属于近似中心而非整条曲线的弧长中点。
|
|
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
|
|
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
|
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
|
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
|
|
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
|
const knots = data.path?.knots || [];
|
|
/** 最多64个中间曲线点;insertCurveKnot按给定点或默认位置拆分曲线,提交后选择新点。 */
|
|
function add(point?: { x: number; y: number }) {
|
|
if (knots.length >= 64) return;
|
|
const next = insertCurveKnot(from, to, data.path, point);
|
|
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
|
}
|
|
/** 仅未处理的主指针左键可开始。已有手势不被重复接管,另一指针到来先取消原手势。
|
|
* 捕获优先放在稳定的画布surface上,避免投影替换SVG控制点后丢掉后续事件。 */
|
|
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
|
|
if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return;
|
|
// The SVG control can be replaced by a projection update. The view surface survives it.
|
|
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
|
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
|
|
const lease = editing.begin(props.id); if (lease === false) return;
|
|
const controller = new AbortController();
|
|
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
|
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
|
|
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
|
|
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
|
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
|
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
|
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
|
|
}
|
|
/** 屏幕点转画布点,并克隆当前path后更新选中部分;只接受当前捕获指针的事件。 */
|
|
function move(event: PointerEvent<SVGCircleElement>) {
|
|
const current = gesture.current;
|
|
if(!current)return;event.stopPropagation();
|
|
if (!ownsPointerCapture(current,event)) return;
|
|
if(!current)return;
|
|
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
|
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
|
|
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
|
else {
|
|
const knot = next.knots.find((knot) => knot.id === current.id);
|
|
if (!knot) return;
|
|
if (current.part === "position") knot.position = point;
|
|
else {
|
|
// corner两柄独立;smooth保持另一柄长度且方向共线反向;symmetric两柄长度也相同。
|
|
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
|
|
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
|
|
knot[current.part] = vector;
|
|
// Shift把该曲线点模式改为symmetric,这个mode会随本次path修改一起提交。
|
|
if (event.shiftKey) knot.mode = "symmetric";
|
|
if (knot.mode !== "corner") {
|
|
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
|
|
knot[other] = { x: -vector.x * scale, y: -vector.y * scale };
|
|
}
|
|
}
|
|
}
|
|
// 优先本次租约的preview;旧版以transient=true派发path修改,结束时另行finish/cancel。
|
|
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
|
|
}
|
|
/** 抬起前再处理一次最后位置,随后清引用、拆监听、提交并释放捕获,避免释放事件重复结束。 */
|
|
function stop(event: PointerEvent<SVGCircleElement>) {
|
|
if(!gesture.current)return;event.stopPropagation();
|
|
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
|
|
if (current.lease) current.lease.commit(); else editing.finish?.();
|
|
releasePointerCapture(current.capture,current.pointerId);
|
|
}
|
|
/** 事件取消需匹配捕获;无参数用于失焦/卸载。先清引用和监听,再取消预览并释放捕获。 */
|
|
function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
|
|
if(event&&gesture.current)event.stopPropagation?.();
|
|
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
|
const current = gesture.current; gesture.current = null;
|
|
if (!current) return;
|
|
current.controller.abort();
|
|
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
|
releasePointerCapture(current.capture,current.pointerId);
|
|
}
|
|
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
|
|
pointerHandlers.current = { move, stop, cancel };
|
|
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
|
|
* 方向键一步1单位、Shift为10单位;中间in/out柄的键盘分支直接返回,不修改曲线。 */
|
|
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
|
|
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
|
|
onKeyDown={(event) => {
|
|
event.stopPropagation(); const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
|
if (delta) { event.preventDefault(); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); const vector = { x: delta[0] * (event.shiftKey ? 10 : 1), y: delta[1] * (event.shiftKey ? 10 : 1) };
|
|
if (part === "position") { const knot = next.knots.find((knot) => knot.id === id); if (knot) knot.position = plus(knot.position, vector); }
|
|
else if (part === "startControl" || part === "endControl") next[part] = plus(minus(point, part === "startControl" ? from.point : to.point), vector);
|
|
else return;
|
|
editing.patch(props.id, { ...data, path: next });
|
|
}
|
|
}} />;
|
|
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
|
|
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
|
|
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
|
|
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
|
|
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
|
|
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
|
|
<BaseEdge id={props.id} path={path} style={{ stroke: color, strokeWidth: width, opacity: appearance.opacity ?? 1, strokeDasharray: appearance.line === "dashed" ? "8 5" : appearance.line === "dotted" ? "1 5" : undefined, strokeLinecap: "round" }} markerStart={appearance.tail?.type && appearance.tail.type !== "none" ? `url(#${markerId}-tail)` : undefined} markerEnd={appearance.head?.type && appearance.head.type !== "none" ? `url(#${markerId}-head)` : undefined} interactionWidth={Math.max(20 / zoom, width + 8)} />
|
|
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
|
|
{props.selected && <>
|
|
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
|
|
// 工具栏模式应用于选中knot,找不到时退到最后一点;平滑/镜像优先以out柄为方向基准重算in柄。
|
|
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
|
|
knot.mode = mode; if (mode !== "corner") {
|
|
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
|
|
const length = Math.hypot(knot.out.x, knot.out.y), scale = mode === "symmetric" ? 1 : length ? Math.hypot(knot.in.x, knot.in.y) / length : 0;
|
|
knot.in = { x: -knot.out.x * scale, y: -knot.out.y * scale };
|
|
}
|
|
editing.patch(props.id, { ...data, path: next });
|
|
}} add={() => add()} remove={() => { const id = selectedKnot || knots.at(-1)?.id; editing.patch(props.id, { ...data, path: { ...data.path!, knots: knots.filter((knot) => knot.id !== id) } }); setSelectedKnot(null); }} />
|
|
{knots.map((knot, index) => <g key={knot.id} onDoubleClick={(event) => event.stopPropagation()}>
|
|
{control(knot.position, knot.id, "position", `曲线点 ${index + 1}`)}
|
|
{selectedKnot === knot.id && <><path d={`M${plus(knot.position, knot.in).x},${plus(knot.position, knot.in).y}L${knot.position.x},${knot.position.y}L${plus(knot.position, knot.out).x},${plus(knot.position, knot.out).y}`} fill="none" stroke="#a47764" strokeWidth={1 / zoom} pointerEvents="none" />{control(plus(knot.position, knot.in), knot.id, "in", "进入控制柄")}{control(plus(knot.position, knot.out), knot.id, "out", "离开控制柄")}</>}
|
|
</g>)}
|
|
{control(segments[0][1], "start", "startControl", "起点控制柄")}
|
|
{control(segments.at(-1)![2], "end", "endControl", "终点控制柄")}
|
|
</>}
|
|
</g>;
|
|
}
|
|
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
|
|
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
|
|
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
|
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
|
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
|
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
|
|
}
|