diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx
index 3775b53..9865ca4 100644
--- a/frontend/src/components/StrategyEditorPage.tsx
+++ b/frontend/src/components/StrategyEditorPage.tsx
@@ -142,6 +142,7 @@ import {
} from '../utils/strategyImportExport';
import PaletteChip from './strategyEditor/PaletteChip';
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
+import { needsPointerPaletteDrag } from '../utils/paletteDragSession';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import {
clampPanelSize,
@@ -1617,7 +1618,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
return (
-
+ {needsPointerPaletteDrag() ?
: null}
{saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
)}
diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx
index 054cca7..ede77aa 100644
--- a/frontend/src/components/strategyEditor/PaletteChip.tsx
+++ b/frontend/src/components/strategyEditor/PaletteChip.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import {
+ handlePaletteMouseDown,
handlePalettePointerDown,
handlePaletteTouchStart,
needsPointerPaletteDrag,
@@ -56,6 +57,10 @@ export default function PaletteChip({
handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
};
+ const onMouseDown = (e: React.MouseEvent) => {
+ handlePaletteMouseDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
+ };
+
const onTouchStart = (e: React.TouchEvent) => {
handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label);
};
@@ -88,6 +93,7 @@ export default function PaletteChip({
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart}
onPointerDown={onPointerDown}
+ onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts
index 5aea13d..f18690e 100644
--- a/frontend/src/utils/paletteDragSession.ts
+++ b/frontend/src/utils/paletteDragSession.ts
@@ -1,13 +1,13 @@
/**
- * Desktop(Tauri) 및 터치 기기 — HTML5 DnD 대신 pointer/touch + 전체화면 overlay 드래그
+ * Desktop(Tauri) 및 터치 — HTML5 DnD 대신 pointer/touch + overlay 드래그
+ * (useDraggablePanel 과 동일: pointerdown 1회 bindWindowDrag 로 전체 제스처 처리)
*/
import { elementsUnderDragPoint } from './flowScreenProjection';
import {
bindWindowDrag,
- clientXYFromNative,
isPrimaryPointerButton,
} from './pointerDrag';
-import { isDesktop } from './platform';
+import { isDesktop, isMacDesktop } from './platform';
export type PaletteDragPayload = Record
& {
type: string;
@@ -45,7 +45,6 @@ const DRAG_THRESHOLD_PX = 6;
let activePayload: PaletteDragPayload | null = null;
let activePointerId: number | null = null;
-let activeTouchId: number | null = null;
let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null;
let gestureCaptureEl: HTMLElement | null = null;
@@ -53,11 +52,8 @@ let listeners = new Set();
let dropHandler: PaletteDropHandler | null = null;
let moveRafId: number | null = null;
let pendingMove: { x: number; y: number } | null = null;
-let unbindActiveMove: (() => void) | null = null;
-let unbindActiveEnd: (() => void) | null = null;
-let pendingPointerListeners: (() => void) | null = null;
+let unbindGesture: (() => void) | null = null;
let lastDragClientXY: { x: number; y: number } | null = null;
-let endListenersArmed = false;
function hasCoarsePointer(): boolean {
if (typeof window === 'undefined') return false;
@@ -68,8 +64,10 @@ function hasCoarsePointer(): boolean {
}
}
-/** HTML5 DnD 대신 pointer/touch overlay 드래그 사용 */
+/** HTML5 DnD 대신 pointer/touch overlay 드래그 사용 (Windows Desktop 등) */
export function needsPointerPaletteDrag(): boolean {
+ // macOS Tauri WKWebView — Safari 웹과 동일하게 HTML5 DnD 사용
+ if (isMacDesktop()) return false;
return isDesktop() || hasCoarsePointer();
}
@@ -123,36 +121,19 @@ function releasePointerCapture(pointerId: number | null) {
} catch {
/* ignore */
}
- try {
- if (document.body.hasPointerCapture(pointerId)) {
- document.body.releasePointerCapture(pointerId);
- }
- } catch {
- /* ignore */
- }
}
-function clearPendingPointerListeners() {
- pendingPointerListeners?.();
- pendingPointerListeners = null;
-}
-
-function unbindActiveDragListeners() {
- unbindActiveMove?.();
- unbindActiveMove = null;
- unbindActiveEnd?.();
- unbindActiveEnd = null;
- endListenersArmed = false;
+function unbindGestureListeners() {
+ unbindGesture?.();
+ unbindGesture = null;
}
function cleanupDrag() {
const pointerId = activePointerId;
- unbindActiveDragListeners();
- clearPendingPointerListeners();
+ unbindGestureListeners();
releasePointerCapture(pointerId);
activePayload = null;
activePointerId = null;
- activeTouchId = null;
pendingMove = null;
lastDragClientXY = null;
if (moveRafId != null) {
@@ -179,6 +160,20 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
cleanupDrag();
}
+function mountDragOverlay(
+ payload: PaletteDragPayload,
+ xy: { x: number; y: number },
+ pointerId: number,
+) {
+ activePayload = payload;
+ activePointerId = pointerId;
+ lastDragClientXY = { x: xy.x, y: xy.y };
+ document.body.classList.add('se-palette-drag-active');
+ overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y });
+ emit({ phase: 'start', x: xy.x, y: xy.y, payload });
+ notifyPaletteDragMove(xy.x, xy.y);
+}
+
export function notifyPaletteDragMove(clientX: number, clientY: number) {
if (!activePayload) return;
lastDragClientXY = { x: clientX, y: clientY };
@@ -194,12 +189,12 @@ export function notifyPaletteDragMove(clientX: number, clientY: number) {
}
export function notifyPaletteDragEnd(clientX: number, clientY: number) {
- if (!endListenersArmed) return;
+ if (!activePayload) return;
finishDrag('end', { x: clientX, y: clientY });
}
export function notifyPaletteDragCancel(clientX: number, clientY: number) {
- if (!endListenersArmed) return;
+ if (!activePayload) return;
finishDrag('cancel', { x: clientX, y: clientY });
}
@@ -209,114 +204,15 @@ export function completePaletteDragAt(clientX: number, clientY: number): boolean
return true;
}
-function eventMatchesActiveInput(ev: Event): boolean {
- if (!activePayload || activePointerId == null) return false;
- if (ev instanceof PointerEvent) return ev.pointerId === activePointerId;
- if (ev instanceof TouchEvent) {
- if (activeTouchId != null) {
- const touches = ev.touches.length > 0 ? ev.touches : ev.changedTouches;
- return Array.from(touches).some(t => t.identifier === activeTouchId);
- }
- return ev.touches.length <= 1;
- }
- return true;
-}
-
-function bindActiveDragMoveListeners(pointerId: number) {
- unbindActiveMove?.();
-
- unbindActiveMove = bindWindowDrag(
- (xy, ev) => {
- if (!activePayload || activePointerId !== pointerId) return;
- if (!eventMatchesActiveInput(ev)) return;
- notifyPaletteDragMove(xy.x, xy.y);
- },
- () => { /* end 는 별도 등록 */ },
- { preventTouchScroll: true, useCapture: true },
- );
-}
-
-function bindActiveDragEndListeners(pointerId: number) {
- unbindActiveEnd?.();
-
- const onEnd = (ev: Event) => {
- if (!endListenersArmed) return;
- if (!activePayload || activePointerId !== pointerId) return;
- if (!eventMatchesActiveInput(ev)) return;
- const xy = clientXYFromNative(ev) ?? lastDragClientXY;
- if (!xy) return;
- finishDrag('end', xy);
- };
-
- window.addEventListener('pointerup', onEnd, true);
- window.addEventListener('mouseup', onEnd, true);
- window.addEventListener('touchend', onEnd, true);
-
- unbindActiveEnd = () => {
- window.removeEventListener('pointerup', onEnd, true);
- window.removeEventListener('mouseup', onEnd, true);
- window.removeEventListener('touchend', onEnd, true);
- };
-}
-
-function captureOnOverlay(pointerId: number) {
- try {
- overlayElement?.setPointerCapture(pointerId);
- } catch {
- /* ignore */
- }
- try {
- if (!overlayElement?.hasPointerCapture(pointerId)) {
- document.body.setPointerCapture(pointerId);
- }
- } catch {
- /* ignore */
- }
-}
-
-function armDragEndListeners(pointerId: number) {
- endListenersArmed = true;
- bindActiveDragEndListeners(pointerId);
-}
-
-function beginDrag(
- payload: PaletteDragPayload,
- xy: { x: number; y: number },
- pointerId: number,
- touchId: number | null = null,
-) {
- if (activePayload) {
- cleanupDrag();
- }
-
- activePayload = payload;
- activePointerId = pointerId;
- activeTouchId = touchId;
- lastDragClientXY = { x: xy.x, y: xy.y };
- endListenersArmed = false;
- document.body.classList.add('se-palette-drag-active');
-
- bindActiveDragMoveListeners(pointerId);
- overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y });
-
- emit({ phase: 'start', x: xy.x, y: xy.y, payload });
- notifyPaletteDragMove(xy.x, xy.y);
-
- requestAnimationFrame(() => {
- if (activePointerId !== pointerId) return;
- captureOnOverlay(pointerId);
- requestAnimationFrame(() => {
- if (activePointerId !== pointerId) return;
- armDragEndListeners(pointerId);
- });
- });
-}
-
export function bindPaletteDragOverlayElement(el: HTMLElement | null) {
overlayElement = el;
overlayController?.bindElement(el);
if (el && activePointerId != null) {
- captureOnOverlay(activePointerId);
+ try {
+ el.setPointerCapture(activePointerId);
+ } catch {
+ /* window gesture listener 가 move/end 처리 */
+ }
}
}
@@ -324,36 +220,35 @@ function startPaletteDragGesture(
startX: number,
startY: number,
pointerId: number,
- touchId: number | null,
payload: PaletteDragPayload,
): void {
if (activePayload) {
cleanupDrag();
}
+ unbindGestureListeners();
- clearPendingPointerListeners();
+ let dragging = false;
- let dragStarted = false;
-
- const unbind = bindWindowDrag(
- (xy, ev) => {
- if (dragStarted) return;
- const dx = xy.x - startX;
- const dy = xy.y - startY;
- if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
- dragStarted = true;
- clearPendingPointerListeners();
- if (ev.cancelable) ev.preventDefault();
- beginDrag(payload, xy, pointerId, touchId);
+ unbindGesture = bindWindowDrag(
+ (xy) => {
+ if (!dragging) {
+ const dx = xy.x - startX;
+ const dy = xy.y - startY;
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
+ dragging = true;
+ mountDragOverlay(payload, xy, pointerId);
+ return;
+ }
+ notifyPaletteDragMove(xy.x, xy.y);
},
- () => {
- if (dragStarted) return;
- clearPendingPointerListeners();
+ (xy) => {
+ unbindGestureListeners();
+ if (dragging && activePayload) {
+ finishDrag('end', xy);
+ }
},
{ preventTouchScroll: true },
);
-
- pendingPointerListeners = unbind;
}
export function handlePalettePointerDown(
@@ -366,25 +261,36 @@ export function handlePalettePointerDown(
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault();
+ e.stopPropagation();
const sourceEl = e.currentTarget as HTMLElement;
try {
sourceEl.setPointerCapture(e.pointerId);
gestureCaptureEl = sourceEl;
} catch {
- /* overlay/body capture later */
+ /* bindWindowDrag 가 move/end 처리 */
}
- startPaletteDragGesture(
- e.clientX,
- e.clientY,
- e.pointerId,
- null,
- payload,
- );
+ startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload);
}
-/** PointerEvent 미지원 환경 — touchstart 전용 진입 */
+/** macOS WebView 등 — pointer 대신 mouse 이벤트만 오는 경우 */
+export function handlePaletteMouseDown(
+ e: React.MouseEvent,
+ payload: PaletteDragPayload,
+ _label: string,
+): void {
+ if (!needsPointerPaletteDrag()) return;
+ if (e.button !== 0) return;
+ if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
+ if (typeof window !== 'undefined' && 'PointerEvent' in window) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ startPaletteDragGesture(e.clientX, e.clientY, 1, payload);
+}
+
+/** PointerEvent 미지원 — touchstart 전용 */
export function handlePaletteTouchStart(
e: React.TouchEvent,
payload: PaletteDragPayload,
@@ -396,15 +302,8 @@ export function handlePaletteTouchStart(
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault();
-
const touch = e.touches[0];
- startPaletteDragGesture(
- touch.clientX,
- touch.clientY,
- touch.identifier,
- touch.identifier,
- payload,
- );
+ startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload);
}
export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void {