전략편집기 수정
This commit is contained in:
@@ -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,24 +34,29 @@ 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 buildPayload = (): PaletteDragPayload => ({
|
||||||
|
type, value, label,
|
||||||
|
composite: composite || undefined,
|
||||||
|
stochPair: stochPair || undefined,
|
||||||
|
priceExtremeBreakout: priceExtremeBreakout || undefined,
|
||||||
|
inflection33: inflection33 || undefined,
|
||||||
|
stableStrategy: stableStrategy || undefined,
|
||||||
|
secondaryIndicator,
|
||||||
|
period: periodValue,
|
||||||
|
shortPeriod,
|
||||||
|
longPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
const onDragStart = (e: React.DragEvent) => {
|
const onDragStart = (e: React.DragEvent) => {
|
||||||
const payload: PaletteDragPayload = {
|
writePaletteHtmlDragData(e, buildPayload());
|
||||||
type, value, label,
|
};
|
||||||
composite: composite || undefined,
|
|
||||||
stochPair: stochPair || undefined,
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
priceExtremeBreakout: priceExtremeBreakout || undefined,
|
handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
|
||||||
inflection33: inflection33 || undefined,
|
|
||||||
stableStrategy: stableStrategy || undefined,
|
|
||||||
secondaryIndicator,
|
|
||||||
period: periodValue,
|
|
||||||
shortPeriod,
|
|
||||||
longPeriod,
|
|
||||||
};
|
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,24 +1205,63 @@ function StrategyEditorCanvasInner({
|
|||||||
return { mode: 'orphan' as const };
|
return { mode: 'orphan' as const };
|
||||||
}, [root, orphans]);
|
}, [root, orphans]);
|
||||||
|
|
||||||
|
const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e);
|
||||||
|
|
||||||
|
const applyPaletteDropAt = useCallback((
|
||||||
|
data: { type: string; value: string; label: string; composite?: boolean },
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
) => {
|
||||||
|
const flowPos = screenToFlowPosition({ x: clientX, y: clientY });
|
||||||
|
if (data.type === 'start') {
|
||||||
|
addStartAt(flowPos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const drop = resolvePaletteDrop(flowPos);
|
||||||
|
if (drop.mode === 'connect') {
|
||||||
|
applyDropWithAttach(drop.anchorId, data, flowPos);
|
||||||
|
} else {
|
||||||
|
addOrphanAt(data, flowPos);
|
||||||
|
}
|
||||||
|
}, [screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
|
||||||
|
|
||||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearDropPreview();
|
clearDropPreview();
|
||||||
try {
|
const data = readPaletteHtmlDragData(e);
|
||||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
if (!data) return;
|
||||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
applyPaletteDropAt(data, e.clientX, e.clientY);
|
||||||
if (data.type === 'start') {
|
}, [clearDropPreview, applyPaletteDropAt]);
|
||||||
addStartAt(flowPos);
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const drop = resolvePaletteDrop(flowPos);
|
if (ev.phase === 'end') {
|
||||||
if (drop.mode === 'connect') {
|
clearDropPreview();
|
||||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
applyPaletteDropAt(ev.payload, ev.x, ev.y);
|
||||||
} else {
|
return;
|
||||||
addOrphanAt(data, flowPos);
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
if (ev.phase === 'cancel') {
|
||||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]);
|
clearDropPreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
screenToFlowPosition,
|
||||||
|
resolvePaletteDrop,
|
||||||
|
updateDropPreview,
|
||||||
|
clearDropPreview,
|
||||||
|
applyPaletteDropAt,
|
||||||
|
]);
|
||||||
|
|
||||||
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
const onPaneDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user