Files
3x-ui/frontend/src/hooks/usePointerRowReorder.ts
T
Hamed 136c1c4d50 feat(hosts): add drag-and-drop and global subscription ordering (#6895)
Hosts already had one saved order on the Hosts page, but subscriptions honoured
it only inside each inbound, so a host of a later inbound could never come before
one of an earlier inbound (#6894). A new opt-in setting makes raw/base64,
Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds;
the default keeps today's inbound-first output unchanged. The Hosts table gains
drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook
the Routing Rules table now shares.
2026-10-24 07:27:47 +02:00

110 lines
3.8 KiB
TypeScript

import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
export interface PointerRowDrop<TKey extends string | number> {
from: TKey;
to: TKey;
after: boolean;
}
interface PointerRowDrag<TKey extends string | number> {
from: TKey;
to: TKey | null;
after: boolean;
}
interface PointerRowReorderOptions<TKey extends string | number> {
containerRef: RefObject<HTMLElement | null>;
getRowKey: (row: HTMLElement) => TKey | null;
disabled?: boolean;
isNoop?: (drop: PointerRowDrop<TKey>) => boolean;
onDrop: (drop: PointerRowDrop<TKey>) => void;
}
// Rows expose data-row-key; consumers retain their own insertion and persistence rules.
export function usePointerRowReorder<TKey extends string | number>(
options: PointerRowReorderOptions<TKey>,
) {
const [drag, setDrag] = useState<PointerRowDrag<TKey> | null>(null);
const latest = useRef(options);
const cleanupRef = useRef<(() => void) | null>(null);
useLayoutEffect(() => {
latest.current = options;
if (cleanupRef.current && options.disabled) {
cleanupRef.current();
setDrag(null);
}
}, [options]);
useLayoutEffect(() => () => cleanupRef.current?.(), []);
const onPointerDown = useCallback((from: TKey, event: ReactPointerEvent<Element>) => {
const current = latest.current;
if (current.disabled || cleanupRef.current || event.isPrimary === false || event.button !== 0)
return;
event.preventDefault();
const handle = event.currentTarget;
const doc = handle.ownerDocument;
const pointerId = event.pointerId;
const startY = event.clientY;
let active = false;
const targetAt = (e: PointerEvent): PointerRowDrop<TKey> | null => {
const row = doc
.elementFromPoint(e.clientX, e.clientY)
?.closest<HTMLElement>('[data-row-key]');
const config = latest.current;
if (!row || !config.containerRef.current?.contains(row)) return null;
const to = config.getRowKey(row);
if (to === null) return null;
const rect = row.getBoundingClientRect();
return { from, to, after: e.clientY > rect.top + rect.height / 2 };
};
const move = (e: PointerEvent) => {
if (e.pointerId !== pointerId || (!active && Math.abs(e.clientY - startY) < 5)) return;
active = true;
setDrag(targetAt(e) ?? { from, to: null, after: false });
};
const cleanup = () => {
doc.removeEventListener('pointermove', move);
doc.removeEventListener('pointerup', up);
doc.removeEventListener('pointercancel', cancel);
handle.removeEventListener('lostpointercapture', cancel);
cleanupRef.current = null;
try {
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
} catch {
// Capture may already be released when the handle is detached.
}
};
const cancel = (e: Event) => {
if (!('pointerId' in e) || e.pointerId !== pointerId) return;
cleanup();
setDrag(null);
};
const up = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const drop =
active && latest.current.containerRef.current?.contains(handle) ? targetAt(e) : null;
cleanup();
setDrag(null);
const config = latest.current;
if (!drop || config.disabled || drop.from === drop.to || config.isNoop?.(drop)) return;
config.onDrop(drop);
};
cleanupRef.current = cleanup;
doc.addEventListener('pointermove', move);
doc.addEventListener('pointerup', up);
doc.addEventListener('pointercancel', cancel);
handle.addEventListener('lostpointercapture', cancel);
try {
handle.setPointerCapture?.(pointerId);
} catch {
// Detached handles and synthetic pointers may not support capture.
}
}, []);
return { drag, onPointerDown };
}