Files
backspace/packages/web/src/hooks/useDragManager.ts
T
Jannis Braun eb6f4bf94e fix: stabilize useDragManager callback refs and containerHandlers memo
Remove activeDrag from drag-start dependency arrays to prevent
unnecessary re-renders during drag. Wrap containerHandlers in
useMemo for consistent reference stability.
2026-03-20 20:39:52 +01:00

230 lines
8.7 KiB
TypeScript

import { useState, useCallback, useEffect, useMemo, type RefObject } from 'react';
export type DragType = 'channel' | 'category' | 'voiceUser';
export interface DragState {
type: DragType;
dragId: string;
sourceId?: string; // only for voiceUser — originating channelId
}
export interface DropTarget {
targetId: string;
position: 'before' | 'after';
targetType: 'channel' | 'category';
}
interface UseDragManagerOpts {
scrollContainerRef: RefObject<HTMLElement | null>;
canManage: boolean;
canMoveMembers: boolean;
onChannelDrop: (dragId: string, target: DropTarget) => void;
onCategoryDrop: (dragId: string, target: DropTarget) => void;
onVoiceUserDrop: (userId: string, fromChannelId: string, toChannelId: string) => void;
}
export function useDragManager(opts: UseDragManagerOpts) {
const { scrollContainerRef, canManage, canMoveMembers, onChannelDrop, onCategoryDrop, onVoiceUserDrop } = opts;
const [activeDrag, setActiveDrag] = useState<DragState | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [voiceHoverChannelId, setVoiceHoverChannelId] = useState<string | null>(null);
const clearState = useCallback(() => {
setActiveDrag(null);
setDropTarget(null);
setVoiceHoverChannelId(null);
}, []);
// --- Channel/Category drag handlers ---
const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => {
if (!canManage) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', channelId); // Firefox requires setData for drag to work
setActiveDrag({ type: 'channel', dragId: channelId });
}, [canManage]);
const handleCategoryDragStart = useCallback((e: React.DragEvent, categoryId: string) => {
if (!canManage) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', categoryId); // Firefox requires setData for drag to work
setActiveDrag({ type: 'category', dragId: categoryId });
}, [canManage]);
const handleLayoutDragOver = useCallback((e: React.DragEvent, targetId: string, targetType: 'channel' | 'category') => {
if (!activeDrag || activeDrag.type === 'voiceUser') return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after';
setDropTarget({ targetId, position, targetType });
}, [activeDrag]);
const handleDragEnd = useCallback(() => {
clearState();
}, [clearState]);
const channelHandlers = useCallback((channelId: string) => ({
draggable: canManage,
onDragStart: (e: React.DragEvent) => handleChannelDragStart(e, channelId),
onDragOver: (e: React.DragEvent) => handleLayoutDragOver(e, channelId, 'channel'),
onDragEnd: handleDragEnd,
}), [canManage, handleChannelDragStart, handleLayoutDragOver, handleDragEnd]);
const categoryHandlers = useCallback((categoryId: string) => ({
draggable: canManage,
onDragStart: (e: React.DragEvent) => handleCategoryDragStart(e, categoryId),
onDragOver: (e: React.DragEvent) => handleLayoutDragOver(e, categoryId, 'category'),
onDragEnd: handleDragEnd,
}), [canManage, handleCategoryDragStart, handleLayoutDragOver, handleDragEnd]);
// --- Container handlers (scrollable sidebar div) ---
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
if (activeDrag && (activeDrag.type === 'channel' || activeDrag.type === 'category')) {
e.preventDefault();
}
}, [activeDrag]);
const handleContainerDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!activeDrag || !dropTarget) {
clearState();
return;
}
// Self-drop no-op (fixes bug 5)
if (activeDrag.dragId === dropTarget.targetId) {
clearState();
return;
}
if (activeDrag.type === 'channel') {
onChannelDrop(activeDrag.dragId, dropTarget);
} else if (activeDrag.type === 'category') {
onCategoryDrop(activeDrag.dragId, dropTarget);
}
clearState();
}, [activeDrag, dropTarget, onChannelDrop, onCategoryDrop, clearState]);
const containerHandlers = useMemo(() => ({
onDrop: handleContainerDrop,
onDragOver: handleContainerDragOver,
}), [handleContainerDrop, handleContainerDragOver]);
// --- Voice user drag handlers ---
const handleVoiceUserDragStart = useCallback((e: React.DragEvent, userId: string, fromChannelId: string) => {
e.stopPropagation(); // Prevents bubbling to ChannelItem (fixes bug 1)
if (!canMoveMembers) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', userId); // Firefox requires setData for drag to work
setActiveDrag({ type: 'voiceUser', dragId: userId, sourceId: fromChannelId });
}, [canMoveMembers]);
const handleVoiceUserDragEnd = useCallback((e: React.DragEvent) => {
e.stopPropagation(); // Prevents bubbling to ChannelItem (fixes bug 6)
clearState();
}, [clearState]);
const voiceUserHandlers = useCallback((userId: string, channelId: string) => ({
draggable: canMoveMembers,
isBeingDragged: activeDrag?.type === 'voiceUser' && activeDrag.dragId === userId && activeDrag.sourceId === channelId,
onDragStart: (e: React.DragEvent) => handleVoiceUserDragStart(e, userId, channelId),
onDragEnd: handleVoiceUserDragEnd,
}), [canMoveMembers, activeDrag, handleVoiceUserDragStart, handleVoiceUserDragEnd]);
const handleVoiceDropZoneDragOver = useCallback((e: React.DragEvent, channelId: string) => {
if (activeDrag?.type !== 'voiceUser') return;
if (activeDrag.sourceId === channelId) return; // same channel — no preventDefault (fixes bug 2)
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setVoiceHoverChannelId(channelId);
}, [activeDrag]);
const handleVoiceDropZoneDragEnter = useCallback((e: React.DragEvent, channelId: string) => {
if (activeDrag?.type !== 'voiceUser') return;
if (activeDrag.sourceId === channelId) return;
e.preventDefault();
setVoiceHoverChannelId(channelId);
}, [activeDrag]);
const handleVoiceDropZoneDragLeave = useCallback((e: React.DragEvent) => {
if (activeDrag?.type !== 'voiceUser') return;
// Only clear when actually leaving the container, not entering a child element
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setVoiceHoverChannelId(null);
}, [activeDrag]);
const handleVoiceDropZoneDrop = useCallback((e: React.DragEvent, channelId: string) => {
e.preventDefault();
e.stopPropagation(); // Prevent bubbling to container's onDrop
if (activeDrag?.type === 'voiceUser' && activeDrag.sourceId && activeDrag.sourceId !== channelId) {
onVoiceUserDrop(activeDrag.dragId, activeDrag.sourceId, channelId);
}
clearState();
}, [activeDrag, onVoiceUserDrop, clearState]);
const voiceChannelDropZone = useCallback((channelId: string) => ({
onDragOver: (e: React.DragEvent) => handleVoiceDropZoneDragOver(e, channelId),
onDragEnter: (e: React.DragEvent) => handleVoiceDropZoneDragEnter(e, channelId),
onDragLeave: handleVoiceDropZoneDragLeave,
onDrop: (e: React.DragEvent) => handleVoiceDropZoneDrop(e, channelId),
isDragOver: voiceHoverChannelId === channelId,
isValidTarget: activeDrag?.type === 'voiceUser' && activeDrag.sourceId !== channelId,
}), [
handleVoiceDropZoneDragOver, handleVoiceDropZoneDragEnter,
handleVoiceDropZoneDragLeave, handleVoiceDropZoneDrop,
voiceHoverChannelId, activeDrag,
]);
// --- Auto-scroll during drag (fixes bug 7) ---
useEffect(() => {
if (!activeDrag || !scrollContainerRef.current) return;
const container = scrollContainerRef.current;
const EDGE_PX = 40;
let scrollSpeed = 0;
let rafId = 0;
const onDragOver = (e: DragEvent) => {
const rect = container.getBoundingClientRect();
const distFromTop = e.clientY - rect.top;
const distFromBottom = rect.bottom - e.clientY;
if (distFromTop < EDGE_PX) {
scrollSpeed = -(1 + 7 * (1 - distFromTop / EDGE_PX));
} else if (distFromBottom < EDGE_PX) {
scrollSpeed = 1 + 7 * (1 - distFromBottom / EDGE_PX);
} else {
scrollSpeed = 0;
}
};
const tick = () => {
if (scrollSpeed !== 0) {
container.scrollTop += scrollSpeed;
}
rafId = requestAnimationFrame(tick);
};
container.addEventListener('dragover', onDragOver);
rafId = requestAnimationFrame(tick);
return () => {
container.removeEventListener('dragover', onDragOver);
cancelAnimationFrame(rafId);
};
}, [activeDrag, scrollContainerRef]);
return {
activeDrag,
dropTarget,
channelHandlers,
categoryHandlers,
containerHandlers,
voiceUserHandlers,
voiceChannelDropZone,
};
}