2f398ae0b3
- 제어모드 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 후속 노트
187 lines
7.5 KiB
TypeScript
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 };
|
|
}
|