앱 전략편집기 수정

This commit is contained in:
Macbook
2026-06-15 00:52:54 +09:00
parent 2a2fe74933
commit 4a7fbcd582
3 changed files with 79 additions and 173 deletions
@@ -142,6 +142,7 @@ import {
} from '../utils/strategyImportExport'; } from '../utils/strategyImportExport';
import PaletteChip from './strategyEditor/PaletteChip'; import PaletteChip from './strategyEditor/PaletteChip';
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay'; import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
import { needsPointerPaletteDrag } from '../utils/paletteDragSession';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import { import {
clampPanelSize, clampPanelSize,
@@ -1617,7 +1618,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
return ( return (
<div className={`se-page se-page--${theme}`}> <div className={`se-page se-page--${theme}`}>
<PaletteDragOverlay /> {needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
{saveToast && ( {saveToast && (
<div className="se-save-toast"> DB에 .</div> <div className="se-save-toast"> DB에 .</div>
)} )}
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { import {
handlePaletteMouseDown,
handlePalettePointerDown, handlePalettePointerDown,
handlePaletteTouchStart, handlePaletteTouchStart,
needsPointerPaletteDrag, needsPointerPaletteDrag,
@@ -56,6 +57,10 @@ export default function PaletteChip({
handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
}; };
const onMouseDown = (e: React.MouseEvent) => {
handlePaletteMouseDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
};
const onTouchStart = (e: React.TouchEvent) => { const onTouchStart = (e: React.TouchEvent) => {
handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label); 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' : ''}`} 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} onDragStart={onDragStart}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
+68 -169
View File
@@ -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 { elementsUnderDragPoint } from './flowScreenProjection';
import { import {
bindWindowDrag, bindWindowDrag,
clientXYFromNative,
isPrimaryPointerButton, isPrimaryPointerButton,
} from './pointerDrag'; } from './pointerDrag';
import { isDesktop } from './platform'; import { isDesktop, isMacDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & { export type PaletteDragPayload = Record<string, unknown> & {
type: string; type: string;
@@ -45,7 +45,6 @@ const DRAG_THRESHOLD_PX = 6;
let activePayload: PaletteDragPayload | null = null; let activePayload: PaletteDragPayload | null = null;
let activePointerId: number | null = null; let activePointerId: number | null = null;
let activeTouchId: number | null = null;
let overlayController: OverlayController | null = null; let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null; let overlayElement: HTMLElement | null = null;
let gestureCaptureEl: HTMLElement | null = null; let gestureCaptureEl: HTMLElement | null = null;
@@ -53,11 +52,8 @@ let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null; let dropHandler: PaletteDropHandler | null = null;
let moveRafId: number | null = null; let moveRafId: number | null = null;
let pendingMove: { x: number; y: number } | null = null; let pendingMove: { x: number; y: number } | null = null;
let unbindActiveMove: (() => void) | null = null; let unbindGesture: (() => void) | null = null;
let unbindActiveEnd: (() => void) | null = null;
let pendingPointerListeners: (() => void) | null = null;
let lastDragClientXY: { x: number; y: number } | null = null; let lastDragClientXY: { x: number; y: number } | null = null;
let endListenersArmed = false;
function hasCoarsePointer(): boolean { function hasCoarsePointer(): boolean {
if (typeof window === 'undefined') return false; 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 { export function needsPointerPaletteDrag(): boolean {
// macOS Tauri WKWebView — Safari 웹과 동일하게 HTML5 DnD 사용
if (isMacDesktop()) return false;
return isDesktop() || hasCoarsePointer(); return isDesktop() || hasCoarsePointer();
} }
@@ -123,36 +121,19 @@ function releasePointerCapture(pointerId: number | null) {
} catch { } catch {
/* ignore */ /* ignore */
} }
try {
if (document.body.hasPointerCapture(pointerId)) {
document.body.releasePointerCapture(pointerId);
}
} catch {
/* ignore */
}
} }
function clearPendingPointerListeners() { function unbindGestureListeners() {
pendingPointerListeners?.(); unbindGesture?.();
pendingPointerListeners = null; unbindGesture = null;
}
function unbindActiveDragListeners() {
unbindActiveMove?.();
unbindActiveMove = null;
unbindActiveEnd?.();
unbindActiveEnd = null;
endListenersArmed = false;
} }
function cleanupDrag() { function cleanupDrag() {
const pointerId = activePointerId; const pointerId = activePointerId;
unbindActiveDragListeners(); unbindGestureListeners();
clearPendingPointerListeners();
releasePointerCapture(pointerId); releasePointerCapture(pointerId);
activePayload = null; activePayload = null;
activePointerId = null; activePointerId = null;
activeTouchId = null;
pendingMove = null; pendingMove = null;
lastDragClientXY = null; lastDragClientXY = null;
if (moveRafId != null) { if (moveRafId != null) {
@@ -179,6 +160,20 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
cleanupDrag(); 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) { export function notifyPaletteDragMove(clientX: number, clientY: number) {
if (!activePayload) return; if (!activePayload) return;
lastDragClientXY = { x: clientX, y: clientY }; lastDragClientXY = { x: clientX, y: clientY };
@@ -194,12 +189,12 @@ export function notifyPaletteDragMove(clientX: number, clientY: number) {
} }
export function notifyPaletteDragEnd(clientX: number, clientY: number) { export function notifyPaletteDragEnd(clientX: number, clientY: number) {
if (!endListenersArmed) return; if (!activePayload) return;
finishDrag('end', { x: clientX, y: clientY }); finishDrag('end', { x: clientX, y: clientY });
} }
export function notifyPaletteDragCancel(clientX: number, clientY: number) { export function notifyPaletteDragCancel(clientX: number, clientY: number) {
if (!endListenersArmed) return; if (!activePayload) return;
finishDrag('cancel', { x: clientX, y: clientY }); finishDrag('cancel', { x: clientX, y: clientY });
} }
@@ -209,114 +204,15 @@ export function completePaletteDragAt(clientX: number, clientY: number): boolean
return true; 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) { export function bindPaletteDragOverlayElement(el: HTMLElement | null) {
overlayElement = el; overlayElement = el;
overlayController?.bindElement(el); overlayController?.bindElement(el);
if (el && activePointerId != null) { if (el && activePointerId != null) {
captureOnOverlay(activePointerId); try {
el.setPointerCapture(activePointerId);
} catch {
/* window gesture listener 가 move/end 처리 */
}
} }
} }
@@ -324,36 +220,35 @@ function startPaletteDragGesture(
startX: number, startX: number,
startY: number, startY: number,
pointerId: number, pointerId: number,
touchId: number | null,
payload: PaletteDragPayload, payload: PaletteDragPayload,
): void { ): void {
if (activePayload) { if (activePayload) {
cleanupDrag(); cleanupDrag();
} }
unbindGestureListeners();
clearPendingPointerListeners(); let dragging = false;
let dragStarted = false; unbindGesture = bindWindowDrag(
(xy) => {
const unbind = bindWindowDrag( if (!dragging) {
(xy, ev) => {
if (dragStarted) return;
const dx = xy.x - startX; const dx = xy.x - startX;
const dy = xy.y - startY; const dy = xy.y - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
dragStarted = true; dragging = true;
clearPendingPointerListeners(); mountDragOverlay(payload, xy, pointerId);
if (ev.cancelable) ev.preventDefault(); return;
beginDrag(payload, xy, pointerId, touchId); }
notifyPaletteDragMove(xy.x, xy.y);
}, },
() => { (xy) => {
if (dragStarted) return; unbindGestureListeners();
clearPendingPointerListeners(); if (dragging && activePayload) {
finishDrag('end', xy);
}
}, },
{ preventTouchScroll: true }, { preventTouchScroll: true },
); );
pendingPointerListeners = unbind;
} }
export function handlePalettePointerDown( export function handlePalettePointerDown(
@@ -366,25 +261,36 @@ export function handlePalettePointerDown(
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const sourceEl = e.currentTarget as HTMLElement; const sourceEl = e.currentTarget as HTMLElement;
try { try {
sourceEl.setPointerCapture(e.pointerId); sourceEl.setPointerCapture(e.pointerId);
gestureCaptureEl = sourceEl; gestureCaptureEl = sourceEl;
} catch { } catch {
/* overlay/body capture later */ /* bindWindowDrag 가 move/end 처리 */
} }
startPaletteDragGesture( startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload);
e.clientX,
e.clientY,
e.pointerId,
null,
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( export function handlePaletteTouchStart(
e: React.TouchEvent, e: React.TouchEvent,
payload: PaletteDragPayload, payload: PaletteDragPayload,
@@ -396,15 +302,8 @@ export function handlePaletteTouchStart(
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault(); e.preventDefault();
const touch = e.touches[0]; const touch = e.touches[0];
startPaletteDragGesture( startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload);
touch.clientX,
touch.clientY,
touch.identifier,
touch.identifier,
payload,
);
} }
export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void { export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void {