498 lines
15 KiB
TypeScript
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;
|
|
}
|