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
|
# 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.
|
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
|
## Running
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, onMounted } from 'vue';
|
import { ref, watch, nextTick } from 'vue';
|
||||||
import { useI18n } from '../i18n/usei18n';
|
import { useI18n } from '../i18n/usei18n';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,45 @@
|
||||||
<!-- src/components/AnnotationsButton.vue -->
|
<!-- AnnotationsButton.vue -->
|
||||||
<template>
|
<template>
|
||||||
<button v-if="count > 0" class="annotations-toggle-btn" @click="$emit('toggle')" :title="isOpen ? 'Close annotations' : 'Open annotations'">
|
<div class="annotations-button-container">
|
||||||
<span class="annotation-count">{{ count }}</span>
|
<!-- 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>
|
||||||
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps({
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
isOpen: {
|
isOpen: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
|
@ -14,19 +47,35 @@ defineProps({
|
||||||
count: {
|
count: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.annotations-toggle-btn {
|
.annotations-button-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 60px;
|
right: 60px;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
width: 24px;
|
z-index: 40;
|
||||||
height: 24px;
|
}
|
||||||
|
|
||||||
|
.annotations-count-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--accent-color);
|
border-bottom: 1px solid var(--accent-color);
|
||||||
|
|
@ -34,11 +83,42 @@ defineEmits(['toggle']);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 40;
|
transition: all 0.2s ease;
|
||||||
transition: transform 0.2s;
|
font-size: 0.9rem;
|
||||||
font-size: 1rem;
|
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>
|
</style>
|
||||||
|
|
@ -166,7 +166,7 @@ const truncateText = (text: string, maxLength: number): string => {
|
||||||
|
|
||||||
.annotation-actions {
|
.annotation-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
justify-content: space-between;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import ePub from 'epubjs'
|
||||||
import type { Book, Rendition, Contents } from 'epubjs'
|
import type { Book, Rendition, Contents } from 'epubjs'
|
||||||
import {
|
import {
|
||||||
clickListener,
|
clickListener,
|
||||||
swipListener,
|
// swipListener,
|
||||||
wheelListener,
|
wheelListener,
|
||||||
keyListener,
|
keyListener,
|
||||||
selectListener
|
selectListener
|
||||||
|
|
@ -40,7 +40,6 @@ interface Props {
|
||||||
location?: any // Current Page number | string | Rendition['location']['start']
|
location?: any // Current Page number | string | Rendition['location']['start']
|
||||||
tocChanged?: (toc: Book['navigation']['toc']) => void
|
tocChanged?: (toc: Book['navigation']['toc']) => void
|
||||||
getRendition?: (rendition: Rendition) => void
|
getRendition?: (rendition: Rendition) => void
|
||||||
handleTextSelected?: (cfiRange: string, contents: Contents) => void
|
|
||||||
handleKeyPress?: () => void
|
handleKeyPress?: () => void
|
||||||
toggleBubble?: (type: string, rect?: any, text?: string, cfiRange?: string) => void // For custom selection
|
toggleBubble?: (type: string, rect?: any, text?: string, cfiRange?: string) => void // For custom selection
|
||||||
epubInitOptions?: Book['settings']
|
epubInitOptions?: Book['settings']
|
||||||
|
|
@ -55,7 +54,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
const {
|
const {
|
||||||
tocChanged,
|
tocChanged,
|
||||||
getRendition,
|
getRendition,
|
||||||
handleTextSelected,
|
|
||||||
handleKeyPress,
|
handleKeyPress,
|
||||||
toggleBubble,
|
toggleBubble,
|
||||||
epubInitOptions,
|
epubInitOptions,
|
||||||
|
|
@ -220,16 +218,9 @@ const registerEvents = () => {
|
||||||
if (!epubOptions?.flow?.includes('scrolled')) {
|
if (!epubOptions?.flow?.includes('scrolled')) {
|
||||||
wheelListener(iframe.document, flipPage)
|
wheelListener(iframe.document, flipPage)
|
||||||
}
|
}
|
||||||
swipListener(iframe.document, flipPage)
|
//swipListener(iframe.document, flipPage)
|
||||||
keyListener(iframe.document, flipPage)
|
keyListener(iframe.document, flipPage)
|
||||||
|
|
||||||
// Register your custom selection listener if toggleBubble is provided
|
|
||||||
if (toggleBubble) {
|
|
||||||
selectListener(iframe.document, rendition, 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
|
// Mark first content as displayed for location restoration
|
||||||
if (!loadingState.firstContentDisplayed) {
|
if (!loadingState.firstContentDisplayed) {
|
||||||
|
|
@ -370,6 +361,7 @@ defineExpose({
|
||||||
user-select: none;
|
user-select: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow:hover {
|
.arrow:hover {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export default {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.styles-button > div {
|
.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 {Document} document - The document object to add event
|
||||||
* @param {Object} rendition - The EPUBJS rendition
|
* @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) {
|
export default function selectListener(document, rendition, fn) {
|
||||||
document.addEventListener('mousedown', () => {
|
document.addEventListener('mousedown', () => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@
|
||||||
<AnnotationsButton
|
<AnnotationsButton
|
||||||
:is-open="showAnnotationsPanel"
|
:is-open="showAnnotationsPanel"
|
||||||
:count="savedAnnotations.length"
|
:count="savedAnnotations.length"
|
||||||
|
:has-selection="hasTextSelection"
|
||||||
@toggle="toggleAnnotationsPanel"
|
@toggle="toggleAnnotationsPanel"
|
||||||
|
@createFromSelection="createAnnotationFromSelection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">{{ t("reader.loading") }}</div>
|
<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 AnnotationsButton from "../components/AnnotationsButton.vue";
|
||||||
import TocComponent from "../components/TocComponent.vue";
|
import TocComponent from "../components/TocComponent.vue";
|
||||||
import { useStyles } from "../composables/useStyles";
|
import { useStyles } from "../composables/useStyles";
|
||||||
|
import { useAnnotations } from "../composables/useAnnotations";
|
||||||
import { loadBookFromIndexedDB } from "../utils/utils";
|
import { loadBookFromIndexedDB } from "../utils/utils";
|
||||||
import type { EpubFile } from "../types/epubFile";
|
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 epub.js types
|
||||||
import type Rendition from 'epubjs/types/rendition';
|
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 epubRef = ref<InstanceType<typeof EpubView> | null>(null);
|
||||||
const currentHref = ref<string | number | 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
|
// TOC related state
|
||||||
const bookState = reactive({
|
const bookState = reactive({
|
||||||
toc: [] as Array<ExtendedNavItem>,
|
toc: [] as Array<ExtendedNavItem>,
|
||||||
|
|
@ -177,6 +164,28 @@ const {
|
||||||
toggleStylesModal, rendition, setRendition,
|
toggleStylesModal, rendition, setRendition,
|
||||||
} = useStyles();
|
} = 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 = {
|
const BookProgressManager = {
|
||||||
saveProgress(bookId: string, cfi: string, extraData = {}) {
|
saveProgress(bookId: string, cfi: string, extraData = {}) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -203,7 +212,6 @@ const BookProgressManager = {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
//console.log(`Progress loaded for book ${bookId}:`, parsed);
|
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading book progress:', 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> => {
|
const loadBook = async (): Promise<void> => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
@ -276,10 +252,10 @@ const loadBook = async (): Promise<void> => {
|
||||||
const progress = BookProgressManager.loadProgress(bookId);
|
const progress = BookProgressManager.loadProgress(bookId);
|
||||||
if (progress && progress.cfi) {
|
if (progress && progress.cfi) {
|
||||||
location.value = progress.cfi;
|
location.value = progress.cfi;
|
||||||
//console.log("Setting initial location from localStorage:", location.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAnnotations();
|
// Load annotations
|
||||||
|
loadAnnotations(bookId);
|
||||||
} 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);
|
||||||
|
|
@ -291,10 +267,8 @@ const loadBook = async (): Promise<void> => {
|
||||||
|
|
||||||
// Handle location changes
|
// Handle location changes
|
||||||
const locationChange = (epubcifi: string): void => {
|
const locationChange = (epubcifi: string): void => {
|
||||||
// Skip saving the location on the first render to prevent
|
// Skip saving the location on the first render
|
||||||
// overriding our saved location
|
|
||||||
if (!firstRenderDone.value) {
|
if (!firstRenderDone.value) {
|
||||||
//console.log("## first render");
|
|
||||||
firstRenderDone.value = true;
|
firstRenderDone.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -311,19 +285,30 @@ const locationChange = (epubcifi: string): void => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRendition = (renditionObj: Rendition): void => {
|
const getRendition = (rendition: Rendition): void => {
|
||||||
setRendition(renditionObj);
|
setRendition(rendition);
|
||||||
|
|
||||||
renditionObj.on("relocated", (location: RelocatedEvent) => {
|
rendition.on("relocated", (location: RelocatedEvent) => {
|
||||||
currentHref.value = location.start.href;
|
currentHref.value = location.start.href;
|
||||||
});
|
});
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
applyAnnotationsToView();
|
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
|
// Get book metadata
|
||||||
const book: Book = renditionObj.book;
|
const book: Book = rendition.book;
|
||||||
book.ready.then(() => {
|
book.ready.then(() => {
|
||||||
const meta = book.packaging?.metadata;
|
const meta = book.packaging?.metadata;
|
||||||
if (!bookTitle.value && meta?.title) {
|
if (!bookTitle.value && meta?.title) {
|
||||||
|
|
@ -333,194 +318,10 @@ const getRendition = (renditionObj: Rendition): void => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Annotation storage functions
|
// Wrapper for delete annotation to include bookId
|
||||||
const getAnnotationStorageKey = (bookId: string): string => {
|
const deleteAnnotationWrapper = (annotationId: string): void => {
|
||||||
return `epub-annotations-${bookId}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAnnotations = (): void => {
|
|
||||||
try {
|
|
||||||
const bookId = route.params.bookId as string;
|
const bookId = route.params.bookId as string;
|
||||||
const storageKey = getAnnotationStorageKey(bookId);
|
deleteAnnotation(annotationId, 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle TOC panel
|
// Toggle TOC panel
|
||||||
|
|
@ -533,11 +334,6 @@ const toggleToc = (): void => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle annotations panel
|
|
||||||
const toggleAnnotationsPanel = () => {
|
|
||||||
showAnnotationsPanel.value = !showAnnotationsPanel.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle key events
|
// Handle key events
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
|
@ -583,36 +379,8 @@ const setLocation = (
|
||||||
expandedToc.value = !close;
|
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(() => {
|
onMounted(() => {
|
||||||
// debugStoredLocation();
|
|
||||||
loadBook();
|
loadBook();
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -653,9 +421,11 @@ defineExpose({
|
||||||
// Annotation related
|
// Annotation related
|
||||||
savedAnnotations,
|
savedAnnotations,
|
||||||
goToAnnotation,
|
goToAnnotation,
|
||||||
editAnnotation,
|
editAnnotation: editAnnotation,
|
||||||
deleteAnnotation,
|
deleteAnnotation: deleteAnnotationWrapper,
|
||||||
toggleAnnotationsPanel
|
toggleAnnotationsPanel,
|
||||||
|
toggleSelectionBubble,
|
||||||
|
createAnnotationFromSelection
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -702,6 +472,7 @@ defineExpose({
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-button-bar {
|
.toc-button-bar {
|
||||||
|
|
@ -739,6 +510,7 @@ defineExpose({
|
||||||
top: 10px;
|
top: 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-view {
|
.reader-view {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue