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"
|
@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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,13 +11,15 @@ 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 = () => {
|
||||||
|
try {
|
||||||
const savedStyles = {
|
const savedStyles = {
|
||||||
text: localStorage.getItem('reader-text-color'),
|
text: localStorage.getItem('reader-text-color'),
|
||||||
background: localStorage.getItem('reader-background-color'),
|
background: localStorage.getItem('reader-background-color'),
|
||||||
|
|
@ -32,14 +35,21 @@ export function useStyles(options: StylesOptions = {}) {
|
||||||
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize;
|
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize;
|
||||||
|
|
||||||
applyStylesToDocument();
|
applyStylesToDocument();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading saved styles:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveStyles = () => {
|
const saveStyles = () => {
|
||||||
|
try {
|
||||||
localStorage.setItem('reader-text-color', textColor.value);
|
localStorage.setItem('reader-text-color', textColor.value);
|
||||||
localStorage.setItem('reader-background-color', backgroundColor.value);
|
localStorage.setItem('reader-background-color', backgroundColor.value);
|
||||||
localStorage.setItem('accent-color', accentColor.value);
|
localStorage.setItem('accent-color', accentColor.value);
|
||||||
localStorage.setItem('reader-font-family', fontFamily.value);
|
localStorage.setItem('reader-font-family', fontFamily.value);
|
||||||
localStorage.setItem('reader-font-size', fontSize.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) {
|
||||||
|
// Handle both single Contents object and array of Contents
|
||||||
|
const contentsArray = Array.isArray(contents) ? contents : [contents];
|
||||||
|
|
||||||
if (iframes) {
|
await Promise.all(
|
||||||
iframes.forEach((iframe: HTMLIFrameElement) => {
|
contentsArray.map(async (content: any) => {
|
||||||
|
if (content && content.document) {
|
||||||
|
await nextTick(); // Ensure DOM is ready
|
||||||
|
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 {
|
try {
|
||||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
const doc = view.document || view.iframe?.contentDocument;
|
||||||
if (doc) {
|
if (doc) {
|
||||||
|
await nextTick();
|
||||||
applyStylesToContent(doc);
|
applyStylesToContent(doc);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not access iframe content:', error);
|
console.warn('Could not access view 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} 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 {
|
||||||
|
// 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) {
|
if (renditionObj.display) {
|
||||||
await renditionObj.display();
|
await renditionObj.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply styles after display
|
// Apply styles after everything is ready
|
||||||
await applyStylesToReader();
|
await applyStylesToReader();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up rendition:', error);
|
||||||
|
// Still try to apply styles even if setup partially failed
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
applyStylesToDocument();
|
applyStylesToDocument();
|
||||||
await applyStylesToReader();
|
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,6 +351,7 @@ 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 () => {
|
||||||
|
try {
|
||||||
applyStylesToDocument();
|
applyStylesToDocument();
|
||||||
await applyStylesToReader();
|
await applyStylesToReader();
|
||||||
saveStyles();
|
saveStyles();
|
||||||
|
|
@ -264,12 +365,24 @@ export function useStyles(options: StylesOptions = {}) {
|
||||||
fontSize.value
|
fontSize.value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 100);
|
} catch (error) {
|
||||||
|
console.error('Error updating styles:', error);
|
||||||
|
}
|
||||||
|
}, 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -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: {
|
sk: {
|
||||||
library: {
|
library: {
|
||||||
library: 'Knižnica',
|
library: 'Knižnica',
|
||||||
title: 'Názov',
|
title: 'Názov',
|
||||||
emptyLibrary: 'V knižnici sa nenašli žiadne knihy.',
|
emptyLibrary: 'V knižnici sa nenašli žiadne knihy.',
|
||||||
loading: 'Načítavam Vašu knižnicu...',
|
loading: 'Načítava sa vaša knižnica...',
|
||||||
download: 'Stiahnuť',
|
download: 'Stiahnuť',
|
||||||
read: 'Čítať',
|
read: 'Čítať',
|
||||||
size: 'Veľkosť',
|
size: 'Veľkosť',
|
||||||
local: 'Moje',
|
local: 'Vlastnené',
|
||||||
added: 'Pridané',
|
added: 'Pridané',
|
||||||
filename: 'Názov súboru',
|
filename: 'Názov súboru',
|
||||||
},
|
},
|
||||||
reader: {
|
reader: {
|
||||||
loading: 'Načítavam',
|
loading: 'Načítava sa',
|
||||||
back: 'Späť',
|
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: {
|
||||||
settings: 'Nastavenia',
|
settings: 'Nastavenia',
|
||||||
textColor: 'Farba Textu',
|
textColor: 'Farba textu',
|
||||||
backgroundColor: 'Farba Pozadia',
|
backgroundColor: 'Farba pozadia',
|
||||||
accentColor: 'Terciárna Farba',
|
accentColor: 'Akcentová farba',
|
||||||
fontFamily: 'Písmo',
|
fontFamily: 'Písmo',
|
||||||
fontSize: "Veľkosť písma",
|
fontSize: 'Veľkosť písma',
|
||||||
presets: 'Predvoľby',
|
presets: 'Prednastavenia',
|
||||||
white: 'Svetlá',
|
white: 'Svetlý',
|
||||||
black: 'Tmavá',
|
black: 'Tmavý',
|
||||||
sepia: 'Sépia',
|
sepia: 'Sepia',
|
||||||
bookSettings: 'Detail Knihy',
|
bookSettings: 'Nastavenia knihy',
|
||||||
saveChanges: 'Uložiť zmeny',
|
saveChanges: 'Uložiť zmeny',
|
||||||
deleteBook: 'Odstrániť Knihu',
|
deleteBook: 'Odstrániť knihu',
|
||||||
confirmDelete: 'Naozaj chcete odstrániť ',
|
confirmDelete: 'Naozaj chcete túto knihu odstrániť? Táto akcia je nevratná!',
|
||||||
cancel: 'Zrušiť',
|
cancel: 'Zrušiť',
|
||||||
delete: 'Odstrániť'
|
delete: 'Odstrániť',
|
||||||
|
discard: 'Zahodiť',
|
||||||
|
save: 'Uložiť',
|
||||||
|
edit: 'Upraviť',
|
||||||
|
update: 'Aktualizovať',
|
||||||
|
add: 'Pridať'
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
success: 'Operácia bola úspešne dokončená',
|
success: 'Operácia bola úspešne dokončená',
|
||||||
error: 'Došlo k chybe',
|
error: 'Došlo k chybe',
|
||||||
loading: 'Načítava sa...',
|
loading: 'Načítava sa...',
|
||||||
confirmDelete: 'Naozaj chcete zmazať túto položku?',
|
confirmDelete: 'Naozaj chcete odstrániť ',
|
||||||
noResults: 'Nenašli sa žiadne výsledky',
|
noResults: 'Nenašli sa žiadne výsledky',
|
||||||
welcome: 'Vitajte!'
|
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
|
// 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;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<!-- ReaderView.vue -->
|
<!-- ReaderView.vue (Updated with separated components) -->
|
||||||
<template>
|
<template>
|
||||||
<div class="reader-container">
|
<div class="reader-container">
|
||||||
<div
|
<div
|
||||||
|
|
@ -8,11 +8,6 @@
|
||||||
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,8 +168,6 @@ const TocComponent = (props: {
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
props.isSubmenu ? " ".repeat(4) + item.label : item.label,
|
props.isSubmenu ? " ".repeat(4) + item.label : item.label,
|
||||||
// Expansion indicator
|
|
||||||
item.subitems &&
|
|
||||||
item.subitems.length > 0 &&
|
item.subitems.length > 0 &&
|
||||||
renderH("div", {
|
renderH("div", {
|
||||||
class: `${item.expansion ? "open" : ""} expansion`,
|
class: `${item.expansion ? "open" : ""} expansion`,
|
||||||
|
|
@ -148,7 +175,6 @@ const TocComponent = (props: {
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
// Nested TOC
|
// Nested TOC
|
||||||
item.subitems &&
|
|
||||||
item.subitems.length > 0 &&
|
item.subitems.length > 0 &&
|
||||||
renderH(
|
renderH(
|
||||||
Transition,
|
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') {
|
||||||
|
if (showAnnotationModal.value) {
|
||||||
|
closeAnnotationModal();
|
||||||
|
} else if (showAnnotationsPanel.value) {
|
||||||
|
showAnnotationsPanel.value = false;
|
||||||
|
} else {
|
||||||
expandedToc.value = false;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue