export type BoardCameraOptions = {
host: HTMLElement;
lanes(): HTMLElement[];
engaged(): boolean;
settle(x: number, y: number): void;
edge?: number;
speed?: number;
settleMs?: number;
};
/** Optional edge scroll while dragging; follows the page's scrollable ancestors and lanes. */
export function installBoardCamera(options: BoardCameraOptions): () => void {
const { host } = options;
const edge = options.edge ?? 60;
const speed = options.speed ?? 15;
const settleMs = options.settleMs ?? 150;
let dragging = false;
let x = 0;
let y = 0;
let frame: number | null = null;
let timeout: number | null = null;
let scrolling = false;
let container: Element | null = null;
function scrollContainer(): Element {
let node = host.parentElement;
while (node) {
const style = getComputedStyle(node);
if ([style.overflowY, style.overflowX].some((value) => value === "auto" || value === "scroll")) return node;
node = node.parentElement;
}
return document.scrollingElement ?? document.documentElement;
}
const isDocument = (element: Element) =>
element === document.scrollingElement || element === document.documentElement;
function setScrolling(active: boolean) {
if (active) {
scrolling = true;
if (timeout !== null) window.clearTimeout(timeout);
timeout = null;
} else if (timeout === null) {
timeout = window.setTimeout(() => {
scrolling = false;
timeout = null;
if (options.engaged()) options.settle(x, y);
}, settleMs);
}
}
function loop() {
if (!dragging) {
frame = null;
if (scrolling) setScrolling(false);
return;
}
const horizontal = container ?? scrollContainer();
const vp = isDocument(horizontal)
? { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }
: (() => {
const rect = horizontal.getBoundingClientRect();
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
})();
let vertical = horizontal;
let top = vp.top;
let height = vp.height;
const boardRect = host.getBoundingClientRect();
if (y >= boardRect.top && y <= boardRect.bottom) {
const lane = options.lanes().find((candidate) => {
const rect = candidate.getBoundingClientRect();
return x >= rect.left && x <= rect.right;
});
if (lane) {
const style = getComputedStyle(lane);
if (style.overflowY === "auto" || style.overflowY === "scroll") {
vertical = lane;
const rect = lane.getBoundingClientRect();
top = rect.top;
height = rect.height;
}
}
}
const rx = x - vp.left;
const ry = y - top;
let vx = 0;
let vy = 0;
if (rx < edge) vx = -speed * (1 - rx / edge);
else if (rx > vp.width - edge) vx = speed * (1 - (vp.width - rx) / edge);
if (ry < edge) vy = -speed * (1 - ry / edge);
else if (ry > height - edge) vy = speed * (1 - (height - ry) / edge);
if (vx || vy) {
if (!scrolling) setScrolling(true);
if (vx) {
if (isDocument(horizontal)) window.scrollBy(vx, 0);
else horizontal.scrollLeft += vx;
}
if (vy) {
if (isDocument(vertical)) window.scrollBy(0, vy);
else vertical.scrollTop += vy;
}
} else if (scrolling) setScrolling(false);
frame = requestAnimationFrame(loop);
}
const onDown = () => {
dragging = true;
container = scrollContainer();
if (frame === null) frame = requestAnimationFrame(loop);
};
const onMove = (event: PointerEvent) => {
if (!options.engaged()) return;
x = event.clientX;
y = event.clientY;
if (dragging && frame === null) frame = requestAnimationFrame(loop);
};
const stop = () => {
dragging = false;
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
if (scrolling) setScrolling(false);
};
host.addEventListener("pointerdown", onDown, true);
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerup", stop);
host.addEventListener("pointercancel", stop);
return () => {
host.removeEventListener("pointerdown", onDown, true);
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerup", stop);
host.removeEventListener("pointercancel", stop);
dragging = false;
if (frame !== null) cancelAnimationFrame(frame);
if (timeout !== null) window.clearTimeout(timeout);
};
}