feat: hold-to-repeat page buttons and tap-counter slider in paginated reader
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user