annotations and styles cleanup
This commit is contained in:
parent
3c19cf1d32
commit
ea373332bb
9 changed files with 1192 additions and 257 deletions
225
src/components/AnnotationModal.vue
Normal file
225
src/components/AnnotationModal.vue
Normal 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>
|
||||
44
src/components/AnnotationsButton.vue
Normal file
44
src/components/AnnotationsButton.vue
Normal 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>
|
||||
206
src/components/AnnotationsPanel.vue
Normal file
206
src/components/AnnotationsPanel.vue
Normal 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>
|
||||
|
|
@ -48,14 +48,14 @@
|
|||
@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>
|
||||
{{ t('settings.deleteBook') }}
|
||||
{{ t('settings.delete') }}
|
||||
</button>
|
||||
<button
|
||||
class="save-button"
|
||||
@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>
|
||||
{{ t('settings.saveChanges') }}
|
||||
{{ t('settings.save') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// src/composables/useStyles.ts
|
||||
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 = {}) {
|
||||
// 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 fontSize = ref(options.initialFontSize || '100%');
|
||||
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
|
||||
let hooksRegistered = false;
|
||||
let renderedEventListener: ((section: any, view: any) => void) | null = null;
|
||||
|
||||
// Local storage management
|
||||
const loadSavedStyles = () => {
|
||||
const savedStyles = {
|
||||
text: localStorage.getItem('reader-text-color'),
|
||||
background: localStorage.getItem('reader-background-color'),
|
||||
accent: localStorage.getItem('accent-color'),
|
||||
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.accent) accentColor.value = savedStyles.accent;
|
||||
if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily;
|
||||
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize;
|
||||
|
||||
applyStylesToDocument();
|
||||
try {
|
||||
const savedStyles = {
|
||||
text: localStorage.getItem('reader-text-color'),
|
||||
background: localStorage.getItem('reader-background-color'),
|
||||
accent: localStorage.getItem('accent-color'),
|
||||
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.accent) accentColor.value = savedStyles.accent;
|
||||
if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily;
|
||||
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize;
|
||||
|
||||
applyStylesToDocument();
|
||||
} catch (error) {
|
||||
console.error('Error loading saved styles:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveStyles = () => {
|
||||
localStorage.setItem('reader-text-color', textColor.value);
|
||||
localStorage.setItem('reader-background-color', backgroundColor.value);
|
||||
localStorage.setItem('accent-color', accentColor.value);
|
||||
localStorage.setItem('reader-font-family', fontFamily.value);
|
||||
localStorage.setItem('reader-font-size', fontSize.value);
|
||||
try {
|
||||
localStorage.setItem('reader-text-color', textColor.value);
|
||||
localStorage.setItem('reader-background-color', backgroundColor.value);
|
||||
localStorage.setItem('accent-color', accentColor.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 = () => {
|
||||
|
|
@ -121,39 +131,56 @@ export function useStyles(options: StylesOptions = {}) {
|
|||
color: ${textColor.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);
|
||||
};
|
||||
|
||||
// Apply styles to all currently loaded content
|
||||
const applyStylesToAllContent = () => {
|
||||
// Apply styles to all currently loaded content - IMPROVED VERSION
|
||||
const applyStylesToAllContent = async () => {
|
||||
if (!rendition.value) return;
|
||||
|
||||
try {
|
||||
// Get all iframes (epub.js uses iframes for content)
|
||||
const iframes = rendition.value.manager?.container?.querySelectorAll('iframe');
|
||||
|
||||
if (iframes) {
|
||||
iframes.forEach((iframe: HTMLIFrameElement) => {
|
||||
try {
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (doc) {
|
||||
applyStylesToContent(doc);
|
||||
// Method 1: Use getContents() API (most reliable)
|
||||
const contents = rendition.value.getContents();
|
||||
if (contents) {
|
||||
// Handle both single Contents object and array of Contents
|
||||
const contentsArray = Array.isArray(contents) ? contents : [contents];
|
||||
|
||||
await Promise.all(
|
||||
contentsArray.map(async (content: any) => {
|
||||
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
|
||||
if (rendition.value.getContents) {
|
||||
const contents = rendition.value.getContents();
|
||||
contents.forEach((content: any) => {
|
||||
if (content.document) {
|
||||
applyStylesToContent(content.document);
|
||||
}
|
||||
});
|
||||
// Method 2: Use views() as fallback
|
||||
const views = rendition.value.views();
|
||||
if (views && Array.isArray(views)) {
|
||||
await Promise.all(
|
||||
views.map(async (view: any) => {
|
||||
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) {
|
||||
|
|
@ -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 = () => {
|
||||
if (!rendition.value || hooksRegistered) return;
|
||||
|
||||
try {
|
||||
rendition.value.hooks.content.register((contents: any) => {
|
||||
rendition.value.hooks.content.register(async (contents: any) => {
|
||||
if (contents.document) {
|
||||
await nextTick();
|
||||
applyStylesToContent(contents.document);
|
||||
}
|
||||
});
|
||||
|
|
@ -181,30 +252,37 @@ export function useStyles(options: StylesOptions = {}) {
|
|||
if (!rendition.value) return;
|
||||
|
||||
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('background', backgroundColor.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);
|
||||
} else {
|
||||
themes.override('font-size', fontSize.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for next tick to ensure themes are applied
|
||||
await nextTick();
|
||||
applyStylesToAllContent();
|
||||
|
||||
// Apply styles to all current content
|
||||
await applyStylesToAllContent();
|
||||
|
||||
// Setup hooks and event listeners for future content
|
||||
registerContentHooks();
|
||||
setupEventListeners();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying styles to reader:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Update status bar meta tags
|
||||
const setMeta = (name: string, content: string) => {
|
||||
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);
|
||||
};
|
||||
|
||||
// Rendition setup (enhanced)
|
||||
const setRendition = async (renditionObj: RenditionTheme): Promise<void> => {
|
||||
// Rendition setup - IMPROVED VERSION
|
||||
const setRendition = async (renditionObj: Rendition): Promise<void> => {
|
||||
// Clean up previous rendition
|
||||
if (rendition.value) {
|
||||
removeEventListeners();
|
||||
hooksRegistered = false;
|
||||
}
|
||||
|
||||
rendition.value = renditionObj;
|
||||
hooksRegistered = false; // Reset hook registration flag
|
||||
|
||||
// Wait for rendition to be ready
|
||||
if (renditionObj.display) {
|
||||
await renditionObj.display();
|
||||
try {
|
||||
// Wait for rendition to be ready if it has a started promise
|
||||
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 = () => {
|
||||
|
|
@ -233,13 +329,17 @@ export function useStyles(options: StylesOptions = {}) {
|
|||
|
||||
// Force refresh all styles (useful for debugging or manual refresh)
|
||||
const refreshStyles = async () => {
|
||||
await nextTick();
|
||||
applyStylesToDocument();
|
||||
await applyStylesToReader();
|
||||
try {
|
||||
await nextTick();
|
||||
applyStylesToDocument();
|
||||
await applyStylesToReader();
|
||||
} catch (error) {
|
||||
console.error('Error refreshing styles:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for style changes with debouncing
|
||||
let styleUpdateTimeout: NodeJS.Timeout | null = null;
|
||||
// Watch for style changes with debouncing - IMPROVED VERSION
|
||||
let styleUpdateTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
watch(
|
||||
[textColor, backgroundColor, accentColor, fontFamily, fontSize],
|
||||
|
|
@ -251,25 +351,38 @@ export function useStyles(options: StylesOptions = {}) {
|
|||
|
||||
// Debounce style updates to avoid too frequent changes
|
||||
styleUpdateTimeout = setTimeout(async () => {
|
||||
applyStylesToDocument();
|
||||
await applyStylesToReader();
|
||||
saveStyles();
|
||||
|
||||
if (options.onStyleChange) {
|
||||
options.onStyleChange(
|
||||
textColor.value,
|
||||
backgroundColor.value,
|
||||
accentColor.value,
|
||||
fontFamily.value,
|
||||
fontSize.value
|
||||
);
|
||||
try {
|
||||
applyStylesToDocument();
|
||||
await applyStylesToReader();
|
||||
saveStyles();
|
||||
|
||||
if (options.onStyleChange) {
|
||||
options.onStyleChange(
|
||||
textColor.value,
|
||||
backgroundColor.value,
|
||||
accentColor.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();
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
removeEventListeners();
|
||||
if (styleUpdateTimeout) {
|
||||
clearTimeout(styleUpdateTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
textColor,
|
||||
backgroundColor,
|
||||
|
|
@ -282,6 +395,7 @@ export function useStyles(options: StylesOptions = {}) {
|
|||
setRendition,
|
||||
applyStylesToReader,
|
||||
applyStylesToDocument,
|
||||
refreshStyles
|
||||
refreshStyles,
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,15 @@ export const translations = {
|
|||
reader: {
|
||||
loading: 'Loading',
|
||||
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',
|
||||
|
|
@ -33,7 +42,12 @@ export const translations = {
|
|||
deleteBook: 'Delete Book',
|
||||
confirmDelete: 'Are you sure you want to delete this book? This action is irreversible!',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete'
|
||||
delete: 'Delete',
|
||||
discard: 'Discard',
|
||||
save: 'Save',
|
||||
edit: 'Edit',
|
||||
update: 'Update',
|
||||
add: 'Add'
|
||||
},
|
||||
messages: {
|
||||
success: 'Operation completed successfully',
|
||||
|
|
@ -44,48 +58,64 @@ export const translations = {
|
|||
welcome: 'Welcome!'
|
||||
},
|
||||
},
|
||||
sk: {
|
||||
library: {
|
||||
library: 'Knižnica',
|
||||
title: 'Názov',
|
||||
emptyLibrary: 'V knižnici sa nenašli žiadne knihy.',
|
||||
loading: 'Načítavam Vašu knižnicu...',
|
||||
download: 'Stiahnuť',
|
||||
read: 'Čítať',
|
||||
size: 'Veľkosť',
|
||||
local: 'Moje',
|
||||
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!'
|
||||
},
|
||||
|
||||
sk: {
|
||||
library: {
|
||||
library: 'Knižnica',
|
||||
title: 'Názov',
|
||||
emptyLibrary: 'V knižnici sa nenašli žiadne knihy.',
|
||||
loading: 'Načítava sa vaša knižnica...',
|
||||
download: 'Stiahnuť',
|
||||
read: 'Čítať',
|
||||
size: 'Veľkosť',
|
||||
local: 'Vlastnené',
|
||||
added: 'Pridané',
|
||||
filename: 'Názov súboru',
|
||||
},
|
||||
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
25
src/types/annotations.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,19 +1,4 @@
|
|||
// 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 {
|
||||
initialTextColor?: string;
|
||||
initialBackgroundColor?: string;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
<!-- ReaderView.vue -->
|
||||
<!-- ReaderView.vue (Updated with separated components) -->
|
||||
<template>
|
||||
<div class="reader-container">
|
||||
<div
|
||||
class="reader-area"
|
||||
:class="{
|
||||
slideRight: expandedToc,
|
||||
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> -->
|
||||
<div
|
||||
class="reader-area"
|
||||
:class="{
|
||||
slideRight: expandedToc,
|
||||
slideLeft: stylesModalOpen
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-if="showToc"
|
||||
class="toc-button"
|
||||
|
|
@ -25,17 +20,16 @@
|
|||
|
||||
<h2 class="book-title">{{ bookTitle }}</h2>
|
||||
|
||||
<StylesButton
|
||||
:is-open="stylesModalOpen"
|
||||
@toggle="toggleStylesModal"
|
||||
<StylesButton :is-open="stylesModalOpen" @toggle="toggleStylesModal" />
|
||||
|
||||
<AnnotationsButton
|
||||
:is-open="showAnnotationsPanel"
|
||||
:count="savedAnnotations.length"
|
||||
@toggle="toggleAnnotationsPanel"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
{{ t("reader.loading") }}
|
||||
</div>
|
||||
<div v-else-if="error" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="loading" class="loading">{{ t("reader.loading") }}</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="reader-view">
|
||||
<EpubView
|
||||
ref="epubRef"
|
||||
|
|
@ -46,6 +40,10 @@
|
|||
:tocChanged="onTocChange"
|
||||
:getRendition="getRendition"
|
||||
/>
|
||||
<!--:epubOptions="{
|
||||
flow: 'scrolled',
|
||||
manager: 'continuous',
|
||||
}" -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -58,10 +56,30 @@
|
|||
:setLocation="setLocation"
|
||||
/>
|
||||
</div>
|
||||
<!-- TOC Background Overlay -->
|
||||
<div v-if="expandedToc" class="toc-background" @click="toggleToc"></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
|
||||
v-model:text-color="textColor"
|
||||
v-model:background-color="backgroundColor"
|
||||
|
|
@ -77,38 +95,49 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
toRefs,
|
||||
h,
|
||||
getCurrentInstance,
|
||||
Transition,
|
||||
ref, reactive, onMounted, onUnmounted, toRefs, h,
|
||||
getCurrentInstance, Transition, nextTick
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "../i18n/usei18n";
|
||||
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 { loadBookFromIndexedDB, formatFilename } from "../utils/utils";
|
||||
import type { RenditionTheme } from "../types/styles";
|
||||
import { loadBookFromIndexedDB } from "../utils/utils";
|
||||
import EpubView from "../components/EpubView.vue";
|
||||
import { type EpubFile } from "../types/epubFile";
|
||||
import { type Annotation, type PendingAnnotation, type AnnotationFormData } from "../types/annotations";
|
||||
|
||||
// NavItem interface
|
||||
interface NavItem {
|
||||
// Import epub.js types
|
||||
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;
|
||||
href: string;
|
||||
label: string;
|
||||
subitems: Array<NavItem>;
|
||||
subitems: Array<ExtendedNavItem>;
|
||||
parent?: string;
|
||||
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: {
|
||||
toc: Array<NavItem>;
|
||||
toc: Array<ExtendedNavItem>;
|
||||
current: string | number;
|
||||
setLocation: (href: string | number, close?: boolean) => void;
|
||||
isSubmenu?: boolean;
|
||||
|
|
@ -139,17 +168,14 @@ const TocComponent = (props: {
|
|||
},
|
||||
[
|
||||
props.isSubmenu ? " ".repeat(4) + item.label : item.label,
|
||||
// Expansion indicator
|
||||
item.subitems &&
|
||||
item.subitems.length > 0 &&
|
||||
item.subitems.length > 0 &&
|
||||
renderH("div", {
|
||||
class: `${item.expansion ? "open" : ""} expansion`,
|
||||
}),
|
||||
]
|
||||
),
|
||||
// Nested TOC
|
||||
item.subitems &&
|
||||
item.subitems.length > 0 &&
|
||||
item.subitems.length > 0 &&
|
||||
renderH(
|
||||
Transition,
|
||||
{ name: "collapse-transition" },
|
||||
|
|
@ -178,40 +204,83 @@ const TocComponent = (props: {
|
|||
);
|
||||
};
|
||||
|
||||
// Setup and state management
|
||||
// Setup state
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref<boolean>(true);
|
||||
const error = ref<string | 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 location = ref<string | null>(null);
|
||||
const firstRenderDone = ref<boolean>(false);
|
||||
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);
|
||||
|
||||
// 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
|
||||
const bookState = reactive({
|
||||
toc: [] as Array<NavItem>,
|
||||
toc: [] as Array<ExtendedNavItem>,
|
||||
expandedToc: false,
|
||||
});
|
||||
const { toc, expandedToc } = toRefs(bookState);
|
||||
|
||||
const {
|
||||
textColor,
|
||||
backgroundColor,
|
||||
accentColor,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
stylesModalOpen,
|
||||
toggleStylesModal,
|
||||
rendition,
|
||||
setRendition,
|
||||
textColor, backgroundColor, accentColor,
|
||||
fontFamily, fontSize, stylesModalOpen,
|
||||
toggleStylesModal, rendition, setRendition,
|
||||
} = 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> => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
|
@ -228,17 +297,17 @@ const loadBook = async (): Promise<void> => {
|
|||
|
||||
if (book.data instanceof Blob) {
|
||||
bookData.value = await book.data.arrayBuffer();
|
||||
// Create object URL for XHR comparison
|
||||
const blob = new Blob([bookData.value]);
|
||||
bookDataUrl.value = URL.createObjectURL(blob);
|
||||
} else if (book.data instanceof ArrayBuffer) {
|
||||
bookData.value = book.data;
|
||||
// Create object URL for XHR comparison
|
||||
const blob = new Blob([bookData.value]);
|
||||
bookDataUrl.value = URL.createObjectURL(blob);
|
||||
} else {
|
||||
throw new Error("Book data is in an unsupported format");
|
||||
}
|
||||
|
||||
loadAnnotations();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error loading book:", err);
|
||||
|
|
@ -258,19 +327,211 @@ const locationChange = (epubcifi: string): void => {
|
|||
location.value = epubcifi;
|
||||
};
|
||||
|
||||
const getRendition = (renditionObj: any): void => {
|
||||
setRendition(renditionObj as RenditionTheme);
|
||||
// Apply annotations to view
|
||||
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
|
||||
renditionObj.on("relocated", (location: { start: { href: string } }) => {
|
||||
renditionObj.on("relocated", (location: RelocatedEvent) => {
|
||||
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
|
||||
const book = renditionObj.book;
|
||||
const book: Book = renditionObj.book;
|
||||
book.ready.then(() => {
|
||||
const meta = book.package.metadata;
|
||||
if (!bookTitle.value && meta.title) {
|
||||
const meta = book.packaging?.metadata;
|
||||
if (!bookTitle.value && meta?.title) {
|
||||
bookTitle.value = meta.title;
|
||||
document.title = meta.title;
|
||||
}
|
||||
|
|
@ -292,18 +553,37 @@ const toggleToc = (): void => {
|
|||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
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 epubjs NavItem to our NavItem with expansion property
|
||||
toc.value = tocData.map((i) => ({
|
||||
...i,
|
||||
// Convert navigation items to our format
|
||||
const convertNavItems = (items: any[]): ExtendedNavItem[] => {
|
||||
return items.map((item) => ({
|
||||
id: item.id || '',
|
||||
href: item.href || '',
|
||||
label: item.label || '',
|
||||
parent: item.parent,
|
||||
expansion: false,
|
||||
// Ensure subitems is always an array
|
||||
subitems: Array.isArray(i.subitems) ? i.subitems.map((s: any) => ({ ...s, expansion: false })) : []
|
||||
}));
|
||||
subitems: Array.isArray(item.subitems)
|
||||
? 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 = (
|
||||
|
|
@ -318,8 +598,7 @@ const setLocation = (
|
|||
// XHR Progress tracking
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const onProgress = (e: ProgressEvent) => {
|
||||
// You could emit a progress event here if needed
|
||||
// emit('progress', Math.floor((e.loaded / e.total) * 100));
|
||||
// Progress tracking if needed
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.open = function (
|
||||
|
|
@ -334,18 +613,18 @@ XMLHttpRequest.prototype.open = function (
|
|||
|
||||
onMounted(() => {
|
||||
loadBook();
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up object URL to prevent memory leaks
|
||||
if (bookDataUrl.value) {
|
||||
URL.revokeObjectURL(bookDataUrl.value);
|
||||
}
|
||||
|
||||
XMLHttpRequest.prototype.open = originalOpen;
|
||||
if (expandedToc.value) {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
|
|
@ -376,21 +655,30 @@ defineExpose({
|
|||
setLocation,
|
||||
epubRef,
|
||||
TocComponent,
|
||||
// Annotation related
|
||||
savedAnnotations,
|
||||
goToAnnotation,
|
||||
editAnnotation,
|
||||
deleteAnnotation,
|
||||
toggleAnnotationsPanel
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* TOC area styles */
|
||||
.toc-area {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 0 !important;
|
||||
z-index: 10;
|
||||
width: 256px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 6px 0;
|
||||
background-color: var(--background-color) !important;
|
||||
border-right: 1px solid var(--divider-color) !important;
|
||||
background-color: var(--background-color);
|
||||
border-right: 1px solid var(--divider-color);
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toc-area::-webkit-scrollbar {
|
||||
|
|
@ -415,13 +703,14 @@ defineExpose({
|
|||
font-size: 0.9em;
|
||||
text-align: left;
|
||||
padding: 0.9em 1em;
|
||||
border-bottom: 1px solid var(--divider-color) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-area .toc-area-button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
|
@ -431,7 +720,7 @@ defineExpose({
|
|||
}
|
||||
|
||||
.toc-area .active {
|
||||
border-left: 3px solid var(--accent-color) !important;
|
||||
border-left: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.toc-area .toc-area-button .expansion {
|
||||
|
|
@ -468,27 +757,46 @@ defineExpose({
|
|||
.toc-area .toc-area-button .open::after {
|
||||
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 scoped>
|
||||
.container {
|
||||
.reader-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-color, #000000);
|
||||
background-color: var(--background-color, #ffffff);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slideRight {
|
||||
transform: translateX(256px);
|
||||
}
|
||||
|
||||
.slideLeft {
|
||||
transform: translateX(-256px);
|
||||
}
|
||||
|
||||
.reader-area {
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
z-index: 9;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--background-color) !important;
|
||||
transition: all 0.3s ease-in-out;
|
||||
background-color: var(--background-color);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.toc-button {
|
||||
|
|
@ -503,52 +811,35 @@ defineExpose({
|
|||
outline: none;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
}
|
||||
.toc-button {
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toc-button-bar {
|
||||
position: absolute;
|
||||
width: 60%;
|
||||
background: var(--accent-color) !important;
|
||||
background: var(--accent-color);
|
||||
height: 2px;
|
||||
left: 50%;
|
||||
margin: -1px -30%;
|
||||
top: 50%;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.toc-button-expanded > .toc-button-bar:first-child {
|
||||
top: 50% !important;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.toc-button-expanded > .toc-button-bar:last-child {
|
||||
top: 50% !important;
|
||||
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 {
|
||||
margin: 0 1rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color, #000000);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
left: 50px;
|
||||
overflow: hidden;
|
||||
|
|
@ -558,17 +849,32 @@ defineExpose({
|
|||
text-overflow: ellipsis;
|
||||
top: 10px;
|
||||
white-space: nowrap;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.reader-view {
|
||||
transition: all 0.3s ease-in-out;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-top: 40px; /* Space for header */
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
|
||||
.loading, .error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue