Compare commits

..

1 commit

Author SHA1 Message Date
jrosh
8d52773c5f
combine select and swipe listeners, fix phone select 2025-05-30 12:30:06 +02:00
11 changed files with 738 additions and 488 deletions

View file

@ -1,7 +1,5 @@
# 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

View file

@ -48,7 +48,7 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { ref, watch, nextTick, onMounted } from 'vue';
import { useI18n } from '../i18n/usei18n';
const { t } = useI18n();

View file

@ -1,45 +1,12 @@
<!-- AnnotationsButton.vue -->
<!-- src/components/AnnotationsButton.vue -->
<template>
<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 v-if="count > 0" class="annotations-toggle-btn" @click="$emit('toggle')" :title="isOpen ? 'Close annotations' : 'Open annotations'">
<span class="annotation-count">{{ 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">
import { computed, watch } from 'vue';
const props = defineProps({
defineProps({
isOpen: {
type: Boolean,
default: false
@ -47,35 +14,19 @@ const props = defineProps({
count: {
type: Number,
default: 0
},
hasSelection: {
type: Boolean,
default: false
},
}
});
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})`;
});
defineEmits(['toggle']);
</script>
<style scoped>
.annotations-button-container {
<style>
.annotations-toggle-btn {
position: fixed;
right: 60px;
top: 6px;
z-index: 40;
}
.annotations-count-btn {
width: 32px;
height: 32px;
width: 24px;
height: 24px;
color: var(--accent-color);
border: none;
border-bottom: 1px solid var(--accent-color);
@ -83,42 +34,11 @@ const countButtonTitle = computed(() => {
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
background: transparent;
font-weight: 600;
z-index: 40;
transition: transform 0.2s;
font-size: 1rem;
}
.annotations-count-btn:hover {
transform: translateY(-2px);
.annotations-toggle-btn:active {
transform: scale(0.95);
}
.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>

View file

@ -166,7 +166,7 @@ const truncateText = (text: string, maxLength: number): string => {
.annotation-actions {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.5rem;
}

View file

@ -29,10 +29,11 @@ import ePub from 'epubjs'
import type { Book, Rendition, Contents } from 'epubjs'
import {
clickListener,
// swipListener,
swipListener,
wheelListener,
keyListener,
selectListener
selectListener,
touchListener
} from '../utils/listeners/listener'
interface Props {
@ -40,6 +41,7 @@ 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']
@ -54,6 +56,7 @@ const props = withDefaults(defineProps<Props>(), {
const {
tocChanged,
getRendition,
handleTextSelected,
handleKeyPress,
toggleBubble,
epubInitOptions,
@ -212,38 +215,54 @@ const registerEvents = () => {
if (rendition) {
rendition.on('rendered', (section, iframe) => {
// Focus the iframe
iframe?.iframe?.contentWindow.focus()
iframe?.iframe?.contentWindow.focus();
// Register interaction listeners
if (!epubOptions?.flow?.includes('scrolled')) {
wheelListener(iframe.document, flipPage)
wheelListener(iframe.document, flipPage);
}
//swipListener(iframe.document, flipPage)
keyListener(iframe.document, flipPage)
selectListener(iframe.document, rendition, toggleBubble)
// Use the unified touch handler instead of separate listeners
touchListener(
iframe.document,
rendition,
flipPage, // Navigation function
toggleBubble || handleTextSelected // Selection function
);
// Register regular click listener for non-touch interactions
clickListener(iframe.document, rendition, flipPage);
// If no unified handler, fall back to separate listeners
// swipListener(iframe.document, flipPage);
// if (toggleBubble) {
// selectListener(iframe.document, rendition, toggleBubble);
// } else if (handleTextSelected) {
// rendition.on('selected', handleTextSelected);
// }
// Mark first content as displayed for location restoration
if (!loadingState.firstContentDisplayed) {
loadingState.firstContentDisplayed = true
loadingState.firstContentDisplayed = true;
}
})
});
// Location change tracking
rendition.on('locationChanged', onLocationChange)
// Other event handlers remain the same
rendition.on('locationChanged', onLocationChange);
rendition.on('relocated', (location: any) => {
// console.log('Book relocated to:', location)
})
rendition.on('relocated', (location) => {
console.log('Book relocated to:', location);
});
rendition.on('displayError', (err: any) => {
console.error('Display error:', err)
})
rendition.on('displayError', (err) => {
console.error('Display error:', err);
});
if (handleKeyPress) {
rendition.on('keypress', handleKeyPress)
rendition.on('keypress', handleKeyPress);
}
}
}
};
// Function to apply saved location
const applyPendingLocation = () => {
@ -361,7 +380,6 @@ defineExpose({
user-select: none;
appearance: none;
font-weight: bold;
touch-action: manipulation;
}
.arrow:hover {

View file

@ -40,7 +40,6 @@ export default {
position: absolute;
top: 6px;
right: 6px;
touch-action: manipulation;
}
.styles-button > div {

View file

@ -1,284 +0,0 @@
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,
};
}

View file

@ -3,10 +3,12 @@ import keyListener from './key'
import wheelListener from './wheel'
import swipListener from './swip'
import selectListener from './select'
import touchListener from './touch'
export {
clickListener,
keyListener,
wheelListener,
swipListener,
selectListener,
touchListener
}

View file

@ -1,29 +1,58 @@
// src/utils/listeners/selectListener.js
/**
* Enhanced selection listener with touch support
* @param {Document} document - The document object to add event
* @param {Object} rendition - The EPUBJS rendition
* @param {Function} fn - The listener function
*/
export default function selectListener(document, rendition, fn) {
document.addEventListener('mousedown', () => {
document.getSelection().removeAllRanges()
fn('cleared')
})
// Track touch selection state
let touchStarted = false;
let touchSelection = false;
let touchStartTime = 0;
const LONG_PRESS_DURATION = 500; // ms
const TOUCH_MOVE_THRESHOLD = 10; // pixels
let startX = 0;
let startY = 0;
document.addEventListener('mouseup', (e) => {
if (e.ignore) return
e.ignore = true
// Check if we're on a mobile device
const isMobileDevice = () => {
return (('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0));
};
const selection = document.getSelection()
const text = selection.toString()
// Check if we're on iOS
const isIOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
if (text === '') return
const range = selection.getRangeAt(0)
// Clear selection handler
const clearSelection = () => {
if (document.getSelection) {
document.getSelection().removeAllRanges();
}
fn('cleared');
};
const [contents] = rendition.getContents()
const cfiRange = contents.cfiFromRange(range)
// Process text selection
const processSelection = (e) => {
if (e.ignore) return;
e.ignore = true;
const SelectionReact = range.getBoundingClientRect()
const viewRect = rendition.manager.container.getBoundingClientRect()
const selection = document.getSelection();
const text = selection.toString();
if (text === '') return;
try {
const range = selection.getRangeAt(0);
const [contents] = rendition.getContents();
const cfiRange = contents.cfiFromRange(range);
const SelectionReact = range.getBoundingClientRect();
const viewRect = rendition.manager.container.getBoundingClientRect();
let react = {
left: `${
@ -32,7 +61,99 @@ export default function selectListener(document, rendition, fn) {
top: `${viewRect.y + SelectionReact.y}px`,
width: `${SelectionReact.width}px`,
height: `${SelectionReact.height}px`,
};
fn('selected', react, text, cfiRange);
} catch (error) {
console.error('Error processing selection:', error);
}
};
// Mouse events for desktop
document.addEventListener('mousedown', clearSelection);
document.addEventListener('mouseup', (e) => {
processSelection(e);
});
// Only add touch events if we're on a touch device
if (isMobileDevice()) {
// Touch events for mobile
document.addEventListener('touchstart', (e) => {
touchStarted = true;
touchSelection = false;
touchStartTime = Date.now();
// Store start position for determining if it's a tap or selection attempt
if (e.touches && e.touches[0]) {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!touchStarted) return;
// Check if this might be a selection attempt (not just a tap)
if (e.touches && e.touches[0]) {
const moveX = Math.abs(e.touches[0].clientX - startX);
const moveY = Math.abs(e.touches[0].clientY - startY);
// If user has moved finger more than threshold, might be trying to select
if (moveX > TOUCH_MOVE_THRESHOLD || moveY > TOUCH_MOVE_THRESHOLD) {
touchSelection = true;
}
}
}, { passive: true });
document.addEventListener('touchend', (e) => {
if (!touchStarted) return;
touchStarted = false;
const touchDuration = Date.now() - touchStartTime;
// Check if it was a long press or a deliberate selection movement
if (touchDuration > LONG_PRESS_DURATION || touchSelection) {
// Delay processing to allow the browser to complete the selection
setTimeout(() => {
processSelection(e);
}, 50);
} else {
// It was a quick tap, clear selection
clearSelection();
}
});
// Handle context menu long press on mobile
document.addEventListener('contextmenu', (e) => {
// This event fires on long-press on many mobile browsers
// Prevent the default context menu
if (isMobileDevice()) {
e.preventDefault();
}
// Delay to let the browser create the selection
setTimeout(() => {
processSelection(e);
}, 50);
});
// Add specific handling for iOS
if (isIOS()) {
// iOS sometimes needs additional help with text selection
document.addEventListener('selectionchange', () => {
// Only process if we're in a touch selection operation
if (touchSelection || (Date.now() - touchStartTime) > LONG_PRESS_DURATION) {
// Delay to let selection complete
setTimeout(() => {
const selection = document.getSelection();
if (selection && selection.toString().length > 0) {
const e = { ignore: false };
processSelection(e);
}
}, 100);
}
});
}
}
fn('selected', react, text, cfiRange)
})
}

View file

@ -0,0 +1,248 @@
/**
* Unified touch handler that supports both swipe navigation and text selection
* @param {Document} document - The document to add event listeners to
* @param {Object} rendition - The EPUBJS rendition
* @param {function} navigationFn - Function to call for navigation (swipe)
* @param {function} selectionFn - Function to call for text selection
*/
type epubEvent = TouchEvent & { ignore?: boolean };
type Direction = 'next' | 'prev' | 'up' | 'down';
export default function touchHandler(
document: Document,
rendition: any,
navigationFn: (direction: Direction) => void,
selectionFn: (type: string, rect?: any, text?: string, cfiRange?: string) => void
) {
// State tracking
let touchState = {
startX: 0,
startY: 0,
startTime: 0,
isLongPress: false,
isSelectionAttempt: false,
lastTapTime: 0,
moveCount: 0,
lastMoveX: 0,
lastMoveY: 0
};
// Constants for gesture detection
const SWIPE = {
threshold: 50, // Min distance for swipe
restraint: 200, // Max perpendicular movement
allowedTime: 500 // Max time for swipe
};
const SELECTION = {
longPressDuration: 500, // Time for long press
moveThreshold: 10, // Movement to trigger selection mode
selectionDelay: 50 // Delay to process selection after touch
};
// Clear selection
const clearSelection = () => {
if (document.getSelection) {
document.getSelection()?.removeAllRanges();
}
selectionFn('cleared');
};
// Process text selection
const processSelection = () => {
const selection = document.getSelection();
if (!selection) return;
const text = selection.toString();
if (text === '') return;
try {
const range = selection.getRangeAt(0);
const [contents] = rendition.getContents();
const cfiRange = contents.cfiFromRange(range);
const selectionRect = range.getBoundingClientRect();
const viewRect = rendition.manager.container.getBoundingClientRect();
const rect = {
left: `${
viewRect.x + selectionRect.x - (rendition.manager.scrollLeft || 0)
}px`,
top: `${viewRect.y + selectionRect.y}px`,
width: `${selectionRect.width}px`,
height: `${selectionRect.height}px`,
};
selectionFn('selected', rect, text, cfiRange);
} catch (error) {
console.error('Error processing selection:', error);
}
};
// Check if device is iOS
const isIOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
// Device-specific adjustments
if (isIOS()) {
SELECTION.longPressDuration = 400;
SELECTION.selectionDelay = 100;
}
// Touch start event
document.addEventListener('touchstart', (e: epubEvent) => {
if (e.ignore) return;
e.ignore = true;
// Get initial touch position
if (e.touches && e.touches[0]) {
touchState.startX = e.touches[0].pageX;
touchState.startY = e.touches[0].pageY;
touchState.lastMoveX = touchState.startX;
touchState.lastMoveY = touchState.startY;
touchState.startTime = Date.now();
touchState.isLongPress = false;
touchState.isSelectionAttempt = false;
touchState.moveCount = 0;
// Handle long press with timeout
const longPressTimeout = setTimeout(() => {
// If finger hasn't moved much, trigger long press
const currentX = touchState.lastMoveX;
const currentY = touchState.lastMoveY;
const moveX = Math.abs(currentX - touchState.startX);
const moveY = Math.abs(currentY - touchState.startY);
if (moveX < SELECTION.moveThreshold && moveY < SELECTION.moveThreshold) {
touchState.isLongPress = true;
}
}, SELECTION.longPressDuration);
// Store the timeout in a property to clear it if needed
(document as any)._longPressTimeout = longPressTimeout;
}
}, { passive: true });
// Touch move event
document.addEventListener('touchmove', (e: epubEvent) => {
if (e.ignore) return;
e.ignore = true;
if (e.touches && e.touches[0]) {
touchState.lastMoveX = e.touches[0].pageX;
touchState.lastMoveY = e.touches[0].pageY;
touchState.moveCount++;
// Calculate movement
const moveX = Math.abs(touchState.lastMoveX - touchState.startX);
const moveY = Math.abs(touchState.lastMoveY - touchState.startY);
// Determine if this might be a selection attempt
if (moveX > SELECTION.moveThreshold || moveY > SELECTION.moveThreshold) {
// Clear long press timeout if significant movement
if ((document as any)._longPressTimeout) {
clearTimeout((document as any)._longPressTimeout);
(document as any)._longPressTimeout = null;
}
// If moved a lot horizontally relative to vertically, might be a swipe
// If moved a lot vertically or in a pattern, might be selection
if ((moveY > moveX * 1.5) || touchState.moveCount > 5) {
touchState.isSelectionAttempt = true;
}
}
}
}, { passive: true });
// Touch end event
document.addEventListener('touchend', (e: epubEvent) => {
if (e.ignore) return;
e.ignore = true;
// Clear long press timeout if it's still active
if ((document as any)._longPressTimeout) {
clearTimeout((document as any)._longPressTimeout);
(document as any)._longPressTimeout = null;
}
// Calculate touch stats
const touchEndTime = Date.now();
const elapsedTime = touchEndTime - touchState.startTime;
// Get distance traveled
const distX = e.changedTouches[0].pageX - touchState.startX;
const distY = e.changedTouches[0].pageY - touchState.startY;
// Handle selection if it was a long press or selection attempt
if (touchState.isLongPress || touchState.isSelectionAttempt) {
// Delay slightly to let the browser finish selection
setTimeout(() => {
processSelection();
}, SELECTION.selectionDelay);
return;
}
// Handle swipe if it wasn't a selection attempt
if (elapsedTime <= SWIPE.allowedTime) {
// Horizontal swipe
if (Math.abs(distX) >= SWIPE.threshold && Math.abs(distY) <= SWIPE.restraint) {
// If dist traveled is negative, it indicates right swipe
navigationFn(distX < 0 ? 'next' : 'prev');
}
// Vertical swipe
else if (Math.abs(distY) >= SWIPE.threshold && Math.abs(distX) <= SWIPE.restraint) {
// If dist traveled is negative, it indicates up swipe
navigationFn(distY < 0 ? 'up' : 'down');
}
// Tap - convert to click for regular interaction
else {
clearSelection();
// Convert tap to click
document.dispatchEvent(
new MouseEvent('click', {
clientX: touchState.startX,
clientY: touchState.startY,
})
);
}
}
}, { passive: false });
// Handle context menu (long press on many mobile browsers)
document.addEventListener('contextmenu', (e) => {
// Only handle on mobile devices
if ('ontouchstart' in window) {
e.preventDefault();
// Delay to let the browser create the selection
setTimeout(() => {
processSelection();
}, SELECTION.selectionDelay);
}
});
// Add specific handling for iOS
if (isIOS()) {
// iOS sometimes needs additional help with text selection
document.addEventListener('selectionchange', () => {
// Only process if it might be from a touch operation
if (touchState.isLongPress || touchState.isSelectionAttempt) {
setTimeout(() => {
const selection = document.getSelection();
if (selection && selection.toString().length > 0) {
processSelection();
}
}, SELECTION.selectionDelay);
}
});
}
// Regular mouse events for desktop
document.addEventListener('mousedown', clearSelection);
document.addEventListener('mouseup', () => {
processSelection();
});
}

View file

@ -24,9 +24,7 @@
<AnnotationsButton
:is-open="showAnnotationsPanel"
:count="savedAnnotations.length"
:has-selection="hasTextSelection"
@toggle="toggleAnnotationsPanel"
@createFromSelection="createAnnotationFromSelection"
/>
<div v-if="loading" class="loading">{{ t("reader.loading") }}</div>
@ -106,10 +104,9 @@ 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 { AnnotationFormData } from "../types/annotations";
import type { Annotation, PendingAnnotation, AnnotationFormData } from "../types/annotations";
// Import epub.js types
import type Rendition from 'epubjs/types/rendition';
@ -150,6 +147,22 @@ 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>,
@ -164,28 +177,6 @@ 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 {
@ -212,6 +203,7 @@ 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);
@ -225,6 +217,38 @@ 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;
@ -252,10 +276,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);
}
// Load annotations
loadAnnotations(bookId);
loadAnnotations();
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error("Error loading book:", err);
@ -267,8 +291,10 @@ const loadBook = async (): Promise<void> => {
// Handle location changes
const locationChange = (epubcifi: string): void => {
// Skip saving the location on the first render
// Skip saving the location on the first render to prevent
// overriding our saved location
if (!firstRenderDone.value) {
//console.log("## first render");
firstRenderDone.value = true;
return;
}
@ -285,30 +311,19 @@ const locationChange = (epubcifi: string): void => {
}
};
const getRendition = (rendition: Rendition): void => {
setRendition(rendition);
const getRendition = (renditionObj: Rendition): void => {
setRendition(renditionObj);
rendition.on("relocated", (location: RelocatedEvent) => {
renditionObj.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 = rendition.book;
const book: Book = renditionObj.book;
book.ready.then(() => {
const meta = book.packaging?.metadata;
if (!bookTitle.value && meta?.title) {
@ -318,10 +333,194 @@ const getRendition = (rendition: Rendition): void => {
});
};
// Wrapper for delete annotation to include bookId
const deleteAnnotationWrapper = (annotationId: string): void => {
// Annotation storage functions
const getAnnotationStorageKey = (bookId: string): string => {
return `epub-annotations-${bookId}`;
};
const loadAnnotations = (): void => {
try {
const bookId = route.params.bookId as string;
deleteAnnotation(annotationId, 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);
}
} 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
@ -334,6 +533,11 @@ const toggleToc = (): void => {
}
};
// Toggle annotations panel
const toggleAnnotationsPanel = () => {
showAnnotationsPanel.value = !showAnnotationsPanel.value;
};
// Handle key events
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@ -379,8 +583,36 @@ 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);
});
@ -421,11 +653,9 @@ defineExpose({
// Annotation related
savedAnnotations,
goToAnnotation,
editAnnotation: editAnnotation,
deleteAnnotation: deleteAnnotationWrapper,
toggleAnnotationsPanel,
toggleSelectionBubble,
createAnnotationFromSelection
editAnnotation,
deleteAnnotation,
toggleAnnotationsPanel
});
</script>
@ -472,7 +702,6 @@ defineExpose({
top: 6px;
left: 6px;
z-index: 10;
touch-action: manipulation;
}
.toc-button-bar {
@ -510,7 +739,6 @@ defineExpose({
top: 10px;
white-space: nowrap;
z-index: 5;
touch-action: manipulation;
}
.reader-view {