- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { DragView } from "../components/ColumnView";
interface Options {
boardRef: RefObject<HTMLElement | null>;
isBlocked: (columnId: string, cardId: string) => boolean;
onDrop: (cardId: string, columnId: string, beforeId: string | null) => void;
onBlockedDrop: (cardId: string, columnId: string) => void;
onStart: (cardId: string) => void;
}
interface Session {
cardId: string;
pointerId: number;
startX: number;
startY: number;
offsetX: number;
offsetY: number;
x: number;
y: number;
width: number;
height: number;
source: HTMLElement;
active: boolean;
ghost: HTMLElement | null;
target: { columnId: string; beforeId: string | null; blocked: boolean } | null;
raf: number;
}
const THRESHOLD = 5;
const EDGE = 56;
/**
* Pointer (mouse / pen) drag and drop for cards, without a library.
* The ghost is a DOM clone that follows the pointer; the drop position is
* computed from column and card rectangles and rendered as a placeholder.
*/
export function usePointerDrag(options: Options) {
const [drag, setDrag] = useState<DragView | null>(null);
const opts = useRef(options);
opts.current = options;
const session = useRef<Session | null>(null);
const suppressClickUntil = useRef(0);
const computeTarget = useCallback((s: Session) => {
const board = opts.current.boardRef.current;
const boardRect = board?.getBoundingClientRect();
let found: Session["target"] = null;
if (boardRect && s.x >= boardRect.left && s.x <= boardRect.right) {
const columns = document.querySelectorAll<HTMLElement>("[data-column-id]");
for (const col of columns) {
const r = col.getBoundingClientRect();
if (s.x >= r.left && s.x <= r.right && s.y >= r.top - 48 && s.y <= r.bottom + 48) {
const columnId = col.dataset.columnId!;
let beforeId: string | null = null;
const cards = col.querySelectorAll<HTMLElement>("[data-column-list] [data-card-id]");
for (const card of cards) {
if (card.dataset.cardId === s.cardId) continue;
const cr = card.getBoundingClientRect();
if (cr.top + cr.height / 2 > s.y) {
beforeId = card.dataset.cardId!;
break;
}
}
found = { columnId, beforeId, blocked: opts.current.isBlocked(columnId, s.cardId) };
break;
}
}
}
const prev = s.target;
const changed =
prev?.columnId !== found?.columnId || prev?.beforeId !== found?.beforeId || prev?.blocked !== found?.blocked;
s.target = found;
if (changed) {
setDrag({
cardId: s.cardId,
overColumnId: found?.columnId ?? null,
beforeId: found?.beforeId ?? null,
blocked: found?.blocked ?? false,
height: s.height,
});
}
}, []);
const end = useCallback((commit: boolean) => {
const s = session.current;
if (!s) return;
session.current = null;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("blur", onCancel);
cancelAnimationFrame(s.raf);
s.ghost?.remove();
document.body.classList.remove("is-dragging");
if (!s.active) return;
suppressClickUntil.current = Date.now() + 250;
const t = s.target;
setDrag(null);
if (!commit || !t) return;
if (t.blocked) opts.current.onBlockedDrop(s.cardId, t.columnId);
else opts.current.onDrop(s.cardId, t.columnId, t.beforeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function tick() {
const s = session.current;
if (!s || !s.active) return;
let scrolled = false;
const board = opts.current.boardRef.current;
if (board) {
const r = board.getBoundingClientRect();
if (s.x < r.left + EDGE && board.scrollLeft > 0) {
board.scrollLeft -= Math.ceil((r.left + EDGE - s.x) / 4);
scrolled = true;
} else if (s.x > r.right - EDGE && board.scrollLeft + board.clientWidth < board.scrollWidth) {
board.scrollLeft += Math.ceil((s.x - (r.right - EDGE)) / 4);
scrolled = true;
}
}
if (s.target) {
const body = document.querySelector<HTMLElement>(`[data-column-id="${s.target.columnId}"] [data-column-scroll]`);
if (body) {
const r = body.getBoundingClientRect();
if (s.y < r.top + EDGE / 1.5 && s.y > r.top - 48 && body.scrollTop > 0) {
body.scrollTop -= 8;
scrolled = true;
} else if (s.y > r.bottom - EDGE / 1.5 && s.y < r.bottom + 48 && body.scrollTop + body.clientHeight < body.scrollHeight) {
body.scrollTop += 8;
scrolled = true;
}
}
}
if (scrolled) computeTarget(s);
s.raf = requestAnimationFrame(tick);
}
function start(s: Session) {
s.active = true;
const ghost = s.source.cloneNode(true) as HTMLElement;
ghost.removeAttribute("data-card-id");
ghost.removeAttribute("tabindex");
ghost.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
ghost.removeAttribute("aria-labelledby");
ghost.removeAttribute("aria-describedby");
ghost.setAttribute("aria-hidden", "true");
ghost.classList.add("drag-ghost");
ghost.style.width = `${s.width}px`;
ghost.style.transform = `translate(${s.x - s.offsetX}px, ${s.y - s.offsetY}px)`;
document.body.appendChild(ghost);
s.ghost = ghost;
document.body.classList.add("is-dragging");
window.getSelection()?.removeAllRanges();
opts.current.onStart(s.cardId);
computeTarget(s);
s.raf = requestAnimationFrame(tick);
}
function onMove(e: PointerEvent) {
const s = session.current;
if (!s || e.pointerId !== s.pointerId) return;
s.x = e.clientX;
s.y = e.clientY;
if (!s.active) {
if (Math.hypot(s.x - s.startX, s.y - s.startY) < THRESHOLD) return;
start(s);
}
e.preventDefault();
if (s.ghost) s.ghost.style.transform = `translate(${s.x - s.offsetX}px, ${s.y - s.offsetY}px)`;
computeTarget(s);
}
function onUp(e: PointerEvent) {
if (session.current && e.pointerId === session.current.pointerId) end(true);
}
function onCancel() {
end(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && session.current?.active) {
e.preventDefault();
e.stopPropagation();
end(false);
}
}
const onPointerDown = useCallback((e: React.PointerEvent<HTMLElement>, cardId: string) => {
if (e.button !== 0 || e.pointerType === "touch" || session.current) return;
if ((e.target as HTMLElement).closest("button, a, input, select, textarea")) return;
const source = e.currentTarget;
const rect = source.getBoundingClientRect();
session.current = {
cardId,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
x: e.clientX,
y: e.clientY,
width: rect.width,
height: rect.height,
source,
active: false,
ghost: null,
target: null,
raf: 0,
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("keydown", onKey, true);
window.addEventListener("blur", onCancel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => () => end(false), [end]);
const shouldSuppressClick = useCallback(() => Date.now() < suppressClickUntil.current, []);
return { drag, onPointerDown, shouldSuppressClick };
}