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;
+}