Download app.js from HelloSun/WFreeCAD: direct link, hf CLI and curl.
- Browser
- Download file 22.3 kB
-
https://huggingface.co/spaces/HelloSun/WFreeCAD/resolve/main/app.js
- Command line
-
hf download hf://spaces/HelloSun/WFreeCAD/app.js
-
curl -L -o app.js https://huggingface.co/spaces/HelloSun/WFreeCAD/resolve/main/app.js
22.3 kB
| import { createViewer } from './viewer.js'; | |
| const DEFAULT_VIEW = [0.86, -1.35, 0.98]; | |
| const MAX_FILE_BYTES = 512 * 1024; | |
| const byId = (id) => document.getElementById(id); | |
| const els = { | |
| runtime: byId('runtime'), | |
| runtimeText: byId('runtimeText'), | |
| loadBtn: byId('loadBtn'), | |
| fileInput: byId('fileInput'), | |
| exampleList: byId('exampleList'), | |
| exampleCount: byId('exampleCount'), | |
| fileName: byId('fileName'), | |
| fileState: byId('fileState'), | |
| revertBtn: byId('revertBtn'), | |
| runBtn: byId('runBtn'), | |
| editorWrap: byId('editorWrap'), | |
| cursorInfo: byId('cursorInfo'), | |
| objectCount: byId('objectCount'), | |
| autoBtn: byId('autoBtn'), | |
| resetBtn: byId('resetBtn'), | |
| canvasWrap: byId('canvasWrap'), | |
| view: byId('view'), | |
| canvasHint: byId('canvasHint'), | |
| loadingState: byId('loadingState'), | |
| loadingText: byId('loadingText'), | |
| emptyState: byId('emptyState'), | |
| emptyHint: byId('emptyHint'), | |
| errorState: byId('errorState'), | |
| errorText: byId('errorText'), | |
| dismissError: byId('dismissError'), | |
| toast: byId('toast'), | |
| dropOverlay: byId('dropOverlay'), | |
| }; | |
| const state = { | |
| examples: [], | |
| current: null, | |
| mode: 'example', | |
| source: '', | |
| fileName: '未命名.py', | |
| dirty: false, | |
| scene: null, | |
| sceneSource: '', | |
| busy: false, | |
| pendingRun: null, | |
| uiSeq: 0, | |
| cm: null, | |
| viewer: null, | |
| toastTimer: null, | |
| hintTimer: null, | |
| }; | |
| const RUNTIME_LABELS = { | |
| idle: 'Python 待命', | |
| loading: 'Python 載入中', | |
| ready: 'Python 就緒', | |
| busy: 'Python 執行中', | |
| error: 'Python 離線', | |
| }; | |
| class PythonRuntime { | |
| constructor() { | |
| this.worker = null; | |
| this.ready = false; | |
| this.failed = false; | |
| this.shim = null; | |
| this.initPromise = null; | |
| this.seq = 0; | |
| this.pending = new Map(); | |
| this.onState = () => {}; | |
| this.lastError = ''; | |
| this._readyResolve = null; | |
| this._readyReject = null; | |
| this._initTimer = null; | |
| } | |
| setState(name, detail = '') { | |
| this.state = name; | |
| this.onState(name, detail); | |
| } | |
| async loadShim() { | |
| if (this.shim) return this.shim; | |
| const response = await fetch('cadshim.py', { cache: 'no-cache' }); | |
| if (!response.ok) { | |
| throw new Error(`無法載入 Python shim(HTTP ${response.status})`); | |
| } | |
| const text = await response.text(); | |
| if (!text.includes('__wfcad_runtime')) { | |
| throw new Error('Python shim 內容不正確'); | |
| } | |
| this.shim = text; | |
| return text; | |
| } | |
| preload() { | |
| this.ensure().catch(() => {}); | |
| } | |
| async ensure() { | |
| if (this.ready && this.worker) return; | |
| if (this.initPromise) return this.initPromise; | |
| this.initPromise = this.boot(); | |
| try { | |
| await this.initPromise; | |
| } catch (error) { | |
| if (this.initPromise === null) throw error; | |
| this.initPromise = null; | |
| throw error; | |
| } | |
| } | |
| async boot() { | |
| this.setState('loading'); | |
| let shim; | |
| try { | |
| shim = await this.loadShim(); | |
| } catch (error) { | |
| this.fail(error); | |
| throw error; | |
| } | |
| let worker; | |
| try { | |
| worker = new Worker('worker.js'); | |
| } catch (error) { | |
| this.fail(error); | |
| throw error; | |
| } | |
| this.worker = worker; | |
| worker.onmessage = (event) => this.handleMessage(event.data); | |
| worker.onerror = (event) => { | |
| this.fail(event.message || 'Python worker 發生錯誤'); | |
| }; | |
| const ready = new Promise((resolve, reject) => { | |
| this._readyResolve = resolve; | |
| this._readyReject = reject; | |
| }); | |
| this._initTimer = setTimeout(() => { | |
| this.fail('Python 執行環境載入逾時,請檢查網路後重試。'); | |
| }, 90000); | |
| worker.postMessage({ type: 'init', shim }); | |
| await ready; | |
| } | |
| handleMessage(message) { | |
| if (!message) return; | |
| if (message.type === 'ready') { | |
| if (this._initTimer) { | |
| clearTimeout(this._initTimer); | |
| this._initTimer = null; | |
| } | |
| this.ready = true; | |
| this.failed = false; | |
| this.setState('ready'); | |
| const resolve = this._readyResolve; | |
| this._readyResolve = null; | |
| this._readyReject = null; | |
| if (resolve) resolve(); | |
| return; | |
| } | |
| if (message.type === 'fatal') { | |
| this.fail(message.error || 'Python 執行環境發生錯誤'); | |
| return; | |
| } | |
| if (message.type === 'result' || message.type === 'error') { | |
| const pending = this.pending.get(message.id); | |
| if (!pending) return; | |
| this.pending.delete(message.id); | |
| if (message.type === 'result') { | |
| pending.resolve(message.result); | |
| } else { | |
| pending.reject(new Error(message.error || 'Python 執行失敗')); | |
| } | |
| } | |
| } | |
| fail(error) { | |
| const reason = error instanceof Error ? error.message : String(error || 'Python 執行環境發生錯誤'); | |
| this.lastError = reason; | |
| this.ready = false; | |
| this.failed = true; | |
| if (this._initTimer) { | |
| clearTimeout(this._initTimer); | |
| this._initTimer = null; | |
| } | |
| if (this.worker) { | |
| this.worker.terminate(); | |
| this.worker = null; | |
| } | |
| this.initPromise = null; | |
| const rejectReady = this._readyReject; | |
| this._readyResolve = null; | |
| this._readyReject = null; | |
| if (rejectReady) rejectReady(new Error(reason)); | |
| for (const pending of [...this.pending.values()]) { | |
| pending.reject(new Error(reason)); | |
| } | |
| this.pending.clear(); | |
| this.setState('error', reason); | |
| } | |
| async run(code) { | |
| await this.ensure(); | |
| if (!this.worker) throw new Error('Python 執行環境不在線'); | |
| this.setState('busy'); | |
| const id = ++this.seq; | |
| return new Promise((resolve, reject) => { | |
| const timer = setTimeout(() => { | |
| this.pending.delete(id); | |
| this.restart(); | |
| reject(new Error('執行逾時。程式可能進入了無窮迴圈,已停止執行。')); | |
| }, 20000); | |
| this.pending.set(id, { | |
| resolve: (value) => { | |
| clearTimeout(timer); | |
| this.finishRun(); | |
| resolve(value); | |
| }, | |
| reject: (error) => { | |
| clearTimeout(timer); | |
| this.finishRun(); | |
| reject(error); | |
| }, | |
| }); | |
| this.worker.postMessage({ type: 'run', id, code }); | |
| }); | |
| } | |
| finishRun() { | |
| if (this.ready) { | |
| this.setState('ready'); | |
| } else if (this.failed) { | |
| this.setState('error', this.lastError); | |
| } | |
| } | |
| restart() { | |
| if (this.worker) { | |
| this.worker.terminate(); | |
| this.worker = null; | |
| } | |
| this.ready = false; | |
| this.failed = false; | |
| this.initPromise = null; | |
| this.pending.clear(); | |
| this.setState('idle'); | |
| } | |
| } | |
| const runtime = new PythonRuntime(); | |
| runtime.onState = (name, detail) => { | |
| els.runtime.dataset.state = name; | |
| els.runtimeText.textContent = RUNTIME_LABELS[name] || 'Python'; | |
| if (name === 'error') { | |
| els.runtime.title = detail || 'Python 執行環境無法載入'; | |
| } else { | |
| els.runtime.title = 'Python 執行環境'; | |
| } | |
| }; | |
| // -------------------------------------------------------------------------- | |
| // small helpers | |
| // -------------------------------------------------------------------------- | |
| function escapeHtml(value) { | |
| return String(value).replace(/[&<>"']/g, (char) => { | |
| const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; | |
| return map[char]; | |
| }); | |
| } | |
| function cleanError(value) { | |
| let text = value instanceof Error ? value.stack || value.message : String(value || '未知錯誤'); | |
| text = text.replace(/\u001b\[[0-9;]*m/g, '').trim(); | |
| if (text.length > 7000) text = `${text.slice(0, 7000)}\n…`; | |
| return text || '未知錯誤'; | |
| } | |
| function hideToast() { | |
| if (state.toastTimer) clearTimeout(state.toastTimer); | |
| state.toastTimer = null; | |
| els.toast.hidden = true; | |
| } | |
| function toast(message, tone = 'info') { | |
| hideToast(); | |
| els.toast.textContent = message; | |
| els.toast.dataset.tone = tone; | |
| els.toast.hidden = false; | |
| state.toastTimer = setTimeout(() => { | |
| els.toast.hidden = true; | |
| state.toastTimer = null; | |
| }, tone === 'error' ? 4200 : 2800); | |
| } | |
| function showLoading(text) { | |
| hideToast(); | |
| if (text) els.loadingText.textContent = text; | |
| els.loadingState.hidden = false; | |
| els.emptyState.hidden = true; | |
| els.errorState.hidden = true; | |
| } | |
| function showError(message) { | |
| hideToast(); | |
| els.errorText.textContent = cleanError(message); | |
| els.errorState.hidden = false; | |
| els.loadingState.hidden = true; | |
| els.emptyState.hidden = true; | |
| els.canvasHint.classList.add('is-hidden'); | |
| els.fileState.textContent = '錯誤'; | |
| els.fileState.dataset.state = 'error'; | |
| } | |
| function hideError() { | |
| if (!els.errorState.hidden) { | |
| els.errorState.hidden = true; | |
| updateFileUI(); | |
| } | |
| } | |
| function updateCursor() { | |
| if (!state.cm) return; | |
| const cursor = state.cm.getCursor(); | |
| els.cursorInfo.textContent = `Ln ${cursor.line + 1}, Col ${cursor.ch + 1}`; | |
| } | |
| function updateRunButton() { | |
| els.runBtn.disabled = state.busy; | |
| els.runBtn.setAttribute('aria-busy', String(state.busy)); | |
| els.runBtn.classList.toggle('is-busy', state.busy); | |
| } | |
| function updateFileUI() { | |
| els.fileName.textContent = state.fileName || '未命名.py'; | |
| document.title = `${state.fileName || 'WFreeCAD'} · WFreeCAD`; | |
| els.revertBtn.disabled = !(state.mode === 'example' && state.current); | |
| els.revertBtn.title = els.revertBtn.disabled ? '只有範例可以還原' : '回到範例程式'; | |
| let label = '範例'; | |
| let kind = 'example'; | |
| if (state.mode === 'upload') { | |
| label = state.dirty ? '已修改' : '已載入'; | |
| kind = state.dirty ? 'modified' : 'upload'; | |
| } else if (state.dirty) { | |
| label = '已修改'; | |
| kind = 'modified'; | |
| } | |
| els.fileState.textContent = label; | |
| els.fileState.dataset.state = kind; | |
| } | |
| function updateActive(id) { | |
| const cards = els.exampleList.querySelectorAll('.example-card'); | |
| for (const card of cards) { | |
| const active = card.dataset.id === id; | |
| card.classList.toggle('is-active', active); | |
| if (active) { | |
| card.setAttribute('aria-current', 'true'); | |
| } else { | |
| card.removeAttribute('aria-current'); | |
| } | |
| } | |
| } | |
| // -------------------------------------------------------------------------- | |
| // editor | |
| // -------------------------------------------------------------------------- | |
| function initEditor() { | |
| state.cm = CodeMirror.fromTextArea(byId('editor'), { | |
| mode: 'python', | |
| lineNumbers: true, | |
| indentUnit: 4, | |
| tabSize: 4, | |
| indentWithTabs: false, | |
| lineWrapping: true, | |
| matchBrackets: true, | |
| autoCloseBrackets: true, | |
| styleActiveLine: true, | |
| viewportMargin: 40, | |
| extraKeys: { | |
| Tab: (editor) => { | |
| if (editor.somethingSelected()) { | |
| editor.indentSelection('add'); | |
| } else { | |
| editor.replaceSelection(' ', 'end'); | |
| } | |
| }, | |
| }, | |
| }); | |
| state.cm.setSize('100%', '100%'); | |
| state.cm.on('change', () => { | |
| const value = state.cm.getValue(); | |
| state.dirty = value !== state.source; | |
| updateFileUI(); | |
| updateCursor(); | |
| if (!els.errorState.hidden) hideError(); | |
| }); | |
| state.cm.on('cursorActivity', updateCursor); | |
| state.cm.on('focus', () => runtime.preload()); | |
| requestAnimationFrame(() => state.cm.refresh()); | |
| updateCursor(); | |
| } | |
| function setEditor(text, { mode, name } = {}) { | |
| state.source = text; | |
| if (mode) state.mode = mode; | |
| if (name) state.fileName = name; | |
| state.dirty = false; | |
| state.cm.setValue(text || ''); | |
| state.cm.clearHistory(); | |
| state.cm.setCursor({ line: 0, ch: 0 }); | |
| updateFileUI(); | |
| updateCursor(); | |
| requestAnimationFrame(() => state.cm.refresh()); | |
| } | |
| // -------------------------------------------------------------------------- | |
| // preview | |
| // -------------------------------------------------------------------------- | |
| function showScene(scene, view) { | |
| if (!scene) { | |
| showError('沒有可顯示的場景'); | |
| return { objects: 0, skipped: 0 }; | |
| } | |
| const stats = state.viewer.setScene(scene, view || DEFAULT_VIEW); | |
| els.objectCount.textContent = `${stats.objects} 個物件`; | |
| els.loadingState.hidden = true; | |
| els.errorState.hidden = true; | |
| if (stats.objects === 0) { | |
| els.emptyState.hidden = false; | |
| if (stats.skipped > 0) { | |
| els.emptyHint.textContent = '場景中的幾何使用了尚未支援的 FreeCAD API'; | |
| } else { | |
| els.emptyHint.innerHTML = '在程式裡呼叫 <code>Part.show(shape)</code>'; | |
| } | |
| } else { | |
| els.emptyState.hidden = true; | |
| } | |
| return stats; | |
| } | |
| function presentScene(scene, view, { notify = false } = {}) { | |
| const stats = showScene(scene, view); | |
| const errors = (scene && scene.errors) || []; | |
| if (stats.objects === 0 && errors.length) { | |
| showError(errors.join('\n')); | |
| } else if (errors.length) { | |
| toast(`有 ${errors.length} 個物件計算失敗`, 'error'); | |
| } else if (stats.objects === 0) { | |
| // Empty state already visible. | |
| } else if (stats.skipped > 0) { | |
| toast(`已顯示 ${stats.objects} 個物件,${stats.skipped} 個未支援`, 'error'); | |
| } else if (notify) { | |
| toast('執行完成'); | |
| } | |
| return stats; | |
| } | |
| // -------------------------------------------------------------------------- | |
| // examples | |
| // -------------------------------------------------------------------------- | |
| async function loadExamples() { | |
| const indexResponse = await fetch('examples/index.json', { cache: 'no-cache' }); | |
| if (!indexResponse.ok) { | |
| throw new Error(`無法載入範例索引(HTTP ${indexResponse.status})`); | |
| } | |
| const index = await indexResponse.json(); | |
| const examples = await Promise.all( | |
| index.map(async (item) => { | |
| const [codeResponse, sceneResponse] = await Promise.all([ | |
| fetch(`examples/${item.code}`, { cache: 'no-cache' }), | |
| fetch(`scenes/${item.scene}`, { cache: 'no-cache' }), | |
| ]); | |
| if (!codeResponse.ok || !sceneResponse.ok) { | |
| throw new Error(`無法載入 ${item.id} 的範例檔`); | |
| } | |
| return { | |
| ...item, | |
| codeText: await codeResponse.text(), | |
| sceneData: await sceneResponse.json(), | |
| }; | |
| }), | |
| ); | |
| state.examples = examples; | |
| els.exampleCount.textContent = String(examples.length).padStart(2, '0'); | |
| renderExamples(); | |
| return examples; | |
| } | |
| function renderExamples() { | |
| els.exampleList.innerHTML = ''; | |
| const fragment = document.createDocumentFragment(); | |
| state.examples.forEach((item, index) => { | |
| const button = document.createElement('button'); | |
| button.type = 'button'; | |
| button.className = 'example-card'; | |
| button.dataset.id = item.id; | |
| button.innerHTML = ` | |
| <span class="example-thumb"> | |
| <img src="thumbs/${encodeURIComponent(item.thumb)}" alt="" loading="lazy" draggable="false"> | |
| </span> | |
| <span class="example-meta"> | |
| <span class="example-title">${escapeHtml(item.title || item.id)}</span> | |
| <span class="example-sub">${escapeHtml(item.subtitle || item.code)}</span> | |
| </span> | |
| <span class="example-index">${String(index + 1).padStart(2, '0')}</span> | |
| <span class="example-chev" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="m9 6 6 6-6 6"/> | |
| </svg> | |
| </span> | |
| `; | |
| button.addEventListener('click', () => loadExample(item)); | |
| fragment.appendChild(button); | |
| }); | |
| els.exampleList.appendChild(fragment); | |
| } | |
| function loadExample(item, { initial = false } = {}) { | |
| if (!item) return; | |
| hideToast(); | |
| state.uiSeq += 1; | |
| state.pendingRun = null; | |
| state.current = item; | |
| state.mode = 'example'; | |
| state.fileName = item.code; | |
| state.source = item.codeText; | |
| state.dirty = false; | |
| state.scene = item.sceneData; | |
| state.sceneSource = item.codeText; | |
| setEditor(item.codeText, { mode: 'example', name: item.code }); | |
| updateActive(item.id); | |
| hideError(); | |
| presentScene(item.sceneData, item.view); | |
| if (initial) { | |
| els.canvasHint.classList.remove('is-hidden'); | |
| if (state.hintTimer) clearTimeout(state.hintTimer); | |
| state.hintTimer = setTimeout(() => els.canvasHint.classList.add('is-hidden'), 9000); | |
| } | |
| } | |
| // -------------------------------------------------------------------------- | |
| // running | |
| // -------------------------------------------------------------------------- | |
| async function runCode(code) { | |
| if (state.busy) { | |
| state.pendingRun = { code }; | |
| toast('正在執行,完成後會接著執行'); | |
| return; | |
| } | |
| state.busy = true; | |
| updateRunButton(); | |
| const uiSeq = state.uiSeq; | |
| showLoading(runtime.ready ? '正在解析 Part 指令…' : 'Python 執行環境載入中…(首次約 10 MB)'); | |
| runtime.preload(); | |
| try { | |
| const result = await runtime.run(code); | |
| if (uiSeq !== state.uiSeq) return; | |
| if (!result || !result.ok) { | |
| showError((result && result.error) || '執行失敗'); | |
| return; | |
| } | |
| state.scene = result.scene; | |
| state.sceneSource = code; | |
| presentScene(result.scene, state.current ? state.current.view : DEFAULT_VIEW, { notify: true }); | |
| } catch (error) { | |
| if (uiSeq !== state.uiSeq) return; | |
| showError(error && error.message ? error.message : error); | |
| } finally { | |
| state.busy = false; | |
| updateRunButton(); | |
| if (state.pendingRun) { | |
| const pending = state.pendingRun; | |
| state.pendingRun = null; | |
| runCode(pending.code); | |
| } | |
| } | |
| } | |
| function requestRun(code) { | |
| runtime.preload(); | |
| if (state.busy) { | |
| state.pendingRun = { code }; | |
| toast('正在執行,完成後會接著執行'); | |
| return; | |
| } | |
| runCode(code); | |
| } | |
| function runCurrent() { | |
| const code = state.cm.getValue(); | |
| if (state.mode === 'example' && state.current && !state.dirty && state.current.sceneData) { | |
| state.uiSeq += 1; | |
| presentScene(state.current.sceneData, state.current.view); | |
| if (els.errorState.hidden) toast('已重新載入範例預覽'); | |
| return; | |
| } | |
| requestRun(code); | |
| } | |
| // -------------------------------------------------------------------------- | |
| // files | |
| // -------------------------------------------------------------------------- | |
| async function loadFile(file) { | |
| if (!file) return; | |
| if (!/\.(py|txt)$/i.test(file.name)) { | |
| toast('請選擇 .py 檔', 'error'); | |
| return; | |
| } | |
| if (file.size > MAX_FILE_BYTES) { | |
| toast('檔案太大(上限 512 KB)', 'error'); | |
| return; | |
| } | |
| try { | |
| const text = await file.text(); | |
| state.uiSeq += 1; | |
| state.pendingRun = null; | |
| state.current = null; | |
| state.mode = 'upload'; | |
| state.fileName = file.name; | |
| state.source = text; | |
| state.dirty = false; | |
| state.scene = null; | |
| state.sceneSource = ''; | |
| setEditor(text, { mode: 'upload', name: file.name }); | |
| updateActive(null); | |
| hideError(); | |
| requestRun(text); | |
| } catch (error) { | |
| showError(error && error.message ? error.message : error); | |
| } | |
| } | |
| // -------------------------------------------------------------------------- | |
| // ui events | |
| // -------------------------------------------------------------------------- | |
| function bindUI() { | |
| els.loadBtn.addEventListener('click', () => { | |
| runtime.preload(); | |
| els.fileInput.click(); | |
| }); | |
| els.fileInput.addEventListener('change', () => { | |
| const file = els.fileInput.files && els.fileInput.files[0]; | |
| if (file) loadFile(file); | |
| els.fileInput.value = ''; | |
| }); | |
| els.runBtn.addEventListener('click', runCurrent); | |
| els.revertBtn.addEventListener('click', () => { | |
| if (!state.current) return; | |
| state.uiSeq += 1; | |
| state.pendingRun = null; | |
| loadExample(state.current); | |
| toast('已回到範例程式'); | |
| }); | |
| els.dismissError.addEventListener('click', hideError); | |
| els.autoBtn.addEventListener('click', () => { | |
| const enabled = !state.viewer.isAutoRotate(); | |
| state.viewer.setAutoRotate(enabled); | |
| els.autoBtn.classList.toggle('is-on', enabled); | |
| els.autoBtn.setAttribute('aria-pressed', String(enabled)); | |
| els.autoBtn.title = enabled ? '自動旋轉' : '自動旋轉(已暫停)'; | |
| }); | |
| els.resetBtn.addEventListener('click', () => { | |
| state.viewer.resetView(); | |
| els.canvasHint.classList.add('is-hidden'); | |
| }); | |
| els.view.addEventListener( | |
| 'pointerdown', | |
| () => { | |
| els.canvasHint.classList.add('is-hidden'); | |
| }, | |
| { once: true }, | |
| ); | |
| window.addEventListener('keydown', (event) => { | |
| if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { | |
| event.preventDefault(); | |
| runCurrent(); | |
| } else if (event.key === 'Escape') { | |
| hideError(); | |
| els.dropOverlay.hidden = true; | |
| } | |
| }); | |
| let dragDepth = 0; | |
| const hasFiles = (event) => { | |
| const types = event.dataTransfer && event.dataTransfer.types; | |
| return Boolean(types && Array.from(types).includes('Files')); | |
| }; | |
| window.addEventListener('dragenter', (event) => { | |
| if (!hasFiles(event)) return; | |
| event.preventDefault(); | |
| dragDepth += 1; | |
| els.dropOverlay.hidden = false; | |
| }); | |
| window.addEventListener('dragover', (event) => { | |
| if (!hasFiles(event)) return; | |
| event.preventDefault(); | |
| if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; | |
| }); | |
| window.addEventListener('dragleave', (event) => { | |
| if (!hasFiles(event)) return; | |
| dragDepth = Math.max(0, dragDepth - 1); | |
| if (dragDepth === 0) els.dropOverlay.hidden = true; | |
| }); | |
| window.addEventListener('drop', (event) => { | |
| event.preventDefault(); | |
| dragDepth = 0; | |
| els.dropOverlay.hidden = true; | |
| const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]; | |
| if (file) loadFile(file); | |
| }); | |
| let resizeTimer = null; | |
| window.addEventListener('resize', () => { | |
| if (resizeTimer) clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(() => { | |
| if (state.cm) state.cm.refresh(); | |
| }, 120); | |
| }); | |
| } | |
| function schedulePreload() { | |
| const connection = | |
| navigator.connection || navigator.mozConnection || navigator.webkitConnection; | |
| if (connection && (connection.saveData || /2g/.test(connection.effectiveType || ''))) { | |
| return; | |
| } | |
| setTimeout(() => runtime.preload(), 5000); | |
| } | |
| async function init() { | |
| state.viewer = createViewer(els.view, { autoRotate: true }); | |
| initEditor(); | |
| bindUI(); | |
| updateFileUI(); | |
| showLoading('正在載入範例…'); | |
| try { | |
| const examples = await loadExamples(); | |
| if (examples.length > 0) { | |
| loadExample(examples[0], { initial: true }); | |
| } else { | |
| showError('找不到任何範例。'); | |
| } | |
| } catch (error) { | |
| showError(error && error.message ? error.message : error); | |
| } | |
| schedulePreload(); | |
| } | |
| init().catch((error) => { | |
| console.error(error); | |
| showError(error && error.message ? error.message : error); | |
| }); | |