앱 전략편집기 수정
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user