전략편집기 수정
This commit is contained in:
@@ -19,8 +19,18 @@ import {
|
||||
type StrategyDto,
|
||||
} from '../utils/strategyEditorShared';
|
||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||
import {
|
||||
emptySignalFlowLayout,
|
||||
loadStrategyFlowLayout,
|
||||
migrateStrategyFlowLayout,
|
||||
saveStrategyFlowLayout,
|
||||
deleteStrategyFlowLayout,
|
||||
type SignalFlowLayoutSnapshot,
|
||||
type FlowLayoutChangePayload,
|
||||
} from '../utils/strategyEditorLayoutStorage';
|
||||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||||
import StrategyEditorCanvas from './strategyEditor/StrategyEditorCanvas';
|
||||
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
||||
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
||||
import PaletteChip from './strategyEditor/PaletteChip';
|
||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||
import '../styles/strategyEditor.css';
|
||||
@@ -38,6 +48,17 @@ interface Props {
|
||||
activeIndicators?: IndicatorConfig[];
|
||||
}
|
||||
|
||||
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
||||
const stored = loadStrategyFlowLayout(strategyKey);
|
||||
const snap = stored?.[tab];
|
||||
if (!snap) return emptySignalFlowLayout();
|
||||
return {
|
||||
positions: snap.positions ?? {},
|
||||
edgeHandles: snap.edgeHandles ?? {},
|
||||
orphans: snap.orphans ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme, activeIndicators = [] }: Props) {
|
||||
const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]);
|
||||
|
||||
@@ -55,17 +76,36 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
|
||||
const initialDraftBuy = readTabLayout('draft', 'buy');
|
||||
const initialDraftSell = readTabLayout('draft', 'sell');
|
||||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
|
||||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
|
||||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
const [saveToast, setSaveToast] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const terminalHeightRef = useRef(terminalHeight);
|
||||
const [buyLayout, setBuyLayout] = useState(() => ({
|
||||
positions: initialDraftBuy.positions,
|
||||
edgeHandles: initialDraftBuy.edgeHandles,
|
||||
}));
|
||||
const [sellLayout, setSellLayout] = useState(() => ({
|
||||
positions: initialDraftSell.positions,
|
||||
edgeHandles: initialDraftSell.edgeHandles,
|
||||
}));
|
||||
const buyLayoutRef = useRef(buyLayout);
|
||||
const sellLayoutRef = useRef(sellLayout);
|
||||
buyLayoutRef.current = buyLayout;
|
||||
sellLayoutRef.current = sellLayout;
|
||||
const layoutRevisionRef = useRef(0);
|
||||
const persistLayoutTimerRef = useRef<number | null>(null);
|
||||
const layoutPersistReadyRef = useRef(false);
|
||||
leftWidthRef.current = leftWidth;
|
||||
terminalHeightRef.current = terminalHeight;
|
||||
|
||||
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
||||
|
||||
const onLeftSplitter = usePanelResize(
|
||||
'vertical',
|
||||
setLeftWidth,
|
||||
@@ -106,6 +146,105 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
|
||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||
|
||||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||
|
||||
const persistFlowLayout = useCallback((strategyKey: string) => {
|
||||
saveStrategyFlowLayout(strategyKey, {
|
||||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||||
});
|
||||
}, [buyOrphans, sellOrphans]);
|
||||
|
||||
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
|
||||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||||
persistLayoutTimerRef.current = window.setTimeout(() => {
|
||||
persistLayoutTimerRef.current = null;
|
||||
persistFlowLayout(strategyKey);
|
||||
}, 150);
|
||||
}, [persistFlowLayout]);
|
||||
|
||||
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
|
||||
layoutRevisionRef.current += 1;
|
||||
setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||||
}, []);
|
||||
|
||||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(emptyBuy);
|
||||
setSellLayout(emptySell);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
||||
bumpLayoutSeed(strategyKey, tab);
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||||
const stored = loadStrategyFlowLayout(strategyKey);
|
||||
if (stored) {
|
||||
const nextBuy = {
|
||||
positions: stored.buy.positions ?? {},
|
||||
edgeHandles: stored.buy.edgeHandles ?? {},
|
||||
};
|
||||
const nextSell = {
|
||||
positions: stored.sell.positions ?? {},
|
||||
edgeHandles: stored.sell.edgeHandles ?? {},
|
||||
};
|
||||
setBuyLayout(nextBuy);
|
||||
setSellLayout(nextSell);
|
||||
setBuyOrphans(stored.buy.orphans ?? []);
|
||||
setSellOrphans(stored.sell.orphans ?? []);
|
||||
} else {
|
||||
const emptyBuy = emptySignalFlowLayout();
|
||||
const emptySell = emptySignalFlowLayout();
|
||||
setBuyLayout(emptyBuy);
|
||||
setSellLayout(emptySell);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
}
|
||||
bumpLayoutSeed(strategyKey, tab);
|
||||
}, [bumpLayoutSeed]);
|
||||
|
||||
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
|
||||
const next = {
|
||||
positions: snapshot.positions,
|
||||
edgeHandles: snapshot.edgeHandles,
|
||||
};
|
||||
if (snapshot.tab === 'buy') setBuyLayout(next);
|
||||
else setSellLayout(next);
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||||
layoutFlushRef.current?.();
|
||||
persistFlowLayout(layoutStrategyKey);
|
||||
layoutRevisionRef.current += 1;
|
||||
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||||
setSignalTab(tab);
|
||||
setSelectedNodeId(null);
|
||||
}, [layoutStrategyKey, persistFlowLayout]);
|
||||
|
||||
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
||||
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
||||
return {
|
||||
seedKey: layoutSeedKey,
|
||||
positions: source.positions,
|
||||
edgeHandles: source.edgeHandles,
|
||||
};
|
||||
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutPersistReadyRef.current) {
|
||||
layoutPersistReadyRef.current = true;
|
||||
return;
|
||||
}
|
||||
schedulePersistFlowLayout(layoutStrategyKey);
|
||||
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||
|
||||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -159,9 +298,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
setStratDesc(s.description ?? '');
|
||||
setBuyCondition(s.buyCondition ?? null);
|
||||
setSellCondition(s.sellCondition ?? null);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
applyStoredFlowLayout(String(s.id), signalTab);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
@@ -170,9 +308,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
setStratDesc('');
|
||||
setBuyCondition(null);
|
||||
setSellCondition(null);
|
||||
setBuyOrphans([]);
|
||||
setSellOrphans([]);
|
||||
setSelectedNodeId(null);
|
||||
resetFlowLayout('draft', signalTab);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -191,6 +328,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
const saved = await saveStrategy(payload);
|
||||
const now = new Date().toISOString();
|
||||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||||
const wasDraft = selectedId == null;
|
||||
setStrategies(prev => {
|
||||
const existing = prev.find(s => s.id === selectedId);
|
||||
if (existing && selectedId) {
|
||||
@@ -202,6 +340,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||||
});
|
||||
if (!selectedId) setSelectedId(dbId);
|
||||
if (wasDraft) {
|
||||
migrateStrategyFlowLayout('draft', String(dbId));
|
||||
}
|
||||
persistFlowLayout(String(dbId));
|
||||
bumpLayoutSeed(String(dbId), signalTab);
|
||||
setSaveOpen(false);
|
||||
setSaveToast(true);
|
||||
setTimeout(() => setSaveToast(false), 2500);
|
||||
@@ -216,6 +359,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteId) return;
|
||||
try { await deleteStrategy(deleteId); } catch { /* local */ }
|
||||
deleteStrategyFlowLayout(String(deleteId));
|
||||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||||
if (selectedId === deleteId) handleNew();
|
||||
setDeleteOpen(false);
|
||||
@@ -245,7 +389,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
id: genId(), type: 'CONDITION',
|
||||
condition: { indicatorType: tmpl.ind, conditionType: tmpl.cond, leftField: tmpl.l, rightField: tmpl.r, candleRange: 1 },
|
||||
};
|
||||
if (tmpl.signal !== signalTab) setSignalTab(tmpl.signal);
|
||||
if (tmpl.signal !== signalTab) switchSignalTab(tmpl.signal);
|
||||
const root = tmpl.signal === 'buy' ? buyCondition : sellCondition;
|
||||
const setRoot = tmpl.signal === 'buy' ? setBuyCondition : setSellCondition;
|
||||
if (!root) setRoot(newNode);
|
||||
@@ -380,14 +524,14 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
<button
|
||||
type="button"
|
||||
className={`se-signal-tab${signalTab === 'buy' ? ' se-signal-tab--buy-on' : ''}`}
|
||||
onClick={() => { setSignalTab('buy'); setSelectedNodeId(null); }}
|
||||
onClick={() => switchSignalTab('buy')}
|
||||
>
|
||||
매수 조건 (Entry)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-signal-tab${signalTab === 'sell' ? ' se-signal-tab--sell-on' : ''}`}
|
||||
onClick={() => { setSignalTab('sell'); setSelectedNodeId(null); }}
|
||||
onClick={() => switchSignalTab('sell')}
|
||||
>
|
||||
매도 조건 (Exit)
|
||||
</button>
|
||||
@@ -406,6 +550,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
onChange={setCurrentRoot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
layoutSeed={layoutSeed}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
|
||||
toastNotifications,
|
||||
dismissToast,
|
||||
dismissToasts,
|
||||
dismissAllToasts,
|
||||
openDetail,
|
||||
} = useTradeNotification();
|
||||
|
||||
@@ -94,17 +93,23 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
|
||||
}, [totalPages]);
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
dismissAllToasts();
|
||||
if (toastNotifications.length === 0) return;
|
||||
dismissToasts(toastNotifications.map(n => n.id));
|
||||
setPageIndex(0);
|
||||
positionsRef.current = {};
|
||||
}, [dismissAllToasts]);
|
||||
}, [dismissToasts, toastNotifications]);
|
||||
|
||||
const dismissCurrentPage = useCallback(() => {
|
||||
dismissToasts(pageItems.map(i => i.id));
|
||||
const ids = pageItems.map(i => i.id);
|
||||
if (ids.length === 0) return;
|
||||
dismissToasts(ids);
|
||||
positionsRef.current = Object.fromEntries(
|
||||
Object.entries(positionsRef.current).filter(([id]) => !pageItems.some(p => p.id === id)),
|
||||
Object.entries(positionsRef.current).filter(([id]) => !ids.includes(id)),
|
||||
);
|
||||
}, [dismissToasts, pageItems]);
|
||||
const remaining = toastNotifications.length - ids.length;
|
||||
const newTotalPages = Math.max(1, Math.ceil(remaining / pageSize));
|
||||
setPageIndex(p => Math.min(p, newTotalPages - 1));
|
||||
}, [dismissToasts, pageItems, toastNotifications.length, pageSize]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.PointerEvent, key: string) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -221,17 +226,19 @@ export const TradeSignalSnackbar: React.FC<Props> = ({
|
||||
type="button"
|
||||
className="tsn-dismiss-page"
|
||||
onClick={dismissCurrentPage}
|
||||
title={`현재 화면 알림 ${pageItems.length}건 닫기`}
|
||||
disabled={pageItems.length === 0}
|
||||
title={`현재 화면에 보이는 알림 ${pageItems.length}건 닫기`}
|
||||
>
|
||||
알림창닫기 ({pageItems.length})
|
||||
알림창 닫기 ({pageItems.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tsn-dismiss-all"
|
||||
onClick={dismissAll}
|
||||
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기`}
|
||||
disabled={toastNotifications.length === 0}
|
||||
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기 (모든 페이지)`}
|
||||
>
|
||||
전체닫기 ({toastNotifications.length})
|
||||
전체 닫기 ({toastNotifications.length})
|
||||
</button>
|
||||
</div>
|
||||
{showSlideNav && (
|
||||
|
||||
@@ -51,9 +51,16 @@ import {
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
|
||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||
import { getMinimapColors } from './minimapTheme';
|
||||
import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage';
|
||||
|
||||
export type FlowLayoutSeed = {
|
||||
seedKey: string;
|
||||
positions: Record<string, { x: number; y: number }>;
|
||||
edgeHandles: Record<string, EdgeHandleBinding>;
|
||||
};
|
||||
|
||||
const nodeTypes = {
|
||||
start: StartNode,
|
||||
@@ -75,6 +82,8 @@ interface Props {
|
||||
onChange: (root: LogicNode | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode: (id: string | null) => void;
|
||||
layoutSeed: FlowLayoutSeed;
|
||||
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
|
||||
}
|
||||
|
||||
function collectTreeIds(node: LogicNode | null): string[] {
|
||||
@@ -88,12 +97,14 @@ function saveEdgeHandles(
|
||||
handles: Map<string, EdgeHandleBinding>,
|
||||
edgeId: string,
|
||||
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
|
||||
manual = true,
|
||||
lockHandles = false,
|
||||
) {
|
||||
handles.set(edgeId, {
|
||||
sourceHandle: conn.sourceHandle,
|
||||
targetHandle: conn.targetHandle,
|
||||
manual,
|
||||
manual: lockHandles,
|
||||
manualSource: lockHandles,
|
||||
manualTarget: lockHandles,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,12 +181,15 @@ function StrategyEditorCanvasInner({
|
||||
onChange,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
layoutSeed,
|
||||
onLayoutChange,
|
||||
}: Props) {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow();
|
||||
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
|
||||
const structureKeyRef = useRef('');
|
||||
const orphanKeyRef = useRef('');
|
||||
const structureKeyRef = useRef<string | null>(null);
|
||||
const orphanKeyRef = useRef<string | null>(null);
|
||||
const layoutEmitTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
@@ -185,8 +199,8 @@ function StrategyEditorCanvasInner({
|
||||
edgesRef.current = edges;
|
||||
|
||||
const treeStructureKey = useMemo(
|
||||
() => `${signalTab}:${collectTreeIds(root).join('|')}`,
|
||||
[root, signalTab],
|
||||
() => collectTreeIds(root).join('|'),
|
||||
[root],
|
||||
);
|
||||
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
||||
|
||||
@@ -210,6 +224,53 @@ function StrategyEditorCanvasInner({
|
||||
return minimapColors.stroke;
|
||||
}, [minimapColors]);
|
||||
|
||||
const signalTabRef = useRef(signalTab);
|
||||
signalTabRef.current = signalTab;
|
||||
|
||||
const emitLayoutChange = useCallback((tab: 'buy' | 'sell') => {
|
||||
if (!onLayoutChange) return;
|
||||
onLayoutChange({
|
||||
tab,
|
||||
positions: Object.fromEntries(positionsRef.current),
|
||||
edgeHandles: Object.fromEntries(edgeHandlesRef.current),
|
||||
});
|
||||
}, [onLayoutChange]);
|
||||
|
||||
const scheduleLayoutEmit = useCallback(() => {
|
||||
if (!onLayoutChange) return;
|
||||
const tabAtSchedule = signalTabRef.current;
|
||||
if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current);
|
||||
layoutEmitTimerRef.current = window.setTimeout(() => {
|
||||
layoutEmitTimerRef.current = null;
|
||||
emitLayoutChange(tabAtSchedule);
|
||||
}, 120);
|
||||
}, [onLayoutChange, emitLayoutChange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
positionsRef.current = new Map(Object.entries(layoutSeed.positions));
|
||||
edgeHandlesRef.current = new Map(Object.entries(layoutSeed.edgeHandles));
|
||||
}, [layoutSeed.edgeHandles, layoutSeed.positions, layoutSeed.seedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
layoutFlushRef.current = () => {
|
||||
if (layoutEmitTimerRef.current != null) {
|
||||
window.clearTimeout(layoutEmitTimerRef.current);
|
||||
layoutEmitTimerRef.current = null;
|
||||
}
|
||||
for (const n of nodesRef.current) {
|
||||
positionsRef.current.set(n.id, { x: n.position.x, y: n.position.y });
|
||||
}
|
||||
emitLayoutChange(signalTabRef.current);
|
||||
};
|
||||
return () => {
|
||||
layoutFlushRef.current = null;
|
||||
};
|
||||
}, [emitLayoutChange]);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
if (id === START_NODE_ID) return;
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
@@ -229,7 +290,8 @@ function StrategyEditorCanvasInner({
|
||||
onChange(next);
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId]);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId, scheduleLayoutEmit]);
|
||||
|
||||
const deleteSelectedNodes = useCallback(() => {
|
||||
const ids = nodesRef.current
|
||||
@@ -260,7 +322,8 @@ function StrategyEditorCanvasInner({
|
||||
onChange(tree);
|
||||
}
|
||||
onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode]);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, scheduleLayoutEmit]);
|
||||
|
||||
const clearDropPreview = useCallback(() => {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
@@ -341,7 +404,8 @@ function StrategyEditorCanvasInner({
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
});
|
||||
onOrphansChange([...orphans, newNode]);
|
||||
}, [orphans, onOrphansChange, signalTab, def]);
|
||||
scheduleLayoutEmit();
|
||||
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
@@ -363,11 +427,13 @@ function StrategyEditorCanvasInner({
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
if (anchorId === START_NODE_ID) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -381,7 +447,8 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
onChange(addChild(root, anchorId, newNode));
|
||||
saveAttachHandles(anchorId);
|
||||
}, [root, signalTab, def, onChange]);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, signalTab, def, onChange, scheduleLayoutEmit]);
|
||||
|
||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
updateDropPreview(anchorId, flowPos);
|
||||
@@ -425,20 +492,8 @@ function StrategyEditorCanvasInner({
|
||||
orphans,
|
||||
), [root, def, signalTab, flowCallbacks, orphans]);
|
||||
|
||||
useEffect(() => {
|
||||
positionsRef.current = new Map();
|
||||
edgeHandlesRef.current = new Map();
|
||||
structureKeyRef.current = '';
|
||||
orphanKeyRef.current = '';
|
||||
onOrphansChange([]);
|
||||
}, [signalTab, onOrphansChange]);
|
||||
|
||||
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
|
||||
useEffect(() => {
|
||||
for (const n of nodesRef.current) {
|
||||
positionsRef.current.set(n.id, n.position);
|
||||
}
|
||||
|
||||
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
|
||||
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
|
||||
pruneEdgeHandles(edgeHandlesRef.current, mergedEdges);
|
||||
@@ -449,13 +504,25 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
|
||||
const prev = nodesRef.current.find(p => p.id === n.id);
|
||||
return { ...n, selected: prev?.selected ?? false };
|
||||
const savedPos = layoutSeed.positions[n.id];
|
||||
return {
|
||||
...n,
|
||||
position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position,
|
||||
selected: prev?.selected ?? false,
|
||||
};
|
||||
}));
|
||||
|
||||
if (structureChanged || orphansChanged) {
|
||||
const hasSavedLayout = layoutNodes.some(
|
||||
n => n.id !== START_NODE_ID && layoutSeed.positions[n.id] != null,
|
||||
);
|
||||
|
||||
const needsNodeSync = layoutNodes.length !== nodesRef.current.length
|
||||
|| !nodesRef.current.some(n => n.id === START_NODE_ID);
|
||||
|
||||
if (structureChanged || orphansChanged || needsNodeSync) {
|
||||
syncNodesFromLayout();
|
||||
setEdges(mergedEdges);
|
||||
if (structureChanged) {
|
||||
if (structureChanged && !hasSavedLayout) {
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.4, duration: 200 });
|
||||
});
|
||||
@@ -464,11 +531,16 @@ function StrategyEditorCanvasInner({
|
||||
setNodes(prev => prev.map(n => {
|
||||
const fresh = layoutNodes.find(ln => ln.id === n.id);
|
||||
if (!fresh) return n;
|
||||
return { ...n, data: fresh.data };
|
||||
const savedPos = layoutSeed.positions[n.id];
|
||||
return {
|
||||
...n,
|
||||
position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position,
|
||||
data: fresh.data,
|
||||
};
|
||||
}));
|
||||
setEdges(mergedEdges);
|
||||
}
|
||||
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView]);
|
||||
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView, layoutSeed]);
|
||||
|
||||
const handleDisconnectEdge = useCallback((edgeId: string) => {
|
||||
const edge = edgesRef.current.find(e => e.id === edgeId);
|
||||
@@ -492,10 +564,22 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
useEffect(() => {
|
||||
edgeDisconnectRef.current = handleDisconnectEdge;
|
||||
edgeBindingUpdateRef.current = (edgeId, patch) => {
|
||||
const prev = edgeHandlesRef.current.get(edgeId) ?? {};
|
||||
const next: EdgeHandleBinding = { ...prev, ...patch };
|
||||
if (patch.sourceHandle != null) next.manualSource = true;
|
||||
if (patch.targetHandle != null) next.manualTarget = true;
|
||||
if (patch.centerX != null || patch.centerY != null) next.manualCenter = true;
|
||||
if (next.manualSource && next.manualTarget) next.manual = true;
|
||||
edgeHandlesRef.current.set(edgeId, next);
|
||||
setEdges(prevEdges => applyEdgeHandles(prevEdges, positionsRef.current, edgeHandlesRef.current));
|
||||
scheduleLayoutEmit();
|
||||
};
|
||||
return () => {
|
||||
edgeDisconnectRef.current = null;
|
||||
edgeBindingUpdateRef.current = null;
|
||||
};
|
||||
}, [handleDisconnectEdge]);
|
||||
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
|
||||
|
||||
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
||||
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
|
||||
@@ -530,17 +614,20 @@ function StrategyEditorCanvasInner({
|
||||
if (childOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== childId));
|
||||
connectOrphanIntoTree(wired, childOrphan, root, onChange);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
if (parentOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== parentId));
|
||||
connectOrphanAsParent(wired, parentOrphan, root, onChange);
|
||||
scheduleLayoutEmit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root) return;
|
||||
applyTreeConnection(parentId, childId, root, onChange);
|
||||
}, [root, orphans, onChange, onOrphansChange]);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, orphans, onChange, onOrphansChange, scheduleLayoutEmit]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
const positionChanges = changes.filter(
|
||||
@@ -565,7 +652,7 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
onNodesChangeBase(changes);
|
||||
|
||||
let dragEnded = false;
|
||||
let positionsChanged = false;
|
||||
|
||||
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
|
||||
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
|
||||
@@ -584,7 +671,7 @@ function StrategyEditorCanvasInner({
|
||||
const p = positionsRef.current.get(n.id);
|
||||
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
|
||||
}));
|
||||
if (startChange?.dragging === false) dragEnded = true;
|
||||
positionsChanged = true;
|
||||
}
|
||||
|
||||
for (const ch of positionChanges) {
|
||||
@@ -595,6 +682,7 @@ function StrategyEditorCanvasInner({
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
|
||||
if (ch.dragging === true) {
|
||||
positionsChanged = true;
|
||||
const nodesSnapshot = nodesRef.current.map(n => (
|
||||
n.id === ch.id
|
||||
? { id: n.id, position: ch.position! }
|
||||
@@ -625,17 +713,18 @@ function StrategyEditorCanvasInner({
|
||||
);
|
||||
applyResolvedConnection(pending.parentId, pending.childId, conn);
|
||||
}
|
||||
dragEnded = true;
|
||||
positionsChanged = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
if (ch.dragging === false) dragEnded = true;
|
||||
positionsChanged = true;
|
||||
}
|
||||
|
||||
if (dragEnded) {
|
||||
if (positionsChanged) {
|
||||
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
|
||||
scheduleLayoutEmit();
|
||||
}
|
||||
}, [
|
||||
onNodesChangeBase,
|
||||
@@ -647,6 +736,7 @@ function StrategyEditorCanvasInner({
|
||||
clearDropPreview,
|
||||
updateOrphanConnectPreview,
|
||||
applyResolvedConnection,
|
||||
scheduleLayoutEmit,
|
||||
]);
|
||||
|
||||
const onConnect = useCallback((conn: Connection) => {
|
||||
@@ -667,16 +757,20 @@ function StrategyEditorCanvasInner({
|
||||
return;
|
||||
}
|
||||
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection);
|
||||
setEdges(prev => prev.map(e => (
|
||||
e.id === oldEdge.id
|
||||
? {
|
||||
...e,
|
||||
sourceHandle: newConnection.sourceHandle,
|
||||
targetHandle: newConnection.targetHandle,
|
||||
}
|
||||
: e
|
||||
)));
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection, true);
|
||||
setEdges(prev => applyEdgeHandles(
|
||||
prev.map(e => (
|
||||
e.id === oldEdge.id
|
||||
? {
|
||||
...e,
|
||||
sourceHandle: newConnection.sourceHandle,
|
||||
targetHandle: newConnection.targetHandle,
|
||||
}
|
||||
: e
|
||||
)),
|
||||
positionsRef.current,
|
||||
edgeHandlesRef.current,
|
||||
));
|
||||
}, [root, orphans, applyResolvedConnection, setEdges]);
|
||||
|
||||
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react';
|
||||
import { bindWindowDrag } from '../../utils/pointerDrag';
|
||||
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
|
||||
import {
|
||||
buildPositionsFromNodes,
|
||||
getEdgeDragZone,
|
||||
pickSourceHandleAtPoint,
|
||||
pickTargetHandleAtPoint,
|
||||
type EdgeDragZone,
|
||||
} from '../../utils/strategyEdgeInteraction';
|
||||
import { edgeBindingUpdateRef, edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
|
||||
type StrategyEdgeData = {
|
||||
binding?: EdgeHandleBinding;
|
||||
};
|
||||
|
||||
export function StrategyFlowEdge({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
@@ -10,7 +32,12 @@ export function StrategyFlowEdge({
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const { screenToFlowPosition, getNodes } = useReactFlow();
|
||||
const binding = (data as StrategyEdgeData | undefined)?.binding;
|
||||
const hasCenter = binding?.manualCenter && binding.centerX != null && binding.centerY != null;
|
||||
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -18,15 +45,147 @@ export function StrategyFlowEdge({
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
...(hasCenter ? { centerX: binding!.centerX!, centerY: binding!.centerY! } : {}),
|
||||
});
|
||||
|
||||
const dragRef = useRef<{
|
||||
zone: EdgeDragZone;
|
||||
startCenterX: number;
|
||||
startCenterY: number;
|
||||
} | null>(null);
|
||||
const unbindRef = useRef<(() => void) | null>(null);
|
||||
const [dragZone, setDragZone] = useState<EdgeDragZone | null>(null);
|
||||
|
||||
const commitBinding = useCallback((patch: EdgeHandleBinding) => {
|
||||
edgeBindingUpdateRef.current?.(id, patch);
|
||||
}, [id]);
|
||||
|
||||
const positionsFromFlow = useCallback(() => {
|
||||
return buildPositionsFromNodes(getNodes());
|
||||
}, [getNodes]);
|
||||
|
||||
const onHitPointerDown = useCallback((e: React.PointerEvent<SVGPathElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as Element).closest('.se-flow-edge-del')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const flow = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const zone = getEdgeDragZone(sourceX, sourceY, targetX, targetY, flow.x, flow.y);
|
||||
setDragZone(zone);
|
||||
|
||||
try {
|
||||
(e.currentTarget as SVGPathElement).setPointerCapture(e.pointerId);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const positions = positionsFromFlow();
|
||||
const startCenterX = binding?.centerX ?? labelX;
|
||||
const startCenterY = binding?.centerY ?? labelY;
|
||||
|
||||
dragRef.current = { zone, startCenterX, startCenterY };
|
||||
|
||||
if (zone === 'source') {
|
||||
commitBinding({
|
||||
sourceHandle: pickSourceHandleAtPoint(source, flow, positions),
|
||||
manualSource: true,
|
||||
});
|
||||
} else if (zone === 'target') {
|
||||
commitBinding({
|
||||
targetHandle: pickTargetHandleAtPoint(target, flow, positions),
|
||||
manualTarget: true,
|
||||
});
|
||||
} else {
|
||||
commitBinding({
|
||||
centerX: flow.x,
|
||||
centerY: flow.y,
|
||||
manualCenter: true,
|
||||
});
|
||||
}
|
||||
|
||||
unbindRef.current?.();
|
||||
unbindRef.current = bindWindowDrag(
|
||||
({ x, y }) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const pos = screenToFlowPosition({ x, y });
|
||||
const positions = positionsFromFlow();
|
||||
|
||||
if (d.zone === 'source') {
|
||||
commitBinding({
|
||||
sourceHandle: pickSourceHandleAtPoint(source, pos, positions),
|
||||
manualSource: true,
|
||||
});
|
||||
} else if (d.zone === 'target') {
|
||||
commitBinding({
|
||||
targetHandle: pickTargetHandleAtPoint(target, pos, positions),
|
||||
manualTarget: true,
|
||||
});
|
||||
} else {
|
||||
commitBinding({
|
||||
centerX: pos.x,
|
||||
centerY: pos.y,
|
||||
manualCenter: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
dragRef.current = null;
|
||||
setDragZone(null);
|
||||
unbindRef.current = null;
|
||||
},
|
||||
);
|
||||
}, [
|
||||
binding?.centerX,
|
||||
binding?.centerY,
|
||||
commitBinding,
|
||||
labelX,
|
||||
labelY,
|
||||
positionsFromFlow,
|
||||
screenToFlowPosition,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
target,
|
||||
targetX,
|
||||
targetY,
|
||||
]);
|
||||
|
||||
const onHitPointerUp = useCallback((e: React.PointerEvent<SVGPathElement>) => {
|
||||
dragRef.current = null;
|
||||
setDragZone(null);
|
||||
unbindRef.current?.();
|
||||
unbindRef.current = null;
|
||||
try {
|
||||
(e.currentTarget as SVGPathElement).releasePointerCapture(e.pointerId);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const hitCursor = dragZone === 'source'
|
||||
? 'grab'
|
||||
: dragZone === 'target'
|
||||
? 'grab'
|
||||
: dragZone === 'middle'
|
||||
? 'move'
|
||||
: 'pointer';
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={`se-flow-edge${selected ? ' se-flow-edge--selected' : ''}`}
|
||||
style={{ strokeWidth: selected ? 3 : 2 }}
|
||||
style={{ strokeWidth: selected ? 3 : 2, pointerEvents: 'none' }}
|
||||
/>
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={22}
|
||||
className={`se-flow-edge-hit${dragZone ? ` se-flow-edge-hit--${dragZone}` : ''}`}
|
||||
style={{ cursor: hitCursor }}
|
||||
onPointerDown={onHitPointerDown}
|
||||
onPointerUp={onHitPointerUp}
|
||||
onPointerCancel={onHitPointerUp}
|
||||
/>
|
||||
{selected && edgeDisconnectRef.current && (
|
||||
<EdgeLabelRenderer>
|
||||
@@ -39,8 +198,8 @@ export function StrategyFlowEdge({
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
edgeDisconnectRef.current?.(id);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
|
||||
|
||||
/** 연결선 끊기 — 커스텀 엣지 컴포넌트에서 캔버스 핸들러 참조 */
|
||||
export const edgeDisconnectRef = {
|
||||
current: null as ((edgeId: string) => void) | null,
|
||||
};
|
||||
|
||||
/** 연결선 핸들·경로 바인딩 갱신 */
|
||||
export const edgeBindingUpdateRef = {
|
||||
current: null as ((edgeId: string, patch: Partial<EdgeHandleBinding>) => void) | null,
|
||||
};
|
||||
|
||||
/** 탭 전환 직전 — 현재 탭 레이아웃 즉시 저장 */
|
||||
export const layoutFlushRef = {
|
||||
current: null as (() => void) | null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user