Files
bookorbit/client/src/features/reader/composables/useFoliate.ts
T
neon 51dd3d30f3 chore: backend infrastructure improvements
- add URI versioning: global prefix changed to api/v1, all frontend call sites updated
- add AbortSignal.timeout to all outbound fetch calls (10s API, 15s scrapers)
- add X-Request-Id response header and requestId field in error payloads
- add response compression via @fastify/compress (gzip + brotli)
- add security headers via @fastify/helmet with CSP for the Vite SPA
- tune pg pool: max 20, idle/connection/statement timeouts on app and migration pools
- add GET /api/v1/health endpoint via @nestjs/terminus with DB connectivity check
2026-02-28 16:02:15 -06:00

164 lines
5.8 KiB
TypeScript

import { onUnmounted, ref } from 'vue'
import { api } from '@/lib/api'
import { useFoliateAnnotations } from './useFoliateAnnotations'
import { useFoliateSelection } from './useFoliateSelection'
import { useFoliateInput } from './useFoliateInput'
export interface RelocateDetail {
cfi?: string | null
fraction?: number
index?: number
total?: number
tocItem?: { label?: string; href?: string }
}
export interface FoliateRenderer {
heads?: HTMLElement[]
feet?: HTMLElement[]
setStyles?: (css: string) => void
setAttribute: (name: string, value: string) => void
removeAttribute: (name: string) => void
getContents?: () => { index: number }[]
}
export function useFoliate(
container: () => HTMLElement | null,
onRelocate?: (detail: RelocateDetail) => void,
onApplyStyles?: (renderer: FoliateRenderer) => void,
onMiddleTap?: () => void,
) {
const loading = ref(false)
const error = ref<string | null>(null)
const fraction = ref(0)
const viewRef = ref<unknown>(null)
const annotations = useFoliateAnnotations()
const selection = useFoliateSelection(() => viewRef.value)
const input = useFoliateInput(() => viewRef.value, onMiddleTap, selection.handleSelectionEnd, selection.handleSelectionChange)
async function loadScript() {
if (customElements.get('foliate-view')) return
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.type = 'module'
script.src = '/assets/foliate/view.js'
script.onload = () => setTimeout(resolve, 100)
script.onerror = () => reject(new Error('Failed to load foliate/view.js'))
document.head.appendChild(script)
})
await customElements.whenDefined('foliate-view')
}
async function open(fileId: number, format: string, cfi?: string | null) {
const el = container()
if (!el) return
loading.value = true
error.value = null
try {
await loadScript()
const view = document.createElement('foliate-view') as HTMLElement & {
renderer: FoliateRenderer
open: (file: File) => Promise<void>
goTo: (target: string | number) => Promise<void>
book?: { toc?: unknown[] }
getSectionFractions?: () => number[]
prev?: () => void
next?: () => void
destroy?: () => void
getCFI?: (index: number, range: Range) => string | null
addAnnotation?: (ann: { value: string }) => void
deleteAnnotation?: (ann: { value: string }) => void
search?: (opts: { query: string }) => AsyncIterable<unknown>
clearSearch?: () => void
}
view.style.cssText = 'width:100%;height:100%;display:block;'
el.innerHTML = ''
el.appendChild(view)
viewRef.value = view
view.addEventListener('load', (e: Event) => {
const detail = (e as CustomEvent).detail
loading.value = false
if (onApplyStyles) onApplyStyles(view.renderer)
annotations.reAddAll(view)
if (detail?.doc) input.attachIframeClicks(detail.doc)
})
view.addEventListener('draw-annotation', (e: Event) => {
annotations.handleDrawAnnotationEvent(e as CustomEvent)
})
view.addEventListener('relocate', (e: Event) => {
const detail = (e as CustomEvent).detail
fraction.value = detail?.fraction ?? 0
onRelocate?.(detail)
})
view.addEventListener('error', (e: Event) => {
const detail = (e as CustomEvent).detail
console.error('[foliate] error event', detail)
error.value = detail?.message ?? 'Reader error'
loading.value = false
})
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip'
const ext = format === 'pdf' ? 'pdf' : format === 'cbz' ? 'cbz' : 'epub'
const res = await api(`/api/v1/books/files/${fileId}/serve`)
if (!res.ok) throw new Error(`Failed to fetch book file: ${res.status}`)
const blob = await res.blob()
const file = new File([blob], `book-file-${fileId}.${ext}`, { type: mimeType })
await view.open(file)
if (onApplyStyles) onApplyStyles(view.renderer)
await view.goTo(cfi ?? 0).catch(() => {})
} catch (e) {
console.error('[useFoliate]', e)
error.value = e instanceof Error ? e.message : 'Failed to open book'
loading.value = false
}
}
function getViewEl() {
return viewRef.value as
| (ReturnType<typeof document.createElement> & {
prev?: () => void
next?: () => void
goTo?: (t: string | number) => Promise<void>
goToFraction?: (f: number) => void
getSectionFractions?: () => number[]
book?: { toc?: unknown[] }
renderer?: FoliateRenderer
destroy?: () => void
})
| null
}
onUnmounted(() => {
input.cleanup()
getViewEl()?.destroy?.()
viewRef.value = null
})
return {
loading,
error,
fraction,
view: viewRef,
open,
prev: () => getViewEl()?.prev?.(),
next: () => getViewEl()?.next?.(),
goTo: (t: string | number) => getViewEl()?.goTo?.(t),
goToFraction: (f: number) => getViewEl()?.goToFraction?.(f),
goToSection: (i: number) => getViewEl()?.goTo?.(i),
getSectionFractions: (): number[] => getViewEl()?.getSectionFractions?.() ?? [],
getChapters: (): unknown[] => getViewEl()?.book?.toc ?? [],
getRenderer: (): FoliateRenderer | null => getViewEl()?.renderer ?? null,
addAnnotation: (cfi: string, color = '#FACC15', style = 'highlight') => annotations.addAnnotation(viewRef.value, cfi, color, style),
addAnnotations: (anns: { cfi: string; color: string; style: string }[]) => annotations.addAnnotations(viewRef.value, anns),
deleteAnnotation: (cfi: string) => annotations.deleteAnnotation(viewRef.value, cfi),
setTextSelectedHandler: selection.setHandler,
}
}