import type { ReactFlowInstance } from '@xyflow/react'; import { getLastHtmlPaletteDragClientXY, isPaletteHtmlDragActive } from './paletteDragSession'; type FlowProjector = Pick; const DRAG_LAYER_SELECTOR = '.se-palette-drag-overlay, .se-palette-drag-ghost'; function manualProject( clientX: number, clientY: number, rf: FlowProjector, containerEl: HTMLElement | null, ): { x: number; y: number } { const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? 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(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, ); } /** 목록 방식 편집기 위인지 */ export function isOverListEditor(clientX: number, clientY: number): boolean { for (const el of elementsUnderDragPoint(clientX, clientY)) { if (el.closest('.se-list-editor')) return true; } return false; } /** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */ export function isOverStrategyBuilder(clientX: number, clientY: number): boolean { 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 = document.querySelectorAll('.react-flow__node'); for (const nodeEl of nodeEls) { 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('[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('[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; } /** WKWebView HTML5 drag — dragover client 좌표가 0,0 으로 오는 경우 보정 */ export function resolvePaletteDragClientPoint(clientX: number, clientY: number): { x: number; y: number } { if (clientX !== 0 || clientY !== 0) return { x: clientX, y: clientY }; if (!isPaletteHtmlDragActive()) return { x: clientX, y: clientY }; const last = getLastHtmlPaletteDragClientXY(); return last ?? { x: clientX, y: clientY }; } /** 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); const domId = findFlowNodeIdAtClientPoint(px, py); if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) { return { flowPos, anchorId: domId }; } 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(); if ( px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom ) { return { flowPos, anchorId: node.id }; } } const flowHit = findAtFlow(flowPos, nodes); if (flowHit && isConnectTarget(flowHit.id)) { return { flowPos, anchorId: flowHit.id }; } return { flowPos, anchorId: null }; }