From 55a517ba1c3966bf22f2730300af5b42c93b4852 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 23:56:27 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategyEditor/PaletteDragOverlay.tsx | 23 +++- .../strategyEditor/StrategyEditorCanvas.tsx | 11 +- .../strategyEditor/StrategyListEditor.tsx | 107 +++++++++++++++++- frontend/src/utils/flowScreenProjection.ts | 8 ++ frontend/src/utils/paletteDragSession.ts | 27 ++++- 5 files changed, 163 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx index adee0aa..4cc4ac3 100644 --- a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx +++ b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx @@ -10,18 +10,24 @@ import { } from '../../utils/paletteDragSession'; /** - * Tauri WebView — 드래그 중 전체 화면 overlay 가 pointer 이벤트·drop 을 수신 + * Tauri WebView — 드래그 overlay (라벨 위치는 ref 갱신, move 마다 React re-render 없음) */ export default function PaletteDragOverlay() { const [session, setSession] = useState(null); - const [cursor, setCursor] = useState({ x: 0, y: 0 }); const overlayRef = useRef(null); + const labelRef = useRef(null); useEffect(() => { registerPaletteDragOverlay({ mount: next => { - setCursor({ x: next.x, y: next.y }); setSession(next); + requestAnimationFrame(() => { + const label = labelRef.current; + if (label) { + label.style.left = `${next.x}px`; + label.style.top = `${next.y}px`; + } + }); }, unmount: () => setSession(null), bindElement: el => { @@ -41,7 +47,7 @@ export default function PaletteDragOverlay() { try { overlayRef.current.setPointerCapture(session.pointerId); } catch { - /* body capture fallback */ + /* ignore */ } }, [session]); @@ -49,7 +55,11 @@ export default function PaletteDragOverlay() { if (!session || e.pointerId !== session.pointerId) return; e.preventDefault(); e.stopPropagation(); - setCursor({ x: e.clientX, y: e.clientY }); + const label = labelRef.current; + if (label) { + label.style.left = `${e.clientX}px`; + label.style.top = `${e.clientY}px`; + } notifyPaletteDragMove(e.clientX, e.clientY); }, [session]); @@ -78,8 +88,9 @@ export default function PaletteDragOverlay() { onPointerCancel={onPointerCancel} >
{session.payload.label}
diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 69981e9..167c8d1 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -1281,16 +1281,22 @@ function StrategyEditorCanvasInner({ updatePreviewRef.current = updateDropPreview; const resolveDropFromClientRef = useRef(resolveDropFromClient); resolveDropFromClientRef.current = resolveDropFromClient; + const lastPreviewAnchorRef = useRef(null); useEffect(() => { if (!needsPointerPaletteDrag()) return; return subscribePaletteDrag((ev: PaletteDragEvent) => { if (ev.phase === 'move') { if (!isOverStrategyBuilder(ev.x, ev.y)) { - clearPreviewRef.current(); + if (lastPreviewAnchorRef.current != null) { + lastPreviewAnchorRef.current = null; + clearPreviewRef.current(); + } return; } const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y); + if (anchorId === lastPreviewAnchorRef.current) return; + lastPreviewAnchorRef.current = anchorId; if (anchorId) { updatePreviewRef.current(anchorId, flowPos); } else { @@ -1298,6 +1304,9 @@ function StrategyEditorCanvasInner({ } return; } + if (ev.phase === 'end' || ev.phase === 'cancel') { + lastPreviewAnchorRef.current = null; + } if (ev.phase === 'cancel') { clearPreviewRef.current(); } diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index b3dea44..b2ca2cb 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { LogicNode } from '../../utils/strategyTypes'; import { CondEditor, @@ -31,6 +31,15 @@ import { import StartTimeframeCheckboxes from './StartTimeframeCheckboxes'; import LogicGateOpToggle from './LogicGateOpToggle'; import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes'; +import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; +import { isOverListEditor } from '../../utils/flowScreenProjection'; +import { + findPaletteDropKeyAtPoint, + needsPointerPaletteDrag, + setPaletteDragDropHandler, + subscribePaletteDrag, + type PaletteDragPayload, +} from '../../utils/paletteDragSession'; const NODE_COLORS: Record = { AND: '#4caf50', @@ -134,6 +143,7 @@ const TreeNodeComp: React.FC = ({
0 ? 12 : 0 }} + data-palette-drop-key={isLogic ? nodeDropKey : undefined} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} @@ -327,6 +337,7 @@ function StartSectionBlock({
{ e.preventDefault(); e.stopPropagation(); @@ -380,8 +391,15 @@ export default function StrategyListEditor({ orphans = [], }: Props) { const [dragOverKey, setDragOverKey] = useState(null); + const dragOverKeyRef = useRef(null); const sections = collectStartSections(editorState); + const setDragOverKeyThrottled = useCallback((key: string | null) => { + if (dragOverKeyRef.current === key) return; + dragOverKeyRef.current = key; + setDragOverKey(key); + }, []); + const handleAddStart = useCallback(() => { if (onAddStart) { onAddStart(); @@ -406,6 +424,92 @@ export default function StrategyListEditor({ } }, [editorState, onEditorStateChange, onOrphansChange, orphans]); + const resolvePaletteNode = useCallback(( + data: { type: string; value: string; label: string; composite?: boolean }, + ) => { + if (data.type === 'sidewaysFilter') { + return buildSidewaysFilterNode(data.value, def); + } + return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); + }, [signalTab, def]); + + const applyPaletteDropAtKey = useCallback(( + dropKey: string | null, + data: PaletteDragPayload, + ) => { + if (!dropKey) return; + if (dropKey === LIST_DROP_KEY) { + if (data.type === 'start') handleAddStart(); + return; + } + + const rootMatch = dropKey.match(/^([^:]+):__root__$/); + const nodeMatch = dropKey.match(/^([^:]+):([^:]+)$/); + const startId = rootMatch?.[1] ?? nodeMatch?.[1]; + const targetId = rootMatch ? null : nodeMatch?.[2]; + if (!startId) return; + + if (data.type === 'start') { + handleAddStart(); + return; + } + + const section = collectStartSections(editorState).find(s => s.startId === startId); + if (!section) return; + + const newNode = resolvePaletteNode(data); + if (!newNode) return; + + const root = section.root; + if (!root) { + handleRootChange(startId, newNode); + return; + } + if (!targetId) { + handleRootChange(startId, mergeAtRoot(root, newNode, data.type === 'operator')); + return; + } + handleRootChange(startId, addChild(root, targetId, newNode)); + }, [ + editorState, + handleAddStart, + handleRootChange, + resolvePaletteNode, + ]); + + const applyDropRef = useRef(applyPaletteDropAtKey); + applyDropRef.current = applyPaletteDropAtKey; + + useLayoutEffect(() => { + if (!needsPointerPaletteDrag()) return undefined; + setPaletteDragDropHandler(ev => { + if (ev.phase !== 'end') return; + dragOverKeyRef.current = null; + setDragOverKey(null); + if (!isOverListEditor(ev.x, ev.y)) return; + applyDropRef.current(findPaletteDropKeyAtPoint(ev.x, ev.y), ev.payload); + }); + return () => setPaletteDragDropHandler(null); + }, []); + + useEffect(() => { + if (!needsPointerPaletteDrag()) return; + return subscribePaletteDrag(ev => { + if (ev.phase === 'move') { + if (!isOverListEditor(ev.x, ev.y)) { + setDragOverKeyThrottled(null); + return; + } + setDragOverKeyThrottled(findPaletteDropKeyAtPoint(ev.x, ev.y)); + return; + } + if (ev.phase === 'cancel') { + dragOverKeyRef.current = null; + setDragOverKey(null); + } + }); + }, [setDragOverKeyThrottled]); + const handleListDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; @@ -429,6 +533,7 @@ export default function StrategyListEditor({ return (
setDragOverKey(null)} onDrop={handleListDrop} diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts index c4f1650..b2e8ee2 100644 --- a/frontend/src/utils/flowScreenProjection.ts +++ b/frontend/src/utils/flowScreenProjection.ts @@ -72,6 +72,14 @@ export function projectScreenToFlowPosition( ); } +/** 목록 방식 편집기 위인지 */ +export function isOverListEditor(clientX: number, clientY: number): boolean { + for (const el of elementsUnderDragPoint(clientX, clientY)) { + if (el.closest('.se-list-editor')) return true; + } + return false; +} + /** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */ export function isOverStrategyBuilder(clientX: number, clientY: number): boolean { for (const el of elementsUnderDragPoint(clientX, clientY)) { diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 396d917..db2d0c5 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,6 +1,7 @@ /** * Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그 */ +import { elementsUnderDragPoint } from './flowScreenProjection'; import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { @@ -43,6 +44,8 @@ let overlayController: OverlayController | null = null; let overlayElement: HTMLElement | null = null; let listeners = new Set(); let dropHandler: PaletteDropHandler | null = null; +let moveRafId: number | null = null; +let pendingMove: { x: number; y: number } | null = null; export function needsPointerPaletteDrag(): boolean { return isDesktop(); @@ -88,6 +91,11 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) function cleanupDrag() { activePayload = null; activePointerId = null; + pendingMove = null; + if (moveRafId != null) { + cancelAnimationFrame(moveRafId); + moveRafId = null; + } document.body.classList.remove('se-palette-drag-active'); overlayController?.unmount(); document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); @@ -107,7 +115,15 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { export function notifyPaletteDragMove(clientX: number, clientY: number) { if (!activePayload) return; - emit({ phase: 'move', x: clientX, y: clientY, payload: activePayload }); + pendingMove = { x: clientX, y: clientY }; + if (moveRafId != null) return; + moveRafId = requestAnimationFrame(() => { + moveRafId = null; + if (!pendingMove || !activePayload) return; + const { x, y } = pendingMove; + pendingMove = null; + emit({ phase: 'move', x, y, payload: activePayload }); + }); } export function notifyPaletteDragEnd(clientX: number, clientY: number) { @@ -229,8 +245,9 @@ export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload } 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; + for (const el of elementsUnderDragPoint(x, y)) { + const host = el.closest('[data-palette-drop-key]') as HTMLElement | null; + if (host?.dataset.paletteDropKey) return host.dataset.paletteDropKey; + } + return null; }