import { useCallback, useEffect, useRef, useState } from "react"; /** * Drag-to-reorder that works with a finger. * * The list used to use the HTML5 drag-and-drop API, which mobile browsers never * fire from touch — so on a phone the arrangement simply could not be changed, * with nothing on screen to say why. This uses pointer events instead, which * mouse, pen and finger all produce. * * The two devices want different things, so they get different starts: * * - **With a mouse**, pressing anywhere on a row and moving a few pixels begins * the drag. That is what a mouse has always done here. * - **With a finger**, only the grip begins it. Pressing the row itself has to * go on scrolling the list, and a long press has to go on opening the menu — * both would be lost to a drag that started from anywhere. */ /** Pixels a mouse must travel before a press counts as a drag rather than a click. */ const MOUSE_THRESHOLD = 5; export interface ReorderDrag { /** The row the pointer is over, for the drop indicator. */ overId: string | null; /** A row that would *take* what is being carried, rather than sit next to it. */ intoId: string | null; /** The row being carried, so it can be shown as lifted. */ draggingId: string | null; /** * Spread onto each row; `id` is what gets reordered. * * `into` marks a row that is a place rather than a neighbour — a chapter. * Dropping on one files what is carried into it instead of putting it above. */ rowProps: ( id: string, into?: boolean ) => { "data-reorder-id": string; "data-reorder-scope": string; "data-reorder-into"?: string; onPointerDown: (e: React.PointerEvent) => void; }; /** Spread onto the grip inside a row. */ gripProps: { "data-reorder-grip": string }; } /** * `scope` names the list these rows belong to, and is what keeps two lists on * the same screen out of each other's way: a chapter unfolded under its row * holds rows *inside* a row of the notebook's list, so "the nearest row above * the pointer" is not enough to say which list is being dragged in. Rows are * looked up by scope, and a note carried in the notebook simply finds no target * over the notes inside a chapter. */ export function useReorderDrag( enabled: boolean, reorder: (dragId: string, targetId: string) => void, fileInto?: (dragId: string, targetId: string) => void, scope = "" ): ReorderDrag { const [overId, setOverId] = useState(null); const [intoId, setIntoId] = useState(null); const [draggingId, setDraggingId] = useState(null); // Everything the pointer handlers need, kept out of state: they run far more // often than anything needs to be redrawn. const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null); const overRef = useRef(null); const intoRef = useRef(null); const stop = useCallback(() => { drag.current = null; overRef.current = null; intoRef.current = null; setDraggingId(null); setOverId(null); setIntoId(null); document.body.style.removeProperty("user-select"); }, []); const onPointerDown = useCallback( (id: string) => (e: React.PointerEvent) => { if (!enabled || e.button > 0) return; /* Only presses that belong to this list. A chapter's rows sit inside a row of the notebook's list, so its events travel up through one — and a grip pressed in the chapter would otherwise pick up the chapter's whole row as well. The nearest scoped row decides whose press it is. */ const own = (e.target as HTMLElement)?.closest?.("[data-reorder-scope]") as HTMLElement | null; if (own && own.dataset.reorderScope !== scope) return; const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]"); // A finger on the row itself is a scroll or a long press, not a drag. if (e.pointerType !== "mouse" && !fromGrip) return; drag.current = { id, x: e.clientX, y: e.clientY, active: fromGrip, fromGrip }; if (fromGrip) { setDraggingId(id); document.body.style.userSelect = "none"; // The grip owns the gesture from here, so the list must not also scroll. e.preventDefault(); } }, [enabled, scope] ); useEffect(() => { if (!enabled) return; const move = (e: PointerEvent) => { const d = drag.current; if (!d) return; if (!d.active) { if (Math.abs(e.clientX - d.x) + Math.abs(e.clientY - d.y) < MOUSE_THRESHOLD) return; d.active = true; setDraggingId(d.id); document.body.style.userSelect = "none"; } // Which row is under the finger — asked of the document rather than // tracked with enter/leave handlers, because a finger generates no // pointerover on anything it did not press. const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; const row = el?.closest?.(`[data-reorder-scope="${scope}"]`) as HTMLElement | null; const target = row?.dataset.reorderId ?? null; const next = target && target !== d.id ? target : null; /* A chapter is both a place and a neighbour, so which one it is depends on * where in the row the pointer is. * * Anywhere on the row meant "into", and that quietly took reordering away: * a chapter could no longer be moved among its neighbours, and nothing — * note or line — could be put in the gap under one. The bottom edge is * that gap; the body of the row is the chapter itself and takes what is * carried in. The indicator says which: a line in the gap, the whole row * lit up when it is about to take something. */ let into: string | null = null; if (next && row && row.dataset.reorderInto !== undefined && fileInto) { // The header, not the row: an open chapter's row includes the notes // inside it, and an edge measured on *that* would be half a notebook. const head = (row.querySelector("[data-reorder-head]") as HTMLElement | null) || row; const rect = head.getBoundingClientRect(); const edge = Math.min(10, rect.height / 3); // Only that band is the gap. Below it lie the chapter's own notes, and // holding over them plainly means the chapter. const inGap = e.clientY >= rect.bottom - edge && e.clientY <= rect.bottom; if (!inGap) into = next; } if (into !== intoRef.current) { intoRef.current = into; setIntoId(into); } const beside = into ? null : next; if (beside !== overRef.current) { overRef.current = beside; setOverId(beside); } if (e.cancelable) e.preventDefault(); }; const up = () => { const d = drag.current; const target = overRef.current; const into = intoRef.current; if (d?.active && into && into !== d.id) fileInto?.(d.id, into); else if (d?.active && target && target !== d.id) reorder(d.id, target); stop(); }; window.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", stop); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", stop); }; }, [enabled, reorder, fileInto, scope, stop]); // A drag that outlives its list would leave the page unselectable. useEffect( () => () => { document.body.style.removeProperty("user-select"); }, [] ); return { overId, intoId, draggingId, rowProps: (id: string, into = false) => ({ "data-reorder-id": id, "data-reorder-scope": scope, ...(into ? { "data-reorder-into": "" } : {}), onPointerDown: onPointerDown(id), }), gripProps: { "data-reorder-grip": "" }, }; }