Files
Psychotoxical-psysonic/src/features/queue/hooks/useQueueResizer.ts
T
Psychotoxical d5bbabac1d refactor(queue): co-locate QueuePanel UI into features/queue
Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).

Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.

Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
2026-06-30 08:40:51 +02:00

145 lines
5.0 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
interface UseQueueResizerArgs {
isMobile: boolean;
isSidebarCollapsed: boolean;
isQueueVisible: boolean;
toggleQueue: () => void;
}
interface UseQueueResizerResult {
queueWidth: number;
isDraggingQueue: boolean;
setIsDraggingQueue: React.Dispatch<React.SetStateAction<boolean>>;
queueHandleTop: number | null;
handleQueueHandleMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
const QUEUE_MIN_WIDTH = 310;
const QUEUE_MAX_WIDTH = 500;
const QUEUE_DEFAULT_WIDTH = 340;
const DRAG_THRESHOLD_PX = 4;
/**
* State + handlers for the queue panel's vertical resizer:
* - `queueWidth` follows the resizer drag (clamped 310..500 px).
* - `queueHandleTop` tracks the y-center of the sidebar's collapse button
* so the queue handle visually aligns with it across resizes and
* sidebar collapse changes.
* - `handleQueueHandleMouseDown` distinguishes a click from a drag with a
* 4-pixel threshold so the handle can both toggle the queue and resize
* it without conflicting interaction modes.
*/
export function useQueueResizer({
isMobile,
isSidebarCollapsed,
isQueueVisible,
toggleQueue,
}: UseQueueResizerArgs): UseQueueResizerResult {
const [queueWidth, setQueueWidth] = useState(QUEUE_DEFAULT_WIDTH);
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
const [queueHandleTop, setQueueHandleTop] = useState<number | null>(null);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDraggingQueue) return;
const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - e.clientX, QUEUE_MAX_WIDTH));
setQueueWidth(newWidth);
}, [isDraggingQueue]);
const handleMouseUp = useCallback(() => {
setIsDraggingQueue(false);
}, []);
useEffect(() => {
if (isDraggingQueue) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.classList.add('is-dragging');
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'default';
document.body.classList.remove('is-dragging');
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.classList.remove('is-dragging');
};
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
const syncQueueHandleTop = useCallback(() => {
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
if (!leftBtn) return;
const r = leftBtn.getBoundingClientRect();
setQueueHandleTop(r.top + r.height / 2);
}, []);
useEffect(() => {
if (isMobile) return;
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
if (!leftBtn) return;
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
syncQueueHandleTop();
const raf = requestAnimationFrame(syncQueueHandleTop);
const onResize = () => syncQueueHandleTop();
window.addEventListener('resize', onResize);
const observer = new ResizeObserver(onResize);
observer.observe(leftBtn);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
observer.disconnect();
};
}, [isMobile, isSidebarCollapsed, syncQueueHandleTop]);
const handleQueueHandleMouseDown = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
let didDrag = false;
const cleanup = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp, true);
document.body.style.cursor = '';
document.body.classList.remove('is-dragging');
};
const applyWidthFromClientX = (clientX: number) => {
const newWidth = Math.max(QUEUE_MIN_WIDTH, Math.min(window.innerWidth - clientX, QUEUE_MAX_WIDTH));
setQueueWidth(newWidth);
};
const onMove = (me: MouseEvent) => {
const movedEnough = Math.hypot(me.clientX - startX, me.clientY - startY) >= DRAG_THRESHOLD_PX;
if (!didDrag && movedEnough) {
didDrag = true;
if (!isQueueVisible) toggleQueue();
document.body.style.cursor = 'col-resize';
document.body.classList.add('is-dragging');
}
if (!didDrag) return;
applyWidthFromClientX(me.clientX);
};
const onUp = () => {
cleanup();
if (!didDrag) toggleQueue();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp, true);
}, [isQueueVisible, toggleQueue]);
return { queueWidth, isDraggingQueue, setIsDraggingQueue, queueHandleTop, handleQueueHandleMouseDown };
}