diff --git a/README.md b/README.md index 4e6d95f..02946cf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- +

Moku

-

A fast, minimal manga reader frontend for Suwayomi-Server, built with Tauri and React.

+

A fast, minimal manga reader for Suwayomi-Server.
Built with Tauri v2 and React.

@@ -21,18 +21,65 @@ ## Features -- Library management with cover art browsing -- Full manga reader with keyboard navigation -- Chapter download queue -- Extension and source management -- Source migration with read progress transfer -- Cross-source search -- Reading history tracking +### Reader +- **Single**, **double-page**, and **longstrip** reading modes +- **Infinite longstrip** — when Auto mode is enabled, the next chapter's pages are appended directly into the scroll without any re-render or gap; the entire series flows as one seamless ribbon +- Fit modes: fit width, fit height, fit screen, and 1:1 original +- Per-series zoom control via Ctrl+scroll or a slider popover +- RTL / LTR reading direction toggle +- Configurable page gaps +- Full keyboard navigation with rebindable keybinds +- UI auto-hides after 3 seconds of inactivity; reappears on cursor movement near edges +- Chapter-relative page counter that updates live as you scroll through the infinite strip +- Auto-mark chapters as read when the last page is reached + +### Library +- Grid view of your entire manga collection with lazy-loaded cover art +- Filter tabs: **Saved**, **Downloaded**, and **All** +- Genre tag filter chips — multi-select to narrow by any combination of tags +- In-line search +- Context menu: open, add/remove from library + +### Series Detail +- Cover, author, artist, status badge, genres, and synopsis +- Read progress bar with percentage +- Continue / Start / Re-read button that picks up exactly where you left off (including mid-chapter page) +- Chapter list with scanlator, upload date, and in-progress page indicator +- **Grid view** — displays all chapters as numbered tiles; read/unread/in-progress states are visually distinct at a glance; switches between list and grid with a single click +- Sort by newest or oldest first +- Jump-to-chapter input +- Bulk download menu: from current chapter, unread only, or all +- Per-chapter context menu: mark read/unread, mark all above as read, download, delete, bulk download from here +- Collapsible source details panel with source ID, language, and source migration + +### Search +- Cross-source search running up to 3 concurrent requests +- Language filter bar (preferred language default, per-language, or all) +- Results grouped by source with skeleton loading states + +### Sources & Extensions +- Browse and search installed sources, grouped by extension with per-language expansion +- Extension manager: install, update, remove, and install from external APK URL +- Repo refresh with update count badge + +### Downloads +- Download queue with live progress + +### History +- Reading history grouped by day with relative timestamps +- Per-entry thumbnail, chapter name, and last-read page +- Full-text search across titles and chapter names +- One-click clear + +--- ## Requirements -[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running at `http://127.0.0.1:4567`. - > Note: The application does also launch the server on start-up by itself, so only the package is required on path. +[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`. + +> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`. + +--- ## Installation @@ -42,7 +89,7 @@ nix run github:Youwes09/moku ``` -Or add to your flake: +Add to your flake: ```nix inputs.moku.url = "github:Youwes09/moku"; @@ -57,6 +104,8 @@ nix build ./result/bin/moku ``` +--- + ## Development ```bash @@ -65,18 +114,24 @@ pnpm install pnpm tauri:dev ``` -> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to set the Vite dev server URL, keeping the release build config clean for `nix build`. +> `tauri:dev` uses `src-tauri/tauri.dev.conf.json` to point at the Vite dev server, keeping the release build config clean for `nix build`. + +--- + ## Stack | | | |---|---| -| [Tauri v2](https://tauri.app) | App shell | +| [Tauri v2](https://tauri.app) | Native app shell | | [React](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | UI | -| [Vite](https://vitejs.dev) | Frontend build | +| [Vite](https://vitejs.dev) | Frontend bundler | | [Zustand](https://zustand-demo.pmnd.rs) | State management | +| [Phosphor Icons](https://phosphoricons.com) | Icon set | | [Crane](https://github.com/ipetkov/crane) | Nix Rust builds | +--- + ## License Distributed under the [Apache 2.0 License](./LICENSE). @@ -85,4 +140,4 @@ Distributed under the [Apache 2.0 License](./LICENSE). ## Disclaimer -Moku does not host any content. The developer(s) of this application have no affiliation with the content providers available freely on the internet. +Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources. \ No newline at end of file diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx index 6f9bbe7..e602e6f 100644 --- a/src/components/pages/Reader.tsx +++ b/src/components/pages/Reader.tsx @@ -10,7 +10,7 @@ import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD, } from "../../lib/queries"; import { useStore, type FitMode } from "../../store"; -import { matchesKeybind } from "../../lib/keybinds"; +import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds"; import s from "./Reader.module.css"; function preloadImage(url: string) { @@ -126,6 +126,16 @@ function ZoomPopover({ } // ── Reader ──────────────────────────────────────────────────────────────────── + +/** One chapter's worth of pages in the infinite strip */ +interface StripChapter { + chapterId: number; + chapterName: string; + urls: string[]; + /** Global page index offset for pages in this strip chunk */ + startGlobalIdx: number; +} + export default function Reader() { const containerRef = useRef(null); const rafRef = useRef(0); @@ -135,6 +145,11 @@ export default function Reader() { const hideTimerRef = useRef | null>(null); const uiRef = useRef(null); + // Track which chapters are being fetched so we don't double-fire + const fetchingRef = useRef>(new Set()); + // Whether we've already appended the next chapter into the strip + const appendedRef = useRef>(new Set()); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [dlOpen, setDlOpen] = useState(false); @@ -143,6 +158,18 @@ export default function Reader() { const [markedRead, setMarkedRead] = useState>(new Set()); const [pageGroups, setPageGroups] = useState([]); + /** + * The infinite strip: an ordered list of chapter chunks. + * In non-longstrip modes this is unused — only pageUrls matters. + */ + const [stripChapters, setStripChapters] = useState([]); + + /** + * In longstrip autoNext mode, this tracks which chapter the user is + * currently reading (for topbar display) without triggering a full reload. + */ + const [visibleChapterId, setVisibleChapterId] = useState(null); + const { activeManga, activeChapter, activeChapterList, pageUrls, pageNumber, settings, @@ -182,19 +209,53 @@ export default function Reader() { containerRef.current?.focus({ preventScroll: true }); }, [activeChapter?.id]); + // ── Fetch helpers ──────────────────────────────────────────────────────────── + const fetchPages = useCallback(async (chapterId: number): Promise => { + const cached = pageCache.current.get(chapterId); + if (cached) return cached; + if (fetchingRef.current.has(chapterId)) { + // Poll until another in-flight fetch resolves + return new Promise((resolve) => { + const interval = setInterval(() => { + const c = pageCache.current.get(chapterId); + if (c) { clearInterval(interval); resolve(c); } + }, 50); + }); + } + fetchingRef.current.add(chapterId); + const d = await gql<{ fetchChapterPages: { pages: string[] } }>( + FETCH_CHAPTER_PAGES, { chapterId } + ); + const urls = d.fetchChapterPages.pages.map(thumbUrl); + pageCache.current.set(chapterId, urls); + fetchingRef.current.delete(chapterId); + return urls; + }, []); + // ── Load pages ────────────────────────────────────────────────────────────── useEffect(() => { if (!activeChapter) return; setLoading(true); setError(null); setPageGroups([]); - const cached = pageCache.current.get(activeChapter.id); - if (cached) { setPageUrls(cached); setLoading(false); return; } - gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: activeChapter.id }) - .then((d) => { - const urls = d.fetchChapterPages.pages.map(thumbUrl); - pageCache.current.set(activeChapter.id, urls); + // Reset strip state for new chapter navigation (non-scroll transitions) + appendedRef.current = new Set(); + + fetchPages(activeChapter.id) + .then((urls) => { setPageUrls(urls); + if (style === "longstrip" && autoNext) { + setStripChapters([{ + chapterId: activeChapter.id, + chapterName: activeChapter.name, + urls, + startGlobalIdx: 0, + }]); + setVisibleChapterId(activeChapter.id); + } else { + setStripChapters([]); + setVisibleChapterId(null); + } }) - .catch((e) => setError(e.message)) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setLoading(false)); }, [activeChapter?.id]); @@ -263,13 +324,9 @@ export default function Reader() { useEffect(() => { const preload = (id: number) => { - if (pageCache.current.has(id)) return; - gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId: id }) - .then((d) => { - const urls = d.fetchChapterPages.pages.map(thumbUrl); - pageCache.current.set(id, urls); - urls.slice(0, 2).forEach(preloadImage); - }).catch(() => {}); + fetchPages(id) + .then((urls) => urls.slice(0, 3).forEach(preloadImage)) + .catch(() => {}); }; if (adjacent.next) preload(adjacent.next.id); if (adjacent.prev) preload(adjacent.prev.id); @@ -277,6 +334,33 @@ export default function Reader() { const lastPage = pageUrls.length; + /** + * In infinite-strip mode, the topbar shows whichever chapter the user is + * currently scrolled into rather than the "root" chapter we opened with. + */ + const displayChapter = useMemo(() => { + if (style !== "longstrip" || !autoNext || !visibleChapterId) return activeChapter; + return activeChapterList.find((c) => c.id === visibleChapterId) ?? activeChapter; + }, [style, autoNext, visibleChapterId, activeChapter, activeChapterList]); + + /** + * In infinite-strip mode, the "last page" shown in the topbar is relative + * to the currently visible chapter chunk. + */ + const visibleChunkLastPage = useMemo(() => { + if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return lastPage; + const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id)); + return chunk ? chunk.urls.length : lastPage; + }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, lastPage]); + + /** Page number within the currently visible chapter chunk (for topbar) */ + const visibleChunkPage = useMemo(() => { + if (style !== "longstrip" || !autoNext || stripChapters.length === 0) return pageNumber; + const chunk = stripChapters.find((c) => c.chapterId === (visibleChapterId ?? activeChapter?.id)); + if (!chunk) return pageNumber; + return Math.max(1, pageNumber - chunk.startGlobalIdx); + }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]); + // ── Auto-mark read + history ───────────────────────────────────────────────── useEffect(() => { if (!activeChapter || !lastPage) return; @@ -356,27 +440,36 @@ export default function Reader() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.target as HTMLElement).tagName === "INPUT") return; + + // Escape: close overlays in priority order, then exit reader if (e.key === "Escape") { - if (zoomOpen) { e.preventDefault(); setZoomOpen(false); return; } - if (dlOpen) { e.preventDefault(); setDlOpen(false); return; } + e.preventDefault(); + if (zoomOpen) { setZoomOpen(false); return; } + if (dlOpen) { setDlOpen(false); return; } + closeReader(); + return; } - if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } - else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } - else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); } - else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); } - else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); } - else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); } - else if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } - else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); } + + if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); } + else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); } + else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); } + else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); setPageNumber(1); } + else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); setPageNumber(lastPage); } + else if (matchesKeybind(e, kb.chapterRight)) { e.preventDefault(); if (adjacent.next) openReader(adjacent.next, activeChapterList); } + else if (matchesKeybind(e, kb.chapterLeft)) { e.preventDefault(); if (adjacent.prev) openReader(adjacent.prev, activeChapterList); } + else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); } else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }); } - else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); } + else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); } + else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); openSettings(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [goForward, goBack, kb, style, rtl, lastPage, adjacent, activeChapterList, zoomOpen, dlOpen]); // ── Longstrip scroll tracker ───────────────────────────────────────────────── - // Tracks current page number and auto-advances to next chapter at end of scroll + // Tracks current page number. In autoNext mode, appends the next chapter's + // pages directly into the strip (no re-render / scroll reset) so the flow + // is one seamless ribbon of images. useEffect(() => { const el = containerRef.current; if (!el || style !== "longstrip") return; @@ -399,11 +492,63 @@ export default function Reader() { const n = closest + 1; if (n !== pageNumRef.current) setPageNumber(n); - // Auto-advance: within 80px of bottom and next chapter exists - if (autoNext && adjacent.next) { - const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; - if (nearBottom) openReader(adjacent.next, activeChapterList); + // ── Infinite append ────────────────────────────────────────────────── + if (!autoNext) { + // Classic behavior: jump to next chapter at the very end of scroll + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; + if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList); + return; } + + // Silently update visibleChapterId as we scroll into each chunk + for (const chunk of stripChapters) { + const chunkEnd = chunk.startGlobalIdx + chunk.urls.length; + if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) { + if (chunk.chapterId !== visibleChapterId) { + setVisibleChapterId(chunk.chapterId); + // Mark as read when we scroll into a new chapter + if (!markedRead.has(chunk.chapterId) && settings.autoMarkRead) { + const prevChunk = stripChapters[stripChapters.indexOf(chunk) - 1]; + if (prevChunk) { + setMarkedRead((r) => new Set(r).add(prevChunk.chapterId)); + gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error); + } + } + } + break; + } + } + + // Append next chapter 300px before we hit the bottom of the last chunk + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300; + if (!nearBottom) return; + + // What's the last chapter currently in the strip? + const lastChunk = stripChapters[stripChapters.length - 1]; + if (!lastChunk) return; + + const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId); + if (lastChunkIdx < 0 || lastChunkIdx >= activeChapterList.length - 1) return; + + const nextChEntry = activeChapterList[lastChunkIdx + 1]; + if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return; + + // Mark immediately so concurrent scroll events don't double-append + appendedRef.current.add(nextChEntry.id); + + // Fetch (likely already cached from preload) then append to strip + fetchPages(nextChEntry.id).then((urls) => { + setStripChapters((prev) => { + const lastInPrev = prev[prev.length - 1]; + const newStart = lastInPrev + ? lastInPrev.startGlobalIdx + lastInPrev.urls.length + : 0; + return [ + ...prev, + { chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart }, + ]; + }); + }).catch(console.error); }); }; @@ -412,17 +557,38 @@ export default function Reader() { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); }; - }, [style, autoNext, adjacent.next?.id, activeChapterList]); + }, [style, autoNext, stripChapters, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]); // Reset scroll position when switching chapters in non-longstrip modes useEffect(() => { if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; }, [pageNumber, style]); - // When switching to longstrip, reset scroll to top + // When switching to longstrip, reset scroll to top and rebuild strip from current chapter useEffect(() => { - if (style === "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; - }, [activeChapter?.id, style]); + if (style === "longstrip" && containerRef.current) { + containerRef.current.scrollTop = 0; + if (activeChapter && pageUrls.length > 0) { + appendedRef.current = new Set(); + if (autoNext) { + setStripChapters([{ + chapterId: activeChapter.id, + chapterName: activeChapter.name, + urls: pageUrls, + startGlobalIdx: 0, + }]); + setVisibleChapterId(activeChapter.id); + } else { + // Plain longstrip — no multi-chapter strip + setStripChapters([]); + setVisibleChapterId(null); + } + } + } else if (style !== "longstrip") { + setStripChapters([]); + setVisibleChapterId(null); + } + }, [activeChapter?.id, style, autoNext]); function handleTap(e: React.MouseEvent) { if (style === "longstrip") return; @@ -492,9 +658,9 @@ export default function Reader() { {activeManga?.title} / - {activeChapter?.name} + {displayChapter?.name} - {pageNumber} / {lastPage || "…"} + {visibleChunkPage} / {visibleChunkLastPage || "…"} +
+ + + +
{/* Jump to chapter */} @@ -448,14 +463,55 @@ export default function SeriesDetail() {
-
+
{loadingChapters && chapters.length === 0 ? ( - Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
- )) + viewMode === "grid" ? ( + Array.from({ length: 24 }).map((_, i) => ( +
+
+
+ )) + ) : ( + Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+ )) + ) + ) : viewMode === "grid" ? ( + sortedChapters.map((ch) => { + const idxInSorted = sortedChapters.indexOf(ch); + const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0; + return ( + + ); + }) ) : ( pageChapters.map((ch) => { const idxInSorted = sortedChapters.indexOf(ch); diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index e2b517e..d386995 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -422,6 +422,12 @@ export default function SettingsModal() { const updateSettings = useStore((s) => s.updateSettings); const resetKeybinds = useStore((s) => s.resetKeybinds); const backdropRef = useRef(null); + const contentBodyRef = useRef(null); + + // Scroll to top on every tab switch + useEffect(() => { + contentBodyRef.current?.scrollTo({ top: 0 }); + }, [tab]); const handleBackdrop = useCallback( (e: React.MouseEvent) => { if (e.target === backdropRef.current) closeSettings(); }, @@ -455,7 +461,7 @@ export default function SettingsModal() {

{TABS.find((t) => t.id === tab)?.label}

-
+
{tab === "general" && } {tab === "reader" && } {tab === "library" && } diff --git a/src/lib/client.ts b/src/lib/client.ts index 53599e7..03d0980 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,8 +1,24 @@ -const SUWAYOMI = "http://127.0.0.1:4567"; -const GQL = `${SUWAYOMI}/api/graphql`; +const DEFAULT_URL = "http://127.0.0.1:4567"; + +function getServerUrl(): string { + // Read from persisted Zustand store if available, fall back to default + try { + const raw = localStorage.getItem("moku-settings"); + if (raw) { + const parsed = JSON.parse(raw); + const url = parsed?.state?.settings?.serverUrl; + if (typeof url === "string" && url.trim()) return url.replace(/\/$/, ""); + } + } catch {} + return DEFAULT_URL; +} + +function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; } export function thumbUrl(path: string): string { - return `${SUWAYOMI}${path}`; + if (!path) return ""; + if (path.startsWith("http")) return path; + return `${getServerUrl()}${path}`; } interface GQLResponse { @@ -28,7 +44,7 @@ export async function gql( query: string, variables?: Record ): Promise { - const res = await fetchWithRetry(GQL, { + const res = await fetchWithRetry(gqlUrl(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), diff --git a/src/lib/keybinds.ts b/src/lib/keybinds.ts index 62f05db..7ea800d 100644 --- a/src/lib/keybinds.ts +++ b/src/lib/keybinds.ts @@ -1,52 +1,45 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; + export interface Keybinds { - pageRight: string; - pageLeft: string; - firstPage: string; - lastPage: string; - chapterRight: string; - chapterLeft: string; - exitReader: string; - close: string; - toggleReadingDirection: string; - togglePageStyle: string; - toggleOffsetDoubleSpreads: string; - toggleFullscreen: string; - openSettings: string; - toggleSidebar: string; + pageRight: string; + pageLeft: string; + firstPage: string; + lastPage: string; + chapterRight: string; + chapterLeft: string; + exitReader: string; + toggleReadingDirection: string; + togglePageStyle: string; + toggleFullscreen: string; + openSettings: string; } export const DEFAULT_KEYBINDS: Keybinds = { - pageRight: "ArrowRight", - pageLeft: "ArrowLeft", - firstPage: "ctrl+ArrowLeft", - lastPage: "ctrl+ArrowRight", - chapterRight: "]", - chapterLeft: "[", - exitReader: "Backspace", - close: "Escape", - toggleReadingDirection: "d", - togglePageStyle: "q", - toggleOffsetDoubleSpreads: "u", - toggleFullscreen: "f", - openSettings: "o", - toggleSidebar: "s", + pageRight: "ArrowRight", + pageLeft: "ArrowLeft", + firstPage: "ctrl+ArrowLeft", + lastPage: "ctrl+ArrowRight", + chapterRight: "]", + chapterLeft: "[", + exitReader: "Backspace", + toggleReadingDirection: "d", + togglePageStyle: "q", + toggleFullscreen: "f", + openSettings: "o", }; export const KEYBIND_LABELS: Record = { - pageRight: "Turn page right", - pageLeft: "Turn page left", - firstPage: "First page", - lastPage: "Last page", - chapterRight: "Change chapter right", - chapterLeft: "Change chapter left", - exitReader: "Exit reader", - close: "Close", - toggleReadingDirection: "Toggle reading direction", - togglePageStyle: "Toggle page style", - toggleOffsetDoubleSpreads: "Toggle double page offset", - toggleFullscreen: "Toggle fullscreen", - openSettings: "Show settings menu", - toggleSidebar: "Toggle sidebar", + pageRight: "Turn page right", + pageLeft: "Turn page left", + firstPage: "Jump to first page", + lastPage: "Jump to last page", + chapterRight: "Next chapter", + chapterLeft: "Previous chapter", + exitReader: "Exit reader", + toggleReadingDirection: "Toggle reading direction", + togglePageStyle: "Toggle page style", + toggleFullscreen: "Toggle fullscreen", + openSettings: "Open settings", }; export function eventToKeybind(e: KeyboardEvent): string { @@ -62,4 +55,10 @@ export function eventToKeybind(e: KeyboardEvent): string { export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { return eventToKeybind(e) === bind; +} + +export async function toggleFullscreen(): Promise { + const win = getCurrentWindow(); + const isFs = await win.isFullscreen(); + await win.setFullscreen(!isFs); } \ No newline at end of file