앱 수정

This commit is contained in:
Macbook
2026-06-15 01:16:14 +09:00
parent 16178cb475
commit b0e2efd7cb
4 changed files with 90 additions and 20 deletions
@@ -37,9 +37,14 @@ import StochPairNodeSettings from './StochPairNodeSettings';
import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings'; import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings';
import { import {
isPaletteHtmlDrag, isPaletteHtmlDrag,
isPaletteHtmlDragActive,
readPaletteHtmlDragData, readPaletteHtmlDragData,
} from '../../utils/paletteDragSession'; } from '../../utils/paletteDragSession';
function isPaletteDropDrag(e: React.DragEvent): boolean {
return isPaletteHtmlDragActive() || isPaletteHtmlDrag(e);
}
const LOGIC_COLORS: Record<string, string> = { const LOGIC_COLORS: Record<string, string> = {
AND: '#00aaff', AND: '#00aaff',
OR: '#00d4ff', OR: '#00d4ff',
@@ -130,13 +135,13 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const onDragEnter = useCallback((e: React.DragEvent) => { const onDragEnter = useCallback((e: React.DragEvent) => {
if (!isPaletteHtmlDrag(e)) return; if (!isPaletteDropDrag(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}, []); }, []);
const onDragOver = useCallback((e: React.DragEvent) => { const onDragOver = useCallback((e: React.DragEvent) => {
if (!isPaletteHtmlDrag(e)) return; if (!isPaletteDropDrag(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
@@ -146,8 +151,10 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
const onDragLeave = useCallback((e: React.DragEvent) => { const onDragLeave = useCallback((e: React.DragEvent) => {
e.stopPropagation(); e.stopPropagation();
// WebKit HTML5 drag — relatedTarget 이 null 인 spurious dragleave 무시
const rel = e.relatedTarget as Node | null; const rel = e.relatedTarget as Node | null;
if (rel && e.currentTarget.contains(rel)) return; if (!rel) return;
if (e.currentTarget.contains(rel)) return;
d.onDragLeaveTarget?.(id); d.onDragLeaveTarget?.(id);
}, [d, id]); }, [d, id]);
@@ -82,6 +82,7 @@ import {
spawnPositionNearAnchor, spawnPositionNearAnchor,
subtreeToFlatOrphans, subtreeToFlatOrphans,
type EdgeHandleBinding, type EdgeHandleBinding,
type StrategyFlowNodeData,
} from '../../utils/strategyFlowLayout'; } from '../../utils/strategyFlowLayout';
import { import {
createStartNodeId, createStartNodeId,
@@ -906,10 +907,16 @@ function StrategyEditorCanvasInner({
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => { const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
const prev = nodesRef.current.find(p => p.id === n.id); const prev = nodesRef.current.find(p => p.id === n.id);
const savedPos = layoutSeed.positions[n.id]; const savedPos = layoutSeed.positions[n.id];
const dragOver = (prev?.data as StrategyFlowNodeData | undefined)?.dragOver;
const activeSourceSide = (prev?.data as StrategyFlowNodeData | undefined)?.activeSourceSide;
return { return {
...n, ...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position, position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position,
selected: prev?.selected ?? false, selected: prev?.selected ?? false,
data: {
...n.data,
...(dragOver ? { dragOver, activeSourceSide } : {}),
},
}; };
})); }));
@@ -933,10 +940,15 @@ function StrategyEditorCanvasInner({
const fresh = layoutNodes.find(ln => ln.id === n.id); const fresh = layoutNodes.find(ln => ln.id === n.id);
if (!fresh) return n; if (!fresh) return n;
const savedPos = layoutSeed.positions[n.id]; const savedPos = layoutSeed.positions[n.id];
const dragOver = (n.data as StrategyFlowNodeData).dragOver;
const activeSourceSide = (n.data as StrategyFlowNodeData).activeSourceSide;
return { return {
...n, ...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position, position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position,
data: fresh.data, data: {
...fresh.data,
...(dragOver ? { dragOver, activeSourceSide } : {}),
},
}; };
})); }));
setEdges(mergedEdges); setEdges(mergedEdges);
@@ -1226,7 +1238,10 @@ function StrategyEditorCanvasInner({
) )
), [root, orphans]); ), [root, orphans]);
const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e); const isPaletteDrag = useCallback(
(e: React.DragEvent) => isPaletteHtmlDragActive() || isPaletteHtmlDrag(e),
[],
);
const applyPaletteDropAt = useCallback(( const applyPaletteDropAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean },
@@ -1291,11 +1306,8 @@ function StrategyEditorCanvasInner({
e.preventDefault(); e.preventDefault();
const { flowPos, anchorId } = resolveDropFromClientRef.current(e.clientX, e.clientY); const { flowPos, anchorId } = resolveDropFromClientRef.current(e.clientX, e.clientY);
if (anchorId) { if (anchorId) {
const sideKey = `${anchorId}:${Math.round(flowPos.x / 8)}:${Math.round(flowPos.y / 8)}`;
if (sideKey === lastPreviewKeyRef.current) return;
lastPreviewKeyRef.current = sideKey;
lastPreviewAnchorRef.current = anchorId;
updatePreviewRef.current(anchorId, flowPos); updatePreviewRef.current(anchorId, flowPos);
lastPreviewAnchorRef.current = anchorId;
return; return;
} }
if (lastPreviewAnchorRef.current != null) { if (lastPreviewAnchorRef.current != null) {
@@ -1311,9 +1323,11 @@ function StrategyEditorCanvasInner({
markPaletteHtmlDragEnd(); markPaletteHtmlDragEnd();
}; };
document.addEventListener('dragover', onDocDragOver, { passive: false }); document.addEventListener('dragover', onDocDragOver, { passive: false });
document.addEventListener('drag', onDocDragOver);
document.addEventListener('dragend', onDocDragEnd); document.addEventListener('dragend', onDocDragEnd);
return () => { return () => {
document.removeEventListener('dragover', onDocDragOver); document.removeEventListener('dragover', onDocDragOver);
document.removeEventListener('drag', onDocDragOver);
document.removeEventListener('dragend', onDocDragEnd); document.removeEventListener('dragend', onDocDragEnd);
}; };
}, []); }, []);
@@ -1368,14 +1382,13 @@ function StrategyEditorCanvasInner({
const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY); const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY);
if (anchorId) { if (anchorId) {
updateDropPreview(anchorId, flowPos); updateDropPreview(anchorId, flowPos);
} else {
clearDropPreview();
} }
}, [resolveDropFromClient, updateDropPreview, clearDropPreview]); }, [resolveDropFromClient, updateDropPreview, isPaletteDrag]);
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;
if (rel && e.currentTarget.contains(rel)) return; if (!rel) return;
if (e.currentTarget.contains(rel)) return;
clearDropPreview(); clearDropPreview();
}, [clearDropPreview]); }, [clearDropPreview]);
+39 -7
View File
@@ -1,4 +1,5 @@
import type { ReactFlowInstance } from '@xyflow/react'; import type { ReactFlowInstance } from '@xyflow/react';
import { getLastHtmlPaletteDragClientXY, isPaletteHtmlDragActive } from './paletteDragSession';
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>; type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
@@ -89,8 +90,27 @@ export function isOverStrategyBuilder(clientX: number, clientY: number): boolean
return false; return false;
} }
/** DOM hit-test — overlay 아래 react-flow 노드 탐색 */ /** DOM hit-test — react-flow 노드 id (Safari HTML5 drag: elementsFromPoint 불가 → rect 우선) */
export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null { export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null {
const pad = 10;
const nodeEls = document.querySelectorAll<HTMLElement>('.react-flow__node');
for (const nodeEl of nodeEls) {
const rect = nodeEl.getBoundingClientRect();
if (
clientX >= rect.left - pad && clientX <= rect.right + pad
&& clientY >= rect.top - pad && clientY <= rect.bottom + pad
) {
const id = nodeEl.getAttribute('data-id')
?? nodeEl.dataset.id
?? (nodeEl.id.startsWith('react-flow__node-')
? nodeEl.id.slice('react-flow__node-'.length)
: null)
?? nodeEl.querySelector<HTMLElement>('[data-nodeid]')?.getAttribute('data-nodeid')
?? null;
if (id) return id;
}
}
for (const el of elementsUnderDragPoint(clientX, clientY)) { for (const el of elementsUnderDragPoint(clientX, clientY)) {
const nodeEl = el.closest('.react-flow__node') as HTMLElement | null; const nodeEl = el.closest('.react-flow__node') as HTMLElement | null;
if (nodeEl) { if (nodeEl) {
@@ -98,7 +118,9 @@ export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): s
?? nodeEl.dataset.id ?? nodeEl.dataset.id
?? (nodeEl.id.startsWith('react-flow__node-') ?? (nodeEl.id.startsWith('react-flow__node-')
? nodeEl.id.slice('react-flow__node-'.length) ? nodeEl.id.slice('react-flow__node-'.length)
: null); : null)
?? nodeEl.querySelector<HTMLElement>('[data-nodeid]')?.getAttribute('data-nodeid')
?? null;
if (id) return id; if (id) return id;
} }
const handleEl = el.closest('[data-nodeid]') as HTMLElement | null; const handleEl = el.closest('[data-nodeid]') as HTMLElement | null;
@@ -115,6 +137,14 @@ export interface PaletteDropResolution {
anchorId: string | null; anchorId: string | null;
} }
/** WKWebView HTML5 drag — dragover client 좌표가 0,0 으로 오는 경우 보정 */
export function resolvePaletteDragClientPoint(clientX: number, clientY: number): { x: number; y: number } {
if (clientX !== 0 || clientY !== 0) return { x: clientX, y: clientY };
if (!isPaletteHtmlDragActive()) return { x: clientX, y: clientY };
const last = getLastHtmlPaletteDragClientXY();
return last ?? { x: clientX, y: clientY };
}
/** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */ /** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */
export function resolvePaletteDropClientPoint( export function resolvePaletteDropClientPoint(
clientX: number, clientX: number,
@@ -128,9 +158,10 @@ export function resolvePaletteDropClientPoint(
) => { id: string } | null, ) => { id: string } | null,
isConnectTarget: (nodeId: string) => boolean, isConnectTarget: (nodeId: string) => boolean,
): PaletteDropResolution { ): PaletteDropResolution {
const flowPos = projectScreenToFlowPosition(clientX, clientY, rf, containerEl); const { x: px, y: py } = resolvePaletteDragClientPoint(clientX, clientY);
const flowPos = projectScreenToFlowPosition(px, py, rf, containerEl);
const domId = findFlowNodeIdAtClientPoint(clientX, clientY); const domId = findFlowNodeIdAtClientPoint(px, py);
if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) { if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) {
return { flowPos, anchorId: domId }; return { flowPos, anchorId: domId };
} }
@@ -140,12 +171,13 @@ export function resolvePaletteDropClientPoint(
const escaped = typeof CSS !== 'undefined' && CSS.escape const escaped = typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(node.id) ? CSS.escape(node.id)
: node.id.replace(/"/g, '\\"'); : node.id.replace(/"/g, '\\"');
const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`); const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`)
?? document.getElementById(`react-flow__node-${node.id}`);
if (!el) continue; if (!el) continue;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if ( if (
clientX >= rect.left && clientX <= rect.right px >= rect.left && px <= rect.right
&& clientY >= rect.top && clientY <= rect.bottom && py >= rect.top && py <= rect.bottom
) { ) {
return { flowPos, anchorId: node.id }; return { flowPos, anchorId: node.id };
} }
+18
View File
@@ -55,6 +55,7 @@ let pendingMove: { x: number; y: number } | null = null;
let unbindGesture: (() => void) | null = null; let unbindGesture: (() => void) | null = null;
let lastDragClientXY: { x: number; y: number } | null = null; let lastDragClientXY: { x: number; y: number } | null = null;
let htmlPaletteDragActive = false; let htmlPaletteDragActive = false;
let lastHtmlDragClientXY: { x: number; y: number } | null = null;
function installHtmlPaletteDragGlobals() { function installHtmlPaletteDragGlobals() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -64,12 +65,23 @@ function installHtmlPaletteDragGlobals() {
window.addEventListener('dragover', (e) => { window.addEventListener('dragover', (e) => {
if (!htmlPaletteDragActive) return; if (!htmlPaletteDragActive) return;
if (e.clientX !== 0 || e.clientY !== 0) {
lastHtmlDragClientXY = { x: e.clientX, y: e.clientY };
}
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}, { passive: false }); }, { passive: false });
window.addEventListener('drag', (e) => {
if (!htmlPaletteDragActive) return;
if (e.clientX !== 0 || e.clientY !== 0) {
lastHtmlDragClientXY = { x: e.clientX, y: e.clientY };
}
});
window.addEventListener('dragend', () => { window.addEventListener('dragend', () => {
htmlPaletteDragActive = false; htmlPaletteDragActive = false;
lastHtmlDragClientXY = null;
}); });
} }
@@ -328,10 +340,16 @@ export function handlePaletteTouchStart(
export function markPaletteHtmlDragStart(): void { export function markPaletteHtmlDragStart(): void {
htmlPaletteDragActive = true; htmlPaletteDragActive = true;
lastHtmlDragClientXY = null;
} }
export function markPaletteHtmlDragEnd(): void { export function markPaletteHtmlDragEnd(): void {
htmlPaletteDragActive = false; htmlPaletteDragActive = false;
lastHtmlDragClientXY = null;
}
export function getLastHtmlPaletteDragClientXY(): { x: number; y: number } | null {
return lastHtmlDragClientXY;
} }
export function isPaletteHtmlDragActive(): boolean { export function isPaletteHtmlDragActive(): boolean {