Fix blank drawings, drop parse-time animation cost, add render diagnostics
Accuracy / completeness - docViews: split parsed scene into model space / paper space / free-standing blocks (geometry hidden in uninserted blocks no longer shows as a blank canvas); auto-select the first block view when model space is empty - viewer: view switcher panel listing every detected view with expanded entity counts; paper space is now parsed (includePaperSpace) and kept apart - SHX: missing .shx references are surfaced with an import control (addReferenceFile), linetypes/text redraw afterwards - unsupported/proxy entities: markers on canvas + diagnostics block showing proxy / wipeout counts and missing SHX names Speed - the loading overlay used an infinite CSS spinner + backdrop blur while the worker parsed; on software rendering that starved the parse thread (TELECOM 18MB: 71s -> 30s wall clock, normalize 33s -> 14s). Replaced with a determinate progress bar fed by loader progress events - transferInputBuffer for worker loads, maxInsertDepth 32, longer worker timeout Tooling - scripts/probe-dwg.mjs / probe-app.mjs: headless render + end-to-end probes (entity/layer/block/layout/proxy stats, timings, screenshots) - tools/render-probe.html, scripts/profile-worker.mjs - version 0.6.0 (versionCode 8)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import type { CadDocument, CadEntity } from '@flyfish-dev/cad-viewer'
|
||||
|
||||
export interface CadView {
|
||||
id: string
|
||||
/** 面板里显示的名字 */
|
||||
name: string
|
||||
/** 面板里的次要说明,如“12,340 实体” */
|
||||
hint: string
|
||||
/** 该视图要渲染的实体集合 */
|
||||
entities: CadEntity[]
|
||||
kind: 'model' | 'paper' | 'block'
|
||||
}
|
||||
|
||||
const SPACE_BLOCK = /^\*?(model|paper)_space/i
|
||||
const MAX_BLOCK_VIEWS = 40
|
||||
|
||||
function blockNameOf(entity: CadEntity): string | undefined {
|
||||
if (entity.type !== 'INSERT' && entity.kind !== 'insert') return undefined
|
||||
const record = entity as unknown as { blockName?: string; effectiveBlockName?: string; name?: string }
|
||||
const name = record.effectiveBlockName || record.blockName || record.name
|
||||
return typeof name === 'string' && name.trim() ? name.trim().toLowerCase() : undefined
|
||||
}
|
||||
|
||||
function childNames(entities: CadEntity[]): string[] {
|
||||
const names = new Set<string>()
|
||||
for (const entity of entities) {
|
||||
const name = blockNameOf(entity)
|
||||
if (name) names.add(name)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析结果拆成可切换的视图:
|
||||
* - 模型空间 / 布局空间(按 isInPaperSpace 拆分)
|
||||
* - 顶层“游离块”:既没被模型/布局空间引用、也没被其它块引用的块。
|
||||
* 很多图纸(尤其带“图纸视图”的施工图)几何全放在这种块里,不列出来就是一片空白。
|
||||
*/
|
||||
export function buildCadViews(doc: CadDocument): CadView[] {
|
||||
const views: CadView[] = []
|
||||
const model = doc.entities.filter((entity) => entity.isInPaperSpace !== true)
|
||||
const paper = doc.entities.filter((entity) => entity.isInPaperSpace === true)
|
||||
|
||||
if (model.length) {
|
||||
views.push({
|
||||
id: 'model',
|
||||
name: '模型空间',
|
||||
hint: `${model.length.toLocaleString()} 实体`,
|
||||
entities: model,
|
||||
kind: 'model'
|
||||
})
|
||||
}
|
||||
if (paper.length) {
|
||||
views.push({
|
||||
id: 'paper',
|
||||
name: '布局空间',
|
||||
hint: `${paper.length.toLocaleString()} 实体`,
|
||||
entities: paper,
|
||||
kind: 'paper'
|
||||
})
|
||||
}
|
||||
|
||||
// 同名块常因大小写各存一份,按小写名去重并保留实体更多的那份
|
||||
const byName = new Map<string, { name: string; entities: CadEntity[] }>()
|
||||
for (const block of Object.values(doc.blocks || {})) {
|
||||
const name = String(block.name ?? '').trim()
|
||||
if (!name) continue
|
||||
const key = name.toLowerCase()
|
||||
const entities = block.entities || []
|
||||
const existing = byName.get(key)
|
||||
if (!existing || entities.length > existing.entities.length) byName.set(key, { name, entities })
|
||||
}
|
||||
|
||||
const childRefs = new Map<string, string[]>()
|
||||
const referencedByAnyBlock = new Set<string>()
|
||||
for (const [key, block] of byName) {
|
||||
const children = childNames(block.entities)
|
||||
childRefs.set(key, children)
|
||||
for (const child of children) referencedByAnyBlock.add(child)
|
||||
}
|
||||
const sceneRefs = new Set(childNames([...model, ...paper]))
|
||||
|
||||
const isFreeStanding = (key: string): boolean =>
|
||||
!sceneRefs.has(key) && !referencedByAnyBlock.has(key) && !SPACE_BLOCK.test(byName.get(key)!.name)
|
||||
|
||||
let roots = [...byName.keys()].filter((key) => byName.get(key)!.entities.length > 0 && isFreeStanding(key))
|
||||
|
||||
// 没有“完全游离”的块时,退而求其次:只要没被模型/布局空间直接引用的块都算候选
|
||||
if (!roots.length) {
|
||||
roots = [...byName.keys()].filter(
|
||||
(key) => byName.get(key)!.entities.length > 0 && !sceneRefs.has(key) && !SPACE_BLOCK.test(byName.get(key)!.name)
|
||||
)
|
||||
}
|
||||
|
||||
// 递归统计展开后的实体总数(含嵌套块),带环保护
|
||||
const expanded = (key: string, stack: Set<string>): number => {
|
||||
const block = byName.get(key)
|
||||
if (!block || stack.has(key)) return 0
|
||||
stack.add(key)
|
||||
let total = block.entities.length
|
||||
for (const child of childRefs.get(key) ?? []) total += expanded(child, stack)
|
||||
stack.delete(key)
|
||||
return total
|
||||
}
|
||||
|
||||
const ranked = roots
|
||||
.map((key) => {
|
||||
const block = byName.get(key)!
|
||||
const total = expanded(key, new Set())
|
||||
return { key, block, total }
|
||||
})
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, MAX_BLOCK_VIEWS)
|
||||
|
||||
for (const [index, entry] of ranked.entries()) {
|
||||
const nested = (childRefs.get(entry.key) ?? []).length
|
||||
views.push({
|
||||
id: `block:${index}`,
|
||||
name: entry.block.name,
|
||||
hint: `${entry.total.toLocaleString()} 实体${nested ? ` · 含 ${nested} 个子块` : ''}(图纸里的游离块)`,
|
||||
entities: entry.block.entities,
|
||||
kind: 'block'
|
||||
})
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
/** 统计代理对象 / 遮挡区域,供界面提示 */
|
||||
export function summarizeEntities(entities: CadEntity[]): {
|
||||
proxies: number
|
||||
wipeouts: number
|
||||
total: number
|
||||
} {
|
||||
let proxies = 0
|
||||
let wipeouts = 0
|
||||
for (const entity of entities) {
|
||||
const type = String(entity.type || '').toUpperCase()
|
||||
if (type.includes('PROXY')) proxies += 1
|
||||
else if (type === 'WIPEOUT') wipeouts += 1
|
||||
}
|
||||
return { proxies, wipeouts, total: entities.length }
|
||||
}
|
||||
+161
-12
@@ -1,8 +1,9 @@
|
||||
import './polyfills'
|
||||
import {
|
||||
CadViewer,
|
||||
type CadViewerLoadResult,
|
||||
type CadDocument as FlyCadDocument
|
||||
type CadDocument as FlyCadDocument,
|
||||
type CadEntity,
|
||||
type CadViewerLoadResult
|
||||
} from '@flyfish-dev/cad-viewer'
|
||||
import '@flyfish-dev/cad-viewer/styles.css'
|
||||
import { clear, h, icon, iconButton } from '../core/dom'
|
||||
@@ -11,6 +12,7 @@ import { getTheme, onThemeChange, toggleTheme } from '../core/theme'
|
||||
import { toast } from '../core/toast'
|
||||
import type { ViewerHost } from '../core/viewerHost'
|
||||
import { showAbout } from '../ui/about'
|
||||
import { buildCadViews, summarizeEntities, type CadView } from './docViews'
|
||||
|
||||
interface Callbacks {
|
||||
onBack: () => void
|
||||
@@ -33,6 +35,10 @@ export class FlyfishCadViewer {
|
||||
private themeBtn!: HTMLButtonElement
|
||||
private fullscreenBtn!: HTMLButtonElement
|
||||
private result: CadViewerLoadResult | null = null
|
||||
private views: CadView[] = []
|
||||
private activeViewId = 'model'
|
||||
private viewListEl: HTMLElement | null = null
|
||||
private diagEl: HTMLElement | null = null
|
||||
private themeUnsub: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private destroyed = false
|
||||
@@ -54,8 +60,14 @@ export class FlyfishCadViewer {
|
||||
workerUrl: new URL('wasm/dwg-worker.js', document.baseURI).href,
|
||||
dwfWasmUrl: new URL('wasm/dwfv-render.wasm', document.baseURI).href,
|
||||
useWorker: true,
|
||||
workerTimeoutMs: 120000,
|
||||
workerTimeoutMs: 180000,
|
||||
// File → worker 直接转移 ArrayBuffer,省掉一次几十 MB 的拷贝
|
||||
transferInputBuffer: true,
|
||||
maxInsertDepth: 32,
|
||||
autoFit: true,
|
||||
// 布局空间实体默认被丢弃,这里取回后在“布局空间”视图里单独渲染
|
||||
includePaperSpace: true,
|
||||
// File → worker 直接转移 ArrayBuffer,省掉一次 23MB 级别的内存拷贝
|
||||
canvasOptions: {
|
||||
fitMode: 'auto',
|
||||
background: '#0d1117',
|
||||
@@ -64,10 +76,15 @@ export class FlyfishCadViewer {
|
||||
enableSpatialIndex: true,
|
||||
spatialIndexCellCount: 96,
|
||||
maxCurveSegments: 72,
|
||||
maxVisibleTextLabels: 2400
|
||||
maxVisibleTextLabels: 2400,
|
||||
// 代理对象/不支持的实体画成方框,避免静默消失
|
||||
showUnsupportedMarkers: true
|
||||
},
|
||||
onLoadProgress: (progress) => {
|
||||
this.host.setLoaderMessage(progress.message || '正在解析 DWG 图纸…', this.file.name)
|
||||
this.host.setLoaderMessage(progress.message || '正在解析 DWG 图纸…', this.file.name, progress.percent)
|
||||
const w = window as unknown as { __progress?: Array<{ ms: number; phase: string }> }
|
||||
if (!w.__progress) w.__progress = []
|
||||
w.__progress.push({ ms: Math.round(performance.now() - ((window as unknown as { __openAt?: number }).__openAt ?? performance.now())), phase: progress.phase })
|
||||
},
|
||||
onViewChange: () => this.updateStatus(),
|
||||
onError: (error) => {
|
||||
@@ -77,6 +94,8 @@ export class FlyfishCadViewer {
|
||||
|
||||
this.tools = this.buildTools()
|
||||
this.sheetBackdrop = h('div', { class: 'sheet-backdrop' })
|
||||
this.viewListEl = h('div', { class: 'view-list' })
|
||||
this.diagEl = h('div', { class: 'diag-box' })
|
||||
this.sheetList = h('div', { class: 'sheet-list' })
|
||||
this.sheetBackdrop.appendChild(this.buildSheet())
|
||||
host.stage.append(this.tools, this.sheetBackdrop)
|
||||
@@ -93,9 +112,15 @@ export class FlyfishCadViewer {
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const tStart = performance.now()
|
||||
const marks: Record<string, number> = {}
|
||||
const mark = (name: string) => {
|
||||
marks[name] = Math.round(performance.now() - tStart)
|
||||
}
|
||||
this.host.showLoader('正在加载 DWG 图纸…', this.file.name)
|
||||
try {
|
||||
const result = await this.viewer.loadFile(this.file)
|
||||
mark('parsed')
|
||||
this.result = result
|
||||
this.host.hideLoader()
|
||||
this.host.setFileInfo(
|
||||
@@ -105,11 +130,32 @@ export class FlyfishCadViewer {
|
||||
'cad',
|
||||
`${result.document.entities.length.toLocaleString()} 个实体 · ${formatBytes(this.file.size)}`
|
||||
)
|
||||
this.viewer.fit('auto')
|
||||
this.views = buildCadViews(result.document)
|
||||
mark('views')
|
||||
const hasModel = this.views.some((view) => view.kind === 'model')
|
||||
const firstBlock = this.views.find((view) => view.kind === 'block')
|
||||
if (!hasModel && firstBlock) {
|
||||
// 模型空间为空:内容全在“未插入的块”里,直接显示第一个,别给用户一片黑屏
|
||||
this.activeViewId = firstBlock.id
|
||||
this.rendererSetEntities(firstBlock.entities)
|
||||
} else {
|
||||
this.activeViewId = this.views[0]?.id ?? 'model'
|
||||
}
|
||||
mark('entities-set')
|
||||
this.viewer.fit(this.views.length > 1 ? 'extents' : 'auto')
|
||||
mark('fitted')
|
||||
this.applyTheme()
|
||||
this.updateStatus()
|
||||
if (result.warnings.length) toast(result.warnings[0], 'info', 6000)
|
||||
if (!hasModel && firstBlock) {
|
||||
toast(`模型空间为空,已显示图纸中的“${firstBlock.name}”块(${this.views.length - 1} 个可切换视图)`, 'info', 7000)
|
||||
} else if (result.warnings.length) {
|
||||
toast(result.warnings[0], 'info', 6000)
|
||||
}
|
||||
this.refreshSheetRows()
|
||||
this.refreshViewRows()
|
||||
this.refreshDiagnostics()
|
||||
mark('done')
|
||||
;(window as unknown as { __loadTimings?: Record<string, number> }).__loadTimings = marks
|
||||
} catch (error) {
|
||||
this.host.hideLoader()
|
||||
throw error
|
||||
@@ -149,7 +195,7 @@ export class FlyfishCadViewer {
|
||||
this.updateStatus()
|
||||
})
|
||||
tools.appendChild(h('div', { class: 'tool-divider' }))
|
||||
add('layers', '图层', 'layers', () => this.openSheet())
|
||||
add('layers', '视图与图层', 'layers', () => this.openSheet())
|
||||
this.themeBtn = add('sun', '切换背景', 'theme', () => {
|
||||
toggleTheme()
|
||||
this.applyTheme()
|
||||
@@ -158,8 +204,104 @@ export class FlyfishCadViewer {
|
||||
return tools
|
||||
}
|
||||
|
||||
private rendererSetEntities(entities: CadEntity[]): void {
|
||||
const doc = this.result?.document
|
||||
if (!doc) return
|
||||
const renderer = this.viewer.renderer
|
||||
const state = renderer.getViewState()
|
||||
renderer.setDocument({ ...doc, entities })
|
||||
renderer.setViewState(state)
|
||||
renderer.render()
|
||||
}
|
||||
|
||||
private switchView(id: string): void {
|
||||
const view = this.views.find((item) => item.id === id)
|
||||
if (!view || id === this.activeViewId) return
|
||||
this.activeViewId = id
|
||||
this.rendererSetEntities(view.entities)
|
||||
this.viewer.fit(view.kind === 'model' ? 'auto' : 'extents')
|
||||
this.updateStatus()
|
||||
this.refreshViewRows()
|
||||
}
|
||||
|
||||
private refreshViewRows(): void {
|
||||
const list = this.viewListEl
|
||||
if (!list) return
|
||||
clear(list)
|
||||
if (this.views.length <= 1) return
|
||||
for (const view of this.views) {
|
||||
const active = view.id === this.activeViewId
|
||||
const row = h('button', {
|
||||
class: `view-row ${active ? 'active' : ''}`,
|
||||
type: 'button',
|
||||
title: view.name
|
||||
})
|
||||
row.append(
|
||||
h('span', { class: 'view-kind', text: view.kind === 'model' ? 'M' : view.kind === 'paper' ? 'L' : 'B' }),
|
||||
h(
|
||||
'span',
|
||||
{ class: 'view-info' },
|
||||
h('span', { class: 'view-name', text: view.name }),
|
||||
h('span', { class: 'view-hint', text: view.hint })
|
||||
)
|
||||
)
|
||||
row.addEventListener('click', () => this.switchView(view.id))
|
||||
list.appendChild(row)
|
||||
}
|
||||
}
|
||||
|
||||
private refreshDiagnostics(): void {
|
||||
const box = this.diagEl
|
||||
const doc = this.result?.document
|
||||
if (!box || !doc) return
|
||||
clear(box)
|
||||
const stats = summarizeEntities(doc.entities)
|
||||
const missing = this.viewer.getMissingReferences?.() ?? []
|
||||
const lines: string[] = []
|
||||
if (stats.proxies) lines.push(`${stats.proxies} 个代理对象(ACAD_PROXY_ENTITY)`)
|
||||
if (stats.wipeouts) lines.push(`${stats.wipeouts} 个遮挡区域(WIPEOUT)`)
|
||||
if (missing.length) lines.push(`缺少 ${missing.length} 个 SHX 字体/线型文件`)
|
||||
if (!lines.length) return
|
||||
box.appendChild(h('div', { class: 'diag-title', text: '渲染提示' }))
|
||||
for (const line of lines) box.appendChild(h('div', { class: 'diag-line', text: `· ${line}` }))
|
||||
if (missing.length) {
|
||||
const names = missing
|
||||
.map((ref) => (ref as { fileName?: string; name?: string }).fileName ?? (ref as { name?: string }).name ?? '')
|
||||
.filter(Boolean)
|
||||
if (names.length) box.appendChild(h('div', { class: 'diag-line diag-muted', text: names.join('、') }))
|
||||
const picker = h('input', { type: 'file', accept: '.shx,.SHX', multiple: true })
|
||||
picker.style.display = 'none'
|
||||
const importBtn = h('button', { class: 'chip-btn', type: 'button', text: '导入 SHX 字体' })
|
||||
importBtn.addEventListener('click', () => picker.click())
|
||||
picker.addEventListener('change', () => void this.importShx(picker))
|
||||
box.append(importBtn, picker)
|
||||
}
|
||||
}
|
||||
|
||||
private async importShx(picker: HTMLInputElement): Promise<void> {
|
||||
const files = [...(picker.files ?? [])]
|
||||
if (!files.length) return
|
||||
let loaded = 0
|
||||
for (const file of files) {
|
||||
try {
|
||||
await this.viewer.addReferenceFile(file)
|
||||
loaded += 1
|
||||
} catch {
|
||||
/* 单个字体失败不影响其它 */
|
||||
}
|
||||
}
|
||||
picker.value = ''
|
||||
if (loaded) {
|
||||
toast(`已载入 ${loaded} 个 SHX 文件,线型/文字已重绘`, 'success', 3200)
|
||||
this.rendererSetEntities(this.views.find((view) => view.id === this.activeViewId)?.entities ?? [])
|
||||
this.refreshDiagnostics()
|
||||
} else {
|
||||
toast('SHX 载入失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private buildSheet(): HTMLElement {
|
||||
const title = h('div', { class: 'sheet-title' }, h('span', { text: '图层' }))
|
||||
const title = h('div', { class: 'sheet-title' }, h('span', { text: '视图与图层' }))
|
||||
const close = iconButton('x', '关闭', () => this.closeSheet())
|
||||
const head = h('div', { class: 'sheet-head' }, title, close)
|
||||
const actions = h('div', { class: 'sheet-actions' })
|
||||
@@ -181,8 +323,11 @@ export class FlyfishCadViewer {
|
||||
})
|
||||
return h(
|
||||
'aside',
|
||||
{ class: 'sheet', role: 'dialog', 'aria-label': '图层' },
|
||||
{ class: 'sheet', role: 'dialog', 'aria-label': '视图与图层' },
|
||||
head,
|
||||
this.diagEl,
|
||||
this.viewListEl,
|
||||
h('div', { class: 'sheet-subtitle', text: '图层' }),
|
||||
actions,
|
||||
this.sheetList
|
||||
)
|
||||
@@ -258,6 +403,8 @@ export class FlyfishCadViewer {
|
||||
|
||||
private openSheet(): void {
|
||||
this.refreshSheetRows()
|
||||
this.refreshViewRows()
|
||||
this.refreshDiagnostics()
|
||||
this.sheetBackdrop.classList.add('open')
|
||||
}
|
||||
|
||||
@@ -285,8 +432,10 @@ export class FlyfishCadViewer {
|
||||
if (this.destroyed || !this.result) return
|
||||
const zoom = Math.round(this.viewer.getZoomPercent())
|
||||
const backend = (this.viewer.renderer as { backend?: string }).backend ?? 'canvas2d'
|
||||
const entities = this.result.document.entities.length.toLocaleString()
|
||||
this.host.setStatus(`${this.file.name} · ${zoom}% · ${entities} 实体 · ${backend}`)
|
||||
const view = this.views.find((item) => item.id === this.activeViewId)
|
||||
const entities = (view?.entities.length ?? this.result.document.entities.length).toLocaleString()
|
||||
const viewLabel = this.views.length > 1 && view ? `${view.name} · ` : ''
|
||||
this.host.setStatus(`${this.file.name} · ${viewLabel}${zoom}% · ${entities} 实体 · ${backend}`)
|
||||
}
|
||||
|
||||
private onFullscreenChange = () => {
|
||||
|
||||
@@ -245,6 +245,8 @@ async function clearAllRecent(): Promise<void> {
|
||||
}
|
||||
|
||||
async function openFile(file: File): Promise<void> {
|
||||
;(window as unknown as { __openAt?: number }).__openAt = performance.now()
|
||||
;(window as unknown as { __progress?: unknown }).__progress = []
|
||||
if (busy) {
|
||||
toast('正在打开文件,请稍候…', 'info', 1600)
|
||||
return
|
||||
|
||||
+12
-7
@@ -11,7 +11,7 @@ export interface ViewerHost {
|
||||
setSubbar: (content: Node | null) => void
|
||||
setStatus: (text: string, title?: string) => void
|
||||
showLoader: (message: string, detail?: string) => void
|
||||
setLoaderMessage: (message: string, detail?: string) => void
|
||||
setLoaderMessage: (message: string, detail?: string, percent?: number) => void
|
||||
hideLoader: () => void
|
||||
destroy: () => void
|
||||
}
|
||||
@@ -38,11 +38,11 @@ export function createViewerHost(onBack: () => void): ViewerHost {
|
||||
const body = h('div', { class: 'viewer-body' }, stage)
|
||||
const loaderText = h('div', { class: 'loader-text', text: '正在打开…' })
|
||||
const loaderDetail = h('div', { class: 'loader-detail' })
|
||||
const loader = h(
|
||||
'div',
|
||||
{ class: 'viewer-loader', hidden: true },
|
||||
h('div', { class: 'loader-card' }, h('div', { class: 'spinner' }), loaderText, loaderDetail)
|
||||
)
|
||||
// 解析期间用“确定进度条”代替无限旋转动画:低端设备/软件渲染下,持续动画会真的拖慢解析
|
||||
const barFill = h('div', { class: 'loader-bar-fill' })
|
||||
const bar = h('div', { class: 'loader-bar', hidden: true }, barFill)
|
||||
const spinner = h('div', { class: 'spinner' })
|
||||
const loader = h('div', { class: 'viewer-loader', hidden: true }, h('div', { class: 'loader-card' }, spinner, loaderText, loaderDetail, bar))
|
||||
const root = h('section', { class: 'viewer' }, header, subbar, body, loader)
|
||||
|
||||
const setSubbar = (content: Node | null) => {
|
||||
@@ -84,9 +84,14 @@ export function createViewerHost(onBack: () => void): ViewerHost {
|
||||
loaderText.textContent = message
|
||||
loaderDetail.textContent = detail ?? ''
|
||||
},
|
||||
setLoaderMessage: (message, detail) => {
|
||||
setLoaderMessage: (message, detail, percent) => {
|
||||
loaderText.textContent = message
|
||||
loaderDetail.textContent = detail ?? ''
|
||||
const determinate = typeof percent === 'number' && Number.isFinite(percent)
|
||||
// 知道百分比时隐藏旋转动画(display:none 会停止动画),只更新进度条宽度
|
||||
spinner.style.display = determinate ? 'none' : ''
|
||||
bar.hidden = !determinate
|
||||
if (determinate) barFill.style.width = `${Math.max(0, Math.min(100, percent))}%`
|
||||
},
|
||||
hideLoader: () => {
|
||||
loader.hidden = true
|
||||
|
||||
+140
-2
@@ -824,9 +824,10 @@ a {
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(8, 11, 17, 0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
/* 解析大图纸时这个遮罩会长时间停留:不要用 backdrop-filter(每帧整屏模糊非常吃 CPU/GPU) */
|
||||
background: rgba(8, 11, 17, 0.82);
|
||||
padding: 20px;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.loader-card {
|
||||
@@ -859,6 +860,22 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
.loader-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--line-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loader-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transition: width 0.25s ease-out;
|
||||
}
|
||||
|
||||
.loader-text {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
@@ -1099,6 +1116,127 @@ a {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.sheet-subtitle {
|
||||
padding: 10px 14px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.view-list {
|
||||
flex: none;
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.view-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.view-row:hover {
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.view-row.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.view-kind {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-3, rgba(255, 255, 255, 0.08));
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.view-row.active .view-kind {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.view-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.view-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diag-box {
|
||||
flex: none;
|
||||
margin: 0 14px 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.diag-box:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diag-title {
|
||||
font-weight: 650;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diag-line {
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diag-muted {
|
||||
opacity: 0.75;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diag-box .chip-btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { h } from '../core/dom'
|
||||
import { openModal } from './modal'
|
||||
|
||||
const VERSION = '0.5.0'
|
||||
const VERSION = '0.6.0'
|
||||
|
||||
export function showAbout(): void {
|
||||
const body = h(
|
||||
|
||||
Reference in New Issue
Block a user