diff --git a/src/renderer/book/chapter-list.ts b/src/renderer/book/chapter-list.ts index f7fd273..d070173 100644 --- a/src/renderer/book/chapter-list.ts +++ b/src/renderer/book/chapter-list.ts @@ -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 {