annotations and styles cleanup

This commit is contained in:
jrosh 2025-05-29 20:09:12 +02:00
commit ea373332bb
No known key found for this signature in database
GPG key ID: A4D68DCA6C9CCD2D
9 changed files with 1192 additions and 257 deletions

View file

@ -0,0 +1,225 @@
<!-- src/components/AnnotationModal.vue -->
<template>
<div v-if="isOpen" class="modal-overlay" @click="$emit('close')">
<div class="annotation-modal" @click.stop>
<h3>{{ isEditing ? t("settings.update") : t("settings.add") }} {{t("reader.annotation")}}</h3>
<div class="selected-text">
<strong>{{ t("reader.selectedText") }}</strong>
<p>"{{ selectedText }}"</p>
</div>
<form @submit.prevent="saveAnnotation">
<div class="form-group">
<label for="annotation-name">{{ t("reader.nameAnnotation") }}</label>
<input
id="annotation-name"
v-model="name"
type="text"
:placeholder="t('reader.namePlaceholderAnnotation')"
class="annotation-input"
ref="nameInput"
/>
<small class="form-hint">{{ t("reader.namelessAnnotation") }}</small>
</div>
<div class="form-group">
<label for="annotation-note">{{ t("reader.noteAnnotation") }}</label>
<textarea
id="annotation-note"
v-model="note"
:placeholder="t('reader.notePlaceholderAnnotation')"
class="annotation-textarea"
rows="3"
></textarea>
</div>
<div class="modal-actions">
<button type="button" @click="$emit('close')" class="cancel-btn">
{{ name.trim() ? t("settings.cancel") : t("settings.discard") }}
</button>
<button
type="submit"
class="save-btn"
:disabled="!name.trim()"
>
{{ isEditing ? t("settings.update") : t("settings.save") }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue';
import { useI18n } from '../i18n/usei18n';
const { t } = useI18n();
const props = defineProps({
isOpen: {
type: Boolean,
default: false
},
selectedText: {
type: String,
default: ''
},
initialName: {
type: String,
default: ''
},
initialNote: {
type: String,
default: ''
},
isEditing: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'save']);
const name = ref(props.initialName);
const note = ref(props.initialNote);
const nameInput = ref<HTMLInputElement | null>(null);
// Watch for prop changes to update internal state
watch(() => props.initialName, (newVal) => {
name.value = newVal;
});
watch(() => props.initialNote, (newVal) => {
note.value = newVal;
});
// Focus the name input when modal opens
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
nextTick(() => {
nameInput.value?.focus();
});
}
});
const saveAnnotation = () => {
if (!name.value.trim()) return;
emit('save', {
name: name.value.trim(),
note: note.value.trim()
});
// Reset form
name.value = '';
note.value = '';
};
</script>
<style>
.form-hint {
font-size: 0.75rem;
color: var(--text-color);
opacity: 0.7;
margin-top: 0.25rem;
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.save-btn:disabled:hover {
background: var(--accent-color);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.annotation-modal {
background-color: var(--background-color);
border-radius: 8px;
padding: 1.5rem;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.annotation-modal h3 {
margin-top: 0;
color: var(--text-color);
}
.selected-text {
margin: 1rem 0;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
font-size: 0.9rem;
}
.selected-text p {
margin: 0.5rem 0 0;
font-style: italic;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-color);
}
.annotation-input, .annotation-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--divider-color);
border-radius: 4px;
background-color: var(--background-color);
color: var(--text-color);
font-size: 0.9rem;
}
.annotation-input:focus, .annotation-textarea:focus {
border-color: var(--accent-color);
outline: none;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
}
.cancel-btn, .save-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.cancel-btn {
background-color: transparent;
color: var(--text-color);
border: 1px solid var(--divider-color);
}
.save-btn {
background-color: var(--accent-color);
color: white;
}
</style>

View file

@ -0,0 +1,44 @@
<!-- src/components/AnnotationsButton.vue -->
<template>
<button v-if="count > 0" class="annotations-toggle-btn" @click="$emit('toggle')" :title="isOpen ? 'Close annotations' : 'Open annotations'">
<span class="annotation-count">{{ count }}</span>
</button>
</template>
<script setup lang="ts">
defineProps({
isOpen: {
type: Boolean,
default: false
},
count: {
type: Number,
default: 0
}
});
defineEmits(['toggle']);
</script>
<style>
.annotations-toggle-btn {
position: fixed;
right: 60px;
top: 6px;
width: 24px;
height: 24px;
color: var(--accent-color);
border: none;
border-bottom: 1px solid var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 40;
transition: transform 0.2s;
font-size: 1rem;
}
.annotations-toggle-btn:active {
transform: scale(0.95);
}
</style>

View file

