OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

893
dev/DWGViewer/app.js Normal file
View File

@@ -0,0 +1,893 @@
/**
* DWG / DXF 图纸查看器 —— 界面与交互装配。
*
* 数据流:文件 → source.js(解析) → flatten.js(展平成显示列表) → render.js(画)
* 这里只负责把 UI 事件接到上面三层,以及图层面板、测量/批注工具、导出等外围功能。
*/
import ACI from './libs/dxf-parser/AutoCadColorIndex.js'
import { flatten } from './js/flatten.js'
import { BACKGROUNDS, Renderer } from './js/render.js'
import { extOf, loadDrawing } from './js/source.js'
import { ANNO_MODES, AnnoTool, MEASURE_MODES, MeasureTool, Snapper } from './js/tools.js'
const $ = (id) => document.getElementById(id)
const params = new URLSearchParams(location.search)
// ---------------------------------------------------------------- 状态
const S = {
doc: null,
shapes: [],
layerStats: new Map(), // 图层名 → 图元数
tool: 'select', // select | measure | anno | zoomBox
embed: params.get('embed') === '1',
}
const wrap = $('canvasWrap')
const renderer = new Renderer(wrap)
const snapper = new Snapper(renderer)
const measure = new MeasureTool(renderer, snapper, () => { renderStats(); renderer.requestDraw() })
const anno = new AnnoTool(renderer, () => renderer.requestDraw())
renderer.overlay = (ctx, r) => {
anno.draw(ctx, r)
if (S.tool === 'measure' || measure.results.length) measure.draw(ctx, r)
drawRubber(ctx, r)
}
if (S.embed) document.body.classList.add('embed')
// ---------------------------------------------------------------- 加载
function setLoading(on, text) {
$('loading').classList.toggle('hidden', !on)
if (text) $('loadingText').textContent = text
}
function showError(msg) {
let box = $('errBox')
if (!box) {
box = document.createElement('div')
box.id = 'errBox'
$('viewport').appendChild(box)
}
box.innerHTML = ''
box.append(msg)
const b = document.createElement('button')
b.className = 'small-btn'
b.textContent = '关闭'
b.style.marginLeft = '12px'
b.onclick = () => box.remove()
box.appendChild(b)
}
async function openBuffer(name, buffer) {
setLoading(true, '正在解析图纸...')
const t0 = performance.now()
try {
const doc = await loadDrawing(name, buffer, (m) => setLoading(true, m))
setLoading(true, '正在生成图形...')
await nextFrame()
const { shapes, bbox, stats } = flatten(doc)
if (!shapes.length) throw new Error('图纸里没有可显示的图元(可能是空图或全部实体类型暂不支持)')
S.doc = doc
S.buffer = buffer // 留着给「上传并生成嵌入链接」用
applyShapes(doc, shapes, bbox)
measure.clear()
anno.clear()
setUnit(unitLabel(doc), true)
buildLayoutSelect(doc)
$('fileName').textContent = `${name} · ${doc.format} · ${stats.entities} 实体 / ${shapes.length} 图元`
renderInfo(stats, performance.now() - t0)
setStatus(`已加载 ${name}`)
$('modelInfo').textContent = `${shapes.length} 图元 · ${(performance.now() - t0).toFixed(0)}ms`
} catch (e) {
console.error(e)
showError(`打开失败:${e && e.message ? e.message : e}`)
} finally {
setLoading(false)
}
}
const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()))
/** 把一份显示列表装进渲染器并刷新左侧图层面板 */
function applyShapes(doc, shapes, bbox) {
S.shapes = shapes
S.layerStats = new Map()
for (const s of shapes) S.layerStats.set(s.layer, (S.layerStats.get(s.layer) || 0) + 1)
renderer.setDocument(doc, shapes, bbox)
renderer.zoomExtents()
isolated = null
buildLayerPanel()
renderer.draw()
}
/**
* 模型空间 / 图纸空间布局切换。
* 国内机械图多半把图框画在模型空间,所以只有布局里真有实体时才显示这个下拉。
*/
function buildLayoutSelect(doc) {
const sel = $('layoutSelect')
const used = (doc.layouts || []).filter((l) => l.entities && l.entities.length)
sel.innerHTML = ''
const add = (val, text) => { const o = document.createElement('option'); o.value = val; o.textContent = text; sel.appendChild(o) }
add('model', '模型空间')
used.forEach((l, i) => add(String(i), l.name.replace(/^\*/, '') || `布局 ${i + 1}`))
$('layoutBox').classList.toggle('hidden', used.length === 0)
sel.value = 'model'
sel.onchange = () => {
const ents = sel.value === 'model' ? doc.modelEntities : used[+sel.value].entities
const { shapes, bbox } = flatten(doc, { entities: ents })
if (!shapes.length) { showError('该布局里没有可显示的图元'); sel.value = 'model'; return }
applyShapes(doc, shapes, bbox)
$('modelInfo').textContent = `${shapes.length} 图元`
}
}
/** 单位DWG 的 $INSUNITS 常年是默认值,所以给用户留了手动覆盖 */
function setUnit(u, fromFile) {
measure.unit = u
$('optUnit').value = u
$('optUnitSrc').textContent = fromFile ? '自动' : '手动'
measure.refresh()
renderStats()
renderer.requestDraw()
}
function openFile(file) {
const ext = extOf(file.name)
if (ext !== 'dwg' && ext !== 'dxf') { showError('只支持 .dwg 和 .dxf 文件'); return }
S.loadPath = null // 本地文件没有服务器路径,嵌入前要先上传
const fr = new FileReader()
fr.onload = () => openBuffer(file.name, fr.result)
fr.onerror = () => showError('文件读取失败')
setLoading(true, '正在读取文件...')
fr.readAsArrayBuffer(file)
}
async function openUrl(url, loadPath) {
setLoading(true, '正在读取图纸...')
try {
const { buffer, name } = await fetchDrawing(url)
S.loadPath = loadPath || url // 嵌入链接沿用真正打开的路径,别用裸文件名
await openBuffer(name || url.split('/').pop() || url, buffer)
} catch (e) {
setLoading(false)
showError(`读取失败:${e.message}${url}`)
}
}
/**
* 取图纸字节。
*
* 站内路径优先走「令牌 → 解密接口」:服务端用 .htaccess 封掉了 .dwg/.dxf/.enc 的直接 GET
* 只有拿到 5 分钟有效的 HMAC 令牌才能通过 api/model.php 读到内容,挡住直链下载和爬取。
* 接口不存在时(比如本地 python 开发服务器没有 PHP自动退回直连不影响开发。
*/
async function fetchDrawing(url) {
if (!/^https?:\/\//i.test(url)) {
const via = await tryTokenFetch(url)
if (via) return via
}
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return { buffer: await res.arrayBuffer(), name: '' }
}
/**
* 走令牌接口取图纸;接口没部署就返回 null 让调用方直连。
*
* 判断「接口有没有部署」看的是响应内容而不是异常文本:本地开发用的 python
* 静态服务器不会执行 PHP会把 token.php 的源码当普通文件 200 发回来,
* 只看状态码会误判成接口可用,然后 json() 直接炸掉。
*/
async function tryTokenFetch(url) {
let tok
try {
const tr = await fetch(`api/token.php?f=${encodeURIComponent(url)}`, { cache: 'no-store' })
if (!tr.ok) return null
if (!/application\/json/i.test(tr.headers.get('content-type') || '')) return null
tok = await tr.json()
} catch {
return null // 网络层失败:当作没部署
}
if (!tok || !tok.token) return null
// 到这里说明接口确实在跑,它再拒绝就该明确报错,不能悄悄退回直连
const mr = await fetch(
`api/model.php?f=${encodeURIComponent(url)}&t=${tok.token}&e=${tok.expires}`, { cache: 'no-store' })
if (!mr.ok) {
const j = await mr.json().catch(() => ({}))
throw new Error(j.error || `读取接口返回 ${mr.status}`)
}
// 落盘名是随机的,真实文件名由接口通过头带回
const hdr = mr.headers.get('X-Filename')
return { buffer: await mr.arrayBuffer(), name: hdr ? decodeURIComponent(hdr) : '' }
}
/** 把当前图纸上传到服务器(加密落盘 + 随机文件名),返回可用于嵌入的路径 */
async function uploadCurrent() {
if (!S.buffer) throw new Error('还没有打开图纸')
const res = await fetch('api/upload.php', {
method: 'POST',
headers: { 'X-Filename': encodeURIComponent(S.doc.name), 'Content-Type': 'application/octet-stream' },
body: S.buffer,
})
const j = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`)
return j.path
}
/**
* $INSUNITS → 单位标签。
* 只认公制的显式取值0未设和 1英寸多半是 CAD 的默认值没改过,
* 国内机械图实际都是毫米,直接按英寸显示会差 25.4 倍。用户可在设置里改。
*/
function unitLabel(doc) {
const u = doc.header && doc.header.INSUNITS
return ({ 2: 'ft', 4: 'mm', 5: 'cm', 6: 'm', 13: 'µm' })[u] || 'mm'
}
// ---------------------------------------------------------------- 图层面板
function buildLayerPanel() {
const box = $('tree')
box.innerHTML = ''
if (!S.doc) return
const names = [...S.doc.layers.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
// 没有任何图元的图层排到后面
names.sort((a, b) => (S.layerStats.get(b) || 0) - (S.layerStats.get(a) || 0))
$('layerCount').textContent = `(${names.length})`
for (const name of names) {
const layer = S.doc.layers.get(name)
const n = S.layerStats.get(name) || 0
const row = document.createElement('div')
row.className = 'tree-row'
row.dataset.layer = name
const cb = document.createElement('input')
cb.type = 'checkbox'
cb.className = 'tree-check'
cb.checked = !renderer.hidden.has(name)
cb.onchange = () => {
if (cb.checked) renderer.hidden.delete(name); else renderer.hidden.add(name)
row.classList.toggle('off', !cb.checked)
renderer.requestDraw()
}
const sw = document.createElement('span')
sw.className = 'layer-swatch'
sw.style.background = swatchColor(layer)
const lb = document.createElement('span')
lb.className = 'tree-label'
lb.textContent = name
lb.title = `${name}${n} 个图元)`
const num = document.createElement('span')
num.className = 'tree-num'
num.textContent = n || ''
row.append(cb, sw, lb, num)
// 点行(不含勾选框)= 只显示这一层
row.onclick = (e) => {
if (e.target === cb) return
isolateLayer(name)
}
box.appendChild(row)
}
if (!names.length) box.innerHTML = '<div class="tree-empty">没有图层</div>'
}
function swatchColor(layer) {
if (!layer) return '#000'
let c = 0
if (typeof layer.trueColor === 'number') c = layer.trueColor
else if (layer.colorIndex != null) {
const idx = Math.abs(layer.colorIndex)
c = idx === 7 ? 0x000000 : ACI_CACHE[idx] || 0x000000
}
return `#${(c >>> 0).toString(16).padStart(6, '0')}`
}
const ACI_CACHE = ACI
let isolated = null
function isolateLayer(name) {
if (isolated === name) {
renderer.hidden.clear()
isolated = null
} else {
renderer.hidden.clear()
for (const k of S.doc.layers.keys()) if (k !== name) renderer.hidden.add(k)
isolated = name
}
syncLayerChecks()
renderer.requestDraw()
setStatus(isolated ? `只显示图层「${name}」,再点一次恢复` : '已显示全部图层')
}
function syncLayerChecks() {
for (const row of $('tree').children) {
const cb = row.querySelector('input')
if (!cb) continue
cb.checked = !renderer.hidden.has(row.dataset.layer)
row.classList.toggle('off', !cb.checked)
}
}
$('treeSearch').addEventListener('input', () => {
const q = $('treeSearch').value.trim().toLowerCase()
for (const row of $('tree').children) {
const name = (row.dataset.layer || '').toLowerCase()
const hit = !q || name.includes(q)
row.style.display = hit ? '' : 'none'
row.classList.toggle('matched', !!q && hit)
}
})
$('btnLayerAll').onclick = () => { renderer.hidden.clear(); isolated = null; syncLayerChecks(); renderer.requestDraw() }
$('btnLayerNone').onclick = () => {
for (const k of S.doc ? S.doc.layers.keys() : []) renderer.hidden.add(k)
isolated = null; syncLayerChecks(); renderer.requestDraw()
}
$('btnLayerInvert').onclick = () => {
for (const k of S.doc ? S.doc.layers.keys() : []) {
if (renderer.hidden.has(k)) renderer.hidden.delete(k); else renderer.hidden.add(k)
}
isolated = null; syncLayerChecks(); renderer.requestDraw()
}
// ---------------------------------------------------------------- 工具栏
function setTool(t) {
S.tool = t
for (const b of document.querySelectorAll('#toolbar .tool-btn[data-tool]')) {
b.classList.toggle('active', b.dataset.tool === t)
}
const isMeasure = t === 'measure'
const isAnno = t === 'anno'
$('toolbar').classList.toggle('hidden', isMeasure || isAnno)
$('measurePanel').classList.toggle('hidden', !isMeasure)
$('annoPanel').classList.toggle('hidden', !isAnno)
if (!isMeasure) measure.setMode(null)
if (!isAnno) anno.setMode(null)
wrap.classList.toggle('crosshair', isMeasure || isAnno || t === 'zoomBox')
setStatus(TOOL_HINT[t] || '')
renderer.requestDraw()
}
const TOOL_HINT = {
select: '选择:点击图元查看属性 · 拖动平移 · 滚轮缩放 · 双击全图',
measure: '测量:先选测量方式,再在图上点取(自动捕捉端点/中点/圆心)',
anno: '批注:先选批注方式,在图上拖动绘制',
zoomBox: '框选放大:按住左键拖出矩形',
}
for (const b of document.querySelectorAll('#toolbar .tool-btn[data-tool]')) {
b.onclick = () => setTool(b.dataset.tool)
}
// 测量子面板
const mBox = $('measureModes')
for (const m of MEASURE_MODES) {
const b = document.createElement('button')
b.className = 'measure-btn'
b.title = m.name
b.innerHTML = `<img src="./icons/${m.icon}" alt=""><span>${m.name}</span>`
b.onclick = () => {
const on = measure.mode === m.id
measure.setMode(on ? null : m.id)
for (const x of mBox.children) x.classList.remove('active')
if (!on) b.classList.add('active')
setStatus(on ? '' : `${m.name}:在图上点取`)
}
mBox.appendChild(b)
}
$('btnMeasureClose').onclick = () => setTool('select')
$('btnMeasureClear').onclick = () => measure.clear()
$('btnStats').onclick = () => togglePanel('stats')
// 批注子面板
const aBox = $('annoModes')
for (const m of ANNO_MODES) {
const b = document.createElement('button')
b.className = 'measure-btn'
b.title = m.name
b.innerHTML = `<img src="./icons/${m.icon}" alt=""><span>${m.name}</span>`
b.onclick = () => {
const on = anno.mode === m.id
anno.setMode(on ? null : m.id)
for (const x of aBox.children) x.classList.remove('active')
if (!on) b.classList.add('active')
}
aBox.appendChild(b)
}
$('btnAnnoClose').onclick = () => setTool('select')
$('btnAnnoClear').onclick = () => anno.clear()
$('btnAnnoUndo').onclick = () => anno.undo()
$('annoColor').oninput = (e) => { anno.color = e.target.value }
$('annoWidth').onchange = (e) => { anno.width = +e.target.value }
// 其它工具按钮
$('btnLayerPanel').onclick = (e) => {
if (e) e.stopPropagation() // 别让「点击外部收起抽屉」把这次打开又关掉
if (window.innerWidth <= 768) {
// 手机:图层面板是抽屉,点按钮开合(默认隐藏)
document.body.classList.toggle('tree-open')
} else {
// 桌面:默认显示,点击隐藏/显示侧栏
document.body.classList.toggle('no-tree')
}
renderer.resize()
}
// 手机抽屉:✕ 按钮关闭 + 点击抽屉外部自动收起
const btnTreeClose = $('btnTreeClose')
if (btnTreeClose) btnTreeClose.onclick = () => document.body.classList.remove('tree-open')
document.addEventListener('click', (e) => {
if (window.innerWidth > 768 || !document.body.classList.contains('tree-open')) return
if (!$('treePanel').contains(e.target)) document.body.classList.remove('tree-open')
})
$('btnFind').onclick = () => togglePanel('find')
$('btnSettings').onclick = () => togglePanel('settings')
$('btnInfo').onclick = () => togglePanel('info')
$('btnReset').onclick = () => { setRotation(0); renderer.zoomExtents(); renderer.requestDraw() }
$('btnLineWeight').onclick = () => {
renderer.opts.lineWeight = !renderer.opts.lineWeight
$('btnLineWeight').classList.toggle('active', renderer.opts.lineWeight)
$('optLineWeight').checked = renderer.opts.lineWeight
renderer.requestDraw()
}
$('btnSnapshot').onclick = snapshot
$('btnPrint').onclick = printDrawing
$('btnOpen').onclick = () => $('fileInput').click()
$('fileInput').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]) }
$('btnFullscreen').onclick = () => toggleFullscreen()
$('btnEmbed').onclick = () => { togglePanel('embed'); updateEmbed() }
for (const el of document.querySelectorAll('.panel-close')) {
el.onclick = () => $(`${el.dataset.panel}Panel`).classList.add('hidden')
}
function togglePanel(name) {
const p = $(`${name}Panel`)
const willShow = p.classList.contains('hidden')
for (const n of ['stats', 'find', 'settings', 'info', 'embed']) {
if (n !== name) $(`${n}Panel`).classList.add('hidden')
}
p.classList.toggle('hidden', !willShow)
if (willShow && name === 'find') $('findInput').focus()
}
// ---------------------------------------------------------------- 显示设置
// ---- 背景(浅灰 / 米白 / 黑,与新迪 2D 一致)----
const bgBox = $('bgSwatches')
for (const b of BACKGROUNDS) {
const el = document.createElement('button')
el.className = 'bg-swatch'
el.dataset.bg = b.id
el.title = b.name
el.style.background = b.color
el.onclick = () => setBackground(b.id)
bgBox.appendChild(el)
}
/** 背景偏好存本地,下次打开还是上次那个底 */
function setBackground(id) {
const b = BACKGROUNDS.find((x) => x.id === id) || BACKGROUNDS[0]
renderer.opts.bg = b.color
for (const el of bgBox.children) el.classList.toggle('active', el.dataset.bg === b.id)
$('btnBg').classList.toggle('active', renderer.isDarkBg)
try { localStorage.setItem('dwgviewer.bg', b.id) } catch { /* 隐私模式下写不了,忽略 */ }
renderer.requestDraw()
}
/** 工具栏那个按钮循环切三种底 */
$('btnBg').onclick = () => {
const i = BACKGROUNDS.findIndex((b) => b.color === renderer.opts.bg)
const next = BACKGROUNDS[(i + 1) % BACKGROUNDS.length]
setBackground(next.id)
setStatus(`背景:${next.name}`)
}
$('optContrast').onchange = (e) => { renderer.opts.autoContrast = e.target.checked; renderer.requestDraw() }
// ---- 视图旋转 ----
function setRotation(rad, fromSlider) {
renderer.setRotation(rad)
const deg = Math.round((renderer.view.rot * 180) / Math.PI)
if (!fromSlider) $('optRot').value = deg
$('optRotVal').textContent = `${deg}°`
renderer.requestDraw()
}
/** 转 90°原本铺满视口的话转完继续铺满已经放大过就保持倍数不动 */
function rotate90(dir) {
const wasFitted = renderer.isFitted()
setRotation(renderer.view.rot + dir * Math.PI / 2)
if (wasFitted) renderer.zoomExtents()
renderer.requestDraw()
}
$('btnRotL2').onclick = () => rotate90(1)
$('btnRotR2').onclick = () => rotate90(-1)
$('btnRot0').onclick = () => setRotation(0)
$('optRot').oninput = (e) => setRotation((+e.target.value * Math.PI) / 180, true)
// ---- 全屏(底部工具栏那个在嵌入模式下也能点到)----
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen()
else document.documentElement.requestFullscreen()
}
$('btnFull').onclick = toggleFullscreen
$('optText').onchange = (e) => { renderer.opts.showText = e.target.checked; renderer.requestDraw() }
$('optHatch').onchange = (e) => { renderer.opts.showHatch = e.target.checked; renderer.requestDraw() }
$('optLineWeight').onchange = (e) => {
renderer.opts.lineWeight = e.target.checked
$('btnLineWeight').classList.toggle('active', e.target.checked)
renderer.requestDraw()
}
$('optLwScale').oninput = (e) => {
renderer.opts.lineWeightScale = e.target.value / 100
$('optLwVal').textContent = `${(e.target.value / 100).toFixed(1)}x`
if (renderer.opts.lineWeight) renderer.requestDraw()
}
$('optUnit').onchange = (e) => setUnit(e.target.value, false)
$('optPrec').oninput = (e) => {
measure.precision = +e.target.value
$('optPrecVal').textContent = e.target.value
measure.refresh()
renderStats()
renderer.requestDraw()
}
// ---------------------------------------------------------------- 鼠标交互
let drag = null
let rubber = null // 框选/框放大的临时矩形(屏幕 CSS 像素)
wrap.addEventListener('contextmenu', (e) => e.preventDefault())
wrap.addEventListener('pointerdown', (e) => {
wrap.setPointerCapture(e.pointerId)
const pos = local(e)
// 中键/右键始终是平移;左键看当前工具
if (e.button === 1 || e.button === 2) {
drag = { mode: 'pan', x: e.clientX, y: e.clientY }
wrap.classList.add('panning')
return
}
if (e.button !== 0) return
if (S.tool === 'zoomBox') { rubber = { x0: pos.x, y0: pos.y, x1: pos.x, y1: pos.y, kind: 'zoom' }; return }
if (S.tool === 'anno' && anno.mode) { if (anno.down(pos.x, pos.y)) { drag = { mode: 'anno' }; return } }
if (S.tool === 'measure' && measure.mode) return // 测量在 pointerup 里按点击处理
drag = { mode: 'maybe-pan', x: e.clientX, y: e.clientY, sx: pos.x, sy: pos.y, moved: false }
})
wrap.addEventListener('pointermove', (e) => {
const pos = local(e)
updateCoord(pos)
if (rubber) { rubber.x1 = pos.x; rubber.y1 = pos.y; renderer.requestDraw(); return }
if (drag && drag.mode === 'anno') { anno.move(pos.x, pos.y); renderer.requestDraw(); return }
if (drag && (drag.mode === 'pan' || drag.mode === 'maybe-pan')) {
const dx = e.clientX - drag.x, dy = e.clientY - drag.y
if (drag.mode === 'maybe-pan' && Math.hypot(dx, dy) < 3) return
if (drag.mode === 'maybe-pan') { drag.mode = 'pan'; drag.moved = true; wrap.classList.add('panning') }
renderer.panByPixels(dx, dy)
drag.x = e.clientX; drag.y = e.clientY
renderer.requestDraw()
return
}
if (S.tool === 'measure' && measure.mode) { measure.hover(pos.x, pos.y); renderer.requestDraw(); return }
if (S.tool === 'select') {
const idx = renderer.pick(pos.x, pos.y, 6)
if (idx !== renderer.highlight) { renderer.highlight = idx; renderer.requestDraw() }
}
})
wrap.addEventListener('pointerup', (e) => {
const pos = local(e)
wrap.classList.remove('panning')
try { wrap.releasePointerCapture(e.pointerId) } catch { /* 指针已释放 */ }
if (rubber) {
const r = rubber; rubber = null
if (Math.abs(r.x1 - r.x0) > 6 && Math.abs(r.y1 - r.y0) > 6) {
const a = renderer.toWorld(r.x0, r.y0), b = renderer.toWorld(r.x1, r.y1)
renderer.zoomToBox([Math.min(a.x, b.x), Math.min(a.y, b.y), Math.max(a.x, b.x), Math.max(a.y, b.y)])
setTool('select')
}
renderer.requestDraw()
return
}
if (drag && drag.mode === 'anno') { anno.up(); drag = null; return }
const wasPan = drag && drag.mode === 'pan'
const wasClick = drag && drag.mode === 'maybe-pan'
drag = null
if (wasPan) return
if (e.button !== 0) return
if (S.tool === 'measure' && measure.mode) { measure.click(pos.x, pos.y); renderer.requestDraw(); return }
if (S.tool === 'select' && wasClick) selectAt(pos)
})
wrap.addEventListener('dblclick', () => {
if (S.tool === 'measure' && measure.mode) { measure.finish(); return }
renderer.zoomExtents()
renderer.requestDraw()
})
wrap.addEventListener('wheel', (e) => {
e.preventDefault()
const pos = local(e)
const f = e.deltaY < 0 ? 1.18 : 1 / 1.18
renderer.zoomAt(pos.x, pos.y, f)
renderer.requestDraw()
updateCoord(pos)
}, { passive: false })
function local(e) {
const r = wrap.getBoundingClientRect()
return { x: e.clientX - r.left, y: e.clientY - r.top }
}
function updateCoord(pos) {
const w = renderer.toWorld(pos.x, pos.y)
$('coordText').textContent = S.doc ? `X ${w.x.toFixed(2)} Y ${w.y.toFixed(2)} 1:${(1 / renderer.view.scale * renderer.dpr).toFixed(2)}` : ''
}
function selectAt(pos) {
const idx = renderer.pick(pos.x, pos.y, 6)
renderer.selection.clear()
if (idx != null) {
renderer.selection.add(idx)
showProps(renderer.shapes[idx])
} else {
$('propPanel').classList.add('hidden')
}
renderer.requestDraw()
}
function drawRubber(ctx, r) {
if (!rubber) return
const d = r.dpr
ctx.save()
// 框选矩形是屏幕上的物件,不跟着图纸转,所以撤掉 overlay 的视图旋转
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.strokeStyle = '#2b9ae8'
ctx.fillStyle = 'rgba(43,154,232,.12)'
ctx.lineWidth = 1.5 * d
ctx.setLineDash([5 * d, 4 * d])
const x = Math.min(rubber.x0, rubber.x1) * d, y = Math.min(rubber.y0, rubber.y1) * d
const w = Math.abs(rubber.x1 - rubber.x0) * d, h = Math.abs(rubber.y1 - rubber.y0) * d
ctx.fillRect(x, y, w, h)
ctx.strokeRect(x, y, w, h)
ctx.restore()
}
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (e.key === 'Escape') {
measure.cancel(); anno.setMode(anno.mode); rubber = null
renderer.selection.clear(); $('propPanel').classList.add('hidden')
renderer.requestDraw()
} else if (e.key === 'Enter') {
measure.finish(); renderer.requestDraw()
} else if (e.key === 'e' || e.key === 'E') {
renderer.zoomExtents(); renderer.requestDraw()
}
})
// ---------------------------------------------------------------- 面板内容
function renderStats() {
const box = $('statsList')
box.innerHTML = ''
if (!measure.results.length) { box.innerHTML = '<div class="stats-empty">暂无测量结果</div>'; return }
measure.results.forEach((r, i) => {
const row = document.createElement('div')
row.className = 'stats-row'
row.innerHTML = `<span class="stats-type">${r.type}</span><span class="stats-value" title="${r.text}">${r.text}</span>`
const del = document.createElement('span')
del.className = 'stats-del'
del.textContent = '×'
del.onclick = () => measure.remove(i)
row.appendChild(del)
box.appendChild(row)
})
}
function renderInfo(stats, ms) {
const d = S.doc
const b = renderer.bbox
const kv = (k, v) => `<div class="kv"><b>${k}</b><span>${v}</span></div>`
const types = Object.entries(stats.byType).sort((a, b2) => b2[1] - a[1])
.map(([k, v]) => `${k} ${v}`).join('、')
$('infoBody').innerHTML =
kv('文件', d.name) +
kv('格式', d.format) +
kv('图层', d.layers.size) +
kv('块', d.blocks.size) +
kv('实体', stats.entities) +
kv('图元', S.shapes.length) +
kv('范围', `${(b[2] - b[0]).toFixed(1)} × ${(b[3] - b[1]).toFixed(1)} ${measure.unit}`) +
kv('解析耗时', `${ms.toFixed(0)} ms`) +
kv('实体分布', types || '—') +
`<div class="lic">解析内核:${d.format === 'DWG' ? 'LibreDWG (WebAssembly)' : 'dxf-parser'} 许可证 ${d.license}
${d.format === 'DWG' ? 'GPL-3.0 会传染到分发的前端代码,商用前请评估,或把 DWG 解析换成服务端/商业 SDK。' : ''}</div>`
}
function showProps(s) {
if (!s) return
const e = s.ent || {}
const kv = (k, v) => (v == null || v === '' ? '' : `<div class="kv"><b>${k}</b><span>${v}</span></div>`)
const b = s.bbox
$('propBody').innerHTML =
kv('类型', e.type || s.kind) +
kv('图层', s.layer) +
kv('句柄', e.handle) +
kv('颜色', `#${(s.color >>> 0).toString(16).padStart(6, '0')}`) +
kv('线型', s.lt || 'Continuous') +
kv('线宽', s.lw >= 0 ? `${(s.lw / 100).toFixed(2)} mm` : '随层') +
kv('文字', s.plain ? s.plain.slice(0, 200) : '') +
kv('半径', s.kind === 'arc' ? s.rx.toFixed(3) : '') +
kv('包围盒', `${(b[2] - b[0]).toFixed(2)} × ${(b[3] - b[1]).toFixed(2)}`)
$('propPanel').classList.remove('hidden')
}
// ---------------------------------------------------------------- 文字查找
$('findInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') doFind() })
function doFind() {
const q = $('findInput').value.trim().toLowerCase()
const box = $('findResult')
box.innerHTML = ''
if (!q) { box.innerHTML = '<div class="stats-empty">输入关键字后回车</div>'; return }
const hits = []
for (const s of S.shapes) {
if (!s.plain) continue
if (s.plain.toLowerCase().includes(q)) hits.push(s)
if (hits.length >= 300) break
}
if (!hits.length) { box.innerHTML = '<div class="stats-empty">没有匹配的文字</div>'; return }
for (const s of hits) {
const row = document.createElement('div')
row.className = 'find-row'
row.innerHTML = `${escapeHtml(s.plain.slice(0, 60))}<span class="find-layer">${escapeHtml(s.layer)}</span>`
row.onclick = () => {
const b = s.bbox
const pad = Math.max((b[2] - b[0]), (b[3] - b[1])) * 6 + 1
renderer.zoomToBox([b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad])
renderer.selection.clear()
renderer.selection.add(s.i)
renderer.requestDraw()
}
box.appendChild(row)
}
setStatus(`找到 ${hits.length} 处文字`)
}
const escapeHtml = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
// ---------------------------------------------------------------- 导出
function snapshot() {
renderer.draw()
renderer.canvas.toBlob((blob) => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${(S.doc && S.doc.name || 'drawing').replace(/\.[^.]+$/, '')}.png`
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 5000)
}, 'image/png')
}
function printDrawing() {
if (!S.doc) return
renderer.draw()
const url = renderer.canvas.toDataURL('image/png')
const w = window.open('', '_blank')
if (!w) { showError('浏览器拦截了打印窗口,请允许弹出窗口'); return }
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(S.doc.name)}</title>
<style>@page{size:auto;margin:8mm}body{margin:0}img{width:100%}</style></head>
<body><img src="${url}" onload="window.focus();window.print();"></body></html>`)
w.document.close()
}
// ---------------------------------------------------------------- 嵌入
function updateEmbed() {
// 默认沿用图纸真正打开时的路径(相对查看器目录),避免生成打不开的裸文件名链接;
// 本地拖进来的图纸没有服务器路径,得先「上传当前图纸」。
// 用 ?file= 打开时把真实路径回填到输入框(与 STEPViewer 行为一致),
// 否则「图纸路径」框空着、只有地址生成,容易误以为坏了。
if (S.loadPath && !$('embedPath').value.trim()) $('embedPath').value = S.loadPath
const path = $('embedPath').value.trim()
const base = location.origin + location.pathname
if (!path) {
$('embedUrl').value = ''
$('embedCode').value = ''
return
}
const url = `${base}?file=${encodeURIComponent(path)}&embed=1`
$('embedUrl').value = url
$('embedCode').value = `<iframe src="${url}" width="100%" height="640" frameborder="0" allowfullscreen></iframe>`
}
$('embedPath').addEventListener('input', updateEmbed)
$('btnUpload').onclick = async () => {
const btn = $('btnUpload')
btn.disabled = true
btn.textContent = '上传中...'
try {
const path = await uploadCurrent()
if (!path) throw new Error('上传接口返回异常(接口未部署)')
$('embedPath').value = path
updateEmbed()
setStatus(`已加密上传:${path}`)
// 嵌在入口页里时通知它刷新首页数模库
try { window.parent.postMessage({ type: 'library-updated' }, '*') } catch { /* 独立打开 */ }
} catch (e) {
showError(`上传失败:${e.message}(本地开发服务器没有 PHP需要在正式站点上试`)
} finally {
btn.disabled = false
btn.textContent = '上传当前图纸'
}
}
$('btnCopyUrl').onclick = () => copy($('embedUrl').value)
$('btnCopyCode').onclick = () => copy($('embedCode').value)
function copy(t) {
navigator.clipboard.writeText(t).then(() => setStatus('已复制到剪贴板'), () => setStatus('复制失败,请手动选择'))
}
// ---------------------------------------------------------------- 杂项
function setStatus(t) { $('statusText').textContent = t || TOOL_HINT[S.tool] || '就绪' }
new ResizeObserver(() => renderer.resize()).observe(wrap)
// 拖拽打开
let dragDepth = 0
document.addEventListener('dragenter', (e) => { e.preventDefault(); dragDepth++; $('dropOverlay').classList.remove('hidden') })
document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; $('dropOverlay').classList.add('hidden') } })
document.addEventListener('dragover', (e) => e.preventDefault())
document.addEventListener('drop', (e) => {
e.preventDefault()
dragDepth = 0
$('dropOverlay').classList.add('hidden')
const f = e.dataTransfer.files[0]
if (f) openFile(f)
})
// 调试用:控制台里可以直接摸到渲染器、文档和工具
window.__R = renderer
window.__S = S
window.__ANNO = anno
window.__MEASURE = measure
// 恢复上次的背景偏好;嵌入时可用 ?bg=black|beige|grey 直接指定
let savedBg = BACKGROUNDS[0].id
try { savedBg = params.get('bg') || localStorage.getItem('dwgviewer.bg') || savedBg } catch { /* 隐私模式读不到 */ }
setBackground(savedBg === 'dark' ? 'black' : savedBg)
// 先画一次底色,否则 alpha:false 的画布初始是黑的
renderer.draw()
setStatus()
// 统一入口(../index.html在 iframe 里把用户选的图纸传进来
window.addEventListener('message', (e) => {
const data = e.data
if (!data || data.type !== 'viewer-file' || data.kind !== 'dwg') return
try { if (e.origin !== location.origin) return } catch { return }
openBuffer(data.name || 'drawing.dwg', data.buffer)
})
try { window.parent.postMessage({ type: 'viewer-ready' }, '*') } catch { /* 没嵌在入口里 */ }
// URL 参数直接打开
const fileParam = params.get('file')
if (fileParam) openUrl(fileParam, fileParam)