355 lines
9.4 KiB
Svelte
355 lines
9.4 KiB
Svelte
<script lang="ts">
|
|
// TODO: Add type hints to the rest of this file
|
|
|
|
import { browser } from '$app/environment';
|
|
import { onDestroy, onMount } from 'svelte';
|
|
|
|
import { Book, type Rendition } from 'epubjs';
|
|
|
|
import '../../../app.css';
|
|
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
|
import * as Collapsible from '$lib/components/ui/collapsible/index';
|
|
|
|
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
|
|
|
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
|
import type { DisplayedLocation } from 'epubjs/types/rendition';
|
|
|
|
let { bookUrl, bookId, initialProgress = 0, initialEpubLoc = null } = $props();
|
|
|
|
let epubViewer = $state<HTMLElement>();
|
|
let containerWidth = $state(0);
|
|
let containerHeight = $state(0);
|
|
let isReaderVisible = $state(false);
|
|
|
|
let hasNextPage = $state(true);
|
|
let hasPrevPage = $state(false);
|
|
|
|
let book: Book | undefined = $state();
|
|
let rendition = $state<Rendition>();
|
|
let chapters = $state([]);
|
|
|
|
let isMounted = $state(false);
|
|
|
|
let currentLocation = $state(initialEpubLoc);
|
|
let currentProgress = $state(initialProgress);
|
|
|
|
let isSidebarOpen = $state(false);
|
|
let debounceTimeout = $state<NodeJS.Timeout>();
|
|
|
|
// Function to update dimensions
|
|
function updateDimensions() {
|
|
if (epubViewer) {
|
|
// Get parent element dimensions
|
|
const parent = epubViewer.parentElement;
|
|
|
|
containerWidth = parent?.clientWidth! * 0.9 - 240;
|
|
containerHeight = window.innerHeight * 0.8;
|
|
}
|
|
}
|
|
|
|
// Handle window resize
|
|
function handleResize() {
|
|
updateDimensions();
|
|
if (rendition) {
|
|
rendition.resize(containerWidth, containerHeight);
|
|
}
|
|
}
|
|
|
|
async function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'ArrowLeft') {
|
|
await prevPage();
|
|
} else if (e.key === 'ArrowRight') {
|
|
await nextPage();
|
|
}
|
|
}
|
|
|
|
async function nextPage() {
|
|
if (hasNextPage) await rendition!.next();
|
|
}
|
|
|
|
async function prevPage() {
|
|
if (hasPrevPage) await rendition!.prev();
|
|
}
|
|
|
|
async function setUserBookProgress() {
|
|
clearTimeout(debounceTimeout);
|
|
|
|
debounceTimeout = setTimeout(async () => {
|
|
await fetch(`/api/books/progress/${bookId}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
percentage: currentProgress,
|
|
epub_cfi: currentLocation,
|
|
completed: currentProgress === 1
|
|
})
|
|
});
|
|
}, 3000);
|
|
}
|
|
|
|
const getChapters = async (book: Book) => {
|
|
await book.ready;
|
|
// Get spine items (basic chapter structure)
|
|
const spineItems = book.spine.items.map((item, index) => ({
|
|
id: item.idref,
|
|
href: item.href,
|
|
index: index,
|
|
label: item.label || `Chapter ${index + 1}`,
|
|
cfi: book.spine.get(index).cfiBase // Get CFI from spine
|
|
}));
|
|
|
|
// Get table of contents for better labels
|
|
const toc = await book.loaded.navigation;
|
|
|
|
// Combine spine items with TOC information
|
|
const chapters = toc.toc.map((chapter) => {
|
|
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
|
|
return {
|
|
...spineItem,
|
|
label: chapter.label || spineItem?.label,
|
|
subitems: chapter.subitems,
|
|
href: chapter.href,
|
|
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
|
|
};
|
|
});
|
|
|
|
return chapters;
|
|
};
|
|
async function navigateToChapter(chapter: any) {
|
|
try {
|
|
if (!rendition || !book) return;
|
|
|
|
// Ensure book is ready
|
|
await book.ready;
|
|
|
|
// Try different navigation methods
|
|
if (chapter.href) {
|
|
await rendition.display(chapter.href);
|
|
} else {
|
|
console.error('No valid navigation target found for chapter:', chapter);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error navigating to chapter:', error);
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (browser) {
|
|
// Initial setup
|
|
updateDimensions();
|
|
|
|
try {
|
|
// Fetch the EPUB file
|
|
const response = await fetch(bookUrl);
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
|
|
// Create book from array buffer
|
|
book = new Book();
|
|
await book.open(arrayBuffer, 'binary');
|
|
|
|
// Generate locations if they do not exist in localStorage
|
|
let existingLocations = localStorage.getItem(`${bookId}-locations`);
|
|
let locations;
|
|
if (existingLocations) {
|
|
locations = JSON.parse(existingLocations);
|
|
book.locations.load(locations);
|
|
} else {
|
|
locations = await book.locations.generate(1600);
|
|
// Save locations to localStorage
|
|
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
|
|
}
|
|
|
|
await book.ready;
|
|
|
|
// Render the book to the viewer element
|
|
rendition = book.renderTo('epub-viewer', {
|
|
width: containerWidth,
|
|
height: containerHeight,
|
|
spread: 'auto',
|
|
snap: true,
|
|
manager: 'continuous',
|
|
flow: 'paginated'
|
|
});
|
|
|
|
// Set the key listener on the iframe element
|
|
let keyListener = async function (e: any) {
|
|
// Left Key
|
|
if ((e.keyCode || e.which) == 37) {
|
|
await prevPage();
|
|
}
|
|
// Right Key
|
|
if ((e.keyCode || e.which) == 39) {
|
|
await nextPage();
|
|
}
|
|
};
|
|
|
|
// Add resize listener
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
// Add Key listener
|
|
rendition.on('keydown', keyListener);
|
|
|
|
// Listen to location changes
|
|
rendition.on('locationChanged', async (location: DisplayedLocation) => {
|
|
if (!location?.start) return;
|
|
|
|
currentLocation = rendition!.currentLocation().start.cfi;
|
|
currentProgress = book?.locations.percentageFromCfi(currentLocation);
|
|
|
|
await setUserBookProgress();
|
|
|
|
hasNextPage = !rendition!.location.atEnd;
|
|
hasPrevPage = !rendition!.location.atStart;
|
|
});
|
|
|
|
chapters = await getChapters(book);
|
|
|
|
let initialLocationCfi =
|
|
currentLocation || book.locations.cfiFromPercentage(currentProgress);
|
|
|
|
if (initialLocationCfi) {
|
|
await rendition.display(initialLocationCfi);
|
|
} else {
|
|
await rendition.display();
|
|
}
|
|
|
|
isMounted = true;
|
|
isReaderVisible = true;
|
|
} catch (error) {
|
|
console.error('Error loading EPUB', error);
|
|
}
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (browser) {
|
|
window.removeEventListener('resize', handleResize);
|
|
book.destroy();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:document onkeydown={handleKeydown} />
|
|
|
|
<Sidebar.Provider bind:open={isSidebarOpen}>
|
|
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}>
|
|
<Sidebar.Header />
|
|
<Sidebar.Content>
|
|
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
|
|
<Sidebar.GroupContent>
|
|
<Sidebar.Menu>
|
|
{#each chapters as chapter}
|
|
{#if chapter.subitems.length > 0}
|
|
<Collapsible.Root class="group/collapsible">
|
|
<div class="flex w-full items-center gap-1">
|
|
<Sidebar.MenuItem class="min-w-0 flex-1">
|
|
<Sidebar.MenuButton
|
|
class="w-full"
|
|
onclick={async () => await navigateToChapter(chapter)}
|
|
>
|
|
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
<Collapsible.Trigger class="flex-shrink-0 p-2">
|
|
<ChevronDown
|
|
class="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
|
|
/>
|
|
</Collapsible.Trigger>
|
|
</div>
|
|
<Collapsible.Content>
|
|
<Sidebar.MenuSub>
|
|
{#each chapter.subitems as subchapter}
|
|
<Sidebar.MenuSubItem class="min-w-0">
|
|
<Sidebar.MenuButton
|
|
class="w-full"
|
|
onclick={async () => await navigateToChapter(subchapter)}
|
|
>
|
|
<span class="block truncate" title={subchapter.label}
|
|
>{subchapter.label}</span
|
|
>
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuSubItem>
|
|
{/each}
|
|
</Sidebar.MenuSub>
|
|
</Collapsible.Content>
|
|
</Collapsible.Root>
|
|
{:else}
|
|
<Sidebar.MenuItem class="min-w-0">
|
|
<Sidebar.MenuButton
|
|
class="w-full"
|
|
onclick={async () => await navigateToChapter(chapter)}
|
|
>
|
|
<span class="block truncate">{chapter.label}</span>
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
{/if}
|
|
{/each}
|
|
</Sidebar.Menu>
|
|
</Sidebar.GroupContent>
|
|
<Sidebar.Group />
|
|
</Sidebar.Content>
|
|
<Sidebar.Footer />
|
|
</Sidebar.Root>
|
|
<main class="flex w-full overflow-hidden">
|
|
{#if browser}
|
|
{#if !isReaderVisible}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<Spinner />
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
|
|
<div class="flex">
|
|
<Sidebar.Trigger />
|
|
</div>
|
|
|
|
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
|
|
<ChevronLeft
|
|
onclick={prevPage}
|
|
class={hasPrevPage
|
|
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
|
: 'text-muted'}
|
|
/>
|
|
<div
|
|
id="epub-viewer"
|
|
bind:this={epubViewer}
|
|
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
|
|
></div>
|
|
<ChevronRight
|
|
onclick={nextPage}
|
|
class={hasNextPage
|
|
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
|
: 'text-muted'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
</Sidebar.Provider>
|
|
|
|
<style>
|
|
.epub-content {
|
|
position: relative;
|
|
background: white;
|
|
}
|
|
|
|
/* Position separator relative to epub content */
|
|
@media (min-width: 1209px) {
|
|
.epub-content:after {
|
|
/* Calculate position based on content width */
|
|
--separator-position: calc(var(--content-width, 100%) / 2);
|
|
position: absolute;
|
|
width: 1px;
|
|
border-right: 1px #000 solid;
|
|
height: 90%;
|
|
z-index: 1;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
top: 5%;
|
|
opacity: 0.15;
|
|
box-shadow: -2px 0 15px rgba(0, 0, 0, 1);
|
|
content: '';
|
|
}
|
|
}
|
|
</style>
|