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 as unknown as { __phoneViewerBack?: () => boolean }).__phoneViewerBack = () => { const modalClose = document.querySelector('.modal-backdrop.show .modal-close') if (modalClose) { modalClose.click() return true } const sheet = document.querySelector('.sheet-backdrop.open') if (sheet) { sheet.classList.remove('open') return true } const menu = document.querySelector('.doc-menu.open') if (menu) { menu.classList.remove('open') return true } const searchClose = document.querySelector( '.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 { recentItems = await listRecent() renderRecent() } function renderRecent(): void { if (!recentWrap) return clear(recentWrap) const clearBtn = recentSection.querySelector('.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 { 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 { await deleteRecent(item.id) await refreshRecent() toast('已从最近记录移除', 'success', 1600) } async function clearAllRecent(): Promise { const ok = await confirmDialog('清空最近记录?', '所有最近打开的文件内容将从本机浏览器中删除。', '清空', { danger: true }) if (!ok) return await clearRecent() await refreshRecent() toast('最近记录已清空', 'success', 1600) } async function openFile(file: File): Promise { 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((resolve) => requestAnimationFrame(() => resolve())) let createdViewer: ActiveViewer | null = null try { if (target === 'cad') { const { FlyfishCadViewer } = await import('../cad/flyfishViewer') const viewer = new FlyfishCadViewer(host!, file, { onBack: () => closeActiveViewer() }) createdViewer = viewer await viewer.load() if (token !== openToken) { viewer.destroy() return } activeViewer = viewer host?.hideLoader() } 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 }> }) => 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')) }) }