213 lines
5.7 KiB
TypeScript
213 lines
5.7 KiB
TypeScript
import React, { useCallback, useRef, useState } from 'react';
|
||
import {
|
||
BaseEdge,
|
||
EdgeLabelRenderer,
|
||
getSmoothStepPath,
|
||
useReactFlow,
|
||
type EdgeProps,
|
||
} from '@xyflow/react';
|
||
import { bindWindowDrag } from '../../utils/pointerDrag';
|
||
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
|
||
import {
|
||
buildPositionsFromNodes,
|
||
getEdgeDragZone,
|
||
pickSourceHandleAtPoint,
|
||
pickTargetHandleAtPoint,
|
||
type EdgeDragZone,
|
||
} from '../../utils/strategyEdgeInteraction';
|
||
import { edgeBindingUpdateRef, edgeDisconnectRef } from './strategyEditorCallbacks';
|
||
|
||
type StrategyEdgeData = {
|
||
binding?: EdgeHandleBinding;
|
||
};
|
||
|
||
export function StrategyFlowEdge({
|
||
id,
|
||
source,
|
||
target,
|
||
sourceX,
|
||
sourceY,
|
||
targetX,
|
||
targetY,
|
||
sourcePosition,
|
||
targetPosition,
|
||
selected,
|
||
data,
|
||
}: EdgeProps) {
|
||
const { screenToFlowPosition, getNodes } = useReactFlow();
|
||
const binding = (data as StrategyEdgeData | undefined)?.binding;
|
||
const hasCenter = binding?.manualCenter && binding.centerX != null && binding.centerY != null;
|
||
|
||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||
sourceX,
|
||
sourceY,
|
||
targetX,
|
||
targetY,
|
||
sourcePosition,
|
||
targetPosition,
|
||
...(hasCenter ? { centerX: binding!.centerX!, centerY: binding!.centerY! } : {}),
|
||
});
|
||
|
||
const dragRef = useRef<{
|
||
zone: EdgeDragZone;
|
||
startCenterX: number;
|
||
startCenterY: number;
|
||
} | null>(null);
|
||
const unbindRef = useRef<(() => void) | null>(null);
|
||
const [dragZone, setDragZone] = useState<EdgeDragZone | null>(null);
|
||
|
||
const commitBinding = useCallback((patch: EdgeHandleBinding) => {
|
||
edgeBindingUpdateRef.current?.(id, patch);
|
||
}, [id]);
|
||
|
||
const positionsFromFlow = useCallback(() => {
|
||
return buildPositionsFromNodes(getNodes());
|
||
}, [getNodes]);
|
||
|
||
const onHitPointerDown = useCallback((e: React.PointerEvent<SVGPathElement>) => {
|
||
if (e.button !== 0) return;
|
||
if ((e.target as Element).closest('.se-flow-edge-del')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const flow = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||
const zone = getEdgeDragZone(sourceX, sourceY, targetX, targetY, flow.x, flow.y);
|
||
setDragZone(zone);
|
||
|
||
try {
|
||
(e.currentTarget as SVGPathElement).setPointerCapture(e.pointerId);
|
||
} catch { /* ignore */ }
|
||
|
||
const positions = positionsFromFlow();
|
||
const startCenterX = binding?.centerX ?? labelX;
|
||
const startCenterY = binding?.centerY ?? labelY;
|
||
|
||
dragRef.current = { zone, startCenterX, startCenterY };
|
||
|
||
if (zone === 'source') {
|
||
commitBinding({
|
||
sourceHandle: pickSourceHandleAtPoint(source, flow, positions),
|
||
manualSource: true,
|
||
});
|
||
} else if (zone === 'target') {
|
||
commitBinding({
|
||
targetHandle: pickTargetHandleAtPoint(target, flow, positions),
|
||
manualTarget: true,
|
||
});
|
||
} else {
|
||
commitBinding({
|
||
centerX: flow.x,
|
||
centerY: flow.y,
|
||
manualCenter: true,
|
||
});
|
||
}
|
||
|
||
unbindRef.current?.();
|
||
unbindRef.current = bindWindowDrag(
|
||
({ x, y }) => {
|
||
const d = dragRef.current;
|
||
if (!d) return;
|
||
const pos = screenToFlowPosition({ x, y });
|
||
const positions = positionsFromFlow();
|
||
|
||
if (d.zone === 'source') {
|
||
commitBinding({
|
||
sourceHandle: pickSourceHandleAtPoint(source, pos, positions),
|
||
manualSource: true,
|
||
});
|
||
} else if (d.zone === 'target') {
|
||
commitBinding({
|
||
targetHandle: pickTargetHandleAtPoint(target, pos, positions),
|
||
manualTarget: true,
|
||
});
|
||
} else {
|
||
commitBinding({
|
||
centerX: pos.x,
|
||
centerY: pos.y,
|
||
manualCenter: true,
|
||
});
|
||
}
|
||
},
|
||
() => {
|
||
dragRef.current = null;
|
||
setDragZone(null);
|
||
unbindRef.current = null;
|
||
},
|
||
);
|
||
}, [
|
||
binding?.centerX,
|
||
binding?.centerY,
|
||
commitBinding,
|
||
labelX,
|
||
labelY,
|
||
positionsFromFlow,
|
||
screenToFlowPosition,
|
||
source,
|
||
sourceX,
|
||
sourceY,
|
||
target,
|
||
targetX,
|
||
targetY,
|
||
]);
|
||
|
||
const onHitPointerUp = useCallback((e: React.PointerEvent<SVGPathElement>) => {
|
||
dragRef.current = null;
|
||
setDragZone(null);
|
||
unbindRef.current?.();
|
||
unbindRef.current = null;
|
||
try {
|
||
(e.currentTarget as SVGPathElement).releasePointerCapture(e.pointerId);
|
||
} catch { /* ignore */ }
|
||
}, []);
|
||
|
||
const hitCursor = dragZone === 'source'
|
||
? 'grab'
|
||
: dragZone === 'target'
|
||
? 'grab'
|
||
: dragZone === 'middle'
|
||
? 'move'
|
||
: 'pointer';
|
||
|
||
return (
|
||
<>
|
||
<BaseEdge
|
||
id={id}
|
||
path={edgePath}
|
||
className={`se-flow-edge${selected ? ' se-flow-edge--selected' : ''}`}
|
||
style={{ strokeWidth: selected ? 3 : 2, pointerEvents: 'none' }}
|
||
/>
|
||
<path
|
||
d={edgePath}
|
||
fill="none"
|
||
stroke="transparent"
|
||
strokeWidth={22}
|
||
className={`se-flow-edge-hit${dragZone ? ` se-flow-edge-hit--${dragZone}` : ''}`}
|
||
style={{ cursor: hitCursor }}
|
||
onPointerDown={onHitPointerDown}
|
||
onPointerUp={onHitPointerUp}
|
||
onPointerCancel={onHitPointerUp}
|
||
/>
|
||
{selected && edgeDisconnectRef.current && (
|
||
<EdgeLabelRenderer>
|
||
<button
|
||
type="button"
|
||
className="se-flow-edge-del"
|
||
title="연결 끊기 (전략 코드에서 제외)"
|
||
style={{
|
||
position: 'absolute',
|
||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||
pointerEvents: 'all',
|
||
}}
|
||
onClick={(ev) => {
|
||
ev.stopPropagation();
|
||
edgeDisconnectRef.current?.(id);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</EdgeLabelRenderer>
|
||
)}
|
||
</>
|
||
);
|
||
}
|