diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 72b7b1a..ba569aa 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -34,6 +34,7 @@ import { normalizeStartCombineOp, updateStartCombineOp, updateStartCandleTypes, + updateBranchRoot, type EditorConditionState, } from '../utils/strategyConditionSerde'; import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize'; @@ -778,6 +779,7 @@ export default function StrategyEditorPage({ const resolved = typeof next === 'function' ? next(currentEditorStateRef.current) : next; + currentEditorStateRef.current = resolved; setCurrentRoot(resolved.root); setCurrentLayout(prev => ({ ...prev, @@ -790,12 +792,12 @@ export default function StrategyEditorPage({ }, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]); const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { - handleEditorStateChange(updateStartCandleTypes(currentEditorState, startId, candleTypes)); - }, [handleEditorStateChange, currentEditorState]); + handleEditorStateChange(prev => updateStartCandleTypes(prev, startId, candleTypes)); + }, [handleEditorStateChange]); const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { - handleEditorStateChange(updateStartCombineOp(currentEditorState, op)); - }, [handleEditorStateChange, currentEditorState]); + handleEditorStateChange(prev => updateStartCombineOp(prev, op)); + }, [handleEditorStateChange]); const switchSignalTab = useCallback((tab: 'buy' | 'sell') => { if (editorMode === 'graph') layoutFlushRef.current?.(); @@ -1328,9 +1330,9 @@ export default function StrategyEditorPage({ }, []); const handleAddStartSection = useCallback(() => { - handleEditorStateChange(addExtraStartSection(currentEditorState)); + handleEditorStateChange(prev => addExtraStartSection(prev)); bumpLayoutSeed(layoutStrategyKey, signalTab); - }, [handleEditorStateChange, currentEditorState, bumpLayoutSeed, layoutStrategyKey, signalTab]); + }, [handleEditorStateChange, bumpLayoutSeed, layoutStrategyKey, signalTab]); const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => { if (type === 'start') { @@ -1338,12 +1340,15 @@ export default function StrategyEditorPage({ return; } const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined); - const root = currentRoot; - if (!root) setCurrentRoot(newNode); - else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true)); - else setCurrentRoot(mergeAtRoot(root, newNode, false)); - scheduleStrategyPersist(); - }, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]); + handleEditorStateChange(prev => { + const root = prev.root; + if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode); + if (type === 'operator') { + return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, true)); + } + return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false)); + }); + }, [signalTab, DEF, handleEditorStateChange, handleAddStartSection]); const applyPaletteItem = useCallback((item: PaletteItem) => { const stochPair = isStochOverboughtPairPaletteValue(item.value); @@ -1361,11 +1366,12 @@ export default function StrategyEditorPage({ ? buildStableStrategyTree(item.value, DEF) : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); if (!newNode) return; - const root = currentRoot; - if (!root) setCurrentRoot(newNode); - else setCurrentRoot(mergeAtRoot(root, newNode, false)); - scheduleStrategyPersist(); - }, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]); + handleEditorStateChange(prev => { + const root = prev.root; + if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode); + return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false)); + }); + }, [signalTab, DEF, handleEditorStateChange]); const applySidewaysFilter = useCallback((filterId: string) => { const newNode = buildSidewaysFilterNode(filterId, DEF, sidewaysPalette); diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index bea77f1..d97e446 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -41,6 +41,7 @@ import { setHtmlPaletteDropRouter, setPaletteDragDropHandler, subscribePaletteDrag, + wasPaletteHtmlDropCommitted, type PaletteDragPayload, } from '../../utils/paletteDragSession'; @@ -108,12 +109,18 @@ const TreeNodeComp: React.FC = ({ e.stopPropagation(); setDragOverKey?.(nodeDropKey); }; - const handleDragLeave = () => setDragOverKey?.(null); + 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') { @@ -338,6 +345,7 @@ function StartSectionBlock({ e.preventDefault(); e.stopPropagation(); setDragOverKey(null); + if (wasPaletteHtmlDropCommitted()) return; const data = readPaletteHtmlDragData(e); if (!data) return; applyDrop(null, data); @@ -375,7 +383,12 @@ function StartSectionBlock({ e.stopPropagation(); setDragOverKey(sectionDropKey); }} - onDragLeave={() => setDragOverKey(null)} + onDragLeave={e => { + const rel = e.relatedTarget as Node | null; + if (!rel) return; + if (e.currentTarget.contains(rel)) return; + setDragOverKey(null); + }} onDrop={handleSectionDrop} > {!root ? ( @@ -561,13 +574,14 @@ export default function StrategyListEditor({ const handleListDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; - setDragOverKey(LIST_DROP_KEY); + 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); @@ -578,7 +592,12 @@ export default function StrategyListEditor({ className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`} data-palette-drop-key={LIST_DROP_KEY} onDragOver={handleListDragOver} - onDragLeave={() => setDragOverKey(null)} + onDragLeave={e => { + const rel = e.relatedTarget as Node | null; + if (!rel) return; + if (e.currentTarget.contains(rel)) return; + setDragOverKey(null); + }} onDrop={handleListDrop} > {sections.map(section => ( @@ -591,7 +610,7 @@ export default function StrategyListEditor({ signalTab={signalTab} def={def} dragOverKey={dragOverKey} - setDragOverKey={setDragOverKey} + setDragOverKey={setDragOverKeyThrottled} onRootChange={root => handleRootChange(section.startId, root)} onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)} onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined} diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 6bfdb90..7a013e8 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -78,6 +78,10 @@ type HtmlPaletteDropRouter = ( let htmlDropRouter: HtmlPaletteDropRouter | null = null; let htmlDropCommitted = false; +export function wasPaletteHtmlDropCommitted(): boolean { + return htmlDropCommitted; +} + export function setHtmlPaletteDropRouter(router: HtmlPaletteDropRouter | null): void { htmlDropRouter = router; }