first commit
This commit is contained in:
+408
@@ -0,0 +1,408 @@
|
||||
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'))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user