227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
/**
|
|
* Tauri WebView — HTML5 DnD 대신 pointer capture 기반 팔레트 드래그
|
|
*/
|
|
import { clientXYFromNative } 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;
|
|
type PaletteDropHandler = (event: PaletteDragEvent) => void;
|
|
|
|
let suppressClickUntil = 0;
|
|
const DRAG_THRESHOLD_PX = 6;
|
|
|
|
let activePayload: PaletteDragPayload | null = null;
|
|
let activePointerId: number | null = null;
|
|
let unbindMove: (() => void) | null = null;
|
|
let pendingPointerId: number | null = null;
|
|
let listeners = new Set<PaletteDragListener>();
|
|
let dropHandler: PaletteDropHandler | null = null;
|
|
|
|
export function needsPointerPaletteDrag(): boolean {
|
|
return isDesktop();
|
|
}
|
|
|
|
export function subscribePaletteDrag(listener: PaletteDragListener): () => void {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
}
|
|
|
|
/** Canvas 가 mount 직후 drop 콜백 등록 (subscribe 누락 방지) */
|
|
export function setPaletteDragDropHandler(handler: PaletteDropHandler | null): void {
|
|
dropHandler = handler;
|
|
}
|
|
|
|
export function getActivePaletteDrag(): PaletteDragPayload | null {
|
|
return activePayload;
|
|
}
|
|
|
|
export function shouldSuppressPaletteClick(): boolean {
|
|
return Date.now() < suppressClickUntil;
|
|
}
|
|
|
|
function emit(event: PaletteDragEvent) {
|
|
listeners.forEach(fn => {
|
|
try {
|
|
fn(event);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) {
|
|
const event: PaletteDragEvent = { phase: 'end', x: xy.x, y: xy.y, payload };
|
|
dropHandler?.(event);
|
|
emit(event);
|
|
}
|
|
|
|
function cleanupDrag() {
|
|
unbindMove?.();
|
|
unbindMove = null;
|
|
activePayload = null;
|
|
activePointerId = null;
|
|
pendingPointerId = null;
|
|
document.body.classList.remove('se-palette-drag-active');
|
|
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
|
|
}
|
|
|
|
function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
|
|
if (!activePayload) return;
|
|
const payload = activePayload;
|
|
cleanupDrag();
|
|
if (phase === 'end') {
|
|
suppressClickUntil = Date.now() + 400;
|
|
dispatchEnd(xy, payload);
|
|
} else {
|
|
emit({ phase: 'cancel', x: xy.x, y: xy.y, payload });
|
|
}
|
|
}
|
|
|
|
/** 캔버스 pointerup 백업 — WebView 에서 window 이벤트 누락 시 */
|
|
export function completePaletteDragAt(clientX: number, clientY: number): boolean {
|
|
if (!activePayload) return false;
|
|
finishDrag('end', { x: clientX, y: clientY });
|
|
return true;
|
|
}
|
|
|
|
function beginDrag(
|
|
payload: PaletteDragPayload,
|
|
xy: { x: number; y: number },
|
|
pointerId: number,
|
|
) {
|
|
activePayload = payload;
|
|
activePointerId = pointerId;
|
|
document.body.classList.add('se-palette-drag-active');
|
|
|
|
try {
|
|
document.body.setPointerCapture(pointerId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
emit({ phase: 'start', x: xy.x, y: xy.y, payload });
|
|
|
|
const onMove = (ev: Event) => {
|
|
if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId || !activePayload) return;
|
|
if (ev.cancelable) ev.preventDefault();
|
|
emit({ phase: 'move', x: ev.clientX, y: ev.clientY, payload: activePayload });
|
|
};
|
|
|
|
const onUp = (ev: Event) => {
|
|
if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return;
|
|
const xy = clientXYFromNative(ev) ?? { x: ev.clientX, y: ev.clientY };
|
|
finishDrag('end', xy);
|
|
};
|
|
|
|
window.addEventListener('pointermove', onMove, { passive: false });
|
|
window.addEventListener('pointerup', onUp, true);
|
|
window.addEventListener('pointercancel', onUp, true);
|
|
window.addEventListener('mouseup', onUp, true);
|
|
document.addEventListener('pointerup', onUp, true);
|
|
document.addEventListener('lostpointercapture', onUp, true);
|
|
|
|
unbindMove = () => {
|
|
window.removeEventListener('pointermove', onMove);
|
|
window.removeEventListener('pointerup', onUp, true);
|
|
window.removeEventListener('pointercancel', onUp, true);
|
|
window.removeEventListener('mouseup', onUp, true);
|
|
document.removeEventListener('pointerup', onUp, true);
|
|
document.removeEventListener('lostpointercapture', onUp, true);
|
|
try {
|
|
if (document.body.hasPointerCapture(pointerId)) {
|
|
document.body.releasePointerCapture(pointerId);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
}
|
|
|
|
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, { x: ev.clientX, y: ev.clientY }, pointerId);
|
|
};
|
|
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|