Files
PhoneViewer/src/core/app.ts
T

409 lines
12 KiB
TypeScript
Raw Normal View History

2026-10-26 17:44:44 +08:00
import { clear, h, icon, LOGO_SVG } from './dom'
import { initTheme } from './theme'
import {
formatBytes,
formatTimeAgo,
detectKind,
kindMeta,
mimeForKind,
readHead,
SUPPORTED_ACCEPT,
viewerFor,
type FileKind
} from './files'
import { clearRecent, deleteRecent, listRecent, loadRecentFile, saveRecent, type RecentItem } from './recent'
import { createViewerHost, type ViewerHost } from './viewerHost'
import { toast } from './toast'
import { confirmDialog, modalMessage } from '../ui/modal'
import { showGuide } from '../ui/guide'
interface ActiveViewer {
destroy: () => void
}
let host: ViewerHost | null = null
let activeViewer: ActiveViewer | null = null
let busy = false
let recentItems: RecentItem[] = []
let dropDepth = 0
let openToken = 0
let homeEl: HTMLElement
let viewerRoot: HTMLElement
let recentWrap: HTMLElement
let recentSection: HTMLElement
let fileInput: HTMLInputElement
let dropOverlay: HTMLElement
export function initApp(): void {
initTheme()
const app = document.getElementById('app')
if (!app) throw new Error('app root missing')
homeEl = buildHome()
viewerRoot = h('div', { class: 'viewer-root', hidden: true })
app.append(homeEl, viewerRoot)
fileInput = h('input', {
type: 'file',
accept: SUPPORTED_ACCEPT,
class: 'hidden-input',
'aria-hidden': 'true',
tabindex: '-1'
})
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0]
fileInput.value = ''
if (file) void openFile(file)
})
document.body.appendChild(fileInput)
dropOverlay = h(
'div',
{ class: 'drop-overlay' },
h('div', { class: 'drop-box' }, h('span', { class: 'drop-icon', html: icon('folder-open', 40) }), h('strong', { text: '松开以打开文件' }), h('span', { text: 'DWG · PDF · EPUB · XPS · CBZ · 图片' }))
)
document.body.appendChild(dropOverlay)
bindGlobalEvents()
void refreshRecent()
handleLaunchQueue()
window.setTimeout(() => {
void import('../cad/loader')
.then((module) => module.preloadDwgEngine())
.catch(() => {})
}, 300)
;(window as unknown as { __phoneViewerBack?: () => boolean }).__phoneViewerBack = () => {
const modalClose = document.querySelector<HTMLButtonElement>('.modal-backdrop.show .modal-close')
if (modalClose) {
modalClose.click()
return true
}
const sheet = document.querySelector<HTMLElement>('.sheet-backdrop.open')
if (sheet) {
sheet.classList.remove('open')
return true
}
const menu = document.querySelector<HTMLElement>('.doc-menu.open')
if (menu) {
menu.classList.remove('open')
return true
}
const searchClose = document.querySelector<HTMLButtonElement>(
'.doc-search button[aria-label="关闭查找"]'
)
if (searchClose) {
searchClose.click()
return true
}
if (activeViewer) {
closeActiveViewer()
return true
}
return false
}
;(window as unknown as { __phoneViewerOpenFile?: (file: File) => void }).__phoneViewerOpenFile = (
file: File
) => {
if (file) void openFile(file)
}
}
function buildHome(): HTMLElement {
const guideBtn = h(
'button',
{ class: 'guide-btn', type: 'button', 'aria-label': '使用指南' },
h('span', { html: icon('book-open', 18) }),
h('span', { text: '使用指南' })
)
guideBtn.addEventListener('click', () => showGuide())
const brand = h(
'div',
{ class: 'home-brand' },
h('div', { class: 'brand-mark', html: LOGO_SVG }),
h('div', { class: 'brand-text' }, h('div', { class: 'brand-name', text: '万能看图' }))
)
const top = h(
'header',
{ class: 'home-top simple-top' },
brand,
h('div', { class: 'home-actions' }, guideBtn)
)
const openBtn = h(
'button',
{ class: 'open-file-card', type: 'button' },
h('span', { class: 'open-file-icon', html: icon('folder-open', 24) }),
h('span', { class: 'open-file-text' }, h('strong', { text: '打开图纸' })),
h('span', { class: 'open-file-arrow', html: icon('chevron-right', 20) })
)
openBtn.addEventListener('click', () => fileInput.click())
recentWrap = h('div', { class: 'recent-grid simple-recent-grid' })
const clearBtn = h('button', {
class: 'btn btn-ghost btn-sm recent-clear',
type: 'button',
text: '清空记录',
hidden: true
})
clearBtn.addEventListener('click', () => void clearAllRecent())
recentSection = h(
'section',
{ class: 'recent-section simple-recent' },
h('div', { class: 'section-head' }, h('h2', { text: '最近打开' }), clearBtn),
recentWrap
)
return h(
'div',
{ class: 'home home-simple' },
h('div', { class: 'home-inner simple-inner' }, top, openBtn, recentSection)
)
}
async function refreshRecent(): Promise<void> {
recentItems = await listRecent()
renderRecent()
}
function renderRecent(): void {
if (!recentWrap) return
clear(recentWrap)
const clearBtn = recentSection.querySelector<HTMLButtonElement>('.recent-clear')
if (clearBtn) clearBtn.hidden = recentItems.length === 0
if (!recentItems.length) {
recentWrap.appendChild(
h(
'div',
{ class: 'recent-empty' },
h('span', { class: 'recent-empty-icon', html: icon('file', 26) }),
h('strong', { text: '还没有最近文件' }),
h('span', { text: '打开过的文件会显示在这里。' })
)
)
return
}
for (const item of recentItems) {
const meta = kindMeta(item.kind)
const card = h('div', {
class: 'recent-card',
role: 'button',
tabindex: '0',
'aria-label': `打开 ${item.name}`
})
const iconEl = h('span', { class: `recent-icon tone-${meta.tone}`, html: icon(meta.icon, 22) })
const info = h(
'span',
{ class: 'recent-info' },
h('span', { class: 'recent-name', text: item.name, title: item.name }),
h('span', {
class: 'recent-meta',
text: `${meta.label} · ${formatBytes(item.size)} · ${formatTimeAgo(item.lastOpened)}${item.hasBlob ? '' : ' · 内容未保存'}`
})
)
const del = h('button', { class: 'recent-del', type: 'button', 'aria-label': `删除 ${item.name}` })
del.innerHTML = icon('trash', 16)
del.addEventListener('click', (ev) => {
ev.stopPropagation()
void removeRecent(item)
})
card.append(iconEl, info, del)
const activate = () => void openRecent(item)
card.addEventListener('click', activate)
card.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault()
activate()
}
})
recentWrap.appendChild(card)
}
}
async function openRecent(item: RecentItem): Promise<void> {
const file = await loadRecentFile(item.id, item.name)
if (!file) {
toast('文件内容没有被浏览器保存,请重新选择文件', 'info', 3800)
fileInput.click()
return
}
await openFile(file)
}
async function removeRecent(item: RecentItem): Promise<void> {
await deleteRecent(item.id)
await refreshRecent()
toast('已从最近记录移除', 'success', 1600)
}
async function clearAllRecent(): Promise<void> {
const ok = await confirmDialog('清空最近记录?', '所有最近打开的文件内容将从本机浏览器中删除。', '清空', {
danger: true
})
if (!ok) return
await clearRecent()
await refreshRecent()
toast('最近记录已清空', 'success', 1600)
}
async function openFile(file: File): Promise<void> {
if (busy) {
toast('正在打开文件,请稍候…', 'info', 1600)
return
}
if (!file.size) {
toast('文件为空', 'error')
return
}
let kind: FileKind
try {
const head = await readHead(file)
kind = detectKind(file.name, file.type, head)
} catch {
kind = detectKind(file.name, file.type)
}
const target = viewerFor(kind)
if (!target) {
toast('暂不支持这种文件类型', 'error', 3200)
return
}
busy = true
closeActiveViewer()
const token = ++openToken
host = createViewerHost(() => closeActiveViewer())
host.setFileInfo(file.name, kindMeta(kind).label, kindMeta(kind).short, kindMeta(kind).tone, formatBytes(file.size))
host.showLoader('正在准备…', file.name)
viewerRoot.appendChild(host.root)
viewerRoot.hidden = false
homeEl.hidden = true
document.title = `${file.name} · 万能看图`
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
let createdViewer: ActiveViewer | null = null
try {
if (target === 'cad') {
const { loadDwg } = await import('../cad/loader')
const doc = await loadDwg(file, (message) => host?.setLoaderMessage(message, file.name))
if (token !== openToken) return
const { CadViewer } = await import('../cad/cadViewer')
createdViewer = new CadViewer(host!, doc, { onBack: () => closeActiveViewer() })
activeViewer = createdViewer
host?.hideLoader()
if (doc.warnings.length) toast(doc.warnings[0], 'info', 5000)
} else {
const [{ loadMuPDF }, { DocViewer }] = await Promise.all([
import('../doc/mupdfLoader'),
import('../doc/docViewer')
])
const mupdf = await loadMuPDF()
host?.setLoaderMessage('正在读取文件…', file.name)
const bytes = new Uint8Array(await file.arrayBuffer())
const magic =
kind === 'xps' && /\.oxps$/i.test(file.name)
? 'application/oxps'
: mimeForKind(kind, file.type)
const viewer = new DocViewer(host!, mupdf, bytes, file.name, file.size, kind, magic)
await viewer.init()
if (token !== openToken) {
viewer.destroy()
return
}
createdViewer = viewer
activeViewer = viewer
}
void saveRecent(file, kind)
.catch(() => {})
.then(() => refreshRecent())
.catch(() => {})
} catch (error) {
if (createdViewer) {
createdViewer.destroy()
} else {
host?.destroy()
}
host = null
activeViewer = null
viewerRoot.hidden = true
homeEl.hidden = false
document.title = '万能看图 · DWG/PDF 浏览器'
const message = error instanceof Error ? error.message : String(error)
console.error(error)
modalMessage(
'无法打开文件',
message,
kind === 'dwg' ? '提示:DWG 需要由 AutoCAD 2000 及之后的版本保存;过旧或加密的图纸可能暂时无法解析。' : undefined
)
} finally {
busy = false
}
}
function closeActiveViewer(): void {
openToken++
const viewer = activeViewer
const previousHost = host
activeViewer = null
host = null
if (viewer) {
viewer.destroy()
} else if (previousHost) {
previousHost.destroy()
}
viewerRoot.innerHTML = ''
viewerRoot.hidden = true
homeEl.hidden = false
document.title = '万能看图 · DWG/PDF 浏览器'
void refreshRecent()
}
function bindGlobalEvents(): void {
window.addEventListener('dragenter', (ev) => {
if (!ev.dataTransfer?.types.includes('Files')) return
ev.preventDefault()
dropDepth++
dropOverlay.classList.add('show')
})
window.addEventListener('dragover', (ev) => {
if (!ev.dataTransfer?.types.includes('Files')) return
ev.preventDefault()
ev.dataTransfer.dropEffect = 'copy'
})
window.addEventListener('dragleave', (ev) => {
if (!ev.dataTransfer?.types.includes('Files')) return
dropDepth = Math.max(0, dropDepth - 1)
if (dropDepth === 0) dropOverlay.classList.remove('show')
})
window.addEventListener('drop', (ev) => {
if (!ev.dataTransfer?.files?.length) return
ev.preventDefault()
dropDepth = 0
dropOverlay.classList.remove('show')
const file = ev.dataTransfer.files[0]
if (file) void openFile(file)
})
window.addEventListener('paste', (ev) => {
const file = ev.clipboardData?.files?.[0]
if (file) {
ev.preventDefault()
void openFile(file)
}
})
}
function handleLaunchQueue(): void {
const launchQueue = (window as unknown as { launchQueue?: { setConsumer: (cb: (params: { files: Array<{ getFile: () => Promise<File> }> }) => void) => void } }).launchQueue
if (!launchQueue) return
launchQueue.setConsumer((params) => {
const first = params.files?.[0]
if (!first) return
void first
.getFile()
.then((file) => openFile(file))
.catch(() => toast('无法读取系统传入的文件', 'error'))
})
}