feat: hold-to-repeat page buttons and tap-counter slider in paginated reader
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m47s
Release / Docker (push) Successful in 6m8s
Release / Gitea Release (push) Successful in 36s

This commit is contained in:
root
2026-04-11 15:13:34 +05:00
parent b95c811898
commit 19b5b44454

View File

@@ -121,6 +121,78 @@
let paginatedContentEl = $state<HTMLDivElement | null>(null);
let containerH = $state(0);
// ── Page slider popover ───────────────────────────────────────────────────
let sliderOpen = $state(false);
let sliderAnchorEl = $state<HTMLElement | null>(null);
let sliderAnchorFocusEl = $state<HTMLElement | null>(null);
function toggleSlider(anchor: HTMLElement) {
if (sliderOpen) { sliderOpen = false; return; }
sliderAnchorEl = anchor;
sliderOpen = true;
}
function closeSliderOnOutside(e: MouseEvent) {
if (!sliderOpen) return;
const target = e.target as Node;
if (sliderAnchorEl && sliderAnchorEl.contains(target)) return;
if (sliderAnchorFocusEl && sliderAnchorFocusEl.contains(target)) return;
sliderOpen = false;
}
$effect(() => {
if (!browser) return;
document.addEventListener('pointerdown', closeSliderOnOutside);
return () => document.removeEventListener('pointerdown', closeSliderOnOutside);
});
// ── Hold-to-repeat action ────────────────────────────────────────────────
/**
* Svelte action: fires `onrepeat()` repeatedly while the pointer is held.
* First repeat fires after `delay` ms; subsequent repeats every `interval` ms.
*/
function holdRepeat(
node: HTMLElement,
params: { onrepeat: () => void; delay?: number; interval?: number }
) {
let timer: ReturnType<typeof setTimeout> | null = null;
function clear() {
if (timer !== null) { clearTimeout(timer); timer = null; }
}
function schedule() {
timer = setTimeout(() => {
params.onrepeat();
schedule(); // re-schedule at interval speed
}, params.interval ?? 80);
}
function start() {
clear();
timer = setTimeout(() => {
params.onrepeat();
schedule();
}, params.delay ?? 400);
}
node.addEventListener('pointerdown', start);
node.addEventListener('pointerup', clear);
node.addEventListener('pointercancel', clear);
node.addEventListener('pointerleave', clear);
return {
update(newParams: typeof params) { params = newParams; },
destroy() {
clear();
node.removeEventListener('pointerdown', start);
node.removeEventListener('pointerup', clear);
node.removeEventListener('pointercancel', clear);
node.removeEventListener('pointerleave', clear);
}
};
}
$effect(() => {
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
// Re-run when html, container refs, or mini-player visibility changes
@@ -610,6 +682,7 @@
type="button"
onclick={() => { if (pageIndex > 0) pageIndex--; }}
disabled={pageIndex === 0}
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -617,13 +690,47 @@
</svg>
Prev
</button>
<span class="text-sm text-(--color-muted) tabular-nums">
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</span>
<!-- Counter — tap to open slider -->
<div class="relative">
<button
type="button"
bind:this={sliderAnchorEl}
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
class="px-3 py-1.5 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) tabular-nums transition-colors"
aria-label="Jump to page"
>
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</button>
{#if sliderOpen}
<div
bind:this={sliderAnchorFocusEl}
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
px-4 py-3 flex flex-col items-center gap-2"
style="min-width: min(220px, 60vw);"
>
<span class="text-xs text-(--color-muted) tabular-nums">
Page {pageIndex + 1} of {totalPages}
</span>
<input
type="range"
min="0"
max={totalPages - 1}
value={pageIndex}
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
class="w-full accent-(--color-brand) cursor-pointer"
/>
</div>
{/if}
</div>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
disabled={pageIndex === totalPages - 1}
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
>
Next
@@ -710,6 +817,7 @@
type="button"
onclick={() => { if (pageIndex > 0) pageIndex--; }}
disabled={pageIndex === 0}
use:holdRepeat={{ onrepeat: () => { if (pageIndex > 0) pageIndex--; } }}
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
aria-label="Previous page"
>
@@ -717,13 +825,46 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<span class="px-2.5 py-2 tabular-nums text-(--color-muted) shrink-0 select-none">
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
</span>
<!-- Counter — tap to open slider -->
<div class="relative shrink-0">
<button
type="button"
onclick={(e) => toggleSlider(e.currentTarget as HTMLElement)}
class="px-2.5 py-2 tabular-nums text-(--color-muted) hover:text-(--color-text) transition-colors select-none"
aria-label="Jump to page"
>
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
</button>
{#if sliderOpen}
<div
bind:this={sliderAnchorFocusEl}
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50
bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-xl
px-4 py-3 flex flex-col items-center gap-2"
style="min-width: min(220px, 60vw);"
>
<span class="text-xs text-(--color-muted) tabular-nums">
Page {pageIndex + 1} of {totalPages}
</span>
<input
type="range"
min="0"
max={totalPages - 1}
value={pageIndex}
oninput={(e) => { pageIndex = Number((e.target as HTMLInputElement).value); }}
class="w-full accent-(--color-brand) cursor-pointer"
/>
</div>
{/if}
</div>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
disabled={pageIndex === totalPages - 1}
use:holdRepeat={{ onrepeat: () => { if (pageIndex < totalPages - 1) pageIndex++; } }}
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
aria-label="Next page"
>