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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user