f70913481e
- 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
1266 lines
42 KiB
TypeScript
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()
|
|
}
|
|
}
|