diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx
index 2dd0fd4..3775b53 100644
--- a/frontend/src/components/StrategyEditorPage.tsx
+++ b/frontend/src/components/StrategyEditorPage.tsx
@@ -141,6 +141,7 @@ import {
strategyDtoToListExportItem,
} from '../utils/strategyImportExport';
import PaletteChip from './strategyEditor/PaletteChip';
+import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import {
clampPanelSize,
@@ -1616,6 +1617,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
return (
+
{saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
)}
diff --git a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx
new file mode 100644
index 0000000..adee0aa
--- /dev/null
+++ b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import {
+ bindPaletteDragOverlayElement,
+ notifyPaletteDragCancel,
+ notifyPaletteDragEnd,
+ notifyPaletteDragMove,
+ registerPaletteDragOverlay,
+ type PaletteDragOverlaySession,
+} from '../../utils/paletteDragSession';
+
+/**
+ * Tauri WebView — 드래그 중 전체 화면 overlay 가 pointer 이벤트·drop 을 수신
+ */
+export default function PaletteDragOverlay() {
+ const [session, setSession] = useState
(null);
+ const [cursor, setCursor] = useState({ x: 0, y: 0 });
+ const overlayRef = useRef(null);
+
+ useEffect(() => {
+ registerPaletteDragOverlay({
+ mount: next => {
+ setCursor({ x: next.x, y: next.y });
+ setSession(next);
+ },
+ unmount: () => setSession(null),
+ bindElement: el => {
+ overlayRef.current = el as HTMLDivElement | null;
+ },
+ });
+ return () => registerPaletteDragOverlay(null);
+ }, []);
+
+ useLayoutEffect(() => {
+ bindPaletteDragOverlayElement(overlayRef.current);
+ return () => bindPaletteDragOverlayElement(null);
+ }, [session]);
+
+ useLayoutEffect(() => {
+ if (!session || !overlayRef.current) return;
+ try {
+ overlayRef.current.setPointerCapture(session.pointerId);
+ } catch {
+ /* body capture fallback */
+ }
+ }, [session]);
+
+ const onPointerMove = useCallback((e: React.PointerEvent) => {
+ if (!session || e.pointerId !== session.pointerId) return;
+ e.preventDefault();
+ e.stopPropagation();
+ setCursor({ x: e.clientX, y: e.clientY });
+ notifyPaletteDragMove(e.clientX, e.clientY);
+ }, [session]);
+
+ const onPointerUp = useCallback((e: React.PointerEvent) => {
+ if (!session || e.pointerId !== session.pointerId) return;
+ e.preventDefault();
+ e.stopPropagation();
+ notifyPaletteDragEnd(e.clientX, e.clientY);
+ }, [session]);
+
+ const onPointerCancel = useCallback((e: React.PointerEvent) => {
+ if (!session || e.pointerId !== session.pointerId) return;
+ e.preventDefault();
+ e.stopPropagation();
+ notifyPaletteDragCancel(e.clientX, e.clientY);
+ }, [session]);
+
+ if (!session) return null;
+
+ return createPortal(
+
+
+ {session.payload.label}
+
+
,
+ document.body,
+ );
+}
diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
index 90ef0e8..69981e9 100644
--- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
+++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx
@@ -1233,9 +1233,9 @@ function StrategyEditorCanvasInner({
clientX: number,
clientY: number,
) => {
- if (!isOverStrategyBuilder(clientX, clientY)) return;
-
const { flowPos, anchorId } = resolveDropFromClient(clientX, clientY);
+ const overCanvas = isOverStrategyBuilder(clientX, clientY);
+ if (!overCanvas && !anchorId) return;
if (data.type === 'start') {
addStartAt(flowPos);
return;
diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css
index fb0178e..b7976a2 100644
--- a/frontend/src/styles/strategyEditor.css
+++ b/frontend/src/styles/strategyEditor.css
@@ -2027,6 +2027,12 @@
background: var(--se-palette-card-bg);
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s, background 0.12s;
}
+.desktop-client .se-palette-card {
+ cursor: grab;
+}
+.desktop-client .se-palette-card:active {
+ cursor: grabbing;
+}
.se-palette-card:hover {
transform: translateY(-2px);
}
@@ -2039,6 +2045,34 @@ body.se-palette-drag-active {
user-select: none;
}
+.se-palette-drag-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 20040;
+ touch-action: none;
+ cursor: grabbing;
+ background: transparent;
+}
+
+.se-palette-drag-overlay-label {
+ position: fixed;
+ z-index: 20041;
+ pointer-events: none;
+ transform: translate(-50%, -100%);
+ padding: 6px 12px;
+ border-radius: 8px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: var(--se-text, #e2e8f0);
+ background: color-mix(in srgb, var(--se-accent, #4dabf7) 22%, var(--se-panel-card-bg, #1e293b));
+ border: 1px solid color-mix(in srgb, var(--se-accent, #4dabf7) 55%, transparent);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ white-space: nowrap;
+ max-width: min(240px, 90vw);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
.se-palette-drag-ghost {
position: fixed;
z-index: 20050;
diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts
index eba100f..c4f1650 100644
--- a/frontend/src/utils/flowScreenProjection.ts
+++ b/frontend/src/utils/flowScreenProjection.ts
@@ -2,6 +2,8 @@ import type { ReactFlowInstance } from '@xyflow/react';
type FlowProjector = Pick;
+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(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 (
diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts
index 25172e1..396d917 100644
--- a/frontend/src/utils/paletteDragSession.ts
+++ b/frontend/src/utils/paletteDragSession.ts
@@ -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 & {
@@ -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();
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);
}