Files
invyone/frontend/components/control/hooks/usePortDrag.ts
T
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

187 lines
7.5 KiB
TypeScript

import { useRef, useCallback, useEffect } from 'react';
import { useControlMode, genConnId } from './useControlMode';
import { bezierPath } from '../ConnectionLine';
/**
* 포트 연결 드래그 로직 (mockup startPortDrag/finishPortDrag 포팅)
* output 포트 mousedown → 임시 선 → input 포트 mouseup → 연결 생성
*/
export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
const { addRuleConnection, ruleConnections } = useControlMode();
const dragRef = useRef<{
fromNodeId: string;
fromPort: string;
line: SVGPathElement;
x1: number;
y1: number;
} | null>(null);
const startDrag = useCallback((nodeId: string, port: string, e: React.MouseEvent) => {
const cv = canvasRef.current;
if (!cv) return;
// SVG 확보
let svg = cv.querySelector('#rule-svg') as SVGSVGElement | null;
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'rule-svg';
svg.classList.add('ctrl-svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.overflow = 'visible';
cv.appendChild(svg);
}
const cr = cv.getBoundingClientRect();
const portEl = (e.target as HTMLElement).closest('.ctrl-io-port') as HTMLElement;
if (!portEl) return;
const pr = portEl.getBoundingClientRect();
const x1 = pr.left + pr.width / 2 - cr.left + cv.scrollLeft;
const y1 = pr.top + pr.height / 2 - cr.top + cv.scrollTop;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.classList.add('rule-temp-line');
line.setAttribute('d', `M${x1},${y1} L${x1},${y1}`);
svg.appendChild(line);
dragRef.current = { fromNodeId: nodeId, fromPort: port, line, x1, y1 };
cv.classList.add('port-dragging');
portEl.classList.add('port-active');
}, [canvasRef]);
const finishDrag = useCallback((toNodeId: string, toPort: string) => {
const d = dragRef.current;
if (!d) return;
// 같은 노드 연결 방지
if (d.fromNodeId === toNodeId) {
cleanup();
return;
}
// ★ [HIGH] port direction validation — output → output 역방향 엣지 차단
// from_port 는 in/out/yes/no/pass/fail/approved/rejected 등 (output port 만 허용)
// to_port 는 in 만 허용 (input port 도착점)
// 단 테이블 port 는 양방향 (in/out 둘 다 가능, PortHandle 단일 dot 양방향화)
// → 노드 type 으로 분기
const stateForValidate = useControlMode.getState();
const fromNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === d.fromNodeId);
const toNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === toNodeId);
// 도착이 action 노드면 to_port 는 'in' 이어야 함 (action 노드는 좌측 in 만 mouseup 받음)
if (toNodeForVal && toNodeForVal.type !== 'table' && toPort !== 'in') {
cleanup();
return;
}
// 출발이 action 노드면 from_port 는 in 이 아니어야 함 (action 노드의 in 에서 시작은 의미 없음)
if (fromNodeForVal && fromNodeForVal.type !== 'table' && d.fromPort === 'in') {
cleanup();
return;
}
// 중복 방지 — getState() 로 최신 ruleConnections 사용 (render-captured stale 회피)
const currentConns = stateForValidate.ruleConnections;
if (currentConns.find((c) =>
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
)) {
cleanup();
return;
}
// Phase 3: edge_type 자동 추론 (위 validation 에서 가져온 노드 재사용)
// table → table = lookup (FK 참조)
// table → action = data-context (테이블 데이터를 노드 입력으로)
// action → table = table-mutation (노드 결과를 테이블에 저장/수정)
// action → action = execution-flow (실행 순서)
const fromIsTable = fromNodeForVal?.type === 'table';
const toIsTable = toNodeForVal?.type === 'table';
let edgeType: 'data-context' | 'execution-flow' | 'table-mutation' | 'lookup';
if (fromIsTable && toIsTable) edgeType = 'lookup';
else if (fromIsTable && !toIsTable) edgeType = 'data-context';
else if (!fromIsTable && toIsTable) edgeType = 'table-mutation';
else edgeType = 'execution-flow';
addRuleConnection({
id: genConnId(),
from_node_id: d.fromNodeId,
from_port: d.fromPort,
to_node_id: toNodeId,
to_port: toPort,
edge_type: edgeType,
});
cleanup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [addRuleConnection]);
const cleanup = useCallback(() => {
const d = dragRef.current;
if (!d) return;
d.line.remove();
canvasRef.current?.classList.remove('port-dragging');
document.querySelectorAll('.port-active').forEach((el) => el.classList.remove('port-active'));
document.querySelectorAll('.port-hover').forEach((el) => el.classList.remove('port-hover'));
dragRef.current = null;
}, [canvasRef]);
// 마우스 이동/종료 전역 핸들러
// ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출
// (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패)
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const cv = canvasRef.current;
if (!cv) return;
const cr = cv.getBoundingClientRect();
const x2 = e.clientX - cr.left + cv.scrollLeft;
const y2 = e.clientY - cr.top + cv.scrollTop;
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2));
// 호버 중인 port 강조
document.querySelectorAll('.ctrl-io-port.port-hover').forEach((el) => el.classList.remove('port-hover'));
const hoverPort = (e.target as HTMLElement)?.closest?.('.ctrl-io-port') as HTMLElement | null;
if (hoverPort && hoverPort.dataset.node !== d.fromNodeId) {
hoverPort.classList.add('port-hover');
}
};
const onUp = (e: MouseEvent) => {
if (!dragRef.current) return;
// ① e.target 의 closest 로 port 찾기 (정확히 port 위에서 mouseup 한 경우)
let portEl = (e.target as HTMLElement | null)?.closest?.('.ctrl-io-port') as HTMLElement | null;
// ② 못 찾으면 마우스 좌표 주변 20px 내 가장 가까운 port 검색 (port 근처에서 mouseup)
if (!portEl) {
const candidates = document.querySelectorAll<HTMLElement>('.ctrl-io-port');
let best: { el: HTMLElement; dist: number } | null = null;
candidates.forEach((el) => {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const dx = e.clientX - cx, dy = e.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 24 && (!best || dist < best.dist)) {
best = { el, dist };
}
});
if (best) portEl = (best as { el: HTMLElement; dist: number }).el;
}
if (portEl) {
const toNodeId = portEl.dataset.node;
const toPort = portEl.dataset.port;
if (toNodeId && toPort) {
finishDrag(toNodeId, toPort);
return;
}
}
cleanup();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [canvasRef, cleanup, finishDrag]);
return { startDrag, finishDrag };
}