데스크탑 앱 전략편집기 수정
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
type Node,
|
type Node,
|
||||||
type OnNodesChange,
|
type OnNodesChange,
|
||||||
type OnSelectionChangeFunc,
|
type OnSelectionChangeFunc,
|
||||||
|
type ReactFlowInstance,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import type { Theme } from '../../types/index';
|
import type { Theme } from '../../types/index';
|
||||||
@@ -36,6 +37,10 @@ import {
|
|||||||
subscribePaletteDrag,
|
subscribePaletteDrag,
|
||||||
type PaletteDragEvent,
|
type PaletteDragEvent,
|
||||||
} from '../../utils/paletteDragSession';
|
} from '../../utils/paletteDragSession';
|
||||||
|
import {
|
||||||
|
isOverStrategyBuilder,
|
||||||
|
projectScreenToFlowPosition,
|
||||||
|
} from '../../utils/flowScreenProjection';
|
||||||
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
||||||
import {
|
import {
|
||||||
setPriceExtremePairFilterLevel,
|
setPriceExtremePairFilterLevel,
|
||||||
@@ -296,6 +301,8 @@ function StrategyEditorCanvasInner({
|
|||||||
onExtraRootsChange,
|
onExtraRootsChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { fitView, screenToFlowPosition } = useReactFlow();
|
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 positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||||
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
|
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
|
||||||
const structureKeyRef = useRef<string | null>(null);
|
const structureKeyRef = useRef<string | null>(null);
|
||||||
@@ -610,16 +617,16 @@ function StrategyEditorCanvasInner({
|
|||||||
anchorId: string,
|
anchorId: string,
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
flowPos: { x: number; y: number },
|
flowPos: { x: number; y: number },
|
||||||
) => {
|
): boolean => {
|
||||||
if (data.type === 'start') {
|
if (data.type === 'start') {
|
||||||
addStartAt(flowPos);
|
addStartAt(flowPos);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNode = resolvePaletteDropNode(data);
|
const newNode = resolvePaletteDropNode(data);
|
||||||
if (!newNode) return;
|
if (!newNode) return false;
|
||||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||||
if (!anchor) return;
|
if (!anchor) return false;
|
||||||
|
|
||||||
const anchorDim = getNodeDimensions(anchorId);
|
const anchorDim = getNodeDimensions(anchorId);
|
||||||
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
|
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
|
||||||
@@ -641,32 +648,38 @@ function StrategyEditorCanvasInner({
|
|||||||
}
|
}
|
||||||
saveAttachHandles(anchorId);
|
saveAttachHandles(anchorId);
|
||||||
scheduleLayoutEmit();
|
scheduleLayoutEmit();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorNode = findNodeInTree(root, anchorId)
|
const anchorNode = findNodeInTree(root, anchorId)
|
||||||
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
|
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
|
||||||
if (anchorNode?.type === 'CONDITION') {
|
if (anchorNode?.type === 'CONDITION') {
|
||||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
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)) {
|
if (root && findNodeInTree(root, anchorId)) {
|
||||||
onChange(addChild(root, anchorId, newNode));
|
onChange(addChild(root, anchorId, newNode));
|
||||||
} else {
|
saveAttachHandles(anchorId);
|
||||||
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId));
|
scheduleLayoutEmit();
|
||||||
if (parentBranch) {
|
return true;
|
||||||
const [sid, br] = parentBranch;
|
|
||||||
onExtraRootsChange?.({
|
|
||||||
...extraRoots,
|
|
||||||
[sid]: addChild(br!, anchorId, newNode),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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]);
|
}, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
|
||||||
|
|
||||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||||
@@ -688,7 +701,8 @@ function StrategyEditorCanvasInner({
|
|||||||
) => {
|
) => {
|
||||||
clearDropPreview();
|
clearDropPreview();
|
||||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||||
applyDropWithAttach(anchorId, data, flowPos);
|
const attached = applyDropWithAttach(anchorId, data, flowPos);
|
||||||
|
if (!attached) addOrphanAt(data, flowPos);
|
||||||
} else {
|
} else {
|
||||||
addOrphanAt(data, flowPos);
|
addOrphanAt(data, flowPos);
|
||||||
}
|
}
|
||||||
@@ -1207,23 +1221,39 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e);
|
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((
|
const applyPaletteDropAt = useCallback((
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
clientX: number,
|
clientX: number,
|
||||||
clientY: number,
|
clientY: number,
|
||||||
) => {
|
) => {
|
||||||
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
|
if (!isOverStrategyBuilder(clientX, clientY)) return;
|
||||||
|
|
||||||
|
const flowPos = clientToFlow(clientX, clientY);
|
||||||
if (data.type === 'start') {
|
if (data.type === 'start') {
|
||||||
addStartAt(flowPos);
|
addStartAt(flowPos);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const drop = resolvePaletteDrop(flowPos);
|
|
||||||
if (drop.mode === 'connect') {
|
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
|
||||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
|
||||||
} else {
|
handleDropTarget(hit.id, data, flowPos);
|
||||||
addOrphanAt(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) => {
|
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1233,48 +1263,57 @@ function StrategyEditorCanvasInner({
|
|||||||
applyPaletteDropAt(data, e.clientX, e.clientY);
|
applyPaletteDropAt(data, e.clientX, e.clientY);
|
||||||
}, [clearDropPreview, applyPaletteDropAt]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!needsPointerPaletteDrag()) return;
|
if (!needsPointerPaletteDrag()) return;
|
||||||
return subscribePaletteDrag((ev: PaletteDragEvent) => {
|
return subscribePaletteDrag((ev: PaletteDragEvent) => {
|
||||||
if (ev.phase === 'move') {
|
if (ev.phase === 'move') {
|
||||||
const flowPos = screenToFlowPosition({ x: ev.x, y: ev.y });
|
if (!isOverStrategyBuilder(ev.x, ev.y)) {
|
||||||
const drop = resolvePaletteDrop(flowPos);
|
clearPreviewRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flowPos = clientToFlowRef.current(ev.x, ev.y);
|
||||||
|
const drop = resolveDropRef.current(flowPos);
|
||||||
if (drop.mode === 'connect') {
|
if (drop.mode === 'connect') {
|
||||||
updateDropPreview(drop.anchorId, flowPos);
|
updatePreviewRef.current(drop.anchorId, flowPos);
|
||||||
} else {
|
} else {
|
||||||
clearDropPreview();
|
clearPreviewRef.current();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.phase === 'end') {
|
if (ev.phase === 'end') {
|
||||||
clearDropPreview();
|
clearPreviewRef.current();
|
||||||
applyPaletteDropAt(ev.payload, ev.x, ev.y);
|
applyDropRef.current(ev.payload, ev.x, ev.y);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.phase === 'cancel') {
|
if (ev.phase === 'cancel') {
|
||||||
clearDropPreview();
|
clearPreviewRef.current();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [
|
}, []);
|
||||||
screenToFlowPosition,
|
|
||||||
resolvePaletteDrop,
|
|
||||||
updateDropPreview,
|
|
||||||
clearDropPreview,
|
|
||||||
applyPaletteDropAt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isPaletteDrag(e)) return;
|
if (!isPaletteDrag(e)) return;
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
const flowPos = clientToFlow(e.clientX, e.clientY);
|
||||||
const drop = resolvePaletteDrop(flowPos);
|
const drop = resolvePaletteDrop(flowPos);
|
||||||
if (drop.mode === 'connect') {
|
if (drop.mode === 'connect') {
|
||||||
updateDropPreview(drop.anchorId, flowPos);
|
updateDropPreview(drop.anchorId, flowPos);
|
||||||
} else {
|
} else {
|
||||||
clearDropPreview();
|
clearDropPreview();
|
||||||
}
|
}
|
||||||
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
|
}, [clientToFlow, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
|
||||||
|
|
||||||
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
|
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
const rel = e.relatedTarget as Element | null;
|
const rel = e.relatedTarget as Element | null;
|
||||||
@@ -1304,11 +1343,13 @@ function StrategyEditorCanvasInner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={canvasWrapRef}
|
||||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
|
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
onInit={inst => { reactFlowRef.current = inst; }}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactFlowInstance } from '@xyflow/react';
|
||||||
|
|
||||||
|
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
|
||||||
|
|
||||||
|
/** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */
|
||||||
|
export function projectScreenToFlowPosition(
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
rf: FlowProjector | null,
|
||||||
|
containerEl: HTMLElement | null,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
if (rf) {
|
||||||
|
try {
|
||||||
|
const pos = rf.screenToFlowPosition({ x: clientX, y: clientY });
|
||||||
|
if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) {
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* manual fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? containerEl;
|
||||||
|
if (!host || !rf) {
|
||||||
|
return { x: clientX, y: clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = host.getBoundingClientRect();
|
||||||
|
const { x: vx, y: vy, zoom } = rf.getViewport();
|
||||||
|
const z = zoom || 1;
|
||||||
|
return {
|
||||||
|
x: (clientX - rect.left - vx) / z,
|
||||||
|
y: (clientY - rect.top - vy) / z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 release 무시) */
|
||||||
|
export function isOverStrategyBuilder(clientX: number, clientY: number): boolean {
|
||||||
|
const el = document.elementFromPoint(clientX, clientY);
|
||||||
|
if (!el) return false;
|
||||||
|
if (el.closest('.se-palette-panel, .se-side-wrap--right')) return false;
|
||||||
|
return el.closest('.se-canvas-wrap') != null;
|
||||||
|
}
|
||||||
@@ -108,16 +108,14 @@ function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) {
|
|||||||
ensureGhost(label, xy.x, xy.y);
|
ensureGhost(label, xy.x, xy.y);
|
||||||
emit({ phase: 'start', x: xy.x, y: xy.y, payload });
|
emit({ phase: 'start', x: xy.x, y: xy.y, payload });
|
||||||
|
|
||||||
let lastXY = xy;
|
|
||||||
unbindMove = bindWindowDrag(
|
unbindMove = bindWindowDrag(
|
||||||
({ x, y }) => {
|
({ x, y }) => {
|
||||||
lastXY = { x, y };
|
|
||||||
moveGhost(x, y);
|
moveGhost(x, y);
|
||||||
if (activePayload) {
|
if (activePayload) {
|
||||||
emit({ phase: 'move', x, y, payload: activePayload });
|
emit({ phase: 'move', x, y, payload: activePayload });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => finishDrag('end', lastXY),
|
(endXY) => finishDrag('end', endXY),
|
||||||
{ preventTouchScroll: true },
|
{ preventTouchScroll: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,21 +48,26 @@ export interface BindWindowDragOptions {
|
|||||||
*/
|
*/
|
||||||
export function bindWindowDrag(
|
export function bindWindowDrag(
|
||||||
onMove: (xy: ClientXY, ev: Event) => void,
|
onMove: (xy: ClientXY, ev: Event) => void,
|
||||||
onEnd: () => void,
|
onEnd: (xy: ClientXY, ev: Event) => void,
|
||||||
options: BindWindowDragOptions = {},
|
options: BindWindowDragOptions = {},
|
||||||
): () => void {
|
): () => void {
|
||||||
const { preventTouchScroll = true } = options;
|
const { preventTouchScroll = true } = options;
|
||||||
|
let lastXY: ClientXY = { x: 0, y: 0 };
|
||||||
|
|
||||||
const move = (ev: Event) => {
|
const move = (ev: Event) => {
|
||||||
const xy = clientXYFromNative(ev);
|
const xy = clientXYFromNative(ev);
|
||||||
if (!xy) return;
|
if (!xy) return;
|
||||||
|
lastXY = xy;
|
||||||
if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) {
|
if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
}
|
}
|
||||||
onMove(xy, ev);
|
onMove(xy, ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const end = () => onEnd();
|
const end = (ev: Event) => {
|
||||||
|
const xy = clientXYFromNative(ev) ?? lastXY;
|
||||||
|
onEnd(xy, ev);
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('pointermove', move);
|
window.addEventListener('pointermove', move);
|
||||||
window.addEventListener('pointerup', end);
|
window.addEventListener('pointerup', end);
|
||||||
|
|||||||
Reference in New Issue
Block a user