diff --git a/frontend/src/components/strategyEditor/FlowNodes.tsx b/frontend/src/components/strategyEditor/FlowNodes.tsx index c8328c3..cd3712f 100644 --- a/frontend/src/components/strategyEditor/FlowNodes.tsx +++ b/frontend/src/components/strategyEditor/FlowNodes.tsx @@ -35,6 +35,10 @@ import { import ConditionNodeSettings from './ConditionNodeSettings'; import StochPairNodeSettings from './StochPairNodeSettings'; import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings'; +import { + isPaletteHtmlDrag, + readPaletteHtmlDragData, +} from '../../utils/paletteDragSession'; const LOGIC_COLORS: Record = { AND: '#00aaff', @@ -126,7 +130,7 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) { const { screenToFlowPosition } = useReactFlow(); const onDragOver = useCallback((e: React.DragEvent) => { - if (!e.dataTransfer.types.includes('application/json')) return; + if (!isPaletteHtmlDrag(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; @@ -144,11 +148,10 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) { const onDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - try { - const payload = JSON.parse(e.dataTransfer.getData('application/json')); - const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); - d.onDropTarget?.(id, payload, flowPos); - } catch { /* ignore */ } + const payload = readPaletteHtmlDragData(e); + if (!payload) return; + const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); + d.onDropTarget?.(id, payload, flowPos); }, [d, id, screenToFlowPosition]); return { onDragOver, onDragLeave, onDrop }; diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 3a48a25..216469f 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -1,19 +1,13 @@ import React from 'react'; +import { + handlePalettePointerDown, + needsPointerPaletteDrag, + shouldSuppressPaletteClick, + writePaletteHtmlDragData, + type PaletteDragPayload, +} from '../../utils/paletteDragSession'; -export interface PaletteDragPayload { - type: 'operator' | 'indicator' | 'start'; - value: string; - label: string; - composite?: boolean; - stochPair?: boolean; - priceExtremeBreakout?: boolean; - inflection33?: boolean; - stableStrategy?: boolean; - secondaryIndicator?: string; - period?: number; - shortPeriod?: number; - longPeriod?: number; -} +export type { PaletteDragPayload }; interface Props { type: 'operator' | 'indicator' | 'start'; @@ -40,24 +34,29 @@ export default function PaletteChip({ type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod, composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, stableStrategy = false, secondaryIndicator, selected = false, onSelect, onAdd, }: Props) { + const buildPayload = (): PaletteDragPayload => ({ + type, value, label, + composite: composite || undefined, + stochPair: stochPair || undefined, + priceExtremeBreakout: priceExtremeBreakout || undefined, + inflection33: inflection33 || undefined, + stableStrategy: stableStrategy || undefined, + secondaryIndicator, + period: periodValue, + shortPeriod, + longPeriod, + }); + const onDragStart = (e: React.DragEvent) => { - const payload: PaletteDragPayload = { - type, value, label, - composite: composite || undefined, - stochPair: stochPair || undefined, - priceExtremeBreakout: priceExtremeBreakout || undefined, - inflection33: inflection33 || undefined, - stableStrategy: stableStrategy || undefined, - secondaryIndicator, - period: periodValue, - shortPeriod, - longPeriod, - }; - e.dataTransfer.setData('application/json', JSON.stringify(payload)); - e.dataTransfer.effectAllowed = 'copy'; + writePaletteHtmlDragData(e, buildPayload()); + }; + + const onPointerDown = (e: React.PointerEvent) => { + handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); }; const handleClick = () => { + if (shouldSuppressPaletteClick()) return; if (onSelect) onSelect(); else onAdd(); }; @@ -80,9 +79,10 @@ export default function PaletteChip({ return (
{ + hideHint(); + const payload: PaletteDragPayload = { + type: 'sidewaysFilter', + value: item.id, + label: item.label, + }; + handlePalettePointerDown(e, payload, item.label); }; const handleClick = () => onSelect(); @@ -94,9 +109,10 @@ function SidewaysFilterChip({ return (
showHint(e.currentTarget)} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index c77e944..77558c7 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -29,6 +29,13 @@ import { type DefType, } from '../../utils/strategyEditorShared'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; +import { + isPaletteHtmlDrag, + needsPointerPaletteDrag, + readPaletteHtmlDragData, + subscribePaletteDrag, + type PaletteDragEvent, +} from '../../utils/paletteDragSession'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { setPriceExtremePairFilterLevel, @@ -1190,8 +1197,6 @@ function StrategyEditorCanvasInner({ )); }, [root, orphans, extraRoots, applyResolvedConnection, setEdges]); - const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json'); - const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => { const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { @@ -1200,24 +1205,63 @@ function StrategyEditorCanvasInner({ return { mode: 'orphan' as const }; }, [root, orphans]); + const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e); + + const applyPaletteDropAt = useCallback(( + data: { type: string; value: string; label: string; composite?: boolean }, + clientX: number, + clientY: number, + ) => { + const flowPos = screenToFlowPosition({ x: clientX, y: clientY }); + if (data.type === 'start') { + addStartAt(flowPos); + return; + } + const drop = resolvePaletteDrop(flowPos); + if (drop.mode === 'connect') { + applyDropWithAttach(drop.anchorId, data, flowPos); + } else { + addOrphanAt(data, flowPos); + } + }, [screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]); + const onPaneDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); clearDropPreview(); - try { - const data = JSON.parse(e.dataTransfer.getData('application/json')); - const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); - if (data.type === 'start') { - addStartAt(flowPos); + const data = readPaletteHtmlDragData(e); + if (!data) return; + applyPaletteDropAt(data, e.clientX, e.clientY); + }, [clearDropPreview, applyPaletteDropAt]); + + useEffect(() => { + if (!needsPointerPaletteDrag()) return; + return subscribePaletteDrag((ev: PaletteDragEvent) => { + if (ev.phase === 'move') { + const flowPos = screenToFlowPosition({ x: ev.x, y: ev.y }); + const drop = resolvePaletteDrop(flowPos); + if (drop.mode === 'connect') { + updateDropPreview(drop.anchorId, flowPos); + } else { + clearDropPreview(); + } return; } - const drop = resolvePaletteDrop(flowPos); - if (drop.mode === 'connect') { - applyDropWithAttach(drop.anchorId, data, flowPos); - } else { - addOrphanAt(data, flowPos); + if (ev.phase === 'end') { + clearDropPreview(); + applyPaletteDropAt(ev.payload, ev.x, ev.y); + return; } - } catch { /* ignore */ } - }, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]); + if (ev.phase === 'cancel') { + clearDropPreview(); + } + }); + }, [ + screenToFlowPosition, + resolvePaletteDrop, + updateDropPreview, + clearDropPreview, + applyPaletteDropAt, + ]); const onPaneDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 831d8c3..fb0178e 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -2021,6 +2021,8 @@ border-radius: 8px; padding: 8px; cursor: grab; + touch-action: none; + -webkit-user-drag: element; border: 1px solid var(--se-border); background: var(--se-palette-card-bg); transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s, background 0.12s; @@ -2028,6 +2030,33 @@ .se-palette-card:hover { transform: translateY(-2px); } +.se-palette-card:active { + cursor: grabbing; +} + +body.se-palette-drag-active { + cursor: grabbing !important; + user-select: none; +} + +.se-palette-drag-ghost { + position: fixed; + z-index: 20050; + pointer-events: none; + transform: translate(-50%, -50%); + 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-card--selected { outline: 2px solid var(--se-accent); diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts new file mode 100644 index 0000000..3b4b47a --- /dev/null +++ b/frontend/src/utils/paletteDragSession.ts @@ -0,0 +1,199 @@ +/** + * Tauri WebView 등 HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션 + */ +import { bindWindowDrag, type ClientXY } from './pointerDrag'; +import { isDesktop } from './platform'; + +export type PaletteDragPayload = Record & { + type: string; + value: string; + label: string; +}; + +export type PaletteDragPhase = 'start' | 'move' | 'end' | 'cancel'; + +export interface PaletteDragEvent { + phase: PaletteDragPhase; + x: number; + y: number; + payload: PaletteDragPayload; +} + +type PaletteDragListener = (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 unbindMove: (() => void) | null = null; +let pendingPointerId: number | null = null; +let listeners = new Set(); + +/** Desktop(Tauri) WebKit — native HTML5 DnD 불안정 */ +export function needsPointerPaletteDrag(): boolean { + return isDesktop(); +} + +export function subscribePaletteDrag(listener: PaletteDragListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getActivePaletteDrag(): PaletteDragPayload | null { + return activePayload; +} + +function emit(event: PaletteDragEvent) { + listeners.forEach(fn => { + try { + fn(event); + } catch { + /* ignore */ + } + }); +} + +function removeGhost() { + ghostEl?.remove(); + ghostEl = null; +} + +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) { + 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 cleanupDrag() { + unbindMove?.(); + unbindMove = null; + activePayload = null; + pendingPointerId = null; + removeGhost(); + document.body.classList.remove('se-palette-drag-active'); +} + +function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) { + activePayload = payload; + document.body.classList.add('se-palette-drag-active'); + ensureGhost(label, xy.x, xy.y); + emit({ phase: 'start', x: xy.x, y: xy.y, payload }); + + let lastXY = xy; + unbindMove = bindWindowDrag( + ({ x, y }) => { + lastXY = { x, y }; + moveGhost(x, y); + if (activePayload) { + emit({ phase: 'move', x, y, payload: activePayload }); + } + }, + () => finishDrag('end', lastXY), + { preventTouchScroll: true }, + ); +} + +/** + * 팔레트 카드 pointerdown — 임계 이동 후 드래그 시작 (클릭과 구분) + */ +export function handlePalettePointerDown( + e: React.PointerEvent, + payload: PaletteDragPayload, + label: string, +): void { + if (!needsPointerPaletteDrag()) return; + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; + + const startX = e.clientX; + const startY = e.clientY; + const pointerId = e.pointerId; + pendingPointerId = pointerId; + + const onPointerMove = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); + + ev.preventDefault(); + beginDrag(payload, label, { x: ev.clientX, y: ev.clientY }); + }; + + const onPointerUp = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); + pendingPointerId = null; + }; + + const onPointerCancel = onPointerUp; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + 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); + e.dataTransfer.setData('text/plain', json); + e.dataTransfer.effectAllowed = 'copy'; +} + +export function isPaletteHtmlDrag(e: React.DragEvent): boolean { + return e.dataTransfer.types.includes('application/json') + || e.dataTransfer.types.includes('text/plain'); +} + +export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload | null { + const raw = e.dataTransfer.getData('application/json') + || e.dataTransfer.getData('text/plain'); + if (!raw) return null; + try { + return JSON.parse(raw) as PaletteDragPayload; + } catch { + return null; + } +}