From 5193d79c394c335d4908fb5495b8836f89790843 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:23:34 +0100 Subject: [PATCH] fix: floating panel ResizeObserver broken by fragment index shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context menu insertion before floatingPanel shifted its fragment index, causing React to unmount/recreate the DOM element on DM↔space transitions. The ResizeObserver was left observing the old detached element, so paddingBottom never adjusted and channels scrolled behind the panel. - Reorder fragment children so floatingPanel is at index 1 in both views - Replace useRef with callback ref so ResizeObserver reattaches on remount - Add stopPropagation to ContextMenu to prevent sidebar menu conflicts - Restyle sidebar create channel/category buttons (smaller, separated) --- .../src/components/layout/ChannelSidebar.tsx | 148 ++++++++++++++---- .../web/src/components/ui/ContextMenu.tsx | 1 + 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 43434392..da57d1a3 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -50,20 +50,19 @@ export function ChannelSidebar() { const navigate = useNavigate(); const location = useLocation(); - const floatingPanelRef = useRef(null); + const [floatingPanelEl, setFloatingPanelEl] = useState(null); const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight); const setFloatingPanelHeight = useUIStore((s) => s.setFloatingPanelHeight); useEffect(() => { - const el = floatingPanelRef.current; - if (!el) return; + if (!floatingPanelEl) return; const ro = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) setFloatingPanelHeight(entry.contentRect.height); }); - ro.observe(el); + ro.observe(floatingPanelEl); return () => ro.disconnect(); - }, [setFloatingPanelHeight]); + }, [floatingPanelEl, setFloatingPanelHeight]); const handleMicToggle = async () => { if (isSpaceMuted || isSpaceDeafened || isPermissionMuted) return; @@ -108,6 +107,11 @@ export function ChannelSidebar() { const [deleteCategoryId, setDeleteCategoryId] = useState(null); const [deleteCategoryLoading, setDeleteCategoryLoading] = useState(false); + // Sidebar background context menu state + const [sidebarMenuOpen, setSidebarMenuOpen] = useState(false); + const [sidebarMenuPos, setSidebarMenuPos] = useState({ x: 0, y: 0 }); + const sidebarMenuRef = useRef(null); + // Collapse state — persisted in localStorage const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`; const [collapsedCategories, setCollapsedCategories] = useState>(() => { @@ -151,6 +155,36 @@ export function ChannelSidebar() { return chs.some(ch => unreadChannels.has(ch.id)); }, [channelsByCategory, unreadChannels]); + // Sidebar background context menu: close on click/scroll, viewport clamp + useEffect(() => { + if (!sidebarMenuOpen) return; + const close = () => setSidebarMenuOpen(false); + document.addEventListener('click', close); + document.addEventListener('scroll', close, true); + return () => { + document.removeEventListener('click', close); + document.removeEventListener('scroll', close, true); + }; + }, [sidebarMenuOpen]); + + useEffect(() => { + if (sidebarMenuOpen && sidebarMenuRef.current) { + const rect = sidebarMenuRef.current.getBoundingClientRect(); + const pos = { ...sidebarMenuPos }; + if (rect.right > window.innerWidth) pos.x = window.innerWidth - rect.width - 8; + if (rect.bottom > window.innerHeight) pos.y = window.innerHeight - rect.height - 8; + if (pos.x < 8) pos.x = 8; + if (pos.y < 8) pos.y = 8; + if (pos.x !== sidebarMenuPos.x || pos.y !== sidebarMenuPos.y) setSidebarMenuPos(pos); + } + }, [sidebarMenuOpen, sidebarMenuPos]); + + const handleSidebarContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setSidebarMenuPos({ x: e.clientX, y: e.clientY }); + setSidebarMenuOpen(true); + }, []); + // DnD handlers const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => { if (!canManageChannels) return; @@ -332,7 +366,7 @@ export function ChannelSidebar() { // Floating bottom panel — shared between DM view and server view const floatingPanel = user ? ( -
+
{/* Voice controls (expands when connected) */} {(currentVoiceChannelId || activeDmCall) && } @@ -573,7 +607,7 @@ export function ChannelSidebar() {
{/* Channels — dynamic category layout */} -
e.preventDefault()}> +
e.preventDefault()} onContextMenu={handleSidebarContextMenu}> {/* Uncategorized channels */} {uncategorizedChannels.length > 0 && (
@@ -722,32 +756,28 @@ export function ChannelSidebar() { ); })} - {/* "Create Channel" button if categories exist but no uncategorized channels */} - {sortedCategories.length > 0 && canManageChannels && ( -
- -
- )} - - {/* Create category button */} + {/* Create channel / category buttons */} {canManageChannels && ( -
+
+ {sortedCategories.length > 0 && ( + + )}
)} @@ -756,6 +786,70 @@ export function ChannelSidebar() {
{floatingPanel} + {/* Sidebar background context menu */} + {sidebarMenuOpen && ( +
+ {canManageChannels && ( + + )} + {canManageChannels && ( + + )} + {canCreateInvite && ( + + )} + +
+ )} setDeleteCategoryId(null)} diff --git a/packages/web/src/components/ui/ContextMenu.tsx b/packages/web/src/components/ui/ContextMenu.tsx index 7ffff9ab..c13a93ac 100644 --- a/packages/web/src/components/ui/ContextMenu.tsx +++ b/packages/web/src/components/ui/ContextMenu.tsx @@ -19,6 +19,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) { const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); setPosition({ x: e.clientX, y: e.clientY }); setIsOpen(true); };