This commit is contained in:
Macbook
2026-06-14 23:39:51 +09:00
parent b2788ee629
commit 0045f0ef2d
6 changed files with 221 additions and 70 deletions
+33 -17
View File
@@ -2,6 +2,8 @@ import type { ReactFlowInstance } from '@xyflow/react';
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
const DRAG_LAYER_SELECTOR = '.se-palette-drag-overlay, .se-palette-drag-ghost';
function manualProject(
clientX: number,
clientY: number,
@@ -20,6 +22,27 @@ function manualProject(
};
}
/** 드래그 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,
@@ -31,7 +54,6 @@ export function projectScreenToFlowPosition(
try {
const pos = rf.screenToFlowPosition({ x: clientX, y: clientY });
if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) {
// WebView 버그: 화면 픽셀을 그대로 반환하는 경우
const looksLikeScreenPx =
Math.abs(pos.x - clientX) < 2 && Math.abs(pos.y - clientY) < 2;
if (!looksLikeScreenPx) return pos;
@@ -50,26 +72,18 @@ export function projectScreenToFlowPosition(
);
}
/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 release 무시) */
/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */
export function isOverStrategyBuilder(clientX: number, clientY: number): boolean {
const elements = typeof document.elementsFromPoint === 'function'
? document.elementsFromPoint(clientX, clientY)
: [document.elementFromPoint(clientX, clientY)].filter(Boolean) as Element[];
for (const el of elements) {
if (el.closest('.se-palette-panel, .se-side-wrap--right, .se-palette-drag-ghost')) continue;
if (el.closest('.se-canvas-wrap')) 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 — elementsFromPoint 로 ghost·overlay 아래 노드까지 탐색 */
/** DOM hit-test — overlay 아래 react-flow 노드 탐색 */
export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null {
const elements = typeof document.elementsFromPoint === 'function'
? document.elementsFromPoint(clientX, clientY)
: [document.elementFromPoint(clientX, clientY)].filter(Boolean) as Element[];
for (const el of elements) {
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');
@@ -109,10 +123,12 @@ export function resolvePaletteDropClientPoint(
return { flowPos, anchorId: domId };
}
// DOM hit-test 실패 시 START 노드 bounding rect 로 보조
for (const node of nodes) {
if (!isConnectTarget(node.id)) continue;
const el = document.querySelector(`.react-flow__node[data-id="${CSS.escape(node.id)}"]`);
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}"]`);
if (!el) continue;
const rect = el.getBoundingClientRect();
if (
+61 -51
View File
@@ -1,7 +1,6 @@
/**
* Tauri WebView — HTML5 DnD 대신 pointer capture 기반 팔레트 드래그
* Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그
*/
import { clientXYFromNative } from './pointerDrag';
import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & {
@@ -19,16 +18,29 @@ export interface PaletteDragEvent {
payload: PaletteDragPayload;
}
export interface PaletteDragOverlaySession {
payload: PaletteDragPayload;
pointerId: number;
x: number;
y: number;
}
type PaletteDragListener = (event: PaletteDragEvent) => void;
type PaletteDropHandler = (event: PaletteDragEvent) => void;
type OverlayController = {
mount: (session: PaletteDragOverlaySession) => void;
unmount: () => void;
bindElement: (el: HTMLElement | null) => void;
};
let suppressClickUntil = 0;
const DRAG_THRESHOLD_PX = 6;
let activePayload: PaletteDragPayload | null = null;
let activePointerId: number | null = null;
let unbindMove: (() => void) | null = null;
let pendingPointerId: number | null = null;
let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null;
let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null;
@@ -41,11 +53,14 @@ export function subscribePaletteDrag(listener: PaletteDragListener): () => void
return () => listeners.delete(listener);
}
/** Canvas 가 mount 직후 drop 콜백 등록 (subscribe 누락 방지) */
export function setPaletteDragDropHandler(handler: PaletteDropHandler | null): void {
dropHandler = handler;
}
export function registerPaletteDragOverlay(controller: OverlayController | null): void {
overlayController = controller;
}
export function getActivePaletteDrag(): PaletteDragPayload | null {
return activePayload;
}
@@ -71,12 +86,10 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload)
}
function cleanupDrag() {
unbindMove?.();
unbindMove = null;
activePayload = null;
activePointerId = null;
pendingPointerId = null;
document.body.classList.remove('se-palette-drag-active');
overlayController?.unmount();
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
}
@@ -92,13 +105,40 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
}
}
/** 캔버스 pointerup 백업 — WebView 에서 window 이벤트 누락 시 */
export function notifyPaletteDragMove(clientX: number, clientY: number) {
if (!activePayload) return;
emit({ phase: 'move', x: clientX, y: clientY, payload: activePayload });
}
export function notifyPaletteDragEnd(clientX: number, clientY: number) {
finishDrag('end', { x: clientX, y: clientY });
}
export function notifyPaletteDragCancel(clientX: number, clientY: number) {
finishDrag('cancel', { x: clientX, y: clientY });
}
export function completePaletteDragAt(clientX: number, clientY: number): boolean {
if (!activePayload) return false;
finishDrag('end', { x: clientX, y: clientY });
return true;
}
function captureOnOverlay(pointerId: number) {
try {
overlayElement?.setPointerCapture(pointerId);
} catch {
/* ignore */
}
try {
if (!overlayElement?.hasPointerCapture(pointerId)) {
document.body.setPointerCapture(pointerId);
}
} catch {
/* ignore */
}
}
function beginDrag(
payload: PaletteDragPayload,
xy: { x: number; y: number },
@@ -108,48 +148,18 @@ function beginDrag(
activePointerId = pointerId;
document.body.classList.add('se-palette-drag-active');
try {
document.body.setPointerCapture(pointerId);
} catch {
/* ignore */
}
overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y });
requestAnimationFrame(() => captureOnOverlay(pointerId));
emit({ phase: 'start', x: xy.x, y: xy.y, payload });
}
const onMove = (ev: Event) => {
if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId || !activePayload) return;
if (ev.cancelable) ev.preventDefault();
emit({ phase: 'move', x: ev.clientX, y: ev.clientY, payload: activePayload });
};
const onUp = (ev: Event) => {
if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return;
const xy = clientXYFromNative(ev) ?? { x: ev.clientX, y: ev.clientY };
finishDrag('end', xy);
};
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('mouseup', onUp, true);
document.addEventListener('pointerup', onUp, true);
document.addEventListener('lostpointercapture', onUp, true);
unbindMove = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('mouseup', onUp, true);
document.removeEventListener('pointerup', onUp, true);
document.removeEventListener('lostpointercapture', onUp, true);
try {
if (document.body.hasPointerCapture(pointerId)) {
document.body.releasePointerCapture(pointerId);
}
} catch {
/* ignore */
}
};
export function bindPaletteDragOverlayElement(el: HTMLElement | null) {
overlayElement = el;
overlayController?.bindElement(el);
if (el && activePointerId != null) {
captureOnOverlay(activePointerId);
}
}
export function handlePalettePointerDown(
@@ -161,10 +171,11 @@ export function handlePalettePointerDown(
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const pointerId = e.pointerId;
pendingPointerId = pointerId;
const onPointerMove = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
@@ -185,12 +196,11 @@ export function handlePalettePointerDown(
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
pendingPointerId = null;
};
const onPointerCancel = onPointerUp;
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
}