feat(sidebar): long-press drag to reorder and hide nav items (#269)

Hold for 1s then drag to reorder configurable library and system links;
drop outside the sidebar to hide items (same visibility as Settings).
Show a trash hint near the cursor when outside removal applies.
Extract shared reorder helpers into src/utils/sidebarNavReorder.ts and
reuse them from the Settings sidebar customizer.
This commit is contained in:
cucadmuh
2026-04-23 00:31:30 +03:00
committed by GitHub
parent 2d320d8681
commit c5dfabf739
4 changed files with 494 additions and 56 deletions
+376 -37
View File
@@ -5,13 +5,13 @@ import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Settings,
PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload,
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle,
ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Trash2,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -20,6 +20,23 @@ import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import OverlayScrollArea from './OverlayScrollArea';
import {
applySidebarDropReorder,
getLibraryItemsForReorder,
getSystemItemsForReorder,
isSidebarNavItemUserHideable,
type SidebarNavDropTarget,
} from '../utils/sidebarNavReorder';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean {
const aside = document.querySelector('aside.sidebar');
if (!aside) return false;
const r = aside.getBoundingClientRect();
return clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom;
}
export default function Sidebar({
@@ -49,6 +66,7 @@ export default function Sidebar({
const setMusicLibraryFilter = useAuthStore(s => s.setMusicLibraryFilter);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
@@ -68,6 +86,239 @@ export default function Sidebar({
filterId === 'all' ? null : musicFolders.find(f => f.id === filterId)?.name ?? null;
const libraryTriggerPlain = filterId === 'all';
const libraryItemsForReorder = useMemo(
() => getLibraryItemsForReorder(sidebarItems, randomNavMode),
[sidebarItems, randomNavMode],
);
const systemItemsForReorder = useMemo(
() => getSystemItemsForReorder(sidebarItems),
[sidebarItems],
);
const visibleLibraryConfigs = useMemo(
() => libraryItemsForReorder.filter(c => c.visible),
[libraryItemsForReorder],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible),
[systemItemsForReorder],
);
const [navDnd, setNavDnd] = useState<{
section: 'library' | 'system';
fromIdx: number;
} | null>(null);
const [navDropTarget, setNavDropTarget] = useState<SidebarNavDropTarget | null>(null);
const navDropTargetRef = useRef<SidebarNavDropTarget | null>(null);
navDropTargetRef.current = navDropTarget;
const sidebarItemsRef = useRef(sidebarItems);
sidebarItemsRef.current = sidebarItems;
const randomNavModeRef = useRef(randomNavMode);
randomNavModeRef.current = randomNavMode;
/** DOM timers are numeric; avoid NodeJS `Timeout` typing from `setTimeout`. */
const longPressTimersRef = useRef<Map<number, number>>(new Map());
const suppressNavClickRef = useRef(false);
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!navDnd) return;
const updateDropFromPoint = (clientX: number, clientY: number) => {
if (isPointerOutsideAsideSidebar(clientX, clientY)) {
navDropTargetRef.current = null;
setNavDropTarget(null);
return;
}
const rows = document.querySelectorAll<HTMLElement>('.sidebar [data-sidebar-nav-dnd-row]');
let target: SidebarNavDropTarget | null = null;
for (const row of rows) {
const section = row.dataset.sidebarSection as 'library' | 'system' | undefined;
if (section !== navDnd.section) continue;
const rect = row.getBoundingClientRect();
const idx = Number(row.dataset.sidebarIdx);
if (Number.isNaN(idx)) continue;
if (clientY < rect.top + rect.height / 2) {
target = { idx, before: true, section };
break;
}
target = { idx, before: false, section };
}
navDropTargetRef.current = target;
setNavDropTarget(target);
};
const endDrag = (apply: boolean) => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
const currentDnd = navDnd;
const drop = navDropTargetRef.current;
setNavDnd(null);
setNavDropTarget(null);
navDropTargetRef.current = null;
if (!apply || !currentDnd) return;
const { x, y } = lastPointerDuringNavDndRef.current;
if (isPointerOutsideAsideSidebar(x, y)) {
const sectionItems =
currentDnd.section === 'library'
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
: getSystemItemsForReorder(sidebarItemsRef.current);
const id = sectionItems[currentDnd.fromIdx]?.id;
if (id && isSidebarNavItemUserHideable(id)) {
const nextItems: SidebarItemConfig[] = sidebarItemsRef.current.map(i =>
i.id === id ? { ...i, visible: false } : i,
);
setSidebarItems(nextItems);
suppressNavClickRef.current = true;
}
return;
}
const next = applySidebarDropReorder(
sidebarItemsRef.current,
currentDnd.section,
currentDnd.fromIdx,
drop,
randomNavModeRef.current,
);
if (next) {
setSidebarItems(next);
suppressNavClickRef.current = true;
}
};
const onMove = (e: PointerEvent) => {
e.preventDefault();
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
const outside = isPointerOutsideAsideSidebar(e.clientX, e.clientY);
const sectionItems =
navDnd.section === 'library'
? getLibraryItemsForReorder(sidebarItemsRef.current, randomNavModeRef.current)
: getSystemItemsForReorder(sidebarItemsRef.current);
const draggedId = sectionItems[navDnd.fromIdx]?.id;
const canTrash = Boolean(draggedId && isSidebarNavItemUserHideable(draggedId));
if (outside && canTrash) {
setNavDndTrashHint({ x: e.clientX, y: e.clientY });
} else {
setNavDndTrashHint(null);
}
updateDropFromPoint(e.clientX, e.clientY);
};
const onUp = (e: PointerEvent) => {
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
endDrag(true);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
endDrag(false);
}
};
document.body.style.userSelect = 'none';
window.addEventListener('pointermove', onMove, { capture: true, passive: false });
window.addEventListener('pointerup', onUp, true);
window.addEventListener('pointercancel', onUp, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointermove', onMove, { capture: true });
window.removeEventListener('pointerup', onUp, true);
window.removeEventListener('pointercancel', onUp, true);
window.removeEventListener('keydown', onKey, true);
document.body.style.userSelect = '';
setNavDndTrashHint(null);
};
}, [navDnd, setSidebarItems]);
const handleNavRowPointerDown = useCallback(
(e: React.PointerEvent, section: 'library' | 'system', sectionIdx: number) => {
if (isCollapsed || navDnd) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
const pid = e.pointerId;
const sx = e.clientX;
const sy = e.clientY;
let cleaned = false;
const cleanupEarly = () => {
if (cleaned) return;
cleaned = true;
document.removeEventListener('pointermove', onEarlyMove);
document.removeEventListener('pointerup', onEarlyUp, true);
document.removeEventListener('pointercancel', onEarlyUp, true);
};
const onEarlyMove = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) > SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX) {
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
}
};
const onEarlyUp = (ev: PointerEvent) => {
if (ev.pointerId !== pid) return;
const t = longPressTimersRef.current.get(pid);
if (t != null) window.clearTimeout(t);
longPressTimersRef.current.delete(pid);
cleanupEarly();
};
const timer = window.setTimeout(() => {
longPressTimersRef.current.delete(pid);
cleanupEarly();
window.getSelection()?.removeAllRanges();
lastPointerDuringNavDndRef.current = { x: sx, y: sy };
setNavDnd({ section, fromIdx: sectionIdx });
navDropTargetRef.current = { idx: sectionIdx, before: true, section };
setNavDropTarget({ idx: sectionIdx, before: true, section });
try {
(e.currentTarget as HTMLElement).setPointerCapture(pid);
} catch {
/* ignore */
}
}, SIDEBAR_NAV_LONG_PRESS_MS) as unknown as number;
longPressTimersRef.current.set(pid, timer);
document.addEventListener('pointermove', onEarlyMove);
document.addEventListener('pointerup', onEarlyUp, true);
document.addEventListener('pointercancel', onEarlyUp, true);
},
[isCollapsed, navDnd],
);
const navDndRowClass = useCallback(
(section: 'library' | 'system', sectionIdx: number) => {
const dragging = navDnd?.section === section && navDnd.fromIdx === sectionIdx;
let drop = '';
if (
navDnd &&
navDropTarget?.section === section &&
navDropTarget.idx === sectionIdx &&
!(navDnd.section === section && navDnd.fromIdx === sectionIdx)
) {
drop = navDropTarget.before
? 'sidebar-nav-dnd-row--drop-before'
: 'sidebar-nav-dnd-row--drop-after';
}
return `sidebar-nav-dnd-row${dragging ? ' sidebar-nav-dnd-row--dragging' : ''}${drop ? ` ${drop}` : ''}`.trim();
},
[navDnd, navDropTarget],
);
const updateDropdownPosition = useCallback(() => {
const el = libraryTriggerRef.current;
if (!el) return;
@@ -122,22 +373,13 @@ export default function Sidebar({
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => {
if (cfg == null || !cfg.visible || ALL_NAV_ITEMS[cfg.id]?.section !== 'library') return false;
// Hide mode-inactive mix entries so the active mode controls what's shown
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
const visibleSystem = sidebarItems
.filter(cfg => cfg != null && cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'system')
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
useEffect(() => () => {
longPressTimersRef.current.forEach(t => window.clearTimeout(t));
longPressTimersRef.current.clear();
}, []);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
{isCollapsed
@@ -155,7 +397,17 @@ export default function Sidebar({
{isCollapsed ? <PanelLeft size={14} /> : <PanelLeftClose size={14} />}
</button>
<nav className="sidebar-nav" aria-label="Main navigation">
<nav
className="sidebar-nav"
aria-label="Main navigation"
onClickCapture={e => {
if (suppressNavClickRef.current) {
suppressNavClickRef.current = false;
e.preventDefault();
e.stopPropagation();
}
}}
>
<OverlayScrollArea
className="sidebar-nav-scroll"
viewportClassName="sidebar-nav-viewport"
@@ -248,10 +500,29 @@ export default function Sidebar({
) : (
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
item.to === '/playlists' ? (
// Playlists item with expand button
<div key={item.to} className="sidebar-playlists-wrapper">
{visibleLibraryConfigs.map(cfg => {
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return null;
const sectionIdx = libraryItemsForReorder.findIndex(x => x.id === cfg.id);
const dndRow = !isCollapsed && sectionIdx >= 0;
const rowClass = dndRow ? navDndRowClass('library', sectionIdx) : undefined;
const dndProps = dndRow
? {
'data-sidebar-nav-dnd-row': '',
'data-sidebar-section': 'library' as const,
'data-sidebar-idx': String(sectionIdx),
onPointerDown: (e: React.PointerEvent) =>
handleNavRowPointerDown(e, 'library', sectionIdx),
}
: {};
return item.to === '/playlists' ? (
<div
key={item.to}
className={`sidebar-playlists-wrapper${rowClass ? ` ${rowClass}` : ''}`}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<div className="sidebar-playlists-header-row">
<NavLink
to={item.to}
@@ -299,7 +570,7 @@ export default function Sidebar({
</div>
)}
</div>
) : (
) : isCollapsed ? (
<NavLink
key={item.to}
to={item.to}
@@ -311,8 +582,26 @@ export default function Sidebar({
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
)
))}
) : (
<div
key={item.to}
className={rowClass}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<NavLink
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
</div>
);
})}
{/* Spacer: everything from here onward sticks to the bottom of the sidebar. */}
<div className="sidebar-bottom-spacer" />
@@ -346,19 +635,53 @@ export default function Sidebar({
</NavLink>
)}
{visibleSystem.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystem.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
))}
{visibleSystemConfigs.length > 0 && !isCollapsed && <span className="nav-section-label">{t('sidebar.system')}</span>}
{visibleSystemConfigs.map(cfg => {
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return null;
const sectionIdx = systemItemsForReorder.findIndex(x => x.id === cfg.id);
const dndRow = !isCollapsed && sectionIdx >= 0;
const rowClass = dndRow ? navDndRowClass('system', sectionIdx) : undefined;
const dndProps = dndRow
? {
'data-sidebar-nav-dnd-row': '',
'data-sidebar-section': 'system' as const,
'data-sidebar-idx': String(sectionIdx),
onPointerDown: (e: React.PointerEvent) =>
handleNavRowPointerDown(e, 'system', sectionIdx),
}
: {};
return isCollapsed ? (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
) : (
<div
key={item.to}
className={rowClass}
{...dndProps}
style={navDnd && dndRow ? { touchAction: 'none' } : undefined}
>
<NavLink
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
</div>
);
})}
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
@@ -406,5 +729,21 @@ export default function Sidebar({
</OverlayScrollArea>
</nav>
</aside>
{navDndTrashHint != null &&
createPortal(
<div
className="sidebar-nav-dnd-trash-hint"
style={{
position: 'fixed',
left: navDndTrashHint.x + 14,
top: navDndTrashHint.y + 14,
}}
aria-hidden
>
<Trash2 size={22} strokeWidth={2.25} />
</div>,
document.body,
)}
</>
);
}
+4 -19
View File
@@ -35,6 +35,7 @@ import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig }
import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { applySidebarDropReorder } from '../utils/sidebarNavReorder';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import {
ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser,
@@ -4420,29 +4421,13 @@ function SidebarCustomizer() {
const fromSection = parsed.section as 'library' | 'system';
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target || target.section !== fromSection) return;
const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems];
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
const [moved] = sectionItems.splice(fromIdx, 1);
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
// Merge reordered section back into flat items array.
// Only update positions of the *visible* items (same filter as libraryItems/systemItems)
// so hidden entries like randomMix/randomAlbums are never overwritten with undefined.
const all = [...itemsRef.current];
const visibleIds = new Set(sectionItems.map(c => c.id));
const positions = all.map((cfg, i) => ({ cfg, i }))
.filter(({ cfg }) => visibleIds.has(cfg.id))
.map(({ i }) => i);
positions.forEach((pos, i) => { all[pos] = sectionItems[i]; });
setItems(all);
const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode);
if (next) setItems(next);
};
el.addEventListener('psy-drop', onPsyDrop);
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [libraryItems, systemItems, setItems]);
}, [libraryItems, systemItems, setItems, randomNavMode]);
const handleMouseMove = (e: React.MouseEvent) => {
if (!isPsyDragging || !containerRef.current) return;
+47
View File
@@ -435,6 +435,53 @@ body.is-overlay-scrollbar-thumb-drag .resizer {
padding: var(--space-4) var(--space-3) var(--space-2);
}
/* Long-press reorder targets (library / system blocks in Sidebar) */
.sidebar-nav-dnd-row {
position: relative;
border-radius: var(--radius-md);
}
.sidebar-nav-dnd-row--dragging {
opacity: 0.45;
}
.sidebar-nav-dnd-row--drop-before::before,
.sidebar-nav-dnd-row--drop-after::after {
content: '';
position: absolute;
left: 6px;
right: 6px;
height: 2px;
background: var(--accent);
border-radius: 1px;
pointer-events: none;
z-index: 3;
}
.sidebar-nav-dnd-row--drop-before::before {
top: 0;
}
.sidebar-nav-dnd-row--drop-after::after {
bottom: 0;
}
/* Drag-out-of-sidebar: hide item — trash follows pointer */
.sidebar-nav-dnd-trash-hint {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
pointer-events: none;
z-index: 100002;
color: var(--danger, #e05555);
background: color-mix(in srgb, var(--danger, #e05555) 18%, var(--bg-card, #1e1e2e));
border: 1px solid color-mix(in srgb, var(--danger, #e05555) 45%, transparent);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
.nav-link {
display: flex;
align-items: center;
+67
View File
@@ -0,0 +1,67 @@
import { ALL_NAV_ITEMS } from '../config/navItems';
import type { SidebarItemConfig } from '../store/sidebarStore';
export type SidebarNavSection = 'library' | 'system';
export type SidebarNavDropTarget = {
idx: number;
before: boolean;
section: SidebarNavSection;
};
export function getLibraryItemsForReorder(
items: SidebarItemConfig[],
randomNavMode: 'hub' | 'separate',
): SidebarItemConfig[] {
return items.filter(cfg => {
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
return true;
});
}
export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarItemConfig[] {
return items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
}
/** Same entries as in Settings toggles — safe to hide via drag-out. */
export function isSidebarNavItemUserHideable(id: string): boolean {
return Boolean(ALL_NAV_ITEMS[id]);
}
/**
* Reorders one sidebar section (library or system) like the Settings customizer.
* Returns a new `items` array, or null if nothing changes.
*/
export function applySidebarDropReorder(
allItems: SidebarItemConfig[],
section: SidebarNavSection,
fromIdx: number,
target: SidebarNavDropTarget | null,
randomNavMode: 'hub' | 'separate',
): SidebarItemConfig[] | null {
if (!target || target.section !== section) return null;
const sectionItems =
section === 'library'
? [...getLibraryItemsForReorder(allItems, randomNavMode)]
: [...getSystemItemsForReorder(allItems)];
const insertBefore = target.before ? target.idx : target.idx + 1;
if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return null;
const [moved] = sectionItems.splice(fromIdx, 1);
sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
const visibleIds = new Set(sectionItems.map(c => c.id));
const next = [...allItems];
const positions = next
.map((cfg, i) => ({ cfg, i }))
.filter(({ cfg }) => visibleIds.has(cfg.id))
.map(({ i }) => i);
positions.forEach((pos, i) => {
next[pos] = sectionItems[i];
});
return next;
}