@ -0,0 +1,206 @@
<!-- src/components/AnnotationsPanel.vue -->
<template>
<div class="annotations-panel" v-show="isVisible">
<div class="annotations-header">
<h3>{{ t('reader.annotations') }} ({{ annotations.length }})</h3>
<button class="close-btn" @click="$emit('close')">×</button>
</div>
<div v-if="annotations.length === 0" class="no-annotations">
{{ t('reader.emptyAnnotationList')}}
</div>
<div v-else class="annotations-list">
<div
v-for="annotation in annotations"
:key="annotation.id"
class="annotation-item"
@click="$emit('goto', annotation.cfiRange)"
>
<div class="annotation-header">
<h4 class="annotation-name">{{ annotation.name }}</h4>
<span class="annotation-date">{{ formatDate(annotation.createdAt) }}</span>
</div>
<p class="annotation-text">{{ truncateText(annotation.text, 100) }}</p>
<div class="annotation-actions">
<button @click.stop="$emit('edit', annotation)" class="edit-btn">{{ t('settings.edit') }}</button>
<button @click.stop="$emit('delete', annotation.id)" class="delete-btn">{{ t('settings.delete') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n/usei18n';
const { t } = useI18n();
defineProps({
annotations: {
type: Array,
required: true
},
isVisible: {
type: Boolean,
default: false
}
});
defineEmits(['close', 'goto', 'edit', 'delete']);
// Utility functions
const formatDate = (timestamp: number): string => {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
</script>
<style>
.annotations-panel {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 300px;
background: var(--background-color);
border-left: 1px solid var(--divider-color);
padding: 1rem;
overflow-y: auto;
z-index: 50;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
}
.annotations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.annotations-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color);
opacity: 0.7;
transition: opacity 0.2s;
}
.close-btn:hover {
opacity: 1;
}
.no-annotations {
color: var(--text-color);
opacity: 0.7;
font-style: italic;
text-align: center;
padding: 2rem 1rem;
}
.annotations-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.annotation-item {
border: 1px solid var(--divider-color);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
background: var(--background-color);
}
.annotation-item:hover {
border-color: var(--accent-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.annotation-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.annotation-name {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
flex: 1;
}
.annotation-date {
font-size: 0.75rem;
color: var(--text-color);
opacity: 0.6;
margin-left: 0.5rem;
}
.annotation-text {
margin: 0 0 0.5rem 0;
font-size: 0.8rem;
color: var(--text-color);
}
.annotation-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.edit-btn, .delete-btn {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.edit-btn {
background-color: var(--accent-color);
color: white;
}
.delete-btn {
background-color: #f44336;
color: white;
}
/* Responsive styles */
@media (max-width: 768px) {
.annotations-panel {
position: fixed;
width: 100%;
left: 0;
right: 0;
top: auto;
bottom: 0;
height: 50%;
z-index: 50;
border-top: 1px solid var(--divider-color);
border-left: none;
}
}
</style>

View file

@ -48,14 +48,14 @@
@click="confirmDelete" @click="confirmDelete"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M14 11v6m-4-6v6M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M4 7h16M7 7l2-4h6l2 4" stroke-width="1"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M14 11v6m-4-6v6M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M4 7h16M7 7l2-4h6l2 4" stroke-width="1"/></svg>
{{ t('settings.deleteBook') }} {{ t('settings.delete') }}
</button> </button>
<button <button
class="save-button" class="save-button"
@click="saveChanges" @click="saveChanges"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M5.616 20q-.691 0-1.153-.462T4 18.384V5.616q0-.691.463-1.153T5.616 4h10.29q.323 0 .628.13q.305.132.522.349l2.465 2.465q.218.218.348.522q.131.305.131.628v10.29q0 .691-.462 1.154T18.384 20zM19 7.85L16.15 5H5.616q-.27 0-.443.173T5 5.616v12.769q0 .269.173.442t.443.173h12.769q.269 0 .442-.173t.173-.443zm-7 8.689q.827 0 1.414-.587T14 14.538t-.587-1.413T12 12.539t-1.413.586T10 14.538t.587 1.414t1.413.586M7.577 9.77h5.808q.348 0 .578-.23t.23-.577V7.577q0-.348-.23-.578t-.578-.23H7.577q-.348 0-.578.23t-.23.578v1.385q0 .348.23.578t.578.23M5 7.85V19V5z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M5.616 20q-.691 0-1.153-.462T4 18.384V5.616q0-.691.463-1.153T5.616 4h10.29q.323 0 .628.13q.305.132.522.349l2.465 2.465q.218.218.348.522q.131.305.131.628v10.29q0 .691-.462 1.154T18.384 20zM19 7.85L16.15 5H5.616q-.27 0-.443.173T5 5.616v12.769q0 .269.173.442t.443.173h12.769q.269 0 .442-.173t.173-.443zm-7 8.689q.827 0 1.414-.587T14 14.538t-.587-1.413T12 12.539t-1.413.586T10 14.538t.587 1.414t1.413.586M7.577 9.77h5.808q.348 0 .578-.23t.23-.577V7.577q0-.348-.23-.578t-.578-.23H7.577q-.348 0-.578.23t-.23.578v1.385q0 .348.23.578t.578.23M5 7.85V19V5z"/></svg>
{{ t('settings.saveChanges') }} {{ t('settings.save') }}
</button> </button>
</div> </div>

View file

@ -1,6 +1,7 @@
// src/composables/useStyles.ts // src/composables/useStyles.ts
import { ref, watch, nextTick } from 'vue'; import { ref, watch, nextTick } from 'vue';
import { type RenditionTheme, type StylesOptions } from '../types/styles'; import { type StylesOptions } from '../types/styles';
import type Rendition from 'epubjs/types/rendition';
export function useStyles(options: StylesOptions = {}) { export function useStyles(options: StylesOptions = {}) {
// Initialize style refs with defaults or provided values // Initialize style refs with defaults or provided values
@ -10,36 +11,45 @@ export function useStyles(options: StylesOptions = {}) {
const fontFamily = ref(options.initialFontFamily || 'Arial, sans-serif'); const fontFamily = ref(options.initialFontFamily || 'Arial, sans-serif');
const fontSize = ref(options.initialFontSize || '100%'); const fontSize = ref(options.initialFontSize || '100%');
const stylesModalOpen = ref(false); const stylesModalOpen = ref(false);
const rendition = ref<RenditionTheme | null>(null); const rendition = ref<Rendition | null>(null);
// Track if hooks are registered to avoid duplicate registration // Track if hooks are registered to avoid duplicate registration
let hooksRegistered = false; let hooksRegistered = false;
let renderedEventListener: ((section: any, view: any) => void) | null = null;
// Local storage management // Local storage management
const loadSavedStyles = () => { const loadSavedStyles = () => {
const savedStyles = { try {
text: localStorage.getItem('reader-text-color'), const savedStyles = {
background: localStorage.getItem('reader-background-color'), text: localStorage.getItem('reader-text-color'),
accent: localStorage.getItem('accent-color'), background: localStorage.getItem('reader-background-color'),
fontFamily: localStorage.getItem('reader-font-family'), accent: localStorage.getItem('accent-color'),
fontSize: localStorage.getItem('reader-font-size') fontFamily: localStorage.getItem('reader-font-family'),
}; fontSize: localStorage.getItem('reader-font-size')
};
if (savedStyles.text) textColor.value = savedStyles.text;
if (savedStyles.background) backgroundColor.value = savedStyles.background; if (savedStyles.text) textColor.value = savedStyles.text;
if (savedStyles.accent) accentColor.value = savedStyles.accent; if (savedStyles.background) backgroundColor.value = savedStyles.background;
if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily; if (savedStyles.accent) accentColor.value = savedStyles.accent;
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize; if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily;
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize;
applyStylesToDocument();
applyStylesToDocument();
} catch (error) {
console.error('Error loading saved styles:', error);
}
}; };
const saveStyles = () => { const saveStyles = () => {
localStorage.setItem('reader-text-color', textColor.value); try {
localStorage.setItem('reader-background-color', backgroundColor.value); localStorage.setItem('reader-text-color', textColor.value);
localStorage.setItem('accent-color', accentColor.value); localStorage.setItem('reader-background-color', backgroundColor.value);
localStorage.setItem('reader-font-family', fontFamily.value); localStorage.setItem('accent-color', accentColor.value);
localStorage.setItem('reader-font-size', fontSize.value); localStorage.setItem('reader-font-family', fontFamily.value);
localStorage.setItem('reader-font-size', fontSize.value);
} catch (error) {
console.error('Error saving styles:', error);
}
}; };
const applyStylesToDocument = () => { const applyStylesToDocument = () => {
@ -121,39 +131,56 @@ export function useStyles(options: StylesOptions = {}) {
color: ${textColor.value} !important; color: ${textColor.value} !important;
font-family: ${fontFamily.value} !important; font-family: ${fontFamily.value} !important;
} }
::selection {
background-color: ${accentColor.value}4D;
color: inherit;
}
::-moz-selection {
background-color: ${accentColor.value}4D;
color: inherit;
}
`; `;
head.appendChild(themeStyle); head.appendChild(themeStyle);
}; };
// Apply styles to all currently loaded content // Apply styles to all currently loaded content - IMPROVED VERSION
const applyStylesToAllContent = () => { const applyStylesToAllContent = async () => {
if (!rendition.value) return; if (!rendition.value) return;
try { try {
// Get all iframes (epub.js uses iframes for content) // Method 1: Use getContents() API (most reliable)
const iframes = rendition.value.manager?.container?.querySelectorAll('iframe'); const contents = rendition.value.getContents();
if (contents) {
if (iframes) { // Handle both single Contents object and array of Contents
iframes.forEach((iframe: HTMLIFrameElement) => { const contentsArray = Array.isArray(contents) ? contents : [contents];
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document; await Promise.all(
if (doc) { contentsArray.map(async (content: any) => {
applyStylesToContent(doc); if (content && content.document) {
await nextTick(); // Ensure DOM is ready
applyStylesToContent(content.document);
} }
} catch (error) { })
console.warn('Could not access iframe content:', error); );
}
});
} }
// Also try to get content through epub.js API // Method 2: Use views() as fallback
if (rendition.value.getContents) { const views = rendition.value.views();
const contents = rendition.value.getContents(); if (views && Array.isArray(views)) {
contents.forEach((content: any) => { await Promise.all(
if (content.document) { views.map(async (view: any) => {
applyStylesToContent(content.document); try {
} const doc = view.document || view.iframe?.contentDocument;
}); if (doc) {
await nextTick();
applyStylesToContent(doc);
}
} catch (error) {
console.warn('Could not access view content:', error);
}
})
);
} }
} catch (error) { } catch (error) {
@ -161,12 +188,56 @@ export function useStyles(options: StylesOptions = {}) {
} }
}; };
// Setup event listeners for automatic style application
const setupEventListeners = () => {
if (!rendition.value || renderedEventListener) return;
try {
// Create event listener function
renderedEventListener = async (section: any, view: any) => {
try {
if (view && view.document) {
await nextTick();
applyStylesToContent(view.document);
}
} catch (error) {
console.warn('Could not apply styles to rendered content:', error);
}
};
// Register event listener
rendition.value.on('rendered', renderedEventListener);
// Also listen for display events
rendition.value.on('displayed', async (section: any) => {
await nextTick();
await applyStylesToAllContent();
});
} catch (error) {
console.error('Error setting up event listeners:', error);
}
};
// Remove event listeners when cleaning up
const removeEventListeners = () => {
if (!rendition.value || !renderedEventListener) return;
try {
rendition.value.off('rendered', renderedEventListener);
renderedEventListener = null;
} catch (error) {
console.error('Error removing event listeners:', error);
}
};
const registerContentHooks = () => { const registerContentHooks = () => {
if (!rendition.value || hooksRegistered) return; if (!rendition.value || hooksRegistered) return;
try { try {
rendition.value.hooks.content.register((contents: any) => { rendition.value.hooks.content.register(async (contents: any) => {
if (contents.document) { if (contents.document) {
await nextTick();
applyStylesToContent(contents.document); applyStylesToContent(contents.document);
} }
}); });
@ -181,30 +252,37 @@ export function useStyles(options: StylesOptions = {}) {
if (!rendition.value) return; if (!rendition.value) return;
try { try {
if (rendition.value.themes) { const { themes } = rendition.value;
const { themes } = rendition.value;
if (themes) {
// Apply theme overrides
themes.override('color', textColor.value); themes.override('color', textColor.value);
themes.override('background', backgroundColor.value); themes.override('background', backgroundColor.value);
themes.override('font-family', fontFamily.value); themes.override('font-family', fontFamily.value);
if (themes.fontSize) { // Use fontSize method if available, otherwise use override
if (typeof themes.fontSize === 'function') {
themes.fontSize(fontSize.value); themes.fontSize(fontSize.value);
} else { } else {
themes.override('font-size', fontSize.value); themes.override('font-size', fontSize.value);
} }
} }
// Wait for next tick to ensure themes are applied
await nextTick(); await nextTick();
applyStylesToAllContent();
// Apply styles to all current content
await applyStylesToAllContent();
// Setup hooks and event listeners for future content
registerContentHooks(); registerContentHooks();
setupEventListeners();
} catch (error) { } catch (error) {
console.error('Error applying styles to reader:', error); console.error('Error applying styles to reader:', error);
} }
}; };
// Update status bar meta tags // Update status bar meta tags
const setMeta = (name: string, content: string) => { const setMeta = (name: string, content: string) => {
let tag = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement || document.createElement('meta'); let tag = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement || document.createElement('meta');
@ -213,18 +291,36 @@ export function useStyles(options: StylesOptions = {}) {
if (!tag.parentNode) document.head.appendChild(tag); if (!tag.parentNode) document.head.appendChild(tag);
}; };
// Rendition setup (enhanced) // Rendition setup - IMPROVED VERSION
const setRendition = async (renditionObj: RenditionTheme): Promise<void> => { const setRendition = async (renditionObj: Rendition): Promise<void> => {
// Clean up previous rendition
if (rendition.value) {
removeEventListeners();
hooksRegistered = false;
}
rendition.value = renditionObj; rendition.value = renditionObj;
hooksRegistered = false; // Reset hook registration flag hooksRegistered = false; // Reset hook registration flag
// Wait for rendition to be ready try {
if (renditionObj.display) { // Wait for rendition to be ready if it has a started promise
await renditionObj.display(); if (renditionObj.started) {
await renditionObj.started;
}
// Wait for initial display if display method exists
if (renditionObj.display) {
await renditionObj.display();
}
// Apply styles after everything is ready
await applyStylesToReader();
} catch (error) {
console.error('Error setting up rendition:', error);
// Still try to apply styles even if setup partially failed
await applyStylesToReader();
} }
// Apply styles after display
await applyStylesToReader();
}; };
const toggleStylesModal = () => { const toggleStylesModal = () => {
@ -233,13 +329,17 @@ export function useStyles(options: StylesOptions = {}) {
// Force refresh all styles (useful for debugging or manual refresh) // Force refresh all styles (useful for debugging or manual refresh)
const refreshStyles = async () => { const refreshStyles = async () => {
await nextTick(); try {
applyStylesToDocument(); await nextTick();
await applyStylesToReader(); applyStylesToDocument();
await applyStylesToReader();
} catch (error) {
console.error('Error refreshing styles:', error);
}
}; };
// Watch for style changes with debouncing // Watch for style changes with debouncing - IMPROVED VERSION
let styleUpdateTimeout: NodeJS.Timeout | null = null; let styleUpdateTimeout: ReturnType<typeof setTimeout> | null = null;
watch( watch(
[textColor, backgroundColor, accentColor, fontFamily, fontSize], [textColor, backgroundColor, accentColor, fontFamily, fontSize],
@ -251,25 +351,38 @@ export function useStyles(options: StylesOptions = {}) {
// Debounce style updates to avoid too frequent changes // Debounce style updates to avoid too frequent changes
styleUpdateTimeout = setTimeout(async () => { styleUpdateTimeout = setTimeout(async () => {
applyStylesToDocument(); try {
await applyStylesToReader(); applyStylesToDocument();
saveStyles(); await applyStylesToReader();
saveStyles();
if (options.onStyleChange) {
options.onStyleChange( if (options.onStyleChange) {
textColor.value, options.onStyleChange(
backgroundColor.value, textColor.value,
accentColor.value, backgroundColor.value,
fontFamily.value, accentColor.value,
fontSize.value fontFamily.value,
); fontSize.value
);
}
} catch (error) {
console.error('Error updating styles:', error);
} }
}, 100); }, 150); // Slightly increased debounce time for better performance
} }
); );
// Load saved styles on initialization
loadSavedStyles(); loadSavedStyles();
// Cleanup function
const cleanup = () => {
removeEventListeners();
if (styleUpdateTimeout) {
clearTimeout(styleUpdateTimeout);
}
};
return { return {
textColor, textColor,
backgroundColor, backgroundColor,
@ -282,6 +395,7 @@ export function useStyles(options: StylesOptions = {}) {
setRendition, setRendition,
applyStylesToReader, applyStylesToReader,
applyStylesToDocument, applyStylesToDocument,
refreshStyles refreshStyles,
cleanup
}; };
} }

View file

@ -16,6 +16,15 @@ export const translations = {
reader: { reader: {
loading: 'Loading', loading: 'Loading',
back: 'Back', back: 'Back',
annotation: 'Annotation',
annotations: 'Annotations',
selectedText: 'Selected text',
namelessAnnotation: 'Note will be discarded if no name is provided',
nameAnnotation: 'Annotation name',
noteAnnotation: 'Note',
namePlaceholderAnnotation: 'Enter a name for this annotation (required)...',
notePlaceholderAnnotation: 'Add a note for this annotation (optional)...',
emptyAnnotationList: 'No annotations yet. Select text and add a note to create your first annotation.'
}, },
settings: { settings: {
settings: 'Settings', settings: 'Settings',
@ -33,7 +42,12 @@ export const translations = {
deleteBook: 'Delete Book', deleteBook: 'Delete Book',
confirmDelete: 'Are you sure you want to delete this book? This action is irreversible!', confirmDelete: 'Are you sure you want to delete this book? This action is irreversible!',
cancel: 'Cancel', cancel: 'Cancel',
delete: 'Delete' delete: 'Delete',
discard: 'Discard',
save: 'Save',
edit: 'Edit',
update: 'Update',
add: 'Add'
}, },
messages: { messages: {
success: 'Operation completed successfully', success: 'Operation completed successfully',
@ -44,48 +58,64 @@ export const translations = {
welcome: 'Welcome!' welcome: 'Welcome!'
}, },
}, },
sk: {
library: { sk: {
library: 'Knižnica', library: {
title: 'Názov', library: 'Knižnica',
emptyLibrary: 'V knižnici sa nenašli žiadne knihy.', title: 'Názov',
loading: 'Načítavam Vašu knižnicu...', emptyLibrary: 'V knižnici sa nenašli žiadne knihy.',
download: 'Stiahnuť', loading: 'Načítava sa vaša knižnica...',
read: 'Čítať', download: 'Stiahnuť',
size: 'Veľkosť', read: 'Čítať',
local: 'Moje', size: 'Veľkosť',
added: 'Pridané', local: 'Vlastnené',
filename: 'Názov súboru', added: 'Pridané',
}, filename: 'Názov súboru',
reader: {
loading: 'Načítavam',
back: 'Späť',
},
settings: {
settings: 'Nastavenia',
textColor: 'Farba Textu',
backgroundColor: 'Farba Pozadia',
accentColor: 'Terciárna Farba',
fontFamily: 'Písmo',
fontSize: "Veľkosť písma",
presets: 'Predvoľby',
white: 'Svetlá',
black: 'Tmavá',
sepia: 'Sépia',
bookSettings: 'Detail Knihy',
saveChanges: 'Uložiť zmeny',
deleteBook: 'Odstrániť Knihu',
confirmDelete: 'Naozaj chcete odstrániť ',
cancel: 'Zrušiť',
delete: 'Odstrániť'
},
messages: {
success: 'Operácia bola úspešne dokončená',
error: 'Došlo k chybe',
loading: 'Načítava sa...',
confirmDelete: 'Naozaj chcete zmazať túto položku?',
noResults: 'Nenašli sa žiadne výsledky',
welcome: 'Vitajte!'
},
}, },
reader: {
loading: 'Načítava sa',
back: 'Späť',
annotation: 'Poznámka',
annotations: 'Poznámky',
selectedText: 'Vybraný text',
namelessAnnotation: 'Poznámka bude zrušená, ak nezadáte názov',
nameAnnotation: 'Názov poznámky',
noteAnnotation: 'Poznámka',
namePlaceholderAnnotation: 'Zadajte názov pre túto poznámku (povinné)...',
notePlaceholderAnnotation: 'Pridajte poznámku k tejto anotácii (voliteľné)...',
emptyAnnotationList: 'Zatiaľ žiadne anotácie. Vyberte text a pridajte poznámku, čím vytvoríte svoju prvú anotáciu.'
},
settings: {
settings: 'Nastavenia',
textColor: 'Farba textu',
backgroundColor: 'Farba pozadia',
accentColor: 'Akcentová farba',
fontFamily: 'Písmo',
fontSize: 'Veľkosť písma',
presets: 'Prednastavenia',
white: 'Svetlý',
black: 'Tmavý',
sepia: 'Sepia',
bookSettings: 'Nastavenia knihy',
saveChanges: 'Uložiť zmeny',
deleteBook: 'Odstrániť knihu',
confirmDelete: 'Naozaj chcete túto knihu odstrániť? Táto akcia je nevratná!',
cancel: 'Zrušiť',
delete: 'Odstrániť',
discard: 'Zahodiť',
save: 'Uložiť',
edit: 'Upraviť',
update: 'Aktualizovať',
add: 'Pridať'
},
messages: {
success: 'Operácia bola úspešne dokončená',
error: 'Došlo k chybe',
loading: 'Načítava sa...',
confirmDelete: 'Naozaj chcete odstrániť ',
noResults: 'Nenašli sa žiadne výsledky',
welcome: 'Vitajte!'
},
}
}; };

25
src/types/annotations.ts Normal file
View file

@ -0,0 +1,25 @@
// src/types/annotations.ts
import type Contents from 'epubjs/types/contents';
export interface Annotation {
id: string;
bookId: string;
cfiRange: string;
text: string;
name: string;
note?: string;
createdAt: number;
updatedAt: number;
chapter?: string;
}
export interface PendingAnnotation {
cfiRange: string;
text: string;
contents: Contents;
}
export interface AnnotationFormData {
name: string;
note: string;
}

View file

@ -1,19 +1,4 @@
// src/types/styles.ts // src/types/styles.ts
export interface RenditionTheme {
themes: {
register: (name: string, styles: string) => void;
select: (name: string) => void;
unregister?: (name: string) => void;
update?: (name: string) => void;
override: (property: string, value: string | object) => void;
fontSize: (size: string) => void;
};
views?: () => Array<any>;
next?: () => void;
prev?: () => void;
[key: string]: any;
}
export interface StylesOptions { export interface StylesOptions {
initialTextColor?: string; initialTextColor?: string;
initialBackgroundColor?: string; initialBackgroundColor?: string;

View file

@ -1,18 +1,13 @@
<!-- ReaderView.vue --> <!-- ReaderView.vue (Updated with separated components) -->
<template> <template>
<div class="reader-container"> <div class="reader-container">
<div <div
class="reader-area" class="reader-area"
:class="{ :class="{
slideRight: expandedToc, slideRight: expandedToc,
slideLeft: stylesModalOpen slideLeft: stylesModalOpen
}" }"
> >
<!-- <button class="back-btn" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button> -->
<button <button
v-if="showToc" v-if="showToc"
class="toc-button" class="toc-button"
@ -25,17 +20,16 @@
<h2 class="book-title">{{ bookTitle }}</h2> <h2 class="book-title">{{ bookTitle }}</h2>
<StylesButton <StylesButton :is-open="stylesModalOpen" @toggle="toggleStylesModal" />
:is-open="stylesModalOpen"
@toggle="toggleStylesModal" <AnnotationsButton
:is-open="showAnnotationsPanel"
:count="savedAnnotations.length"
@toggle="toggleAnnotationsPanel"
/> />
<div v-if="loading" class="loading"> <div v-if="loading" class="loading">{{ t("reader.loading") }}</div>
{{ t("reader.loading") }} <div v-else-if="error" class="error">{{ error }}</div>
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else class="reader-view"> <div v-else class="reader-view">
<EpubView <EpubView
ref="epubRef" ref="epubRef"
@ -46,6 +40,10 @@
:tocChanged="onTocChange" :tocChanged="onTocChange"
:getRendition="getRendition" :getRendition="getRendition"
/> />
<!--:epubOptions="{
flow: 'scrolled',
manager: 'continuous',
}" -->
</div> </div>
</div> </div>
@ -58,10 +56,30 @@
:setLocation="setLocation" :setLocation="setLocation"
/> />
</div> </div>
<!-- TOC Background Overlay -->
<div v-if="expandedToc" class="toc-background" @click="toggleToc"></div> <div v-if="expandedToc" class="toc-background" @click="toggleToc"></div>
</div> </div>
<!-- Annotations Panel (imported component) -->
<AnnotationsPanel
:annotations="savedAnnotations"
:is-visible="showAnnotationsPanel"
@close="showAnnotationsPanel = false"
@goto="goToAnnotation"
@edit="editAnnotation"
@delete="deleteAnnotation"
/>
<!-- Annotation Modal (imported component) -->
<AnnotationModal
:is-open="showAnnotationModal"
:selected-text="pendingAnnotation?.text || ''"
:initial-name="annotationName"
:initial-note="annotationNote"
:is-editing="!!editingAnnotation"
@close="closeAnnotationModal"
@save="handleAnnotationSave"
/>
<StylesModal <StylesModal
v-model:text-color="textColor" v-model:text-color="textColor"
v-model:background-color="backgroundColor" v-model:background-color="backgroundColor"
@ -77,38 +95,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ref, ref, reactive, onMounted, onUnmounted, toRefs, h,
reactive, getCurrentInstance, Transition, nextTick
onMounted,
onUnmounted,
toRefs,
h,
getCurrentInstance,
Transition,
} from "vue"; } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useI18n } from "../i18n/usei18n"; import { useI18n } from "../i18n/usei18n";
import StylesModal from "../components/StylesModal.vue"; import StylesModal from "../components/StylesModal.vue";
import StylesButton from '../components/StylesButton.vue' import StylesButton from "../components/StylesButton.vue";
import AnnotationsPanel from "../components/AnnotationsPanel.vue";
import AnnotationModal from "../components/AnnotationModal.vue";
import AnnotationsButton from "../components/AnnotationsButton.vue";
import { useStyles } from "../composables/useStyles"; import { useStyles } from "../composables/useStyles";
import { loadBookFromIndexedDB, formatFilename } from "../utils/utils"; import { loadBookFromIndexedDB } from "../utils/utils";
import type { RenditionTheme } from "../types/styles";
import EpubView from "../components/EpubView.vue"; import EpubView from "../components/EpubView.vue";
import { type EpubFile } from "../types/epubFile"; import { type EpubFile } from "../types/epubFile";
import { type Annotation, type PendingAnnotation, type AnnotationFormData } from "../types/annotations";
// NavItem interface // Import epub.js types
interface NavItem { import type Rendition from 'epubjs/types/rendition';
import type { DisplayedLocation } from 'epubjs/types/rendition';
import type Contents from 'epubjs/types/contents';
import type Book from 'epubjs/types/book';
// Extended NavItem interface
interface ExtendedNavItem {
id: string; id: string;
href: string; href: string;
label: string; label: string;
subitems: Array<NavItem>; subitems: Array<ExtendedNavItem>;
parent?: string; parent?: string;
expansion: boolean; expansion: boolean;
} }
// TocComponent definition - Using setup function within script setup // Event handler types
interface RelocatedEvent {
start: DisplayedLocation;
end: DisplayedLocation;
atStart: boolean;
atEnd: boolean;
}
// TocComponent definition
const TocComponent = (props: { const TocComponent = (props: {
toc: Array<NavItem>; toc: Array<ExtendedNavItem>;
current: string | number; current: string | number;
setLocation: (href: string | number, close?: boolean) => void; setLocation: (href: string | number, close?: boolean) => void;
isSubmenu?: boolean; isSubmenu?: boolean;
@ -139,17 +168,14 @@ const TocComponent = (props: {
}, },
[ [
props.isSubmenu ? " ".repeat(4) + item.label : item.label, props.isSubmenu ? " ".repeat(4) + item.label : item.label,
// Expansion indicator item.subitems.length > 0 &&
item.subitems &&
item.subitems.length > 0 &&
renderH("div", { renderH("div", {
class: `${item.expansion ? "open" : ""} expansion`, class: `${item.expansion ? "open" : ""} expansion`,
}), }),
] ]
), ),
// Nested TOC // Nested TOC
item.subitems && item.subitems.length > 0 &&
item.subitems.length > 0 &&
renderH( renderH(
Transition, Transition,
{ name: "collapse-transition" }, { name: "collapse-transition" },
@ -178,40 +204,83 @@ const TocComponent = (props: {
); );
}; };
// Setup and state management // Setup state
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const loading = ref<boolean>(true); const loading = ref<boolean>(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const bookData = ref<ArrayBuffer | null>(null); const bookData = ref<ArrayBuffer | null>(null);
const bookDataUrl = ref<string | null>(null); // Add this to store URL for comparison const bookDataUrl = ref<string | null>(null);
const bookTitle = ref<string>(""); const bookTitle = ref<string>("");
const location = ref<string | null>(null); const location = ref<string | null>(null);
const firstRenderDone = ref<boolean>(false); const firstRenderDone = ref<boolean>(false);
const showToc = ref<boolean>(true); const showToc = ref<boolean>(true);
const epubRef = ref<InstanceType<typeof EpubView> | null>(null); // Add null type for initialization const epubRef = ref<InstanceType<typeof EpubView> | null>(null);
const currentHref = ref<string | number | null>(null); const currentHref = ref<string | number | null>(null);
// Annotation state
const savedAnnotations = ref<Annotation[]>([]);
const pendingAnnotation = ref<PendingAnnotation | null>(null);
const showAnnotationModal = ref<boolean>(false);
const showAnnotationsPanel = ref<boolean>(false);
const annotationName = ref<string>('');
const annotationNote = ref<string>('');
const editingAnnotation = ref<Annotation | null>(null);
// TOC related state // TOC related state
const bookState = reactive({ const bookState = reactive({
toc: [] as Array<NavItem>, toc: [] as Array<ExtendedNavItem>,
expandedToc: false, expandedToc: false,
}); });
const { toc, expandedToc } = toRefs(bookState); const { toc, expandedToc } = toRefs(bookState);
const { const {
textColor, textColor, backgroundColor, accentColor,
backgroundColor, fontFamily, fontSize, stylesModalOpen,
accentColor, toggleStylesModal, rendition, setRendition,
fontFamily,
fontSize,
stylesModalOpen,
toggleStylesModal,
rendition,
setRendition,
} = useStyles(); } = useStyles();
// Toggle annotations panel
const toggleAnnotationsPanel = () => {
showAnnotationsPanel.value = !showAnnotationsPanel.value;
};
// Annotation storage functions
const getAnnotationStorageKey = (bookId: string): string => {
return `epub-annotations-${bookId}`;
};
const loadAnnotations = (): void => {
try {
const bookId = route.params.bookId as string;
const storageKey = getAnnotationStorageKey(bookId);
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsedAnnotations: Annotation[] = JSON.parse(stored);
savedAnnotations.value = parsedAnnotations.sort((a, b) => b.createdAt - a.createdAt);
}
} catch (error) {
console.error('Error loading annotations:', error);
savedAnnotations.value = [];
}
};
const saveAnnotationsToStorage = (): void => {
try {
const bookId = route.params.bookId as string;
const storageKey = getAnnotationStorageKey(bookId);
localStorage.setItem(storageKey, JSON.stringify(savedAnnotations.value));
} catch (error) {
console.error('Error saving annotations:', error);
}
};
const generateAnnotationId = (): string => {
return `annotation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
const loadBook = async (): Promise<void> => { const loadBook = async (): Promise<void> => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@ -228,17 +297,17 @@ const loadBook = async (): Promise<void> => {
if (book.data instanceof Blob) { if (book.data instanceof Blob) {
bookData.value = await book.data.arrayBuffer(); bookData.value = await book.data.arrayBuffer();
// Create object URL for XHR comparison
const blob = new Blob([bookData.value]); const blob = new Blob([bookData.value]);
bookDataUrl.value = URL.createObjectURL(blob); bookDataUrl.value = URL.createObjectURL(blob);
} else if (book.data instanceof ArrayBuffer) { } else if (book.data instanceof ArrayBuffer) {
bookData.value = book.data; bookData.value = book.data;
// Create object URL for XHR comparison
const blob = new Blob([bookData.value]); const blob = new Blob([bookData.value]);
bookDataUrl.value = URL.createObjectURL(blob); bookDataUrl.value = URL.createObjectURL(blob);
} else { } else {
throw new Error("Book data is in an unsupported format"); throw new Error("Book data is in an unsupported format");
} }
loadAnnotations();
} catch (err: unknown) { } catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
console.error("Error loading book:", err); console.error("Error loading book:", err);
@ -258,19 +327,211 @@ const locationChange = (epubcifi: string): void => {
location.value = epubcifi; location.value = epubcifi;
}; };
const getRendition = (renditionObj: any): void => { // Apply annotations to view
setRendition(renditionObj as RenditionTheme); const applyAnnotationsToView = async (): Promise<void> => {
if (!rendition.value || savedAnnotations.value.length === 0) return;
try {
await nextTick();
setTimeout(() => {
savedAnnotations.value.forEach(annotation => {
try {
rendition.value?.annotations.highlight(
annotation.cfiRange,
{
id: annotation.id,
name: annotation.name,
note: annotation.note
},
undefined,
'saved-annotation',
{
fill: accentColor.value,
'fill-opacity': '0.4',
'mix-blend-mode': 'multiply',
stroke: accentColor.value,
'stroke-width': '1px'
}
);
} catch (error) {
console.warn('Failed to apply annotation:', annotation.id, error);
}
});
}, 500);
} catch (error) {
console.error('Error applying annotations:', error);
}
};
// Handle text selection
const handleSelection = (cfiRange: string, contents: Contents): void => {
try {
if (!rendition.value) return;
const range = rendition.value.getRange(cfiRange);
const selectedText = range.toString().trim();
if (!selectedText || selectedText.length < 3) {
console.log('Selection too short, ignoring');
return;
}
pendingAnnotation.value = {
cfiRange,
text: selectedText,
contents
};
showAnnotationModal.value = true;
if (contents.window && contents.window.getSelection) {
contents.window.getSelection()?.removeAllRanges();
}
} catch (error) {
console.error('Error handling selection:', error);
}
};
// Handle annotation save from modal
const handleAnnotationSave = (formData: AnnotationFormData): void => {
if (!pendingAnnotation.value) return;
try {
const bookId = route.params.bookId as string;
const now = Date.now();
if (editingAnnotation.value) {
// Update existing annotation
const index = savedAnnotations.value.findIndex(a => a.id === editingAnnotation.value!.id);
if (index !== -1) {
savedAnnotations.value[index] = {
...editingAnnotation.value,
name: formData.name,
note: formData.note || undefined,
updatedAt: now
};
}
} else {
// Create new annotation
const annotation: Annotation = {
id: generateAnnotationId(),
bookId,
cfiRange: pendingAnnotation.value.cfiRange,
text: pendingAnnotation.value.text,
name: formData.name,
note: formData.note || undefined,
createdAt: now,
updatedAt: now,
chapter: currentHref.value?.toString()
};
savedAnnotations.value.unshift(annotation);
// Add visual highlight
rendition.value?.annotations.highlight(
annotation.cfiRange,
{
id: annotation.id,
name: annotation.name,
note: annotation.note
},
undefined,
'saved-annotation',
{
fill: accentColor.value,
'fill-opacity': '0.4',
'mix-blend-mode': 'multiply',
stroke: accentColor.value,
'stroke-width': '1px'
}
);
}
saveAnnotationsToStorage();
closeAnnotationModal();
// Show the annotations panel after creating a new annotation
if (!editingAnnotation.value) {
showAnnotationsPanel.value = true;
}
} catch (error) {
console.error('Error saving annotation:', error);
}
};
// Close annotation modal
const closeAnnotationModal = (): void => {
showAnnotationModal.value = false;
pendingAnnotation.value = null;
annotationName.value = '';
annotationNote.value = '';
editingAnnotation.value = null;
};
// Go to annotation
const goToAnnotation = (cfiRange: string): void => {
if (rendition.value) {
rendition.value.display(cfiRange);
}
};
// Edit annotation
const editAnnotation = (annotation: Annotation): void => {
editingAnnotation.value = annotation;
annotationName.value = annotation.name;
annotationNote.value = annotation.note || '';
// Need to set a dummy pending annotation to make the modal work
pendingAnnotation.value = {
cfiRange: annotation.cfiRange,
text: annotation.text,
contents: null as any // This is fine as we're just editing
};
showAnnotationModal.value = true;
};
// Delete annotation
const deleteAnnotation = (annotationId: string): void => {
if (confirm('Are you sure you want to delete this annotation?')) {
const index = savedAnnotations.value.findIndex(a => a.id === annotationId);
if (index !== -1) {
const annotation = savedAnnotations.value[index];
try {
rendition.value?.annotations.remove(annotation.cfiRange, 'highlight');
} catch (error) {
console.warn('Could not remove highlight:', error);
}
savedAnnotations.value.splice(index, 1);
saveAnnotationsToStorage();
}
}
};
const getRendition = (renditionObj: Rendition): void => {
setRendition(renditionObj);
// Track current location for TOC highlighting // Track current location for TOC highlighting
renditionObj.on("relocated", (location: { start: { href: string } }) => { renditionObj.on("relocated", (location: RelocatedEvent) => {
currentHref.value = location.start.href; currentHref.value = location.start.href;
}); });
// Handle text selection
renditionObj.on('selected', (cfiRange: string, contents: Contents) => {
handleSelection(cfiRange, contents);
});
// Apply saved annotations when view is displayed
renditionObj.on('displayed', () => {
applyAnnotationsToView();
});
// Get book metadata // Get book metadata
const book = renditionObj.book; const book: Book = renditionObj.book;
book.ready.then(() => { book.ready.then(() => {
const meta = book.package.metadata; const meta = book.packaging?.metadata;
if (!bookTitle.value && meta.title) { if (!bookTitle.value && meta?.title) {
bookTitle.value = meta.title; bookTitle.value = meta.title;
document.title = meta.title; document.title = meta.title;
} }
@ -292,18 +553,37 @@ const toggleToc = (): void => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
expandedToc.value = false; if (showAnnotationModal.value) {
closeAnnotationModal();
} else if (showAnnotationsPanel.value) {
showAnnotationsPanel.value = false;
} else {
expandedToc.value = false;
}
} }
}; };
const onTocChange = (tocData: any[]): void => { // Convert navigation items to our format
// Convert epubjs NavItem to our NavItem with expansion property const convertNavItems = (items: any[]): ExtendedNavItem[] => {
toc.value = tocData.map((i) => ({ return items.map((item) => ({
...i, id: item.id || '',
href: item.href || '',
label: item.label || '',
parent: item.parent,
expansion: false, expansion: false,
// Ensure subitems is always an array subitems: Array.isArray(item.subitems)
subitems: Array.isArray(i.subitems) ? i.subitems.map((s: any) => ({ ...s, expansion: false })) : [] ? convertNavItems(item.subitems)
})); : []
} as ExtendedNavItem));
};
const onTocChange = (tocData: any[]): void => {
try {
toc.value = convertNavItems(tocData);
} catch (error) {
console.error('Error processing TOC data:', error);
toc.value = [];
}
}; };
const setLocation = ( const setLocation = (
@ -318,8 +598,7 @@ const setLocation = (
// XHR Progress tracking // XHR Progress tracking
const originalOpen = XMLHttpRequest.prototype.open; const originalOpen = XMLHttpRequest.prototype.open;
const onProgress = (e: ProgressEvent) => { const onProgress = (e: ProgressEvent) => {
// You could emit a progress event here if needed // Progress tracking if needed
// emit('progress', Math.floor((e.loaded / e.total) * 100));
}; };
XMLHttpRequest.prototype.open = function ( XMLHttpRequest.prototype.open = function (
@ -334,18 +613,18 @@ XMLHttpRequest.prototype.open = function (
onMounted(() => { onMounted(() => {
loadBook(); loadBook();
// Add keyboard shortcuts
window.addEventListener('keydown', handleKeyDown);
}); });
onUnmounted(() => { onUnmounted(() => {
// Clean up object URL to prevent memory leaks
if (bookDataUrl.value) { if (bookDataUrl.value) {
URL.revokeObjectURL(bookDataUrl.value); URL.revokeObjectURL(bookDataUrl.value);
} }
XMLHttpRequest.prototype.open = originalOpen; XMLHttpRequest.prototype.open = originalOpen;
if (expandedToc.value) { window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
}
}); });
defineExpose({ defineExpose({
@ -376,21 +655,30 @@ defineExpose({
setLocation, setLocation,
epubRef, epubRef,
TocComponent, TocComponent,
// Annotation related
savedAnnotations,
goToAnnotation,
editAnnotation,
deleteAnnotation,
toggleAnnotationsPanel
}); });
</script> </script>
<style> <style>
/* TOC area styles */
.toc-area { .toc-area {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 0 !important; z-index: 10;
width: 256px; width: 256px;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: 6px 0; padding: 6px 0;
background-color: var(--background-color) !important; background-color: var(--background-color);
border-right: 1px solid var(--divider-color) !important; border-right: 1px solid var(--divider-color);
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
} }
.toc-area::-webkit-scrollbar { .toc-area::-webkit-scrollbar {
@ -415,13 +703,14 @@ defineExpose({
font-size: 0.9em; font-size: 0.9em;
text-align: left; text-align: left;
padding: 0.9em 1em; padding: 0.9em 1em;
border-bottom: 1px solid var(--divider-color) !important; border-bottom: 1px solid var(--divider-color);
color: var(--text-color) !important; color: var(--text-color);
box-sizing: border-box; box-sizing: border-box;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
} }
.toc-area .toc-area-button:hover { .toc-area .toc-area-button:hover {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
@ -431,7 +720,7 @@ defineExpose({
} }
.toc-area .active { .toc-area .active {
border-left: 3px solid var(--accent-color) !important; border-left: 3px solid var(--accent-color);
} }
.toc-area .toc-area-button .expansion { .toc-area .toc-area-button .expansion {
@ -468,27 +757,46 @@ defineExpose({
.toc-area .toc-area-button .open::after { .toc-area .toc-area-button .open::after {
transform: rotate(-45deg) translateX(-2.5px); transform: rotate(-45deg) translateX(-2.5px);
} }
/* TOC background overlay */
.toc-background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 5;
}
</style> </style>
<style scoped> <style scoped>
.container { .reader-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden; overflow: hidden;
color: var(--text-color, #000000);
background-color: var(--background-color, #ffffff);
position: relative; position: relative;
height: 100%;
} }
.slideRight { .slideRight {
transform: translateX(256px); transform: translateX(256px);
} }
.slideLeft { .slideLeft {
transform: translateX(-256px); transform: translateX(-256px);
} }
.reader-area { .reader-area {
position: relative; position: relative;
z-index: 999; z-index: 9;
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: var(--background-color) !important; background-color: var(--background-color);
transition: all 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
} }
.toc-button { .toc-button {
@ -503,52 +811,35 @@ defineExpose({
outline: none; outline: none;
position: absolute; position: absolute;
top: 6px; top: 6px;
}
.toc-button {
left: 6px; left: 6px;
z-index: 10;
} }
.toc-button-bar { .toc-button-bar {
position: absolute; position: absolute;
width: 60%; width: 60%;
background: var(--accent-color) !important; background: var(--accent-color);
height: 2px; height: 2px;
left: 50%; left: 50%;
margin: -1px -30%; margin: -1px -30%;
top: 50%; top: 50%;
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
} }
.toc-button-expanded > .toc-button-bar:first-child { .toc-button-expanded > .toc-button-bar:first-child {
top: 50% !important; top: 50% !important;
transform: rotate(45deg); transform: rotate(45deg);
} }
.toc-button-expanded > .toc-button-bar:last-child { .toc-button-expanded > .toc-button-bar:last-child {
top: 50% !important; top: 50% !important;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
/* loading */
.loading-view {
position: absolute;
top: 50%;
left: 10%;
right: 10%;
color: var(--accent-color);
text-align: center;
margin-top: -0.5em;
}
.reader-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
color: var(--text-color, #000000);
background-color: var(--background-color, #ffffff);
}
.book-title { .book-title {
margin: 0 1rem; margin: 0 1rem;
font-size: 1rem; font-size: 1rem;
color: var(--text-color, #000000); color: var(--text-color);
opacity: 0.7; opacity: 0.7;
left: 50px; left: 50px;
overflow: hidden; overflow: hidden;
@ -558,17 +849,32 @@ defineExpose({
text-overflow: ellipsis; text-overflow: ellipsis;
top: 10px; top: 10px;
white-space: nowrap; white-space: nowrap;
z-index: 5;
} }
.reader-view { .reader-view {
transition: all 0.3s ease-in-out; height: 100%;
overflow: hidden; overflow: hidden;
padding-top: 40px; /* Space for header */
} }
.loading,
.error { .loading, .error {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: var(--accent-color); color: var(--accent-color);
height: 100%; height: 100%;
font-size: 1.2rem;
} }
</style>
/* Ensure highlight styles are properly applied */
:deep(.epub-view) {
height: 100%;
}
:deep(.saved-annotation) {
background-color: var(--accent-color);
opacity: 0.4;
cursor: pointer;
}
</style>