Deleting a note moved everything else. A row leaving is the same problem as a row arriving, in reverse: a line's position counts the rows above it, so a note deleted above a line left the line counting rows that were no longer there, and everything under it appeared to jump up past it. withoutRow renumbers the list a row leaves, and every place one leaves says so -- the bin, a move, a chapter being removed. Arrivals land at the top: an old position belongs to an arrangement that has moved on. A line could not be dragged under another line. Two lines in the same gap carry the same position, and the comparator claimed "a separator comes first" without asking what the other one was -- both directions at once for that case. A comparator that contradicts itself has no defined result, so the sort returned them in whichever order it happened to walk. A chapter is always in the user's own order and can be arranged like any other list: its notes wear handles under the same switch as the list beside them and are dragged among themselves, strips at both ends included. The two lists keep apart by scope -- a chapter unfolded under its row holds rows inside a row of the notebook's list, so the nearest row above the pointer cannot say which list a drag belongs to. The sort menu is gone inside a chapter: the mode belongs to the notebook, and a menu there would offer a choice that cannot be honoured. The chapters suite no longer assumes the first chapter row is the one that matters. A chapter is a row in the arrangement and can be anywhere in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
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<string | null>(null);
|
|
const [intoId, setIntoId] = useState<string | null>(null);
|
|
const [draggingId, setDraggingId] = useState<string | null>(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<string | null>(null);
|
|
const intoRef = useRef<string | null>(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": "" },
|
|
};
|
|
}
|