데스크탑 앱 전략편집기 수정

This commit is contained in:
Macbook
2026-06-14 22:39:00 +09:00
parent 76ffa5ac52
commit 21f5d16b6f
4 changed files with 134 additions and 47 deletions
@@ -15,6 +15,7 @@ import {
type Node,
type OnNodesChange,
type OnSelectionChangeFunc,
type ReactFlowInstance,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index';
@@ -36,6 +37,10 @@ import {
subscribePaletteDrag,
type PaletteDragEvent,
} from '../../utils/paletteDragSession';
import {
isOverStrategyBuilder,
projectScreenToFlowPosition,
} from '../../utils/flowScreenProjection';
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
import {
setPriceExtremePairFilterLevel,
@@ -296,6 +301,8 @@ function StrategyEditorCanvasInner({
onExtraRootsChange,
}: Props) {
const { fitView, screenToFlowPosition } = useReactFlow();
const canvasWrapRef = useRef<HTMLDivElement>(null);
const reactFlowRef = useRef<ReactFlowInstance | null>(null);
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
const structureKeyRef = useRef<string | null>(null);
@@ -610,16 +617,16 @@ function StrategyEditorCanvasInner({
anchorId: string,
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
): boolean => {
if (data.type === 'start') {
addStartAt(flowPos);
return;
return true;
}
const newNode = resolvePaletteDropNode(data);
if (!newNode) return;
if (!newNode) return false;
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
if (!anchor) return false;
const anchorDim = getNodeDimensions(anchorId);
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
@@ -641,32 +648,38 @@ function StrategyEditorCanvasInner({
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
return;
return true;
}
const anchorNode = findNodeInTree(root, anchorId)
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
if (anchorNode?.type === 'CONDITION') {
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
return;
return true;
}
if (!canAcceptPaletteAsParent(anchorId, root)) return;
if (!canAcceptPaletteAsParent(anchorId, root)) return false;
if (root && findNodeInTree(root, anchorId)) {
onChange(addChild(root, anchorId, newNode));
} else {
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId));
if (parentBranch) {
const [sid, br] = parentBranch;
onExtraRootsChange?.({
...extraRoots,
[sid]: addChild(br!, anchorId, newNode),
});
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
return true;
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId));
if (parentBranch) {
const [sid, br] = parentBranch;
onExtraRootsChange?.({
...extraRoots,
[sid]: addChild(br!, anchorId, newNode),
});
saveAttachHandles(anchorId);
scheduleLayoutEmit();
return true;
}
return false;
}, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
@@ -688,7 +701,8 @@ function StrategyEditorCanvasInner({
) => {
clearDropPreview();
if (isPaletteConnectTarget(anchorId, root, orphans)) {
applyDropWithAttach(anchorId, data, flowPos);
const attached = applyDropWithAttach(anchorId, data, flowPos);
if (!attached) addOrphanAt(data, flowPos);
} else {
addOrphanAt(data, flowPos);
}
@@ -1207,23 +1221,39 @@ function StrategyEditorCanvasInner({
const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e);
const clientToFlow = useCallback((clientX: number, clientY: number) => {
const rf = reactFlowRef.current;
if (rf) {
return projectScreenToFlowPosition(clientX, clientY, rf, canvasWrapRef.current);
}
return screenToFlowPosition({ x: clientX, y: clientY });
}, [screenToFlowPosition]);
const applyPaletteDropAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
clientX: number,
clientY: number,
) => {
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
if (!isOverStrategyBuilder(clientX, clientY)) return;
const flowPos = clientToFlow(clientX, clientY);
if (data.type === 'start') {
addStartAt(flowPos);
return;
}
const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') {
applyDropWithAttach(drop.anchorId, data, flowPos);
} else {
addOrphanAt(data, flowPos);
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
handleDropTarget(hit.id, data, flowPos);
return;
}
}, [screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
addOrphanAt(data, flowPos);
requestAnimationFrame(() => {
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
});
}, [clientToFlow, addStartAt, handleDropTarget, addOrphanAt, root, orphans]);
const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -1233,48 +1263,57 @@ function StrategyEditorCanvasInner({
applyPaletteDropAt(data, e.clientX, e.clientY);
}, [clearDropPreview, applyPaletteDropAt]);
const applyDropRef = useRef(applyPaletteDropAt);
applyDropRef.current = applyPaletteDropAt;
const clearPreviewRef = useRef(clearDropPreview);
clearPreviewRef.current = clearDropPreview;
const resolveDropRef = useRef(resolvePaletteDrop);
resolveDropRef.current = resolvePaletteDrop;
const updatePreviewRef = useRef(updateDropPreview);
updatePreviewRef.current = updateDropPreview;
const clientToFlowRef = useRef(clientToFlow);
clientToFlowRef.current = clientToFlow;
useEffect(() => {
if (!needsPointerPaletteDrag()) return;
return subscribePaletteDrag((ev: PaletteDragEvent) => {
if (ev.phase === 'move') {
const flowPos = screenToFlowPosition({ x: ev.x, y: ev.y });
const drop = resolvePaletteDrop(flowPos);
if (!isOverStrategyBuilder(ev.x, ev.y)) {
clearPreviewRef.current();
return;
}
const flowPos = clientToFlowRef.current(ev.x, ev.y);
const drop = resolveDropRef.current(flowPos);
if (drop.mode === 'connect') {
updateDropPreview(drop.anchorId, flowPos);
updatePreviewRef.current(drop.anchorId, flowPos);
} else {
clearDropPreview();
clearPreviewRef.current();
}
return;
}
if (ev.phase === 'end') {
clearDropPreview();
applyPaletteDropAt(ev.payload, ev.x, ev.y);
clearPreviewRef.current();
applyDropRef.current(ev.payload, ev.x, ev.y);
return;
}
if (ev.phase === 'cancel') {
clearDropPreview();
clearPreviewRef.current();
}
});
}, [
screenToFlowPosition,
resolvePaletteDrop,
updateDropPreview,
clearDropPreview,
applyPaletteDropAt,
]);
}, []);
const onPaneDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!isPaletteDrag(e)) return;
e.dataTransfer.dropEffect = 'copy';
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
const flowPos = clientToFlow(e.clientX, e.clientY);
const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') {
updateDropPreview(drop.anchorId, flowPos);
} else {
clearDropPreview();
}
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
}, [clientToFlow, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
const rel = e.relatedTarget as Element | null;
@@ -1304,11 +1343,13 @@ function StrategyEditorCanvasInner({
return (
<div
ref={canvasWrapRef}
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
onKeyDown={onKeyDown}
tabIndex={0}
>
<ReactFlow
onInit={inst => { reactFlowRef.current = inst; }}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}