Files
goldenChart/frontend/src/utils/flowScreenProjection.ts
T
2026-06-15 01:16:14 +09:00

193 lines
6.6 KiB
TypeScript

import type { ReactFlowInstance } from '@xyflow/react';
import { getLastHtmlPaletteDragClientXY, isPaletteHtmlDragActive } from './paletteDragSession';
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
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<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,
);
}
/** 목록 방식 편집기 위인지 */
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<HTMLElement>('.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<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;
}
/** 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 };
}