From 99e6f2f24967fab24b86127e67aee36de795350f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:07:20 +0100 Subject: [PATCH] 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. --- .../src/components/layout/ChannelSidebar.tsx | 31 ++++++++++++++++--- packages/web/src/hooks/useDragManager.ts | 31 ++++++++++++++++--- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 51c4ced8..e125e1f5 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -19,7 +19,7 @@ import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } fro import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { ConfirmDialog } from '../ui/ConfirmDialog'; import { DmSearchBar } from './DmSearchBar'; -import { useDragManager, type DropTarget } from '../../hooks/useDragManager'; +import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -157,6 +157,26 @@ export function ChannelSidebar() { }, [channelsByCategory, unreadChannels]); // --- 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(() => { + 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 handleChannelDrop = useCallback((dragId: string, target: DropTarget) => { @@ -275,6 +295,7 @@ export function ChannelSidebar() { scrollContainerRef, canManage: canManageChannels, canMoveMembers, + orderedItems, onChannelDrop: handleChannelDrop, onCategoryDrop: handleCategoryDrop, onVoiceUserDrop: handleVoiceUserDrop, @@ -1257,7 +1278,7 @@ function ChannelItem({ className={`relative ${isDragging ? 'opacity-50' : ''}`} {...channelDragHandlers} > - {dropIndicator === 'before' &&
} + {dropIndicator === 'before' &&
} - {dropIndicator === 'after' &&
} + {dropIndicator === 'after' &&
}
); } @@ -1278,7 +1299,7 @@ function ChannelItem({ className={`relative ${isDragging ? 'opacity-50' : ''}`} {...channelDragHandlers} > - {dropIndicator === 'before' &&
} + {dropIndicator === 'before' &&
} - {dropIndicator === 'after' &&
} + {dropIndicator === 'after' &&
}
); } diff --git a/packages/web/src/hooks/useDragManager.ts b/packages/web/src/hooks/useDragManager.ts index 4fbb06a2..8e287e4d 100644 --- a/packages/web/src/hooks/useDragManager.ts +++ b/packages/web/src/hooks/useDragManager.ts @@ -14,17 +14,25 @@ export interface DropTarget { targetType: 'channel' | 'category'; } +export interface LayoutItem { + id: string; + type: 'channel' | 'category'; +} + interface UseDragManagerOpts { scrollContainerRef: RefObject; canManage: 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; 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 { scrollContainerRef, canManage, canMoveMembers, orderedItems, onChannelDrop, onCategoryDrop, onVoiceUserDrop } = opts; const [activeDrag, setActiveDrag] = useState(null); const [dropTarget, setDropTarget] = useState(null); @@ -58,9 +66,24 @@ export function useDragManager(opts: UseDragManagerOpts) { 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]); + let position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after'; + + // 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(() => { clearState();