/** * Desktop(Tauri) — document capture pointer 드래그 + overlay * Web — HTML5 DnD * * Desktop: pointerdown 즉시 overlay + document capture move/end * → subscribePaletteDrag(move) → preview + drop (동일 좌표 파이프) * Web: HTML5 drag + dragover 좌표 (브라우저 네이티브 지원) */ import { elementsUnderDragPoint } from './flowScreenProjection'; import { bindDocumentPointerDrag, bindWindowDrag, isPrimaryPointerButton, } 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; } export interface PaletteDragOverlaySession { payload: PaletteDragPayload; pointerId: number; x: number; y: number; /** 드래그 시작 카드 — overlay 에 동일 UI 클론용 */ sourceElement: HTMLElement | null; grabOffsetX: number; grabOffsetY: number; } type PaletteDragListener = (event: PaletteDragEvent) => void; type PaletteDropHandler = (event: PaletteDragEvent) => void; type OverlayController = { mount: (session: PaletteDragOverlaySession) => void; unmount: () => void; bindElement: (el: HTMLElement | null) => void; }; /** 클릭과 드래그 구분 — 이보다 작으면 cancel (클릭 허용) */ const CLICK_VS_DRAG_PX = 5; let suppressClickUntil = 0; let activePayload: PaletteDragPayload | null = null; let activePointerId: number | null = null; let overlayController: OverlayController | null = null; let overlayElement: HTMLElement | null = null; let listeners = new Set(); let dropHandler: PaletteDropHandler | null = null; let moveRafId: number | null = null; let pendingMove: { x: number; y: number } | null = null; let unbindGesture: (() => void) | null = null; let lastDragClientXY: { x: number; y: number } | null = null; let htmlDragMouseTrackUnbind: (() => void) | null = null; let htmlPaletteDragActive = false; let lastHtmlDragClientXY: { x: number; y: number } | null = null; let activeHtmlDragPayload: PaletteDragPayload | null = null; type HtmlPaletteDropRouter = ( clientX: number, clientY: number, payload: PaletteDragPayload, ) => void; let htmlDropRouter: HtmlPaletteDropRouter | null = null; let htmlDropCommitted = false; export function setHtmlPaletteDropRouter(router: HtmlPaletteDropRouter | null): void { htmlDropRouter = router; } function hasCoarsePointer(): boolean { if (typeof window === 'undefined') return false; try { return window.matchMedia('(pointer: coarse)').matches; } catch { return false; } } /** Desktop(Tauri) + coarse touch — pointer overlay. Web browser — HTML5 */ export function needsPointerPaletteDrag(): boolean { return isDesktop() || hasCoarsePointer(); } export function isPaletteDragSessionActive(): boolean { return activePayload != null || htmlPaletteDragActive; } export function subscribePaletteDrag(listener: PaletteDragListener): () => void { listeners.add(listener); return () => listeners.delete(listener); } export function setPaletteDragDropHandler(handler: PaletteDropHandler | null): void { dropHandler = handler; } export function registerPaletteDragOverlay(controller: OverlayController | null): void { overlayController = controller; } 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) { dropHandler?.({ phase: 'end', x: xy.x, y: xy.y, payload }); emit({ phase: 'end', x: xy.x, y: xy.y, payload }); } function unbindGestureListeners() { unbindGesture?.(); unbindGesture = null; } function armPaletteDragScrollLock() { document.body.classList.add('se-palette-drag-armed'); } function releasePaletteDragScrollLock() { document.body.classList.remove('se-palette-drag-armed'); } function cleanupDrag() { unbindGestureListeners(); activePayload = null; activePointerId = null; pendingMove = null; lastDragClientXY = null; if (moveRafId != null) { cancelAnimationFrame(moveRafId); moveRafId = null; } document.body.classList.remove('se-palette-drag-active'); releasePaletteDragScrollLock(); overlayController?.unmount(); overlayElement = null; } function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { if (!activePayload) { unbindGestureListeners(); releasePaletteDragScrollLock(); return; } const payload = activePayload; const dropXY = lastDragClientXY ?? xy; if (phase === 'end') { suppressClickUntil = Date.now() + 400; dispatchEnd(dropXY, payload); } else { emit({ phase: 'cancel', x: dropXY.x, y: dropXY.y, payload }); } cleanupDrag(); } function mountDragOverlay( payload: PaletteDragPayload, xy: { x: number; y: number }, pointerId: number, sourceElement: HTMLElement | null, grabOffset: { x: number; y: number }, ) { activePayload = payload; activePointerId = pointerId; lastDragClientXY = { x: xy.x, y: xy.y }; document.body.classList.add('se-palette-drag-active'); armPaletteDragScrollLock(); overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y, sourceElement, grabOffsetX: grabOffset.x, grabOffsetY: grabOffset.y, }); emit({ phase: 'start', x: xy.x, y: xy.y, payload }); notifyPaletteDragMove(xy.x, xy.y); } function grabOffsetFromSource( sourceElement: HTMLElement | null, clientX: number, clientY: number, ): { x: number; y: number } { if (!sourceElement) return { x: 0, y: 0 }; const rect = sourceElement.getBoundingClientRect(); return { x: clientX - rect.left, y: clientY - rect.top }; } export function notifyPaletteDragMove(clientX: number, clientY: number) { if (!activePayload) return; lastDragClientXY = { x: clientX, y: clientY }; pendingMove = { x: clientX, y: clientY }; if (moveRafId != null) return; moveRafId = requestAnimationFrame(() => { moveRafId = null; if (!pendingMove || !activePayload) return; const { x, y } = pendingMove; pendingMove = null; emit({ phase: 'move', x, y, payload: activePayload }); }); } export function notifyPaletteDragEnd(clientX: number, clientY: number) { if (!activePayload) return; finishDrag('end', { x: clientX, y: clientY }); } export function notifyPaletteDragCancel(clientX: number, clientY: number) { if (!activePayload) return; finishDrag('cancel', { x: clientX, y: clientY }); } export function bindPaletteDragOverlayElement(el: HTMLElement | null) { overlayElement = el; overlayController?.bindElement(el); } /** * Desktop pointer 드래그 — pointerdown 즉시 overlay, document capture 로 move/end * (스크롤 패널·setPointerCapture 충돌 없음 — drag + preview + drop 동일 좌표) */ function startPaletteDragGesture( startX: number, startY: number, pointerId: number, payload: PaletteDragPayload, sourceElement: HTMLElement | null, ): void { if (activePayload) cleanupDrag(); unbindGestureListeners(); const origin = { x: startX, y: startY }; const grabOffset = grabOffsetFromSource(sourceElement, startX, startY); mountDragOverlay(payload, origin, pointerId, sourceElement, grabOffset); unbindGesture = bindDocumentPointerDrag( pointerId, (xy) => notifyPaletteDragMove(xy.x, xy.y), (xy) => { unbindGestureListeners(); const dist = Math.hypot(xy.x - origin.x, xy.y - origin.y); if (dist < CLICK_VS_DRAG_PX) { finishDrag('cancel', xy); } else { finishDrag('end', xy); } }, ); } export function handlePalettePointerDown( e: React.PointerEvent, payload: PaletteDragPayload, sourceElement: HTMLElement, ): void { if (!needsPointerPaletteDrag()) return; if (!isPrimaryPointerButton(e)) return; const target = e.target as HTMLElement; if (target.closest('button')) return; const input = target.closest('input, textarea, select') as HTMLInputElement | null; if (input && !input.readOnly && !input.disabled) return; e.preventDefault(); startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload, sourceElement); } export function handlePaletteMouseDown( e: React.MouseEvent, payload: PaletteDragPayload, sourceElement: HTMLElement, ): void { if (!needsPointerPaletteDrag()) return; if (e.button !== 0) return; const target = e.target as HTMLElement; if (target.closest('button')) return; const input = target.closest('input, textarea, select') as HTMLInputElement | null; if (input && !input.readOnly && !input.disabled) return; if (typeof window !== 'undefined' && 'PointerEvent' in window) return; e.preventDefault(); startPaletteDragGesture(e.clientX, e.clientY, 1, payload, sourceElement); } export function handlePaletteTouchStart( e: React.TouchEvent, payload: PaletteDragPayload, sourceElement: HTMLElement, ): void { if (!needsPointerPaletteDrag()) return; if (typeof window !== 'undefined' && 'PointerEvent' in window) return; if (e.touches.length !== 1) return; if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; e.preventDefault(); const touch = e.touches[0]; startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload, sourceElement); } /* ── Web HTML5 ── */ function installHtmlPaletteDragGlobals() { if (typeof window === 'undefined') return; const w = window as Window & { __gcPaletteHtmlDragGlobals?: boolean }; if (w.__gcPaletteHtmlDragGlobals) return; w.__gcPaletteHtmlDragGlobals = true; window.addEventListener('dragover', (e) => { if (!htmlPaletteDragActive) return; if (e.clientX !== 0 || e.clientY !== 0) { lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; if (activeHtmlDragPayload) { emit({ phase: 'move', x: e.clientX, y: e.clientY, payload: activeHtmlDragPayload, }); } } e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }, { passive: false }); window.addEventListener('drag', (e) => { if (!htmlPaletteDragActive || !activeHtmlDragPayload) return; if (e.clientX !== 0 || e.clientY !== 0) { lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; emit({ phase: 'move', x: e.clientX, y: e.clientY, payload: activeHtmlDragPayload, }); } }); window.addEventListener('dragend', () => { const payload = activeHtmlDragPayload; const xy = lastHtmlDragClientXY; if (htmlPaletteDragActive && !htmlDropCommitted && payload && xy && htmlDropRouter) { htmlDropRouter(xy.x, xy.y, payload); } htmlDropCommitted = false; stopHtmlDragMouseTracking(); htmlPaletteDragActive = false; lastHtmlDragClientXY = null; activeHtmlDragPayload = null; }); document.addEventListener('drop', (e) => { if (!htmlPaletteDragActive) return; e.preventDefault(); e.stopPropagation(); const payload = readPaletteHtmlDragDataFromEvent(e) ?? activeHtmlDragPayload; if (!payload || !htmlDropRouter || htmlDropCommitted) return; htmlDropCommitted = true; const x = e.clientX !== 0 || e.clientY !== 0 ? e.clientX : (lastHtmlDragClientXY?.x ?? 0); const y = e.clientX !== 0 || e.clientY !== 0 ? e.clientY : (lastHtmlDragClientXY?.y ?? 0); htmlDropRouter(x, y, payload); }, true); } installHtmlPaletteDragGlobals(); function stopHtmlDragMouseTracking(): void { htmlDragMouseTrackUnbind?.(); htmlDragMouseTrackUnbind = null; } export function startHtmlDragMouseTracking( clientX: number, clientY: number, payload: PaletteDragPayload, ): void { activeHtmlDragPayload = payload; stopHtmlDragMouseTracking(); if (clientX !== 0 || clientY !== 0) { lastHtmlDragClientXY = { x: clientX, y: clientY }; } htmlDragMouseTrackUnbind = bindWindowDrag( (xy) => { lastHtmlDragClientXY = xy; emit({ phase: 'move', x: xy.x, y: xy.y, payload }); }, () => { /* dragend 에서 정리 */ }, { preventTouchScroll: false }, ); } export function endHtmlDragMouseTracking(): void { stopHtmlDragMouseTracking(); markPaletteHtmlDragEnd(); } export function markPaletteHtmlDragStart(): void { htmlPaletteDragActive = true; htmlDropCommitted = false; lastHtmlDragClientXY = null; } export function markPaletteHtmlDragEnd(): void { stopHtmlDragMouseTracking(); htmlPaletteDragActive = false; lastHtmlDragClientXY = null; activeHtmlDragPayload = null; } function readPaletteHtmlDragDataFromEvent(e: DragEvent): PaletteDragPayload | null { const raw = e.dataTransfer?.getData('text/plain') || e.dataTransfer?.getData('application/json'); if (!raw) return null; try { return JSON.parse(raw) as PaletteDragPayload; } catch { return null; } } export function getLastHtmlPaletteDragClientXY(): { x: number; y: number } | null { return lastHtmlDragClientXY; } export function isPaletteHtmlDragActive(): boolean { return htmlPaletteDragActive; } export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void { activeHtmlDragPayload = payload; markPaletteHtmlDragStart(); 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 { if (htmlPaletteDragActive) return true; const types = e.dataTransfer?.types; if (!types) return false; return Array.from(types).includes('application/json') || Array.from(types).includes('text/plain'); } export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload | null { return readPaletteHtmlDragDataFromEvent(e.nativeEvent) ?? activeHtmlDragPayload; } export function findPaletteDropKeyAtPoint(x: number, y: number): string | null { const hosts = Array.from(document.querySelectorAll('[data-palette-drop-key]')); for (let i = hosts.length - 1; i >= 0; i--) { const host = hosts[i]; const key = host.dataset.paletteDropKey; if (!key) continue; const rect = host.getBoundingClientRect(); if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { return key; } } for (const el of elementsUnderDragPoint(x, y)) { const host = el.closest('[data-palette-drop-key]') as HTMLElement | null; if (host?.dataset.paletteDropKey) return host.dataset.paletteDropKey; } return null; }