fix(renderer): chapter reorder follows selection, drag autoscrolls list

Keyboard reorder (Alt+Up/Down) restores focus and scrolls the moved row into view so repeated moves keep working. Pointer drags autoscroll near the list edges and show drop position.
This commit is contained in:
avi 2026-09-19 16:37:45 -05:00
commit 341b89cd8a

View file

@ -41,6 +41,7 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
"#3b82f6", "#8b5cf6", "#78716c", "#18181b",
];
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
let animateNextRender = false;
let colorPopover: HTMLDivElement | null = null;
function closeColorPopover() {
@ -299,12 +300,29 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
}
}
async function persistOrder() {
async function persistOrder(scrollSelected = false) {
if (!state.meta) return;
state.meta.chapterOrder = state.ordered.map((c) => c.id);
const res = await api.reorderChapters(state.meta.chapterOrder);
if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error);
else renderChapterList();
else {
renderChapterList();
if (scrollSelected) keepSelectedVisible();
}
}
// After renderChapterList() rebuilds the list, DOM focus is lost and the
// moved row can end up outside the visible scroll area. Restore focus so
// repeated Alt+Arrow presses keep working, and scroll the selected row
// into view so it follows the move.
function keepSelectedVisible() {
if (!state.selectedId) return;
const row = chapterList.querySelector(
`li.chapter-row[data-id="${CSS.escape(state.selectedId)}"]`
) as HTMLElement | null;
if (!row) return;
row.focus({ preventScroll: true });
row.scrollIntoView({ block: "nearest" });
}
function moveSelectedDown() {
@ -313,8 +331,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
if (i < 0 || i >= state.ordered.length - 1) return;
state.ordered = moveInList(state.ordered, i, i + 1);
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
animateNextRender = true;
renderChapterList();
void persistOrder();
keepSelectedVisible();
void persistOrder(true);
}
function moveSelectedUp() {
@ -323,8 +343,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
if (i <= 0) return;
state.ordered = moveInList(state.ordered, i, i - 1);
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
animateNextRender = true;
renderChapterList();
void persistOrder();
keepSelectedVisible();
void persistOrder(true);
}
// ---- pointer-based reordering ----
@ -333,10 +355,65 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
let dragStartY = 0;
let dragMoved = false;
let dragJustHappened = false;
let animateNextRender = false;
let dragGhost: HTMLElement | null = null;
let dragPlaceholder: HTMLElement | null = null;
let dragOffsetY = 0;
let dragClientX = 0;
let dragClientY = 0;
let dragScrollRaf = 0;
function updateDropTarget(clientX: number, clientY: number) {
const overEl = document.elementFromPoint(clientX, clientY);
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
chapterList.querySelectorAll(".chapter-row").forEach((n) => {
n.classList.remove("drop-target", "drop-before", "drop-after");
});
if (overLi && overLi.dataset.id !== draggingId) {
overLi.classList.add("drop-target");
const before = clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2;
overLi.classList.add(before ? "drop-before" : "drop-after");
if (dragPlaceholder) {
if (before) chapterList.insertBefore(dragPlaceholder, overLi);
else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling);
}
}
}
function stopDragScroll() {
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
dragScrollRaf = 0;
}
// Continuously scroll the chapter list while the dragged ghost is held
// near its top/bottom edge, so chapters above/below the fold are reachable.
// pointermove alone is not enough: it stops firing when the pointer holds
// still, which is exactly when the user expects the list to keep scrolling.
function tickDragScroll() {
dragScrollRaf = 0;
if (!dragMoved || draggingId === null) return;
const rect = chapterList.getBoundingClientRect();
const EDGE = 48;
const MAX_SPEED = 14;
let dy = 0;
if (dragClientY < rect.top + EDGE) {
const strength = Math.min(1, (rect.top + EDGE - dragClientY) / EDGE);
dy = -(2 + MAX_SPEED * strength);
} else if (dragClientY > rect.bottom - EDGE) {
const strength = Math.min(1, (dragClientY - (rect.bottom - EDGE)) / EDGE);
dy = 2 + MAX_SPEED * strength;
}
if (dy !== 0) {
chapterList.scrollTop += dy;
if (dragGhost) dragGhost.style.top = `${dragClientY - dragOffsetY}px`;
updateDropTarget(dragClientX, dragClientY);
}
dragScrollRaf = requestAnimationFrame(tickDragScroll);
}
function startDragScroll() {
stopDragScroll();
dragScrollRaf = requestAnimationFrame(tickDragScroll);
}
chapterList.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return; // left button only
@ -353,6 +430,8 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
document.addEventListener("pointermove", (e) => {
if (dragPointerId === null || draggingId === null) return;
dragClientX = e.clientX;
dragClientY = e.clientY;
if (!dragMoved) {
if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold
dragMoved = true;
@ -375,27 +454,16 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
src.replaceWith(dragPlaceholder);
}
document.body.classList.add("dragging-active");
startDragScroll();
}
if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`;
const overEl = document.elementFromPoint(e.clientX, e.clientY);
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
chapterList.querySelectorAll(".chapter-row").forEach((n) => {
n.classList.remove("drop-target", "drop-before", "drop-after");
});
if (overLi && overLi.dataset.id !== draggingId) {
overLi.classList.add("drop-target");
const before = e.clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2;
overLi.classList.add(before ? "drop-before" : "drop-after");
if (dragPlaceholder) {
if (before) chapterList.insertBefore(dragPlaceholder, overLi);
else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling);
}
}
updateDropTarget(e.clientX, e.clientY);
});
document.addEventListener("pointerup", (e) => {
if (dragPointerId === null) return;
dragPointerId = null;
stopDragScroll();
document.body.classList.remove("dragging-active");
if (!dragMoved || !draggingId) {
draggingId = null;
@ -437,6 +505,22 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
draggingId = null;
});
// If the OS cancels the gesture (touch interruption, alert, etc.),
// stop scrolling and restore the list so no ghost/placeholder is left behind.
document.addEventListener("pointercancel", () => {
if (dragPointerId === null) return;
dragPointerId = null;
stopDragScroll();
document.body.classList.remove("dragging-active");
dragGhost?.remove();
dragGhost = null;
dragPlaceholder?.remove();
dragPlaceholder = null;
draggingId = null;
dragMoved = false;
renderChapterList();
});
addChapterBtn.addEventListener("click", () => void create());
return {