129 lines
3.7 KiB
TypeScript
129 lines
3.7 KiB
TypeScript
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
|
|
import { bindWindowDrag, clientXYFromReact, isPrimaryPointerButton } from '../utils/pointerDrag';
|
|
|
|
export interface DraggablePosition {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface UseDraggablePanelOptions {
|
|
/** 마운트 후 패널 크기 기준 화면 중앙 배치 */
|
|
centerOnMount?: boolean;
|
|
initialPosition?: DraggablePosition;
|
|
panelRef?: RefObject<HTMLElement | null>;
|
|
}
|
|
|
|
const DEFAULT_POS: DraggablePosition = { x: 80, y: 72 };
|
|
const DEFAULT_CENTER_ESTIMATE = { width: 520, height: 560 };
|
|
|
|
function estimateCenterPosition(
|
|
width = DEFAULT_CENTER_ESTIMATE.width,
|
|
height = DEFAULT_CENTER_ESTIMATE.height,
|
|
): DraggablePosition {
|
|
return {
|
|
x: Math.max(8, (window.innerWidth - width) / 2),
|
|
y: Math.max(8, (window.innerHeight - height) / 2),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 팝업 패널 타이틀바 드래그 이동 (마우스·터치·펜)
|
|
*/
|
|
export function useDraggablePanel(options: UseDraggablePanelOptions = {}) {
|
|
const {
|
|
centerOnMount = false,
|
|
initialPosition = DEFAULT_POS,
|
|
panelRef: externalRef,
|
|
} = options;
|
|
|
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
const panelRef = (externalRef ?? internalRef) as RefObject<HTMLDivElement | null>;
|
|
const [pos, setPos] = useState<DraggablePosition>(() =>
|
|
centerOnMount ? estimateCenterPosition() : initialPosition,
|
|
);
|
|
const [positionReady, setPositionReady] = useState(!centerOnMount);
|
|
const [dragging, setDragging] = useState(false);
|
|
const dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 });
|
|
const centeredOnce = useRef(false);
|
|
const unbindDragRef = useRef<(() => void) | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!centerOnMount || centeredOnce.current) return;
|
|
const el = panelRef.current;
|
|
if (!el) return;
|
|
|
|
const placeCenter = () => {
|
|
const w = el.offsetWidth || DEFAULT_CENTER_ESTIMATE.width;
|
|
const h = el.offsetHeight || DEFAULT_CENTER_ESTIMATE.height;
|
|
setPos({
|
|
x: Math.max(8, (window.innerWidth - w) / 2),
|
|
y: Math.max(8, (window.innerHeight - h) / 2),
|
|
});
|
|
centeredOnce.current = true;
|
|
setPositionReady(true);
|
|
};
|
|
|
|
placeCenter();
|
|
const id = requestAnimationFrame(placeCenter);
|
|
return () => cancelAnimationFrame(id);
|
|
}, [centerOnMount, panelRef]);
|
|
|
|
const endDrag = useCallback(() => {
|
|
unbindDragRef.current?.();
|
|
unbindDragRef.current = null;
|
|
setDragging(false);
|
|
}, []);
|
|
|
|
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
|
|
if (!isPrimaryPointerButton(e)) return;
|
|
if ((e.target as HTMLElement).closest('button')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const el = e.currentTarget as HTMLElement;
|
|
try {
|
|
el.setPointerCapture(e.pointerId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
const { x, y } = clientXYFromReact(e);
|
|
setDragging(true);
|
|
dragOrigin.current = { mx: x, my: y, px: pos.x, py: pos.y };
|
|
|
|
unbindDragRef.current?.();
|
|
unbindDragRef.current = bindWindowDrag(
|
|
({ x: cx, y: cy }) => {
|
|
const o = dragOrigin.current;
|
|
setPos({
|
|
x: o.px + (cx - o.mx),
|
|
y: o.py + (cy - o.my),
|
|
});
|
|
},
|
|
endDrag,
|
|
);
|
|
}, [pos, endDrag]);
|
|
|
|
useEffect(() => () => {
|
|
unbindDragRef.current?.();
|
|
}, []);
|
|
|
|
const panelStyle: React.CSSProperties = {
|
|
position: 'fixed',
|
|
left: pos.x,
|
|
top: pos.y,
|
|
...(centerOnMount && !positionReady ? { visibility: 'hidden' as const } : null),
|
|
};
|
|
|
|
return {
|
|
panelRef: internalRef,
|
|
pos,
|
|
setPos,
|
|
dragging,
|
|
/** @deprecated onHeaderPointerDown 사용 */
|
|
onHeaderMouseDown: onHeaderPointerDown,
|
|
onHeaderPointerDown,
|
|
panelStyle,
|
|
headerCursor: dragging ? 'grabbing' : 'grab',
|
|
headerTouchStyle: { touchAction: 'none' as const },
|
|
};
|
|
}
|