From afd2cb2693f251f546d4429a3cad62ddc512663f Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 22:57:37 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=83=91=20?= =?UTF-8?q?=EC=95=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategyEditor/StrategyEditorCanvas.tsx | 32 +++- frontend/src/utils/flowScreenProjection.ts | 56 ++++-- frontend/src/utils/paletteDragSession.ts | 172 ++++++++++-------- 3 files changed, 156 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 7f3061f..90ef0e8 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ReactFlow, Background, @@ -32,8 +32,11 @@ import { import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { isPaletteHtmlDrag, + completePaletteDragAt, + getActivePaletteDrag, needsPointerPaletteDrag, readPaletteHtmlDragData, + setPaletteDragDropHandler, subscribePaletteDrag, type PaletteDragEvent, } from '../../utils/paletteDragSession'; @@ -1262,6 +1265,18 @@ function StrategyEditorCanvasInner({ applyDropRef.current = applyPaletteDropAt; const clearPreviewRef = useRef(clearDropPreview); clearPreviewRef.current = clearDropPreview; + + useLayoutEffect(() => { + if (!needsPointerPaletteDrag()) return undefined; + setPaletteDragDropHandler((ev) => { + if (ev.phase === 'end') { + clearPreviewRef.current(); + applyDropRef.current(ev.payload, ev.x, ev.y); + } + }); + return () => setPaletteDragDropHandler(null); + }, []); + const updatePreviewRef = useRef(updateDropPreview); updatePreviewRef.current = updateDropPreview; const resolveDropFromClientRef = useRef(resolveDropFromClient); @@ -1283,11 +1298,6 @@ function StrategyEditorCanvasInner({ } return; } - if (ev.phase === 'end') { - clearPreviewRef.current(); - applyDropRef.current(ev.payload, ev.x, ev.y); - return; - } if (ev.phase === 'cancel') { clearPreviewRef.current(); } @@ -1332,11 +1342,21 @@ function StrategyEditorCanvasInner({ } }, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]); + const handleCanvasPointerUp = useCallback((e: React.PointerEvent) => { + if (!needsPointerPaletteDrag()) return; + if (e.button !== 0) return; + if (!getActivePaletteDrag()) return; + e.preventDefault(); + e.stopPropagation(); + completePaletteDragAt(e.clientX, e.clientY); + }, []); + return (
= rect.left && clientX <= rect.right + && clientY >= rect.top && clientY <= rect.bottom + ) { + return { flowPos, anchorId: node.id }; + } + } + const flowHit = findAtFlow(flowPos, nodes); if (flowHit && isConnectTarget(flowHit.id)) { return { flowPos, anchorId: flowHit.id }; diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 390ad64..25172e1 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,7 +1,7 @@ /** - * Tauri WebView 등 HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션 + * Tauri WebView — HTML5 DnD 대신 pointer capture 기반 팔레트 드래그 */ -import { bindWindowDrag, clientXYFromNative, type ClientXY } from './pointerDrag'; +import { clientXYFromNative } from './pointerDrag'; import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { @@ -20,24 +20,18 @@ export interface PaletteDragEvent { } type PaletteDragListener = (event: PaletteDragEvent) => void; +type PaletteDropHandler = (event: PaletteDragEvent) => void; let suppressClickUntil = 0; - -/** 드래그 직후 발생하는 click 이벤트 무시 */ -export function shouldSuppressPaletteClick(): boolean { - return Date.now() < suppressClickUntil; -} - const DRAG_THRESHOLD_PX = 6; -const GHOST_CLASS = 'se-palette-drag-ghost'; let activePayload: PaletteDragPayload | null = null; -let ghostEl: HTMLDivElement | null = null; +let activePointerId: number | null = null; let unbindMove: (() => void) | null = null; let pendingPointerId: number | null = null; let listeners = new Set(); +let dropHandler: PaletteDropHandler | null = null; -/** Desktop(Tauri) WebKit — native HTML5 DnD 불안정 */ export function needsPointerPaletteDrag(): boolean { return isDesktop(); } @@ -47,10 +41,19 @@ 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 getActivePaletteDrag(): PaletteDragPayload | null { return activePayload; } +export function shouldSuppressPaletteClick(): boolean { + return Date.now() < suppressClickUntil; +} + function emit(event: PaletteDragEvent) { listeners.forEach(fn => { try { @@ -61,89 +64,98 @@ function emit(event: PaletteDragEvent) { }); } -function removeGhost() { - ghostEl?.remove(); - ghostEl = null; - document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); -} - -function ensureGhost(label: string, x: number, y: number) { - if (ghostEl) return; - ghostEl = document.createElement('div'); - ghostEl.className = GHOST_CLASS; - ghostEl.textContent = label; - ghostEl.style.left = `${x}px`; - ghostEl.style.top = `${y}px`; - document.body.appendChild(ghostEl); -} - -function moveGhost(x: number, y: number) { - if (!ghostEl) return; - ghostEl.style.left = `${x}px`; - ghostEl.style.top = `${y}px`; -} - -function finishDrag(phase: 'end' | 'cancel', xy: ClientXY) { - if (!activePayload) return; - const payload = activePayload; - cleanupDrag(); - if (payload && phase === 'end') { - suppressClickUntil = Date.now() + 400; - emit({ phase: 'end', x: xy.x, y: xy.y, payload }); - } else if (payload) { - emit({ phase: 'cancel', x: xy.x, y: xy.y, payload }); - } +function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) { + const event: PaletteDragEvent = { phase: 'end', x: xy.x, y: xy.y, payload }; + dropHandler?.(event); + emit(event); } function cleanupDrag() { unbindMove?.(); unbindMove = null; activePayload = null; + activePointerId = null; pendingPointerId = null; - removeGhost(); document.body.classList.remove('se-palette-drag-active'); + document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); } -function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) { +function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { + if (!activePayload) return; + const payload = activePayload; + cleanupDrag(); + if (phase === 'end') { + suppressClickUntil = Date.now() + 400; + dispatchEnd(xy, payload); + } else { + emit({ phase: 'cancel', x: xy.x, y: xy.y, payload }); + } +} + +/** 캔버스 pointerup 백업 — WebView 에서 window 이벤트 누락 시 */ +export function completePaletteDragAt(clientX: number, clientY: number): boolean { + if (!activePayload) return false; + finishDrag('end', { x: clientX, y: clientY }); + return true; +} + +function beginDrag( + payload: PaletteDragPayload, + xy: { x: number; y: number }, + pointerId: number, +) { activePayload = payload; + activePointerId = pointerId; document.body.classList.add('se-palette-drag-active'); - ensureGhost(label, xy.x, xy.y); + + try { + document.body.setPointerCapture(pointerId); + } catch { + /* ignore */ + } + emit({ phase: 'start', x: xy.x, y: xy.y, payload }); - unbindMove = bindWindowDrag( - ({ x, y }) => { - moveGhost(x, y); - if (activePayload) { - emit({ phase: 'move', x, y, payload: activePayload }); - } - }, - (endXY) => finishDrag('end', endXY), - { preventTouchScroll: true }, - ); - - // Tauri WebView: bubble 단계 pointerup 누락 대비 - const captureEnd = (ev: Event) => { - if (!activePayload) return; - const xy = clientXYFromNative(ev); - if (xy) finishDrag('end', xy); + 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 }); }; - window.addEventListener('pointerup', captureEnd, true); - window.addEventListener('pointercancel', captureEnd, true); - const prevUnbind = unbindMove; + + 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('pointerup', captureEnd, true); - window.removeEventListener('pointercancel', captureEnd, true); - prevUnbind(); + 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 */ + } }; } -/** - * 팔레트 카드 pointerdown — 임계 이동 후 드래그 시작 (클릭과 구분) - */ export function handlePalettePointerDown( e: React.PointerEvent, payload: PaletteDragPayload, - label: string, + _label: string, ): void { if (!needsPointerPaletteDrag()) return; if (e.button !== 0) return; @@ -165,7 +177,7 @@ export function handlePalettePointerDown( window.removeEventListener('pointercancel', onPointerCancel); ev.preventDefault(); - beginDrag(payload, label, { x: ev.clientX, y: ev.clientY }); + beginDrag(payload, { x: ev.clientX, y: ev.clientY }, pointerId); }; const onPointerUp = (ev: PointerEvent) => { @@ -183,13 +195,6 @@ export function handlePalettePointerDown( window.addEventListener('pointercancel', onPointerCancel); } -export function findPaletteDropKeyAtPoint(x: number, y: number): string | null { - const el = document.elementFromPoint(x, y); - if (!el) return null; - const host = el.closest('[data-palette-drop-key]') as HTMLElement | null; - return host?.dataset.paletteDropKey ?? null; -} - export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void { const json = JSON.stringify(payload); e.dataTransfer.setData('application/json', json); @@ -212,3 +217,10 @@ export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload return null; } } + +export function findPaletteDropKeyAtPoint(x: number, y: number): string | null { + const el = document.elementFromPoint(x, y); + if (!el) return null; + const host = el.closest('[data-palette-drop-key]') as HTMLElement | null; + return host?.dataset.paletteDropKey ?? null; +}