Files
InkNote/frontend/src/hooks/useReorderDrag.ts
T
marcandClaude Opus 5 a9391cdcf2 One direction to drop in, and a new note that stays on top
Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it near the top by age --
which is not the same thing. A separator's position counts the rows above
it, so a row appearing without a renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only there.
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:45:16 +02:00

181 lines
6.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-into"?: string;
onPointerDown: (e: React.PointerEvent) => void;
};
/** Spread onto the grip inside a row. */
gripProps: { "data-reorder-grip": string };
}
export function useReorderDrag(
enabled: boolean,
reorder: (dragId: string, targetId: string) => void,
fileInto?: (dragId: string, targetId: string) => void
): 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;
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]
);
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-id]") 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, 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,
...(into ? { "data-reorder-into": "" } : {}),
onPointerDown: onPointerDown(id),
}),
gripProps: { "data-reorder-grip": "" },
};
}