전략편집기 수정

This commit is contained in:
Macbook
2026-06-14 22:17:53 +09:00
parent dec812089d
commit 76ffa5ac52
6 changed files with 343 additions and 52 deletions
@@ -35,6 +35,10 @@ import {
import ConditionNodeSettings from './ConditionNodeSettings'; import ConditionNodeSettings from './ConditionNodeSettings';
import StochPairNodeSettings from './StochPairNodeSettings'; import StochPairNodeSettings from './StochPairNodeSettings';
import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings'; import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings';
import {
isPaletteHtmlDrag,
readPaletteHtmlDragData,
} from '../../utils/paletteDragSession';
const LOGIC_COLORS: Record<string, string> = { const LOGIC_COLORS: Record<string, string> = {
AND: '#00aaff', AND: '#00aaff',
@@ -126,7 +130,7 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const onDragOver = useCallback((e: React.DragEvent) => { const onDragOver = useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('application/json')) return; if (!isPaletteHtmlDrag(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
@@ -144,11 +148,10 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) {
const onDrop = useCallback((e: React.DragEvent) => { const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
try { const payload = readPaletteHtmlDragData(e);
const payload = JSON.parse(e.dataTransfer.getData('application/json')); if (!payload) return;
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
d.onDropTarget?.(id, payload, flowPos); d.onDropTarget?.(id, payload, flowPos);
} catch { /* ignore */ }
}, [d, id, screenToFlowPosition]); }, [d, id, screenToFlowPosition]);
return { onDragOver, onDragLeave, onDrop }; return { onDragOver, onDragLeave, onDrop };
@@ -1,19 +1,13 @@
import React from 'react'; import React from 'react';
import {
handlePalettePointerDown,
needsPointerPaletteDrag,
shouldSuppressPaletteClick,
writePaletteHtmlDragData,
type PaletteDragPayload,
} from '../../utils/paletteDragSession';
export interface PaletteDragPayload { export type { PaletteDragPayload };
type: 'operator' | 'indicator' | 'start';
value: string;
label: string;
composite?: boolean;
stochPair?: boolean;
priceExtremeBreakout?: boolean;
inflection33?: boolean;
stableStrategy?: boolean;
secondaryIndicator?: string;
period?: number;
shortPeriod?: number;
longPeriod?: number;
}
interface Props { interface Props {
type: 'operator' | 'indicator' | 'start'; type: 'operator' | 'indicator' | 'start';
@@ -40,8 +34,7 @@ export default function PaletteChip({
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod, type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, stableStrategy = false, secondaryIndicator, selected = false, onSelect, onAdd, composite = false, stochPair = false, priceExtremeBreakout = false, inflection33 = false, stableStrategy = false, secondaryIndicator, selected = false, onSelect, onAdd,
}: Props) { }: Props) {
const onDragStart = (e: React.DragEvent) => { const buildPayload = (): PaletteDragPayload => ({
const payload: PaletteDragPayload = {
type, value, label, type, value, label,
composite: composite || undefined, composite: composite || undefined,
stochPair: stochPair || undefined, stochPair: stochPair || undefined,
@@ -52,12 +45,18 @@ export default function PaletteChip({
period: periodValue, period: periodValue,
shortPeriod, shortPeriod,
longPeriod, longPeriod,
});
const onDragStart = (e: React.DragEvent) => {
writePaletteHtmlDragData(e, buildPayload());
}; };
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.effectAllowed = 'copy'; const onPointerDown = (e: React.PointerEvent) => {
handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
}; };
const handleClick = () => { const handleClick = () => {
if (shouldSuppressPaletteClick()) return;
if (onSelect) onSelect(); if (onSelect) onSelect();
else onAdd(); else onAdd();
}; };
@@ -80,9 +79,10 @@ export default function PaletteChip({
return ( return (
<div <div
draggable draggable={!needsPointerPaletteDrag()}
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}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
role="button" role="button"
@@ -1,6 +1,12 @@
import React, { useMemo, useState, useCallback } from 'react'; import React, { useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { DefType } from '../../utils/strategyEditorShared'; import type { DefType } from '../../utils/strategyEditorShared';
import {
handlePalettePointerDown,
needsPointerPaletteDrag,
writePaletteHtmlDragData,
type PaletteDragPayload,
} from '../../utils/paletteDragSession';
import { import {
filterSidewaysItems, filterSidewaysItems,
type SidewaysFilterMode, type SidewaysFilterMode,
@@ -75,8 +81,17 @@ function SidewaysFilterChip({
value: item.id, value: item.id,
label: item.label, label: item.label,
}; };
e.dataTransfer.setData('application/json', JSON.stringify(payload)); writePaletteHtmlDragData(e, payload as PaletteDragPayload);
e.dataTransfer.effectAllowed = 'copy'; };
const onPointerDown = (e: React.PointerEvent) => {
hideHint();
const payload: PaletteDragPayload = {
type: 'sidewaysFilter',
value: item.id,
label: item.label,
};
handlePalettePointerDown(e, payload, item.label);
}; };
const handleClick = () => onSelect(); const handleClick = () => onSelect();
@@ -94,9 +109,10 @@ function SidewaysFilterChip({
return ( return (
<div <div
draggable draggable={!needsPointerPaletteDrag()}
className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}`} className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart} onDragStart={onDragStart}
onPointerDown={onPointerDown}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseEnter={e => showHint(e.currentTarget)} onMouseEnter={e => showHint(e.currentTarget)}
@@ -29,6 +29,13 @@ import {
type DefType, type DefType,
} from '../../utils/strategyEditorShared'; } from '../../utils/strategyEditorShared';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage'; import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import {
isPaletteHtmlDrag,
needsPointerPaletteDrag,
readPaletteHtmlDragData,
subscribePaletteDrag,
type PaletteDragEvent,
} from '../../utils/paletteDragSession';
import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
import { import {
setPriceExtremePairFilterLevel, setPriceExtremePairFilterLevel,
@@ -1190,8 +1197,6 @@ function StrategyEditorCanvasInner({
)); ));
}, [root, orphans, extraRoots, applyResolvedConnection, setEdges]); }, [root, orphans, extraRoots, applyResolvedConnection, setEdges]);
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => { const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => {
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
@@ -1200,12 +1205,14 @@ function StrategyEditorCanvasInner({
return { mode: 'orphan' as const }; return { mode: 'orphan' as const };
}, [root, orphans]); }, [root, orphans]);
const onPaneDrop = useCallback((e: React.DragEvent) => { const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e);
e.preventDefault();
clearDropPreview(); const applyPaletteDropAt = useCallback((
try { data: { type: string; value: string; label: string; composite?: boolean },
const data = JSON.parse(e.dataTransfer.getData('application/json')); clientX: number,
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); clientY: number,
) => {
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
if (data.type === 'start') { if (data.type === 'start') {
addStartAt(flowPos); addStartAt(flowPos);
return; return;
@@ -1216,8 +1223,45 @@ function StrategyEditorCanvasInner({
} else { } else {
addOrphanAt(data, flowPos); addOrphanAt(data, flowPos);
} }
} catch { /* ignore */ } }, [screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
clearDropPreview();
const data = readPaletteHtmlDragData(e);
if (!data) return;
applyPaletteDropAt(data, e.clientX, e.clientY);
}, [clearDropPreview, applyPaletteDropAt]);
useEffect(() => {
if (!needsPointerPaletteDrag()) return;
return subscribePaletteDrag((ev: PaletteDragEvent) => {
if (ev.phase === 'move') {
const flowPos = screenToFlowPosition({ x: ev.x, y: ev.y });
const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') {
updateDropPreview(drop.anchorId, flowPos);
} else {
clearDropPreview();
}
return;
}
if (ev.phase === 'end') {
clearDropPreview();
applyPaletteDropAt(ev.payload, ev.x, ev.y);
return;
}
if (ev.phase === 'cancel') {
clearDropPreview();
}
});
}, [
screenToFlowPosition,
resolvePaletteDrop,
updateDropPreview,
clearDropPreview,
applyPaletteDropAt,
]);
const onPaneDragOver = useCallback((e: React.DragEvent) => { const onPaneDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
+29
View File
@@ -2021,6 +2021,8 @@
border-radius: 8px; border-radius: 8px;
padding: 8px; padding: 8px;
cursor: grab; cursor: grab;
touch-action: none;
-webkit-user-drag: element;
border: 1px solid var(--se-border); border: 1px solid var(--se-border);
background: var(--se-palette-card-bg); background: var(--se-palette-card-bg);
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s, background 0.12s; transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s, background 0.12s;
@@ -2028,6 +2030,33 @@
.se-palette-card:hover { .se-palette-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
.se-palette-card:active {
cursor: grabbing;
}
body.se-palette-drag-active {
cursor: grabbing !important;
user-select: none;
}
.se-palette-drag-ghost {
position: fixed;
z-index: 20050;
pointer-events: none;
transform: translate(-50%, -50%);
padding: 6px 12px;
border-radius: 8px;
font-size: 0.72rem;
font-weight: 700;
color: var(--se-text, #e2e8f0);
background: color-mix(in srgb, var(--se-accent, #4dabf7) 22%, var(--se-panel-card-bg, #1e293b));
border: 1px solid color-mix(in srgb, var(--se-accent, #4dabf7) 55%, transparent);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
white-space: nowrap;
max-width: min(240px, 90vw);
overflow: hidden;
text-overflow: ellipsis;
}
.se-palette-card--selected { .se-palette-card--selected {
outline: 2px solid var(--se-accent); outline: 2px solid var(--se-accent);
+199
View File
@@ -0,0 +1,199 @@
/**
* Tauri WebView 등 HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션
*/
import { bindWindowDrag, type ClientXY } from './pointerDrag';
import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & {
type: string;
value: string;
label: string;
};
export type PaletteDragPhase = 'start' | 'move' | 'end' | 'cancel';
export interface PaletteDragEvent {
phase: PaletteDragPhase;
x: number;
y: number;
payload: PaletteDragPayload;
}
type PaletteDragListener = (event: PaletteDragEvent) => void;
let suppressClickUntil = 0;
/** 드래그 직후 발생하는 click 이벤트 무시 */
export function shouldSuppressPaletteClick(): boolean {
return Date.now() < suppressClickUntil;
}
const DRAG_THRESHOLD_PX = 6;
const GHOST_CLASS = 'se-palette-drag-ghost';
let activePayload: PaletteDragPayload | null = null;
let ghostEl: HTMLDivElement | null = null;
let unbindMove: (() => void) | null = null;
let pendingPointerId: number | null = null;
let listeners = new Set<PaletteDragListener>();
/** Desktop(Tauri) WebKit — native HTML5 DnD 불안정 */
export function needsPointerPaletteDrag(): boolean {
return isDesktop();
}
export function subscribePaletteDrag(listener: PaletteDragListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getActivePaletteDrag(): PaletteDragPayload | null {
return activePayload;
}
function emit(event: PaletteDragEvent) {
listeners.forEach(fn => {
try {
fn(event);
} catch {
/* ignore */
}
});
}
function removeGhost() {
ghostEl?.remove();
ghostEl = null;
}
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) {
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() {
unbindMove?.();
unbindMove = null;
activePayload = null;
pendingPointerId = null;
removeGhost();
document.body.classList.remove('se-palette-drag-active');
}
function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) {
activePayload = payload;
document.body.classList.add('se-palette-drag-active');
ensureGhost(label, xy.x, xy.y);
emit({ phase: 'start', x: xy.x, y: xy.y, payload });
let lastXY = xy;
unbindMove = bindWindowDrag(
({ x, y }) => {
lastXY = { x, y };
moveGhost(x, y);
if (activePayload) {
emit({ phase: 'move', x, y, payload: activePayload });
}
},
() => finishDrag('end', lastXY),
{ preventTouchScroll: true },
);
}
/**
* 팔레트 카드 pointerdown — 임계 이동 후 드래그 시작 (클릭과 구분)
*/
export function handlePalettePointerDown(
e: React.PointerEvent,
payload: PaletteDragPayload,
label: string,
): void {
if (!needsPointerPaletteDrag()) return;
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
const startX = e.clientX;
const startY = e.clientY;
const pointerId = e.pointerId;
pendingPointerId = pointerId;
const onPointerMove = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
ev.preventDefault();
beginDrag(payload, label, { x: ev.clientX, y: ev.clientY });
};
const onPointerUp = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
pendingPointerId = null;
};
const onPointerCancel = onPointerUp;
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
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 {
const json = JSON.stringify(payload);
e.dataTransfer.setData('application/json', json);
e.dataTransfer.setData('text/plain', json);
e.dataTransfer.effectAllowed = 'copy';
}
export function isPaletteHtmlDrag(e: React.DragEvent): boolean {
return e.dataTransfer.types.includes('application/json')
|| e.dataTransfer.types.includes('text/plain');
}
export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload | null {
const raw = e.dataTransfer.getData('application/json')
|| e.dataTransfer.getData('text/plain');
if (!raw) return null;
try {
return JSON.parse(raw) as PaletteDragPayload;
} catch {
return null;
}
}