전략 목록 드래그

This commit is contained in:
Macbook
2026-07-01 00:07:22 +09:00
parent 5438696fb3
commit 935ae02373
6 changed files with 169 additions and 12 deletions
+21 -3
View File
@@ -45,6 +45,8 @@ import {
isStartNodeId,
type StartCombineOp,
} from '../utils/strategyStartNodes';
import SavedStrategyDragBadge from './strategyEditor/SavedStrategyDragBadge';
import type { PaletteDragPayload } from '../utils/paletteDragSession';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
@@ -1448,6 +1450,21 @@ export default function StrategyEditorPage({
});
}, [signalTab, DEF, handleEditorStateChange]);
const buildSavedStrategyDragPayload = useCallback((s: StrategyDto): PaletteDragPayload | null => {
const side = buySellTab(signalTab);
const primaryRaw = side === 'sell' ? s.sellCondition : s.buyCondition;
const fallbackRaw = side === 'sell' ? s.buyCondition : s.sellCondition;
const primary = decodeConditionForEditor((primaryRaw ?? null) as LogicNode | null).root;
const condition = primary ?? decodeConditionForEditor((fallbackRaw ?? null) as LogicNode | null).root;
if (!condition) return null;
return {
type: 'savedStrategy',
value: String(s.id),
label: s.name ?? `전략 #${s.id}`,
condition,
};
}, [signalTab]);
const templateRows = useMemo(
() => buildStrategyTemplatePaletteRows({
def: DEF,
@@ -2089,9 +2106,10 @@ export default function StrategyEditorPage({
/>
</svg>
</button>
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}>
{s.enabled ? '활성' : '비활성'}
</span>
<SavedStrategyDragBadge
enabled={!!s.enabled}
buildPayload={() => buildSavedStrategyDragPayload(s)}
/>
</div>
</div>
);
@@ -0,0 +1,81 @@
import React from 'react';
import {
handlePaletteMouseDown,
handlePalettePointerDown,
handlePaletteTouchStart,
endHtmlDragMouseTracking,
needsPointerPaletteDrag,
startHtmlDragMouseTracking,
writePaletteHtmlDragData,
type PaletteDragPayload,
} from '../../utils/paletteDragSession';
interface Props {
enabled: boolean;
/** 드래그 시작 시점에 현재 탭(매수/매도)에 맞는 조건 payload 생성. 없으면 드래그 불가 */
buildPayload: () => PaletteDragPayload | null;
}
/**
* 좌측 전략 목록 아이템의 활성 배지 — 드래그하여 전략빌더(캔버스)에 조건 추가.
* 팔레트 칩과 동일한 DnD 세션(HTML5 + pointer)을 사용한다.
*/
export default function SavedStrategyDragBadge({ enabled, buildPayload }: Props) {
const pointerMode = needsPointerPaletteDrag();
const onDragStart = (e: React.DragEvent) => {
if (pointerMode) {
e.preventDefault();
return;
}
const payload = buildPayload();
if (!payload) {
e.preventDefault();
return;
}
e.stopPropagation();
writePaletteHtmlDragData(e, payload);
startHtmlDragMouseTracking(e.clientX, e.clientY, payload);
};
const onDragEnd = () => {
endHtmlDragMouseTracking();
};
const onPointerDown = (e: React.PointerEvent) => {
if (!pointerMode) return;
const payload = buildPayload();
if (!payload) return;
handlePalettePointerDown(e, payload, e.currentTarget as HTMLElement);
};
const onMouseDown = (e: React.MouseEvent) => {
if (!pointerMode) return;
const payload = buildPayload();
if (!payload) return;
handlePaletteMouseDown(e, payload, e.currentTarget as HTMLElement);
};
const onTouchStart = (e: React.TouchEvent) => {
if (!pointerMode) return;
const payload = buildPayload();
if (!payload) return;
handlePaletteTouchStart(e, payload, e.currentTarget as HTMLElement);
};
return (
<span
className={`se-strat-status se-strat-status--drag${enabled ? ' se-strat-status--on' : ''}`}
draggable={!pointerMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onPointerDownCapture={onPointerDown}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onClick={e => e.stopPropagation()}
title="드래그하여 전략빌더에 이 전략의 조건을 추가/연결"
>
{enabled ? '활성' : '비활성'}
</span>
);
}
@@ -20,6 +20,7 @@ import {
import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index';
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
import { normalizeConditionForPersistence } from '../../utils/strategyConditionNormalize';
import {
addChild,
@@ -609,7 +610,7 @@ function StrategyEditorCanvasInner({
}, [setNodes]);
const resolvePaletteDropNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
): LogicNode | null => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
@@ -617,6 +618,9 @@ function StrategyEditorCanvasInner({
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
if (data.type === 'savedStrategy') {
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
@@ -634,6 +638,20 @@ function StrategyEditorCanvasInner({
scheduleLayoutEmit();
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
/**
* 저장 전략(서브트리)은 단일 고아 노드로 추가하면 자식이 캔버스에 표시되지 않으므로
* 항상 루트에 AND 병합(빈 경우 루트로 설정)한다.
*/
const addSavedStrategyAtRoot = useCallback((
data: { type: string; value: string; label: string; condition?: LogicNode },
): boolean => {
const newNode = resolvePaletteDropNode(data);
if (!newNode) return false;
onChange(root ? mergeAtRoot(root, newNode, false) : newNode);
scheduleLayoutEmit();
return true;
}, [resolvePaletteDropNode, onChange, root, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback((
anchorId: string,
data: { type: string; value: string; label: string; composite?: boolean },
@@ -717,17 +735,21 @@ function StrategyEditorCanvasInner({
const handleDropTarget = useCallback((
anchorId: string,
data: { type: string; value: string; label: string },
data: { type: string; value: string; label: string; condition?: LogicNode },
flowPos: { x: number; y: number },
) => {
clearDropPreview();
const fallback = () => {
if (data.type === 'savedStrategy') addSavedStrategyAtRoot(data);
else addOrphanAt(data, flowPos);
};
if (isPaletteConnectTarget(anchorId, root, orphans)) {
const attached = applyDropWithAttach(anchorId, data, flowPos);
if (!attached) addOrphanAt(data, flowPos);
if (!attached) fallback();
} else {
addOrphanAt(data, flowPos);
fallback();
}
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, addSavedStrategyAtRoot, root, orphans]);
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
const next = normalizeConditionForPersistence(condition);
@@ -1295,7 +1317,7 @@ function StrategyEditorCanvasInner({
);
const applyPaletteDropAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
data: { type: string; value: string; label: string; composite?: boolean; condition?: LogicNode },
clientX: number,
clientY: number,
) => {
@@ -1312,12 +1334,16 @@ function StrategyEditorCanvasInner({
return;
}
addOrphanAt(data, flowPos);
if (data.type === 'savedStrategy') {
addSavedStrategyAtRoot(data);
} else {
addOrphanAt(data, flowPos);
}
requestAnimationFrame(() => {
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
});
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
}, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt, addSavedStrategyAtRoot]);
const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { LogicNode } from '../../utils/strategyTypes';
import { cloneLogicNodeWithNewIds } from '../../utils/strategyTypes';
import {
CondEditor,
addChild,
@@ -404,7 +405,7 @@ export default function StrategyListEditor({
}, [onEditorStateChange, onOrphansChange, orphans]);
const resolvePaletteNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string },
data: { type: string; value: string; label: string; composite?: boolean; lookback?: number; indicatorType?: string; leftField?: string; condition?: LogicNode },
) => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
@@ -412,6 +413,9 @@ export default function StrategyListEditor({
if (data.type === 'trendLine') {
return buildTrendLineConditionNodeFromDrag(data, signalTab, def);
}
if (data.type === 'savedStrategy') {
return data.condition ? cloneLogicNodeWithNewIds(data.condition) : null;
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
+13
View File
@@ -713,6 +713,19 @@
background: color-mix(in srgb, var(--se-gold) 15%, transparent);
border-color: color-mix(in srgb, var(--se-gold) 35%, transparent);
}
.se-strat-status--drag {
cursor: grab;
touch-action: none;
user-select: none;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.se-strat-status--drag:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
.se-strat-status--drag:active {
cursor: grabbing;
}
.se-empty {
font-size: 0.78rem;
+15
View File
@@ -358,6 +358,21 @@ export function generateNodeId(): string {
return `node_${++_nodeCounter}_${Date.now()}`;
}
/** 서브트리 깊은 복제 + 모든 노드 id 재생성 (저장 전략 드롭 시 id 충돌 방지) */
export function cloneLogicNodeWithNewIds(node: LogicNode): LogicNode {
return {
...node,
id: generateNodeId(),
condition: node.condition
? {
...node.condition,
params: node.condition.params ? { ...node.condition.params } : node.condition.params,
}
: node.condition,
children: node.children ? node.children.map(cloneLogicNodeWithNewIds) : undefined,
};
}
/** 자연어 설명 변환 */
export function nodeToNaturalLanguage(node: LogicNode): string {
if (!node) return '';