From 7a7e0784d82d650c299ad9c725579deba048302c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:38:56 +0100 Subject: [PATCH] fix: folder flyout context menu stays open + intra-folder DnD reordering Context menu no longer closes the flyout when right-clicking a space inside a folder. Added data-flyout-safe attribute so click-outside detection skips portaled context menu elements. Added drag-and-drop reordering within folder flyouts with drop indicators and layout persistence. --- .../src/components/layout/SpaceSidebar.tsx | 197 ++++++++++++++---- 1 file changed, 154 insertions(+), 43 deletions(-) diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index a73f6c61..1a0ee1bb 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -346,6 +346,8 @@ function FolderFlyout({ onSpaceContextMenu, onRename, onDragStart, + onReorder, + onParentDragEnd, }: { folder: SpaceFolder; spaces: TaggedSpace[]; @@ -359,6 +361,8 @@ function FolderFlyout({ onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void; onRename: (name: string) => void; onDragStart: (e: React.DragEvent, spaceId: string) => void; + onReorder: (reorderedSpaceIds: string[]) => void; + onParentDragEnd: () => void; }) { const anchorRef = useRef(anchorEl); anchorRef.current = anchorEl; @@ -369,12 +373,86 @@ function FolderFlyout({ offset: 12, }); + // Intra-folder DnD state + const [flyoutDrop, setFlyoutDrop] = useState<{ + targetSpaceId: string; + position: 'before' | 'after'; + } | null>(null); + const flyoutDropRef = useRef(flyoutDrop); + flyoutDropRef.current = flyoutDrop; + + const handleFlyoutDragOver = useCallback((e: React.DragEvent, targetSpaceId: string) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + + const rect = e.currentTarget.getBoundingClientRect(); + const relY = e.clientY - rect.top; + const position: 'before' | 'after' = relY < rect.height * 0.5 ? 'before' : 'after'; + + // Normalize 'before' to previous item's 'after' so a single indicator renders + if (position === 'before') { + const idx = spaces.findIndex(s => s.id === targetSpaceId); + if (idx > 0) { + const prevId = spaces[idx - 1]!.id; + setFlyoutDrop({ targetSpaceId: prevId, position: 'after' }); + return; + } + } + + setFlyoutDrop({ targetSpaceId, position }); + }, [spaces]); + + const handleFlyoutDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const indicator = flyoutDropRef.current; + const dragId = e.dataTransfer.getData('text/plain'); + if (!indicator || !dragId) { + setFlyoutDrop(null); + onParentDragEnd(); + return; + } + + // Don't reorder if dropping on self in same position + const currentIds = spaces.map(s => s.id); + const dragIdx = currentIds.indexOf(dragId); + if (dragIdx === -1) { + // Dragged space is not in this folder — let parent handle it + setFlyoutDrop(null); + onParentDragEnd(); + return; + } + + // Remove dragged space and re-insert at target position + const without = currentIds.filter(id => id !== dragId); + const targetIdx = without.indexOf(indicator.targetSpaceId); + if (targetIdx === -1) { + setFlyoutDrop(null); + onParentDragEnd(); + return; + } + + const insertIdx = indicator.position === 'before' ? targetIdx : targetIdx + 1; + without.splice(insertIdx, 0, dragId); + + onReorder(without); + setFlyoutDrop(null); + onParentDragEnd(); + }, [spaces, onReorder, onParentDragEnd]); + + const handleFlyoutDragEnd = useCallback(() => { + setFlyoutDrop(null); + onParentDragEnd(); + }, [onParentDragEnd]); + // Close on click-outside and Escape useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( floatingRef.current && !floatingRef.current.contains(e.target as Node) && - !anchorEl.contains(e.target as Node) + !anchorEl.contains(e.target as Node) && + !(e.target as HTMLElement).closest?.('[data-flyout-safe]') ) { onClose(); } @@ -421,7 +499,7 @@ function FolderFlyout({ )} {/* Space rows */} - {spaces.map((space) => { + {spaces.map((space, idx) => { const isActive = currentSpaceId === space.id; const hasUnread = unreadSpaceIds.has(space.id) && !isActive; const origin = space._instanceOrigin; @@ -431,51 +509,64 @@ function FolderFlyout({ const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null; return ( - - {/* Federation badge */} - {isFederated && !isDimmed && ( - - - + {/* Drop indicator: after item */} + {flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'after' && ( +
)} - - {/* Unread dot */} - {hasUnread && ( -
- )} - + ); })}
, @@ -556,6 +647,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb return ReactDOM.createPortal(
@@ -946,6 +1038,23 @@ export function SpaceSidebar() { updateSpaceLayout(items, folderPayload); }, [buildLayoutPayload, updateSpaceLayout]); + const handleReorderInFolder = useCallback((folderId: string, reorderedSpaceIds: string[]) => { + const newLayout = resolvedLayout.map(item => { + if (item.type === 'folder' && item.folder.id === folderId) { + const reorderedSpaces = reorderedSpaceIds + .map(id => item.spaces.find(s => s.id === id)) + .filter((s): s is TaggedSpace => !!s); + return { + ...item, + spaces: reorderedSpaces, + folder: { ...item.folder, spaceIds: reorderedSpaceIds }, + }; + } + return item; + }) as ResolvedItem[]; + persistLayout(newLayout); + }, [resolvedLayout, persistLayout]); + const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -1286,6 +1395,8 @@ export function SpaceSidebar() { onSpaceContextMenu={handleSpaceContextMenu} onRename={(name) => handleFolderRename(openFolderId, name)} onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)} + onReorder={(ids) => handleReorderInFolder(openFolderId!, ids)} + onParentDragEnd={handleDragEnd} /> ); })()}