mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(hooks): move generic scroll/pagination/dnd UI hooks into lib/hooks
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useAsyncInpagePagination } from '@/lib/hooks/useAsyncInpagePagination';
|
||||
|
||||
describe('useAsyncInpagePagination', () => {
|
||||
it('blocks concurrent page requests', async () => {
|
||||
let resolveLoad: (() => void) | undefined;
|
||||
const onPage = vi.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
resolveLoad = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAsyncInpagePagination(30));
|
||||
|
||||
act(() => {
|
||||
expect(result.current.requestNextPage(onPage)).toBe(true);
|
||||
expect(result.current.requestNextPage(onPage)).toBe(false);
|
||||
});
|
||||
|
||||
expect(onPage).toHaveBeenCalledTimes(1);
|
||||
expect(onPage).toHaveBeenCalledWith(30);
|
||||
|
||||
await act(async () => {
|
||||
resolveLoad?.();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.page).toBe(1);
|
||||
});
|
||||
|
||||
it('resetPage clears guards and page index', () => {
|
||||
const { result } = renderHook(() => useAsyncInpagePagination(30));
|
||||
|
||||
act(() => {
|
||||
result.current.pageRef.current = 3;
|
||||
result.current.loadPendingRef.current = true;
|
||||
result.current.setPage(3);
|
||||
result.current.resetPage();
|
||||
});
|
||||
|
||||
expect(result.current.page).toBe(0);
|
||||
expect(result.current.pageRef.current).toBe(0);
|
||||
expect(result.current.loadPendingRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type UseAsyncInpagePaginationOptions = {
|
||||
/** Initial `loading` state (e.g. true when the first fetch runs on mount). */
|
||||
initialLoading?: boolean;
|
||||
};
|
||||
|
||||
/** Sync guards for offset-based server pagination inside in-page scroll areas. */
|
||||
export function useAsyncInpagePagination(
|
||||
pageSize: number,
|
||||
options?: UseAsyncInpagePaginationOptions,
|
||||
) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(options?.initialLoading ?? false);
|
||||
const pageRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const loadPendingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
pageRef.current = page;
|
||||
}, [page]);
|
||||
|
||||
const isBlocked = useCallback(
|
||||
() => loadingRef.current || loadPendingRef.current,
|
||||
[],
|
||||
);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
pageRef.current = 0;
|
||||
loadPendingRef.current = false;
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
const runLoad = useCallback(async (fn: () => Promise<void>) => {
|
||||
loadingRef.current = true;
|
||||
loadPendingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
loadPendingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestNextPage = useCallback(
|
||||
(onPage: (offset: number) => void | Promise<void>) => {
|
||||
if (isBlocked()) return false;
|
||||
loadPendingRef.current = true;
|
||||
const next = pageRef.current + 1;
|
||||
pageRef.current = next;
|
||||
setPage(next);
|
||||
void onPage(next * pageSize);
|
||||
return true;
|
||||
},
|
||||
[isBlocked, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
page,
|
||||
setPage,
|
||||
loading,
|
||||
setLoading,
|
||||
pageRef,
|
||||
loadingRef,
|
||||
loadPendingRef,
|
||||
isBlocked,
|
||||
resetPage,
|
||||
runLoad,
|
||||
requestNextPage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAsyncInpagePagination } from '@/lib/hooks/useAsyncInpagePagination';
|
||||
import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel';
|
||||
|
||||
export type UseAsyncInpageScrollArgs = {
|
||||
pageSize: number;
|
||||
active: boolean;
|
||||
hasMore: boolean;
|
||||
getScrollRoot?: () => HTMLElement | null;
|
||||
scrollRootEl?: HTMLElement | null;
|
||||
rootMargin?: string;
|
||||
onLoadOffset: (offset: number, append: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Offset-based server pagination with stable in-page sentinel wiring.
|
||||
*/
|
||||
export function useAsyncInpageScroll({
|
||||
pageSize,
|
||||
active,
|
||||
hasMore,
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
rootMargin,
|
||||
onLoadOffset,
|
||||
}: UseAsyncInpageScrollArgs) {
|
||||
const paging = useAsyncInpagePagination(pageSize);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!active || !hasMore || paging.isBlocked()) return;
|
||||
paging.requestNextPage(offset => onLoadOffset(offset, true));
|
||||
}, [active, hasMore, onLoadOffset, paging]);
|
||||
|
||||
const bindSentinel = useInpageScrollSentinel({
|
||||
active: active && hasMore,
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
rootMargin,
|
||||
onIntersect: loadMore,
|
||||
});
|
||||
|
||||
return {
|
||||
...paging,
|
||||
bindSentinel,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useClientSliceInfiniteScroll } from '@/lib/hooks/useClientSliceInfiniteScroll';
|
||||
|
||||
vi.mock('@/lib/hooks/useInpageScrollSentinel', () => ({
|
||||
useInpageScrollSentinel: () => vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useClientSliceInfiniteScroll', () => {
|
||||
it('grows visibleCount by pageSize and clears loadingMore', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ filter }: { filter: string }) =>
|
||||
useClientSliceInfiniteScroll({
|
||||
pageSize: 50,
|
||||
resetDeps: [filter],
|
||||
}),
|
||||
{ initialProps: { filter: '' } },
|
||||
);
|
||||
|
||||
expect(result.current.visibleCount).toBe(50);
|
||||
|
||||
act(() => {
|
||||
result.current.loadMore();
|
||||
});
|
||||
expect(result.current.visibleCount).toBe(100);
|
||||
|
||||
rerender({ filter: 'a' });
|
||||
expect(result.current.visibleCount).toBe(50);
|
||||
expect(result.current.loadingMore).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel';
|
||||
|
||||
export type UseClientSliceInfiniteScrollArgs = {
|
||||
pageSize: number;
|
||||
resetDeps: ReadonlyArray<unknown>;
|
||||
getScrollRoot?: () => HTMLElement | null;
|
||||
scrollRootEl?: HTMLElement | null;
|
||||
rootMargin?: string;
|
||||
/** One-shot bootstrap when restoring browse scroll (All Albums back navigation). */
|
||||
restoreDisplayCount?: number;
|
||||
};
|
||||
|
||||
function sliceVisibleCount(pageSize: number, restoreDisplayCount?: number): number {
|
||||
if (restoreDisplayCount == null || restoreDisplayCount <= 0) return pageSize;
|
||||
return Math.max(pageSize, restoreDisplayCount);
|
||||
}
|
||||
|
||||
export type UseClientSliceInfiniteScrollResult = {
|
||||
visibleCount: number;
|
||||
loadingMore: boolean;
|
||||
bindSentinel: ReturnType<typeof useInpageScrollSentinel>;
|
||||
/** @deprecated Use `bindSentinel`. */
|
||||
observerTarget: ReturnType<typeof useInpageScrollSentinel>;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Client-side infinite scroll: grow a visible slice from an in-memory list.
|
||||
* Used by Artists and Composers browse grids.
|
||||
*/
|
||||
export function useClientSliceInfiniteScroll({
|
||||
pageSize,
|
||||
resetDeps,
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
rootMargin = '200px',
|
||||
restoreDisplayCount,
|
||||
}: UseClientSliceInfiniteScrollArgs): UseClientSliceInfiniteScrollResult {
|
||||
const [visibleCount, setVisibleCount] = useState(() =>
|
||||
sliceVisibleCount(pageSize, restoreDisplayCount),
|
||||
);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loadPendingRef = useRef(false);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadPendingRef.current) return;
|
||||
loadPendingRef.current = true;
|
||||
setLoadingMore(true);
|
||||
setVisibleCount(prev => prev + pageSize);
|
||||
}, [pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingRef.current = false;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoadingMore(false);
|
||||
}, [visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVisibleCount(sliceVisibleCount(pageSize, restoreDisplayCount));
|
||||
// resetDeps is intentionally spread into the dep array.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageSize, restoreDisplayCount, ...resetDeps]);
|
||||
|
||||
const bindSentinel = useInpageScrollSentinel({
|
||||
active: true,
|
||||
getScrollRoot: () =>
|
||||
getScrollRoot?.() ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
|
||||
scrollRootEl,
|
||||
rootMargin,
|
||||
onIntersect: loadMore,
|
||||
});
|
||||
|
||||
return {
|
||||
visibleCount,
|
||||
loadingMore,
|
||||
bindSentinel,
|
||||
observerTarget: bindSentinel,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/** `Event.target` may be a Text node — only Elements expose `closest` / `tagName`. */
|
||||
function eventTargetElement(e: Event): Element | null {
|
||||
const t = e.target;
|
||||
if (!t) return null;
|
||||
if (t instanceof Element) return t;
|
||||
if (!(t instanceof Node)) return null;
|
||||
const parent = t.parentNode;
|
||||
return parent instanceof Element ? parent : null;
|
||||
}
|
||||
|
||||
function isTextInputElement(el: Element): boolean {
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Globally tame Linux/WebKitGTK + Wayland behaviour that the page-level
|
||||
* CSS / Tauri config can't reach:
|
||||
*
|
||||
* - dragover/dragenter: WebKitGTK shows a permanent "forbidden" cursor
|
||||
* for external drops unless we preventDefault and force dropEffect.
|
||||
* - drop on document: block so an OS-file-manager drag doesn't navigate
|
||||
* the webview away from the app.
|
||||
* - dragstart (capture): cancel native drags from in-page elements (e.g.
|
||||
* SVG grips) — on Wayland these can leave a stuck GTK drag-proxy.
|
||||
* In-app moves go through psy-drag (mouse events), unaffected.
|
||||
* - Ctrl/Cmd+A: WebKit ignores `user-select: none` for keyboard
|
||||
* shortcuts. Still allow select-all inside real text fields.
|
||||
* - selectstart: same story for mouse-drag selection. Allow inside
|
||||
* inputs / textareas / contentEditable and explicit `[data-selectable]`
|
||||
* regions (cover info, etc.).
|
||||
*
|
||||
* Harmless on Windows/macOS.
|
||||
*/
|
||||
export function useGlobalDndAndSelectionBlockers(): void {
|
||||
useEffect(() => {
|
||||
const allow = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
|
||||
|
||||
const blockSelectAll = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
const el = eventTargetElement(e);
|
||||
if (el && isTextInputElement(el)) return;
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const blockSelectStart = (e: Event) => {
|
||||
const el = eventTargetElement(e);
|
||||
if (!el) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isTextInputElement(el)) return;
|
||||
if (el.closest('[data-selectable]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const blockDragStart = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('dragover', allow);
|
||||
document.addEventListener('dragenter', allow);
|
||||
document.addEventListener('drop', blockDrop);
|
||||
document.addEventListener('dragstart', blockDragStart, true);
|
||||
document.addEventListener('keydown', blockSelectAll, true);
|
||||
document.addEventListener('selectstart', blockSelectStart);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragover', allow);
|
||||
document.removeEventListener('dragenter', allow);
|
||||
document.removeEventListener('drop', blockDrop);
|
||||
document.removeEventListener('dragstart', blockDragStart, true);
|
||||
document.removeEventListener('keydown', blockSelectAll, true);
|
||||
document.removeEventListener('selectstart', blockSelectStart);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel';
|
||||
|
||||
describe('useInpageScrollSentinel', () => {
|
||||
it('returns a callback ref function', () => {
|
||||
const onIntersect = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useInpageScrollSentinel({
|
||||
active: true,
|
||||
onIntersect,
|
||||
}),
|
||||
);
|
||||
expect(typeof result.current).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject, type RefCallback } from 'react';
|
||||
|
||||
const DEFAULT_ROOT_MARGIN = '400px';
|
||||
|
||||
export type UseInpageScrollSentinelArgs = {
|
||||
/** When false, disconnect and ignore the sentinel. */
|
||||
active: boolean;
|
||||
getScrollRoot?: () => HTMLElement | null;
|
||||
/** Rebind when the in-page scroll viewport mounts (callback-ref body). */
|
||||
scrollRootEl?: HTMLElement | null;
|
||||
onIntersect: () => void;
|
||||
rootMargin?: string;
|
||||
/** Re-fire `onIntersect` when this changes and the sentinel is still visible. */
|
||||
drainSignal?: unknown;
|
||||
/** Updated when the sentinel enters/leaves the scroll root viewport. */
|
||||
intersectingRef?: MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable IntersectionObserver callback ref for in-page infinite scroll.
|
||||
* Matches {@link useClientSliceInfiniteScroll} — avoids reconnect storms when
|
||||
* `onIntersect` / `loadMore` identities change every render.
|
||||
*/
|
||||
export function useInpageScrollSentinel({
|
||||
active,
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
onIntersect,
|
||||
rootMargin = DEFAULT_ROOT_MARGIN,
|
||||
drainSignal,
|
||||
intersectingRef,
|
||||
}: UseInpageScrollSentinelArgs): RefCallback<HTMLDivElement | null> {
|
||||
const onIntersectRef = useRef(onIntersect);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
onIntersectRef.current = onIntersect;
|
||||
|
||||
const setIntersecting = useCallback((hit: boolean) => {
|
||||
if (intersectingRef) intersectingRef.current = hit;
|
||||
}, [intersectingRef]);
|
||||
|
||||
const observerInst = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const bindSentinel = useCallback((node: HTMLDivElement | null) => {
|
||||
observerInst.current?.disconnect();
|
||||
observerInst.current = null;
|
||||
if (!node) {
|
||||
setIntersecting(false);
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
setIntersecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootEl = getScrollRoot?.() ?? null;
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
const hit = Boolean(entries[0]?.isIntersecting);
|
||||
setIntersecting(hit);
|
||||
if (hit) onIntersectRef.current();
|
||||
},
|
||||
{
|
||||
root: rootEl instanceof HTMLElement ? rootEl : null,
|
||||
rootMargin,
|
||||
},
|
||||
);
|
||||
observer.observe(node);
|
||||
observerInst.current = observer;
|
||||
// scrollRootEl is an intentional re-create trigger: when the resolved scroll
|
||||
// root element changes the sentinel must re-bind its observer to the new root,
|
||||
// even though the body reads it via getScrollRoot() rather than directly.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, getScrollRoot, scrollRootEl, rootMargin, setIntersecting]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = observerInst.current;
|
||||
if (!observer || !active) return;
|
||||
for (const entry of observer.takeRecords()) {
|
||||
const hit = entry.isIntersecting;
|
||||
setIntersecting(hit);
|
||||
if (hit) onIntersectRef.current();
|
||||
}
|
||||
}, [active, drainSignal, setIntersecting]);
|
||||
|
||||
useEffect(() => () => {
|
||||
observerInst.current?.disconnect();
|
||||
observerInst.current = null;
|
||||
}, []);
|
||||
|
||||
return bindSentinel;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
/** In-page overlay scroll viewport ref + state for IntersectionObserver roots. */
|
||||
export function useInpageScrollViewport() {
|
||||
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
const bindScrollBody = useCallback((el: HTMLDivElement | null) => {
|
||||
scrollBodyRef.current = el;
|
||||
setScrollBodyEl(el);
|
||||
}, []);
|
||||
const getScrollRoot = useCallback(() => scrollBodyRef.current, []);
|
||||
return { scrollBodyRef, scrollBodyEl, bindScrollBody, getScrollRoot };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDragDrop } from '@/lib/dnd/DragDropContext';
|
||||
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
|
||||
|
||||
interface Options {
|
||||
/** Payload discriminator the drag source emits, e.g. `'lyrics_source_reorder'`. */
|
||||
type: string;
|
||||
/**
|
||||
* Apply the move. Receives the dragged row id and the resolved drop target.
|
||||
* Consumers own the actual list mutation (store-specific). Memoise this
|
||||
* (`useCallback`) so the drop listener does not re-bind every render.
|
||||
*/
|
||||
apply: (draggedId: string, target: ListReorderDropTarget) => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
isDragging: boolean;
|
||||
/** Attach to the rows' container: `ref={setContainer}`. */
|
||||
setContainer: (el: HTMLElement | null) => void;
|
||||
/** Attach to the rows' container: `onMouseMove={onMouseMove}`. */
|
||||
onMouseMove: (e: React.MouseEvent) => void;
|
||||
/** Which edge (if any) of row `id` should show the drop indicator. */
|
||||
dropEdge: (id: string) => 'before' | 'after' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-to-reorder wiring shared by the customizer panels. Tracks the hovered
|
||||
* drop target, listens for the `psy-drop` event, and resolves the target by
|
||||
* **stable id** (read from `data-reorder-id`, with optional
|
||||
* `data-reorder-section`). The actual reorder is delegated to `apply`, keeping
|
||||
* this hook list-agnostic. Pair with `ReorderGripHandle` on each row and
|
||||
* `applyListReorderById` (or a section-aware variant) inside `apply`.
|
||||
*/
|
||||
export function useListReorderDnd({ type, apply }: Options): Result {
|
||||
const { isDragging } = useDragDrop();
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<ListReorderDropTarget | null>(null);
|
||||
const dropTargetRef = useRef<ListReorderDropTarget | null>(null);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
dropTargetRef.current = dropTarget;
|
||||
|
||||
// Clear the drop indicator as soon as any drag ends.
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: local state synced with the drag input.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!isDragging) { dropTargetRef.current = null; setDropTarget(null); }
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) return;
|
||||
const onPsyDrop = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.data) return;
|
||||
let parsed: { type?: string; id?: string; section?: string };
|
||||
try { parsed = JSON.parse(detail.data as string); } catch { return; }
|
||||
if (parsed.type !== type || !parsed.id) return;
|
||||
|
||||
const target = dropTargetRef.current;
|
||||
dropTargetRef.current = null; setDropTarget(null);
|
||||
if (!target) return;
|
||||
apply(parsed.id, target);
|
||||
};
|
||||
container.addEventListener('psy-drop', onPsyDrop);
|
||||
return () => container.removeEventListener('psy-drop', onPsyDrop);
|
||||
}, [container, type, apply]);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging || !container) return;
|
||||
const rows = container.querySelectorAll<HTMLElement>('[data-reorder-id]');
|
||||
let target: ListReorderDropTarget | null = null;
|
||||
for (const row of rows) {
|
||||
const id = row.dataset.reorderId;
|
||||
if (!id) continue;
|
||||
const section = row.dataset.reorderSection;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
target = section ? { id, before, section } : { id, before };
|
||||
if (before) break;
|
||||
}
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
}, [isDragging, container]);
|
||||
|
||||
const dropEdge = useCallback((id: string): 'before' | 'after' | null => {
|
||||
if (!isDragging || dropTarget?.id !== id) return null;
|
||||
return dropTarget.before ? 'before' : 'after';
|
||||
}, [isDragging, dropTarget]);
|
||||
|
||||
return { isDragging, setContainer, onMouseMove, dropEdge };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const TIGHT_AFTER_PX = 10;
|
||||
const LOOSE_BELOW_PX = 2;
|
||||
|
||||
/**
|
||||
* Compact the browse toolbar when the in-page overlay viewport scrolls down,
|
||||
* same thresholds as Artists (`> 10` tight, `< 2` loose).
|
||||
*/
|
||||
export function useMainstageInpageHeaderTight(
|
||||
scrollBodyEl: HTMLElement | null,
|
||||
resetDeps: ReadonlyArray<unknown>,
|
||||
): boolean {
|
||||
const [tight, setTight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollBodyEl) return;
|
||||
const el = scrollBodyEl;
|
||||
const onScroll = () => {
|
||||
const y = el.scrollTop;
|
||||
setTight(prev => {
|
||||
if (y > TIGHT_AFTER_PX) return true;
|
||||
if (y < LOOSE_BELOW_PX) return false;
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [scrollBodyEl]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTight(false);
|
||||
// Spread values so deps track filter keys, not a new array identity each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...resetDeps]);
|
||||
|
||||
return tight;
|
||||
}
|
||||
Reference in New Issue
Block a user