전략편집기 수정
This commit is contained in:
@@ -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