From 935ae02373fd66250a7287d6f610ba82ed13e9eb Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 1 Jul 2026 00:07:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 24 +++++- .../strategyEditor/SavedStrategyDragBadge.tsx | 81 +++++++++++++++++++ .../strategyEditor/StrategyEditorCanvas.tsx | 42 ++++++++-- .../strategyEditor/StrategyListEditor.tsx | 6 +- frontend/src/styles/strategyEditor.css | 13 +++ frontend/src/utils/strategyTypes.ts | 15 ++++ 6 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 3ef5aca..8b05494 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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({ /> - - {s.enabled ? '활성' : '비활성'} - + buildSavedStrategyDragPayload(s)} + /> ); diff --git a/frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx b/frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx new file mode 100644 index 0000000..cf8dba1 --- /dev/null +++ b/frontend/src/components/strategyEditor/SavedStrategyDragBadge.tsx @@ -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 ( + e.stopPropagation()} + title="드래그하여 전략빌더에 이 전략의 조건을 추가/연결" + > + {enabled ? '활성' : '비활성'} + + ); +} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index b435db1..d34d648 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -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(); diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx index b3538df..4a85bf9 100644 --- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx +++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx @@ -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]); diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 5f063aa..75f8738 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -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; diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 442a39d..93faff2 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -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 '';