mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)')]
|
||||
|
||||
+77
-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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user