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

This commit is contained in:
Macbook
2026-06-14 22:47:23 +09:00
parent 21f5d16b6f
commit ec40241b2f
3 changed files with 127 additions and 50 deletions
@@ -39,7 +39,7 @@ import {
} from '../../utils/paletteDragSession'; } from '../../utils/paletteDragSession';
import { import {
isOverStrategyBuilder, isOverStrategyBuilder,
projectScreenToFlowPosition, resolvePaletteDropClientPoint,
} from '../../utils/flowScreenProjection'; } from '../../utils/flowScreenProjection';
import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
import { import {
@@ -300,7 +300,7 @@ function StrategyEditorCanvasInner({
onExtraStartIdsChange, onExtraStartIdsChange,
onExtraRootsChange, onExtraRootsChange,
}: Props) { }: Props) {
const { fitView, screenToFlowPosition } = useReactFlow(); const { fitView } = useReactFlow();
const canvasWrapRef = useRef<HTMLDivElement>(null); const canvasWrapRef = useRef<HTMLDivElement>(null);
const reactFlowRef = useRef<ReactFlowInstance | null>(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 }>());
@@ -1211,24 +1211,20 @@ function StrategyEditorCanvasInner({
)); ));
}, [root, orphans, extraRoots, applyResolvedConnection, setEdges]); }, [root, orphans, extraRoots, applyResolvedConnection, setEdges]);
const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => { const resolveDropFromClient = useCallback((clientX: number, clientY: number) => (
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); resolvePaletteDropClientPoint(
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { clientX,
return { mode: 'connect' as const, anchorId: hit.id }; clientY,
} reactFlowRef.current,
return { mode: 'orphan' as const }; canvasWrapRef.current,
}, [root, orphans]); nodesRef.current,
findNodeAtFlowPosition,
nodeId => isPaletteConnectTarget(nodeId, root, orphans),
)
), [root, orphans]);
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,
@@ -1236,15 +1232,14 @@ function StrategyEditorCanvasInner({
) => { ) => {
if (!isOverStrategyBuilder(clientX, clientY)) return; if (!isOverStrategyBuilder(clientX, clientY)) return;
const flowPos = clientToFlow(clientX, clientY); const { flowPos, anchorId } = resolveDropFromClient(clientX, clientY);
if (data.type === 'start') { if (data.type === 'start') {
addStartAt(flowPos); addStartAt(flowPos);
return; return;
} }
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); if (anchorId) {
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { handleDropTarget(anchorId, data, flowPos);
handleDropTarget(hit.id, data, flowPos);
return; return;
} }
@@ -1253,7 +1248,7 @@ function StrategyEditorCanvasInner({
requestAnimationFrame(() => { requestAnimationFrame(() => {
reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 }); reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 });
}); });
}, [clientToFlow, addStartAt, handleDropTarget, addOrphanAt, root, orphans]); }, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]);
const onPaneDrop = useCallback((e: React.DragEvent) => { const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1267,12 +1262,10 @@ function StrategyEditorCanvasInner({
applyDropRef.current = applyPaletteDropAt; applyDropRef.current = applyPaletteDropAt;
const clearPreviewRef = useRef(clearDropPreview); const clearPreviewRef = useRef(clearDropPreview);
clearPreviewRef.current = clearDropPreview; clearPreviewRef.current = clearDropPreview;
const resolveDropRef = useRef(resolvePaletteDrop);
resolveDropRef.current = resolvePaletteDrop;
const updatePreviewRef = useRef(updateDropPreview); const updatePreviewRef = useRef(updateDropPreview);
updatePreviewRef.current = updateDropPreview; updatePreviewRef.current = updateDropPreview;
const clientToFlowRef = useRef(clientToFlow); const resolveDropFromClientRef = useRef(resolveDropFromClient);
clientToFlowRef.current = clientToFlow; resolveDropFromClientRef.current = resolveDropFromClient;
useEffect(() => { useEffect(() => {
if (!needsPointerPaletteDrag()) return; if (!needsPointerPaletteDrag()) return;
@@ -1282,10 +1275,9 @@ function StrategyEditorCanvasInner({
clearPreviewRef.current(); clearPreviewRef.current();
return; return;
} }
const flowPos = clientToFlowRef.current(ev.x, ev.y); const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y);
const drop = resolveDropRef.current(flowPos); if (anchorId) {
if (drop.mode === 'connect') { updatePreviewRef.current(anchorId, flowPos);
updatePreviewRef.current(drop.anchorId, flowPos);
} else { } else {
clearPreviewRef.current(); clearPreviewRef.current();
} }
@@ -1306,14 +1298,13 @@ function StrategyEditorCanvasInner({
e.preventDefault(); e.preventDefault();
if (!isPaletteDrag(e)) return; if (!isPaletteDrag(e)) return;
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
const flowPos = clientToFlow(e.clientX, e.clientY); const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY);
const drop = resolvePaletteDrop(flowPos); if (anchorId) {
if (drop.mode === 'connect') { updateDropPreview(anchorId, flowPos);
updateDropPreview(drop.anchorId, flowPos);
} else { } else {
clearDropPreview(); clearDropPreview();
} }
}, [clientToFlow, resolvePaletteDrop, updateDropPreview, clearDropPreview]); }, [resolveDropFromClient, 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;
+83 -14
View File
@@ -2,6 +2,24 @@ import type { ReactFlowInstance } from '@xyflow/react';
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>; type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
function manualProject(
clientX: number,
clientY: number,
rf: FlowProjector,
containerEl: HTMLElement | null,
): { x: number; y: number } {
const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? containerEl;
if (!host) return { x: 0, y: 0 };
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,
};
}
/** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */ /** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */
export function projectScreenToFlowPosition( export function projectScreenToFlowPosition(
clientX: number, clientX: number,
@@ -13,31 +31,82 @@ export function projectScreenToFlowPosition(
try { try {
const pos = rf.screenToFlowPosition({ x: clientX, y: clientY }); const pos = rf.screenToFlowPosition({ x: clientX, y: clientY });
if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) { if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) {
return pos; // WebView 버그: 화면 픽셀을 그대로 반환하는 경우
const looksLikeScreenPx =
Math.abs(pos.x - clientX) < 2 && Math.abs(pos.y - clientY) < 2;
if (!looksLikeScreenPx) return pos;
} }
} catch { } catch {
/* manual fallback */ /* manual fallback */
} }
return manualProject(clientX, clientY, rf, containerEl);
} }
const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? containerEl; return manualProject(
if (!host || !rf) { clientX,
return { x: clientX, y: clientY }; clientY,
} { screenToFlowPosition: p => p, getViewport: () => ({ x: 0, y: 0, zoom: 1 }) },
containerEl,
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 무시) */ /** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 release 무시) */
export function isOverStrategyBuilder(clientX: number, clientY: number): boolean { export function isOverStrategyBuilder(clientX: number, clientY: number): boolean {
const el = document.elementFromPoint(clientX, clientY); const el = document.elementFromPoint(clientX, clientY);
if (!el) return false; if (!el) return false;
if (el.closest('.se-palette-panel, .se-side-wrap--right')) return false; if (el.closest('.se-palette-panel, .se-side-wrap--right, .se-palette-drag-ghost')) return false;
return el.closest('.se-canvas-wrap') != null; return el.closest('.se-canvas-wrap') != null;
} }
/** DOM hit-test — flow 좌표 변환 오류 시에도 드롭 대상 노드 식별 */
export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null {
const el = document.elementFromPoint(clientX, clientY);
if (!el) return null;
const nodeEl = el.closest('.react-flow__node') as HTMLElement | null;
if (nodeEl) {
const id = nodeEl.getAttribute('data-id');
if (id) return id;
}
const handleEl = el.closest('[data-nodeid]') as HTMLElement | null;
if (handleEl) {
const id = handleEl.getAttribute('data-nodeid');
if (id) return id;
}
return null;
}
export interface PaletteDropResolution {
flowPos: { x: number; y: number };
anchorId: string | null;
}
/** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */
export function resolvePaletteDropClientPoint(
clientX: number,
clientY: number,
rf: FlowProjector | null,
containerEl: HTMLElement | null,
nodes: { id: string; position: { x: number; y: number } }[],
findAtFlow: (
flowPos: { x: number; y: number },
list: { id: string; position: { x: number; y: number } }[],
) => { id: string } | null,
isConnectTarget: (nodeId: string) => boolean,
): PaletteDropResolution {
const flowPos = projectScreenToFlowPosition(clientX, clientY, rf, containerEl);
const domId = findFlowNodeIdAtClientPoint(clientX, clientY);
if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) {
return { flowPos, anchorId: domId };
}
const flowHit = findAtFlow(flowPos, nodes);
if (flowHit && isConnectTarget(flowHit.id)) {
return { flowPos, anchorId: flowHit.id };
}
return { flowPos, anchorId: null };
}
+18 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Tauri WebView 등 HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션 * Tauri WebView 등 HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션
*/ */
import { bindWindowDrag, type ClientXY } from './pointerDrag'; import { bindWindowDrag, clientXYFromNative, type ClientXY } from './pointerDrag';
import { isDesktop } from './platform'; import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & { export type PaletteDragPayload = Record<string, unknown> & {
@@ -64,6 +64,7 @@ function emit(event: PaletteDragEvent) {
function removeGhost() { function removeGhost() {
ghostEl?.remove(); ghostEl?.remove();
ghostEl = null; ghostEl = null;
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
} }
function ensureGhost(label: string, x: number, y: number) { function ensureGhost(label: string, x: number, y: number) {
@@ -83,6 +84,7 @@ function moveGhost(x: number, y: number) {
} }
function finishDrag(phase: 'end' | 'cancel', xy: ClientXY) { function finishDrag(phase: 'end' | 'cancel', xy: ClientXY) {
if (!activePayload) return;
const payload = activePayload; const payload = activePayload;
cleanupDrag(); cleanupDrag();
if (payload && phase === 'end') { if (payload && phase === 'end') {
@@ -118,6 +120,21 @@ function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) {
(endXY) => finishDrag('end', endXY), (endXY) => finishDrag('end', endXY),
{ preventTouchScroll: true }, { preventTouchScroll: true },
); );
// Tauri WebView: bubble 단계 pointerup 누락 대비
const captureEnd = (ev: Event) => {
if (!activePayload) return;
const xy = clientXYFromNative(ev);
if (xy) finishDrag('end', xy);
};
window.addEventListener('pointerup', captureEnd, true);
window.addEventListener('pointercancel', captureEnd, true);
const prevUnbind = unbindMove;
unbindMove = () => {
window.removeEventListener('pointerup', captureEnd, true);
window.removeEventListener('pointercancel', captureEnd, true);
prevUnbind();
};
} }
/** /**