데스크탑 앱 수정
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user