데스크탑 앱 수정

This commit is contained in:
Macbook
2026-06-14 22:57:37 +09:00
parent ec40241b2f
commit afd2cb2693
3 changed files with 156 additions and 104 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -32,8 +32,11 @@ import {
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { import {
isPaletteHtmlDrag, isPaletteHtmlDrag,
completePaletteDragAt,
getActivePaletteDrag,
needsPointerPaletteDrag, needsPointerPaletteDrag,
readPaletteHtmlDragData, readPaletteHtmlDragData,
setPaletteDragDropHandler,
subscribePaletteDrag, subscribePaletteDrag,
type PaletteDragEvent, type PaletteDragEvent,
} from '../../utils/paletteDragSession'; } from '../../utils/paletteDragSession';
@@ -1262,6 +1265,18 @@ function StrategyEditorCanvasInner({
applyDropRef.current = applyPaletteDropAt; applyDropRef.current = applyPaletteDropAt;
const clearPreviewRef = useRef(clearDropPreview); const clearPreviewRef = useRef(clearDropPreview);
clearPreviewRef.current = clearDropPreview; clearPreviewRef.current = clearDropPreview;
useLayoutEffect(() => {
if (!needsPointerPaletteDrag()) return undefined;
setPaletteDragDropHandler((ev) => {
if (ev.phase === 'end') {
clearPreviewRef.current();
applyDropRef.current(ev.payload, ev.x, ev.y);
}
});
return () => setPaletteDragDropHandler(null);
}, []);
const updatePreviewRef = useRef(updateDropPreview); const updatePreviewRef = useRef(updateDropPreview);
updatePreviewRef.current = updateDropPreview; updatePreviewRef.current = updateDropPreview;
const resolveDropFromClientRef = useRef(resolveDropFromClient); const resolveDropFromClientRef = useRef(resolveDropFromClient);
@@ -1283,11 +1298,6 @@ function StrategyEditorCanvasInner({
} }
return; return;
} }
if (ev.phase === 'end') {
clearPreviewRef.current();
applyDropRef.current(ev.payload, ev.x, ev.y);
return;
}
if (ev.phase === 'cancel') { if (ev.phase === 'cancel') {
clearPreviewRef.current(); clearPreviewRef.current();
} }
@@ -1332,11 +1342,21 @@ function StrategyEditorCanvasInner({
} }
}, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]); }, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]);
const handleCanvasPointerUp = useCallback((e: React.PointerEvent) => {
if (!needsPointerPaletteDrag()) return;
if (e.button !== 0) return;
if (!getActivePaletteDrag()) return;
e.preventDefault();
e.stopPropagation();
completePaletteDragAt(e.clientX, e.clientY);
}, []);
return ( return (
<div <div
ref={canvasWrapRef} 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}
onPointerUpCapture={handleCanvasPointerUp}
tabIndex={0} tabIndex={0}
> >
<ReactFlow <ReactFlow
+29 -9
View File
@@ -52,29 +52,35 @@ export function projectScreenToFlowPosition(
/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 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 elements = typeof document.elementsFromPoint === 'function'
if (!el) return false; ? document.elementsFromPoint(clientX, clientY)
if (el.closest('.se-palette-panel, .se-side-wrap--right, .se-palette-drag-ghost')) return false; : [document.elementFromPoint(clientX, clientY)].filter(Boolean) as Element[];
return el.closest('.se-canvas-wrap') != null;
for (const el of elements) {
if (el.closest('.se-palette-panel, .se-side-wrap--right, .se-palette-drag-ghost')) continue;
if (el.closest('.se-canvas-wrap')) return true;
}
return false;
} }
/** DOM hit-test — flow 좌표 변환 오류 시에도 드롭 대상 노드 식별 */ /** DOM hit-test — elementsFromPoint 로 ghost·overlay 아래 노드까지 탐색 */
export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null { export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null {
const el = document.elementFromPoint(clientX, clientY); const elements = typeof document.elementsFromPoint === 'function'
if (!el) return null; ? document.elementsFromPoint(clientX, clientY)
: [document.elementFromPoint(clientX, clientY)].filter(Boolean) as Element[];
for (const el of elements) {
const nodeEl = el.closest('.react-flow__node') as HTMLElement | null; const nodeEl = el.closest('.react-flow__node') as HTMLElement | null;
if (nodeEl) { if (nodeEl) {
const id = nodeEl.getAttribute('data-id'); const id = nodeEl.getAttribute('data-id');
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;
if (handleEl) { if (handleEl) {
const id = handleEl.getAttribute('data-nodeid'); const id = handleEl.getAttribute('data-nodeid');
if (id) return id; if (id) return id;
} }
}
return null; return null;
} }
@@ -103,6 +109,20 @@ export function resolvePaletteDropClientPoint(
return { flowPos, anchorId: domId }; return { flowPos, anchorId: domId };
} }
// DOM hit-test 실패 시 START 노드 bounding rect 로 보조
for (const node of nodes) {
if (!isConnectTarget(node.id)) continue;
const el = document.querySelector(`.react-flow__node[data-id="${CSS.escape(node.id)}"]`);
if (!el) continue;
const rect = el.getBoundingClientRect();
if (
clientX >= rect.left && clientX <= rect.right
&& clientY >= rect.top && clientY <= rect.bottom
) {
return { flowPos, anchorId: node.id };
}
}
const flowHit = findAtFlow(flowPos, nodes); const flowHit = findAtFlow(flowPos, nodes);
if (flowHit && isConnectTarget(flowHit.id)) { if (flowHit && isConnectTarget(flowHit.id)) {
return { flowPos, anchorId: flowHit.id }; return { flowPos, anchorId: flowHit.id };
+92 -80
View File
@@ -1,7 +1,7 @@
/** /**
* Tauri WebView HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션 * Tauri WebView HTML5 DnD 대신 pointer capture 기반 팔레트 드래그
*/ */
import { bindWindowDrag, clientXYFromNative, type ClientXY } from './pointerDrag'; import { clientXYFromNative } from './pointerDrag';
import { isDesktop } from './platform'; import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & { export type PaletteDragPayload = Record<string, unknown> & {
@@ -20,24 +20,18 @@ export interface PaletteDragEvent {
} }
type PaletteDragListener = (event: PaletteDragEvent) => void; type PaletteDragListener = (event: PaletteDragEvent) => void;
type PaletteDropHandler = (event: PaletteDragEvent) => void;
let suppressClickUntil = 0; let suppressClickUntil = 0;
/** 드래그 직후 발생하는 click 이벤트 무시 */
export function shouldSuppressPaletteClick(): boolean {
return Date.now() < suppressClickUntil;
}
const DRAG_THRESHOLD_PX = 6; const DRAG_THRESHOLD_PX = 6;
const GHOST_CLASS = 'se-palette-drag-ghost';
let activePayload: PaletteDragPayload | null = null; let activePayload: PaletteDragPayload | null = null;
let ghostEl: HTMLDivElement | null = null; let activePointerId: number | null = null;
let unbindMove: (() => void) | null = null; let unbindMove: (() => void) | null = null;
let pendingPointerId: number | null = null; let pendingPointerId: number | null = null;
let listeners = new Set<PaletteDragListener>(); let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null;
/** Desktop(Tauri) WebKit — native HTML5 DnD 불안정 */
export function needsPointerPaletteDrag(): boolean { export function needsPointerPaletteDrag(): boolean {
return isDesktop(); return isDesktop();
} }
@@ -47,10 +41,19 @@ export function subscribePaletteDrag(listener: PaletteDragListener): () => void
return () => listeners.delete(listener); return () => listeners.delete(listener);
} }
/** Canvas 가 mount 직후 drop 콜백 등록 (subscribe 누락 방지) */
export function setPaletteDragDropHandler(handler: PaletteDropHandler | null): void {
dropHandler = handler;
}
export function getActivePaletteDrag(): PaletteDragPayload | null { export function getActivePaletteDrag(): PaletteDragPayload | null {
return activePayload; return activePayload;
} }
export function shouldSuppressPaletteClick(): boolean {
return Date.now() < suppressClickUntil;
}
function emit(event: PaletteDragEvent) { function emit(event: PaletteDragEvent) {
listeners.forEach(fn => { listeners.forEach(fn => {
try { try {
@@ -61,89 +64,98 @@ function emit(event: PaletteDragEvent) {
}); });
} }
function removeGhost() { function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) {
ghostEl?.remove(); const event: PaletteDragEvent = { phase: 'end', x: xy.x, y: xy.y, payload };
ghostEl = null; dropHandler?.(event);
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); emit(event);
}
function ensureGhost(label: string, x: number, y: number) {
if (ghostEl) return;
ghostEl = document.createElement('div');
ghostEl.className = GHOST_CLASS;
ghostEl.textContent = label;
ghostEl.style.left = `${x}px`;
ghostEl.style.top = `${y}px`;
document.body.appendChild(ghostEl);
}
function moveGhost(x: number, y: number) {
if (!ghostEl) return;
ghostEl.style.left = `${x}px`;
ghostEl.style.top = `${y}px`;
}
function finishDrag(phase: 'end' | 'cancel', xy: ClientXY) {
if (!activePayload) return;
const payload = activePayload;
cleanupDrag();
if (payload && phase === 'end') {
suppressClickUntil = Date.now() + 400;
emit({ phase: 'end', x: xy.x, y: xy.y, payload });
} else if (payload) {
emit({ phase: 'cancel', x: xy.x, y: xy.y, payload });
}
} }
function cleanupDrag() { function cleanupDrag() {
unbindMove?.(); unbindMove?.();
unbindMove = null; unbindMove = null;
activePayload = null; activePayload = null;
activePointerId = null;
pendingPointerId = null; pendingPointerId = null;
removeGhost();
document.body.classList.remove('se-palette-drag-active'); document.body.classList.remove('se-palette-drag-active');
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
} }
function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) { function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
if (!activePayload) return;
const payload = activePayload;
cleanupDrag();
if (phase === 'end') {
suppressClickUntil = Date.now() + 400;
dispatchEnd(xy, payload);
} else {
emit({ phase: 'cancel', x: xy.x, y: xy.y, payload });
}
}
/** 캔버스 pointerup 백업 — WebView 에서 window 이벤트 누락 시 */
export function completePaletteDragAt(clientX: number, clientY: number): boolean {
if (!activePayload) return false;
finishDrag('end', { x: clientX, y: clientY });
return true;
}
function beginDrag(
payload: PaletteDragPayload,
xy: { x: number; y: number },
pointerId: number,
) {
activePayload = payload; activePayload = payload;
activePointerId = pointerId;
document.body.classList.add('se-palette-drag-active'); document.body.classList.add('se-palette-drag-active');
ensureGhost(label, xy.x, xy.y);
try {
document.body.setPointerCapture(pointerId);
} catch {
/* ignore */
}
emit({ phase: 'start', x: xy.x, y: xy.y, payload }); emit({ phase: 'start', x: xy.x, y: xy.y, payload });
unbindMove = bindWindowDrag( const onMove = (ev: Event) => {
({ x, y }) => { if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId || !activePayload) return;
moveGhost(x, y); if (ev.cancelable) ev.preventDefault();
if (activePayload) { emit({ phase: 'move', x: ev.clientX, y: ev.clientY, payload: activePayload });
emit({ phase: 'move', x, y, payload: activePayload });
}
},
(endXY) => finishDrag('end', endXY),
{ 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 onUp = (ev: Event) => {
const prevUnbind = unbindMove; if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return;
const xy = clientXYFromNative(ev) ?? { x: ev.clientX, y: ev.clientY };
finishDrag('end', xy);
};
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('mouseup', onUp, true);
document.addEventListener('pointerup', onUp, true);
document.addEventListener('lostpointercapture', onUp, true);
unbindMove = () => { unbindMove = () => {
window.removeEventListener('pointerup', captureEnd, true); window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointercancel', captureEnd, true); window.removeEventListener('pointerup', onUp, true);
prevUnbind(); window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('mouseup', onUp, true);
document.removeEventListener('pointerup', onUp, true);
document.removeEventListener('lostpointercapture', onUp, true);
try {
if (document.body.hasPointerCapture(pointerId)) {
document.body.releasePointerCapture(pointerId);
}
} catch {
/* ignore */
}
}; };
} }
/**
* 팔레트 카드 pointerdown — 임계 이동 후 드래그 시작 (클릭과 구분)
*/
export function handlePalettePointerDown( export function handlePalettePointerDown(
e: React.PointerEvent, e: React.PointerEvent,
payload: PaletteDragPayload, payload: PaletteDragPayload,
label: string, _label: string,
): void { ): void {
if (!needsPointerPaletteDrag()) return; if (!needsPointerPaletteDrag()) return;
if (e.button !== 0) return; if (e.button !== 0) return;
@@ -165,7 +177,7 @@ export function handlePalettePointerDown(
window.removeEventListener('pointercancel', onPointerCancel); window.removeEventListener('pointercancel', onPointerCancel);
ev.preventDefault(); ev.preventDefault();
beginDrag(payload, label, { x: ev.clientX, y: ev.clientY }); beginDrag(payload, { x: ev.clientX, y: ev.clientY }, pointerId);
}; };
const onPointerUp = (ev: PointerEvent) => { const onPointerUp = (ev: PointerEvent) => {
@@ -183,13 +195,6 @@ export function handlePalettePointerDown(
window.addEventListener('pointercancel', onPointerCancel); window.addEventListener('pointercancel', onPointerCancel);
} }
export function findPaletteDropKeyAtPoint(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y);
if (!el) return null;
const host = el.closest('[data-palette-drop-key]') as HTMLElement | null;
return host?.dataset.paletteDropKey ?? null;
}
export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void { export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void {
const json = JSON.stringify(payload); const json = JSON.stringify(payload);
e.dataTransfer.setData('application/json', json); e.dataTransfer.setData('application/json', json);
@@ -212,3 +217,10 @@ export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload
return null; return null;
} }
} }
export function findPaletteDropKeyAtPoint(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y);
if (!el) return null;
const host = el.closest('[data-palette-drop-key]') as HTMLElement | null;
return host?.dataset.paletteDropKey ?? null;
}