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.
This commit is contained in:
@@ -346,6 +346,8 @@ function FolderFlyout({
|
|||||||
onSpaceContextMenu,
|
onSpaceContextMenu,
|
||||||
onRename,
|
onRename,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
|
onReorder,
|
||||||
|
onParentDragEnd,
|
||||||
}: {
|
}: {
|
||||||
folder: SpaceFolder;
|
folder: SpaceFolder;
|
||||||
spaces: TaggedSpace[];
|
spaces: TaggedSpace[];
|
||||||
@@ -359,6 +361,8 @@ function FolderFlyout({
|
|||||||
onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void;
|
onSpaceContextMenu: (spaceId: string, e: React.MouseEvent) => void;
|
||||||
onRename: (name: string) => void;
|
onRename: (name: string) => void;
|
||||||
onDragStart: (e: React.DragEvent, spaceId: string) => void;
|
onDragStart: (e: React.DragEvent, spaceId: string) => void;
|
||||||
|
onReorder: (reorderedSpaceIds: string[]) => void;
|
||||||
|
onParentDragEnd: () => void;
|
||||||
}) {
|
}) {
|
||||||
const anchorRef = useRef<HTMLDivElement>(anchorEl);
|
const anchorRef = useRef<HTMLDivElement>(anchorEl);
|
||||||
anchorRef.current = anchorEl;
|
anchorRef.current = anchorEl;
|
||||||
@@ -369,12 +373,86 @@ function FolderFlyout({
|
|||||||
offset: 12,
|
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
|
// Close on click-outside and Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
floatingRef.current && !floatingRef.current.contains(e.target as Node) &&
|
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();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -421,7 +499,7 @@ function FolderFlyout({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Space rows */}
|
{/* Space rows */}
|
||||||
{spaces.map((space) => {
|
{spaces.map((space, idx) => {
|
||||||
const isActive = currentSpaceId === space.id;
|
const isActive = currentSpaceId === space.id;
|
||||||
const hasUnread = unreadSpaceIds.has(space.id) && !isActive;
|
const hasUnread = unreadSpaceIds.has(space.id) && !isActive;
|
||||||
const origin = space._instanceOrigin;
|
const origin = space._instanceOrigin;
|
||||||
@@ -431,51 +509,64 @@ function FolderFlyout({
|
|||||||
const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null;
|
const grad = !icon ? getSpaceGradient(space.id, space.name, space.avatarColor) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<React.Fragment key={space.id}>
|
||||||
key={space.id}
|
{/* Drop indicator: before first item */}
|
||||||
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 mx-1 rounded-md transition-colors ${
|
{idx === 0 && flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'before' && (
|
||||||
isDimmed ? 'opacity-40 saturate-50' : ''
|
<div className="h-0.5 bg-accent-mint rounded-full mx-2.5 my-0.5" />
|
||||||
} ${isActive ? 'bg-white/[0.10]' : 'hover:bg-white/[0.06]'}`}
|
)}
|
||||||
style={{ width: 'calc(100% - 8px)' }}
|
|
||||||
draggable
|
<button
|
||||||
onDragStart={(e) => onDragStart(e, space.id)}
|
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 mx-1 rounded-md transition-colors ${
|
||||||
onClick={() => {
|
isDimmed ? 'opacity-40 saturate-50' : ''
|
||||||
onSpaceClick(space.id);
|
} ${isActive ? 'bg-white/[0.10]' : 'hover:bg-white/[0.06]'}`}
|
||||||
onClose();
|
style={{ width: 'calc(100% - 8px)' }}
|
||||||
}}
|
draggable
|
||||||
onContextMenu={(e) => {
|
onDragStart={(e) => onDragStart(e, space.id)}
|
||||||
onSpaceContextMenu(space.id, e);
|
onDragOver={(e) => handleFlyoutDragOver(e, space.id)}
|
||||||
onClose();
|
onDrop={handleFlyoutDrop}
|
||||||
}}
|
onDragEnd={handleFlyoutDragEnd}
|
||||||
>
|
onClick={() => {
|
||||||
{/* Space icon */}
|
onSpaceClick(space.id);
|
||||||
<div className="w-8 h-8 rounded-[10px] flex-shrink-0 overflow-hidden flex items-center justify-center" style={grad ? { background: grad.gradient } : undefined}>
|
onClose();
|
||||||
{icon ? (
|
}}
|
||||||
<img
|
onContextMenu={(e) => {
|
||||||
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
|
onSpaceContextMenu(space.id, e);
|
||||||
alt=""
|
}}
|
||||||
className="w-full h-full object-cover"
|
>
|
||||||
/>
|
{/* Space icon */}
|
||||||
) : (
|
<div className="w-8 h-8 rounded-[10px] flex-shrink-0 overflow-hidden flex items-center justify-center" style={grad ? { background: grad.gradient } : undefined}>
|
||||||
<span className="text-[13px] font-bold text-white">{space.name.charAt(0).toUpperCase()}</span>
|
{icon ? (
|
||||||
|
<img
|
||||||
|
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] font-bold text-white">{space.name.charAt(0).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span className="text-sm text-txt-primary truncate flex-1 text-left">{space.name}</span>
|
||||||
|
|
||||||
|
{/* Federation badge */}
|
||||||
|
{isFederated && !isDimmed && (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80 flex-shrink-0">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
{/* Unread dot */}
|
||||||
<span className="text-sm text-txt-primary truncate flex-1 text-left">{space.name}</span>
|
{hasUnread && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-white flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Federation badge */}
|
{/* Drop indicator: after item */}
|
||||||
{isFederated && !isDimmed && (
|
{flyoutDrop?.targetSpaceId === space.id && flyoutDrop.position === 'after' && (
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80 flex-shrink-0">
|
<div className="h-0.5 bg-accent-mint rounded-full mx-2.5 my-0.5" />
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
|
</React.Fragment>
|
||||||
{/* Unread dot */}
|
|
||||||
{hasUnread && (
|
|
||||||
<div className="w-2 h-2 rounded-full bg-white flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>,
|
</div>,
|
||||||
@@ -556,6 +647,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb
|
|||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
|
data-flyout-safe
|
||||||
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
|
className="fixed z-[9999] min-w-[160px] glass rounded-lg py-1 animate-in fade-in zoom-in-95 duration-100"
|
||||||
style={{ left: clampedX, top: clampedY }}
|
style={{ left: clampedX, top: clampedY }}
|
||||||
>
|
>
|
||||||
@@ -946,6 +1038,23 @@ export function SpaceSidebar() {
|
|||||||
updateSpaceLayout(items, folderPayload);
|
updateSpaceLayout(items, folderPayload);
|
||||||
}, [buildLayoutPayload, updateSpaceLayout]);
|
}, [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) => {
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1286,6 +1395,8 @@ export function SpaceSidebar() {
|
|||||||
onSpaceContextMenu={handleSpaceContextMenu}
|
onSpaceContextMenu={handleSpaceContextMenu}
|
||||||
onRename={(name) => handleFolderRename(openFolderId, name)}
|
onRename={(name) => handleFolderRename(openFolderId, name)}
|
||||||
onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)}
|
onDragStart={(e, spaceId) => handleDragStart(e, spaceId, 'space', openFolderId)}
|
||||||
|
onReorder={(ids) => handleReorderInFolder(openFolderId!, ids)}
|
||||||
|
onParentDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user