diff --git a/frontend/src/App.css b/frontend/src/App.css index 0113a37..ed9c592 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -7599,7 +7599,8 @@ html.desktop-client .tmb-logo-version { background: var(--bg3); transition: border-color .15s; } -.sp-tree-node--over { +.sp-tree-node--over, +.sp-tree-node.sp-drag-over { border-color: var(--accent) !important; box-shadow: 0 0 0 2px rgba(122,162,247,.25); } diff --git a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx index 73a9e26..2b5bb66 100644 --- a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx +++ b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx @@ -113,6 +113,7 @@ export default function ComboFieldSelect({ return; } const clamped = clampValue(parsed, min, max, allowDecimal); + if (clamped === customNumber) return; onCustomNumberChange(clamped); setDraft(String(clamped)); }, [draft, allowDecimal, min, max, onCustomNumberChange, customNumber]); @@ -120,8 +121,15 @@ export default function ComboFieldSelect({ const commitRef = useRef(commitCustom); commitRef.current = commitCustom; - // 직접입력 blur 전 탭 전환·저장 시 값 유실 방지 - useEffect(() => () => { commitRef.current(); }, []); + const focusedRef = useRef(false); + useEffect(() => { + focusedRef.current = focused; + }, [focused]); + + // 직접입력 blur 전 탭 전환·저장 시 값 유실 방지 (포커스 중일 때만 — unmount 시 stale onChange 방지) + useEffect(() => () => { + if (focusedRef.current) commitRef.current(); + }, []); useEffect(() => { if (!focused || mode !== 'custom') return; diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index d97e446..1f195dc 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -7,6 +7,7 @@ import { makeNodeOptionsFromPalette, mergeAtRoot, nodeToText, + updateNode, type DefType, } from '../../utils/strategyEditorShared'; import { hasNodeSettings } from '../../utils/conditionPeriods'; @@ -35,13 +36,11 @@ import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes'; import { START_NODE_ID } from '../../utils/strategyFlowLayout'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { + clearPaletteDropHighlights, findPaletteDropKeyAtPoint, needsPointerPaletteDrag, - readPaletteHtmlDragData, setHtmlPaletteDropRouter, setPaletteDragDropHandler, - subscribePaletteDrag, - wasPaletteHtmlDropCommitted, type PaletteDragPayload, } from '../../utils/paletteDragSession'; @@ -53,18 +52,20 @@ const NODE_COLORS: Record = { const IND_COLOR = '#9c27b0'; const LIST_DROP_KEY = '__list__'; +/** HTML5 드롭 허용 — 하이라이트·로컬 drop 은 전역 라우터만 사용 (DOM 교체 시 dragenter 루프 방지) */ +const allowPaletteDrop = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; +}; + interface TreeNodeProps { node: LogicNode; signalType: 'buy' | 'sell'; - onUpdate: (n: LogicNode) => void; + onUpdate: (n: LogicNode | ((prev: LogicNode) => LogicNode)) => void; onDelete: () => void; - onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => void; - dragOverKey?: string | null; - setDragOverKey?: (key: string | null) => void; dropScope: string; depth?: number; def: DefType; - onAddStart?: () => void; stochPairForest?: (LogicNode | null)[]; onStochPairSecondaryChange?: (orNodeId: string, secondary: string) => void; } @@ -74,13 +75,9 @@ const TreeNodeComp: React.FC = ({ signalType, onUpdate, onDelete, - onDropOnNode, - dragOverKey, - setDragOverKey, dropScope, depth = 0, def, - onAddStart, stochPairForest, onStochPairSecondaryChange, }) => { @@ -103,37 +100,9 @@ const TreeNodeComp: React.FC = ({ ? 'OR (또는)' : 'NOT (반대)'; - const handleDragOver = (e: React.DragEvent) => { - if (!isLogic) return; - e.preventDefault(); - e.stopPropagation(); - setDragOverKey?.(nodeDropKey); + const handleCondChange = (c: NonNullable) => { + onUpdate(prev => updateNode(prev, node.id, n => ({ ...n, condition: c }))); }; - const handleDragLeave = (e: React.DragEvent) => { - const rel = e.relatedTarget as Node | null; - if (!rel) return; - if (e.currentTarget.contains(rel)) return; - setDragOverKey?.(null); - }; - const handleDrop = (e: React.DragEvent) => { - if (!isLogic) return; - e.preventDefault(); - e.stopPropagation(); - setDragOverKey?.(null); - if (wasPaletteHtmlDropCommitted()) return; - try { - const data = JSON.parse(e.dataTransfer.getData('application/json')); - if (data.type === 'start') { - onAddStart?.(); - return; - } - onDropOnNode?.(node.id, data); - } catch { - /* ignore */ - } - }; - - const handleCondChange = (c: NonNullable) => onUpdate({ ...node, condition: c }); const stochPairRoot = stochPairForest && node.condition ? findStochPairRootInForest(stochPairForest, node.id) @@ -146,19 +115,29 @@ const TreeNodeComp: React.FC = ({ } : undefined; - const handleChildUpdate = (childId: string, updated: LogicNode) => - onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) }); - const handleChildDelete = (childId: string) => - onUpdate({ ...node, children: (node.children ?? []).filter(c => c.id !== childId) }); + const handleChildUpdate = ( + childId: string, + updated: LogicNode | ((prev: LogicNode) => LogicNode), + ) => { + if (typeof updated === 'function') { + onUpdate(updated); + return; + } + onUpdate(prev => updateNode(prev, childId, () => updated)); + }; + const handleChildDelete = (childId: string) => { + onUpdate(prev => updateNode(prev, node.id, n => ({ + ...n, + children: (n.children ?? []).filter(c => c.id !== childId), + }))); + }; return (
0 ? 12 : 0 }} data-palette-drop-key={isLogic ? nodeDropKey : undefined} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} + onDragOver={isLogic ? allowPaletteDrop : undefined} >
@@ -173,11 +152,11 @@ const TreeNodeComp: React.FC = ({ {(node.type === 'AND' || node.type === 'OR') && ( onUpdate({ - ...node, + onChange={op => onUpdate(prev => updateNode(prev, node.id, n => ({ + ...n, type: op, - stochPair: op === 'OR' ? node.stochPair : undefined, - })} + stochPair: op === 'OR' ? n.stochPair : undefined, + })))} compact /> )} @@ -228,7 +207,7 @@ const TreeNodeComp: React.FC = ({ signalType={signalType} nodeId={node.id} onChange={handleCondChange} - onReplaceNode={onUpdate} + onReplaceNode={next => onUpdate(prev => updateNode(prev, node.id, () => next))} onClose={() => setPresetOpen(false)} popoverClassName="sp-node-preset-pop" /> @@ -246,7 +225,7 @@ const TreeNodeComp: React.FC = ({ {settingsOpen && isStochPair && node.stochPair && ( onUpdate(setStochPairSecondary(node, sec, def))} + onChange={sec => onUpdate(prev => updateNode(prev, node.id, n => setStochPairSecondary(n, sec, def)))} onClose={() => setSettingsOpen(false)} popoverClassName="sp-node-settings-pop" /> @@ -272,20 +251,13 @@ const TreeNodeComp: React.FC = ({ signalType={signalType} onUpdate={u => handleChildUpdate(child.id, u)} onDelete={() => handleChildDelete(child.id)} - onDropOnNode={onDropOnNode} - dragOverKey={dragOverKey} - setDragOverKey={setDragOverKey} dropScope={dropScope} depth={depth + 1} def={def} - onAddStart={onAddStart} stochPairForest={stochPairForest} onStochPairSecondaryChange={onStochPairSecondaryChange} /> ))} - {dragOverKey === nodeDropKey && ( -
여기에 드롭하세요
- )}
)}
@@ -299,12 +271,9 @@ interface StartSectionBlockProps { canDelete: boolean; signalTab: 'buy' | 'sell'; def: DefType; - dragOverKey: string | null; - setDragOverKey: (key: string | null) => void; onRootChange: (root: LogicNode | null | ((prev: LogicNode | null) => LogicNode | null)) => void; onCandleTypesChange: (candleTypes: string[]) => void; onDeleteStart?: () => void; - onAddStart?: () => void; } function StartSectionBlock({ @@ -314,51 +283,16 @@ function StartSectionBlock({ canDelete, signalTab, def, - dragOverKey, - setDragOverKey, onRootChange, onCandleTypesChange, onDeleteStart, - onAddStart, }: StartSectionBlockProps) { const sectionDropKey = `${startId}:__root__`; - const applyDrop = useCallback(( - targetId: string | null, - data: { type: string; value: string; label: string; composite?: boolean }, - ) => { - if (data.type === 'start') { - onAddStart?.(); - return; - } - const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data)); - onRootChange(prev => { - if (!prev) return newNode; - if (!targetId) { - return mergeAtRoot(prev, newNode, data.type === 'operator'); - } - return addChild(prev, targetId, newNode); - }); - }, [signalTab, def, onRootChange, onAddStart]); - - const handleSectionDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverKey(null); - if (wasPaletteHtmlDropCommitted()) return; - const data = readPaletteHtmlDragData(e); - if (!data) return; - applyDrop(null, data); - }; - - const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => { - applyDrop(targetId, data); - }; - const stochPairForest = useMemo(() => [root], [root]); const handleStochPairSecondaryChange = useCallback((orNodeId: string, secondary: string) => { - onRootChange(patchStochPairInTree(root, orNodeId, secondary, def)); - }, [root, def, onRootChange]); + onRootChange(prev => patchStochPairInTree(prev, orNodeId, secondary, def)); + }, [def, onRootChange]); return (
@@ -376,20 +310,9 @@ function StartSectionBlock({
{ - e.preventDefault(); - e.stopPropagation(); - setDragOverKey(sectionDropKey); - }} - onDragLeave={e => { - const rel = e.relatedTarget as Node | null; - if (!rel) return; - if (e.currentTarget.contains(rel)) return; - setDragOverKey(null); - }} - onDrop={handleSectionDrop} + onDragOver={allowPaletteDrop} > {!root ? (
@@ -399,14 +322,16 @@ function StartSectionBlock({ { + if (typeof next === 'function') { + onRootChange(prev => (prev ? next(prev) : prev)); + } else { + onRootChange(next); + } + }} onDelete={() => onRootChange(null)} - onDropOnNode={handleDropOnNode} - dragOverKey={dragOverKey} - setDragOverKey={setDragOverKey} dropScope={startId} def={def} - onAddStart={onAddStart} stochPairForest={stochPairForest} onStochPairSecondaryChange={handleStochPairSecondaryChange} /> @@ -437,16 +362,8 @@ export default function StrategyListEditor({ onOrphansChange, 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(); @@ -537,12 +454,22 @@ export default function StrategyListEditor({ const applyDropRef = useRef(applyPaletteDropAtKey); applyDropRef.current = applyPaletteDropAtKey; + const schedulePaletteDrop = useCallback((x: number, y: number, payload: PaletteDragPayload) => { + const dropKey = findPaletteDropKeyAtPoint(x, y); + // dragend/DOM 교체 이후 한 프레임 뒤 적용 — dragenter 루프·이중 추가 방지 + requestAnimationFrame(() => { + applyDropRef.current(dropKey, payload); + }); + }, []); + + const scheduleDropRef = useRef(schedulePaletteDrop); + scheduleDropRef.current = schedulePaletteDrop; + useLayoutEffect(() => { if (needsPointerPaletteDrag()) return undefined; setHtmlPaletteDropRouter((x, y, payload) => { - dragOverKeyRef.current = null; - setDragOverKey(null); - applyDropRef.current(findPaletteDropKeyAtPoint(x, y), payload); + clearPaletteDropHighlights(); + scheduleDropRef.current(x, y, payload); }); return () => setHtmlPaletteDropRouter(null); }, []); @@ -551,54 +478,19 @@ export default function StrategyListEditor({ if (!needsPointerPaletteDrag()) return undefined; setPaletteDragDropHandler(ev => { if (ev.phase !== 'end') return; - dragOverKeyRef.current = null; - setDragOverKey(null); - applyDropRef.current(findPaletteDropKeyAtPoint(ev.x, ev.y), ev.payload); + clearPaletteDropHighlights(); + scheduleDropRef.current(ev.x, ev.y, ev.payload); }); return () => setPaletteDragDropHandler(null); }, []); - useEffect(() => { - return subscribePaletteDrag(ev => { - if (ev.phase === 'move') { - 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'; - setDragOverKeyThrottled(LIST_DROP_KEY); - }; - - const handleListDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverKey(null); - if (wasPaletteHtmlDropCommitted()) return; - const data = readPaletteHtmlDragData(e); - if (!data) return; - applyDropRef.current(findPaletteDropKeyAtPoint(e.clientX, e.clientY) ?? LIST_DROP_KEY, data); - }; + useEffect(() => () => clearPaletteDropHighlights(), []); return (
{ - const rel = e.relatedTarget as Node | null; - if (!rel) return; - if (e.currentTarget.contains(rel)) return; - setDragOverKey(null); - }} - onDrop={handleListDrop} + onDragOver={allowPaletteDrop} > {sections.map(section => ( handleRootChange(section.startId, root)} onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)} onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined} - onAddStart={handleAddStart} /> ))} {sections.length === 0 && ( diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index f8c4206..77955f1 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -240,7 +240,8 @@ padding-bottom: 28px; } -.se-list-editor--over { +.se-list-editor--over, +.se-list-editor.sp-drag-over { border-color: var(--se-node-start-border, var(--se-gold)); background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, var(--se-center-bg)); } @@ -421,7 +422,8 @@ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); } -.sp-start-body--over { +.sp-start-body--over, +.sp-start-body.sp-drag-over { background: color-mix(in srgb, var(--se-accent) 10%, transparent); } diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 7a013e8..74636ad 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -77,11 +77,30 @@ type HtmlPaletteDropRouter = ( let htmlDropRouter: HtmlPaletteDropRouter | null = null; let htmlDropCommitted = false; +let paletteDropConsumed = false; export function wasPaletteHtmlDropCommitted(): boolean { return htmlDropCommitted; } +/** 목록 편집기 등 — 드래그 하이라이트 클래스 일괄 제거 */ +export function clearPaletteDropHighlights(): void { + if (typeof document === 'undefined') return; + document.querySelectorAll('.sp-drag-over').forEach(el => { + el.classList.remove('sp-drag-over'); + }); +} + +function resetPaletteDropConsumed(): void { + paletteDropConsumed = false; +} + +function tryConsumePaletteDrop(): boolean { + if (paletteDropConsumed) return false; + paletteDropConsumed = true; + return true; +} + export function setHtmlPaletteDropRouter(router: HtmlPaletteDropRouter | null): void { htmlDropRouter = router; } @@ -180,10 +199,15 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { if (phase === 'end') { suppressClickUntil = Date.now() + 400; - dispatchEnd(dropXY, payload); + if (tryConsumePaletteDrop()) { + dispatchEnd(dropXY, payload); + } else { + emit({ phase: 'end', x: dropXY.x, y: dropXY.y, payload }); + } } else { emit({ phase: 'cancel', x: dropXY.x, y: dropXY.y, payload }); } + clearPaletteDropHighlights(); cleanupDrag(); } @@ -197,6 +221,8 @@ function mountDragOverlay( activePayload = payload; activePointerId = pointerId; lastDragClientXY = { x: xy.x, y: xy.y }; + resetPaletteDropConsumed(); + clearPaletteDropHighlights(); document.body.classList.add('se-palette-drag-active'); armPaletteDragScrollLock(); overlayController?.mount({ @@ -375,9 +401,12 @@ function installHtmlPaletteDragGlobals() { const payload = activeHtmlDragPayload; const xy = lastHtmlDragClientXY; if (htmlPaletteDragActive && !htmlDropCommitted && payload && xy && htmlDropRouter) { - htmlDropRouter(xy.x, xy.y, payload); + if (tryConsumePaletteDrop()) { + htmlDropRouter(xy.x, xy.y, payload); + } } htmlDropCommitted = false; + clearPaletteDropHighlights(); stopHtmlDragMouseTracking(); htmlPaletteDragActive = false; lastHtmlDragClientXY = null; @@ -390,9 +419,11 @@ function installHtmlPaletteDragGlobals() { e.stopPropagation(); const payload = readPaletteHtmlDragDataFromEvent(e) ?? activeHtmlDragPayload; if (!payload || !htmlDropRouter || htmlDropCommitted) return; + if (!tryConsumePaletteDrop()) return; htmlDropCommitted = true; const x = e.clientX !== 0 || e.clientY !== 0 ? e.clientX : (lastHtmlDragClientXY?.x ?? 0); const y = e.clientX !== 0 || e.clientY !== 0 ? e.clientY : (lastHtmlDragClientXY?.y ?? 0); + clearPaletteDropHighlights(); htmlDropRouter(x, y, payload); }, true); } @@ -432,6 +463,8 @@ export function endHtmlDragMouseTracking(): void { export function markPaletteHtmlDragStart(): void { htmlPaletteDragActive = true; htmlDropCommitted = false; + resetPaletteDropConsumed(); + clearPaletteDropHighlights(); lastHtmlDragClientXY = null; } @@ -484,15 +517,16 @@ export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload export function findPaletteDropKeyAtPoint(x: number, y: number): string | null { const hosts = Array.from(document.querySelectorAll('[data-palette-drop-key]')); - for (let i = hosts.length - 1; i >= 0; i--) { - const host = hosts[i]; + let best: { key: string; area: number } | null = null; + for (const host of hosts) { const key = host.dataset.paletteDropKey; if (!key) continue; const rect = host.getBoundingClientRect(); - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { - return key; - } + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) continue; + const area = rect.width * rect.height; + if (!best || area < best.area) best = { key, area }; } + if (best) return best.key; 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;