Compare commits
1 commit
github-pag
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deeaa1146d |
9 changed files with 451 additions and 320 deletions
|
|
@ -1,5 +1,7 @@
|
|||
# E-inn
|
||||
|
||||
[Pre-release](https://jroshthen1.github.io/e-inn-reader/)
|
||||
|
||||
E-inn is a basic EPUB reader supporting remote API imports and local file uploads. Books are stored in IndexedDB; settings and reading progress are retained via localStorage. Built as a single-page application with Vue.js, Vue Router, and epub.js for rendering. No accounts, sync, or advanced features, serves static assets. Minimal UI, lightweight, customizable interface.
|
||||
|
||||
## Running
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted } from 'vue';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { useI18n } from '../i18n/usei18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,45 @@
|
|||
<!-- src/components/AnnotationsButton.vue -->
|
||||
<!-- 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>
|
||||
<div class="annotations-button-container">
|
||||
<!-- Count button when no selection -->
|
||||
<button
|
||||
v-if="count > 0 && !hasSelection"
|
||||
class="annotations-count-btn"
|
||||
:class="{ 'is-open': isOpen }"
|
||||
@click="$emit('toggle')"
|
||||
:title="countButtonTitle"
|
||||
>
|
||||
<span class="count-indicator">{{ count }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
class="create-annotation-btn"
|
||||
@click="$emit('createFromSelection')"
|
||||
:title="'Double-tap text to select, then click here to annotate'"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="pencil-icon"
|
||||
>
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
|
||||
<path d="m15 5 4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
|
@ -14,19 +47,35 @@ defineProps({
|
|||
count: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggle']);
|
||||
defineEmits(['toggle', 'createFromSelection']);
|
||||
|
||||
watch(() => props.hasSelection, (newValue, oldValue) => {
|
||||
console.log('AnnotationsButton hasSelection changed:', { newValue, oldValue });
|
||||
}, { immediate: true });
|
||||
|
||||
const countButtonTitle = computed(() => {
|
||||
return props.isOpen ? `Close annotations (${props.count})` : `Open annotations (${props.count})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.annotations-toggle-btn {
|
||||
<style scoped>
|
||||
.annotations-button-container {
|
||||
position: fixed;
|
||||
right: 60px;
|
||||
top: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.annotations-count-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent-color);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--accent-color);
|
||||
|
|
@ -34,11 +83,42 @@ defineEmits(['toggle']);
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 40;
|
||||
transition: transform 0.2s;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.annotations-toggle-btn:active {
|
||||
transform: scale(0.95);
|
||||
|
||||
.annotations-count-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.create-annotation-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.pencil-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.create-annotation-btn:hover .pencil-icon {
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
.count-indicator {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -166,7 +166,7 @@ const truncateText = (text: string, maxLength: number): string => {
|
|||
|
||||
.annotation-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import ePub from 'epubjs'
|
|||
import type { Book, Rendition, Contents } from 'epubjs'
|
||||
import {
|
||||
clickListener,
|
||||
swipListener,
|
||||
// swipListener,
|
||||
wheelListener,
|
||||
keyListener,
|
||||
selectListener
|
||||
|
|
@ -40,7 +40,6 @@ interface Props {
|
|||
location?: any // Current Page number | string | Rendition['location']['start']
|
||||
tocChanged?: (toc: Book['navigation']['toc']) => void
|
||||
getRendition?: (rendition: Rendition) => void
|
||||
handleTextSelected?: (cfiRange: string, contents: Contents) => void
|
||||
handleKeyPress?: () => void
|
||||
toggleBubble?: (type: string, rect?: any, text?: string, cfiRange?: string) => void // For custom selection
|
||||
epubInitOptions?: Book['settings']
|
||||
|
|
@ -55,7 +54,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
const {
|
||||
tocChanged,
|
||||
getRendition,
|
||||
handleTextSelected,
|
||||
handleKeyPress,
|
||||
toggleBubble,
|
||||
epubInitOptions,
|
||||
|
|
@ -220,16 +218,9 @@ const registerEvents = () => {
|
|||
if (!epubOptions?.flow?.includes('scrolled')) {
|
||||
wheelListener(iframe.document, flipPage)
|
||||
}
|
||||
swipListener(iframe.document, flipPage)
|
||||
//swipListener(iframe.document, flipPage)
|
||||
keyListener(iframe.document, flipPage)
|
||||
|
||||
// Register your custom selection listener if toggleBubble is provided
|
||||
if (toggleBubble) {
|
||||
selectListener(iframe.document, rendition, toggleBubble)
|
||||
} else if (handleTextSelected) {
|
||||
// If no toggleBubble but handleTextSelected exists, use the built-in selection event
|
||||
rendition.on('selected', handleTextSelected)
|
||||
}
|
||||
|
||||
// Mark first content as displayed for location restoration
|
||||
if (!loadingState.firstContentDisplayed) {
|
||||
|
|
@ -370,6 +361,7 @@ defineExpose({
|
|||
user-select: none;
|
||||
appearance: none;
|
||||
font-weight: bold;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export default {
|
|||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.styles-button > div {
|
||||
|
|
|
|||
284
src/composables/useAnnotations.ts
Normal file
284
src/composables/useAnnotations.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { ref, type Ref, nextTick } from 'vue';
|
||||
import type { Annotation, PendingAnnotation, AnnotationFormData } from '../types/annotations';
|
||||
import type Rendition from 'epubjs/types/rendition';
|
||||
|
||||
export function useAnnotations(
|
||||
rendition: Ref<Rendition | null>,
|
||||
currentHref: Ref<string | number | null>,
|
||||
accentColor: Ref<string>
|
||||
) {
|
||||
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);
|
||||
const currentBookId = ref<string>('');
|
||||
const hasTextSelection = ref<boolean>(false);
|
||||
|
||||
// Device detection
|
||||
const isMobileDevice = /iPad|iPhone|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
// Storage key helper
|
||||
const getAnnotationStorageKey = (bookId: string): string => {
|
||||
return `epub-annotations-${bookId}`;
|
||||
};
|
||||
|
||||
// Load annotations from storage
|
||||
const loadAnnotations = (bookId: string): void => {
|
||||
try {
|
||||
currentBookId.value = bookId;
|
||||
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);
|
||||
} else {
|
||||
savedAnnotations.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading annotations:', error);
|
||||
savedAnnotations.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveAnnotationsToStorage = (bookId: string): void => {
|
||||
try {
|
||||
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)}`;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error applying annotations:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Simple toggle selection bubble
|
||||
const toggleSelectionBubble = (type: string, rect: any, text: string, cfiRange: string): void => {
|
||||
if (type === 'selected' && text && text.length > 0) {
|
||||
hasTextSelection.value = true;
|
||||
|
||||
pendingAnnotation.value = {
|
||||
cfiRange,
|
||||
text,
|
||||
contents: rendition.value?.getContents() || null
|
||||
};
|
||||
|
||||
// Reset form fields
|
||||
annotationName.value = '';
|
||||
annotationNote.value = '';
|
||||
editingAnnotation.value = null;
|
||||
|
||||
} else if (type === 'cleared') {
|
||||
hasTextSelection.value = false;
|
||||
pendingAnnotation.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const createAnnotationFromSelection = (): void => {
|
||||
console.log('Creating annotation from selection...');
|
||||
if (pendingAnnotation.value) {
|
||||
showAnnotationModal.value = true;
|
||||
} else {
|
||||
console.warn('No pending annotation to create');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle annotation save from modal
|
||||
const handleAnnotationSave = (formData?: AnnotationFormData): void => {
|
||||
const bookId = currentBookId.value;
|
||||
|
||||
if (!pendingAnnotation.value || !bookId || !formData) {
|
||||
console.error('Missing data for annotation save:', {
|
||||
pendingAnnotation: !!pendingAnnotation.value,
|
||||
bookId: !!bookId,
|
||||
formData: !!formData
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
};
|
||||
console.log('Updated existing annotation');
|
||||
}
|
||||
} else {
|
||||
// Create new annotation
|
||||
const annotation: Annotation = {
|
||||
id: generateAnnotationId(),
|
||||
bookId: 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);
|
||||
console.log('Created new annotation:', annotation.name);
|
||||
|
||||
// Apply visual highlight only if it's a real CFI
|
||||
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('Could not apply highlight immediately:', error);
|
||||
}
|
||||
}
|
||||
|
||||
saveAnnotationsToStorage(bookId);
|
||||
closeAnnotationModal();
|
||||
|
||||
// Show annotations panel after creating new annotation
|
||||
if (!editingAnnotation.value) {
|
||||
showAnnotationsPanel.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving annotation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeAnnotationModal = (): void => {
|
||||
showAnnotationModal.value = false;
|
||||
pendingAnnotation.value = null;
|
||||
annotationName.value = '';
|
||||
annotationNote.value = '';
|
||||
editingAnnotation.value = null;
|
||||
hasTextSelection.value = false;
|
||||
};
|
||||
|
||||
const goToAnnotation = (cfiRange: string): void => {
|
||||
if (rendition.value) {
|
||||
rendition.value.display(cfiRange);
|
||||
}
|
||||
};
|
||||
|
||||
const editAnnotation = (annotation: Annotation): void => {
|
||||
editingAnnotation.value = annotation;
|
||||
annotationName.value = annotation.name;
|
||||
annotationNote.value = annotation.note || '';
|
||||
|
||||
pendingAnnotation.value = {
|
||||
cfiRange: annotation.cfiRange,
|
||||
text: annotation.text,
|
||||
contents: null as any
|
||||
};
|
||||
|
||||
showAnnotationModal.value = true;
|
||||
};
|
||||
|
||||
const deleteAnnotation = (annotationId: string, bookId?: string): void => {
|
||||
const actualBookId = bookId || currentBookId.value;
|
||||
|
||||
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, 'saved-annotation');
|
||||
} catch (error) {
|
||||
console.warn('Could not remove highlight:', error);
|
||||
}
|
||||
|
||||
savedAnnotations.value.splice(index, 1);
|
||||
saveAnnotationsToStorage(actualBookId);
|
||||
console.log('Deleted annotation:', annotation.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAnnotationsPanel = (): void => {
|
||||
showAnnotationsPanel.value = !showAnnotationsPanel.value;
|
||||
};
|
||||
|
||||
return {
|
||||
savedAnnotations,
|
||||
pendingAnnotation,
|
||||
showAnnotationModal,
|
||||
showAnnotationsPanel,
|
||||
annotationName,
|
||||
annotationNote,
|
||||
editingAnnotation,
|
||||
hasTextSelection,
|
||||
|
||||
loadAnnotations,
|
||||
applyAnnotationsToView,
|
||||
toggleSelectionBubble,
|
||||
createAnnotationFromSelection,
|
||||
handleAnnotationSave,
|
||||
closeAnnotationModal,
|
||||
goToAnnotation,
|
||||
editAnnotation,
|
||||
deleteAnnotation,
|
||||
toggleAnnotationsPanel,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* @param {Document} document - The document object to add event
|
||||
* @param {Object} rendition - The EPUBJS rendition
|
||||
* @param {Function} fb - The listener function
|
||||
* @param {Function} fn - The listener function
|
||||
*/
|
||||
export default function selectListener(document, rendition, fn) {
|
||||
document.addEventListener('mousedown', () => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@
|
|||
<AnnotationsButton
|
||||
:is-open="showAnnotationsPanel"
|
||||
:count="savedAnnotations.length"
|
||||
:has-selection="hasTextSelection"
|
||||
@toggle="toggleAnnotationsPanel"
|
||||
@createFromSelection="createAnnotationFromSelection"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="loading">{{ t("reader.loading") }}</div>
|
||||
|
|
@ -104,9 +106,10 @@ import AnnotationModal from "../components/AnnotationModal.vue";
|
|||
import AnnotationsButton from "../components/AnnotationsButton.vue";
|
||||
import TocComponent from "../components/TocComponent.vue";
|
||||
import { useStyles } from "../composables/useStyles";
|
||||
import { useAnnotations } from "../composables/useAnnotations";
|
||||
import { loadBookFromIndexedDB } from "../utils/utils";
|
||||
import type { EpubFile } from "../types/epubFile";
|
||||
import type { Annotation, PendingAnnotation, AnnotationFormData } from "../types/annotations";
|
||||
import type { AnnotationFormData } from "../types/annotations";
|
||||
|
||||
// Import epub.js types
|
||||
import type Rendition from 'epubjs/types/rendition';
|
||||
|
|
@ -147,22 +150,6 @@ const showToc = ref<boolean>(true);
|
|||
const epubRef = ref<InstanceType<typeof EpubView> | null>(null);
|
||||
const currentHref = ref<string | number | null>(null);
|
||||
|
||||
const selectionBubble = reactive({
|
||||
visible: false,
|
||||
position: { left: '0px', top: '0px', width: '0px', height: '0px' },
|
||||
selectedText: '',
|
||||
cfiRange: ''
|
||||
});
|
||||
|
||||
// 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<ExtendedNavItem>,
|
||||
|
|
@ -177,6 +164,28 @@ const {
|
|||
toggleStylesModal, rendition, setRendition,
|
||||
} = useStyles();
|
||||
|
||||
// Annotations composable
|
||||
const {
|
||||
savedAnnotations,
|
||||
pendingAnnotation,
|
||||
showAnnotationModal,
|
||||
showAnnotationsPanel,
|
||||
annotationName,
|
||||
annotationNote,
|
||||
editingAnnotation,
|
||||
hasTextSelection,
|
||||
loadAnnotations,
|
||||
applyAnnotationsToView,
|
||||
toggleSelectionBubble,
|
||||
handleAnnotationSave,
|
||||
closeAnnotationModal,
|
||||
goToAnnotation,
|
||||
editAnnotation,
|
||||
deleteAnnotation,
|
||||
toggleAnnotationsPanel,
|
||||
createAnnotationFromSelection,
|
||||
} = useAnnotations(rendition, currentHref, accentColor);
|
||||
|
||||
const BookProgressManager = {
|
||||
saveProgress(bookId: string, cfi: string, extraData = {}) {
|
||||
try {
|
||||
|
|
@ -203,7 +212,6 @@ const BookProgressManager = {
|
|||
if (!data) return null;
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
//console.log(`Progress loaded for book ${bookId}:`, parsed);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error('Error loading book progress:', error);
|
||||
|
|
@ -217,38 +225,6 @@ const BookProgressManager = {
|
|||
}
|
||||
};
|
||||
|
||||
// The custom selection bubble toggle function to pass to EpubView
|
||||
const toggleSelectionBubble = (type, rect, text, cfiRange) => {
|
||||
if (type === 'selected' && text && text.length > 0) {
|
||||
selectionBubble.visible = true;
|
||||
selectionBubble.position = rect;
|
||||
selectionBubble.selectedText = text;
|
||||
selectionBubble.cfiRange = cfiRange;
|
||||
|
||||
// Create pending annotation to be used when the user wants to save
|
||||
pendingAnnotation.value = {
|
||||
cfiRange,
|
||||
text,
|
||||
contents: rendition.value.getContents()[0]
|
||||
};
|
||||
|
||||
// Show annotation modal directly
|
||||
showAnnotationModal.value = true;
|
||||
|
||||
// Clear any selection after capturing it
|
||||
if (rendition.value) {
|
||||
const contents = rendition.value.getContents();
|
||||
contents.forEach(content => {
|
||||
if (content.window && content.window.getSelection) {
|
||||
content.window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (type === 'cleared') {
|
||||
selectionBubble.visible = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadBook = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
|
@ -276,10 +252,10 @@ const loadBook = async (): Promise<void> => {
|
|||
const progress = BookProgressManager.loadProgress(bookId);
|
||||
if (progress && progress.cfi) {
|
||||
location.value = progress.cfi;
|
||||
//console.log("Setting initial location from localStorage:", location.value);
|
||||
}
|
||||
|
||||
loadAnnotations();
|
||||
// Load annotations
|
||||
loadAnnotations(bookId);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error loading book:", err);
|
||||
|
|
@ -291,10 +267,8 @@ const loadBook = async (): Promise<void> => {
|
|||
|
||||
// Handle location changes
|
||||
const locationChange = (epubcifi: string): void => {
|
||||
// Skip saving the location on the first render to prevent
|
||||
// overriding our saved location
|
||||
// Skip saving the location on the first render
|
||||
if (!firstRenderDone.value) {
|
||||
//console.log("## first render");
|
||||
firstRenderDone.value = true;
|
||||
return;
|
||||
}
|
||||
|
|
@ -311,19 +285,30 @@ const locationChange = (epubcifi: string): void => {
|
|||
}
|
||||
};
|
||||
|
||||
const getRendition = (renditionObj: Rendition): void => {
|
||||
setRendition(renditionObj);
|
||||
const getRendition = (rendition: Rendition): void => {
|
||||
setRendition(rendition);
|
||||
|
||||
renditionObj.on("relocated", (location: RelocatedEvent) => {
|
||||
rendition.on("relocated", (location: RelocatedEvent) => {
|
||||
currentHref.value = location.start.href;
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
applyAnnotationsToView();
|
||||
|
||||
let annotationsApplied = true;
|
||||
|
||||
rendition.on("rendered", async () => {
|
||||
|
||||
if (!annotationsApplied) {
|
||||
try {
|
||||
annotationsApplied = true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while applying annotations:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get book metadata
|
||||
const book: Book = renditionObj.book;
|
||||
const book: Book = rendition.book;
|
||||
book.ready.then(() => {
|
||||
const meta = book.packaging?.metadata;
|
||||
if (!bookTitle.value && meta?.title) {
|
||||
|
|
@ -333,194 +318,10 @@ const getRendition = (renditionObj: Rendition): void => {
|
|||
});
|
||||
};
|
||||
|
||||
// Annotation storage functions
|
||||
const getAnnotationStorageKey = (bookId: string): string => {
|
||||
return `epub-annotations-${bookId}`;
|
||||
};
|
||||
|
||||
const loadAnnotations = (): void => {
|
||||
try {
|
||||
// Wrapper for delete annotation to include bookId
|
||||
const deleteAnnotationWrapper = (annotationId: string): void => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 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;
|
||||
};
|
||||
|
||||
// Generate annotation ID
|
||||
const generateAnnotationId = (): string => {
|
||||
return `annotation-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
deleteAnnotation(annotationId, bookId);
|
||||
};
|
||||
|
||||
// Toggle TOC panel
|
||||
|
|
@ -533,11 +334,6 @@ const toggleToc = (): void => {
|
|||
}
|
||||
};
|
||||
|
||||
// Toggle annotations panel
|
||||
const toggleAnnotationsPanel = () => {
|
||||
showAnnotationsPanel.value = !showAnnotationsPanel.value;
|
||||
};
|
||||
|
||||
// Handle key events
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
|
|
@ -583,36 +379,8 @@ const setLocation = (
|
|||
expandedToc.value = !close;
|
||||
};
|
||||
|
||||
const debugStoredLocation = () => {
|
||||
const bookId = route.params.bookId as string;
|
||||
const progressKey = `book-progress-${bookId}`;
|
||||
const savedLocation = localStorage.getItem(progressKey);
|
||||
|
||||
console.log('================ DEBUG INFO ================');
|
||||
console.log('Book ID:', bookId);
|
||||
console.log('Progress key:', progressKey);
|
||||
console.log('Saved location in localStorage:', savedLocation);
|
||||
|
||||
// Check if the location format is valid
|
||||
if (savedLocation) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedLocation);
|
||||
console.log('Parsed location:', parsed);
|
||||
console.log('Is valid CFI format:', typeof parsed.cfi === 'string' && parsed.cfi.includes('epubcfi'));
|
||||
} catch (e) {
|
||||
console.log('Raw location string:', savedLocation);
|
||||
console.log('Is valid CFI format:', savedLocation.includes('epubcfi'));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=========================================');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// debugStoredLocation();
|
||||
loadBook();
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
|
|
@ -653,9 +421,11 @@ defineExpose({
|
|||
// Annotation related
|
||||
savedAnnotations,
|
||||
goToAnnotation,
|
||||
editAnnotation,
|
||||
deleteAnnotation,
|
||||
toggleAnnotationsPanel
|
||||
editAnnotation: editAnnotation,
|
||||
deleteAnnotation: deleteAnnotationWrapper,
|
||||
toggleAnnotationsPanel,
|
||||
toggleSelectionBubble,
|
||||
createAnnotationFromSelection
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -702,6 +472,7 @@ defineExpose({
|
|||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.toc-button-bar {
|
||||
|
|
@ -739,6 +510,7 @@ defineExpose({
|
|||
top: 10px;
|
||||
white-space: nowrap;
|
||||
z-index: 5;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.reader-view {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue