feat(koreader): rebuild the plugin interface and fix four-way annotation sync

Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
This commit is contained in:
neon
2026-10-19 21:04:23 +00:00
parent 7202a59afc
commit 80244ea4e2
349 changed files with 66312 additions and 8386 deletions
@@ -263,9 +263,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
</Tooltip>
<div class="flex flex-1 min-w-0 flex-col self-stretch">
<p class="text-sm leading-relaxed text-foreground" :class="quoteClampClass">
{{ annotation.text }}
</p>
<p class="whitespace-pre-line text-sm leading-relaxed text-foreground" :class="quoteClampClass">{{ annotation.text }}</p>
<button
v-if="isLong"
@@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import type { AnnotationHubOverview } from '@bookorbit/types'
import { i18n } from '@/i18n'
import HubSideRail from '../hub/HubSideRail.vue'
const stubs = {
RouterLink: { template: '<a><slot /></a>' },
AnnotationBookThumb: { template: '<div />' },
AnnotationActivitySpark: { template: '<div />' },
}
function makeOverview(overrides: Partial<AnnotationHubOverview> = {}): AnnotationHubOverview {
return {
total: 12,
books: 3,
withNotes: 2,
needsReview: 0,
trashed: 46,
originBreakdown: [],
colorBreakdown: [],
shelf: [],
weeks: [],
busiestWeek: null,
longestQuietWeeks: 0,
devices: [],
...overrides,
}
}
function mountRail(overview: AnnotationHubOverview, trashOpen = false) {
return mount(HubSideRail, {
props: { overview, selectedColors: [], selectedBookId: 'all', selectedOrigin: 'all', trashOpen },
global: { stubs },
})
}
describe('HubSideRail', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('names the trash toggle with its count and pressed state', async () => {
const wrapper = mountRail(makeOverview(), true)
const toggle = wrapper.get('header button[aria-pressed]')
expect(toggle.attributes('aria-label')).toBe('Trash, 46 annotations')
expect(toggle.attributes('aria-pressed')).toBe('true')
await toggle.trigger('click')
expect(wrapper.emitted('openTrash')).toHaveLength(1)
})
it('uses the singular for one trashed annotation', () => {
const wrapper = mountRail(makeOverview({ trashed: 1 }))
expect(wrapper.get('header button[aria-pressed]').attributes('aria-label')).toBe('Trash, 1 annotation')
})
})
@@ -7,6 +7,7 @@ import { ANNOTATION_HIGHLIGHT_COLORS, type AnnotationHubOverview } from '@bookor
import { formatNumber, formatRelativeFromNow } from '@/i18n/formatters'
import AnnotationBookThumb from '../AnnotationBookThumb.vue'
import AnnotationActivitySpark from '../shared/AnnotationActivitySpark.vue'
import { annotationSourceKey } from '@/features/annotations/lib/source-label'
const props = defineProps<{
overview: AnnotationHubOverview
@@ -44,7 +45,7 @@ function colorName(hex: string): string {
}
function deviceLabel(device: AnnotationHubOverview['devices'][number]): string {
return device.deviceName ?? t(`annotations.sources.${device.source}`)
return device.deviceName ?? t(annotationSourceKey(device.source))
}
function handleReview() {
@@ -68,6 +69,7 @@ function handleOpenTrash() {
class="ml-auto inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-[11px] font-semibold transition-colors"
:class="trashOpen ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
:aria-pressed="trashOpen"
:aria-label="t('annotations.hub.trashToggle', { count: overview.trashed })"
@click="handleOpenTrash"
>
<Trash2 :size="12" />
@@ -136,7 +138,7 @@ function handleOpenTrash() {
@click="emit('toggleOrigin', entry.origin)"
>
<span class="size-1.5 rounded-full" :style="{ backgroundColor: `var(--pill-${entry.origin})` }" />
{{ t(`annotations.sources.${entry.origin}`) }}
{{ t(annotationSourceKey(entry.origin)) }}
<b class="font-semibold text-foreground">{{ formatNumber(entry.count) }}</b>
</button>
</div>
@@ -20,6 +20,7 @@ import type { AnnotationHubItem, AnnotationItem } from '@bookorbit/types'
import { formatDate } from '@/i18n/formatters'
import { formatChipStyle } from '@/features/book/lib/format-colors'
import AnnotationBookThumb from '../AnnotationBookThumb.vue'
import { annotationSourceKey } from '@/features/annotations/lib/source-label'
type EntryAnnotation = AnnotationItem | AnnotationHubItem
@@ -154,7 +155,7 @@ function handlePurge() {
<!-- WHAT -->
<div class="col-start-3 min-w-0" :class="stacked ? 'row-start-2' : 'row-start-1'">
<p class="text-foreground" :class="quoteClass">{{ annotation.text }}</p>
<p class="whitespace-pre-line text-foreground" :class="quoteClass">{{ annotation.text }}</p>
<div v-if="annotation.note" class="mt-2 border-l-2 border-primary pl-2.5">
<span class="mb-0.5 block text-[8.5px] font-bold uppercase tracking-[0.13em] text-primary">{{ t('annotations.hub.noteLabel') }}</span>
<p class="text-[11.5px] leading-relaxed text-foreground">{{ annotation.note }}</p>
@@ -268,7 +269,7 @@ function handlePurge() {
</span>
<span v-if="locationLabel" class="shrink-0 text-[10px] font-semibold leading-[14px] text-muted-foreground">{{ locationLabel }}</span>
<span class="inline-flex shrink-0 items-center gap-1 text-[10px] leading-[14px]" :style="{ color: `var(--pill-${annotation.origin})` }">
{{ t(`annotations.sources.${annotation.origin}`) }}
{{ t(annotationSourceKey(annotation.origin)) }}
</span>
<span
v-if="format"
@@ -308,15 +308,17 @@ describe('useAnnotationsHub', () => {
expect(apiMock).toHaveBeenCalledWith('/api/v1/annotations/7', { method: 'DELETE' })
})
it('purge surfaces the conflict message on 409', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ message: 'Still queued' }, { ok: false, status: 409 }))
it('purge reports a pending device sync from the error code, not the message', async () => {
apiMock.mockResolvedValueOnce(
makeResponse({ errorCode: 'ANNOTATION_PURGE_PENDING_DEVICE_SYNC', message: 'Still queued' }, { ok: false, status: 409 }),
)
const { useAnnotationsHub } = await import('../useAnnotationsHub')
const hub = useAnnotationsHub()
const result = await hub.purge(7)
expect(result).toEqual({ ok: false, message: 'Still queued' })
expect(result).toEqual({ ok: false, pendingDeviceSync: true })
})
it('purge returns a generic failure on other errors', async () => {
@@ -327,7 +329,7 @@ describe('useAnnotationsHub', () => {
const result = await hub.purge(7)
expect(result).toEqual({ ok: false })
expect(result).toEqual({ ok: false, pendingDeviceSync: false })
})
})
@@ -1,5 +1,11 @@
import { computed, nextTick, ref, watch } from 'vue'
import type { AnnotationHubBookFacet, AnnotationHubItem, AnnotationHubOverview, AnnotationHubResponse } from '@bookorbit/types'
import {
ANNOTATION_PURGE_PENDING_DEVICE_SYNC,
type AnnotationHubBookFacet,
type AnnotationHubItem,
type AnnotationHubOverview,
type AnnotationHubResponse,
} from '@bookorbit/types'
import { api } from '@/lib/api'
import { HUB_VIEWS, type HubViewKey } from '../lib/hub-groups'
import { useAnnotationSelection } from './useAnnotationSelection'
@@ -270,17 +276,14 @@ export function useAnnotationsHub() {
return res.ok
}
async function purge(id: number): Promise<{ ok: boolean; message?: string }> {
async function purge(id: number): Promise<{ ok: boolean; pendingDeviceSync?: boolean }> {
const res = await api(`/api/v1/annotations/${id}`, { method: 'DELETE' })
if (res.ok) {
await refresh()
return { ok: true }
}
if (res.status === 409) {
const body = (await res.json().catch(() => null)) as { message?: string } | null
return { ok: false, message: body?.message }
}
return { ok: false }
const body = (await res.json().catch(() => null)) as { errorCode?: string } | null
return { ok: false, pendingDeviceSync: body?.errorCode === ANNOTATION_PURGE_PENDING_DEVICE_SYNC }
}
async function trashOne(id: number): Promise<boolean> {
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { annotationSourceKey } from '../source-label'
describe('annotationSourceKey', () => {
it('names highlights made in the browser or the phone apps BookOrbit, and devices by their own name', () => {
expect(annotationSourceKey('web')).toBe('annotations.sources.bookorbit')
expect(annotationSourceKey('kobo')).toBe('annotations.sources.kobo')
expect(annotationSourceKey('koreader')).toBe('annotations.sources.koreader')
})
})
@@ -1,4 +1,5 @@
import { ANNOTATION_COLOR_FILTER_OPTIONS, ANNOTATION_HIGHLIGHT_COLORS } from '@bookorbit/types'
import { annotationSourceKey } from '@/features/annotations/lib/source-label'
export interface HubChip {
id: string
@@ -40,7 +41,7 @@ export function buildHubChips(state: HubChipState, t: Translate, formatDay: (val
}
if (state.originFilter && state.originFilter !== 'all') {
chips.push({ id: 'origin', label: t(`annotations.sources.${state.originFilter}`) })
chips.push({ id: 'origin', label: t(annotationSourceKey(state.originFilter)) })
}
if (state.dateFrom || state.dateTo) {
@@ -0,0 +1,7 @@
/**
* The i18n key naming where a highlight was made. Highlights from the web reader and the phone apps
* are both stored as `web`, so that origin reads as BookOrbit rather than as the browser.
*/
export function annotationSourceKey(origin: string): string {
return origin === 'web' ? 'annotations.sources.bookorbit' : `annotations.sources.${origin}`
}
@@ -9,6 +9,7 @@ import { ANNOTATION_HIGHLIGHT_COLORS, type AnnotationHubItem, type AnnotationIte
import { formatDate } from '@/i18n/formatters'
import { downloadFromUrl } from '@/lib/download'
import PageTitleRow from '@/components/page-header/PageTitleRow.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import AnnotationStream from '../components/shared/AnnotationStream.vue'
import AnnotationEntry from '../components/shared/AnnotationEntry.vue'
import AnnotationEntryDetail from '../components/shared/AnnotationEntryDetail.vue'
@@ -21,6 +22,7 @@ import type { StreamGroup } from '../lib/stream-groups'
import { useAnnotationsHub } from '../composables/useAnnotationsHub'
import { useAnnotationsUrlSync } from '../composables/useAnnotationsUrlSync'
import { useDensity } from '../composables/useDensity'
import { annotationSourceKey } from '@/features/annotations/lib/source-label'
const { t } = useI18n()
const router = useRouter()
@@ -40,6 +42,8 @@ const { density } = useDensity()
const stacked = useMediaQuery('(max-width: 1535px), (pointer: coarse)')
const compact = computed(() => density.value === 'compact')
const activeId = ref<number | null>(null)
const purgeConfirmId = ref<number | null>(null)
const purging = ref(false)
const chips = computed(() =>
buildHubChips(
@@ -83,7 +87,7 @@ const groups = computed<StreamGroup[]>(() =>
if (group.mode === 'source') {
return {
key: group.key,
label: t(`annotations.sources.${group.key}`),
label: t(annotationSourceKey(group.key)),
swatch: `var(--pill-${group.key})`,
count: group.items.length,
items: group.items,
@@ -149,14 +153,28 @@ async function handleTrash(id: number) {
}
}
async function handlePurge(id: number) {
const result = await hub.purge(id)
function handlePurge(id: number) {
purgeConfirmId.value = id
}
function cancelPurge() {
purgeConfirmId.value = null
}
async function confirmPurge() {
const id = purgeConfirmId.value
if (id === null) return
purging.value = true
const result = await hub.purge(id).finally(() => {
purging.value = false
purgeConfirmId.value = null
})
if (result.ok) {
if (activeId.value === id) activeId.value = null
toast.success(t('annotations.hub.toast.annotationDeletedForever'))
return
}
toast.error(result.message ?? t('annotations.hub.toast.failedToDelete'))
toast.error(result.pendingDeviceSync ? t('annotations.hub.toast.purgePendingDeviceSync') : t('annotations.hub.toast.failedToDelete'))
}
async function handleRestore(id: number) {
@@ -326,5 +344,15 @@ onMounted(() => {
@open-trash="handleOpenTrash"
/>
</div>
<ConfirmDialog
:open="purgeConfirmId !== null"
:title="t('annotations.hub.purgeDialog.title')"
:description="t('annotations.hub.purgeDialog.description')"
:confirm-label="t('annotations.listItem.deleteForever')"
:busy="purging"
@confirm="confirmPurge"
@cancel="cancelPurge"
/>
</div>
</template>
@@ -7,6 +7,7 @@ import { formatNumber } from '@/i18n/formatters'
import AnnotationActivitySpark from '@/features/annotations/components/shared/AnnotationActivitySpark.vue'
import { busiestWeek, foldDaysIntoWeeks, longestQuietWeeks } from '@/features/book/lib/highlight-activity'
import type { HighlightGroup } from '@/features/book/lib/highlight-groups'
import { annotationSourceKey } from '@/features/annotations/lib/source-label'
const props = defineProps<{
stats: AnnotationStats
@@ -124,7 +125,7 @@ function handleReview() {
class="inline-flex h-6 items-center gap-1.5 text-[11.5px] text-muted-foreground"
>
<span class="size-1.5 rounded-full" :style="{ backgroundColor: `var(--pill-${entry.origin})` }" />
{{ t(`annotations.sources.${entry.origin}`) }}
{{ t(annotationSourceKey(entry.origin)) }}
<b class="font-semibold text-foreground">{{ formatNumber(entry.count) }}</b>
</span>
</div>
@@ -6,6 +6,7 @@ import type { ReadingAttempt, ReadingAttemptOutcome } from '@bookorbit/types'
import { READING_ATTEMPT_OUTCOMES } from '@bookorbit/types'
import { formatDate } from '@/i18n/formatters'
import type { ReadingAttemptDraft } from '@/features/book/composables/useReadingAttempts'
import { compareReadingOrder } from '@/features/book/lib/reading-attempt-order'
const props = defineProps<{
attempts: ReadingAttempt[]
@@ -33,12 +34,12 @@ const localError = ref<string | null>(null)
/** Oldest first, so "attempt 2" means the second one the reader started. */
const ordinalById = computed(() => {
const ordered = [...props.attempts].sort((left, right) => left.id - right.id)
const ordered = [...props.attempts].sort(compareReadingOrder)
return new Map(ordered.map((attempt, index) => [attempt.id, index + 1]))
})
/** Newest first: the attempt in progress is the one worth seeing without scrolling. */
const ordered = computed(() => [...props.attempts].sort((left, right) => right.id - left.id))
const ordered = computed(() => [...props.attempts].sort((left, right) => compareReadingOrder(right, left)))
const outcomeOptions = computed(() =>
READING_ATTEMPT_OUTCOMES.map((outcome) => ({ value: outcome, label: t(`book.detail.readingLog.attempts.outcome.${outcome}`) })),
@@ -7,6 +7,7 @@ import { toReadingSessionSourceBucket, READING_SESSION_SOURCE_BUCKET_LABELS } fr
import { formatDate } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
import { buildReadingCheckpointRoute } from '@/lib/reading-checkpoint'
import { compareReadingOrder } from '@/features/book/lib/reading-attempt-order'
const props = defineProps<{
bookId: number
@@ -141,7 +142,7 @@ function positionBar(session: BookReadingSession) {
const attemptById = computed(() => new Map(props.attempts.map((attempt) => [attempt.id, attempt])))
const attemptOrdinalById = computed(() => {
const ordered = [...props.attempts].sort((left, right) => left.id - right.id)
const ordered = [...props.attempts].sort(compareReadingOrder)
return new Map(ordered.map((attempt, index) => [attempt.id, index + 1]))
})
@@ -18,6 +18,7 @@ import { useBookHighlights } from '@/features/book/composables/useBookHighlights
import { HIGHLIGHT_VIEW_KEYS, type HighlightViewKey } from '@/features/book/lib/highlight-views'
import HighlightsSideRail from '@/features/book/components/detail/highlights/HighlightsSideRail.vue'
import HighlightsEmptyStage from '@/features/book/components/detail/highlights/HighlightsEmptyStage.vue'
import { annotationSourceKey } from '@/features/annotations/lib/source-label'
const props = defineProps<{ book: BookDetail }>()
@@ -63,7 +64,7 @@ const groups = computed<StreamGroup[]>(() =>
if (group.mode === 'source') {
return {
key: group.key,
label: t(`annotations.sources.${group.key}`),
label: t(annotationSourceKey(group.key)),
swatch: `var(--pill-${group.key})`,
count: group.total,
items: group.items,
@@ -1,6 +1,7 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { ReadingAttempt, ReadingAttemptListResponse, ReadingAttemptOutcome, UserBookStatus } from '@bookorbit/types'
import { api } from '@/lib/api'
import { compareReadingOrder } from '@/features/book/lib/reading-attempt-order'
export type ReadingAttemptDraft = {
startedOn: string | null
@@ -25,7 +26,7 @@ export function useReadingAttempts(bookIdRef: Ref<number>) {
const byId = computed(() => new Map(attempts.value.map((attempt) => [attempt.id, attempt])))
/** Oldest first, so "attempt 2" means the second one the reader started. */
const ordinalById = computed(() => {
const ordered = [...attempts.value].sort((left, right) => left.id - right.id)
const ordered = [...attempts.value].sort(compareReadingOrder)
return new Map(ordered.map((attempt, index) => [attempt.id, index + 1]))
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import type { ReadingAttempt } from '@bookorbit/types'
import { compareReadingOrder } from './reading-attempt-order'
function attempt(id: number, startedOn: string | null, endedOn: string | null): ReadingAttempt {
return { id, bookId: 7, startedOn, endedOn, outcome: 'completed', origin: 'bookorbit' } as ReadingAttempt
}
describe('compareReadingOrder', () => {
it('puts an undated earlier read before the dated reads recorded before it', () => {
const first = attempt(15, '2026-08-17', '2026-10-17')
const earlier = attempt(427, null, null)
const reread = attempt(428, '2026-10-17', '2026-10-17')
expect([reread, earlier, first].sort(compareReadingOrder).map((read) => read.id)).toEqual([427, 15, 428])
})
it('places a past read added later by its dates', () => {
const current = attempt(30, '2026-10-01', null)
const past = attempt(31, '2019-03-01', '2019-03-20')
expect([current, past].sort(compareReadingOrder).map((read) => read.id)).toEqual([31, 30])
})
it('orders a read that records only its end by that day, and same-day reads by id', () => {
const ended = attempt(5, null, '2024-04-05')
const started = attempt(6, '2024-04-05', null)
const later = attempt(4, '2024-05-01', null)
expect([later, started, ended].sort(compareReadingOrder).map((read) => read.id)).toEqual([5, 6, 4])
})
})
@@ -0,0 +1,14 @@
import type { ReadingAttempt } from '@bookorbit/types'
/**
* Orders reads as they happened: by the day each began, or ended when that is all it records. An
* undated read stands for one before BookOrbit kept history, such as the earlier read a reread
* implies, so it comes first. Ordered by id, that read, recorded at the reread, was numbered as the
* newest and shown above the read it came before.
*/
export function compareReadingOrder(left: ReadingAttempt, right: ReadingAttempt): number {
const leftDay = left.startedOn ?? left.endedOn ?? ''
const rightDay = right.startedOn ?? right.endedOn ?? ''
if (leftDay !== rightDay) return leftDay < rightDay ? -1 : 1
return left.id - right.id
}
+144 -21
View File
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate'
import type { SelectionDetail } from './epub/composables/useFoliateSelection'
import { useReaderProgress } from './shared/composables/useReaderProgress'
import { useReaderProgress, type NewerPosition } from './shared/composables/useReaderProgress'
import { useReadingSession } from './shared/composables/useReadingSession'
import { useReaderPageTitle } from './shared/composables/useReaderPageTitle'
import { useReaderState } from './epub/composables/useReaderState'
@@ -17,6 +17,7 @@ import { useFullscreen } from './shared/composables/useFullscreen'
import { useBookmarks } from './epub/composables/useBookmarks'
import { useAnnotations } from './epub/composables/useAnnotations'
import { useReaderAnnotationActions } from './epub/composables/useReaderAnnotationActions'
import { useReaderMarksRefresh } from './epub/composables/useReaderMarksRefresh'
import { useToc } from './epub/composables/useToc'
import { useSearch, type FoliateView } from './epub/composables/useSearch'
import { useReaderSelection } from './epub/composables/useReaderSelection'
@@ -30,7 +31,7 @@ import { useTtsPosition } from '@/features/tts/composables/useTtsPosition'
import { useTtsKeyboard } from '@/features/tts/composables/useTtsKeyboard'
import { getProviders, getVoices } from '@/features/tts/api/tts.api'
import { useMediaOverlay } from './media-overlay/composables/useMediaOverlay'
import { resolveMediaOverlayResume, type MediaOverlayResumePosition } from './media-overlay/lib/media-overlay-resume'
import { mediaOverlayFragmentSeconds, resolveMediaOverlayResume, type MediaOverlayResumePosition } from './media-overlay/lib/media-overlay-resume'
import { injectMediaOverlayHighlightCss, MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS } from './media-overlay/lib/media-overlay-highlight'
import { startMediaOverlayWithFallback } from './media-overlay/lib/media-overlay-start'
import { mediaOverlayEntriesOverlapping } from './media-overlay/lib/media-overlay-range'
@@ -39,6 +40,7 @@ import ReaderHeader from './epub/components/ReaderHeader.vue'
import ReaderFooter from './epub/components/ReaderFooter.vue'
import ReaderSidebar from './epub/components/ReaderSidebar.vue'
import ReaderSettingsPanel from './epub/components/ReaderSettingsPanel.vue'
import NewerPositionDialog from './epub/components/NewerPositionDialog.vue'
import SelectionPopup from './epub/components/SelectionPopup.vue'
import ReaderSearchPanel from './epub/components/ReaderSearchPanel.vue'
import NoteDialog from './shared/components/NoteDialog.vue'
@@ -137,6 +139,8 @@ const { onActivity, elapsedMinutes } = useReadingSession(
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, {
trackingEnabled,
narrationSeconds: narrationSecondsAt,
onNarrationMovedBack: handleNarrationMovedBack,
})
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal, footerMode, cycleFooterMode, updateHeadsFeet } = progress
@@ -158,6 +162,16 @@ const { results: searchResults, isSearching, search: doSearch, clear: clearSearc
const selection = useReaderSelection()
const { isSidebarPinned, toggleSidebarPinned, shouldCloseAfterNavigation } = useReaderSidebarPin(fileId)
async function toggleBookmark() {
const wasBookmarked = bookmarks.isCurrentCfiBookmarked.value
const saved = await bookmarks.toggle(bookId, cfi.value ?? '', chapterTitle.value, fileId)
if (!saved) toast.error(t(wasBookmarked ? 'reader.toast.bookmarkDeleteFailed' : 'reader.toast.bookmarkSaveFailed'))
}
function handleToggleBookmark() {
void toggleBookmark()
}
function closeAnyPanel() {
if (showTapZones.value) {
showTapZones.value = false
@@ -179,9 +193,7 @@ const { showHelpModal } = useReaderKeyboardShortcuts({
toggleSearch: () => {
showSearch.value = !showSearch.value
},
toggleBookmark: () => {
bookmarks.toggle(bookId, cfi.value ?? '', chapterTitle.value)
},
toggleBookmark: handleToggleBookmark,
toggleFullscreen,
cycleFooterMode,
closePanel: closeAnyPanel,
@@ -751,6 +763,88 @@ function onApplyStylesHandler(renderer: FoliateRenderer) {
}
}
// A reader left open while another device reads on would save its old page over the newer one at
// its next turn. Coming back to the tab asks the server first, and the reader chooses, as on iOS.
const NEWER_POSITION_CHECK_INTERVAL_MS = 5000
let lastNewerPositionCheckAt = 0
const newerPosition = ref<NewerPosition | null>(null)
const newerPositionLocalPercentage = ref(0)
async function offerNewerPosition() {
if (document.visibilityState !== 'visible' || initialNavigationPending.value || newerPosition.value) return
if (mediaOverlay.isPlaying.value || isTtsActive.value) return
const now = Date.now()
if (now - lastNewerPositionCheckAt < NEWER_POSITION_CHECK_INTERVAL_MS) return
lastNewerPositionCheckAt = now
const newer = await progress.checkForNewerPosition().catch(() => null)
if (!newer || newerPosition.value) return
newerPositionLocalPercentage.value = progress.percentage.value
newerPosition.value = newer
}
async function narrationSecondsAt(fragment: string, section: number | null): Promise<number | null> {
const playlist = await loadMediaOverlayPlaylist()
return playlist ? mediaOverlayFragmentSeconds(playlist, fragment, section) : null
}
let resumeNarrationAfterStay = false
let askedOverNarration = false
/** A narration save held back because another device sent narration well behind it: pause and ask. */
function handleNarrationMovedBack(newer: NewerPosition) {
if (newerPosition.value) return
askedOverNarration = true
if (mediaOverlay.isPlaying.value) {
mediaOverlay.pause()
resumeNarrationAfterStay = true
}
newerPositionLocalPercentage.value = progress.percentage.value
newerPosition.value = newer
}
function handleFollowNewerPosition() {
const newer = newerPosition.value
newerPosition.value = null
resumeNarrationAfterStay = false
askedOverNarration = false
if (newer) void goToNewerPosition(newer)
}
function handleStayAtCurrentPosition() {
const newer = newerPosition.value
newerPosition.value = null
if (newer) void progress.keepCurrentPosition(newer, askedOverNarration ? 'narration' : 'text')
if (resumeNarrationAfterStay) mediaOverlay.resume()
resumeNarrationAfterStay = false
askedOverNarration = false
}
async function goToNewerPosition(newer: NewerPosition) {
relocationSource = 'text'
await progress.adoptNewerPosition(newer, async () => {
// A paused narration resumes from its own sentence, so it gives way to the one adopted.
if (mediaOverlay.isActive.value) mediaOverlay.stop()
try {
if (progress.cfi.value) await goTo(progress.cfi.value)
else await goToFraction(newer.percentage / 100)
} catch {
toast.error(t('reader.toast.newerPositionError'))
}
})
const narration = getCurrentNarrationPos()
if (narration) saveNarrationPos(narration.section, narration.fragment)
}
function handleReaderReturn() {
void offerNewerPosition()
if (document.visibilityState === 'visible') void refreshMarks()
}
onMounted(() => {
document.addEventListener('visibilitychange', handleReaderReturn)
window.addEventListener('focus', handleReaderReturn)
})
function onMiddleTapHandler() {
handleMiddleTap()
}
@@ -802,7 +896,13 @@ const {
getMediaActiveClass,
} = useFoliate(() => containerRef.value, onRelocateHandler, onApplyStylesHandler, onMiddleTapHandler, onChapterLoadHandler, canRunManualNavigation)
const { handleHighlight, handleOpenNoteDialog, handleSaveNote } = useReaderAnnotationActions({
const {
isSaving: isSavingAnnotation,
handleHighlight,
handleOpenNoteDialog,
handleSaveNote,
handleDeleteAnnotation: deleteReaderAnnotation,
} = useReaderAnnotationActions({
bookId,
fileId,
chapterTitle,
@@ -810,6 +910,19 @@ const { handleHighlight, handleOpenNoteDialog, handleSaveNote } = useReaderAnnot
selection,
addAnnotation,
redrawAnnotation,
deleteAnnotation,
})
const marksDrawn = ref(false)
const { refreshMarks } = useReaderMarksRefresh({
bookId,
fileId,
annotations,
bookmarks,
canRefresh: () => marksDrawn.value && !isSavingAnnotation.value && !initialNavigationPending.value,
addAnnotation,
redrawAnnotation,
deleteAnnotation,
})
function handleTextSelected(detail: SelectionDetail) {
@@ -876,8 +989,9 @@ onMounted(async () => {
})
setChapters(getChapters())
sectionFractions.value = getSectionFractions()
await bookmarks.load(bookId)
await annotations.load(bookId)
await bookmarks.load(bookId, fileId)
await annotations.load(bookId, fileId)
if (bookmarks.loadError.value || annotations.loadError.value) toast.error(t('reader.toast.marksLoadFailed'))
const drawableAnnotations = annotations.annotations.value.filter((a): a is typeof a & { cfi: string } => a.cfi != null)
if (drawableAnnotations.length > 0) {
addAnnotations(
@@ -888,6 +1002,7 @@ onMounted(async () => {
})),
)
}
marksDrawn.value = true
void hydrateSidebarLocationMeta()
if (deepLinkCfi) {
@@ -1033,24 +1148,20 @@ watch(() => [customFonts.fonts.value, customFonts.serverFonts.value], refreshFon
watch(() => state.value.fontFamily, refreshFontFaces)
function handleDeleteAnnotation(id: number) {
const ann = annotations.annotations.value.find((a) => a.id === id)
if (ann) {
if (ann.cfi) deleteAnnotation(ann.cfi)
annotations.remove(bookId, id)
}
void deleteReaderAnnotation(id)
selection.dismiss()
}
function handleSidebarDeleteAnnotation(id: number) {
const ann = annotations.annotations.value.find((a) => a.id === id)
if (ann) {
if (ann.cfi) deleteAnnotation(ann.cfi)
annotations.remove(bookId, id)
}
void deleteReaderAnnotation(id)
}
function handleSidebarDeleteBookmark(id: number) {
bookmarks.remove(bookId, id)
async function handleSidebarDeleteBookmark(id: number) {
if (!(await bookmarks.remove(bookId, id))) toast.error(t('reader.toast.bookmarkDeleteFailed'))
}
async function handleSidebarEditBookmark(id: number, title: string, note: string | null) {
if (!(await bookmarks.update(bookId, id, { title, note }))) toast.error(t('reader.toast.bookmarkUpdateFailed'))
}
function getSidebarCfiTargets(): string[] {
@@ -1200,6 +1311,8 @@ watch(
)
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleReaderReturn)
window.removeEventListener('focus', handleReaderReturn)
mediaOverlayStartId++
setReaderFooterVisible(false)
mediaOverlay.stop()
@@ -1233,7 +1346,7 @@ onUnmounted(() => {
@back="router.back()"
@toggleSidebar="showSidebar = !showSidebar"
@toggleSearch="showSearch = !showSearch"
@toggleBookmark="bookmarks.toggle(bookId, cfi ?? '', chapterTitle)"
@toggleBookmark="handleToggleBookmark"
@update:settings-open="setSettingsOpen"
@toggleFullscreen="toggleFullscreen"
@toggleHelp="toggleHelpModal"
@@ -1565,6 +1678,7 @@ onUnmounted(() => {
@navigateAnnotation="navigateFromSidebar"
@navigateAnnotationChapter="navigateAnnotationChapterFromSidebar"
@deleteBookmark="handleSidebarDeleteBookmark"
@editBookmark="handleSidebarEditBookmark"
@deleteAnnotation="handleSidebarDeleteAnnotation"
@toggleExpand="toggleExpand"
/>
@@ -1585,6 +1699,7 @@ onUnmounted(() => {
v-if="selection.showNoteDialog.value"
:selectedText="selection.text.value"
:modelValue="selection.noteText.value"
:saving="isSavingAnnotation"
@update:modelValue="selection.noteText.value = $event"
@save="handleSaveNote"
@cancel="selection.showNoteDialog.value = false"
@@ -1623,6 +1738,14 @@ onUnmounted(() => {
</Transition>
</Teleport>
<NewerPositionDialog
:open="newerPosition !== null"
:remotePercentage="newerPosition?.percentage ?? 0"
:localPercentage="newerPositionLocalPercentage"
@follow="handleFollowNewerPosition"
@stay="handleStayAtCurrentPosition"
/>
<DictionaryPopover
v-if="showDictionary"
:word="dictionaryWord"
@@ -354,7 +354,7 @@ describe('Foliate navigation', () => {
view.addEventListener('relocate', relocate)
renderer.dispatchEvent(new CustomEvent('relocate', { detail: { index: 0, range, fraction: 0.5 } }))
const detail = (relocate.mock.calls[0]![0] as CustomEvent).detail
expect(detail.cfi).toBe('epubcfi(/6/2!/4/2[book-columns]/2[book-inner]/4/1:4)')
expect(detail.cfi).toBe('epubcfi(/6/2!/4/2[book-columns]/2[book-inner]/4/1:0)')
expect(detail.koreaderProgress).toBeNull()
expect(detail.range).toBe(range)
expect(range.startContainer).toBe(doc.querySelector('#anchor'))
@@ -364,6 +364,52 @@ describe('Foliate navigation', () => {
}
})
it('keeps the reader selection while narration moves on, and follows the voice once it is gone', async () => {
document.body.innerHTML = '<p><span id="s1">First sentence.</span> <span id="s2">Second sentence.</span></p>'
const originalCreateElement = document.createElement.bind(document)
const goTo = vi.fn<() => Promise<void>>(() => Promise.resolve())
const renderer = Object.assign(originalCreateElement('div'), {
open: vi.fn<() => void>(),
goTo,
getContents: () => [{ index: 0, doc: document }],
})
const overlay = new EventTarget()
const createElement = vi
.spyOn(document, 'createElement')
.mockImplementation((name: string) => (name === 'foliate-paginator' ? renderer : originalCreateElement(name)))
const highlight = async (id: string) => {
overlay.dispatchEvent(new CustomEvent('highlight', { detail: { text: `chapter.xhtml#${id}` } }))
await Promise.resolve()
}
try {
const view = new View()
await view.open({
sections: [{ id: 'chapter.xhtml', mediaOverlay: {} }],
media: { activeClass: 'narrating' },
getMediaOverlay: () => overlay,
resolveHref: (href: string) => ({ index: 0, anchor: (doc: Document) => doc.getElementById(href.split('#')[1]!) }),
} as never)
const selection = window.getSelection()!
const range = document.createRange()
range.selectNodeContents(document.getElementById('s1')!)
selection.removeAllRanges()
selection.addRange(range)
await highlight('s2')
expect(goTo).not.toHaveBeenCalled()
expect(selection.toString()).toBe('First sentence.')
expect(document.getElementById('s2')!.classList.contains('narrating')).toBe(true)
selection.removeAllRanges()
await highlight('s1')
expect(goTo).toHaveBeenCalledTimes(1)
} finally {
createElement.mockRestore()
window.getSelection()?.removeAllRanges()
document.body.innerHTML = ''
}
})
it('preserves the source text point inside inline elements', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = '<p>First paragraph</p><p><span>Middle text</span> tail</p>'
@@ -379,7 +425,25 @@ describe('Foliate navigation', () => {
expect(getReadingProgressRange(range)?.startOffset).toBe(2)
})
it('uses the text endpoint and retains source layout wrappers when the range starts on a container', () => {
it('keeps the first visible text when a page range starts at an inline container', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML = '<p><span id="first">First visible sentence.</span></p><p>Last visible sentence.</p>'
const first = doc.querySelector('span')!.firstChild!
const last = doc.querySelector('p:last-child')!.firstChild!
const range = doc.createRange()
range.setStart(doc.querySelector('p')!, 0)
range.setEnd(last, 8)
const point = getReadingProgressRange(range)
expect(point?.startContainer).toBe(first)
expect(point?.startOffset).toBe(0)
expect(point?.collapsed).toBe(true)
expect(range.startContainer).toBe(doc.querySelector('p'))
expect(range.endContainer).toBe(last)
expect(range.collapsed).toBe(false)
})
it('finds the first following text and retains source wrappers after an empty anchor', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML =
'<div id="book-columns"><div id="book-inner"><div id="filepos48449"></div><p><span id="kobo.10.6">Middle text</span></p></div></div>'
@@ -393,7 +457,7 @@ describe('Foliate navigation', () => {
range.setEnd(text, 6)
expect(getReadingProgressRange(range)?.startContainer).toBe(text)
expect(getReadingProgressRange(range)?.startOffset).toBe(6)
expect(getReadingProgressRange(range)?.startOffset).toBe(0)
expect(range.startContainer).toBe(start)
})
@@ -456,7 +520,7 @@ describe('Foliate navigation', () => {
expect(getReadingProgressRange(null)).toBeNull()
})
it('uses the selected text point for KoboSpan instead of the first span in a wide range', () => {
it('uses the first visible KoboSpan when a page starts at an empty anchor', () => {
const doc = document.implementation.createHTMLDocument('chapter')
doc.body.innerHTML =
'<div id="filepos48449"></div><p><span class="koboSpan" id="kobo.1.1">Start</span></p><p><span class="koboSpan" id="kobo.10.6">Middle text</span></p>'
@@ -469,6 +533,6 @@ describe('Foliate navigation', () => {
range.setStart(start, 0)
range.setEnd(text, 6)
expect(getKoboSpanValue(range)).toBe('kobo.10.6')
expect(getKoboSpanValue(range)).toBe('kobo.1.1')
})
})
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
const props = defineProps<{
title: string
note: string | null
}>()
const emit = defineEmits<{
save: [title: string, note: string | null]
cancel: []
}>()
const { t } = useI18n()
const titleId = useId()
const noteId = useId()
const draftTitle = ref(props.title)
const draftNote = ref(props.note ?? '')
const canSave = computed(() => draftTitle.value.trim().length > 0)
function handleOpenChange(open: boolean) {
if (!open) emit('cancel')
}
function handleCancel() {
emit('cancel')
}
function handleSave() {
if (!canSave.value) return
const note = draftNote.value.trim()
emit('save', draftTitle.value.trim(), note === '' ? null : note)
}
</script>
<template>
<DialogRoot :open="true" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[70] bg-scrim" />
<DialogContent
aria-modal="true"
:aria-describedby="undefined"
class="fixed left-1/2 top-1/2 z-[70] flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-lg bg-card p-4 text-card-foreground shadow-2xl focus-visible:outline-none"
>
<DialogTitle class="text-sm font-medium">{{ t('reader.bookmarkEdit.title') }}</DialogTitle>
<form class="flex flex-col gap-3" @submit.prevent="handleSave">
<div class="flex flex-col gap-1">
<label :for="titleId" class="text-xs text-muted-foreground">{{ t('reader.bookmarkEdit.titleLabel') }}</label>
<Input :id="titleId" v-model="draftTitle" maxlength="500" />
</div>
<div class="flex flex-col gap-1">
<label :for="noteId" class="text-xs text-muted-foreground">{{ t('reader.bookmarkEdit.noteLabel') }}</label>
<Textarea
:id="noteId"
v-model="draftNote"
:placeholder="t('reader.bookmarkEdit.notePlaceholder')"
class="resize-none"
rows="3"
maxlength="4000"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" @click="handleCancel">{{ t('common.cancel') }}</Button>
<Button type="submit" size="sm" :disabled="!canSave">{{ t('common.save') }}</Button>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { Button } from '@/components/ui/button'
import { formatPercent } from '@/i18n/formatters'
const props = defineProps<{
open: boolean
remotePercentage: number
localPercentage: number
}>()
const emit = defineEmits<{ follow: []; stay: [] }>()
const { t } = useI18n()
// One decimal, because the two places are often within a percent of each other.
const remote = computed(() => formatPercent(props.remotePercentage / 100, 1))
const local = computed(() => formatPercent(props.localPercentage / 100, 1))
// Only the two answers close it: staying saves this page over the other device's, so it is never
// the outcome of a stray Escape or click.
function preventDismiss(event: Event) {
event.preventDefault()
}
function handleFollow() {
emit('follow')
}
function handleStay() {
emit('stay')
}
</script>
<template>
<DialogRoot :open="open">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[70] bg-scrim" />
<DialogContent
aria-modal="true"
class="fixed left-1/2 top-1/2 z-[70] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@escape-key-down="preventDismiss"
@pointer-down-outside="preventDismiss"
@interact-outside="preventDismiss"
>
<DialogTitle class="text-lg font-semibold text-foreground">{{ t('reader.newerPositionDialog.title') }}</DialogTitle>
<DialogDescription class="mt-1 text-sm text-muted-foreground">
{{ t('reader.newerPositionDialog.description', { remote, local }) }}
</DialogDescription>
<div class="mt-6 flex flex-wrap justify-end gap-2">
<Button variant="outline" @click="handleStay">{{ t('reader.newerPositionDialog.stay', { pct: local }) }}</Button>
<Button @click="handleFollow">{{ t('reader.newerPositionDialog.follow', { pct: remote }) }}</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -120,6 +120,7 @@ function getFooterModeTooltip(mode: 0 | 1 | 2): string {
class="viewer-btn"
:class="isBookmarked ? '!text-primary' : ''"
:aria-label="t('reader.header.toggleBookmark')"
:aria-pressed="isBookmarked"
@click="emit('toggleBookmark')"
>
<BookmarkCheck v-if="isBookmarked" :size="18" />
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bookmark, BookOpen, Highlighter, Pin, PinOff, Trash2, TriangleAlert, X } from '@lucide/vue'
import { Bookmark, BookOpen, Highlighter, Pencil, Pin, PinOff, Trash2, TriangleAlert, X } from '@lucide/vue'
import { ANNOTATION_COLOR_FILTER_OPTIONS } from '@bookorbit/types'
import type { TocItem } from '../composables/useToc'
import type { Bookmark as BookmarkType } from '../composables/useBookmarks'
import type { Annotation } from '../composables/useAnnotations'
import { stripFragment, findNearestCfi, formatCfiLocation, formatDate, getCfiSortKey } from '../utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import BookmarkEditDialog from './BookmarkEditDialog.vue'
const { t } = useI18n()
@@ -35,6 +36,7 @@ const emit = defineEmits<{
navigateAnnotation: [cfi: string]
navigateAnnotationChapter: [chapterIndex: number]
deleteBookmark: [id: number]
editBookmark: [id: number, title: string, note: string | null]
deleteAnnotation: [id: number]
toggleExpand: [href: string]
togglePinned: []
@@ -218,6 +220,22 @@ function deleteBookmark(id: number) {
emit('deleteBookmark', id)
}
const editingBookmark = ref<BookmarkType | null>(null)
function editBookmark(bookmark: BookmarkType) {
editingBookmark.value = bookmark
}
function handleBookmarkEditSave(title: string, note: string | null) {
const bookmark = editingBookmark.value
editingBookmark.value = null
if (bookmark) emit('editBookmark', bookmark.id, title, note)
}
function handleBookmarkEditCancel() {
editingBookmark.value = null
}
function deleteAnnotation(id: number) {
emit('deleteAnnotation', id)
}
@@ -336,6 +354,7 @@ function togglePinned() {
<Bookmark :size="14" class="mt-0.5 shrink-0 text-muted-foreground" />
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium leading-snug truncate">{{ bm.title || t('reader.sidebar.bookmarkFallback') }}</p>
<p v-if="bm.note" class="text-xs text-foreground mt-0.5 line-clamp-2 whitespace-pre-line">{{ bm.note }}</p>
<p class="text-[11px] text-muted-foreground mt-0.5">{{ getBookmarkContextLine(bm) }}</p>
<p class="text-[11px] text-muted-foreground mt-0.5">{{ formatDate(bm.createdAt) }}</p>
</div>
@@ -344,7 +363,21 @@ function togglePinned() {
<TooltipTrigger as-child>
<button
type="button"
class="opacity-0 group-hover:opacity-100 flex items-center justify-center w-6 h-6 rounded-xs text-muted-foreground hover:text-destructive transition-all shrink-0"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 flex items-center justify-center w-6 h-6 rounded-xs text-muted-foreground hover:text-foreground transition-all shrink-0"
:aria-label="t('reader.sidebar.editBookmark')"
@click.stop="editBookmark(bm)"
>
<Pencil :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.sidebar.editBookmark') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 flex items-center justify-center w-6 h-6 rounded-xs text-muted-foreground hover:text-destructive transition-all shrink-0"
:aria-label="t('reader.sidebar.deleteBookmark')"
@click.stop="deleteBookmark(bm.id)"
>
<Trash2 :size="13" />
@@ -413,7 +446,7 @@ function togglePinned() {
<button type="button" class="flex flex-1 min-w-0 items-start gap-2 text-left cursor-pointer" @click="navigateAnnotation(ann)">
<span class="mt-1.5 w-2.5 h-2.5 rounded-full shrink-0" :style="{ background: ann.color }" />
<div class="flex-1 min-w-0">
<p class="text-[13px] leading-relaxed line-clamp-3">{{ ann.text }}</p>
<p class="text-[13px] leading-relaxed line-clamp-3 whitespace-pre-line">{{ ann.text }}</p>
<p class="text-[11px] text-muted-foreground mt-1 flex items-center gap-1">
<Tooltip v-if="isApproximateAnnotation(ann)">
<TooltipTrigger as-child>
@@ -448,6 +481,13 @@ function togglePinned() {
</div>
</div>
<div v-if="!pinned" class="flex-1" data-reader-sidebar-backdrop @click="closeSidebar" />
<BookmarkEditDialog
v-if="editingBookmark"
:title="editingBookmark.title"
:note="editingBookmark.note ?? null"
@save="handleBookmarkEditSave"
@cancel="handleBookmarkEditCancel"
/>
</div>
</template>
@@ -35,10 +35,10 @@ const copied = ref(false)
const colors = ANNOTATION_HIGHLIGHT_COLORS
const styles = [
{ id: 'highlight', label: 'H' },
{ id: 'underline', label: 'U' },
{ id: 'strikethrough', label: 'S' },
{ id: 'squiggly', label: '~' },
{ id: 'highlight', label: 'H', nameKey: 'reader.selection.styles.highlight' },
{ id: 'underline', label: 'U', nameKey: 'reader.selection.styles.underline' },
{ id: 'strikethrough', label: 'S', nameKey: 'reader.selection.styles.strikethrough' },
{ id: 'squiggly', label: '~', nameKey: 'reader.selection.styles.squiggly' },
]
const selectedColor = ref('#FACC15')
@@ -91,6 +91,7 @@ async function onCopy() {
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors"
:class="copied ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'"
:aria-label="copied ? t('reader.selection.copied') : t('reader.selection.copy')"
@click="onCopy"
>
<Check v-if="copied" :size="15" />
@@ -105,6 +106,7 @@ async function onCopy() {
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors"
:class="showColorPicker ? 'bg-yellow-100 text-yellow-600' : 'text-muted-foreground hover:text-foreground'"
:aria-label="t('reader.selection.highlight')"
@click="onHighlightClick"
>
<Highlighter :size="15" />
@@ -117,6 +119,7 @@ async function onCopy() {
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
:aria-label="t('common.search')"
@click="emit('search')"
>
<Search :size="15" />
@@ -129,6 +132,7 @@ async function onCopy() {
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
:aria-label="t('reader.selection.translate')"
@click="emit('translate')"
>
<Languages :size="15" />
@@ -141,6 +145,7 @@ async function onCopy() {
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
:aria-label="t('reader.selection.define')"
@click="emit('define')"
>
<BookA :size="15" />
@@ -153,6 +158,7 @@ async function onCopy() {
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
:aria-label="t('reader.note.title')"
@click="emit('note')"
>
<FileText :size="15" />
@@ -165,6 +171,7 @@ async function onCopy() {
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-destructive hover:text-destructive"
:aria-label="t('reader.selection.deleteAnnotation')"
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
>
<Trash2 :size="15" />
@@ -177,12 +184,13 @@ async function onCopy() {
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
:aria-label="t('reader.selection.readFromHere')"
@click="emit('readFromHere')"
>
<Headphones :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>Read from here</TooltipContent>
<TooltipContent>{{ t('reader.selection.readFromHere') }}</TooltipContent>
</Tooltip>
</div>
@@ -208,6 +216,8 @@ async function onCopy() {
:class="
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
"
:aria-label="t('reader.selection.style', { style: t(s.nameKey) })"
:aria-pressed="selectedStyle === s.id"
@click="selectedStyle = s.id"
>
{{ s.label }}
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { i18n } from '@/i18n'
import ReaderHeader from '../ReaderHeader.vue'
const viewport = vi.hoisted(() => ({ isCompact: false }))
@@ -76,6 +77,16 @@ describe('ReaderHeader', () => {
expect(wrapper.emitted('togglePin')?.length).toBe(1)
})
it('exposes whether the current location is bookmarked as a pressed state', async () => {
i18n.global.locale.value = 'en'
const wrapper = mountHeader({ isBookmarked: false })
expect(wrapper.get('button[aria-label="Toggle bookmark"]').attributes('aria-pressed')).toBe('false')
await wrapper.setProps({ isBookmarked: true })
expect(wrapper.get('button[aria-label="Toggle bookmark"]').attributes('aria-pressed')).toBe('true')
})
describe('settings container', () => {
it('anchors settings to a popover on wide viewports', () => {
viewport.isCompact = false
@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReaderSidebar from '../ReaderSidebar.vue'
@@ -220,6 +220,42 @@ describe('ReaderSidebar', () => {
expect(wrapper.emitted('deleteAnnotation')?.[0]).toEqual([9])
})
it('renames a bookmark and sets its note from an accessible dialog', async () => {
const wrapper = mount(ReaderSidebar, {
props: {
...makeBaseProps(),
bookmarks: [{ id: 4, bookId: 1, cfi: 'epubcfi(/6/2)', title: 'Chapter 1', note: null, createdAt: '2026-02-14T12:00:00.000Z' }],
},
global,
attachTo: document.body,
})
await wrapper
.findAll('button')
.find((btn) => btn.text().includes('Marks'))
?.trigger('click')
await wrapper.get('button[aria-label="Edit bookmark"]').trigger('click')
await flushPromises()
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"]')
expect(dialog?.getAttribute('aria-modal')).toBe('true')
const [title] = dialog!.querySelectorAll('input')
const note = dialog!.querySelector('textarea')!
expect(title!.value).toBe('Chapter 1')
expect(document.querySelector(`label[for="${title!.id}"]`)?.textContent).toBe('Title')
title!.value = ' The storm '
title!.dispatchEvent(new Event('input'))
note.value = 'Reread before the exam'
note.dispatchEvent(new Event('input'))
await flushPromises()
dialog!.querySelector('form')!.dispatchEvent(new Event('submit'))
await flushPromises()
expect(wrapper.emitted('editBookmark')?.[0]).toEqual([4, 'The storm', 'Reread before the exam'])
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
wrapper.unmount()
})
it('applies highlight search, filters, and sorting controls', async () => {
const wrapper = mount(ReaderSidebar, {
props: {
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n } from '@/i18n'
import SelectionPopup from '../SelectionPopup.vue'
const globalStubs = {
@@ -11,7 +12,70 @@ const globalStubs = {
},
}
function accessibleName(button: DOMWrapper<Element>) {
return (button.attributes('aria-label') ?? button.text()).trim()
}
function getButtonByName(wrapper: VueWrapper, name: string) {
const matches = wrapper.findAll('button').filter((button) => accessibleName(button) === name)
expect(matches, `button named "${name}"`).toHaveLength(1)
return matches[0]!
}
function mountPopup(props: Partial<InstanceType<typeof SelectionPopup>['$props']> = {}) {
return mount(SelectionPopup, {
props: {
visible: true,
position: { x: 100, y: 200 },
showBelow: false,
selectedText: 'Text',
overlappingAnnotationId: null,
isTtsAvailable: false,
...props,
},
global: globalStubs,
})
}
describe('SelectionPopup', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('gives every icon-only action a localized accessible name', () => {
const wrapper = mountPopup({ overlappingAnnotationId: 7, isTtsAvailable: true })
for (const name of ['Copy', 'Highlight', 'Search', 'Translate', 'Define', 'Add note', 'Delete annotation', 'Read from here']) {
getButtonByName(wrapper, name)
}
for (const button of wrapper.findAll('button')) {
expect(accessibleName(button)).not.toBe('')
}
})
it('names the highlight style buttons and exposes the selected style', async () => {
const wrapper = mountPopup()
await getButtonByName(wrapper, 'Highlight').trigger('click')
const highlight = getButtonByName(wrapper, 'Highlight style: Highlight')
const underline = getButtonByName(wrapper, 'Highlight style: Underline')
const strikethrough = getButtonByName(wrapper, 'Highlight style: Strikethrough')
const squiggly = getButtonByName(wrapper, 'Highlight style: Squiggly underline')
expect([highlight, underline, strikethrough, squiggly].map((button) => button.text())).toEqual(['H', 'U', 'S', '~'])
expect(highlight.attributes('aria-pressed')).toBe('true')
expect(underline.attributes('aria-pressed')).toBe('false')
await underline.trigger('click')
expect(getButtonByName(wrapper, 'Highlight style: Underline').attributes('aria-pressed')).toBe('true')
expect(getButtonByName(wrapper, 'Highlight style: Highlight').attributes('aria-pressed')).toBe('false')
for (const button of wrapper.findAll('button')) {
expect(accessibleName(button)).not.toBe('')
}
})
it('copies selected text to clipboard and emits copy after feedback delay', async () => {
vi.useFakeTimers()
const writeText = vi.fn<(value: string) => Promise<void>>().mockResolvedValue(undefined)
@@ -66,6 +66,20 @@ describe('useAnnotations', () => {
expect(store.annotations.value).toEqual([])
})
it('loads every page of annotations projected for the selected reader file', async () => {
apiMock.mockResolvedValueOnce(response(true, { items: [makeAnnotation(1)], total: 2 }))
apiMock.mockResolvedValueOnce(response(true, { items: [makeAnnotation(2)], total: 2 }))
const store = useAnnotations()
await store.load(9, 33)
expect(apiMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/books/9/annotations?page=1&pageSize=100&readerFileId=33',
'/api/v1/books/9/annotations?page=2&pageSize=100&readerFileId=33',
])
expect(store.annotations.value.map((annotation) => annotation.id)).toEqual([1, 2])
})
it('creates and appends annotation on success', async () => {
const created = makeAnnotation(3)
const createdCfi = created.cfi ?? 'epubcfi(/6/3)'
@@ -159,6 +173,19 @@ describe('useAnnotations', () => {
expect(store.annotations.value).toEqual(existing)
})
it('keeps the reader projection when a metadata update returns the canonical source position', async () => {
const projected = { ...makeAnnotation(1, 'epubcfi(/6/2!/4/2/2,/1:0,/1:5)'), jumpFileId: 44, positionStatus: 'repaired' as const }
const canonical = { ...makeAnnotation(1), note: 'Edited note', color: '#38BDF8' }
apiMock.mockResolvedValueOnce(response(true, canonical))
const store = useAnnotations()
store.annotations.value = [projected]
const result = await store.update(9, 1, { note: canonical.note, color: canonical.color })
expect(result).toEqual({ ...canonical, cfi: projected.cfi, jumpFileId: 44, positionStatus: 'repaired', chapterIndex: projected.chapterIndex })
expect(store.annotations.value).toEqual([result])
})
it('removes annotation when delete succeeds', async () => {
apiMock.mockResolvedValueOnce(response(true))
@@ -170,4 +197,24 @@ describe('useAnnotations', () => {
expect(apiMock).toHaveBeenCalledWith('/api/v1/books/9/annotations/1', { method: 'DELETE' })
expect(store.annotations.value.map((a) => a.id)).toEqual([2])
})
it('reports a failed or unreachable delete and keeps the annotation', async () => {
apiMock.mockResolvedValueOnce(response(false))
apiMock.mockRejectedValueOnce(new Error('offline'))
const store = useAnnotations()
store.annotations.value = [makeAnnotation(1)]
expect(await store.remove(9, 1)).toBe(false)
expect(await store.remove(9, 1)).toBe(false)
expect(store.annotations.value.map((a) => a.id)).toEqual([1])
})
it('returns null instead of throwing when a save cannot reach the server', async () => {
apiMock.mockRejectedValueOnce(new Error('offline'))
const store = useAnnotations()
expect(await store.update(9, 1, { note: 'x' })).toBeNull()
})
})
@@ -59,18 +59,30 @@ describe('useBookmarks', () => {
expect(store.bookmarks.value).toEqual([])
})
it('requests bookmark positions for the selected reader and records the source on creation', async () => {
apiMock.mockResolvedValueOnce(response(true, []))
apiMock.mockResolvedValueOnce(response(true, makeBookmark(1, 'epubcfi(/6/2)')))
const store = useBookmarks()
await store.load(7, 42)
await store.toggle(7, 'epubcfi(/6/2)', 'Chapter', 42)
expect(apiMock.mock.calls[0]![0]).toBe('/api/v1/books/7/bookmarks?readerFileId=42')
expect(JSON.parse(String(apiMock.mock.calls[1]![1]!.body))).toEqual({ cfi: 'epubcfi(/6/2)', title: 'Chapter', bookFileId: 42 })
})
it('creates bookmark on toggle when CFI does not exist', async () => {
const created = makeBookmark(21, 'epubcfi(/6/10)', 'Chapter 2')
apiMock.mockResolvedValueOnce(response(true, created))
const store = useBookmarks()
await store.toggle(7, created.cfi, created.title)
await store.toggle(7, created.cfi!, created.title)
const [url, req] = apiMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/v1/books/7/bookmarks')
expect(req.method).toBe('POST')
expect(JSON.parse(String(req.body))).toEqual({ cfi: created.cfi, title: created.title })
expect(JSON.parse(String(req.body))).toEqual({ cfi: created.cfi!, title: created.title })
expect(store.bookmarks.value).toEqual([created])
})
@@ -81,7 +93,7 @@ describe('useBookmarks', () => {
const store = useBookmarks()
store.bookmarks.value = [existing, makeBookmark(11, 'epubcfi(/6/12)')]
await store.toggle(7, existing.cfi, existing.title)
await store.toggle(7, existing.cfi!, existing.title)
expect(apiMock).toHaveBeenCalledWith('/api/v1/books/7/bookmarks/10', { method: 'DELETE' })
expect(store.bookmarks.value.map((b) => b.id)).toEqual([11])
@@ -93,11 +105,49 @@ describe('useBookmarks', () => {
const store = useBookmarks()
store.bookmarks.value = [makeBookmark(1, 'epubcfi(/6/2)')]
await store.remove(7, 1)
expect(await store.remove(7, 1)).toBe(false)
expect(store.bookmarks.value).toHaveLength(1)
})
it('reports a bookmark that could not be added', async () => {
apiMock.mockRejectedValueOnce(new Error('offline'))
const store = useBookmarks()
expect(await store.toggle(7, 'epubcfi(/6/2)', 'Chapter')).toBe(false)
expect(store.bookmarks.value).toHaveLength(0)
})
it('renames a bookmark and sets its note in one request', async () => {
apiMock.mockResolvedValueOnce(response(true, { ...makeBookmark(1, 'epubcfi(/6/2)', 'Renamed'), note: 'Why it matters' }))
const store = useBookmarks()
store.bookmarks.value = [makeBookmark(1, 'epubcfi(/6/2)'), makeBookmark(2, 'epubcfi(/6/4)')]
expect(await store.update(7, 1, { title: 'Renamed', note: 'Why it matters' })).toBe(true)
expect(apiMock).toHaveBeenCalledWith('/api/v1/books/7/bookmarks/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Renamed', note: 'Why it matters' }),
})
expect(store.bookmarks.value.map((b) => [b.title, b.note ?? null])).toEqual([
['Renamed', 'Why it matters'],
['Bookmark 2', null],
])
})
it('leaves the bookmark as it was when a rename fails', async () => {
apiMock.mockResolvedValueOnce(response(false))
const store = useBookmarks()
store.bookmarks.value = [makeBookmark(1, 'epubcfi(/6/2)')]
expect(await store.update(7, 1, { title: 'Renamed', note: null })).toBe(false)
expect(store.bookmarks.value[0]!.title).toBe('Bookmark 1')
})
it('tracks whether current CFI is bookmarked', () => {
const store = useBookmarks()
store.bookmarks.value = [makeBookmark(1, 'epubcfi(/6/2)')]
@@ -1,8 +1,11 @@
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Annotation } from '../useAnnotations'
import { useReaderAnnotationActions } from '../useReaderAnnotationActions'
const toastError = vi.hoisted(() => vi.fn<(message: string) => void>())
vi.mock('vue-sonner', () => ({ toast: { error: toastError } }))
function makeAnnotation(overrides: Partial<Annotation> = {}): Annotation {
return {
id: 42,
@@ -28,6 +31,8 @@ function makeHarness(initialAnnotations: Annotation[] = []) {
const annotationItems = ref<Annotation[]>(initialAnnotations)
const create = vi.fn<() => Promise<Annotation | null>>()
const update = vi.fn<() => Promise<Annotation | null>>()
const remove = vi.fn<() => Promise<boolean>>()
const deleteAnnotation = vi.fn<(cfi: string) => void>()
const addAnnotation = vi.fn<(cfi: string, color: string, style: string) => void>()
const redrawAnnotation = vi.fn<(cfi: string, color: string, style: string) => void>()
@@ -53,16 +58,22 @@ function makeHarness(initialAnnotations: Annotation[] = []) {
annotations: annotationItems,
create,
update,
remove,
},
selection,
addAnnotation,
redrawAnnotation,
deleteAnnotation,
})
return { actions, annotationItems, create, update, addAnnotation, redrawAnnotation, selection }
return { actions, annotationItems, create, update, remove, addAnnotation, redrawAnnotation, deleteAnnotation, selection }
}
describe('useReaderAnnotationActions', () => {
beforeEach(() => {
toastError.mockClear()
})
it('creates and draws a new highlight when the selection does not overlap an existing annotation', async () => {
const created = makeAnnotation({ id: 99, color: '#38BDF8', style: 'underline' })
const { actions, create, update, addAnnotation, redrawAnnotation, selection } = makeHarness()
@@ -146,4 +157,68 @@ describe('useReaderAnnotationActions', () => {
expect(selection.noteText.value).toBe('old note')
expect(selection.showNoteDialog.value).toBe(true)
})
it('sends one request when a note is saved twice before the first answer', async () => {
const existing = makeAnnotation({ id: 42 })
const { actions, update, selection } = makeHarness([existing])
selection.overlappingAnnotationId.value = 42
let answer: (value: Annotation | null) => void = () => {}
update.mockReturnValueOnce(new Promise((resolve) => (answer = resolve)))
const first = actions.handleSaveNote('note')
expect(actions.isSaving.value).toBe(true)
await actions.handleSaveNote('note')
answer(makeAnnotation({ id: 42, note: 'note' }))
await first
expect(update).toHaveBeenCalledTimes(1)
expect(actions.isSaving.value).toBe(false)
})
it('keeps the note dialog and its text open when the save fails', async () => {
const { actions, create, selection } = makeHarness()
selection.showNoteDialog.value = true
selection.noteText.value = 'unsaved words'
create.mockResolvedValueOnce(null)
await actions.handleSaveNote('unsaved words')
expect(toastError).toHaveBeenCalledWith('Could not save the note')
expect(selection.showNoteDialog.value).toBe(true)
expect(selection.noteText.value).toBe('unsaved words')
})
it('tells the reader when a highlight could not be saved', async () => {
const { actions, create, addAnnotation, selection } = makeHarness()
create.mockResolvedValueOnce(null)
await actions.handleHighlight('#38BDF8', 'underline')
expect(toastError).toHaveBeenCalledWith('Could not save the highlight')
expect(addAnnotation).not.toHaveBeenCalled()
expect(selection.dismiss).toHaveBeenCalledTimes(1)
})
it('erases a highlight from the page only after the server deleted it', async () => {
const existing = makeAnnotation({ id: 42 })
const { actions, remove, deleteAnnotation } = makeHarness([existing])
remove.mockResolvedValueOnce(true)
await actions.handleDeleteAnnotation(42)
expect(remove).toHaveBeenCalledWith(9, 42)
expect(deleteAnnotation).toHaveBeenCalledWith(existing.cfi)
expect(toastError).not.toHaveBeenCalled()
})
it('keeps a highlight on the page when its delete fails', async () => {
const existing = makeAnnotation({ id: 42 })
const { actions, remove, deleteAnnotation } = makeHarness([existing])
remove.mockResolvedValueOnce(false)
await actions.handleDeleteAnnotation(42)
expect(deleteAnnotation).not.toHaveBeenCalled()
expect(toastError).toHaveBeenCalledWith('Could not delete the highlight')
})
})
@@ -0,0 +1,83 @@
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { Annotation } from '../useAnnotations'
import { useReaderMarksRefresh } from '../useReaderMarksRefresh'
function mark(id: number, cfi: string, color = '#FACC15', style = 'highlight'): Annotation {
return {
id,
bookId: 9,
cfi,
jumpFileId: 33,
pageno: null,
text: 'text',
color,
style,
note: null,
chapterTitle: null,
origin: 'web',
positionStatus: 'exact',
chapterIndex: null,
highlightedAt: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
}
}
function makeHarness(current: Annotation[], next: Annotation[] | 'fail', canRefresh = true) {
const annotationItems = ref<Annotation[]>(current)
const loadError = ref<string | null>(null)
const annotations = {
annotations: annotationItems,
loadError,
load: vi.fn<() => Promise<void>>(async () => {
if (next === 'fail') loadError.value = 'Failed to load'
else annotationItems.value = next
}),
}
const bookmarks = { load: vi.fn<() => Promise<void>>(async () => {}) }
const draw = {
addAnnotation: vi.fn<(cfi: string, color: string, style: string) => void>(),
redrawAnnotation: vi.fn<(cfi: string, color: string, style: string) => void>(),
deleteAnnotation: vi.fn<(cfi: string) => void>(),
}
const { refreshMarks } = useReaderMarksRefresh({ bookId: 9, fileId: 33, annotations, bookmarks, canRefresh: () => canRefresh, ...draw })
return { refreshMarks, annotations, bookmarks, ...draw }
}
describe('useReaderMarksRefresh', () => {
it('draws marks made elsewhere, redraws recolored ones and erases deleted ones', async () => {
const { refreshMarks, annotations, bookmarks, addAnnotation, redrawAnnotation, deleteAnnotation } = makeHarness(
[mark(1, 'cfi-kept'), mark(2, 'cfi-recolored'), mark(3, 'cfi-deleted')],
[mark(1, 'cfi-kept'), mark(2, 'cfi-recolored', '#F472B6', 'underline'), mark(4, 'cfi-new', '#4ADE80')],
)
await refreshMarks(100_000)
expect(annotations.load).toHaveBeenCalledWith(9, 33)
expect(bookmarks.load).toHaveBeenCalledWith(9, 33)
expect(deleteAnnotation.mock.calls).toEqual([['cfi-deleted']])
expect(addAnnotation.mock.calls).toEqual([['cfi-new', '#4ADE80', 'highlight']])
expect(redrawAnnotation.mock.calls).toEqual([['cfi-recolored', '#F472B6', 'underline']])
})
it('leaves the page as drawn when the list cannot be loaded', async () => {
const { refreshMarks, addAnnotation, deleteAnnotation } = makeHarness([mark(1, 'cfi-kept')], 'fail')
await refreshMarks(100_000)
expect(deleteAnnotation).not.toHaveBeenCalled()
expect(addAnnotation).not.toHaveBeenCalled()
})
it('waits for the first draw and asks at most once per interval', async () => {
const blocked = makeHarness([], [], false)
await blocked.refreshMarks(100_000)
expect(blocked.annotations.load).not.toHaveBeenCalled()
const { refreshMarks, annotations } = makeHarness([], [])
await refreshMarks(100_000)
await refreshMarks(105_000)
await refreshMarks(116_000)
expect(annotations.load).toHaveBeenCalledTimes(2)
})
})
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { AnnotationItem } from '@bookorbit/types'
import type { AnnotationItem, AnnotationListResponse } from '@bookorbit/types'
import { api } from '@/lib/api'
export type Annotation = AnnotationItem
@@ -10,14 +10,37 @@ export interface AnnotationPatch {
style?: string
}
async function send(path: string, init?: RequestInit): Promise<Response | null> {
try {
return await (init ? api(path, init) : api(path))
} catch {
return null
}
}
export function useAnnotations() {
const annotations = ref<Annotation[]>([])
const loadError = ref<string | null>(null)
async function load(bookId: number) {
async function load(bookId: number, readerFileId?: number) {
loadError.value = null
const res = await api(`/api/v1/books/${bookId}/annotations`)
if (!res.ok) {
if (readerFileId != null) {
const loaded: Annotation[] = []
for (let page = 1; ; page += 1) {
const res = await send(`/api/v1/books/${bookId}/annotations?page=${page}&pageSize=100&readerFileId=${readerFileId}`)
if (!res?.ok) {
loadError.value = 'Failed to load'
return
}
const result: AnnotationListResponse = await res.json()
loaded.push(...result.items)
if (loaded.length >= result.total || result.items.length === 0) break
}
annotations.value = loaded
return
}
const res = await send(`/api/v1/books/${bookId}/annotations`)
if (!res?.ok) {
loadError.value = 'Failed to load'
return
}
@@ -28,26 +51,36 @@ export function useAnnotations() {
bookId: number,
data: { cfi: string; bookFileId?: number; text: string; color: string; style: string; note?: string | null; chapterTitle?: string | null },
): Promise<Annotation | null> {
const res = await api(`/api/v1/books/${bookId}/annotations`, {
const res = await send(`/api/v1/books/${bookId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) return null
if (!res?.ok) return null
const created: Annotation = await res.json()
annotations.value = [...annotations.value, created]
return created
}
async function update(bookId: number, id: number, data: AnnotationPatch): Promise<Annotation | null> {
const res = await api(`/api/v1/books/${bookId}/annotations/${id}`, {
const res = await send(`/api/v1/books/${bookId}/annotations/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) return null
if (!res?.ok) return null
const updated: Annotation = await res.json()
const canonical: Annotation = await res.json()
const existing = annotations.value.find((annotation) => annotation.id === id)
const updated: Annotation = existing
? {
...canonical,
cfi: existing.cfi,
jumpFileId: existing.jumpFileId,
positionStatus: existing.positionStatus,
chapterIndex: existing.chapterIndex,
}
: canonical
annotations.value = annotations.value.map((a) => (a.id === id ? updated : a))
return updated
}
@@ -56,13 +89,13 @@ export function useAnnotations() {
return update(bookId, id, { note })
}
async function remove(bookId: number, id: number) {
const res = await api(`/api/v1/books/${bookId}/annotations/${id}`, {
async function remove(bookId: number, id: number): Promise<boolean> {
const res = await send(`/api/v1/books/${bookId}/annotations/${id}`, {
method: 'DELETE',
})
if (res.ok) {
annotations.value = annotations.value.filter((a) => a.id !== id)
}
if (!res?.ok) return false
annotations.value = annotations.value.filter((a) => a.id !== id)
return true
}
return { annotations, loadError, load, create, update, updateNote, remove }
@@ -1,14 +1,29 @@
import { computed, ref } from 'vue'
import { api } from '@/lib/api'
import { jsonBody } from '@/lib/api-json'
export interface Bookmark {
id: number
bookId: number
cfi: string
cfi: string | null
title: string
note?: string | null
createdAt: string
}
export interface BookmarkPatch {
title: string
note: string | null
}
async function send(path: string, init?: RequestInit): Promise<Response | null> {
try {
return await (init ? api(path, init) : api(path))
} catch {
return null
}
}
export function useBookmarks() {
const bookmarks = ref<Bookmark[]>([])
const currentCfi = ref<string | null>(null)
@@ -23,40 +38,47 @@ export function useBookmarks() {
currentCfi.value = cfi
}
async function load(bookId: number) {
async function load(bookId: number, readerFileId?: number) {
loadError.value = null
const res = await api(`/api/v1/books/${bookId}/bookmarks`)
if (!res.ok) {
const res = await send(`/api/v1/books/${bookId}/bookmarks${readerFileId != null ? `?readerFileId=${readerFileId}` : ''}`)
if (!res?.ok) {
loadError.value = 'Failed to load'
return
}
bookmarks.value = await res.json()
}
async function toggle(bookId: number, cfi: string, title: string) {
async function toggle(bookId: number, cfi: string, title: string, bookFileId?: number): Promise<boolean> {
const existing = bookmarks.value.find((b) => b.cfi === cfi)
if (existing) {
await remove(bookId, existing.id)
} else {
const res = await api(`/api/v1/books/${bookId}/bookmarks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cfi, title }),
})
if (res.ok) {
const created: Bookmark = await res.json()
bookmarks.value = [...bookmarks.value, created]
}
}
if (existing) return remove(bookId, existing.id)
const res = await send(`/api/v1/books/${bookId}/bookmarks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cfi, title, ...(bookFileId != null ? { bookFileId } : {}) }),
})
if (!res?.ok) return false
const created: Bookmark = await res.json()
bookmarks.value = [...bookmarks.value, created]
return true
}
async function remove(bookId: number, bookmarkId: number) {
const res = await api(`/api/v1/books/${bookId}/bookmarks/${bookmarkId}`, {
// Both fields are always sent: an omitted note would leave the old one in place.
async function update(bookId: number, bookmarkId: number, patch: BookmarkPatch): Promise<boolean> {
const res = await send(`/api/v1/books/${bookId}/bookmarks/${bookmarkId}`, jsonBody('PATCH', patch))
if (!res?.ok) return false
const updated: Bookmark = await res.json()
bookmarks.value = bookmarks.value.map((b) => (b.id === bookmarkId ? { ...b, title: updated.title, note: updated.note ?? null } : b))
return true
}
async function remove(bookId: number, bookmarkId: number): Promise<boolean> {
const res = await send(`/api/v1/books/${bookId}/bookmarks/${bookmarkId}`, {
method: 'DELETE',
})
if (res.ok) {
bookmarks.value = bookmarks.value.filter((b) => b.id !== bookmarkId)
}
if (!res?.ok) return false
bookmarks.value = bookmarks.value.filter((b) => b.id !== bookmarkId)
return true
}
return {
@@ -67,6 +89,7 @@ export function useBookmarks() {
setCfi,
load,
toggle,
update,
remove,
}
}
@@ -9,6 +9,10 @@ import { scalePublisherFontSizes } from '../lib/publisher-font-sizes'
import type { EpubBookInfo, EpubReaderSettings } from '@bookorbit/types'
export interface RelocateDetail {
/** Why the view moved: a user's `page`/`snap`/`scroll`, a programmatic `navigation`, or an `anchor` relayout. */
reason?: string
/** The view is still opening the book at its starting position. */
restoring?: boolean
cfi?: string | null
fraction?: number
index?: number
@@ -254,7 +258,7 @@ export function useFoliate(
view.addEventListener('relocate', (e: Event) => {
const detail = (e as CustomEvent).detail
fraction.value = detail?.fraction ?? 0
onRelocate?.(detail)
onRelocate?.(initialNavigationPending ? { ...detail, restoring: true } : detail)
})
view.addEventListener('error', (e: Event) => {
@@ -1,4 +1,6 @@
import type { Ref } from 'vue'
import { ref, type Ref } from 'vue'
import { toast } from 'vue-sonner'
import { i18n } from '@/i18n'
import type { Annotation, AnnotationPatch } from './useAnnotations'
interface ReaderAnnotations {
@@ -8,6 +10,7 @@ interface ReaderAnnotations {
data: { cfi: string; bookFileId?: number; text: string; color: string; style: string; note?: string | null; chapterTitle?: string | null },
) => Promise<Annotation | null>
update: (bookId: number, id: number, data: AnnotationPatch) => Promise<Annotation | null>
remove: (bookId: number, id: number) => Promise<boolean>
}
interface ReaderSelection {
@@ -28,8 +31,11 @@ interface ReaderAnnotationActionsOptions {
selection: ReaderSelection
addAnnotation: (cfi: string, color: string, style: string) => void
redrawAnnotation: (cfi: string, color: string, style: string) => void
deleteAnnotation: (cfi: string) => void
}
type SaveOutcome = 'saved' | 'failed' | 'nothing'
const DEFAULT_NOTE_HIGHLIGHT_COLOR = '#FACC15'
const DEFAULT_NOTE_HIGHLIGHT_STYLE = 'highlight'
@@ -41,35 +47,37 @@ export function useReaderAnnotationActions({
selection,
addAnnotation,
redrawAnnotation,
deleteAnnotation,
}: ReaderAnnotationActionsOptions) {
const isSaving = ref(false)
function selectedAnnotation(): Annotation | null {
const id = selection.overlappingAnnotationId.value
if (id === null) return null
return annotations.annotations.value.find((annotation) => annotation.id === id) ?? null
}
async function updateSelectedAnnotation(data: AnnotationPatch, redraw: boolean) {
async function updateSelectedAnnotation(data: AnnotationPatch, redraw: boolean): Promise<SaveOutcome> {
const id = selection.overlappingAnnotationId.value
if (id === null) return null
if (id === null) return 'nothing'
const updated = await annotations.update(bookId, id, data)
if (redraw && updated?.cfi) {
if (!updated) return 'failed'
if (redraw && updated.cfi) {
redrawAnnotation(updated.cfi, updated.color, updated.style)
}
return updated
return 'saved'
}
async function handleHighlight(color: string, style: string, note?: string) {
async function saveHighlight(color: string, style: string, note?: string): Promise<SaveOutcome> {
if (selection.overlappingAnnotationId.value !== null) {
const patch: AnnotationPatch = { color, style }
if (note !== undefined) patch.note = note
await updateSelectedAnnotation(patch, true)
selection.dismiss()
return
return updateSelectedAnnotation(patch, true)
}
const annotationCfi = selection.cfi.value
if (!selection.text.value || !annotationCfi) return
if (!selection.text.value || !annotationCfi) return 'nothing'
const created = await annotations.create(bookId, {
cfi: annotationCfi,
@@ -80,25 +88,64 @@ export function useReaderAnnotationActions({
note: note ?? null,
chapterTitle: chapterTitle.value || null,
})
if (created?.cfi) {
if (!created) return 'failed'
if (created.cfi) {
addAnnotation(created.cfi, created.color, created.style)
}
selection.dismiss()
return 'saved'
}
// One request per save: the dialog stays open while it runs, so a second tap must not send another.
async function whileSaving(work: () => Promise<void>) {
if (isSaving.value) return
isSaving.value = true
try {
await work()
} finally {
isSaving.value = false
}
}
function handleHighlight(color: string, style: string) {
return whileSaving(async () => {
const outcome = await saveHighlight(color, style)
if (outcome === 'nothing') return
if (outcome === 'failed') toast.error(i18n.global.t('reader.toast.highlightSaveFailed'))
selection.dismiss()
})
}
function handleOpenNoteDialog() {
selection.openNoteDialog(selectedAnnotation()?.note ?? '')
}
async function handleSaveNote(note: string) {
if (selection.overlappingAnnotationId.value !== null) {
await updateSelectedAnnotation({ note }, false)
} else {
await handleHighlight(DEFAULT_NOTE_HIGHLIGHT_COLOR, DEFAULT_NOTE_HIGHLIGHT_STYLE, note)
}
selection.showNoteDialog.value = false
selection.noteText.value = ''
function handleSaveNote(note: string) {
return whileSaving(async () => {
const outcome =
selection.overlappingAnnotationId.value !== null
? await updateSelectedAnnotation({ note }, false)
: await saveHighlight(DEFAULT_NOTE_HIGHLIGHT_COLOR, DEFAULT_NOTE_HIGHLIGHT_STYLE, note)
if (outcome === 'failed') {
toast.error(i18n.global.t('reader.toast.noteSaveFailed'))
return
}
if (selection.overlappingAnnotationId.value === null) selection.dismiss()
selection.showNoteDialog.value = false
selection.noteText.value = ''
})
}
return { handleHighlight, handleOpenNoteDialog, handleSaveNote }
// The drawing goes only once the server has the delete, so a failed one leaves the page as it was.
async function handleDeleteAnnotation(id: number) {
const annotation = annotations.annotations.value.find((a) => a.id === id)
if (!annotation) return
const removed = await annotations.remove(bookId, id)
if (!removed) {
toast.error(i18n.global.t('reader.toast.highlightDeleteFailed'))
return
}
if (annotation.cfi) deleteAnnotation(annotation.cfi)
}
return { isSaving, handleHighlight, handleOpenNoteDialog, handleSaveNote, handleDeleteAnnotation }
}
@@ -0,0 +1,69 @@
import type { Ref } from 'vue'
import type { Annotation } from './useAnnotations'
interface RefreshableAnnotations {
annotations: Ref<Annotation[]>
loadError: Ref<string | null>
load: (bookId: number, readerFileId?: number) => Promise<void>
}
interface RefreshableBookmarks {
load: (bookId: number, readerFileId?: number) => Promise<void>
}
interface ReaderMarksRefreshOptions {
bookId: number
fileId: number
annotations: RefreshableAnnotations
bookmarks: RefreshableBookmarks
/** False until the first load has drawn the marks, and while a save is on its way. */
canRefresh: () => boolean
addAnnotation: (cfi: string, color: string, style: string) => void
redrawAnnotation: (cfi: string, color: string, style: string) => void
deleteAnnotation: (cfi: string) => void
}
const REFRESH_INTERVAL_MS = 15_000
type Drawn = Map<string, { color: string; style: string }>
function drawn(annotations: Annotation[]): Drawn {
const map: Drawn = new Map()
for (const annotation of annotations) {
if (annotation.cfi) map.set(annotation.cfi, { color: annotation.color, style: annotation.style })
}
return map
}
/**
* Brings in highlights and bookmarks changed on other devices while the book stayed open, when the
* reader comes back to it, and redraws only what changed.
*/
export function useReaderMarksRefresh(options: ReaderMarksRefreshOptions) {
let lastRefreshAt = 0
let running = false
async function refreshMarks(now = Date.now()) {
if (running || !options.canRefresh() || now - lastRefreshAt < REFRESH_INTERVAL_MS) return
running = true
lastRefreshAt = now
try {
const before = drawn(options.annotations.annotations.value)
await Promise.all([options.annotations.load(options.bookId, options.fileId), options.bookmarks.load(options.bookId, options.fileId)])
if (options.annotations.loadError.value) return
const after = drawn(options.annotations.annotations.value)
for (const cfi of before.keys()) {
if (!after.has(cfi)) options.deleteAnnotation(cfi)
}
for (const [cfi, mark] of after) {
const previous = before.get(cfi)
if (!previous) options.addAnnotation(cfi, mark.color, mark.style)
else if (previous.color !== mark.color || previous.style !== mark.style) options.redrawAnnotation(cfi, mark.color, mark.style)
}
} finally {
running = false
}
}
return { refreshMarks }
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { EpubMediaOverlayPlaylist } from '@bookorbit/types'
import { mediaOverlayItemFragment, resolveMediaOverlayResume } from './media-overlay-resume'
import { mediaOverlayFragmentSeconds, mediaOverlayItemFragment, resolveMediaOverlayResume } from './media-overlay-resume'
function makePlaylist(): EpubMediaOverlayPlaylist {
return {
@@ -99,3 +99,20 @@ describe('media-overlay resume', () => {
).toEqual({ section: 1, fragment: 'OPS/ch2.xhtml#s1' })
})
})
describe('mediaOverlayFragmentSeconds', () => {
it('places a sentence where the narration reaches it', () => {
expect(mediaOverlayFragmentSeconds(makePlaylist(), 'OPS/ch1.xhtml#s1', 0)).toBe(0)
expect(mediaOverlayFragmentSeconds(makePlaylist(), 'OPS/ch1.xhtml#s2', 0)).toBe(4)
expect(mediaOverlayFragmentSeconds(makePlaylist(), 'OPS/ch2.xhtml#s1', 1)).toBe(12)
})
it('tells same-named sentences apart by their section', () => {
expect(mediaOverlayFragmentSeconds(makePlaylist(), 'OPS/ch2.xhtml#s1', null)).toBe(12)
expect(mediaOverlayFragmentSeconds(makePlaylist(), 'OPS/ch1.xhtml#s1', 1)).toBeNull()
})
it('places nothing it cannot find', () => {
expect(mediaOverlayFragmentSeconds(makePlaylist(), 'OPS/ch9.xhtml#s1', null)).toBeNull()
})
})
@@ -74,3 +74,16 @@ export function resolveMediaOverlayResume(playlist: EpubMediaOverlayPlaylist, st
return null
}
/** Where the sentence `fragment` names starts in the narration, in seconds, or null when it is not in it. */
export function mediaOverlayFragmentSeconds(playlist: EpubMediaOverlayPlaylist, fragment: string, sectionIndex: number | null): number | null {
let elapsed = 0
for (const item of playlist.items) {
const itemFragment = mediaOverlayItemFragment(item)
if (itemFragment && sameFragment(itemFragment, fragment) && (sectionIndex === null || item.sectionIndex === sectionIndex)) return elapsed
const duration = item.durationSeconds
if (duration == null || !Number.isFinite(duration) || duration < 0) return null
elapsed += duration
}
return null
}
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
const { t } = useI18n()
@@ -14,12 +17,16 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function handleBackdropClick() {
if (!props.saving) emit('cancel')
function handleOpenChange(open: boolean) {
if (!open && !props.saving) emit('cancel')
}
function handleInput(event: Event) {
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
function handleDismissWhileSaving(event: Event) {
if (props.saving) event.preventDefault()
}
function handleInput(value: string) {
emit('update:modelValue', value)
}
function handleCancel() {
@@ -32,37 +39,36 @@ function handleSave() {
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center bg-scrim p-4" @click.self="handleBackdropClick">
<div class="bg-card text-card-foreground rounded-lg shadow-2xl p-4 w-full max-w-sm flex flex-col gap-3">
<p class="text-sm font-medium">{{ t('reader.note.title') }}</p>
<p class="text-xs text-muted-foreground line-clamp-2 italic">"{{ selectedText }}"</p>
<textarea
:value="modelValue"
<DialogRoot :open="true" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[70] bg-scrim" data-testid="note-dialog-overlay" />
<DialogContent
aria-modal="true"
class="fixed left-1/2 top-1/2 z-[70] flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-lg bg-card p-4 text-card-foreground shadow-2xl focus-visible:outline-none"
@escape-key-down="handleDismissWhileSaving"
@pointer-down-outside="handleDismissWhileSaving"
@interact-outside="handleDismissWhileSaving"
>
<DialogTitle class="text-sm font-medium">{{ t('reader.note.title') }}</DialogTitle>
<DialogDescription class="line-clamp-2 text-xs italic text-muted-foreground">"{{ selectedText }}"</DialogDescription>
<Textarea
:modelValue="modelValue"
:disabled="props.saving"
@input="handleInput"
class="w-full rounded-lg border border-border bg-background text-foreground text-sm p-2 resize-none focus:outline-none focus:ring-1 focus:ring-primary"
rows="4"
:aria-label="t('reader.note.title')"
:placeholder="t('reader.note.placeholder')"
autofocus
class="resize-none"
rows="4"
@update:modelValue="handleInput"
/>
<div class="flex gap-2 justify-end">
<button
class="px-3 py-1.5 rounded-lg text-sm text-muted-foreground hover:bg-muted transition-colors"
:disabled="props.saving"
@click="handleCancel"
>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="props.saving" @click="handleCancel">
{{ t('common.cancel') }}
</button>
<button
class="px-3 py-1.5 rounded-lg text-sm bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
:disabled="props.saving"
@click="handleSave"
>
</Button>
<Button size="sm" :disabled="props.saving" @click="handleSave">
{{ t('common.save') }}
</button>
</Button>
</div>
</div>
</div>
</Teleport>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -1,46 +1,79 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import NoteDialog from '../NoteDialog.vue'
describe('NoteDialog', () => {
it('emits note updates, save, and cancel actions', async () => {
const wrapper = mount(NoteDialog, {
props: {
selectedText: 'Selected quote',
modelValue: 'initial note',
},
global: {
stubs: {
teleport: true,
},
},
})
function mountDialog(props: Record<string, unknown> = {}) {
return mount(NoteDialog, {
props: {
selectedText: 'Selected quote',
modelValue: 'initial note',
...props,
},
attachTo: document.body,
})
}
await wrapper.get('textarea').setValue('updated note')
function buttons() {
return [...document.body.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')]
}
describe('NoteDialog', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('is a labelled modal dialog', async () => {
const wrapper = mountDialog()
await flushPromises()
const dialog = document.body.querySelector<HTMLElement>('[role="dialog"]')
expect(dialog).not.toBeNull()
expect(dialog!.getAttribute('aria-modal')).toBe('true')
const title = document.getElementById(dialog!.getAttribute('aria-labelledby') ?? '')
expect(title?.textContent).toBe('Add note')
expect(dialog!.querySelector('textarea')?.getAttribute('aria-label')).toBe('Add note')
wrapper.unmount()
})
it('emits note updates, save, and cancel actions', async () => {
const wrapper = mountDialog()
await flushPromises()
const textarea = document.body.querySelector('textarea')!
textarea.value = 'updated note'
textarea.dispatchEvent(new Event('input'))
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['updated note'])
const buttons = wrapper.findAll('button')
await buttons[0]!.trigger('click')
await buttons[1]!.trigger('click')
buttons()[0]!.click()
buttons()[1]!.click()
expect(wrapper.emitted('cancel')?.length).toBe(1)
expect(wrapper.emitted('save')?.[0]).toEqual(['initial note'])
wrapper.unmount()
})
it('emits cancel when backdrop is clicked', async () => {
const wrapper = mount(NoteDialog, {
props: {
selectedText: 'Selected quote',
modelValue: '',
},
global: {
stubs: {
teleport: true,
},
},
})
it('cancels on Escape', async () => {
const wrapper = mountDialog()
await flushPromises()
document.body.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
await wrapper.get('.fixed.inset-0').trigger('click')
expect(wrapper.emitted('cancel')?.length).toBe(1)
wrapper.unmount()
})
it('stays open and sends nothing while saving', async () => {
const wrapper = mountDialog({ saving: true })
await flushPromises()
document.body.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
buttons()[1]!.click()
await flushPromises()
expect(wrapper.emitted('cancel')).toBeUndefined()
expect(wrapper.emitted('save')).toBeUndefined()
wrapper.unmount()
})
})
@@ -4,7 +4,7 @@ import { ref } from 'vue'
vi.mock('@/lib/api', () => ({ api: vi.fn<(...args: unknown[]) => unknown>() }))
import { api } from '@/lib/api'
import { formatTimeRemaining, useReaderProgress } from './useReaderProgress'
import { formatTimeRemaining, useReaderProgress, type NewerPosition } from './useReaderProgress'
const mockApi = vi.mocked(api)
@@ -258,6 +258,296 @@ describe('useReaderProgress', () => {
expect(mockApi).not.toHaveBeenCalled()
})
describe('positions it did not move to (#1136)', () => {
function postCount() {
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST').length
}
async function openWith(saved: Record<string, unknown>) {
mockApi.mockResolvedValue(makeOkResponse(saved))
const progress = useReaderProgress(1, 42, ref(0))
await progress.load()
return progress
}
it('does not save the page it opens on over a position a device saved', async () => {
const { onRelocate } = await openWith({ cfi: 'epubcfi(/6/34!/4/4/58/3:150)', percentage: 23.93 })
onRelocate({ cfi: 'epubcfi(/6/34!/4/4/2/3:0)', fraction: 0.237, reason: 'navigation', restoring: true } as never)
vi.advanceTimersByTime(2500)
expect(postCount()).toBe(0)
})
it('does not save a reflow of the same page', async () => {
const { onRelocate } = await openWith({ cfi: 'epubcfi(/6/34!/4/4/58/3:150)', percentage: 23.93 })
onRelocate({ cfi: 'epubcfi(/6/34!/4/4/2/3:0)', fraction: 0.237, reason: 'anchor' } as never)
vi.advanceTimersByTime(2500)
expect(postCount()).toBe(0)
})
it('does not restamp a paused narration when its page reflows', async () => {
const { onRelocate } = await openWith({
cfi: 'epubcfi(/6/34!/4/4/58/3:150)',
percentage: 23.93,
mediaOverlayFragment: 'chapter.xhtml#s12',
mediaOverlaySectionIndex: 16,
})
onRelocate({ cfi: 'epubcfi(/6/34!/4/4/2/3:0)', fraction: 0.237, reason: 'anchor' } as never, 'narration')
vi.advanceTimersByTime(2500)
expect(postCount()).toBe(0)
})
it('saves a page the reader turned to', async () => {
const { onRelocate } = await openWith({ cfi: 'epubcfi(/6/34!/4/4/58/3:150)', percentage: 23.93 })
onRelocate({ cfi: 'epubcfi(/6/34!/4/4/84/1:405)', fraction: 0.249, reason: 'page' } as never)
vi.advanceTimersByTime(2500)
expect(postCount()).toBe(1)
expect(postedProgressBody(1).cfi).toBe('epubcfi(/6/34!/4/4/84/1:405)')
})
it('still saves where a book with no saved position first opens', async () => {
const { onRelocate } = await openWith({ cfi: null, percentage: 0 })
onRelocate({ cfi: 'epubcfi(/6/2!/4/2/1:0)', fraction: 0, reason: 'navigation', restoring: true } as never)
vi.advanceTimersByTime(2500)
expect(postCount()).toBe(1)
})
})
describe('a position another device moved on to', () => {
const OPENED = '2026-10-17T06:40:00.000Z'
const LATER = '2026-10-17T06:45:00.000Z'
function getCount() {
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === undefined).length
}
async function openAt(cfi: string) {
mockApi.mockResolvedValueOnce(
makeOkResponse({ cfi, percentage: 20, mediaOverlayFragment: 'c.xhtml#s1', mediaOverlaySectionIndex: 3, textUpdatedAt: OPENED }),
)
const progress = useReaderProgress(1, 42, ref(0))
await progress.load()
return progress
}
it('offers a position saved elsewhere since the reader opened', async () => {
const progress = await openAt('epubcfi(/6/4!/4/2/1:0)')
mockApi.mockResolvedValueOnce(
makeOkResponse({ cfi: 'epubcfi(/6/4!/4/90/1:0)', percentage: 31.6, mediaOverlayFragment: 'c.xhtml#s40', textUpdatedAt: LATER }),
)
const newer = await progress.checkForNewerPosition()
expect(newer?.percentage).toBe(31.6)
})
it('does not offer the position this reader saved itself', async () => {
const progress = await openAt('epubcfi(/6/4!/4/2/1:0)')
mockApi.mockResolvedValueOnce({
ok: true,
json: async () => ({}),
headers: new Headers({ Date: new Date(LATER).toUTCString() }),
} as Response)
progress.onRelocate({ cfi: 'epubcfi(/6/4!/4/8/1:0)', fraction: 0.21, reason: 'page' } as never)
await vi.advanceTimersByTimeAsync(2500)
// The server can move what this reader wrote, as a narration write moves the page.
mockApi.mockResolvedValueOnce(makeOkResponse({ cfi: 'epubcfi(/6/4!/4/10/1:0)', percentage: 21, textUpdatedAt: '2026-10-17T06:45:00.600Z' }))
expect(await progress.checkForNewerPosition()).toBeNull()
})
it('does not ask while a save is waiting', async () => {
const progress = await openAt('epubcfi(/6/4!/4/2/1:0)')
progress.onRelocate({ cfi: 'epubcfi(/6/4!/4/8/1:0)', fraction: 0.21, reason: 'page' } as never)
expect(await progress.checkForNewerPosition()).toBeNull()
expect(getCount()).toBe(1)
})
it('takes both positions and does not save the page it lands on', async () => {
const progress = await openAt('epubcfi(/6/4!/4/2/1:0)')
mockApi.mockResolvedValueOnce(
makeOkResponse({
cfi: 'epubcfi(/6/4!/4/90/1:0)',
percentage: 31.6,
mediaOverlayFragment: 'c.xhtml#s40',
mediaOverlaySectionIndex: 3,
textUpdatedAt: LATER,
}),
)
const newer = (await progress.checkForNewerPosition())!
await progress.adoptNewerPosition(newer, async () => {
progress.onRelocate({ cfi: 'epubcfi(/6/4!/4/88/1:0)', fraction: 0.31, reason: 'navigation' } as never)
})
await vi.advanceTimersByTimeAsync(2500)
expect(progress.mediaOverlayFragment.value).toBe('c.xhtml#s40')
expect(mockApi.mock.calls.some(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST')).toBe(false)
})
it('saves its own page as reading when it stays, and does not offer the other again', async () => {
const progress = await openAt('epubcfi(/6/4!/4/2/1:0)')
const elsewhere = makeOkResponse({ cfi: 'epubcfi(/6/4!/4/90/1:0)', percentage: 31.6, textUpdatedAt: LATER })
mockApi.mockResolvedValueOnce(elsewhere)
const newer = (await progress.checkForNewerPosition())!
mockApi.mockResolvedValueOnce(makeOkResponse({}))
await progress.keepCurrentPosition(newer)
const posts = mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST')
expect(posts).toHaveLength(1)
expect(JSON.parse((posts[0]![1] as RequestInit).body as string)).toMatchObject({ cfi: 'epubcfi(/6/4!/4/2/1:0)', source: 'text' })
mockApi.mockResolvedValueOnce(elsewhere)
expect(await progress.checkForNewerPosition()).toBeNull()
})
})
describe('narration saved while another device sent it back', () => {
const OPENED = '2026-10-17T06:40:00.000Z'
const RESET = '2026-10-17T06:45:00.000Z'
const SECONDS: Record<string, number> = { 'c.xhtml#s0': 0, 'c.xhtml#s5': 900, 'c.xhtml#s9': 930, 'c.xhtml#s40': 2000 }
const resetRow = {
percentage: 0,
mediaOverlayFragment: 'c.xhtml#s0',
mediaOverlaySectionIndex: 3,
narrationUpdatedAt: RESET,
textUpdatedAt: RESET,
}
function posts() {
return mockApi.mock.calls.filter(([, opts]) => (opts as RequestInit | undefined)?.method === 'POST')
}
async function narrating(movedBack = vi.fn<(position: NewerPosition) => void>()) {
mockApi.mockResolvedValueOnce(
makeOkResponse({
cfi: 'epubcfi(/6/4!/4/2/1:0)',
percentage: 40,
mediaOverlayFragment: 'c.xhtml#s5',
mediaOverlaySectionIndex: 3,
narrationUpdatedAt: OPENED,
}),
)
const progress = useReaderProgress(1, 42, ref(0), 0, {
narrationSeconds: async (fragment) => SECONDS[fragment] ?? null,
onNarrationMovedBack: movedBack,
})
await progress.load()
return { progress, movedBack }
}
// Read-aloud saves every few sentences, so a reset made on another device while it played was
// overwritten by the next save and nobody was asked.
it('holds the save and asks when another device sent narration well behind it', async () => {
const { progress, movedBack } = await narrating()
mockApi.mockResolvedValueOnce(makeOkResponse(resetRow))
progress.setMediaOverlayProgress('c.xhtml#s9', 3)
await vi.advanceTimersByTimeAsync(2500)
expect(posts()).toHaveLength(0)
expect(movedBack).toHaveBeenCalledWith(expect.objectContaining({ percentage: 0 }))
})
it('saves over another device that moved ahead', async () => {
const { progress, movedBack } = await narrating()
mockApi.mockResolvedValueOnce(
makeOkResponse({ percentage: 84, mediaOverlayFragment: 'c.xhtml#s40', mediaOverlaySectionIndex: 3, narrationUpdatedAt: RESET }),
)
mockApi.mockResolvedValueOnce(makeOkResponse({}))
progress.setMediaOverlayProgress('c.xhtml#s9', 3)
await vi.advanceTimersByTimeAsync(2500)
expect(posts()).toHaveLength(1)
expect(movedBack).not.toHaveBeenCalled()
})
it('saves over the narration it last read or wrote without asking', async () => {
const { progress, movedBack } = await narrating()
mockApi.mockResolvedValueOnce(
makeOkResponse({ percentage: 40, mediaOverlayFragment: 'c.xhtml#s5', mediaOverlaySectionIndex: 3, narrationUpdatedAt: OPENED }),
)
mockApi.mockResolvedValueOnce(makeOkResponse({}))
progress.setMediaOverlayProgress('c.xhtml#s9', 3)
await vi.advanceTimersByTimeAsync(2500)
expect(posts()).toHaveLength(1)
expect(movedBack).not.toHaveBeenCalled()
})
it('saves its own narration after staying', async () => {
const { progress, movedBack } = await narrating()
mockApi.mockResolvedValueOnce(makeOkResponse(resetRow))
progress.setMediaOverlayProgress('c.xhtml#s9', 3)
await vi.advanceTimersByTimeAsync(2500)
const newer = movedBack.mock.calls[0]![0]
mockApi.mockResolvedValueOnce(makeOkResponse(resetRow))
mockApi.mockResolvedValueOnce(makeOkResponse({}))
await progress.keepCurrentPosition(newer, 'narration')
expect(posts()).toHaveLength(1)
expect(JSON.parse((posts()[0]![1] as RequestInit).body as string)).toMatchObject({ mediaOverlayFragment: 'c.xhtml#s9', source: 'narration' })
})
async function savedAt(progress: ReturnType<typeof useReaderProgress>, fragment: string, date: string) {
mockApi.mockResolvedValueOnce(
makeOkResponse({ percentage: 40, mediaOverlayFragment: 'c.xhtml#s5', mediaOverlaySectionIndex: 3, narrationUpdatedAt: OPENED }),
)
mockApi.mockResolvedValueOnce({ ok: true, headers: new Headers({ Date: new Date(date).toUTCString() }), json: async () => ({}) } as Response)
progress.setMediaOverlayProgress(fragment, 3)
await vi.advanceTimersByTimeAsync(2500)
}
// The response `Date` counts whole seconds, so a reset landing in the same second as this
// reader's save looked like its own write and the next save went over it.
it('asks when another device sent narration back in the same second as its own save', async () => {
const { progress, movedBack } = await narrating()
await savedAt(progress, 'c.xhtml#s9', RESET)
mockApi.mockResolvedValueOnce(
makeOkResponse({ ...resetRow, narrationUpdatedAt: '2026-10-17T06:45:00.400Z', textUpdatedAt: '2026-10-17T06:45:00.400Z' }),
)
progress.setMediaOverlayProgress('c.xhtml#s40', 3)
await vi.advanceTimersByTimeAsync(2500)
expect(posts()).toHaveLength(1)
expect(movedBack).toHaveBeenCalledWith(expect.objectContaining({ percentage: 0 }))
})
it('saves over its own narration read back in the same second, even after skipping ahead', async () => {
const { progress, movedBack } = await narrating()
await savedAt(progress, 'c.xhtml#s9', RESET)
mockApi.mockResolvedValueOnce(
makeOkResponse({
percentage: 41,
mediaOverlayFragment: 'c.xhtml#s9',
mediaOverlaySectionIndex: 3,
narrationUpdatedAt: '2026-10-17T06:45:00.400Z',
}),
)
mockApi.mockResolvedValueOnce(makeOkResponse({}))
progress.setMediaOverlayProgress('c.xhtml#s40', 3)
await vi.advanceTimersByTimeAsync(2500)
expect(posts()).toHaveLength(2)
expect(movedBack).not.toHaveBeenCalled()
})
})
it('sets koboLocationSource and koboLocationValue from detail', () => {
const elapsedMinutes = ref(0)
const { onRelocate, koboLocationSource, koboLocationValue } = useReaderProgress(1, 42, elapsedMinutes)
@@ -616,7 +906,7 @@ describe('useReaderProgress', () => {
expect(postedProgressBody(1).source).toBe('text')
})
it('saves and reloads later reading progress without losing the paused narration position', async () => {
it('saves later reading without sending, so without overwriting, the paused narration position', async () => {
mockApi.mockResolvedValue(makeOkResponse({}))
const progress = useReaderProgress(1, 42, ref(0))
progress.onRelocate({ fraction: 0.1, cfi: 'epubcfi(/6/4)' } as never)
@@ -627,16 +917,15 @@ describe('useReaderProgress', () => {
progress.onRelocate({ fraction: 0.32, cfi: 'epubcfi(/6/40)' } as never)
await vi.advanceTimersByTimeAsync(2000)
const saved = postedProgressBody(1)
expect(saved).toMatchObject({
source: 'text',
percentage: 32,
cfi: 'epubcfi(/6/40)',
positionSeconds: 44.3,
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
mediaOverlaySectionIndex: 2,
})
expect(saved).toMatchObject({ source: 'text', percentage: 32, cfi: 'epubcfi(/6/40)' })
expect(saved).not.toHaveProperty('positionSeconds')
expect(saved).not.toHaveProperty('mediaOverlayFragment')
expect(saved).not.toHaveProperty('mediaOverlaySectionIndex')
mockApi.mockResolvedValueOnce(makeOkResponse(saved))
// The server keeps the stored marker for a write that leaves it out.
mockApi.mockResolvedValueOnce(
makeOkResponse({ ...saved, positionSeconds: 44.3, mediaOverlayFragment: 'OPS/ch1.xhtml#s12', mediaOverlaySectionIndex: 2 }),
)
const reopened = useReaderProgress(1, 42, ref(0))
await reopened.load()
expect(reopened.cfi.value).toBe('epubcfi(/6/40)')
@@ -662,11 +951,8 @@ describe('useReaderProgress', () => {
progress.onRelocate({ fraction: 0.33, cfi: 'epubcfi(/6/42)' })
await vi.advanceTimersByTimeAsync(2000)
expect(postedProgressBody(3)).toMatchObject({
source: 'text',
percentage: 33,
mediaOverlayFragment: 'OPS/ch1.xhtml#s12',
})
expect(postedProgressBody(3)).toMatchObject({ source: 'text', percentage: 33 })
expect(postedProgressBody(3)).not.toHaveProperty('mediaOverlayFragment')
})
})
@@ -6,8 +6,15 @@ export type FooterDisplayMode = 0 | 1 | 2
export interface ReaderProgressOptions {
trackingEnabled?: MaybeRef<boolean>
/** Where a narrated sentence starts in the narration, in seconds, or null when it cannot be placed. */
narrationSeconds?: (fragment: string, sectionIndex: number | null) => Promise<number | null>
/** Told when a narration save was held back because another device sent the narration well behind it. */
onNarrationMovedBack?: (position: NewerPosition) => void
}
/** How far behind the sentence being saved another device must send narration for the save to wait. */
const NARRATION_MOVED_BACK_SECONDS = 30
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
@@ -39,6 +46,27 @@ function normalizePageNumber(value: unknown): number | null {
return parsed === null || parsed < 0 ? null : parsed
}
/** A position another device saved while this reader was open: the server's row, and when it moved. */
export interface NewerPosition {
percentage: number
stamp: number
data: Record<string, unknown>
}
function serverTime(value: unknown): number | null {
if (typeof value !== 'string') return null
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? parsed : null
}
/** When the row's positions last moved, on the server's clock; the row's own stamp before the split. */
function positionStamp(data: Record<string, unknown>): number {
const text = serverTime(data.textUpdatedAt)
const narration = serverTime(data.narrationUpdatedAt)
if (text === null && narration === null) return serverTime(data.updatedAt) ?? 0
return Math.max(text ?? 0, narration ?? 0)
}
export function formatTimeRemaining(minutes: number): string {
if (!Number.isFinite(minutes) || minutes < 0) return ''
if (minutes < 1) return '< 1 min'
@@ -86,7 +114,17 @@ export function useReaderProgress(
const trackingEnabled = options.trackingEnabled ?? true
let saveTimer: ReturnType<typeof setTimeout> | null = null
let saving = false
let hadSavedPosition = false
let lastValidPercentage = 0
// The newest write to the row this reader has read or made, on the server's clock. Anything
// newer came from another device.
let knownServerStamp = 0
// The narration marker that write left on the row; null after a page turn, which the server may
// have carried narration along with. `Date` counts whole seconds, so a row inside that second
// holding another marker was written by another device just after this one.
let knownNarrationFragment: string | null = null
let adoptingNewerPosition = false
onUnmounted(() => {
if (saveTimer) clearTimeout(saveTimer)
@@ -108,6 +146,17 @@ export function useReaderProgress(
const res = await api(`/api/v1/books/files/${fileId}/progress`)
if (!res.ok) return
const data = await res.json()
hadSavedPosition =
normalizeString(data.cfi) !== null ||
normalizeString(data.koboLocationValue) !== null ||
normalizeString(data.koreaderProgress) !== null ||
(typeof data.percentage === 'number' && data.percentage > 0)
applyServerProgress(data)
knownServerStamp = positionStamp(data)
knownNarrationFragment = normalizeString(data.mediaOverlayFragment)
}
function applyServerProgress(data: Record<string, unknown>) {
cfi.value = normalizeString(data.cfi)
pageNumber.value = normalizePageNumber(data.pageNumber)
updatePercentage(data.percentage, 0)
@@ -124,7 +173,10 @@ export function useReaderProgress(
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
if (!unref(trackingEnabled)) return
saveTimer = setTimeout(() => save(), 2000)
saveTimer = setTimeout(() => {
saveTimer = null
void save()
}, 2000)
}
function onRelocate(detail: RelocateDetail, source: 'text' | 'narration' = 'text') {
@@ -156,6 +208,11 @@ export function useReaderProgress(
if (saveTimer) clearTimeout(saveTimer)
return
}
// Opening at a position a device saved, or reflowing the page, moves no reader. Saving the
// page start the view lands on would pull every other device back to it (#1136). A reflow
// under paused narration would also restamp its old sentence as just narrated.
const repositionOnly = (detail?.restoring === true && hadSavedPosition) || adoptingNewerPosition || detail?.reason === 'anchor'
if (repositionOnly) return
pendingSource.value = source
scheduleSave()
}
@@ -170,8 +227,59 @@ export function useReaderProgress(
async function save() {
if (!unref(trackingEnabled)) return
const safePercentage = updatePercentage(percentage.value)
await api(`/api/v1/books/files/${fileId}/progress`, {
saving = true
try {
if (pendingSource.value === 'narration') {
const movedBack = await narrationMovedBack()
if (movedBack) {
options.onNarrationMovedBack?.(movedBack)
return
}
}
const safePercentage = updatePercentage(percentage.value)
const savedFragment = pendingSource.value === 'narration' ? mediaOverlayFragment.value : null
noteOwnWrite(await postProgress(safePercentage), savedFragment)
} finally {
saving = false
}
}
/**
* The server's position when another device sent narration well behind the sentence about to be
* saved, a reset or a return to an earlier passage, since this reader last read or wrote it.
* Read-aloud saves every few sentences, so a reset made elsewhere while it played was overwritten
* by the next one and nobody was asked. A move ahead is not held: this narration is the newer place.
*/
async function narrationMovedBack(): Promise<NewerPosition | null> {
const secondsOf = options.narrationSeconds
const fragment = mediaOverlayFragment.value
if (!secondsOf || !fragment) return null
const res = await api(`/api/v1/books/files/${fileId}/progress`).catch(() => null)
if (!res?.ok) return null
const data = (await res.json()) as Record<string, unknown>
const stamp = positionStamp(data)
const serverFragment = normalizeString(data.mediaOverlayFragment)
if (!serverFragment || serverFragment === fragment) return null
if (stamp <= knownServerStamp && (knownNarrationFragment === null || serverFragment === knownNarrationFragment)) return null
const serverSection = typeof data.mediaOverlaySectionIndex === 'number' ? data.mediaOverlaySectionIndex : null
const [serverAt, savingAt] = await Promise.all([secondsOf(serverFragment, serverSection), secondsOf(fragment, mediaOverlaySectionIndex.value)])
if (serverAt === null || savingAt === null || serverAt >= savingAt - NARRATION_MOVED_BACK_SECONDS) return null
return { percentage: normalizePercentage(data.percentage) ?? 0, stamp, data }
}
/**
* The write lands with a server stamp no later than the response's `Date`, which counts whole
* seconds, so the next second covers it.
*/
function noteOwnWrite(res: Response | undefined, narrationFragment: string | null) {
const date = serverTime(res?.headers?.get?.('Date') ?? null)
if (date === null) return
knownServerStamp = Math.max(knownServerStamp, date + 1000)
knownNarrationFragment = narrationFragment
}
function postProgress(safePercentage: number) {
return api(`/api/v1/books/files/${fileId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -183,14 +291,65 @@ export function useReaderProgress(
koboLocationValue: koboLocationValue.value,
koboContentSourceProgressPercent: normalizeNullablePercentage(koboContentSourceProgressPercent.value),
koreaderProgress: koreaderProgress.value,
positionSeconds: positionSeconds.value,
mediaOverlayFragment: mediaOverlayFragment.value,
mediaOverlaySectionIndex: mediaOverlaySectionIndex.value,
// Left out of a page turn, which says nothing about narration: sent along, the marker this
// reader read when it opened went back over a newer one another device had saved since.
...(pendingSource.value === 'narration'
? {
positionSeconds: positionSeconds.value,
mediaOverlayFragment: mediaOverlayFragment.value,
mediaOverlaySectionIndex: mediaOverlaySectionIndex.value,
}
: {}),
source: pendingSource.value,
}),
})
}
/**
* The server's position when another device moved it since this reader last read or wrote it,
* so a reader left open can offer it instead of saving its old page over it. Nothing is asked
* while a save is waiting: this reader is about to speak for the position itself.
*/
async function checkForNewerPosition(): Promise<NewerPosition | null> {
if (!unref(trackingEnabled) || saveTimer || saving) return null
const res = await api(`/api/v1/books/files/${fileId}/progress`)
if (!res.ok || saveTimer || saving) return null
const data = (await res.json()) as Record<string, unknown>
const stamp = positionStamp(data)
if (stamp <= knownServerStamp) return null
if (normalizeString(data.cfi) === cfi.value && normalizeString(data.mediaOverlayFragment) === mediaOverlayFragment.value) {
knownServerStamp = stamp
knownNarrationFragment = mediaOverlayFragment.value
return null
}
return { percentage: normalizePercentage(data.percentage) ?? 0, stamp, data }
}
/** Takes the newer position, both text and narration, and lets `navigate` go there without saving it back. */
async function adoptNewerPosition(position: NewerPosition, navigate: () => Promise<void>) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
applyServerProgress(position.data)
knownServerStamp = Math.max(knownServerStamp, position.stamp)
knownNarrationFragment = normalizeString(position.data.mediaOverlayFragment)
adoptingNewerPosition = true
try {
await navigate()
} finally {
adoptingNewerPosition = false
}
}
/** Keeps this reader's page over the one it turned down, and saves it so other devices follow. */
async function keepCurrentPosition(position: NewerPosition, source: 'text' | 'narration' = 'text') {
knownServerStamp = Math.max(knownServerStamp, position.stamp)
knownNarrationFragment = normalizeString(position.data.mediaOverlayFragment)
if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
pendingSource.value = source
await save()
}
function cycleFooterMode() {
footerMode.value = ((footerMode.value + 1) % 3) as FooterDisplayMode
}
@@ -331,6 +490,9 @@ export function useReaderProgress(
timeTotal,
footerMode,
load,
checkForNewerPosition,
adoptNewerPosition,
keepCurrentPosition,
onRelocate,
setMediaOverlayProgress,
save,
+32 -3
View File
@@ -5071,7 +5071,8 @@
"bulkRestored": "{count, plural, =0 {Restored no annotations} one {Restored # annotation} other {Restored # annotations}}",
"bulkRecolored": "{count, plural, =0 {Recolored no annotations} one {Recolored # annotation} other {Recolored # annotations}}",
"bulkRestyled": "{count, plural, =0 {Restyled no annotations} one {Restyled # annotation} other {Restyled # annotations}}",
"noteSaved": "Note saved"
"noteSaved": "Note saved",
"purgePendingDeviceSync": "A synced device still has to remove this annotation. Sync that device, then delete it forever."
},
"railTitle": "{marks} marks · {books} books",
"lastTwelveMonths": "Last 12 months",
@@ -5116,10 +5117,16 @@
},
"activitySummary": "{count, plural, one {# highlight between {from} and {to}} other {# highlights between {from} and {to}}}",
"book": "Book",
"needsReview": "Needs review"
"needsReview": "Needs review",
"trashToggle": "{count, plural, one {Trash, # annotation} other {Trash, # annotations}}",
"purgeDialog": {
"title": "Delete forever?",
"description": "This annotation and its note are removed for good. It cannot be restored from the trash."
}
},
"sources": {
"web": "Web",
"bookorbit": "BookOrbit",
"koreader": "KOReader",
"kobo": "Kobo"
},
@@ -11523,7 +11530,21 @@
},
"toast": {
"linkedHighlightError": "Could not open the linked highlight position",
"resumed": "Resumed at {pct}% - {label}"
"resumed": "Resumed at {pct}% - {label}",
"newerPositionError": "Could not open your newer place",
"highlightSaveFailed": "Could not save the highlight",
"noteSaveFailed": "Could not save the note",
"highlightDeleteFailed": "Could not delete the highlight",
"bookmarkSaveFailed": "Could not add the bookmark",
"bookmarkDeleteFailed": "Could not remove the bookmark",
"bookmarkUpdateFailed": "Could not save the bookmark",
"marksLoadFailed": "Could not load your highlights and bookmarks"
},
"newerPositionDialog": {
"title": "Continue from another device?",
"description": "This book was read to {remote} on another device while it was open here at {local}.",
"follow": "Go to {pct}",
"stay": "Stay at {pct}"
},
"header": {
"goBack": "Go back",
@@ -11665,6 +11686,7 @@
"allChapters": "All chapters",
"notesOnly": "Notes only",
"deleteBookmark": "Delete bookmark",
"editBookmark": "Edit bookmark",
"deleteHighlight": "Delete highlight",
"approximatePosition": "Synced from device; exact position unavailable, jumps to the chapter",
"sort": {
@@ -11680,6 +11702,7 @@
"translate": "Translate",
"define": "Define",
"deleteAnnotation": "Delete annotation",
"readFromHere": "Read from here",
"apply": "Apply",
"color": "Highlight color: {color}",
"style": "Highlight style: {style}",
@@ -11830,6 +11853,12 @@
"hideControls": "Hide controls",
"stop": "Stop narration",
"error": "Narration playback error"
},
"bookmarkEdit": {
"title": "Edit bookmark",
"titleLabel": "Title",
"noteLabel": "Note",
"notePlaceholder": "Optional note"
}
},
"scanner": {