/** * Tauri WebView — HTML5 DnD 대신 pointer capture 기반 팔레트 드래그 */ import { clientXYFromNative } from './pointerDrag'; import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { 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(); 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; }