301 lines
9.7 KiB
TypeScript
301 lines
9.7 KiB
TypeScript
import type { ReactFlowInstance } from '@xyflow/react';
|
|
import {
|
|
getActivePaletteDrag,
|
|
getLastHtmlPaletteDragClientXY,
|
|
isPaletteHtmlDragActive,
|
|
} from './paletteDragSession';
|
|
import { getNodeDimensions } from './strategyFlowLayout';
|
|
|
|
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
|
|
|
|
const DRAG_LAYER_SELECTOR = '.se-palette-drag-overlay, .se-palette-drag-ghost';
|
|
|
|
function getFlowProjectionHost(containerEl: HTMLElement | null): HTMLElement | null {
|
|
if (!containerEl) return null;
|
|
return containerEl.querySelector('.react-flow__viewport') as HTMLElement | null
|
|
?? containerEl.querySelector('.react-flow__renderer') as HTMLElement | null
|
|
?? containerEl.querySelector('.react-flow') as HTMLElement | null
|
|
?? containerEl;
|
|
}
|
|
|
|
function manualProject(
|
|
clientX: number,
|
|
clientY: number,
|
|
rf: FlowProjector,
|
|
containerEl: HTMLElement | null,
|
|
): { x: number; y: number } {
|
|
const host = getFlowProjectionHost(containerEl);
|
|
if (!host) return { x: 0, y: 0 };
|
|
|
|
const rect = host.getBoundingClientRect();
|
|
const { x: vx, y: vy, zoom } = rf.getViewport();
|
|
const z = zoom || 1;
|
|
return {
|
|
x: (clientX - rect.left - vx) / z,
|
|
y: (clientY - rect.top - vy) / z,
|
|
};
|
|
}
|
|
|
|
/** 드래그 overlay 아래 DOM 탐색 (overlay pointer-events 잠시 비활성) */
|
|
export function elementsUnderDragPoint(clientX: number, clientY: number): Element[] {
|
|
const overlays = document.querySelectorAll<HTMLElement>(DRAG_LAYER_SELECTOR);
|
|
const prev: string[] = [];
|
|
overlays.forEach(el => {
|
|
prev.push(el.style.pointerEvents);
|
|
el.style.pointerEvents = 'none';
|
|
});
|
|
try {
|
|
if (typeof document.elementsFromPoint === 'function') {
|
|
return document.elementsFromPoint(clientX, clientY);
|
|
}
|
|
const hit = document.elementFromPoint(clientX, clientY);
|
|
return hit ? [hit] : [];
|
|
} finally {
|
|
overlays.forEach((el, i) => {
|
|
el.style.pointerEvents = prev[i] ?? '';
|
|
});
|
|
}
|
|
}
|
|
|
|
/** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */
|
|
export function projectScreenToFlowPosition(
|
|
clientX: number,
|
|
clientY: number,
|
|
rf: FlowProjector | null,
|
|
containerEl: HTMLElement | null,
|
|
): { x: number; y: number } {
|
|
if (rf) {
|
|
try {
|
|
const pos = rf.screenToFlowPosition({ x: clientX, y: clientY });
|
|
if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) {
|
|
const looksLikeScreenPx =
|
|
Math.abs(pos.x - clientX) < 2 && Math.abs(pos.y - clientY) < 2;
|
|
if (!looksLikeScreenPx) return pos;
|
|
}
|
|
} catch {
|
|
/* manual fallback */
|
|
}
|
|
return manualProject(clientX, clientY, rf, containerEl);
|
|
}
|
|
|
|
return manualProject(
|
|
clientX,
|
|
clientY,
|
|
{ screenToFlowPosition: p => p, getViewport: () => ({ x: 0, y: 0, zoom: 1 }) },
|
|
containerEl,
|
|
);
|
|
}
|
|
|
|
/** 목록 방식 편집기 위인지 (rect hit-test) */
|
|
export function isOverListEditor(clientX: number, clientY: number): boolean {
|
|
const editors = document.querySelectorAll<HTMLElement>('.se-list-editor');
|
|
for (const el of editors) {
|
|
const rect = el.getBoundingClientRect();
|
|
if (
|
|
clientX >= rect.left && clientX <= rect.right
|
|
&& clientY >= rect.top && clientY <= rect.bottom
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */
|
|
export function isOverStrategyBuilder(clientX: number, clientY: number): boolean {
|
|
const canvas = document.querySelector<HTMLElement>('.se-canvas-wrap');
|
|
if (canvas) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (
|
|
clientX >= rect.left && clientX <= rect.right
|
|
&& clientY >= rect.top && clientY <= rect.bottom
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
for (const el of elementsUnderDragPoint(clientX, clientY)) {
|
|
if (el.closest('.se-palette-panel, .se-side-wrap--right')) continue;
|
|
if (el.closest('.se-canvas-wrap, .react-flow__pane, .react-flow__renderer')) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** DOM hit-test — react-flow 노드 id (Safari HTML5 drag: elementsFromPoint 불가 → rect 우선) */
|
|
export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null {
|
|
const pad = 10;
|
|
const nodeEls = Array.from(document.querySelectorAll<HTMLElement>('.react-flow__node'));
|
|
for (let i = nodeEls.length - 1; i >= 0; i--) {
|
|
const nodeEl = nodeEls[i];
|
|
const rect = nodeEl.getBoundingClientRect();
|
|
if (
|
|
clientX >= rect.left - pad && clientX <= rect.right + pad
|
|
&& clientY >= rect.top - pad && clientY <= rect.bottom + pad
|
|
) {
|
|
const id = nodeEl.getAttribute('data-id')
|
|
?? nodeEl.dataset.id
|
|
?? (nodeEl.id.startsWith('react-flow__node-')
|
|
? nodeEl.id.slice('react-flow__node-'.length)
|
|
: null)
|
|
?? nodeEl.querySelector<HTMLElement>('[data-nodeid]')?.getAttribute('data-nodeid')
|
|
?? null;
|
|
if (id) return id;
|
|
}
|
|
}
|
|
|
|
for (const el of elementsUnderDragPoint(clientX, clientY)) {
|
|
const nodeEl = el.closest('.react-flow__node') as HTMLElement | null;
|
|
if (nodeEl) {
|
|
const id = nodeEl.getAttribute('data-id')
|
|
?? nodeEl.dataset.id
|
|
?? (nodeEl.id.startsWith('react-flow__node-')
|
|
? nodeEl.id.slice('react-flow__node-'.length)
|
|
: null)
|
|
?? nodeEl.querySelector<HTMLElement>('[data-nodeid]')?.getAttribute('data-nodeid')
|
|
?? null;
|
|
if (id) return id;
|
|
}
|
|
const handleEl = el.closest('[data-nodeid]') as HTMLElement | null;
|
|
if (handleEl) {
|
|
const id = handleEl.getAttribute('data-nodeid');
|
|
if (id) return id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface PaletteDropResolution {
|
|
flowPos: { x: number; y: number };
|
|
anchorId: string | null;
|
|
}
|
|
|
|
function projectFlowToScreen(
|
|
flowX: number,
|
|
flowY: number,
|
|
rf: FlowProjector | null,
|
|
containerEl: HTMLElement | null,
|
|
): { x: number; y: number } {
|
|
const host = getFlowProjectionHost(containerEl);
|
|
if (!host || !rf) return { x: flowX, y: flowY };
|
|
|
|
const rect = host.getBoundingClientRect();
|
|
const { x: vx, y: vy, zoom } = rf.getViewport();
|
|
const z = zoom || 1;
|
|
return {
|
|
x: rect.left + vx + flowX * z,
|
|
y: rect.top + vy + flowY * z,
|
|
};
|
|
}
|
|
|
|
/** WKWebView HTML5 drag — dragover client 0,0 보정 (Web 전용) */
|
|
export function resolvePaletteDragClientPoint(
|
|
clientX: number,
|
|
clientY: number,
|
|
): { x: number; y: number } {
|
|
if (clientX !== 0 || clientY !== 0) {
|
|
return { x: clientX, y: clientY };
|
|
}
|
|
if (!isPaletteHtmlDragActive() && !getActivePaletteDrag()) {
|
|
return { x: clientX, y: clientY };
|
|
}
|
|
const fromHtml = getLastHtmlPaletteDragClientXY();
|
|
if (fromHtml && (fromHtml.x !== 0 || fromHtml.y !== 0)) return fromHtml;
|
|
return { x: clientX, y: clientY };
|
|
}
|
|
|
|
/** flow 노드 screen rect hit-test (DOM / elementsFromPoint 불필요 — WKWebView HTML5 drag 대응) */
|
|
function findConnectTargetAtClientPoint(
|
|
clientX: number,
|
|
clientY: number,
|
|
rf: FlowProjector | null,
|
|
containerEl: HTMLElement | null,
|
|
nodes: { id: string; position: { x: number; y: number } }[],
|
|
isConnectTarget: (nodeId: string) => boolean,
|
|
): string | null {
|
|
const pad = 12;
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const n = nodes[i];
|
|
if (!isConnectTarget(n.id)) continue;
|
|
const dim = getNodeDimensions(n.id);
|
|
const tl = projectFlowToScreen(n.position.x, n.position.y, rf, containerEl);
|
|
const br = projectFlowToScreen(
|
|
n.position.x + dim.w,
|
|
n.position.y + dim.h,
|
|
rf,
|
|
containerEl,
|
|
);
|
|
const left = Math.min(tl.x, br.x) - pad;
|
|
const right = Math.max(tl.x, br.x) + pad;
|
|
const top = Math.min(tl.y, br.y) - pad;
|
|
const bottom = Math.max(tl.y, br.y) + pad;
|
|
if (
|
|
clientX >= left && clientX <= right
|
|
&& clientY >= top && clientY <= bottom
|
|
) {
|
|
return n.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */
|
|
export function resolvePaletteDropClientPoint(
|
|
clientX: number,
|
|
clientY: number,
|
|
rf: FlowProjector | null,
|
|
containerEl: HTMLElement | null,
|
|
nodes: { id: string; position: { x: number; y: number } }[],
|
|
findAtFlow: (
|
|
flowPos: { x: number; y: number },
|
|
list: { id: string; position: { x: number; y: number } }[],
|
|
) => { id: string } | null,
|
|
isConnectTarget: (nodeId: string) => boolean,
|
|
): PaletteDropResolution {
|
|
const { x: px, y: py } = resolvePaletteDragClientPoint(clientX, clientY);
|
|
const flowPos = projectScreenToFlowPosition(px, py, rf, containerEl);
|
|
|
|
// 1) DOM rect — pointer overlay 에서 가장 정확
|
|
for (const node of nodes) {
|
|
if (!isConnectTarget(node.id)) continue;
|
|
const escaped = typeof CSS !== 'undefined' && CSS.escape
|
|
? CSS.escape(node.id)
|
|
: node.id.replace(/"/g, '\\"');
|
|
const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`)
|
|
?? document.getElementById(`react-flow__node-${node.id}`);
|
|
if (!el) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
const pad = 12;
|
|
if (
|
|
px >= rect.left - pad && px <= rect.right + pad
|
|
&& py >= rect.top - pad && py <= rect.bottom + pad
|
|
) {
|
|
return { flowPos, anchorId: node.id };
|
|
}
|
|
}
|
|
|
|
const flowRectId = findConnectTargetAtClientPoint(
|
|
px, py, rf, containerEl, nodes, isConnectTarget,
|
|
);
|
|
if (flowRectId) {
|
|
return { flowPos, anchorId: flowRectId };
|
|
}
|
|
|
|
const domId = findFlowNodeIdAtClientPoint(px, py);
|
|
if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) {
|
|
return { flowPos, anchorId: domId };
|
|
}
|
|
|
|
if (getActivePaletteDrag() || isPaletteHtmlDragActive()) {
|
|
const flowHit = findAtFlow(flowPos, nodes);
|
|
if (flowHit && isConnectTarget(flowHit.id)) {
|
|
return { flowPos, anchorId: flowHit.id };
|
|
}
|
|
}
|
|
|
|
const flowHit = findAtFlow(flowPos, nodes);
|
|
if (flowHit && isConnectTarget(flowHit.id)) {
|
|
return { flowPos, anchorId: flowHit.id };
|
|
}
|
|
|
|
return { flowPos, anchorId: null };
|
|
}
|