// ==UserScript== // @name ET 表格预览器(拖入 .et 文件直接查看) // @namespace http://tampermonkey.net/ // @version 1.1.0 // @description 点右下角按钮打开预览页,把 WPS 表格文件(.et/.ett,兼容 .xls/.xlsx/.xlsm/.csv)拖进去就能看;也可直接在任意网页拖入。支持多工作表切换、查找过滤、自动换行、单元格详情、复制、导出 CSV、一键转存 XLSX,大表格渐进加载不卡顿。 // @author xizi // @match *://*/* // @match file:///* // @require https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant unsafeWindow // @run-at document-start // @noframes // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMTYyMTFhIiBzdHJva2U9IiM1YWQwN2EiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHBhdGggZD0iTTYgNy41aDEyTTYgMTJoMTJNNiAxNi41aDEyTTEwLjUgNy41djkiIHN0cm9rZT0iIzVhZDA3YSIgc3Ryb2tlLXdpZHRoPSIxLjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg== // ==/UserScript== /* * 功能一览 * 1. 把 .et(WPS 表格)文件拖到网页任意位置,松手即预览;也可以点右下角悬浮按钮 / 油猴菜单选择文件。 * 2. .et 有两种常见形态:老版本是 OLE2 复合文档(BIFF8),新版本是 ZIP 包(等同 xlsx)。脚本按文件头自动识别, * 两种都能直接解析,解析工作由 SheetJS 完成(已在 @require 里内置)。 * 3. 预览界面:多工作表标签、查找过滤(命中高亮)、自动换行、单元格详情、复制、导出 CSV、转存 XLSX。 * 4. 大表格渐进渲染:滚动到底部自动追加,导出始终是完整数据。 * * 说明:脚本只接管拖入的表格文件,其它文件的拖放会原样交还给网页,尽量不影响目标站点自身的拖拽功能。 */ (function () { 'use strict'; /* ============================================================ * 0. 常量与全局状态 * ============================================================ */ const VERSION = '1.1.0'; const ROOT_ID = 'et-preview-root'; const BALL_ID = 'et-preview-ball'; const BANNER_ID = 'et-preview-banner'; const TOAST_ID = 'et-preview-toast'; // 支持的文件后缀:前两个是脚本的主场(WPS 表格),后面几个是顺带支持 const EXT_ET = ['et', 'ett']; const EXT_OTHER = ['xls', 'xlsx', 'xlsm', 'xlsb', 'csv']; // 解析库地址:即使 @require 失败,运行期也会按顺序再试一遍 const CDN_LIST = [ 'https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js', 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://fastly.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js', 'https://cdn.staticfile.org/xlsx/0.18.5/xlsx.full.min.js' ]; const CHUNK_ROWS = 150; // 每次追加渲染的行数 const RENDER_STEP = 20000; // 提高渲染上限时的步长 const COVER_LIMIT = 500000; // 合并区域覆盖点统计上限(防御异常文件) const state = { wb: null, // SheetJS 工作簿对象 fileName: '', fileSize: 0, sheetIndex: 0, models: new Map(), // 工作表名 -> 渲染模型 filter: '', visibleRows: [], rendered: 0, renderCap: RENDER_STEP, selected: null, // { r, c } xlsxPromise: null }; const refs = {}; // DOM 引用集合 let xlsxLib = null; // 解析库缓存 let dragDepth = 0; let toastTimer = 0; /* ============================================================ * 1. 小工具 * ============================================================ */ function getXLSX() { if (xlsxLib) return xlsxLib; let lib = null; try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.XLSX) lib = unsafeWindow.XLSX; } catch (e) { /* 忽略 */ } if (!lib) { try { if (typeof XLSX !== 'undefined' && XLSX) lib = XLSX; } catch (e) { /* 忽略 */ } } if (!lib && window.XLSX) lib = window.XLSX; if (lib) xlsxLib = lib; return lib; } /** 确保解析库就绪:@require 已加载则直接命中缓存,否则按 CDN 列表兜底加载 */ function ensureXLSX() { const ready = getXLSX(); if (ready) return Promise.resolve(ready); if (state.xlsxPromise) return state.xlsxPromise; state.xlsxPromise = new Promise(function (resolve, reject) { let i = 0; const next = function () { if (i >= CDN_LIST.length) { reject(new Error('解析库加载失败(XLSX),请检查网络或脚本里的 @require 地址')); return; } const url = CDN_LIST[i++]; const s = document.createElement('script'); s.src = url; s.async = true; s.onload = function () { getXLSX() ? resolve(getXLSX()) : next(); }; s.onerror = function () { try { s.remove(); } catch (e) { /* 忽略 */ } next(); }; (document.head || document.documentElement).appendChild(s); }; next(); }).catch(function (err) { state.xlsxPromise = null; throw err; }); return state.xlsxPromise; } function extOf(name) { const i = String(name || '').lastIndexOf('.'); return i < 0 ? '' : String(name).slice(i + 1).toLowerCase(); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (ch) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]; }); } function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function fmtInt(n) { return Number(n || 0).toLocaleString('zh-CN'); } function fmtSize(n) { if (n == null) return ''; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let v = Number(n); while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + ' ' + units[i]; } /** 估算显示宽度:ASCII 记 1,中文等宽字符记 2 */ function visualUnits(text) { let u = 0; for (let i = 0; i < text.length; i++) { u += text.charCodeAt(i) < 0x2e80 ? 1 : 2; } return u; } function firstLine(text) { const s = String(text); const i = s.search(/\r|\n/); return i < 0 ? s : s.slice(0, i); } function toast(msg, type) { let box = document.getElementById(TOAST_ID); if (!box) { box = document.createElement('div'); box.id = TOAST_ID; (document.body || document.documentElement).appendChild(box); } box.className = 'et-toast et-show' + (type ? ' et-toast-' + type : ''); box.innerHTML = escapeHtml(msg).replace(/\n/g, '
'); clearTimeout(toastTimer); toastTimer = setTimeout(function () { box.classList.remove('et-show'); }, type === 'error' ? 6000 : 3200); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; (document.body || document.documentElement).appendChild(a); a.click(); setTimeout(function () { try { a.remove(); } catch (e) { /* 忽略 */ } URL.revokeObjectURL(url); }, 4000); } function baseName(name) { return String(name || 'workbook').replace(/\.[^.]+$/, ''); } function copyText(text, okMsg) { const done = function () { toast(okMsg || '已复制到剪贴板'); }; try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); done(); return; } } catch (e) { /* 继续尝试下面两种方案 */ } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text, done); }); return; } fallbackCopy(text, done); } function fallbackCopy(text, done) { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;'; (document.body || document.documentElement).appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } try { ta.remove(); } catch (e) { /* 忽略 */ } if (ok && done) done(); else toast('复制失败,请手动选择后复制', 'error'); } /* ============================================================ * 2. 样式 * ============================================================ */ const CSS = [ '#' + ROOT_ID + '{position:fixed;inset:0;z-index:2147483600;display:none;align-items:center;justify-content:center;', 'background:rgba(6,8,12,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);', 'font:13px/1.55 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;color:#e9edf5;}', '#' + ROOT_ID + '.et-show{display:flex;animation:et-fade .16s ease-out;}', '@keyframes et-fade{from{opacity:0}to{opacity:1}}', '#' + ROOT_ID + ' *{box-sizing:border-box;}', '#' + ROOT_ID + ' .et-panel{display:flex;flex-direction:column;width:min(1520px,96vw);height:min(920px,92vh);overflow:hidden;', 'background:linear-gradient(180deg,#171b24,#11141b);border:1px solid rgba(255,255,255,.1);border-radius:14px;', 'box-shadow:0 26px 90px rgba(0,0,0,.62);}', '#' + ROOT_ID + '.et-full .et-panel{width:100vw;height:100vh;border-radius:0;border:0;}', '#' + ROOT_ID + ' .et-head{display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.08);', 'background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,0));}', '#' + ROOT_ID + ' .et-badge{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:34px;height:24px;border-radius:6px;', 'background:rgba(90,208,122,.14);border:1px solid rgba(90,208,122,.45);color:#7ee29a;font-size:11px;font-weight:700;letter-spacing:.5px;}', '#' + ROOT_ID + ' .et-fname{font-size:14px;font-weight:600;max-width:52vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', '#' + ROOT_ID + ' .et-fmeta{color:#8d97a8;font-size:12px;white-space:nowrap;}', '#' + ROOT_ID + ' .et-grow{flex:1 1 auto;}', '#' + ROOT_ID + ' .et-btn{appearance:none;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#dbe3f0;', 'border-radius:8px;padding:5px 10px;font-size:12px;line-height:1.5;cursor:pointer;white-space:nowrap;transition:.15s;}', '#' + ROOT_ID + ' .et-btn:hover{background:rgba(90,208,122,.16);border-color:rgba(90,208,122,.5);color:#c9ffd9;}', '#' + ROOT_ID + ' .et-btn:active{transform:translateY(1px);}', '#' + ROOT_ID + ' .et-close{width:30px;padding:0;height:28px;font-size:14px;}', '#' + ROOT_ID + ' .et-tabs{display:flex;gap:6px;align-items:flex-end;padding:8px 14px 0;overflow-x:auto;overflow-y:hidden;}', '#' + ROOT_ID + ' .et-tabs:empty{display:none;}', '#' + ROOT_ID + ' .et-tab{flex:0 0 auto;border:1px solid rgba(255,255,255,.1);border-bottom:none;background:rgba(255,255,255,.04);', 'color:#a9b4c5;border-radius:8px 8px 0 0;padding:6px 12px;font-size:12px;cursor:pointer;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', '#' + ROOT_ID + ' .et-tab:hover{color:#e9edf5;}', '#' + ROOT_ID + ' .et-tab.et-on{background:rgba(90,208,122,.16);border-color:rgba(90,208,122,.5);color:#c9ffd9;font-weight:600;}', '#' + ROOT_ID + ' .et-toolbar{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,.08);flex-wrap:wrap;}', '#' + ROOT_ID + ' .et-search{width:min(320px,42vw);background:rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.14);border-radius:8px;', 'color:#e9edf5;padding:5px 10px;font-size:12px;outline:none;}', '#' + ROOT_ID + ' .et-search:focus{border-color:rgba(90,208,122,.6);}', '#' + ROOT_ID + ' .et-filterinfo{color:#f7c66b;font-size:12px;}', '#' + ROOT_ID + ' .et-chk{display:inline-flex;align-items:center;gap:5px;color:#b8c2d2;font-size:12px;cursor:pointer;user-select:none;}', '#' + ROOT_ID + ' .et-chk input{accent-color:#5ad07a;}', '#' + ROOT_ID + ' .et-body{flex:1 1 auto;display:flex;min-height:0;position:relative;}', '#' + ROOT_ID + ' .et-tablewrap{flex:1 1 auto;overflow:auto;background:#0d1016;position:relative;}', '#' + ROOT_ID + ' .et-tablewrap::-webkit-scrollbar{width:12px;height:12px;}', '#' + ROOT_ID + ' .et-tablewrap::-webkit-scrollbar-thumb{background:rgba(255,255,255,.16);border-radius:8px;border:3px solid #0d1016;}', '#' + ROOT_ID + ' .et-tablewrap::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.28);}', '#' + ROOT_ID + ' .et-welcome{flex:1 1 auto;display:none;flex-direction:column;align-items:center;justify-content:center;gap:12px;', 'text-align:center;padding:40px 24px;background:#0d1016;}', '#' + ROOT_ID + '.et-empty .et-welcome{display:flex;}', '#' + ROOT_ID + '.et-empty .et-tablewrap,#' + ROOT_ID + '.et-empty .et-side{display:none;}', '#' + ROOT_ID + ' .et-w-icon{font-size:44px;line-height:1;color:#5ad07a;opacity:.9;}', '#' + ROOT_ID + ' .et-w-title{font-size:20px;font-weight:600;color:#e7eef8;}', '#' + ROOT_ID + ' .et-w-sub{font-size:13px;color:#8d97a8;}', '#' + ROOT_ID + ' .et-w-sub b{color:#a8b6c9;font-weight:600;}', '#' + ROOT_ID + ' .et-w-btn{padding:8px 18px;font-size:13px;margin-top:2px;}', '#' + ROOT_ID + ' .et-w-hint{font-size:12px;color:#6f7f95;}', '#' + ROOT_ID + ' .et-table{border-collapse:separate;border-spacing:0;table-layout:fixed;min-width:100%;font-size:12.5px;}', '#' + ROOT_ID + ' .et-table th,#' + ROOT_ID + ' .et-table td{border-right:1px solid rgba(255,255,255,.06);border-bottom:1px solid rgba(255,255,255,.06);', 'padding:5px 8px;vertical-align:top;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', '#' + ROOT_ID + ' .et-table thead th{position:sticky;top:0;z-index:3;background:#1b2130;color:#93a0b5;font-weight:600;text-align:center;', 'font-size:11.5px;border-bottom:1px solid rgba(255,255,255,.12);}', '#' + ROOT_ID + ' .et-table .et-rh{position:sticky;left:0;z-index:2;background:#161b26;color:#7c8798;text-align:right;font-weight:400;', 'font-size:11.5px;min-width:52px;font-variant-numeric:tabular-nums;}', '#' + ROOT_ID + ' .et-table thead .et-rh{z-index:4;background:#1b2130;}', '#' + ROOT_ID + ' .et-table tbody tr:nth-child(even) td{background:rgba(255,255,255,.022);}', '#' + ROOT_ID + ' .et-table tbody tr:hover td{background:rgba(90,208,122,.08);}', '#' + ROOT_ID + ' .et-table td{cursor:cell;}', '#' + ROOT_ID + ' .et-num{text-align:right;color:#cfe6ff;font-variant-numeric:tabular-nums;}', '#' + ROOT_ID + ' .et-date{text-align:right;color:#8fd0ff;}', '#' + ROOT_ID + ' .et-bool{text-align:center;color:#b39ddb;}', '#' + ROOT_ID + ' .et-err{text-align:center;color:#ff8a8a;}', '#' + ROOT_ID + ' .et-merge{background:rgba(90,208,122,.05);}', '#' + ROOT_ID + ' .et-cell.et-sel{outline:2px solid #5ad07a;outline-offset:-2px;background:rgba(90,208,122,.14)!important;}', '#' + ROOT_ID + ' mark{background:rgba(247,198,107,.32);color:#ffe6b0;border-radius:2px;padding:0 1px;}', '#' + ROOT_ID + '.et-wrap-on .et-table th,#' + ROOT_ID + '.et-wrap-on .et-table td{white-space:pre-wrap;overflow-wrap:anywhere;}', '#' + ROOT_ID + ' .et-merge-tag{color:#7ee29a;font-size:11px;}', '#' + ROOT_ID + ' .et-side{flex:0 0 300px;border-left:1px solid rgba(255,255,255,.08);background:#141922;padding:12px;overflow:auto;}', '#' + ROOT_ID + ' .et-side-empty{color:#7c8798;font-size:12px;line-height:1.8;}', '#' + ROOT_ID + ' .et-side h4{margin:0 0 8px;font-size:12px;color:#93a0b5;font-weight:600;}', '#' + ROOT_ID + ' .et-kv{display:grid;grid-template-columns:64px 1fr;gap:4px 8px;font-size:12px;margin-bottom:10px;}', '#' + ROOT_ID + ' .et-kv b{color:#7c8798;font-weight:400;}', '#' + ROOT_ID + ' .et-sideval{background:rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.09);border-radius:8px;padding:8px;', 'font-size:12.5px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:44vh;overflow:auto;color:#dfe7f3;}', '#' + ROOT_ID + ' .et-foot{display:flex;align-items:center;gap:10px;padding:6px 14px;border-top:1px solid rgba(255,255,255,.08);', 'color:#8d97a8;font-size:12px;background:#12161e;flex-wrap:wrap;}', '#' + ROOT_ID + ' .et-foot b{color:#a8b6c9;font-weight:600;}', '#' + ROOT_ID + ' .et-loading{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:14px;', 'background:rgba(8,10,15,.72);z-index:9;font-size:13px;color:#c9d3e2;}', '#' + ROOT_ID + ' .et-loading.et-show{display:flex;}', '#' + ROOT_ID + ' .et-spin{width:34px;height:34px;border-radius:50%;border:3px solid rgba(90,208,122,.25);border-top-color:#5ad07a;animation:et-spin .8s linear infinite;}', '@keyframes et-spin{to{transform:rotate(360deg)}}', '#' + ROOT_ID + ' .et-help{max-width:560px;width:min(560px,92vw);height:auto;padding:22px 24px;line-height:1.9;font-size:13px;}', '#' + ROOT_ID + ' .et-help h3{margin:0 0 12px;font-size:15px;color:#c9ffd9;}', '#' + ROOT_ID + ' .et-help code{background:rgba(255,255,255,.08);border-radius:4px;padding:1px 5px;font-size:12px;}', '#' + ROOT_ID + ' .et-help ul{margin:6px 0 12px;padding-left:20px;color:#b8c2d2;}', '#' + BANNER_ID + '{position:fixed;left:50%;top:16px;transform:translate(-50%,-24px);z-index:2147483600;opacity:0;pointer-events:none;', 'display:flex;align-items:center;gap:10px;padding:10px 18px;border-radius:12px;background:linear-gradient(180deg,#1d2534,#141922);', 'border:1px solid rgba(90,208,122,.55);box-shadow:0 12px 40px rgba(0,0,0,.5);color:#d9f7e2;', 'font:13px/1.4 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;transition:opacity .16s,transform .16s;}', '#' + BANNER_ID + '.et-show{opacity:1;transform:translate(-50%,0);}', '#' + BANNER_ID + ' .et-dot{width:9px;height:9px;border-radius:50%;background:#5ad07a;box-shadow:0 0 0 4px rgba(90,208,122,.18);}', '#' + BALL_ID + '{position:fixed;right:18px;bottom:18px;z-index:2147483590;width:42px;height:42px;border-radius:50%;cursor:pointer;', 'display:none;align-items:center;justify-content:center;background:linear-gradient(180deg,#1d2534,#12161e);', 'border:1px solid rgba(90,208,122,.5);box-shadow:0 10px 26px rgba(0,0,0,.45);color:#7ee29a;font-size:18px;user-select:none;transition:.16s;}', '#' + BALL_ID + '.et-show{display:flex;}', '#' + BALL_ID + ':hover{transform:translateY(-2px);border-color:#5ad07a;color:#c9ffd9;}', '#' + TOAST_ID + '{position:fixed;left:50%;bottom:34px;transform:translate(-50%,20px);z-index:2147483610;max-width:min(520px,88vw);', 'padding:10px 16px;border-radius:10px;background:rgba(20,25,34,.96);border:1px solid rgba(90,208,122,.42);color:#dbe6f2;', 'font:13px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;box-shadow:0 14px 40px rgba(0,0,0,.5);', 'opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;}', '#' + TOAST_ID + '.et-show{opacity:1;transform:translate(-50%,0);}', '#' + TOAST_ID + '.et-toast-error{border-color:rgba(255,120,120,.55);color:#ffdada;}' ].join(''); function injectStyles() { try { if (typeof GM_addStyle === 'function') { GM_addStyle(CSS); return; } } catch (e) { /* 忽略,走下面的兜底方案 */ } const style = document.createElement('style'); style.textContent = CSS; (document.head || document.documentElement).appendChild(style); } /* ============================================================ * 3. 界面构建 * ============================================================ */ /** 拖拽提示条(懒创建:脚本在 document-start 就运行,那时可能还没有 body) */ function ensureBanner() { if (refs.banner || !document.body) return refs.banner; const banner = document.createElement('div'); banner.id = BANNER_ID; banner.innerHTML = '松手即可预览表格文件'; document.body.appendChild(banner); refs.banner = banner; return banner; } /** 右下角悬浮按钮(默认显示,可在油猴菜单里隐藏) */ function ensureBall() { if (refs.ball || !document.body) return refs.ball; const ball = document.createElement('div'); ball.id = BALL_ID; ball.title = '打开 ET 预览页(拖入或选择 .et 文件)'; ball.textContent = '▦'; ball.addEventListener('click', openPreviewPage); document.body.appendChild(ball); refs.ball = ball; return ball; } /** 界面只建一次;拖放事件可能在界面建好之前就发生,所以这里做成“按需创建” */ function ensureUI() { if (refs.root || !document.body) return; buildUI(); updateBallVisibility(); } function buildUI() { if (refs.root) return; ensureBanner(); ensureBall(); // 主面板 const root = document.createElement('div'); root.id = ROOT_ID; root.innerHTML = [ '
', '
', ' ET', ' 未选择文件', ' ', ' ', ' ', ' ', '
', '
', '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', '
', '
', ' ', ' ', ' ', ' ', '
', '
', ' ', '
', '
▦
', '
把 .et 文件拖到这里
', '
支持 WPS 表格的 .et / .ett,顺带支持 .xls / .xlsx / .xlsm / .csv
', ' ', '
拖放完成后自动解析 · Esc 关闭 · ← / → 切换工作表
', '
', '
解析中…
', '
', '
', '
' ].join(''); document.body.appendChild(root); root.querySelectorAll('[data-ref]').forEach(function (el) { refs[el.dataset.ref] = el; }); refs.root = root; refs.panel = root.querySelector('.et-panel'); // 按钮事件(用事件委托,便于动态生成的按钮共用) root.addEventListener('click', function (e) { const actEl = e.target && e.target.closest ? e.target.closest('[data-act]') : null; if (!actEl) return; const act = actEl.dataset.act; if (act === 'close') closePanel(); else if (act === 'full') refs.root.classList.toggle('et-full'); else if (act === 'pick') pickFiles(); else if (act === 'copy') exportCopy(); else if (act === 'csv') exportCSV(); else if (act === 'xlsx') exportXLSX(); else if (act === 'more') { state.renderCap += RENDER_STEP; renderChunk(); } }); // 点击单元格查看详情 refs.tbody.addEventListener('click', function (e) { const td = e.target && e.target.closest ? e.target.closest('td[data-r]') : null; if (!td) return; selectCell(Number(td.dataset.r), Number(td.dataset.c)); }); // 查找 let searchTimer = 0; refs.search.addEventListener('input', function () { clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.filter = refs.search.value || ''; applyFilter(); }, 180); }); // 面板内输入时不要让快捷键跑到页面去 refs.search.addEventListener('keydown', function (e) { e.stopPropagation(); }); // 自动换行 refs.wrapchk.addEventListener('change', function () { refs.root.classList.toggle('et-wrap-on', refs.wrapchk.checked); }); // 滚动到底部时继续追加渲染 let scrollTick = 0; refs.tablewrapScroll = function () { if (scrollTick) return; scrollTick = requestAnimationFrame(function () { scrollTick = 0; const el = refs.wrap; if (!state.wb) return; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 320) { if (state.rendered < Math.min(state.visibleRows.length, state.renderCap)) renderChunk(); } }); }; refs.wrap.addEventListener('scroll', refs.tablewrapScroll); // 点击遮罩空白处关闭 root.addEventListener('mousedown', function (e) { if (e.target === root) closePanel(); }); setEmpty(true); bindGlobalEvents(); } function updateBallVisibility() { const ball = ensureBall(); if (!ball) return; let hidden = false; try { hidden = !!GM_getValue('et_hide_ball', false); } catch (e) { hidden = false; } ball.classList.toggle('et-show', !hidden); } function toggleBall() { const ball = ensureBall(); if (!ball) return; let hidden = false; try { hidden = !!GM_getValue('et_hide_ball', false); } catch (e) { hidden = false; } const next = !hidden; try { GM_setValue('et_hide_ball', next); } catch (e) { /* 忽略 */ } ball.classList.toggle('et-show', !next); toast(next ? '悬浮按钮已隐藏(在油猴菜单里可以再打开)' : '悬浮按钮已显示'); } /** 同一个事件会先后经过 window 和 document,这里保证只处理一次 */ function firstTime(e) { if (!e) return true; if (e.__etSeen) return false; try { e.__etSeen = true; } catch (err) { /* 只读事件对象,忽略 */ } return true; } function bindGlobalEvents() { // 拖拽:只在“拖的是文件”时介入 const onDragEnter = function (e) { if (!firstTime(e) || !isFileDrag(e)) return; dragDepth++; showBanner(true); }; const onDragOver = function (e) { if (!firstTime(e) || !isFileDrag(e)) return; e.preventDefault(); // 必须拦截,否则浏览器会直接打开或下载这个文件 try { e.dataTransfer.dropEffect = 'copy'; } catch (err) { /* 忽略 */ } }; const onDragLeave = function (e) { if (!firstTime(e) || !isFileDrag(e)) return; dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) showBanner(false); }; const onDrop = function (e) { if (!firstTime(e) || !isFileDrag(e)) return; dragDepth = 0; showBanner(false); const dt = e.dataTransfer; const files = Array.prototype.slice.call((dt && dt.files) || []); const hit = files.filter(isAcceptedFile); if (!hit.length) return; // 没有认识的文件,交还给网页自己处理 e.preventDefault(); e.stopPropagation(); handleFiles(hit, files.length); }; // 粘贴(剪贴板里带了表格文件时也能用) const onPaste = function (e) { if (!firstTime(e)) return; const dt = e.clipboardData; if (!dt || !dt.files || !dt.files.length) return; const files = Array.prototype.slice.call(dt.files); const hit = files.filter(isAcceptedFile); if (hit.length) handleFiles(hit, files.length); }; // window 和 document 都挂一遍:页面自己劫持了其中一层时,另一层仍能兜住 [window, document].forEach(function (target) { target.addEventListener('dragenter', onDragEnter, true); target.addEventListener('dragover', onDragOver, true); target.addEventListener('dragleave', onDragLeave, true); target.addEventListener('drop', onDrop, true); target.addEventListener('paste', onPaste, true); }); // 快捷键:Esc 关闭,左右方向键切换工作表 window.addEventListener('keydown', function (e) { if (e.key === 'Escape') { if (document.querySelector('#' + ROOT_ID + ' .et-help')) { closeHelp(); return; } if (refs.root && refs.root.classList.contains('et-show')) closePanel(); return; } if (!refs.root || !refs.root.classList.contains('et-show') || !state.wb) return; const tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (e.key === 'ArrowRight' || e.key === 'PageDown') switchSheet(state.sheetIndex + 1); if (e.key === 'ArrowLeft' || e.key === 'PageUp') switchSheet(state.sheetIndex - 1); }, true); } function isFileDrag(e) { const dt = e.dataTransfer; if (!dt) return false; const types = dt.types || []; for (let i = 0; i < types.length; i++) { if (types[i] === 'Files') return true; } return false; } function isAcceptedFile(file) { const ext = extOf(file && file.name); return EXT_ET.indexOf(ext) >= 0 || EXT_OTHER.indexOf(ext) >= 0; } function showBanner(on) { if (on) ensureBanner(); if (refs.banner) refs.banner.classList.toggle('et-show', !!on); } /* ============================================================ * 4. 选文件 / 读文件 / 解析 * ============================================================ */ function pickFiles() { const input = document.createElement('input'); input.type = 'file'; input.accept = EXT_ET.concat(EXT_OTHER).map(function (ext) { return '.' + ext; }).join(','); input.style.display = 'none'; input.addEventListener('change', function () { const files = Array.prototype.slice.call(input.files || []); try { input.remove(); } catch (e) { /* 忽略 */ } if (files.length) handleFiles(files, files.length); }); (document.body || document.documentElement).appendChild(input); input.click(); } /** 一次拖入多个文件时,只处理第一个可识别的文件 */ function handleFiles(files, totalCount) { const list = Array.prototype.slice.call(files || []); if (!list.length) return Promise.resolve(); const file = list[0]; ensureUI(); if (!refs.root) return Promise.resolve(); // body 还没就绪,正常不会走到这里 openPanel(); const task = parseAndShow(file); if (list.length > 1 || (totalCount && totalCount > 1)) { toast('已选择「' + file.name + '」,其余文件未处理'); } return task; } async function parseAndShow(file) { setLoading(true, '正在读取 ' + file.name + ' …'); try { const lib = await ensureXLSX(); setLoading(true, '正在解析表格数据…'); const wb = await readWorkbook(lib, file); if (!wb.SheetNames || !wb.SheetNames.length) throw new Error('文件里没有找到工作表'); state.wb = wb; state.fileName = file.name; state.fileSize = file.size || 0; state.models.clear(); state.sheetIndex = 0; state.filter = ''; state.renderCap = RENDER_STEP; state.selected = null; refs.search.value = ''; refs.wrapchk.checked = false; refs.root.classList.remove('et-wrap-on'); refs.fname.textContent = file.name; refs.fname.title = file.name; refs.fmeta.textContent = fmtSize(file.size) + ' · ' + wb.SheetNames.length + ' 个工作表'; setEmpty(false); buildTabs(); switchSheet(0); setLoading(false); toast('已加载「' + file.name + '」'); } catch (err) { setLoading(false); showParseError(err, file); } } async function readWorkbook(lib, file) { const buf = await file.arrayBuffer(); const u8 = new Uint8Array(buf); // 文件头识别:PK.. = ZIP(新版 .et / xlsx),D0CF11E0 = OLE2 复合文档(老版 .et / xls) const isZip = u8.length > 3 && u8[0] === 0x50 && u8[1] === 0x4b && (u8[2] === 0x03 || u8[2] === 0x05 || u8[2] === 0x07); const isOle = u8.length > 7 && u8[0] === 0xd0 && u8[1] === 0xcf && u8[2] === 0x11 && u8[3] === 0xe0; if (isZip || isOle) { return lib.read(buf, { type: 'array', cellDates: false, cellNF: false, cellStyles: false, sheetStubs: false }); } // 其余按文本处理(CSV/TSV):先 UTF-8,解码失败再退回 GB18030 return lib.read(decodeText(u8), { type: 'string', cellDates: false }); } function decodeText(u8) { try { return new TextDecoder('utf-8', { fatal: true }).decode(u8); } catch (e) { try { return new TextDecoder('gb18030').decode(u8); } catch (e2) { return new TextDecoder('utf-8').decode(u8); } } } function showParseError(err, file) { const msg = (err && err.message) || String(err); try { console.error('[ET 预览] 解析失败', err); } catch (e) { /* 忽略 */ } state.wb = null; setEmpty(true); // 回到引导页,方便直接换一个文件重试 refs.fname.textContent = file.name; refs.fname.title = file.name; refs.fmeta.textContent = fmtSize(file.size); refs.tabs.innerHTML = ''; refs.colgroup.innerHTML = ''; refs.thead.innerHTML = ''; refs.tbody.innerHTML = ''; refs.foot.innerHTML = '无法解析这个文件'; renderSide(); toast('解析失败:' + msg + '\n如果文件有密码保护,请先在 WPS 里另存为 .xlsx 再试', 'error'); } function setLoading(on, text) { if (text) refs.loadtext.textContent = text; refs.loading.classList.toggle('et-show', !!on); } function openPanel() { ensureUI(); if (!refs.root) return; refs.root.classList.add('et-show'); } function closePanel() { refs.root.classList.remove('et-show'); refs.root.classList.remove('et-full'); closeHelp(); } /** 空状态:面板里显示“把 .et 拖到这里”的引导页(尚未打开任何文件时) */ function setEmpty(on) { if (!refs.root) return; refs.root.classList.toggle('et-empty', !!on); } /** 打开预览页:铺满屏幕的拖放引导,用户拖入或点按钮选文件后直接出表格 */ function openPreviewPage() { openPanel(); if (!state.wb) setEmpty(true); refs.root.classList.add('et-full'); } /* ============================================================ * 5. 工作表模型(单元格文本 / 列宽 / 合并区域) * ============================================================ */ function buildModel(name) { const lib = getXLSX(); const ws = state.wb.Sheets[name]; const range = lib.utils.decode_range(ws['!ref'] || 'A1'); const totalRows = range.e.r - range.s.r + 1; const totalCols = range.e.c - range.s.c + 1; const merges = (ws['!merges'] || []).map(function (mg) { return { sr: mg.s.r, sc: mg.s.c, er: mg.e.r, ec: mg.e.c }; }); const mergeAt = new Map(); // 合并区域左上角:'r,c' -> 合并信息 const covered = new Set(); // 被合并覆盖的格子(不需要单独输出) let coverCount = 0; merges.forEach(function (mg) { mergeAt.set(mg.sr + ',' + mg.sc, mg); const area = (mg.er - mg.sr + 1) * (mg.ec - mg.sc + 1); if (coverCount + area > COVER_LIMIT) return; coverCount += area; for (let r = mg.sr; r <= mg.er; r++) { for (let c = mg.sc; c <= mg.ec; c++) { if (r === mg.sr && c === mg.sc) continue; covered.add(r + ',' + c); } } }); const model = { name: name, ws: ws, range: range, totalRows: totalRows, totalCols: totalCols, merges: merges, mergeAt: mergeAt, covered: covered, widths: null }; model.cellAt = function (r, c) { return ws[lib.utils.encode_cell({ r: r, c: c })] || null; }; model.textAt = function (r, c) { return cellText(model.cellAt(r, c)); }; // 列宽:抽样若干行,按最长内容估算(中文按 2 个字符宽度计) model.widthsOf = function () { if (model.widths) return model.widths; const step = Math.max(1, Math.floor(totalRows / 80)); const widths = []; for (let ci = 0; ci < totalCols; ci++) { const absCol = range.s.c + ci; let maxUnits = lib.utils.encode_col(absCol).length; for (let r = range.s.r; r <= range.e.r; r += step) { const text = firstLine(cellText(model.cellAt(r, absCol))); if (!text) continue; const u = visualUnits(text); if (u > maxUnits) maxUnits = u; if (maxUnits > 60) break; } widths.push(Math.max(46, Math.min(520, Math.round(16 + maxUnits * 7.1)))); } model.widths = widths; return widths; }; return model; } function currentModel() { const name = state.wb.SheetNames[state.sheetIndex]; if (!state.models.has(name)) state.models.set(name, buildModel(name)); return state.models.get(name); } function cellText(cell) { if (!cell) return ''; if (cell.w != null && cell.w !== '') return String(cell.w); // 优先用原文件里的显示格式 const v = cell.v; if (v == null) return ''; if (cell.t === 'b') return v ? 'TRUE' : 'FALSE'; if (cell.t === 'e') return String(v); if (cell.t === 'n') { if (typeof v !== 'number' || !isFinite(v)) return String(v); return String(Math.round(v * 1e10) / 1e10); } return String(v); } function cellKind(cell) { if (!cell) return ''; if (cell.t === 'e') return 'et-err'; if (cell.t === 'b') return 'et-bool'; if (cell.t === 'n') { const w = cell.w || ''; if (w && /[年月日\/\-::]/.test(w) && !/^[\d.,\s:%¥¥$]*$/.test(w)) return 'et-date'; return 'et-num'; } return 'et-txt'; } function cellTypeName(cell) { if (!cell) return '空'; if (cell.t === 'e') return '错误值'; if (cell.t === 'b') return '布尔值'; if (cell.t === 'n') return cellKind(cell) === 'et-date' ? '日期 / 时间' : '数字'; if (cell.t === 'd') return '日期'; return '文本'; } /* ============================================================ * 6. 渲染 * ============================================================ */ function buildTabs() { if (!state.wb) { refs.tabs.innerHTML = ''; return; } refs.tabs.innerHTML = state.wb.SheetNames.map(function (name, i) { return '
' + escapeHtml(name) + '
'; }).join(''); refs.tabs.querySelectorAll('.et-tab').forEach(function (tab) { tab.addEventListener('click', function () { switchSheet(Number(tab.dataset.sheet)); }); }); } function switchSheet(index) { if (!state.wb || !state.wb.SheetNames.length) return; const count = state.wb.SheetNames.length; if (index < 0) index = count - 1; if (index >= count) index = 0; state.sheetIndex = index; state.rendered = 0; state.selected = null; refs.tabs.querySelectorAll('.et-tab').forEach(function (tab, i) { tab.classList.toggle('et-on', i === index); }); buildHeader(); applyFilter(); refs.wrap.scrollTop = 0; refs.wrap.scrollLeft = 0; renderSide(); } /** 表头 + 列宽(列宽用 ,配合 table-layout:fixed 保证长内容不撑破表格) */ function buildHeader() { const lib = getXLSX(); const m = currentModel(); const widths = m.widthsOf(); let totalWidth = 56; refs.colgroup.innerHTML = '' + widths.map(function (w) { totalWidth += w; return ''; }).join(''); refs.table.style.width = totalWidth + 'px'; let head = '#'; for (let ci = 0; ci < m.totalCols; ci++) { head += '' + lib.utils.encode_col(m.range.s.c + ci) + ''; } refs.thead.innerHTML = head + ''; } /** 应用查找条件,得到要显示的行号列表 */ function applyFilter() { if (!state.wb) return; const m = currentModel(); const q = state.filter.trim().toLowerCase(); const rows = []; if (!q) { for (let r = m.range.s.r; r <= m.range.e.r; r++) rows.push(r); } else { for (let r = m.range.s.r; r <= m.range.e.r; r++) { for (let c = m.range.s.c; c <= m.range.e.c; c++) { const text = m.textAt(r, c); if (text && text.toLowerCase().indexOf(q) >= 0) { rows.push(r); break; } } } } state.visibleRows = rows; state.rendered = 0; refs.tbody.innerHTML = ''; refs.filterinfo.textContent = q ? ('命中 ' + fmtInt(rows.length) + ' 行') : ''; renderChunk(); } function renderChunk() { if (!state.wb) return; const m = currentModel(); const total = state.visibleRows.length; const cap = Math.min(total, state.renderCap); const start = state.rendered; const end = Math.min(cap, start + CHUNK_ROWS); if (end <= start) { updateFoot(); return; } refs.tbody.insertAdjacentHTML('beforeend', buildRows(m, start, end, state.filter.trim())); state.rendered = end; updateFoot(); } function buildRows(m, start, end, q) { let html = ''; for (let i = start; i < end; i++) { const r = state.visibleRows[i]; html += ''; html += '' + fmtInt(r - m.range.s.r + 1) + ''; for (let ci = 0; ci < m.totalCols; ci++) { const c = m.range.s.c + ci; if (m.covered.has(r + ',' + c)) continue; // 合并区域内的从属格不输出 const cell = m.cellAt(r, c); const text = cellText(cell); const merge = m.mergeAt.get(r + ',' + c); let attrs = ''; let cls = 'et-cell ' + cellKind(cell); if (merge) { const rowspan = Math.min(merge.er, m.range.e.r) - r + 1; const colspan = Math.min(merge.ec, m.range.e.c) - c + 1; if (rowspan > 1) attrs += ' rowspan="' + rowspan + '"'; if (colspan > 1) attrs += ' colspan="' + colspan + '"'; if (rowspan > 1 || colspan > 1) cls += ' et-merge'; } const tip = text.length > 300 ? text.slice(0, 300) + '…' : text; const body = text === '' ? '' : (q ? highlight(text, q) : escapeHtml(text)); html += '' + body + ''; } html += ''; } return html; } function highlight(text, q) { const safe = escapeHtml(text); const re = new RegExp('(' + escapeRegExp(escapeHtml(q)) + ')', 'gi'); return safe.replace(re, '$1'); } function updateFoot() { if (!state.wb) { refs.foot.innerHTML = ''; return; } const m = currentModel(); const total = state.visibleRows.length; const cap = Math.min(total, state.renderCap); const parts = []; parts.push('工作表 ' + escapeHtml(m.name) + ''); parts.push('' + fmtInt(m.totalRows) + ' 行 × ' + fmtInt(m.totalCols) + ' 列'); parts.push('已显示 ' + fmtInt(state.rendered) + ' 行' + (state.filter.trim() ? '(查找命中 ' + fmtInt(total) + ' 行)' : '') + ''); if (m.merges.length) parts.push('合并区域 ' + fmtInt(m.merges.length) + ' 处'); if (state.rendered < cap) { parts.push(''); } else if (state.rendered >= state.renderCap && total > state.renderCap) { parts.push(''); } parts.push(''); parts.push('← / → 切换工作表 · Esc 关闭'); refs.foot.innerHTML = parts.join(''); } function selectCell(r, c) { state.selected = { r: r, c: c }; refs.tbody.querySelectorAll('td.et-sel').forEach(function (el) { el.classList.remove('et-sel'); }); const td = refs.tbody.querySelector('td[data-r="' + r + '"][data-c="' + c + '"]'); if (td) td.classList.add('et-sel'); renderSide(); } function renderSide() { if (!state.wb || !state.selected) { refs.side.innerHTML = '
点击任意单元格,这里会显示它的完整内容(长文本、日期格式、合并区域等)。
'; return; } const lib = getXLSX(); const m = currentModel(); const r = state.selected.r; const c = state.selected.c; const cell = m.cellAt(r, c); const text = cellText(cell); const addr = lib.utils.encode_cell({ r: r, c: c }); // 如果点在合并区域的从属格里,就把内容定位到左上角主格 let merge = m.mergeAt.get(r + ',' + c); let ownerCell = cell; let ownerText = text; let coveredFlag = false; if (!merge) { for (let i = 0; i < m.merges.length; i++) { const mg = m.merges[i]; if (r >= mg.sr && r <= mg.er && c >= mg.sc && c <= mg.ec) { merge = mg; const oc = m.cellAt(mg.sr, mg.sc); if (oc && cellText(oc)) { ownerCell = oc; ownerText = cellText(oc); coveredFlag = true; } break; } } } let html = '

单元格详情

'; html += '
'; html += '地址' + addr + '(第 ' + fmtInt(r + 1) + ' 行,第 ' + fmtInt(c + 1) + ' 列)'; html += '类型' + cellTypeName(ownerCell) + (coveredFlag ? ' · 合并区域从属格' : '') + ''; html += '
'; if (merge && (merge.er > merge.sr || merge.ec > merge.sc)) { html += '
合并区域' + lib.utils.encode_cell({ r: merge.sr, c: merge.sc }) + ':' + lib.utils.encode_cell({ r: merge.er, c: merge.ec }) + '(' + (merge.er - merge.sr + 1) + ' 行 × ' + (merge.ec - merge.sc + 1) + ' 列)
'; } html += '

完整内容

'; html += '
' + (ownerText === '' ? '(空)' : escapeHtml(ownerText)) + '
'; if (ownerCell && ownerCell.v != null && String(ownerCell.v) !== ownerText) { html += '

原始值

'; html += '
' + escapeHtml(String(ownerCell.v)) + '
'; } html += ''; refs.side.innerHTML = html; const btn = refs.side.querySelector('[data-act="copy-cell"]'); if (btn) btn.addEventListener('click', function () { copyText(ownerText, '已复制单元格内容'); }); } /* ============================================================ * 7. 导出(CSV / XLSX / 复制) * ============================================================ */ /** 取当前工作表内容;filtered=true 时只取查找命中的行 */ function toAOA(filtered) { const m = currentModel(); let rowIndexes; if (filtered && state.filter.trim()) { rowIndexes = state.visibleRows; } else { rowIndexes = []; for (let r = m.range.s.r; r <= m.range.e.r; r++) rowIndexes.push(r); } const out = []; for (let i = 0; i < rowIndexes.length; i++) { const r = rowIndexes[i]; const row = []; for (let c = m.range.s.c; c <= m.range.e.c; c++) { if (m.covered.has(r + ',' + c)) continue; row.push(m.textAt(r, c)); } out.push(row); } return out; } function toDelimited(aoa, sep) { const isCSV = sep === ','; return aoa.map(function (row) { return row.map(function (v) { let s = v == null ? '' : String(v); if (isCSV) { if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'; } else { s = s.replace(/\t/g, ' ').replace(/\r?\n/g, ' '); } return s; }).join(sep); }).join('\r\n'); } function exportCSV() { if (!state.wb) return; const name = baseName(state.fileName) + '-' + currentModel().name + '.csv'; const text = toDelimited(toAOA(true), ','); downloadBlob(new Blob(['\ufeff' + text], { type: 'text/csv;charset=utf-8' }), name); toast('已导出 ' + name + (state.filter.trim() ? '(仅当前查找结果)' : '')); } function exportCopy() { if (!state.wb) return; copyText(toDelimited(toAOA(true), '\t'), '已复制当前显示内容(可直接粘贴到表格软件)'); } function exportXLSX() { if (!state.wb) return; try { const lib = getXLSX(); const out = lib.write(state.wb, { bookType: 'xlsx', type: 'array', cellStyles: false }); const name = baseName(state.fileName) + '.xlsx'; downloadBlob(new Blob([out], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), name); toast('已转存为 ' + name + '(包含全部工作表,可用 Excel / WPS 打开)'); } catch (err) { toast('转存失败:' + ((err && err.message) || err), 'error'); } } /* ============================================================ * 8. 使用说明 * ============================================================ */ function openHelp() { closeHelp(); const box = document.createElement('div'); box.className = 'et-panel et-help'; box.innerHTML = [ '

ET 表格预览器 · 使用说明

', '', '
提示:在 file:// 页面上使用时,需要在扩展详情里开启「允许访问文件网址」。
', '' ].join(''); refs.root.appendChild(box); refs.root.classList.add('et-show'); box.addEventListener('click', function (e) { const btn = e.target && e.target.closest ? e.target.closest('[data-act="help-close"]') : null; if (btn || e.target === box) closeHelp(); }); } function closeHelp() { const box = refs.root ? refs.root.querySelector('.et-help') : null; if (box) box.remove(); } /* ============================================================ * 9. 初始化 * ============================================================ */ /** body 就绪后执行(脚本在 document-start 运行,那时可能还没有 body) */ function onReady(fn) { if (document.body) { fn(); return; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function handler() { document.removeEventListener('DOMContentLoaded', handler); fn(); }); } else { fn(); } } function init() { // 先注入样式、尽早挂上拖放拦截:这样即使页面还在加载,拖入 .et 也不会被浏览器当成下载 injectStyles(); bindGlobalEvents(); // 菜单和对外接口不依赖 DOM,可以立刻注册 if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('🖥 打开 ET 预览页(拖入 / 选择文件)', openPreviewPage); GM_registerMenuCommand('📄 选择表格文件(.et / Excel)…', pickFiles); GM_registerMenuCommand('💡 使用说明', openHelp); GM_registerMenuCommand('👁 显示 / 隐藏 悬浮按钮', toggleBall); } // 对外接口:方便在控制台里调试,也方便其它脚本调用 const api = { version: VERSION, open: openPanel, close: closePanel, openFiles: handleFiles, help: openHelp }; try { (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).ETPreview = api; } catch (e) { /* 忽略 */ } try { window.ETPreview = api; } catch (e) { /* 忽略 */ } // 界面部分等 body 出来再建 onReady(function () { buildUI(); updateBallVisibility(); // 当前页本身就是 .et 链接时给点提示(浏览器不会自动预览,需要拖进来或点悬浮按钮) if (/\.(et|ett)$/i.test(location.pathname)) { toast('检测到 .et 地址:把文件拖到本页面,或点右下角按钮选择文件即可预览'); } }); } init(); })();