Files
PhoneViewer/src/doc/docViewer.ts
T
admin f70913481e Rename app to EasyView and make APK build cross-platform
- App display name -> EasyView (Android label, PWA manifest, page titles,
  about/guide text, APK artifact name)
- scripts/build-apk.mjs: auto-detect JDK/SDK/Gradle toolchain instead of
  hardcoded Windows paths; pick highest build-tools; platform 34 fallback;
  path.delimiter classpaths; npm run build without cmd.exe on POSIX
- Add build-apk.sh one-shot Linux build wrapper (loads ven env + keystore)
- Signing keystore renamed to easyview.jks (alias easyview),
  env vars EASYVIEW_KEYSTORE_PASS / EASYVIEW_KEY_PASS
2026-10-26 19:38:42 +08:00

1266 lines
42 KiB
TypeScript

import type { MuPDFModule } from './mupdfLoader'
import type { ViewerHost } from '../core/viewerHost'
import { clear, h, icon, iconButton } from '../core/dom'
import { toast } from '../core/toast'
import { openModal } from '../ui/modal'
import { showAbout } from '../ui/about'
import { formatBytes, kindMeta, type FileKind } from '../core/files'
interface PageSlot {
index: number
el: HTMLElement
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D
renderedKey: string
}
interface OutlineItem {
title?: string
uri?: string
page?: number
down?: OutlineItem[]
}
interface SearchHit {
hitIndex: number
page: number
quads: number[][]
}
interface Size {
w: number
h: number
}
const INVERT_KEY = 'viewer.docInvert'
const PAGE_GAP = 18
export class DocViewer {
private host: ViewerHost
private mupdf: MuPDFModule
private bytes: Uint8Array
private fileName: string
private fileSize: number
private kind: FileKind
private magic?: string
private doc: any = null
private scroller: HTMLElement
private pagesEl: HTMLElement
private slots: PageSlot[] = []
private pageSizes = new Map<number, Size>()
private defaultSize: Size = { w: 612, h: 792 }
private pageCount = 0
private currentPage = 0
private zoom = 1
private rotate = 0
private invert = false
private reflowable = false
private fitMode: 'width' | 'page' | 'custom' = 'width'
private epoch = 0
private renderQueue: Promise<void> = Promise.resolve()
private pendingRenders = new Set<number>()
private visiblePages = new Set<number>()
private intersectionObserver: IntersectionObserver | null = null
private resizeObserver: ResizeObserver | null = null
private resizeTimer = 0
private scrollRaf = 0
private searchToken = 0
private searchQuery = ''
private searchHits: SearchHit[] = []
private currentHit = -1
private searchVisible = false
private lastReflowWidth = 0
private lastReflowHeight = 0
private pinch:
| {
startDist: number
startZoom: number
startScrollTop: number
startScrollLeft: number
midX: number
midY: number
}
| null = null
private destroyed = false
private pageInput: HTMLInputElement
private pageTotal: HTMLElement
private zoomLabel: HTMLButtonElement
private searchBar: HTMLElement
private searchInput: HTMLInputElement
private searchCount: HTMLElement
private searchPrev: HTMLButtonElement
private searchNext: HTMLButtonElement
private outlineBackdrop: HTMLElement
private outlineList: HTMLElement
private menu: HTMLElement
private menuButtons: Record<string, HTMLButtonElement> = {}
private fullscreenBtn: HTMLButtonElement | null = null
private firstRenderDone = false
private onDocumentPointerDown: ((ev: PointerEvent) => void) | null = null
constructor(
host: ViewerHost,
mupdf: MuPDFModule,
bytes: Uint8Array,
fileName: string,
fileSize: number,
kind: FileKind,
magic?: string
) {
this.host = host
this.mupdf = mupdf
this.bytes = bytes
this.fileName = fileName
this.fileSize = fileSize
this.kind = kind
this.magic = magic
try {
this.invert = localStorage.getItem(INVERT_KEY) === '1'
} catch {
this.invert = false
}
const meta = kindMeta(kind)
host.setFileInfo(fileName, meta.label, meta.short, meta.tone, formatBytes(fileSize))
host.setStatus('')
this.scroller = h('div', { class: 'doc-scroll' })
this.pagesEl = h('div', { class: 'doc-pages' })
this.pagesEl.classList.toggle('inverted', this.invert)
this.scroller.appendChild(this.pagesEl)
host.stage.appendChild(this.scroller)
this.pageInput = h('input', {
class: 'page-input',
type: 'number',
min: '1',
inputMode: 'numeric',
'aria-label': '页码'
})
this.pageTotal = h('span', { class: 'page-total', text: '/ 1' })
const prev = iconButton('chevron-left', '上一页', () => this.goToPage(this.currentPage - 1), {
className: 'doc-btn'
})
const next = iconButton('chevron-right', '下一页', () => this.goToPage(this.currentPage + 1), {
className: 'doc-btn'
})
const pageGroup = h('div', { class: 'doc-group' }, prev, this.pageInput, this.pageTotal, next)
const zoomOut = iconButton('zoom-out', '缩小', () => this.zoomTo(this.zoom / 1.2), {
className: 'doc-btn'
})
const zoomIn = iconButton('zoom-in', '放大', () => this.zoomTo(this.zoom * 1.2), {
className: 'doc-btn'
})
this.zoomLabel = h('button', { class: 'zoom-label', type: 'button', text: '100%', title: '适合宽度' })
this.zoomLabel.addEventListener('click', () => this.fitWidth())
const zoomGroup = h('div', { class: 'doc-group' }, zoomOut, this.zoomLabel, zoomIn)
const fitBtn = iconButton('fit', '适合宽度', () => this.fitWidth(), { className: 'doc-btn' })
const moreBtn = iconButton('more', '更多', () => this.toggleMenu(), { className: 'doc-btn' })
moreBtn.dataset.action = 'more'
const actionGroup = h('div', { class: 'doc-group' }, fitBtn, moreBtn)
const bottom = h('div', { class: 'doc-bottom' }, pageGroup, zoomGroup, actionGroup)
host.stage.appendChild(bottom)
this.menu = this.buildMenu()
host.stage.appendChild(this.menu)
this.searchInput = h('input', {
class: 'doc-search-input',
type: 'search',
placeholder: '在文档中查找…',
'aria-label': '查找'
})
this.searchCount = h('span', { class: 'doc-search-count', text: '' })
this.searchPrev = iconButton('chevron-up', '上一个结果', () => this.prevHit(), { className: 'doc-btn' })
this.searchNext = iconButton('chevron-down', '下一个结果', () => this.nextHit(), { className: 'doc-btn' })
const searchClose = iconButton('x', '关闭查找', () => this.closeSearch(), { className: 'doc-btn' })
this.searchBar = h(
'div',
{ class: 'doc-search' },
h('span', { class: 'doc-search-icon', html: icon('search', 18) }),
this.searchInput,
this.searchCount,
this.searchPrev,
this.searchNext,
searchClose
)
host.stage.appendChild(this.searchBar)
this.outlineList = h('div', { class: 'outline-list' })
this.outlineBackdrop = h('div', { class: 'sheet-backdrop sheet-left' })
const outlineClose = iconButton('x', '关闭', () => this.closeOutline())
const outlineSheet = h(
'aside',
{ class: 'sheet sheet-outline', role: 'dialog', 'aria-label': '目录' },
h('div', { class: 'sheet-head' }, h('div', { class: 'sheet-title' }, h('span', { text: '目录' })), outlineClose),
this.outlineList
)
this.outlineBackdrop.appendChild(outlineSheet)
this.outlineBackdrop.addEventListener('click', (ev) => {
if (ev.target === this.outlineBackdrop) this.closeOutline()
})
host.stage.appendChild(this.outlineBackdrop)
this.buildHeaderActions()
this.pageInput.addEventListener('change', () => {
const value = Number(this.pageInput.value)
if (Number.isFinite(value)) this.goToPage(value - 1)
})
this.pageInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
this.pageInput.blur()
const value = Number(this.pageInput.value)
if (Number.isFinite(value)) this.goToPage(value - 1)
}
})
this.searchInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
const q = this.searchInput.value.trim()
if (!q) return
if (q === this.searchQuery && this.searchHits.length) {
if (ev.shiftKey) this.prevHit()
else this.nextHit()
} else {
void this.runSearch(q)
}
} else if (ev.key === 'Escape') {
this.closeSearch()
}
})
this.onDocumentPointerDown = (ev) => {
if (!this.menu.classList.contains('open')) return
const target = ev.target as Element | null
if (target?.closest('[data-action="more"]')) return
if (!this.menu.contains(ev.target as Node)) this.closeMenu()
}
document.addEventListener('pointerdown', this.onDocumentPointerDown)
this.zoomLabel.textContent = '100%'
this.applyInvertUi()
this.updateMenuUi()
}
async init(): Promise<void> {
this.host.showLoader('正在打开文档…', this.fileName)
await this.waitForStage()
try {
this.doc = this.mupdf.Document.openDocument(this.bytes, this.magic)
await this.unlockIfNeeded()
this.reflowable = this.kind === 'epub' || this.kind === 'fb2'
try {
const reflow = (this.doc as { isReflowable?: () => unknown }).isReflowable?.()
if (reflow === true) this.reflowable = true
} catch {
/* ignore */
}
if (this.reflowable) this.layoutReflow()
this.pageCount = this.doc.countPages()
if (!this.pageCount) throw new Error('文档中没有可显示的页面')
this.measureFirstPage()
this.buildPages()
this.buildOutline()
this.pageTotal.textContent = `/ ${this.pageCount}`
this.pageInput.max = String(this.pageCount)
this.pageInput.value = '1'
this.zoom = this.computeFitWidth()
this.applyZoomLabel()
this.layoutPageSizes()
this.bindEvents()
this.observe()
this.scroller.scrollTop = 0
this.updateCurrentPage()
this.renderVisible()
this.host.setLoaderMessage('正在渲染页面…')
window.setTimeout(() => {
if (!this.firstRenderDone) this.host.hideLoader()
}, 4000)
} catch (error) {
this.host.hideLoader()
this.doc?.destroy?.()
this.doc = null
throw error
}
}
private buildHeaderActions(): void {
const searchBtn = iconButton('search', '查找', () => this.toggleSearch())
const outlineBtn = iconButton('list', '目录', () => this.openOutline())
const infoBtn = iconButton('info', '关于', () => showAbout())
this.host.actions.append(searchBtn, outlineBtn, infoBtn)
}
private buildMenu(): HTMLElement {
const menu = h('div', { class: 'doc-menu', role: 'menu' })
const add = (key: string, iconName: string, label: string, onClick: () => void) => {
const btn = h('button', { class: 'menu-item', type: 'button', role: 'menuitem' })
const iconEl = h('span', { class: 'menu-icon', html: icon(iconName, 18) })
const textEl = h('span', { class: 'menu-text', text: label })
btn.append(iconEl, textEl)
btn.addEventListener('click', () => {
this.closeMenu()
onClick()
})
menu.appendChild(btn)
this.menuButtons[key] = btn
return btn
}
add('fit-page', 'fit', '适合页面', () => this.fitPage())
add('rotate', 'rotate-cw', '旋转 90°', () => this.rotatePage())
add('invert', 'moon', '反色阅读', () => this.toggleInvert())
this.fullscreenBtn = add('fullscreen', 'maximize', '全屏', () => void this.toggleFullscreen())
add('about', 'info', '关于 EasyView', () => showAbout())
return menu
}
private updateMenuUi(): void {
const invertBtn = this.menuButtons.invert
if (invertBtn) {
const iconEl = invertBtn.querySelector('.menu-icon')
const textEl = invertBtn.querySelector('.menu-text')
if (iconEl) iconEl.innerHTML = icon(this.invert ? 'sun' : 'moon', 18)
if (textEl) textEl.textContent = this.invert ? '关闭反色' : '反色阅读'
invertBtn.classList.toggle('active', this.invert)
}
if (this.fullscreenBtn) {
const active = !!document.fullscreenElement
const iconEl = this.fullscreenBtn.querySelector('.menu-icon')
const textEl = this.fullscreenBtn.querySelector('.menu-text')
if (iconEl) iconEl.innerHTML = icon(active ? 'minimize' : 'maximize', 18)
if (textEl) textEl.textContent = active ? '退出全屏' : '全屏'
}
}
private waitForStage(): Promise<void> {
if (this.host.stage.clientWidth > 0 && this.host.stage.clientHeight > 0) return Promise.resolve()
return new Promise((resolve) => {
const ro = new ResizeObserver(() => {
if (this.host.stage.clientWidth > 0 && this.host.stage.clientHeight > 0) {
ro.disconnect()
resolve()
}
})
ro.observe(this.host.stage)
})
}
private async unlockIfNeeded(): Promise<void> {
let attempts = 0
while (this.doc.needsPassword()) {
if (attempts >= 4) throw new Error('密码尝试次数过多')
attempts++
const password = await this.promptPassword(attempts > 1)
if (password == null) throw new Error('文档已加密,需要密码才能打开')
try {
this.doc.authenticatePassword(password)
} catch {
/* ignore */
}
if (this.doc.needsPassword()) toast('密码不正确,请重试', 'error')
}
}
private promptPassword(retry: boolean): Promise<string | null> {
return new Promise((resolve) => {
let done = false
const finish = (value: string | null) => {
if (done) return
done = true
resolve(value)
modal.close()
}
const input = h('input', {
class: 'text-input',
type: 'password',
placeholder: '请输入文档密码',
autocomplete: 'current-password'
})
const form = h(
'form',
{ class: 'password-form' },
h('p', { class: 'modal-text', text: '此文档受密码保护,请输入密码继续。' }),
retry ? h('p', { class: 'form-error', text: '密码不正确,请重试。' }) : null,
input
)
form.addEventListener('submit', (ev) => {
ev.preventDefault()
finish(input.value)
})
const cancel = h('button', { class: 'btn btn-ghost', type: 'button', text: '取消' })
cancel.addEventListener('click', () => finish(null))
const unlock = h('button', { class: 'btn btn-primary', type: 'submit', text: '解锁' })
unlock.addEventListener('click', (ev) => {
ev.preventDefault()
finish(input.value)
})
const modal = openModal({
title: '加密文档',
subtitle: this.fileName,
content: form,
actions: [cancel, unlock],
size: 'sm',
onClose: () => {
if (!done) {
done = true
resolve(null)
}
}
})
window.setTimeout(() => input.focus({ preventScroll: true }), 80)
})
}
private layoutReflow(): void {
const width = (this.scroller.clientWidth - 32) * (72 / 96)
const height = Math.max(400, (this.scroller.clientHeight - 90) * (72 / 96))
this.lastReflowWidth = this.scroller.clientWidth
this.lastReflowHeight = this.scroller.clientHeight
try {
this.doc.layout(Math.max(200, width), Math.max(400, height), 12)
} catch {
/* ignore */
}
}
private measureFirstPage(): void {
const page = this.doc.loadPage(0)
try {
const b = page.getBounds()
const size = { w: Math.max(1, b[2] - b[0]), h: Math.max(1, b[3] - b[1]) }
this.defaultSize = size
this.pageSizes.set(0, size)
} finally {
page.destroy()
}
}
private buildPages(): void {
clear(this.pagesEl)
this.slots = []
this.visiblePages.clear()
this.pendingRenders.clear()
for (let i = 0; i < this.pageCount; i++) {
const canvas = h('canvas', { class: 'doc-page-canvas' })
const ctx = canvas.getContext('2d', { alpha: false })
if (!ctx) throw new Error('无法创建 Canvas 2D 上下文')
const el = h(
'div',
{
class: 'doc-page',
dataset: { page: String(i + 1) },
'aria-label': `第 ${i + 1} 页`
},
canvas
)
const slot: PageSlot = { index: i, el, canvas, ctx, renderedKey: '' }
this.slots.push(slot)
this.pagesEl.appendChild(el)
}
this.epoch++
}
private pageSize(index: number): Size {
return this.pageSizes.get(index) ?? this.defaultSize
}
private displaySize(index: number): Size {
const size = this.pageSize(index)
return this.rotate % 180 === 0 ? size : { w: size.h, h: size.w }
}
private applySlotSize(slot: PageSlot): void {
const size = this.displaySize(slot.index)
const w = Math.max(24, size.w * this.zoom)
const hgt = Math.max(24, size.h * this.zoom)
slot.el.style.width = `${w}px`
slot.el.style.height = `${hgt}px`
}
private layoutPageSizes(): void {
for (const slot of this.slots) this.applySlotSize(slot)
}
private computeFitWidth(): number {
const size = this.displaySize(this.currentPage)
const available = Math.max(120, this.scroller.clientWidth - 32)
return Math.max(0.03, Math.min(16, available / Math.max(1, size.w)))
}
private computeFitPage(): number {
const size = this.displaySize(this.currentPage)
const availableW = Math.max(120, this.scroller.clientWidth - 32)
const availableH = Math.max(180, this.scroller.clientHeight - 110)
return Math.max(0.03, Math.min(16, Math.min(availableW / size.w, availableH / size.h)))
}
private fitWidth(): void {
this.fitMode = 'width'
const z = this.computeFitWidth()
this.zoomTo(z)
this.scroller.scrollTop = Math.min(
this.scroller.scrollTop,
Math.max(0, this.scroller.scrollHeight - this.scroller.clientHeight)
)
}
private fitPage(): void {
this.fitMode = 'page'
this.zoomTo(this.computeFitPage())
}
private zoomTo(nextZoom: number, anchorY?: number, final = true, anchorX?: number): void {
const z = Math.max(0.02, Math.min(20, nextZoom))
if (Math.abs(z - this.zoom) / this.zoom < 1e-4) {
this.applyZoomLabel()
return
}
const old = this.zoom
const localY = anchorY ?? this.scroller.clientHeight / 2
const localX = anchorX ?? this.scroller.clientWidth / 2
const contentY = this.scroller.scrollTop + localY
const contentX = this.scroller.scrollLeft + localX
this.zoom = z
this.layoutPageSizes()
const ratio = z / old
const maxScroll = Math.max(0, this.scroller.scrollHeight - this.scroller.clientHeight)
const maxScrollX = Math.max(0, this.scroller.scrollWidth - this.scroller.clientWidth)
this.scroller.scrollTop = Math.max(0, Math.min(maxScroll, contentY * ratio - localY))
this.scroller.scrollLeft = Math.max(0, Math.min(maxScrollX, contentX * ratio - localX))
this.applyZoomLabel()
if (final) {
this.epoch++
this.renderVisible()
}
if (this.fitMode !== 'custom' && Math.abs(z - (this.fitMode === 'page' ? this.computeFitPage() : this.computeFitWidth())) > 0.001) {
this.fitMode = 'custom'
}
this.updateCurrentPage()
}
private applyZoomLabel(): void {
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`
}
private buildOutline(): void {
clear(this.outlineList)
let outline: OutlineItem[] | null = null
try {
outline = this.doc.loadOutline() as OutlineItem[] | null
} catch {
outline = null
}
if (!outline || outline.length === 0) {
this.outlineList.appendChild(
h('p', { class: 'outline-empty', text: '此文档没有书签或目录。' })
)
return
}
const addItems = (items: OutlineItem[], depth: number): void => {
if (depth > 8) return
for (const item of items) {
const title = (item.title || '').trim() || '(无标题)'
let page = typeof item.page === 'number' ? item.page : undefined
if (page == null && item.uri) {
try {
const resolved = this.doc.resolveLink(item.uri)
if (typeof resolved === 'number' && resolved >= 0) page = resolved
} catch {
/* ignore */
}
}
const row = h('button', {
class: `outline-item ${page != null ? '' : 'no-page'}`,
type: 'button'
})
row.style.paddingLeft = `${14 + depth * 16}px`
row.append(h('span', { class: 'outline-text', text: title }))
if (page != null) {
row.append(h('span', { class: 'outline-page', text: String(page + 1) }))
}
row.addEventListener('click', () => {
if (page != null) {
this.goToPage(page, false)
this.closeOutline()
}
})
this.outlineList.appendChild(row)
if (item.down && item.down.length) addItems(item.down, depth + 1)
}
}
addItems(outline, 0)
}
private bindEvents(): void {
this.scroller.addEventListener('scroll', this.onScroll, { passive: true })
this.scroller.addEventListener('dblclick', this.onDoubleClick)
this.scroller.addEventListener('wheel', this.onWheel, { passive: false })
this.scroller.addEventListener('touchstart', this.onTouchStart, { passive: false })
this.scroller.addEventListener('touchmove', this.onTouchMove, { passive: false })
this.scroller.addEventListener('touchend', this.onTouchEnd, { passive: true })
this.scroller.addEventListener('touchcancel', this.onTouchEnd, { passive: true })
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('fullscreenchange', this.onFullscreenChange)
window.addEventListener('resize', this.onWindowResize)
}
private observe(): void {
this.disconnectObservers()
this.resizeObserver = new ResizeObserver(() => {
if (this.destroyed) return
if (this.resizeTimer) window.clearTimeout(this.resizeTimer)
this.resizeTimer = window.setTimeout(() => this.handleResize(), 320)
})
this.resizeObserver.observe(this.host.stage)
this.intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.index)
if (!Number.isFinite(index)) continue
if (entry.isIntersecting) {
this.visiblePages.add(index)
this.queueRender(index)
} else {
this.visiblePages.delete(index)
}
}
this.trimCanvases()
},
{ root: this.scroller, rootMargin: '900px 0px', threshold: 0 }
)
for (const slot of this.slots) {
slot.el.dataset.index = String(slot.index)
this.intersectionObserver.observe(slot.el)
}
}
private disconnectObservers(): void {
this.intersectionObserver?.disconnect()
this.intersectionObserver = null
this.resizeObserver?.disconnect()
this.resizeObserver = null
}
private handleResize(): void {
if (this.destroyed) return
let reflowChanged = false
if (this.reflowable) {
const width = this.scroller.clientWidth
const height = this.scroller.clientHeight
const changed =
Math.abs(width - this.lastReflowWidth) > 2 || Math.abs(height - this.lastReflowHeight) > 2
reflowChanged = changed
if (changed) {
const page = this.currentPage
this.layoutReflow()
this.pageCount = this.doc.countPages()
this.pageTotal.textContent = `/ ${this.pageCount}`
this.pageInput.max = String(this.pageCount)
this.pageSizes.clear()
this.measureFirstPage()
this.buildPages()
this.buildOutline()
this.observe()
this.currentPage = Math.min(page, Math.max(0, this.pageCount - 1))
if (this.searchHits.length) {
this.searchHits = []
this.currentHit = -1
this.searchCount.textContent = ''
}
}
}
if (this.fitMode === 'width') this.zoom = this.computeFitWidth()
else if (this.fitMode === 'page') this.zoom = this.computeFitPage()
this.applyZoomLabel()
this.layoutPageSizes()
this.epoch++
this.renderVisible()
if (this.reflowable && reflowChanged && this.slots.length) this.goToPage(this.currentPage, false)
}
private onWindowResize = () => {
if (this.resizeTimer) window.clearTimeout(this.resizeTimer)
this.resizeTimer = window.setTimeout(() => this.handleResize(), 320)
}
private onScroll = () => {
if (this.scrollRaf) return
this.scrollRaf = requestAnimationFrame(() => {
this.scrollRaf = 0
this.updateCurrentPage()
this.renderNear()
})
}
private updateCurrentPage(): void {
if (!this.slots.length) return
const mid = this.scroller.scrollTop + this.scroller.clientHeight / 2
let best = this.currentPage
let bestDist = Infinity
for (const slot of this.slots) {
const center = slot.el.offsetTop + slot.el.offsetHeight / 2
const dist = Math.abs(center - mid)
if (dist < bestDist) {
bestDist = dist
best = slot.index
}
}
if (best !== this.currentPage) this.currentPage = best
if (document.activeElement !== this.pageInput) {
this.pageInput.value = String(this.currentPage + 1)
}
this.applySlotSize(this.slots[this.currentPage])
}
private goToPage(index: number, smooth = true): void {
const target = Math.max(0, Math.min(this.pageCount - 1, index))
this.currentPage = target
const slot = this.slots[target]
if (!slot) return
const top = Math.max(0, slot.el.offsetTop - 10)
this.scroller.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' })
this.pageInput.value = String(target + 1)
this.queueRender(target)
this.renderNear()
}
private renderNear(): void {
const top = this.scroller.scrollTop - 900
const bottom = this.scroller.scrollTop + this.scroller.clientHeight + 900
for (const slot of this.slots) {
const slotTop = slot.el.offsetTop
const slotBottom = slotTop + slot.el.offsetHeight
if (slotBottom >= top && slotTop <= bottom) this.queueRender(slot.index)
}
}
private renderVisible(): void {
this.renderNear()
}
private pageKey(): string {
return `${this.epoch}:${this.zoom.toFixed(3)}:${this.rotate}:${this.invert ? 1 : 0}`
}
private queueRender(index: number): void {
const slot = this.slots[index]
if (!slot || this.destroyed) return
const key = this.pageKey()
if (slot.renderedKey === key) return
if (this.pendingRenders.has(index)) return
this.pendingRenders.add(index)
this.renderQueue = this.renderQueue
.then(async () => {
if (this.destroyed) return
this.pendingRenders.delete(index)
await this.renderPage(index)
})
.catch((error: unknown) => {
this.pendingRenders.delete(index)
console.warn('page render failed', error)
if (!this.firstRenderDone) {
this.firstRenderDone = true
this.host.hideLoader()
toast('页面渲染失败,文档可能不受支持', 'error')
}
})
}
private async renderPage(index: number): Promise<void> {
const slot = this.slots[index]
if (!slot || this.destroyed) return
const key = this.pageKey()
if (slot.renderedKey === key) return
let page: any = null
let pixmap: any = null
try {
page = this.doc.loadPage(index)
const bounds = page.getBounds()
const bw = Math.max(1, bounds[2] - bounds[0])
const bh = Math.max(1, bounds[3] - bounds[1])
const size = { w: bw, h: bh }
const known = this.pageSizes.get(index)
if (!known || Math.abs(known.w - bw) > 0.5 || Math.abs(known.h - bh) > 0.5) {
this.pageSizes.set(index, size)
this.applySlotSize(slot)
}
const dpr = Math.min(window.devicePixelRatio || 1, 3)
let z = this.zoom * dpr
let pixels = bw * z * bh * z
const MAX_PIXELS = 6_000_000
if (pixels > MAX_PIXELS) {
z *= Math.sqrt(MAX_PIXELS / pixels)
pixels = bw * z * bh * z
}
let matrix = this.mupdf.Matrix.scale(z, z)
if (this.rotate) matrix = this.mupdf.Matrix.concat(matrix, this.mupdf.Matrix.rotate(this.rotate))
pixmap = page.toPixmap(matrix, this.mupdf.ColorSpace.DeviceRGB, false, true)
if (this.invert) pixmap.invert()
const pw = pixmap.getWidth()
const ph = pixmap.getHeight()
if (!pw || !ph) throw new Error('页面渲染为空')
slot.canvas.width = pw
slot.canvas.height = ph
const imageData = this.pixmapToImageData(pixmap, pw, ph)
slot.ctx.setTransform(1, 0, 0, 1, 0, 0)
slot.ctx.putImageData(imageData, 0, 0)
this.drawSearchHighlights(slot.ctx, matrix, pixmap, index)
slot.el.classList.add('rendered')
slot.renderedKey = key
if (!this.firstRenderDone) {
this.firstRenderDone = true
this.host.hideLoader()
}
this.trimCanvases()
} finally {
try {
pixmap?.destroy?.()
} catch {
/* ignore */
}
try {
page?.destroy?.()
} catch {
/* ignore */
}
}
}
private pixmapToImageData(pixmap: any, width: number, height: number): ImageData {
const components = pixmap.getNumberOfComponents()
const stride = pixmap.getStride()
const src: Uint8ClampedArray = pixmap.getPixels()
const image = new ImageData(width, height)
const dst = image.data
if (components === 4) {
for (let y = 0; y < height; y++) {
const start = y * stride
dst.set(src.subarray(start, start + width * 4), y * width * 4)
}
} else if (components === 3) {
for (let y = 0; y < height; y++) {
const start = y * stride
let d = y * width * 4
for (let x = 0; x < width; x++) {
const s = start + x * 3
dst[d++] = src[s]
dst[d++] = src[s + 1]
dst[d++] = src[s + 2]
dst[d++] = 255
}
}
} else if (components === 1) {
for (let y = 0; y < height; y++) {
const start = y * stride
let d = y * width * 4
for (let x = 0; x < width; x++) {
const v = src[start + x]
dst[d++] = v
dst[d++] = v
dst[d++] = v
dst[d++] = 255
}
}
} else {
throw new Error(`不支持的像素格式(${components} 通道)`)
}
return image
}
private drawSearchHighlights(
ctx: CanvasRenderingContext2D,
matrix: number[],
pixmap: any,
pageIndex: number
): void {
if (!this.searchHits.length) return
const hits = this.searchHits.filter((hit) => hit.page === pageIndex)
if (!hits.length) return
const originX = pixmap.getX?.() ?? 0
const originY = pixmap.getY?.() ?? 0
ctx.save()
for (const hit of hits) {
const active = hit.hitIndex === this.currentHit
ctx.fillStyle = active ? 'rgba(255,140,0,0.46)' : 'rgba(255,210,40,0.28)'
ctx.strokeStyle = active ? 'rgba(255,110,0,0.95)' : 'rgba(230,170,0,0.5)'
ctx.lineWidth = active ? 2 : 1
for (const quad of hit.quads) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (let i = 0; i + 1 < quad.length; i += 2) {
const x = quad[i]
const y = quad[i + 1]
const tx = matrix[0] * x + matrix[2] * y + matrix[4] - originX
const ty = matrix[1] * x + matrix[3] * y + matrix[5] - originY
if (tx < minX) minX = tx
if (ty < minY) minY = ty
if (tx > maxX) maxX = tx
if (ty > maxY) maxY = ty
}
if (!Number.isFinite(minX)) continue
const w = Math.max(2, maxX - minX)
const hgt = Math.max(2, maxY - minY)
ctx.fillRect(minX, minY, w, hgt)
ctx.strokeRect(minX + 0.5, minY + 0.5, w - 1, hgt - 1)
}
}
ctx.restore()
}
private trimCanvases(): void {
const keep = new Set<number>(this.visiblePages)
keep.add(this.currentPage)
const rendered = this.slots.filter((slot) => slot.renderedKey)
if (rendered.length <= 6) return
const removable = rendered
.filter((slot) => !keep.has(slot.index))
.sort((a, b) => Math.abs(b.index - this.currentPage) - Math.abs(a.index - this.currentPage))
while (rendered.length > 6 && removable.length) {
const slot = removable.shift()!
slot.canvas.width = 0
slot.canvas.height = 0
slot.renderedKey = ''
slot.el.classList.remove('rendered')
rendered.splice(rendered.indexOf(slot), 1)
}
}
private toggleSearch(): void {
if (this.searchVisible) {
this.closeSearch()
} else {
this.searchVisible = true
this.searchBar.classList.add('open')
window.setTimeout(() => this.searchInput.focus({ preventScroll: true }), 100)
this.searchInput.select()
}
}
private closeSearch(): void {
this.searchVisible = false
this.searchInput.blur()
this.searchBar.classList.remove('open')
this.host.stage.scrollTop = 0
this.host.stage.scrollLeft = 0
this.searchToken++
this.searchQuery = ''
this.searchHits = []
this.currentHit = -1
this.searchCount.textContent = ''
this.epoch++
this.renderVisible()
}
private async runSearch(query: string): Promise<void> {
const token = ++this.searchToken
this.searchVisible = true
this.searchBar.classList.add('open')
this.searchQuery = query
this.searchHits = []
this.currentHit = -1
this.searchCount.textContent = '搜索中…'
const startPage = this.currentPage
let total = 0
for (let pass = 0; pass < 2; pass++) {
const begin = pass === 0 ? startPage : 0
const end = pass === 0 ? this.pageCount : startPage
for (let i = begin; i < end; i++) {
if (token !== this.searchToken || this.destroyed) return
let page: any = null
try {
page = this.doc.loadPage(i)
const results = page.search(query, {}) as number[][][]
let pageHit = 0
for (const quads of results) {
if (!Array.isArray(quads) || !quads.length) continue
const flat: number[][] = []
for (const quad of quads) {
if (Array.isArray(quad) && quad.length >= 4) flat.push(quad as number[])
}
if (!flat.length) continue
this.searchHits.push({ hitIndex: total, page: i, quads: flat })
total++
pageHit++
if (total >= 4000) break
}
if (pageHit && i % 5 === 0) {
this.searchCount.textContent = `已找到 ${total} 处…`
}
} catch {
/* skip page */
} finally {
try {
page?.destroy?.()
} catch {
/* ignore */
}
}
if (total >= 4000) break
if (i % 4 === 3) await new Promise((resolve) => setTimeout(resolve, 0))
}
if (total >= 4000) break
if (this.searchHits.length) break
}
if (token !== this.searchToken) return
if (!this.searchHits.length) {
this.searchCount.textContent = '无结果'
this.epoch++
this.renderVisible()
return
}
this.currentHit = 0
this.updateSearchCount()
this.epoch++
this.gotoHit(0)
}
private updateSearchCount(): void {
if (!this.searchHits.length) {
this.searchCount.textContent = this.searchQuery ? '无结果' : ''
return
}
this.searchCount.textContent = `${this.currentHit + 1} / ${this.searchHits.length}`
}
private gotoHit(index: number): void {
if (!this.searchHits.length) return
const clamped = ((index % this.searchHits.length) + this.searchHits.length) % this.searchHits.length
this.currentHit = clamped
const hit = this.searchHits[clamped]
this.updateSearchCount()
this.epoch++
const target = this.slots[hit.page]
if (target) {
const top = Math.max(0, target.el.offsetTop - 10)
this.scroller.scrollTo({ top, behavior: 'auto' })
this.currentPage = hit.page
this.pageInput.value = String(hit.page + 1)
}
this.renderVisible()
}
private nextHit(): void {
if (!this.searchHits.length) {
if (this.searchInput.value.trim()) void this.runSearch(this.searchInput.value.trim())
return
}
this.gotoHit(this.currentHit + 1)
}
private prevHit(): void {
if (!this.searchHits.length) return
this.gotoHit(this.currentHit - 1)
}
private openOutline(): void {
this.closeMenu()
this.outlineBackdrop.classList.add('open')
}
private closeOutline(): void {
this.outlineBackdrop.classList.remove('open')
}
private toggleMenu(): void {
this.menu.classList.toggle('open')
}
private closeMenu(): void {
this.menu.classList.remove('open')
}
private rotatePage(): void {
this.rotate = (this.rotate + 90) % 360
if (this.fitMode === 'width') this.zoom = this.computeFitWidth()
else if (this.fitMode === 'page') this.zoom = this.computeFitPage()
this.applyZoomLabel()
this.layoutPageSizes()
this.epoch++
this.renderVisible()
toast('已旋转 90°', 'info', 1500)
}
private toggleInvert(): void {
this.invert = !this.invert
try {
localStorage.setItem(INVERT_KEY, this.invert ? '1' : '0')
} catch {
/* ignore */
}
this.pagesEl.classList.toggle('inverted', this.invert)
this.applyInvertUi()
this.epoch++
this.renderVisible()
}
private applyInvertUi(): void {
this.updateMenuUi()
}
private async toggleFullscreen(): Promise<void> {
try {
if (!document.fullscreenElement) await this.host.root.requestFullscreen?.()
else await document.exitFullscreen?.()
} catch {
toast('当前浏览器不支持全屏', 'error')
}
}
private onFullscreenChange = () => {
this.updateMenuUi()
window.setTimeout(() => this.handleResize(), 100)
}
private onDoubleClick = (ev: MouseEvent) => {
const rect = this.scroller.getBoundingClientRect()
const y = ev.clientY - rect.top
const x = ev.clientX - rect.left
const fit = this.computeFitWidth()
if (this.zoom < fit * 1.8) this.zoomTo(fit * 2, y, true, x)
else this.fitWidth()
}
private onWheel = (ev: WheelEvent) => {
if (!ev.ctrlKey && !ev.metaKey) return
ev.preventDefault()
const rect = this.scroller.getBoundingClientRect()
const localY = ev.clientY - rect.top
const localX = ev.clientX - rect.left
const factor = Math.exp(-ev.deltaY * 0.002)
this.zoomTo(this.zoom * factor, localY, true, localX)
}
private onTouchStart = (ev: TouchEvent) => {
if (ev.touches.length !== 2) return
ev.preventDefault()
const [a, b] = [ev.touches[0], ev.touches[1]]
const rect = this.scroller.getBoundingClientRect()
const midY = (a.clientY + b.clientY) / 2 - rect.top
const midX = (a.clientX + b.clientX) / 2 - rect.left
this.pinch = {
startDist: Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)),
startZoom: this.zoom,
startScrollTop: this.scroller.scrollTop,
startScrollLeft: this.scroller.scrollLeft,
midX,
midY
}
}
private onTouchMove = (ev: TouchEvent) => {
if (!this.pinch || ev.touches.length !== 2) return
ev.preventDefault()
const [a, b] = [ev.touches[0], ev.touches[1]]
const rect = this.scroller.getBoundingClientRect()
const dist = Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY))
const midY = (a.clientY + b.clientY) / 2 - rect.top
const midX = (a.clientX + b.clientX) / 2 - rect.left
const next = Math.max(0.02, Math.min(20, this.pinch.startZoom * (dist / this.pinch.startDist)))
const ratio = next / this.pinch.startZoom
const contentY = this.pinch.startScrollTop + this.pinch.midY
const contentX = this.pinch.startScrollLeft + this.pinch.midX
this.zoom = next
this.layoutPageSizes()
const maxScroll = Math.max(0, this.scroller.scrollHeight - this.scroller.clientHeight)
const maxScrollX = Math.max(0, this.scroller.scrollWidth - this.scroller.clientWidth)
this.scroller.scrollTop = Math.max(0, Math.min(maxScroll, contentY * ratio - midY))
this.scroller.scrollLeft = Math.max(0, Math.min(maxScrollX, contentX * ratio - midX))
this.applyZoomLabel()
this.fitMode = 'custom'
}
private onTouchEnd = (ev: TouchEvent) => {
if (!this.pinch) return
if (ev.touches.length < 2) {
this.pinch = null
this.epoch++
this.renderVisible()
this.updateCurrentPage()
}
}
private onKeyDown = (ev: KeyboardEvent) => {
const active = document.activeElement
if (active && /^(input|textarea|select)$/i.test(active.tagName)) {
if (ev.key === 'Escape') (active as HTMLElement).blur()
return
}
switch (ev.key) {
case '+':
case '=':
ev.preventDefault()
this.zoomTo(this.zoom * 1.2)
break
case '-':
case '_':
ev.preventDefault()
this.zoomTo(this.zoom / 1.2)
break
case '0':
ev.preventDefault()
this.fitWidth()
break
case '/':
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault()
this.toggleSearch()
}
break
case 'f':
case 'F':
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault()
this.toggleSearch()
}
break
case 'Escape':
if (this.outlineBackdrop.classList.contains('open')) this.closeOutline()
else if (this.menu.classList.contains('open')) this.closeMenu()
else if (this.searchVisible) this.closeSearch()
break
default:
break
}
}
destroy(): void {
if (this.destroyed) return
this.destroyed = true
this.searchToken++
this.disconnectObservers()
if (this.resizeTimer) window.clearTimeout(this.resizeTimer)
if (this.scrollRaf) cancelAnimationFrame(this.scrollRaf)
this.scroller.removeEventListener('scroll', this.onScroll)
this.scroller.removeEventListener('dblclick', this.onDoubleClick)
this.scroller.removeEventListener('wheel', this.onWheel)
this.scroller.removeEventListener('touchstart', this.onTouchStart)
this.scroller.removeEventListener('touchmove', this.onTouchMove)
this.scroller.removeEventListener('touchend', this.onTouchEnd)
this.scroller.removeEventListener('touchcancel', this.onTouchEnd)
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('fullscreenchange', this.onFullscreenChange)
window.removeEventListener('resize', this.onWindowResize)
if (this.onDocumentPointerDown) document.removeEventListener('pointerdown', this.onDocumentPointerDown)
for (const slot of this.slots) {
slot.canvas.width = 0
slot.canvas.height = 0
}
try {
this.doc?.destroy?.()
} catch {
/* ignore */
}
this.doc = null
this.host.destroy()
}
}