데스크탑 앱 전략편집기 수정
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user