Files
goldenChart/frontend/src/utils/paletteDragSession.ts
T

498 lines
15 KiB
TypeScript

/**
* 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<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;
}
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<PaletteDragListener>();
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<HTMLElement>('[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;
}