전략편집기 수정

This commit is contained in:
Macbook
2026-05-24 12:25:17 +09:00
parent ea39f7df27
commit 1465cb2255
10 changed files with 714 additions and 83 deletions
@@ -1,8 +1,30 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
import { edgeDisconnectRef } from './strategyEditorCallbacks';
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,
@@ -10,7 +32,12 @@ export function StrategyFlowEdge({
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,
@@ -18,15 +45,147 @@ export function StrategyFlowEdge({
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 }}
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>
@@ -39,8 +198,8 @@ export function StrategyFlowEdge({
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
onClick={(e) => {
e.stopPropagation();
onClick={(ev) => {
ev.stopPropagation();
edgeDisconnectRef.current?.(id);
}}
>