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:
parent
ada5eee679
commit
341b89cd8a
1 changed files with 103 additions and 19 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue