fix: normalize drop indicator to single line between channels

Port the SpaceSidebar normalization pattern: convert 'before B'
to 'after A' so only one drop indicator renders at any gap.
Fixes double-line visual glitch and unreliable drops when cursor
is between two channels. Also adjust indicator offsets from
top-0/bottom-0 to -top-[1px]/-bottom-[1px] to center in the gap.
This commit is contained in:
Jannis Braun
2026-03-20 21:07:20 +01:00
parent ebe34a710b
commit 99e6f2f249
2 changed files with 53 additions and 9 deletions
@@ -19,7 +19,7 @@ import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } fro
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { ConfirmDialog } from '../ui/ConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog';
import { DmSearchBar } from './DmSearchBar'; import { DmSearchBar } from './DmSearchBar';
import { useDragManager, type DropTarget } from '../../hooks/useDragManager'; import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager';
export function ChannelSidebar() { export function ChannelSidebar() {
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
@@ -157,6 +157,26 @@ export function ChannelSidebar() {
}, [channelsByCategory, unreadChannels]); }, [channelsByCategory, unreadChannels]);
// --- Centralized drag-and-drop --- // --- Centralized drag-and-drop ---
// Flat ordered list matching visual sidebar order — used by useDragManager
// to normalize 'before B' into 'after A' for a single drop indicator line
const orderedItems = useMemo<LayoutItem[]>(() => {
const items: LayoutItem[] = [];
for (const ch of uncategorizedChannels) {
items.push({ id: ch.id, type: 'channel' });
}
for (const cat of sortedCategories) {
items.push({ id: cat.id, type: 'category' });
const catChs = channelsByCategory.get(cat.id) ?? [];
if (!collapsedCategories.has(cat.id)) {
for (const ch of catChs) {
items.push({ id: ch.id, type: 'channel' });
}
}
}
return items;
}, [uncategorizedChannels, sortedCategories, channelsByCategory, collapsedCategories]);
const canMoveMembers = hasPermissionBit(mySpacePerms, PermissionBits.MOVE_MEMBERS); const canMoveMembers = hasPermissionBit(mySpacePerms, PermissionBits.MOVE_MEMBERS);
const handleChannelDrop = useCallback((dragId: string, target: DropTarget) => { const handleChannelDrop = useCallback((dragId: string, target: DropTarget) => {
@@ -275,6 +295,7 @@ export function ChannelSidebar() {
scrollContainerRef, scrollContainerRef,
canManage: canManageChannels, canManage: canManageChannels,
canMoveMembers, canMoveMembers,
orderedItems,
onChannelDrop: handleChannelDrop, onChannelDrop: handleChannelDrop,
onCategoryDrop: handleCategoryDrop, onCategoryDrop: handleCategoryDrop,
onVoiceUserDrop: handleVoiceUserDrop, onVoiceUserDrop: handleVoiceUserDrop,
@@ -1257,7 +1278,7 @@ function ChannelItem({
className={`relative ${isDragging ? 'opacity-50' : ''}`} className={`relative ${isDragging ? 'opacity-50' : ''}`}
{...channelDragHandlers} {...channelDragHandlers}
> >
{dropIndicator === 'before' && <div className="absolute top-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />} {dropIndicator === 'before' && <div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
<VoiceChannel <VoiceChannel
channelId={channel.id} channelId={channel.id}
channelName={channel.name} channelName={channel.name}
@@ -1268,7 +1289,7 @@ function ChannelItem({
voiceUserHandlers={voiceUserHandlers} voiceUserHandlers={voiceUserHandlers}
dropZone={voiceChannelDropZone} dropZone={voiceChannelDropZone}
/> />
{dropIndicator === 'after' && <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />} {dropIndicator === 'after' && <div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
</div> </div>
); );
} }
@@ -1278,7 +1299,7 @@ function ChannelItem({
className={`relative ${isDragging ? 'opacity-50' : ''}`} className={`relative ${isDragging ? 'opacity-50' : ''}`}
{...channelDragHandlers} {...channelDragHandlers}
> >
{dropIndicator === 'before' && <div className="absolute top-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />} {dropIndicator === 'before' && <div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
<button <button
onClick={onChannelClick} onClick={onChannelClick}
className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${ className={`relative w-full flex items-center gap-1.5 px-[10px] h-8 rounded-[6px] group transition-colors ${
@@ -1318,7 +1339,7 @@ function ChannelItem({
</svg> </svg>
)} )}
</button> </button>
{dropIndicator === 'after' && <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />} {dropIndicator === 'after' && <div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent-mint rounded-full z-10" />}
</div> </div>
); );
} }
+27 -4
View File
@@ -14,17 +14,25 @@ export interface DropTarget {
targetType: 'channel' | 'category'; targetType: 'channel' | 'category';
} }
export interface LayoutItem {
id: string;
type: 'channel' | 'category';
}
interface UseDragManagerOpts { interface UseDragManagerOpts {
scrollContainerRef: RefObject<HTMLElement | null>; scrollContainerRef: RefObject<HTMLElement | null>;
canManage: boolean; canManage: boolean;
canMoveMembers: boolean; canMoveMembers: boolean;
/** Flat ordered list of all visible items in sidebar order — used to normalize
* 'before B' into 'after A' so only a single drop indicator line renders. */
orderedItems: LayoutItem[];
onChannelDrop: (dragId: string, target: DropTarget) => void; onChannelDrop: (dragId: string, target: DropTarget) => void;
onCategoryDrop: (dragId: string, target: DropTarget) => void; onCategoryDrop: (dragId: string, target: DropTarget) => void;
onVoiceUserDrop: (userId: string, fromChannelId: string, toChannelId: string) => void; onVoiceUserDrop: (userId: string, fromChannelId: string, toChannelId: string) => void;
} }
export function useDragManager(opts: UseDragManagerOpts) { export function useDragManager(opts: UseDragManagerOpts) {
const { scrollContainerRef, canManage, canMoveMembers, onChannelDrop, onCategoryDrop, onVoiceUserDrop } = opts; const { scrollContainerRef, canManage, canMoveMembers, orderedItems, onChannelDrop, onCategoryDrop, onVoiceUserDrop } = opts;
const [activeDrag, setActiveDrag] = useState<DragState | null>(null); const [activeDrag, setActiveDrag] = useState<DragState | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
@@ -58,9 +66,24 @@ export function useDragManager(opts: UseDragManagerOpts) {
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2; const midY = rect.top + rect.height / 2;
const position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after'; let position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after';
setDropTarget({ targetId, position, targetType });
}, [activeDrag]); // Normalize 'before' to previous item's 'after' so a single drop indicator
// line renders between items, eliminating the double-line visual glitch
let resolvedId = targetId;
let resolvedType = targetType;
if (position === 'before') {
const idx = orderedItems.findIndex(item => item.id === targetId);
if (idx > 0) {
const prev = orderedItems[idx - 1]!;
resolvedId = prev.id;
resolvedType = prev.type;
position = 'after';
}
}
setDropTarget({ targetId: resolvedId, position, targetType: resolvedType });
}, [activeDrag, orderedItems]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
clearState(); clearState();