mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(reader): fix PDF viewer with shallowRef and parallel page loading
This commit is contained in:
@@ -1,133 +1,209 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, Minus, Plus, Maximize2, AlignJustify } from 'lucide-vue-next'
|
import { AlignJustify, ArrowLeft, ChevronLeft, ChevronRight, Maximize2, Minus, Plus, RotateCw, Search, X } from 'lucide-vue-next'
|
||||||
import { usePdf, type ZoomMode } from '../composables/usePdf'
|
import { usePdf } from '../composables/usePdf'
|
||||||
import { useReaderProgress } from '../composables/useReaderProgress'
|
import { useReaderProgress } from '../composables/useReaderProgress'
|
||||||
import { useVisibility } from '../composables/useVisibility'
|
import { useVisibility } from '../composables/useVisibility'
|
||||||
|
import type { PageDim } from '../composables/usePdf'
|
||||||
|
|
||||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport } = usePdf()
|
|
||||||
|
const { pdfDoc, totalPages, loading, error, load, getAllPageDims, renderPage, getTextContent } = usePdf()
|
||||||
const progress = useReaderProgress(props.bookId, props.fileId)
|
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||||
const { headerVisible, showHeader } = useVisibility()
|
const { headerVisible, showHeader } = useVisibility()
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
const scrollRef = ref<HTMLElement | null>(null)
|
const scrollRef = ref<HTMLElement | null>(null)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
const pageInput = ref(1)
|
||||||
|
const rotation = ref<0 | 90 | 180 | 270>(0)
|
||||||
|
const spread = ref<'none' | 'odd' | 'even'>('none')
|
||||||
|
const showFind = ref(false)
|
||||||
|
const findQuery = ref('')
|
||||||
|
const findMatches = ref(0)
|
||||||
|
const PAGE_GAP = 16
|
||||||
|
|
||||||
|
// ── Page dimensions ───────────────────────────────────────────────────────────
|
||||||
|
const pageDims = ref<PageDim[]>([])
|
||||||
|
|
||||||
|
// After rotation, swap width/height for 90°/270°.
|
||||||
|
const effectiveDims = computed(() =>
|
||||||
|
pageDims.value.map((d) => (rotation.value === 90 || rotation.value === 270 ? { width: d.height, height: d.width } : d)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Container size ────────────────────────────────────────────────────────────
|
||||||
|
const containerW = ref(0)
|
||||||
|
const containerH = ref(0)
|
||||||
|
|
||||||
|
// ── Zoom ──────────────────────────────────────────────────────────────────────
|
||||||
|
type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
|
||||||
const zoomMode = ref<ZoomMode>('fit-width')
|
const zoomMode = ref<ZoomMode>('fit-width')
|
||||||
const customScale = ref(1.0)
|
const customScale = ref(1.0)
|
||||||
const PAGE_GAP = 12 // px between pages
|
|
||||||
|
|
||||||
// Page dimension cache: pageNum → { width, height } at scale=1
|
|
||||||
const pageDims = ref<{ width: number; height: number }[]>([])
|
|
||||||
|
|
||||||
// ── Scale computation ─────────────────────────────────────────────────────────
|
|
||||||
const containerWidth = ref(0)
|
|
||||||
const containerHeight = ref(0)
|
|
||||||
|
|
||||||
const scale = computed(() => {
|
const scale = computed(() => {
|
||||||
const first = pageDims.value[0]
|
const first = effectiveDims.value[0]
|
||||||
if (!first) return 1
|
if (!first || !containerW.value) return 1
|
||||||
if (zoomMode.value === 'fit-width') return (containerWidth.value - 32) / first.width
|
if (spread.value !== 'none') {
|
||||||
if (zoomMode.value === 'fit-page') {
|
// Two-page spread: fit two pages side by side
|
||||||
const sw = (containerWidth.value - 32) / first.width
|
const sw = (containerW.value - 48) / (first.width * 2 + PAGE_GAP)
|
||||||
const sh = (containerHeight.value - 48) / first.height
|
if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
|
||||||
return Math.min(sw, sh)
|
if (zoomMode.value === 'fit-width') return sw
|
||||||
}
|
}
|
||||||
|
if (zoomMode.value === 'fit-width') return (containerW.value - 32) / first.width
|
||||||
|
if (zoomMode.value === 'fit-page') return Math.min((containerW.value - 32) / first.width, (containerH.value - 48) / first.height)
|
||||||
return customScale.value
|
return customScale.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const zoomPercent = computed(() => Math.round(scale.value * 100))
|
const zoomPct = computed(() => Math.round(scale.value * 100))
|
||||||
|
|
||||||
// ── Page placeholder heights at current scale ─────────────────────────────────
|
function adjustZoom(delta: number) {
|
||||||
const pageHeights = computed(() => pageDims.value.map((d) => Math.round(d.height * scale.value)))
|
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
|
||||||
|
zoomMode.value = 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
// ── Canvas render tracking ────────────────────────────────────────────────────
|
// ── Spread pages into rows ────────────────────────────────────────────────────
|
||||||
const canvasRefs = ref<(HTMLCanvasElement | null)[]>([])
|
// Returns array of rows, each row = [pageNum] or [pageNum, pageNum+1]
|
||||||
const rendered = new Set<number>() // 1-based page numbers already rendered
|
const pageRows = computed<number[][]>(() => {
|
||||||
|
const rows: number[][] = []
|
||||||
|
const total = totalPages.value
|
||||||
|
if (spread.value === 'none') {
|
||||||
|
for (let i = 1; i <= total; i++) rows.push([i])
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
// 'odd' spread: page 1 alone, then pairs [2,3], [4,5]...
|
||||||
|
// 'even' spread: pairs [1,2], [3,4]...
|
||||||
|
let i = spread.value === 'odd' ? 1 : 0
|
||||||
|
if (spread.value === 'odd' && total >= 1) {
|
||||||
|
rows.push([1])
|
||||||
|
i = 2
|
||||||
|
}
|
||||||
|
while (i < total) {
|
||||||
|
const a = i + 1
|
||||||
|
const b = i + 2
|
||||||
|
rows.push(b <= total ? [a, b] : [a])
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
// Height of each row in px
|
||||||
|
const rowHeights = computed(() =>
|
||||||
|
pageRows.value.map((row) => {
|
||||||
|
const maxH = Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value)))
|
||||||
|
return maxH
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Canvas / text-layer tracking ──────────────────────────────────────────────
|
||||||
|
const canvasMap = ref(new Map<number, HTMLCanvasElement>())
|
||||||
|
const textLayerMap = ref(new Map<number, HTMLElement>())
|
||||||
|
const rendered = new Set<number>()
|
||||||
let renderQueue: number[] = []
|
let renderQueue: number[] = []
|
||||||
let rendering = false
|
let isRendering = false
|
||||||
|
|
||||||
async function flushQueue() {
|
async function flushQueue() {
|
||||||
if (rendering || renderQueue.length === 0) return
|
if (isRendering) return
|
||||||
rendering = true
|
isRendering = true
|
||||||
while (renderQueue.length > 0) {
|
while (renderQueue.length > 0) {
|
||||||
const pageNum = renderQueue.shift()!
|
const pageNum = renderQueue.shift()!
|
||||||
if (!pdfDoc.value) break
|
if (!pdfDoc.value) break
|
||||||
const canvas = canvasRefs.value[pageNum - 1]
|
const canvas = canvasMap.value.get(pageNum)
|
||||||
if (!canvas) continue
|
if (!canvas) continue
|
||||||
await renderPageToCanvas(pdfDoc.value as Parameters<typeof renderPageToCanvas>[0], pageNum, canvas, scale.value)
|
await renderPage(pageNum, canvas, scale.value)
|
||||||
rendered.add(pageNum)
|
rendered.add(pageNum)
|
||||||
|
// Text layer
|
||||||
|
await renderTextLayer(pageNum)
|
||||||
}
|
}
|
||||||
rendering = false
|
isRendering = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueuePage(pageNum: number) {
|
async function renderTextLayer(pageNum: number) {
|
||||||
|
const container = textLayerMap.value.get(pageNum)
|
||||||
|
if (!container) return
|
||||||
|
const result = await getTextContent(pageNum)
|
||||||
|
if (!result) return
|
||||||
|
const { content, viewport } = result
|
||||||
|
const scaledVp = viewport.clone({ scale: scale.value, rotation: rotation.value })
|
||||||
|
container.innerHTML = ''
|
||||||
|
container.style.width = `${scaledVp.width}px`
|
||||||
|
container.style.height = `${scaledVp.height}px`
|
||||||
|
|
||||||
|
for (const item of content.items) {
|
||||||
|
if (!('str' in item) || !item.str.trim()) continue
|
||||||
|
const [a, b, c, d, e, f] = item.transform
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.textContent = item.str
|
||||||
|
const tx = pdfjsLib_scaleTransform([a, b, c, d, e, f], scale.value, scaledVp.height)
|
||||||
|
span.style.cssText = `position:absolute;left:${tx[4]}px;top:${scaledVp.height - tx[5] - (item.height ?? 0) * scale.value}px;font-size:${Math.abs(tx[0]) || Math.abs(tx[1])}px;transform-origin:0% 0%;white-space:pre;color:transparent;cursor:text;`
|
||||||
|
container.appendChild(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale a 2D transform matrix
|
||||||
|
function pdfjsLib_scaleTransform([a, b, c, d, e, f]: number[], s: number, _h: number) {
|
||||||
|
return [a * s, b * s, c * s, d * s, e * s, f * s]
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(pageNum: number) {
|
||||||
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
|
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
|
||||||
renderQueue.push(pageNum)
|
renderQueue.push(pageNum)
|
||||||
flushQueue()
|
flushQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
// When scale changes, clear rendered cache and re-render visible pages.
|
// Invalidate and re-render on zoom/rotation change
|
||||||
watch(scale, () => {
|
watch([scale, rotation], () => {
|
||||||
rendered.clear()
|
rendered.clear()
|
||||||
renderQueue = []
|
renderQueue = []
|
||||||
rendering = false
|
isRendering = false
|
||||||
nextTick(() => observerCallback(currentObserverEntries))
|
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── IntersectionObserver — detects visible pages ──────────────────────────────
|
// ── IntersectionObserver ──────────────────────────────────────────────────────
|
||||||
let currentObserverEntries: IntersectionObserverEntry[] = []
|
let ioEntries: IntersectionObserverEntry[] = []
|
||||||
let observer: IntersectionObserver | null = null
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
function observerCallback(entries: IntersectionObserverEntry[]) {
|
function ioCallback(entries: IntersectionObserverEntry[]) {
|
||||||
currentObserverEntries = entries
|
for (const e of entries) {
|
||||||
for (const entry of entries) {
|
if (!e.isIntersecting) continue
|
||||||
if (!entry.isIntersecting) continue
|
const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
|
||||||
const idx = Number((entry.target as HTMLElement).dataset.page)
|
for (const n of nums) {
|
||||||
enqueuePage(idx)
|
enqueue(n)
|
||||||
// Preload adjacent pages
|
if (n > 1) enqueue(n - 1)
|
||||||
if (idx > 1) enqueuePage(idx - 1)
|
if (n < totalPages.value) enqueue(n + 1)
|
||||||
if (idx < totalPages.value) enqueuePage(idx + 1)
|
}
|
||||||
}
|
}
|
||||||
|
ioEntries = entries
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupObserver() {
|
function setupIO() {
|
||||||
observer?.disconnect()
|
io?.disconnect()
|
||||||
observer = new IntersectionObserver(observerCallback, {
|
io = new IntersectionObserver(ioCallback, { root: scrollRef.value, rootMargin: '300px', threshold: 0 })
|
||||||
root: scrollRef.value,
|
nextTick(() => scrollRef.value?.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
|
||||||
rootMargin: '200px',
|
|
||||||
threshold: 0,
|
|
||||||
})
|
|
||||||
nextTick(() => {
|
|
||||||
const items = scrollRef.value?.querySelectorAll('[data-page]') ?? []
|
|
||||||
items.forEach((el) => observer!.observe(el))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Scroll → current page tracking ───────────────────────────────────────────
|
// ── Scroll → page tracking ────────────────────────────────────────────────────
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const el = scrollRef.value
|
const el = scrollRef.value
|
||||||
if (!el || !pageHeights.value.length) return
|
if (!el || !rowHeights.value.length) return
|
||||||
|
|
||||||
let offset = el.scrollTop
|
let offset = el.scrollTop
|
||||||
let pg = 1
|
let rowIdx = 0
|
||||||
for (let i = 0; i < pageHeights.value.length; i++) {
|
for (let i = 0; i < rowHeights.value.length; i++) {
|
||||||
const h = (pageHeights.value[i] ?? 0) + PAGE_GAP
|
const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
|
||||||
if (offset < h) {
|
if (offset < h) {
|
||||||
pg = i + 1
|
rowIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
offset -= h
|
offset -= h
|
||||||
pg = i + 2
|
rowIdx = i + 1
|
||||||
}
|
}
|
||||||
currentPage.value = Math.min(pg, totalPages.value)
|
const row = pageRows.value[rowIdx]
|
||||||
|
currentPage.value = row?.[0] ?? totalPages.value
|
||||||
|
|
||||||
// Debounced progress save
|
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
progress.pageNumber.value = currentPage.value
|
progress.pageNumber.value = currentPage.value
|
||||||
@@ -136,65 +212,77 @@ function onScroll() {
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard navigation ───────────────────────────────────────────────────────
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
if (!scrollRef.value) return
|
|
||||||
const pageH = pageHeights.value[currentPage.value - 1] ?? containerHeight.value
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
|
|
||||||
e.preventDefault()
|
|
||||||
scrollRef.value.scrollBy({ top: pageH + PAGE_GAP, behavior: 'smooth' })
|
|
||||||
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
||||||
e.preventDefault()
|
|
||||||
scrollRef.value.scrollBy({ top: -(pageH + PAGE_GAP), behavior: 'smooth' })
|
|
||||||
} else if (e.key === 'Home') {
|
|
||||||
goToPage(1)
|
|
||||||
} else if (e.key === 'End') {
|
|
||||||
goToPage(totalPages.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigation helpers ────────────────────────────────────────────────────────
|
|
||||||
function goToPage(n: number) {
|
|
||||||
const el = scrollRef.value
|
|
||||||
if (!el) return
|
|
||||||
const clamped = Math.max(1, Math.min(n, totalPages.value))
|
|
||||||
let top = 0
|
|
||||||
for (let i = 0; i < clamped - 1; i++) top += (pageHeights.value[i] ?? 0) + PAGE_GAP
|
|
||||||
el.scrollTo({ top, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageInput = ref(1)
|
|
||||||
watch(currentPage, (v) => {
|
watch(currentPage, (v) => {
|
||||||
pageInput.value = v
|
pageInput.value = v
|
||||||
})
|
})
|
||||||
|
|
||||||
function onPageInputCommit() {
|
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||||
|
function goToPage(n: number) {
|
||||||
|
const el = scrollRef.value
|
||||||
|
if (!el) return
|
||||||
|
const clamped = Math.max(1, Math.min(n, totalPages.value))
|
||||||
|
// Find which row contains this page
|
||||||
|
const rowIdx = pageRows.value.findIndex((row) => row.includes(clamped))
|
||||||
|
if (rowIdx < 0) return
|
||||||
|
let top = 0
|
||||||
|
for (let i = 0; i < rowIdx; i++) top += (rowHeights.value[i] ?? 0) + PAGE_GAP
|
||||||
|
el.scrollTo({ top, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageCommit() {
|
||||||
const n = parseInt(String(pageInput.value))
|
const n = parseInt(String(pageInput.value))
|
||||||
if (!isNaN(n)) goToPage(n)
|
if (!isNaN(n)) goToPage(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zoom helpers ──────────────────────────────────────────────────────────────
|
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
||||||
function setZoom(mode: ZoomMode) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
zoomMode.value = mode
|
if (showFind.value && (e.target as HTMLElement)?.tagName === 'INPUT') return
|
||||||
if (mode !== 'custom') return
|
const el = scrollRef.value
|
||||||
|
if (!el) return
|
||||||
|
const ph = (rowHeights.value[pageRows.value.findIndex((r) => r.includes(currentPage.value))] ?? containerH.value) + PAGE_GAP
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
el.scrollBy({ top: ph, behavior: 'smooth' })
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
el.scrollBy({ top: -ph, behavior: 'smooth' })
|
||||||
|
} else if (e.key === 'Home') goToPage(1)
|
||||||
|
else if (e.key === 'End') goToPage(totalPages.value)
|
||||||
|
else if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
||||||
|
e.preventDefault()
|
||||||
|
showFind.value = true
|
||||||
|
} else if (e.key === 'Escape') showFind.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustZoom(delta: number) {
|
// ── Rotation ──────────────────────────────────────────────────────────────────
|
||||||
const next = Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta))
|
function rotate() {
|
||||||
customScale.value = Math.round(next * 20) / 20 // snap to 5% increments
|
rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
|
||||||
zoomMode.value = 'custom'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resize observer ───────────────────────────────────────────────────────────
|
// ── Find ──────────────────────────────────────────────────────────────────────
|
||||||
let resizeObserver: ResizeObserver | null = null
|
watch(findQuery, (q) => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
findMatches.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Naive: count rendered text spans containing query
|
||||||
|
let count = 0
|
||||||
|
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
|
||||||
|
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
|
||||||
|
})
|
||||||
|
findMatches.value = count
|
||||||
|
})
|
||||||
|
|
||||||
function measureContainer() {
|
// ── Resize ────────────────────────────────────────────────────────────────────
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function measure() {
|
||||||
if (!scrollRef.value) return
|
if (!scrollRef.value) return
|
||||||
containerWidth.value = scrollRef.value.clientWidth
|
containerW.value = scrollRef.value.clientWidth
|
||||||
containerHeight.value = scrollRef.value.clientHeight
|
containerH.value = scrollRef.value.clientHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initialisation ────────────────────────────────────────────────────────────
|
// ── Mount ─────────────────────────────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
@@ -202,30 +290,24 @@ onMounted(async () => {
|
|||||||
await load(props.fileId)
|
await load(props.fileId)
|
||||||
if (!pdfDoc.value) return
|
if (!pdfDoc.value) return
|
||||||
|
|
||||||
// Fetch all page natural dimensions (scale=1) upfront
|
pageDims.value = await getAllPageDims()
|
||||||
const dims: { width: number; height: number }[] = []
|
canvasMap.value = new Map()
|
||||||
for (let i = 1; i <= totalPages.value; i++) {
|
textLayerMap.value = new Map()
|
||||||
const vp = await getPageViewport(i, 1)
|
|
||||||
dims.push(vp ? { width: vp.width, height: vp.height } : { width: 595, height: 842 })
|
|
||||||
}
|
|
||||||
pageDims.value = dims
|
|
||||||
canvasRefs.value = new Array(totalPages.value).fill(null)
|
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
measureContainer()
|
measure()
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
ro = new ResizeObserver(() => {
|
||||||
measureContainer()
|
measure()
|
||||||
rendered.clear()
|
rendered.clear()
|
||||||
renderQueue = []
|
renderQueue = []
|
||||||
rendering = false
|
isRendering = false
|
||||||
nextTick(() => observerCallback(currentObserverEntries))
|
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
|
||||||
})
|
})
|
||||||
if (scrollRef.value) resizeObserver.observe(scrollRef.value)
|
if (scrollRef.value) ro.observe(scrollRef.value)
|
||||||
|
|
||||||
setupObserver()
|
setupIO()
|
||||||
|
|
||||||
// Restore saved page
|
|
||||||
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
|
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
goToPage(progress.pageNumber.value)
|
goToPage(progress.pageNumber.value)
|
||||||
@@ -234,43 +316,32 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
observer?.disconnect()
|
io?.disconnect()
|
||||||
resizeObserver?.disconnect()
|
ro?.disconnect()
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
// ── Derived ───────────────────────────────────────────────────────────────────
|
||||||
const progressPercent = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
|
const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 bg-[#404040] flex flex-col overflow-hidden" @mousemove="showHeader()">
|
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()">
|
||||||
<!-- Header -->
|
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 right-0 z-50 h-12 flex items-center px-3 gap-1 transition-all duration-300"
|
class="absolute top-0 left-0 right-0 z-50 flex flex-col transition-all duration-300"
|
||||||
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
||||||
style="background: rgba(30, 30, 30, 0.92); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.1)"
|
|
||||||
>
|
>
|
||||||
<!-- Back -->
|
<!-- Main toolbar -->
|
||||||
<button
|
<div
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-colors shrink-0"
|
class="h-12 flex items-center px-2 gap-0.5"
|
||||||
@click="router.back()"
|
style="background: rgba(50, 54, 57, 0.96); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
|
||||||
title="Go back"
|
|
||||||
>
|
>
|
||||||
<ArrowLeft :size="18" />
|
<button class="pdf-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
|
||||||
</button>
|
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
|
||||||
|
|
||||||
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
|
<!-- Page nav -->
|
||||||
|
<button class="pdf-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)"><ChevronLeft :size="16" /></button>
|
||||||
<!-- Page navigation -->
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors text-sm font-medium disabled:opacity-30"
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-1 text-sm text-white/80">
|
<div class="flex items-center gap-1 text-sm text-white/80">
|
||||||
<input
|
<input
|
||||||
v-model.number="pageInput"
|
v-model.number="pageInput"
|
||||||
@@ -278,109 +349,161 @@ const progressPercent = computed(() => (totalPages.value ? Math.round((currentPa
|
|||||||
min="1"
|
min="1"
|
||||||
:max="totalPages"
|
:max="totalPages"
|
||||||
class="w-10 text-center bg-white/10 rounded px-1 py-0.5 text-white text-sm outline-none focus:bg-white/20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
class="w-10 text-center bg-white/10 rounded px-1 py-0.5 text-white text-sm outline-none focus:bg-white/20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
@keydown.enter="onPageInputCommit"
|
@keydown.enter="onPageCommit"
|
||||||
@blur="onPageInputCommit"
|
@blur="onPageCommit"
|
||||||
/>
|
/>
|
||||||
<span class="text-white/50">/</span>
|
<span class="text-white/40">/</span>
|
||||||
<span>{{ totalPages }}</span>
|
<span class="tabular-nums">{{ totalPages }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button class="pdf-btn" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)"><ChevronRight :size="16" /></button>
|
||||||
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors text-sm font-medium disabled:opacity-30"
|
|
||||||
:disabled="currentPage >= totalPages"
|
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
>
|
<!-- Zoom -->
|
||||||
›
|
<button class="pdf-btn" @click="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
|
||||||
|
<span class="w-12 text-center text-xs text-white/60 font-mono tabular-nums">{{ zoomPct }}%</span>
|
||||||
|
<button class="pdf-btn" @click="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
|
||||||
|
<button class="pdf-btn" :class="zoomMode === 'fit-width' ? 'bg-white/20 text-white' : ''" @click="zoomMode = 'fit-width'" title="Fit width">
|
||||||
|
<AlignJustify :size="14" />
|
||||||
|
</button>
|
||||||
|
<button class="pdf-btn" :class="zoomMode === 'fit-page' ? 'bg-white/20 text-white' : ''" @click="zoomMode = 'fit-page'" title="Fit page">
|
||||||
|
<Maximize2 :size="14" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
|
||||||
|
|
||||||
|
<!-- Rotation -->
|
||||||
|
<button class="pdf-btn" @click="rotate" title="Rotate 90°"><RotateCw :size="14" /></button>
|
||||||
|
|
||||||
|
<!-- Spread -->
|
||||||
|
<div class="flex items-center gap-0.5 ml-0.5">
|
||||||
|
<button
|
||||||
|
v-for="s in [
|
||||||
|
['none', '▬'],
|
||||||
|
['odd', '▬▬'],
|
||||||
|
['even', '▬▬'],
|
||||||
|
] as const"
|
||||||
|
:key="s[0]"
|
||||||
|
class="pdf-btn text-[10px] font-bold px-1.5"
|
||||||
|
:class="spread === s[0] ? 'bg-white/20 text-white' : ''"
|
||||||
|
:title="s[0] === 'none' ? 'Single page' : s[0] === 'odd' ? 'Spread (cover)' : 'Spread'"
|
||||||
|
@click="spread = s[0]"
|
||||||
|
>
|
||||||
|
{{ s[1] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<!-- Find -->
|
||||||
|
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
|
||||||
|
<Search :size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1" />
|
<!-- Find bar -->
|
||||||
|
<div
|
||||||
<!-- Zoom controls -->
|
v-if="showFind"
|
||||||
<div class="flex items-center gap-0.5">
|
class="flex items-center gap-2 px-3 py-2"
|
||||||
|
style="background: rgba(50, 54, 57, 0.98); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
|
||||||
|
>
|
||||||
|
<Search :size="13" class="text-white/40 shrink-0" />
|
||||||
|
<input
|
||||||
|
v-model="findQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Find in document…"
|
||||||
|
class="flex-1 bg-transparent text-sm text-white outline-none placeholder:text-white/30"
|
||||||
|
autofocus
|
||||||
|
@keydown.escape="showFind = false"
|
||||||
|
/>
|
||||||
|
<span v-if="findQuery" class="text-xs text-white/40 tabular-nums shrink-0">{{ findMatches }} match{{ findMatches !== 1 ? 'es' : '' }}</span>
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
class="pdf-btn w-6 h-6"
|
||||||
@click="adjustZoom(-0.1)"
|
@click="
|
||||||
title="Zoom out"
|
showFind = false
|
||||||
|
findQuery = ''
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<Minus :size="14" />
|
<X :size="12" />
|
||||||
</button>
|
|
||||||
<span class="w-12 text-center text-xs text-white/70 font-mono tabular-nums">{{ zoomPercent }}%</span>
|
|
||||||
<button
|
|
||||||
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
|
||||||
@click="adjustZoom(0.1)"
|
|
||||||
title="Zoom in"
|
|
||||||
>
|
|
||||||
<Plus :size="14" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="w-px h-4 mx-1 bg-white/15 shrink-0" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="flex items-center justify-center w-7 h-7 rounded transition-colors text-xs font-bold"
|
|
||||||
:class="zoomMode === 'fit-width' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'"
|
|
||||||
@click="setZoom('fit-width')"
|
|
||||||
title="Fit width"
|
|
||||||
>
|
|
||||||
<AlignJustify :size="14" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
|
||||||
:class="zoomMode === 'fit-page' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'"
|
|
||||||
@click="setZoom('fit-page')"
|
|
||||||
title="Fit page"
|
|
||||||
>
|
|
||||||
<Maximize2 :size="14" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hover zone to re-show header -->
|
<!-- Hover zone -->
|
||||||
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
|
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<div class="w-8 h-8 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
<div class="w-8 h-8 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
|
||||||
<p class="text-sm text-white/60">Loading PDF…</p>
|
<p class="text-sm text-white/50">Loading PDF…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8">
|
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8 text-center">
|
||||||
<div class="text-center max-w-sm">
|
<div>
|
||||||
<p class="text-sm font-medium text-white/90 mb-2">Failed to load PDF</p>
|
<p class="text-sm font-medium text-white/80 mb-1">Failed to load PDF</p>
|
||||||
<p class="text-xs text-white/50">{{ error }}</p>
|
<p class="text-xs text-white/40">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pages -->
|
<!-- ── Pages ─────────────────────────────────────────────────────────── -->
|
||||||
<div v-else ref="scrollRef" class="flex-1 overflow-y-auto overflow-x-auto" @scroll="onScroll">
|
<div v-else ref="scrollRef" class="flex-1 overflow-auto" @scroll.passive="onScroll">
|
||||||
<div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px` }">
|
<div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px`, paddingTop: '56px' }">
|
||||||
|
<!-- Each row = one or two pages side-by-side -->
|
||||||
<div
|
<div
|
||||||
v-for="n in totalPages"
|
v-for="(row, ri) in pageRows"
|
||||||
:key="n"
|
:key="ri"
|
||||||
:data-page="n"
|
:data-pages="row.join(',')"
|
||||||
class="bg-white shadow-lg"
|
class="flex gap-1 items-start"
|
||||||
:style="{
|
:style="{ height: `${rowHeights[ri] ?? 0}px` }"
|
||||||
width: `${Math.round((pageDims[n - 1]?.width ?? 595) * scale)}px`,
|
|
||||||
height: `${pageHeights[n - 1] ?? 0}px`,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<canvas
|
<div
|
||||||
:ref="
|
v-for="pageNum in row"
|
||||||
(el) => {
|
:key="pageNum"
|
||||||
canvasRefs[n - 1] = el as HTMLCanvasElement | null
|
class="relative bg-white shadow-xl overflow-hidden"
|
||||||
}
|
:style="{
|
||||||
"
|
width: `${Math.round((effectiveDims[pageNum - 1]?.width ?? 595) * scale)}px`,
|
||||||
class="block"
|
height: `${Math.round((effectiveDims[pageNum - 1]?.height ?? 842) * scale)}px`,
|
||||||
/>
|
}"
|
||||||
|
>
|
||||||
|
<!-- Canvas -->
|
||||||
|
<canvas
|
||||||
|
class="block"
|
||||||
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<!-- Text layer (for selection / find highlight) -->
|
||||||
|
<div
|
||||||
|
data-text-layer
|
||||||
|
class="absolute inset-0 overflow-hidden"
|
||||||
|
style="pointer-events: none"
|
||||||
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
if (el) textLayerMap.set(pageNum, el as HTMLElement)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<!-- Find highlight overlay -->
|
||||||
|
<div v-if="showFind && findQuery" class="absolute inset-0 pointer-events-none" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer progress bar -->
|
<!-- Progress bar -->
|
||||||
<div v-if="!loading && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10">
|
<div v-if="!loading && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10">
|
||||||
<div class="h-full bg-white/40 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
|
<div class="h-full bg-blue-400/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pdf-btn {
|
||||||
|
@apply flex items-center justify-center w-7 h-7 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { onUnmounted, ref } from 'vue'
|
import { onUnmounted, shallowRef, ref } from 'vue'
|
||||||
import * as pdfjsLib from 'pdfjs-dist'
|
import * as pdfjsLib from 'pdfjs-dist'
|
||||||
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
|
||||||
@@ -6,23 +6,19 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
|
|||||||
|
|
||||||
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
|
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
|
||||||
|
|
||||||
// Renders one page onto the given canvas at the given scale (pdfjs-dist v5 API).
|
export interface PageDim {
|
||||||
async function renderPageToCanvas(doc: pdfjsLib.PDFDocumentProxy, pageNum: number, canvas: HTMLCanvasElement, scale: number) {
|
width: number
|
||||||
const page = await doc.getPage(pageNum)
|
height: number
|
||||||
const viewport = page.getViewport({ scale })
|
|
||||||
canvas.width = viewport.width
|
|
||||||
canvas.height = viewport.height
|
|
||||||
await page.render({ canvas, viewport }).promise
|
|
||||||
page.cleanup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePdf() {
|
export function usePdf() {
|
||||||
const pdfDoc = ref<pdfjsLib.PDFDocumentProxy | null>(null)
|
// shallowRef is critical — Vue's deep proxy breaks PDF.js internal state
|
||||||
|
const pdfDoc = shallowRef<pdfjsLib.PDFDocumentProxy | null>(null)
|
||||||
const totalPages = ref(0)
|
const totalPages = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
async function load(fileId: number) {
|
async function load(fileId: number): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -39,13 +35,47 @@ export function usePdf() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the natural width/height of a page at scale=1 (used to size placeholder divs).
|
// Fetch ALL page natural dims in parallel (capped at 10 concurrent).
|
||||||
async function getPageViewport(pageNum: number, scale: number) {
|
async function getAllPageDims(): Promise<PageDim[]> {
|
||||||
if (!pdfDoc.value) return null
|
const doc = pdfDoc.value
|
||||||
const page = await pdfDoc.value.getPage(pageNum)
|
if (!doc) return []
|
||||||
const vp = page.getViewport({ scale })
|
const total = doc.numPages
|
||||||
|
const results: PageDim[] = new Array(total)
|
||||||
|
const CONCURRENCY = 10
|
||||||
|
|
||||||
|
async function fetchDim(i: number) {
|
||||||
|
const page = await doc.getPage(i + 1)
|
||||||
|
const vp = page.getViewport({ scale: 1 })
|
||||||
|
page.cleanup()
|
||||||
|
results[i] = { width: vp.width, height: vp.height }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let start = 0; start < total; start += CONCURRENCY) {
|
||||||
|
const batch = Array.from({ length: Math.min(CONCURRENCY, total - start) }, (_, k) => fetchDim(start + k))
|
||||||
|
await Promise.all(batch)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(pageNum: number, canvas: HTMLCanvasElement, scale: number): Promise<void> {
|
||||||
|
const doc = pdfDoc.value
|
||||||
|
if (!doc) return
|
||||||
|
const page = await doc.getPage(pageNum)
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
canvas.width = viewport.width
|
||||||
|
canvas.height = viewport.height
|
||||||
|
await page.render({ canvas, viewport }).promise
|
||||||
page.cleanup()
|
page.cleanup()
|
||||||
return vp
|
}
|
||||||
|
|
||||||
|
async function getTextContent(pageNum: number) {
|
||||||
|
const doc = pdfDoc.value
|
||||||
|
if (!doc) return null
|
||||||
|
const page = await doc.getPage(pageNum)
|
||||||
|
const content = await page.getTextContent()
|
||||||
|
const viewport = page.getViewport({ scale: 1 })
|
||||||
|
page.cleanup()
|
||||||
|
return { content, viewport }
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -53,5 +83,5 @@ export function usePdf() {
|
|||||||
pdfDoc.value = null
|
pdfDoc.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
return { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport }
|
return { pdfDoc, totalPages, loading, error, load, getAllPageDims, renderPage, getTextContent }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,28 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766926208614,
|
"when": 1766937175999,
|
||||||
"tag": "0000_init_schema",
|
"tag": "0000_init_schema",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766926208614,
|
"when": 1766937175999,
|
||||||
"tag": "0001_lethal_pixie",
|
"tag": "0001_lethal_pixie",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766926208614,
|
"when": 1766937175999,
|
||||||
"tag": "0002_powerful_thundra",
|
"tag": "0002_powerful_thundra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1766926208614,
|
"when": 1766937175999,
|
||||||
"tag": "0003_reading_progress_by_file",
|
"tag": "0003_reading_progress_by_file",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user