diff --git a/src/core/keybinds/defaultBinds.ts b/src/core/keybinds/defaultBinds.ts index 7f989dd..83cac86 100644 --- a/src/core/keybinds/defaultBinds.ts +++ b/src/core/keybinds/defaultBinds.ts @@ -12,6 +12,7 @@ export interface Keybinds { openSettings: string; toggleBookmark: string; toggleMarker: string; + toggleAutoScroll: string; } export const DEFAULT_KEYBINDS: Keybinds = { @@ -28,6 +29,7 @@ export const DEFAULT_KEYBINDS: Keybinds = { openSettings: "o", toggleBookmark: "m", toggleMarker: "n", + toggleAutoScroll: "s", }; export const KEYBIND_LABELS: Record = { @@ -44,4 +46,5 @@ export const KEYBIND_LABELS: Record = { openSettings: "Open settings", toggleBookmark: "Toggle bookmark", toggleMarker: "Toggle marker", -}; + toggleAutoScroll: "Toggle auto scroll", +}; \ No newline at end of file diff --git a/src/features/reader/components/PageView.svelte b/src/features/reader/components/PageView.svelte index 97c574f..698d84c 100644 --- a/src/features/reader/components/PageView.svelte +++ b/src/features/reader/components/PageView.svelte @@ -211,6 +211,26 @@ let stripDragStartY = 0; let stripScrollStart = 0; + let autoScrollPaused = false; + let autoScrollPauseTimer: ReturnType | null = null; + + function pauseAutoScroll() { + autoScrollPaused = true; + if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer); + autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500); + } + + $effect(() => { + if (style !== "longstrip" || !store.settings.autoScroll) return; + let rafId: number; + const tick = () => { + if (!autoScrollPaused && containerEl) containerEl.scrollTop += (store.settings.autoScrollSpeed ?? 5) * 0.5; + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }); + let pinch: PinchTracker | null = null; $effect(() => { @@ -235,6 +255,7 @@ stripDragMoved = false; stripDragStartY = e.clientY; stripScrollStart = containerEl?.scrollTop ?? 0; + pauseAutoScroll(); e.preventDefault(); return; } @@ -305,6 +326,7 @@ export function handleWheel(e: WheelEvent) { if (style === "longstrip") { if (e.ctrlKey) { onWheel(e); } + else pauseAutoScroll(); return; } if (!e.ctrlKey) { onWheel(e); return; } @@ -328,7 +350,10 @@ } function handleTap(e: MouseEvent) { - if (style === "longstrip") return; + if (style === "longstrip") { + if (stripDragMoved) { stripDragMoved = false; return; } + return; + } if (inspectDragMoved) { inspectDragMoved = false; return; } if (stripDragMoved) { stripDragMoved = false; return; } onTap(e); @@ -359,12 +384,12 @@ role="presentation" tabindex="-1" onclick={handleTap} - ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }} + ondblclick={() => { if (tapToToggleBar) onToggleUi(); }} onmousedown={onInspectMouseDown} onpointerdown={pinchZoomEnabled ? onPointerDown : undefined} onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }} style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined} - onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }} + onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; } }} > {#if loading} diff --git a/src/features/reader/components/Reader.svelte b/src/features/reader/components/Reader.svelte index 945f0fd..8f0ab5d 100644 --- a/src/features/reader/components/Reader.svelte +++ b/src/features/reader/components/Reader.svelte @@ -225,6 +225,7 @@ toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }), openSettings: () => setSettingsOpen(true), toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber), + toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) }); }, toggleMarker: () => { if (currentPageMarkers.length > 0) { const first = currentPageMarkers[0]; diff --git a/src/features/reader/components/ReaderPresetPanel.svelte b/src/features/reader/components/ReaderPresetPanel.svelte index 09a29c7..ff46e60 100644 --- a/src/features/reader/components/ReaderPresetPanel.svelte +++ b/src/features/reader/components/ReaderPresetPanel.svelte @@ -178,6 +178,32 @@ aria-checked={store.settings.autoNextChapter ?? false} > + + {#if store.settings.autoScroll} +
+ Speed + updateSettings({ autoScrollSpeed: Number(e.currentTarget.value) })} + /> + {store.settings.autoScrollSpeed ?? 5} +
+ {/if} {/if} @@ -760,4 +786,28 @@ padding: var(--sp-2) 0; text-align: center; } + + .speed-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-1) 0; + } + + .speed-label { + font-size: var(--text-xs); + color: var(--text-faint); + flex-shrink: 0; + min-width: 40px; + } + + .speed-val { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-secondary); + letter-spacing: var(--tracking-wide); + min-width: 1.5ch; + text-align: right; + flex-shrink: 0; + } \ No newline at end of file diff --git a/src/features/reader/components/ReaderProgressBar.svelte b/src/features/reader/components/ReaderProgressBar.svelte index dcdc0e0..334e46a 100644 --- a/src/features/reader/components/ReaderProgressBar.svelte +++ b/src/features/reader/components/ReaderProgressBar.svelte @@ -5,22 +5,22 @@ import type { Chapter } from "@types"; interface Props { - style: string; - loading: boolean; - rtl: boolean; - sliderPage: number; - sliderMax: number; - sliderPct: number; - lastPage: number; - displayChapter: Chapter | null; - currentBookmark: BookmarkEntry | undefined; + style: string; + loading: boolean; + rtl: boolean; + sliderPage: number; + sliderMax: number; + sliderPct: number; + lastPage: number; + displayChapter: Chapter | null; + currentBookmark: BookmarkEntry | undefined; activeChapterMarkers: MarkerEntry[]; - adjacent: { prev: Chapter | null; next: Chapter | null }; - uiVisible: boolean; - barPosition: "top" | "left" | "right"; - onGoPrev: () => void; - onGoNext: () => void; - onJumpToPage: (page: number) => void; + adjacent: { prev: Chapter | null; next: Chapter | null }; + uiVisible: boolean; + barPosition: "top" | "left" | "right"; + onGoPrev: () => void; + onGoNext: () => void; + onJumpToPage: (page: number) => void; } const { @@ -31,6 +31,25 @@ }: Props = $props(); const isVertical = $derived(barPosition === "left" || barPosition === "right"); + + const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage); + const hPct = $derived(`--pct:${sliderPct}%`); + const vPct = $derived(`--pct:${sliderPct}%`); + + function handleH(e: Event) { + const raw = Number((e.target as HTMLInputElement).value); + onJumpToPage(rtl ? sliderMax - raw + 1 : raw); + } + + function handleV(e: Event) { + onJumpToPage(Number((e.target as HTMLInputElement).value)); + } + + function markerPct(pageNumber: number, forRtl = false): number { + if (sliderMax <= 1) return 0; + const ord = forRtl ? sliderMax - pageNumber + 1 : pageNumber; + return ((ord - 1) / (sliderMax - 1)) * 100; + } {#if !isVertical} @@ -43,44 +62,35 @@ {#if sliderMax > 1}
readerState.sliderHover = true} - onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} - onmousedown={(e) => { - readerState.sliderDragging = true; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); - }} - onmousemove={(e) => { - if (!readerState.sliderDragging) return; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1))); - }} - onmouseup={() => readerState.sliderDragging = false} + onmouseleave={() => readerState.sliderHover = false} > -
-
+ readerState.sliderDragging = true} + onmouseup={() => readerState.sliderDragging = false} + /> + + -
- - {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} - {@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber} - {@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0} -
- {/if} - - {#each activeChapterMarkers as m (m.id)} - {@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber} - {@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0} -
- {/each} {#if readerState.sliderHover || readerState.sliderDragging}
@@ -100,42 +110,37 @@ {#if sliderMax > 1}
readerState.sliderHover = true} - onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }} - onmousedown={(e) => { - readerState.sliderDragging = true; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - onJumpToPage(Math.round(1 + ratio * (sliderMax - 1))); - }} - onmousemove={(e) => { - if (!readerState.sliderDragging) return; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - onJumpToPage(Math.round(1 + ratio * (sliderMax - 1))); - }} - onmouseup={() => readerState.sliderDragging = false} + onmouseleave={() => readerState.sliderHover = false} > -
-
+ readerState.sliderDragging = true} + onmouseup={() => readerState.sliderDragging = false} + /> + + -
- - {#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} - {@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} -
- {/if} - - {#each activeChapterMarkers as m (m.id)} - {@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0} -
- {/each} {#if readerState.sliderHover || readerState.sliderDragging}
@@ -155,101 +160,99 @@ .nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); } .nav-btn:disabled { opacity: 0.25; cursor: default; } - .slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; } - .slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; pointer-events: none; } - .slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; } - .slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; } - .slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); pointer-events: none; z-index: 2; box-shadow: 0 0 0 2px rgba(0,0,0,0.5); transition: transform var(--t-fast); } - .slider-wrap:hover .slider-thumb, .slider-wrap.dragging .slider-thumb { transform: translate(-50%, -50%) scale(1.3); } - .bookmark-checkpoint { background: #ffffff; opacity: 0.8; } - .marker-checkpoint { opacity: 0.85; } - .slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); } - .slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; } + .slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; } - .vbar-progress { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; + .h-range { + -webkit-appearance: none; + appearance: none; width: 100%; - padding: var(--sp-2) 0; - transition: opacity 0.25s ease; - pointer-events: none; + height: 34px; + background: transparent; + cursor: pointer; + position: relative; + z-index: 2; + margin: 0; + padding: 0; } + .h-range::-webkit-slider-runnable-track { + height: 3px; + background: linear-gradient(to right, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); + border-radius: 2px; + transition: height 0.15s ease, background 0.05s linear; + } + .h-range:hover::-webkit-slider-runnable-track, + .h-range:active::-webkit-slider-runnable-track { height: 5px; } + .h-range::-moz-range-track { height: 3px; background: var(--border-strong); border-radius: 2px; transition: height 0.15s ease; } + .h-range::-moz-range-progress { height: 3px; background: var(--accent-fg); border-radius: 2px; transition: height 0.15s ease; } + .h-range:hover::-moz-range-track, .h-range:active::-moz-range-track { height: 5px; } + .h-range:hover::-moz-range-progress, .h-range:active::-moz-range-progress { height: 5px; } + .h-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent-fg); + box-shadow: 0 0 0 2px rgba(0,0,0,0.5); + margin-top: -4.5px; + transition: transform var(--t-fast); + } + .h-range:hover::-webkit-slider-thumb, + .h-range:active::-webkit-slider-thumb { transform: scale(1.3); } + .h-range::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent-fg); + box-shadow: 0 0 0 2px rgba(0,0,0,0.5); + border: none; + transition: transform var(--t-fast); + } + .h-range:hover::-moz-range-thumb, + .h-range:active::-moz-range-thumb { transform: scale(1.3); } + + .slider-markers { position: absolute; inset: 0; pointer-events: none; z-index: 1; } + .slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); } + .bookmark-checkpoint { background: #ffffff; opacity: 0.8; } + .marker-checkpoint { opacity: 0.85; } + .slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); } + + .vbar-progress { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; padding: var(--sp-2) 0; transition: opacity 0.25s ease; pointer-events: none; } .vbar-progress.hidden { opacity: 0; } - .vslider-wrap { - flex: 1; - position: relative; - display: flex; - flex-direction: column; - align-items: center; - width: 36px; + .vslider-wrap { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; width: 36px; pointer-events: all; margin: var(--sp-1) 0; } + + .v-range { + -webkit-appearance: slider-vertical; + appearance: slider-vertical; + writing-mode: vertical-lr; + direction: rtl; + width: 34px; + height: 100%; + background: transparent; cursor: pointer; - pointer-events: all; - margin: var(--sp-1) 0; + position: relative; + z-index: 2; + margin: 0; + padding: 0; } - .vslider-track-bg { - position: absolute; - top: 0; - bottom: 0; - width: 5px; - background: var(--border-strong); - border-radius: 3px; - pointer-events: none; - left: 50%; - translate: -50% 0; - } - .vslider-fill { - width: 100%; - background: var(--accent-fg); - border-radius: 3px; - transition: height 0.05s linear; - } - .vslider-thumb { - position: absolute; - left: 50%; - transform: translate(-50%, -50%); + .v-range::-webkit-slider-runnable-track { width: 5px; background: linear-gradient(to bottom, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 3px; transition: width 0.15s ease, background 0.05s linear; } + .v-range:hover::-webkit-slider-runnable-track, + .v-range:active::-webkit-slider-runnable-track { width: 7px; } + .v-range::-webkit-slider-thumb { + -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent-fg); - pointer-events: none; - z-index: 2; box-shadow: 0 0 0 2px rgba(0,0,0,0.5); + margin-left: -4.5px; transition: transform var(--t-fast); } - .vslider-wrap:hover .vslider-thumb, .vslider-wrap.dragging .vslider-thumb { transform: translate(-50%, -50%) scale(1.3); } - .vslider-wrap:hover .vslider-track-bg, .vslider-wrap.dragging .vslider-track-bg { width: 7px; } - .vslider-checkpoint { - position: absolute; - left: 50%; - transform: translate(-50%, -50%); - width: 12px; - height: 5px; - border-radius: 2px; - pointer-events: none; - z-index: 1; - } - .vslider-tooltip { - position: absolute; - left: calc(100% + 6px); - transform: translateY(-50%); - background: var(--bg-raised); - border: 1px solid var(--border-base); - border-radius: var(--radius-sm); - padding: 2px 6px; - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-secondary); - white-space: nowrap; - pointer-events: none; - z-index: 10; - letter-spacing: var(--tracking-wide); - } - .vslider-tooltip.tooltip-right { - left: auto; - right: calc(100% + 6px); - } + .v-range:hover::-webkit-slider-thumb, + .v-range:active::-webkit-slider-thumb { transform: scale(1.3); } + + .vslider-markers { position: absolute; inset: 0; pointer-events: none; } + .vslider-checkpoint { position: absolute; left: 50%; transform: translate(-50%, -50%); width: 12px; height: 5px; border-radius: 2px; } + .vslider-tooltip { position: absolute; left: calc(100% + 6px); transform: translateY(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); } + .vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 6px); } \ No newline at end of file diff --git a/src/features/reader/lib/readerKeybinds.ts b/src/features/reader/lib/readerKeybinds.ts index c268df7..c8c6104 100644 --- a/src/features/reader/lib/readerKeybinds.ts +++ b/src/features/reader/lib/readerKeybinds.ts @@ -14,6 +14,7 @@ export interface ReaderKeyActions { openSettings: () => void; toggleBookmark: () => void; toggleMarker: () => void; + toggleAutoScroll: () => void; chapterNext: () => void; chapterPrev: () => void; closePopovers: () => boolean; @@ -55,5 +56,6 @@ export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardE else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); } else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); } else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); } + else if (matchesKeybind(e, kb.toggleAutoScroll)) { e.preventDefault(); actions.toggleAutoScroll(); } }; } \ No newline at end of file diff --git a/src/types/settings.ts b/src/types/settings.ts index cade81e..122b3ba 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -128,6 +128,8 @@ export interface Settings { downloadAutoRetry: boolean; hiddenLibraryTabs: string[]; libraryPinnedTabOrder: string[]; + autoScroll?: boolean; + autoScrollSpeed?: number; } export const DEFAULT_SETTINGS: Settings = { @@ -171,4 +173,6 @@ export const DEFAULT_SETTINGS: Settings = { downloadAutoRetry: false, hiddenLibraryTabs: [], libraryPinnedTabOrder: [], + autoScroll: false, + autoScrollSpeed: 5, }; \ No newline at end of file