Initial commit
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
<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, 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({
|
||||
progress: currentProgress,
|
||||
epub_loc: 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>
|
||||
Reference in New Issue
Block a user