// ==UserScript== // @name JSON / CSV 阅读器 & 编辑器 (分屏实时预览) // @namespace http://tampermonkey.net/ // @version 1.2.2 // @description 把 .json/.csv/.tsv 拖到任意网页或空白页即可解析成表格;直接打开本地文件时,CSV 没有 JSON 那种内置查看器,会按地址把文件字节读回来再解析。支持列排序、搜索、列显隐、分页、列统计、另存为、Ctrl+S 保存。其它格式一律不拦。 // @author healer007 // @match *://*/* // @match file:///* // @include about:blank // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect * // @run-at document-start // @noframes // @license MIT // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMjgyYTM2IiBzdHJva2U9IiM1MGZhN2IiIHN0cm9rZS13aWR0aD0iMS40Ii8+PHRleHQgeD0iMTIiIHk9IjE2LjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJDb25zb2xhcyxNZW5sbyxtb25vc3BhY2UiIGZvbnQtc2l6ZT0iMTAuNSIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiM1MGZhN2IiPnsgfTwvdGV4dD48L3N2Zz4= // ==/UserScript== /* * 用法 * 1. 浏览器直接打开本地 .json / .csv / .tsv 文件 → 自动解析成分屏界面(左侧表格预览,右侧原文编辑)。 * 2. 编辑右侧原文,左侧实时刷新(解析失败会显示错误并保留原文,不会清空内容)。 * 3. 保存:Ctrl+S 或点「保存」,按当前格式导出;也可以在「另存为」里换成另一种格式导出。 * * 范围边界(和 markdown-reader / word-reader 互斥) * · 脚本会注入到所有网页,但只为了接住拖放。拖进来的文件扩展名不是 .json/.csv/.tsv 时, * 不 preventDefault,浏览器照常打开,另外两个脚本也能接手。 * · 自动铺满页面只发生在 file:// 且扩展名是这三种时。以前只要注入就 buildUi() 清空 body, * .txt/.markdown/.docx 会被盖成一张「请拖入 JSON」的全屏,和另外两个脚本抢页面。 * · http(s) 上的 .json 接口页面不自动接管。 * · 不再用 @exclude-match 互斥:它挡不住 .txt/.markdown,管理器不支持时等于没写, * 而且会让脚本在普通网页上根本不运行,拖进去就没人接。 * * 与浏览器自带查看器的冲突(Chrome / Edge) * · .json 会进内置查看器,原文在
 里,但 document_idle 会重建 body 把界面冲掉。
 *   · .csv / .tsv 通常没有查看器:要么直接下载,要么是一张空白页,
 里什么都没有。
 *     只读 
 就会表现为「JSON 能开,CSV 完全不解析」。
 *   对策:
 *     1. 有 
 就先抓快照(JSON 这条同步路径不能等,查看器马上会把页面换掉);
 *     2. 
 是空的就按 file:// 地址把字节读回来(UTF-8 失败再试 gb18030);
 *     3. 挂界面前摘掉 .json-formatter-container,并用 MutationObserver 在根节点被冲掉后挂回去。
 *
 * 数据特性
 *   · CSV 按 RFC 4180 解析:支持引号包裹、"" 转义、字段内逗号/换行、CRLF、BOM。
 *   · 分隔符自动嗅探(, ; Tab |),按首行一致性打分选最优。
 *   · 每列自动推断类型(数字/布尔/文本),排序和统计都按推断后的类型走。
 *     超出 JS 安全整数的纯数字(如 17 位 SteamID)保持文本,避免另存为 JSON 时丢精度。
 *   · JSON 支持对象数组、数组的数组、纯原始值数组;不是数组时退化成可折叠树视图。
 *   · 大文件只渲染当前页,DOM 节点数与总行数无关。
 */

(function () {
    'use strict';

    /* ============================================================
     * 0. 常量与状态
     * ============================================================ */

    const VERSION = '1.2.2';

    const ROOT_ID = 'dr-reader-root';
    const EXT_DATA = ['json', 'csv', 'tsv'];
    const PAGE_SIZES = [50, 100, 200, 500, 1000];
    const DEFAULT_PAGE = 100;
    const MAX_AUTO_PARSE = 64 * 1024 * 1024;   // 超过 64MB 就不再自动解析,避免把浏览器拖死
    const PREVIEW_DEBOUNCE = 300;

    const state = {
        fileName: 'data',
        format: 'json',            // json | csv | tsv
        original: '',              // 载入时的原文,用于「还原」和未保存判断
        text: '',                  // 当前原文(= textarea.value)
        model: null,               // 解析结果
        error: '',                 // 解析错误信息
        query: '',
        sortKey: null,
        sortDir: 0,                // 1 升序 / -1 降序 / 0 不排序
        page: 1,
        pageSize: DEFAULT_PAGE,
        hidden: {},                // { 列名: true } 隐藏的列
        showStats: false,
        stats: null,
        debounceTimer: 0
    };

    const refs = {};

    /**
     * 浏览器自带的 JSON 查看器("美观输出"那个)会在 document_idle 阶段
     * 重建整个 body,把油猴脚本注入的界面直接冲掉 —— 表现就是「插件不生效」。
     * 所以正文必须一次性抓到自己手里,之后只认这份快照。
     */
    let capturedSource = null;

    function captureSource(force) {
        if (!force && capturedSource !== null) return capturedSource;
        let text = '';
        try {
            // 只认 
。刻意不把任意页面的 body.textContent 当数据 ——
            // 那会把网页自己的脚本文本喂进解析器。
            const pre = document.querySelector('body > pre') || document.querySelector('pre');
            if (pre) text = pre.textContent || '';
            // 本地文件页有时没有 
,原文直接挂在 body 的文本节点上。
            // 已经有子元素时不再收:那是脚本自己的界面,或者是别的网页。
            if (!text && location.protocol === 'file:' && document.body && !document.body.firstElementChild) {
                text = document.body.textContent || '';
            }
        } catch (e) {
            text = '';
        }
        // 空读不写入缓存。否则 DOMContentLoaded 时 
 还没出来,
        // 这次空字符串会把后面 load / 重新读取永远挡住。
        if (text) capturedSource = text;
        return text || capturedSource || '';
    }

    /** 摘掉浏览器自带查看器的挂载点,避免它继续往页面上盖东西 */
    function killNativeViewer() {
        try {
            document.querySelectorAll('.json-formatter-container').forEach(function (el) { el.remove(); });
        } catch (e) { /* 忽略 */ }
    }

    /* ============================================================
     * 1. 小工具
     * ============================================================ */

    function escapeHtml(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&')
            .replace(//g, '>')
            .replace(/"/g, '"')
            .replace(/'/g, ''');
    }

    function extOf(name) {
        const i = String(name || '').lastIndexOf('.');
        return i < 0 ? '' : String(name).slice(i + 1).toLowerCase();
    }

    function baseName(name) {
        return String(name || 'data').replace(/\.[^.]+$/, '') || 'data';
    }

    function fmtSize(n) {
        if (n == null) return '';
        const units = ['B', 'KB', 'MB', 'GB'];
        let i = 0, 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];
    }

    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.appendChild(a);
        a.click();
        setTimeout(function () {
            try { a.remove(); } catch (e) { /* 忽略 */ }
            URL.revokeObjectURL(url);
        }, 4000);
    }

    /** UTF-8 严格解码,失败退 gb18030(中文 Windows 导出的 CSV 常见) */
    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 stripBom(s) {
        return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
    }

    /* ============================================================
     * 2. 解析:CSV / JSON → 统一表格模型
     * ============================================================ */

    /**
     * RFC 4180 分隔符解析器。
     * 关键点:引号内的分隔符和换行都当普通字符;"" 是转义后的一个引号;
     * 未加引号字段里出现的引号按字面量处理。
     */
    function parseDelimited(text, delim) {
        const rows = [];
        let row = [], field = '', i = 0, inQuotes = false, touched = false;
        const n = text.length;
        while (i < n) {
            const ch = text[i];
            if (inQuotes) {
                if (ch === '"') {
                    if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
                    inQuotes = false; i++; continue;
                }
                field += ch; i++; continue;
            }
            if (ch === '"' && field === '') { inQuotes = true; touched = true; i++; continue; }
            if (ch === delim) { row.push(field); field = ''; touched = true; i++; continue; }
            if (ch === '\r') { i++; continue; }
            if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; touched = false; i++; continue; }
            field += ch; i++;
        }
        if (field !== '' || touched || row.length) { row.push(field); rows.push(row); }
        return rows;
    }

    /** 嗅探分隔符:取候选里「首行列数最多且后续行最齐」的那个 */
    function sniffDelim(text) {
        const candidates = [',', '\t', ';', '|'];
        let best = ',', bestScore = -1;
        for (let k = 0; k < candidates.length; k++) {
            const sample = parseDelimited(text.slice(0, 200 * 1024), candidates[k]).slice(0, 60);
            if (sample.length < 2) continue;
            const cols = sample[0].length;
            if (cols < 2) continue;
            let same = 0;
            for (let i = 1; i < sample.length; i++) if (sample[i].length === cols) same++;
            const score = same * 1000 + cols;
            if (score > bestScore) { bestScore = score; best = candidates[k]; }
        }
        return best;
    }

    const NUM_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
    const BOOL_RE = /^(?:true|false|yes|no|是|否|y|n)$/i;

    /**
     * 文本能不能当成 JS 数字。
     * 纯整数还必须是安全整数:SteamID 这种 17 位数字转 Number 会丢末尾精度,
     * 排序和「另存为 JSON」都会把好友 ID 改掉。
     */
    function isPreciseNumber(s) {
        if (!NUM_RE.test(s)) return false;
        if (/^-?\d+$/.test(s)) return Number.isSafeInteger(Number(s));
        const n = Number(s);
        return isFinite(n);
    }

    /** 按整列取值推断类型:全数字 → number,全布尔 → boolean,否则 string */
    function inferType(values) {
        let num = 0, bool = 0, txt = 0;
        for (let i = 0; i < values.length; i++) {
            const v = values[i];
            if (v === null || v === undefined || v === '') continue;
            if (typeof v === 'number') { num++; continue; }
            if (typeof v === 'boolean') { bool++; continue; }
            const s = String(v).trim();
            if (!s) continue;
            if (isPreciseNumber(s)) num++;
            else if (BOOL_RE.test(s)) bool++;
            else txt++;
        }
        if (num && !txt && !bool) return 'number';
        if (bool && !txt && !num) return 'boolean';
        return 'string';
    }

    /** 按列类型把单元格转成可排序/可统计的值;空值返回 undefined */
    function castVal(v, type) {
        if (v === null || v === undefined || v === '') return undefined;
        if (type === 'number') {
            if (typeof v === 'number') return isFinite(v) ? v : undefined;
            const s = String(v).trim();
            if (!isPreciseNumber(s)) return undefined;
            return Number(s);
        }
        if (type === 'boolean') {
            const s = String(v).trim();
            if (!s) return undefined;
            return /^(?:true|yes|y|是|1)$/i.test(s);
        }
        if (typeof v === 'object') return JSON.stringify(v);
        return String(v);
    }

    /** 单元格显示文本:嵌套结构压缩成摘要,完整内容放 title 里 */
    function cellText(v) {
        if (v === null) return 'null';
        if (v === undefined) return '';
        if (Array.isArray(v)) {
            if (!v.length) return '[]';
            const head = v.slice(0, 3).map(function (x) {
                return typeof x === 'object' && x !== null ? JSON.stringify(x) : String(x);
            }).join(', ');
            return '[' + v.length + '] ' + head + (v.length > 3 ? ' …' : '');
        }
        if (typeof v === 'object') {
            const keys = Object.keys(v);
            if (!keys.length) return '{}';
            return '{' + keys.length + '} ' + keys.slice(0, 3).join(', ') + (keys.length > 3 ? ' …' : '');
        }
        return String(v);
    }

    function cellTitle(v) {
        if (v !== null && v !== undefined && typeof v === 'object') {
            try { return JSON.stringify(v); } catch (e) { return ''; }
        }
        return '';
    }

    /** JSON 值 → 表格模型;非数组返回 kind:'tree' */
    function modelFromJson(value) {
        if (!Array.isArray(value)) {
            return { kind: 'tree', jsonValue: value, headers: [], rows: [] };
        }

        // 空数组:给出明确提示而不是空白表格
        if (!value.length) {
            return { kind: 'json', jsonValue: value, headers: [], rows: [], types: {} };
        }

        // 数组的数组 / 原始值数组 → 统一转成「每列一个 header」
        const allObjects = value.every(function (r) { return r !== null && typeof r === 'object' && !Array.isArray(r); });
        const width = value.reduce(function (m, r) {
            return Math.max(m, Array.isArray(r) ? r.length : 1);
        }, 0);

        let headers, rows, types;

        if (allObjects) {
            // 列顺序 = 首次出现顺序(不会因为后面某行多了个 key 就乱序)
            headers = [];
            const seen = {};
            for (let i = 0; i < value.length && headers.length < 500; i++) {
                const r = value[i];
                for (const k of Object.keys(r)) {
                    if (!Object.prototype.hasOwnProperty.call(seen, k)) { seen[k] = 1; headers.push(k); }
                }
            }
            rows = value.map(function (r) {
                return headers.map(function (h) { return r[h]; });
            });
        } else if (value.every(function (r) { return Array.isArray(r); })) {
            headers = [];
            for (let i = 0; i < width; i++) headers.push('列 ' + (i + 1));
            rows = value.map(function (r) {
                const out = new Array(width);
                for (let i = 0; i < width; i++) out[i] = r[i];
                return out;
            });
        } else {
            headers = ['值'];
            rows = value.map(function (r) { return [r]; });
        }

        types = {};
        for (let c = 0; c < headers.length; c++) {
            const col = new Array(rows.length);
            for (let r = 0; r < rows.length; r++) col[r] = rows[r][c];
            types[headers[c]] = inferType(col);
        }

        return { kind: 'json', jsonValue: value, headers: headers, rows: rows, types: types };
    }

    /** 文本 → 解析模型;格式靠扩展名 + 内容嗅探 */
    function parseData(text, fileName) {
        const raw = stripBom(String(text == null ? '' : text));
        const ext = extOf(fileName) || state.format;
        const looksJson = /^\s*[[{]/.test(raw);

        let format;
        if (ext === 'json') format = 'json';
        else if (ext === 'csv' || ext === 'tsv') format = 'csv';
        else format = looksJson ? 'json' : 'csv';
        state.format = ext === 'tsv' ? 'tsv' : (format === 'json' ? 'json' : 'csv');

        if (format === 'json') {
            let value;
            try {
                value = JSON.parse(raw);
            } catch (e) {
                throw new Error('JSON 解析失败:' + (e && e.message ? e.message : e));
            }
            const m = modelFromJson(value);
            m.format = 'json';
            if (ext === 'tsv') m.delim = '\t';
            return m;
        }

        const delim = ext === 'tsv' ? '\t' : sniffDelim(raw);
        const table = parseDelimited(raw, delim).filter(function (r) {
            return r.length > 1 || (r.length === 1 && r[0] !== '');
        });
        if (!table.length) throw new Error('没有解析到任何数据行');

        const headers = table[0].map(function (h, i) { return String(h).trim() || '列 ' + (i + 1); });
        const rows = table.slice(1).map(function (r) {
            const out = new Array(headers.length);
            for (let i = 0; i < headers.length; i++) out[i] = i < r.length ? r[i] : '';
            return out;
        });

        const types = {};
        for (let c = 0; c < headers.length; c++) {
            const col = new Array(rows.length);
            for (let r = 0; r < rows.length; r++) col[r] = rows[r][c];
            types[headers[c]] = inferType(col);
        }

        return {
            kind: 'csv',
            format: state.format,
            delim: delim,
            headers: headers,
            rows: rows,
            types: types,
            jsonValue: null
        };
    }

    /* ---------------- 序列化 ---------------- */

    function csvEscape(v) {
        let s;
        if (v === null || v === undefined) s = '';
        else if (typeof v === 'object') { try { s = JSON.stringify(v); } catch (e) { s = String(v); } }
        else s = String(v);
        return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
    }

    function toCsv(model, delim) {
        const d = delim || (model && model.delim) || ',';
        const lines = [];
        lines.push((model.headers || []).map(csvEscape).join(d));
        (model.rows || []).forEach(function (r) {
            lines.push(r.map(csvEscape).join(d));
        });
        // 带 BOM,Excel / WPS 双击才不会乱码(和样例文件保持一致)
        return '\ufeff' + lines.join('\r\n') + '\r\n';
    }

    /** CSV 表格模型 → JSON 值(按列类型回填数字/布尔) */
    function tableToJson(model) {
        const headers = model.headers || [];
        return (model.rows || []).map(function (r) {
            const o = {};
            headers.forEach(function (h, i) {
                const type = (model.types && model.types[h]) || 'string';
                o[h] = castVal(r[i], type);
            });
            return o;
        });
    }

    /* ============================================================
     * 3. 样式
     * ============================================================ */

    const CSS = [
        'html,body{height:100%;}',
        'body{margin:0;padding:0;overflow:hidden;background:#1e1f29;color:#f8f8f2;',
        'font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Helvetica,Arial,sans-serif;}',
        '.dr-root{display:flex;flex-direction:column;height:100vh;width:100vw;}',

        /* 顶部工具条 */
        '.dr-topbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 12px;flex:0 0 auto;',
        'background:#282a36;border-bottom:1px solid #44475a;}',
        '.dr-badge{display:inline-flex;align-items:center;justify-content:center;height:21px;padding:0 7px;border-radius:5px;',
        'font-size:10.5px;font-weight:700;letter-spacing:.5px;background:rgba(80,250,123,.14);border:1px solid rgba(80,250,123,.45);color:#50fa7b;}',
        '.dr-badge-csv{background:rgba(255,184,108,.14);border-color:rgba(255,184,108,.45);color:#ffb86c;}',
        '.dr-name{font-weight:600;font-size:13px;max-width:26vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
        '.dr-meta{color:#6272a4;font-size:12px;white-space:nowrap;}',
        '.dr-dirty{color:#f1fa8c;font-size:12px;white-space:nowrap;}',
        '.dr-grow{flex:1 1 auto;}',
        '.dr-btn{appearance:none;border:1px solid #44475a;background:#313244;color:#f8f8f2;border-radius:6px;',
        'padding:4px 10px;font-size:12px;line-height:1.5;cursor:pointer;white-space:nowrap;transition:.14s;}',
        '.dr-btn:hover{border-color:#50fa7b;color:#50fa7b;}',
        '.dr-btn:active{transform:translateY(1px);}',
        '.dr-btn.primary{background:rgba(80,250,123,.16);border-color:rgba(80,250,123,.55);color:#50fa7b;font-weight:600;}',
        '.dr-btn.on{background:rgba(80,250,123,.24);border-color:#50fa7b;color:#50fa7b;}',
        '.dr-sel{appearance:none;background:#313244;color:#f8f8f2;border:1px solid #44475a;border-radius:6px;',
        'padding:4px 6px;font-size:12px;cursor:pointer;}',
        '.dr-search{background:#313244;border:1px solid #44475a;border-radius:6px;color:#f8f8f2;',
        'padding:4px 9px;font-size:12px;width:190px;outline:none;}',
        '.dr-search:focus{border-color:#50fa7b;}',
        '.dr-search::placeholder{color:#6272a4;}',

        /* 列显隐面板 */
        '.dr-colpanel{position:absolute;z-index:30;top:34px;right:12px;min-width:190px;max-height:60vh;overflow:auto;',
        'padding:7px 9px;border-radius:9px;background:#313244;border:1px solid #44475a;box-shadow:0 14px 40px rgba(0,0,0,.55);}',
        '.dr-colpanel h4{margin:0 0 6px;font-size:11px;color:#6272a4;font-weight:600;letter-spacing:.4px;}',
        '.dr-colpanel label{display:flex;align-items:center;gap:6px;font-size:12px;padding:3px 2px;cursor:pointer;border-radius:4px;}',
        '.dr-colpanel label:hover{background:#44475a;}',
        '.dr-colpanel input{accent-color:#50fa7b;}',
        '.dr-colpanel .dr-tp{color:#6272a4;font-size:10.5px;margin-left:auto;padding-left:10px;}',

        /* 统计面板 */
        '.dr-statspanel{flex:0 0 auto;max-height:30vh;overflow:auto;background:#21222c;border-bottom:1px solid #44475a;padding:8px 12px;}',
        '.dr-statspanel table{border-collapse:collapse;width:100%;font-size:12px;}',
        '.dr-statspanel th,.dr-statspanel td{border:1px solid #44475a;padding:3px 8px;text-align:left;white-space:nowrap;}',
        '.dr-statspanel th{background:#313244;color:#bd93f9;font-weight:600;}',
        '.dr-statspanel td{color:#cfd3e0;}',

        /* 分屏 */
        '.dr-split{flex:1 1 auto;display:flex;min-height:0;}',
        '.dr-pane{overflow:auto;box-sizing:border-box;height:100%;}',
        '.dr-pane-left{flex:1 1 0;min-width:0;background:#21222c;}',
        '.dr-pane-right{flex:1 1 0;min-width:0;background:#282a36;border-left:0;position:relative;}',
        '.dr-editor{width:100%;height:100%;background:transparent;color:#f8f8f2;border:none;resize:none;outline:none;padding:12px 14px;',
        'box-sizing:border-box;white-space:pre;overflow-wrap:normal;tab-size:2;',
        'font:12.5px/1.6 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;}',
        '.dr-splitter{flex:0 0 6px;background:#44475a;cursor:col-resize;position:relative;z-index:5;transition:background .15s;}',
        '.dr-splitter:hover,.dr-splitter.dragging{background:#50fa7b;}',
        '.dr-splitter::after{content:"⋮";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#888;font-size:16px;line-height:1;}',

        /* 表格 */
        '.dr-tablewrap{padding:0 0 40px;}',
        '.dr-table{border-collapse:separate;border-spacing:0;font-size:12.5px;width:max-content;min-width:100%;}',
        '.dr-table th,.dr-table td{border-right:1px solid #44475a;border-bottom:1px solid #44475a;padding:5px 10px;',
        'white-space:nowrap;max-width:420px;overflow:hidden;text-overflow:ellipsis;vertical-align:top;}',
        '.dr-table thead th{position:sticky;top:0;z-index:2;background:#313244;color:#bd93f9;cursor:pointer;user-select:none;',
        'border-bottom:1px solid #6272a4;font-weight:600;}',
        '.dr-table thead th:hover{color:#50fa7b;}',
        '.dr-table thead th .dr-arrow{color:#50fa7b;font-size:10px;margin-left:4px;}',
        '.dr-table thead th .dr-tp{color:#6272a4;font-size:10px;margin-left:5px;font-weight:400;}',
        '.dr-table tbody tr:nth-child(even){background:rgba(255,255,255,.022);}',
        '.dr-table tbody tr:hover{background:rgba(80,250,123,.07);}',
        '.dr-table td.dr-num{text-align:right;font-family:"SFMono-Regular",Consolas,Menlo,monospace;color:#bd93f9;}',
        '.dr-table td.dr-bool{text-align:center;color:#ffb86c;}',
        '.dr-table td.dr-null{color:#6272a4;font-style:italic;}',
        '.dr-table td.dr-deep{color:#8be9fd;}',
        '.dr-table td.dr-rownum{color:#6272a4;background:rgba(255,255,255,.03);text-align:right;position:sticky;left:0;z-index:1;}',

        /* 分页条 */
        '.dr-foot{flex:0 0 auto;display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:5px 12px;',
        'background:#282a36;border-top:1px solid #44475a;color:#6272a4;font-size:12px;}',
        '.dr-foot b{color:#f8f8f2;font-weight:600;}',
        '.dr-foot .dr-btn{padding:2px 8px;font-size:11.5px;}',

        /* 空态 / 错误 */
        '.dr-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:9px;',
        'color:#6272a4;text-align:center;padding:40px;}',
        '.dr-empty .dr-big{font-size:40px;line-height:1;}',
        '.dr-err{margin:14px;padding:12px 14px;border-radius:9px;background:rgba(255,85,85,.1);',
        'border:1px solid rgba(255,85,85,.45);color:#ffb3b3;font-size:12.5px;}',
        '.dr-err b{display:block;margin-bottom:4px;color:#ff6b6b;font-size:13px;}',
        '.dr-err code{background:rgba(0,0,0,.3);border-radius:4px;padding:1px 5px;color:#ffb3b3;}',

        /* 树视图 */
        '.dr-treewrap{padding:12px 14px;font-family:"SFMono-Regular",Consolas,Menlo,monospace;font-size:12.5px;line-height:1.6;}',
        '.dr-tree details{margin-left:14px;border-left:1px dotted #44475a;padding-left:10px;}',
        '.dr-tree summary{cursor:pointer;list-style:none;outline:none;}',
        '.dr-tree summary::-webkit-details-marker{display:none;}',
        '.dr-tree summary::before{content:"▸";display:inline-block;width:12px;color:#6272a4;transition:transform .12s;}',
        '.dr-tree details[open]>summary::before{transform:rotate(90deg);}',
        '.dr-tree .dr-k{color:#bd93f9;}',
        '.dr-tree .dr-s{color:#50fa7b;}',
        '.dr-tree .dr-n{color:#ffb86c;}',
        '.dr-tree .dr-b{color:#ff79c6;}',
        '.dr-tree .dr-null{color:#6272a4;font-style:italic;}',
        '.dr-tree .dr-cnt{color:#6272a4;font-size:11px;margin-left:6px;}',

        /* 拖放遮罩 */
        '#dr-drop-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483646;display:flex;align-items:center;',
        'justify-content:center;background:rgba(40,42,54,.95);opacity:0;pointer-events:none;transition:opacity .2s;}',
        '#dr-drop-overlay.show{opacity:1;pointer-events:auto;}',
        '#dr-drop-overlay .dr-box{text-align:center;border:3px dashed #50fa7b;padding:52px 74px;border-radius:22px;background:rgba(30,31,41,.8);}',
        '#dr-drop-overlay .dr-ico{font-size:64px;margin-bottom:16px;}',
        '#dr-drop-overlay .dr-tx{font-size:24px;font-weight:600;margin-bottom:8px;}',
        '#dr-drop-overlay .dr-hi{font-size:14px;color:#6272a4;}',

        /* 滚动条 */
        '.dr-pane::-webkit-scrollbar,.dr-statspanel::-webkit-scrollbar,.dr-colpanel::-webkit-scrollbar{width:10px;height:10px;}',
        '.dr-pane::-webkit-scrollbar-track,.dr-statspanel::-webkit-scrollbar-track{background:#1e1f29;}',
        '.dr-pane::-webkit-scrollbar-thumb,.dr-statspanel::-webkit-scrollbar-thumb,.dr-colpanel::-webkit-scrollbar-thumb{',
        'background:#44475a;border-radius:5px;border:2px solid #1e1f29;}',
        '.dr-pane::-webkit-scrollbar-thumb:hover{background:#6272a4;}',

        '#dr-drop-overlay,#dr-drop-overlay *{box-sizing:border-box;}'
    ].join('\n');

    let stylesInjected = false;
    function injectStyles() {
        if (stylesInjected) return;
        stylesInjected = true;
        try {
            if (typeof GM_addStyle === 'function') { GM_addStyle(CSS); return; }
        } catch (e) { /* 走兜底 */ }
        const st = document.createElement('style');
        st.textContent = CSS;
        (document.head || document.documentElement).appendChild(st);
    }

    /** 声明当前页面归本脚本。markdown-reader 接管时会改成 markdown,守卫看到就停手。 */
    function claimReader() {
        try { document.documentElement.dataset.fileReader = 'data'; } catch (e) { /* 忽略 */ }
    }

    function readerStillOurs() {
        try { return document.documentElement.dataset.fileReader === 'data'; } catch (e) { return false; }
    }

    /* ============================================================
     * 4. 界面
     * ============================================================ */

    function buildUi() {
        const existing = document.getElementById(ROOT_ID);
        if (existing && existing.parentNode === document.body) return;   // 已经好好挂着,别重复建
        if (existing) existing.remove();                                 // 被塞到奇怪的位置,拆了重来

        // 关键:先把原文抓到手,再动 DOM。
        // 原来这里是先 innerHTML='' 把 body 清了,之后再去读 
,读到的永远是空的。
        // 只在「这个页面本身就是数据文件」时抓。
 出现在别人的网页上时,抓来的是页面文案不是文件。
        if (autoTarget()) captureSource();
        claimReader();

        injectStyles();

        document.body.innerHTML = '';
        document.documentElement.style.height = '100%';

        const root = document.createElement('div');
        root.className = 'dr-root';
        root.id = ROOT_ID;

        // ---- 顶部工具条 ----
        const top = document.createElement('div');
        top.className = 'dr-topbar';
        top.innerHTML = [
            'JSON',
            'data',
            '',
            '',
            '',
            '',
            '',
            '',
            '
', '', '', '', '' ].join(''); // ---- 列显隐面板 ---- const colPanel = document.createElement('div'); colPanel.className = 'dr-colpanel'; colPanel.hidden = true; // ---- 统计面板 ---- const statPanel = document.createElement('div'); statPanel.className = 'dr-statspanel'; statPanel.hidden = true; // ---- 分屏 ---- const split = document.createElement('div'); split.className = 'dr-split'; const left = document.createElement('div'); left.className = 'dr-pane dr-pane-left'; left.setAttribute('data-ref', 'left'); const splitter = document.createElement('div'); splitter.className = 'dr-splitter'; const right = document.createElement('div'); right.className = 'dr-pane dr-pane-right'; const editor = document.createElement('textarea'); editor.className = 'dr-editor'; editor.spellcheck = false; editor.setAttribute('data-ref', 'editor'); editor.setAttribute('wrap', 'off'); right.appendChild(editor); split.appendChild(left); split.appendChild(splitter); split.appendChild(right); // ---- 分页条 ---- const foot = document.createElement('div'); foot.className = 'dr-foot'; foot.setAttribute('data-ref', 'foot'); root.appendChild(top); root.appendChild(statPanel); root.appendChild(split); root.appendChild(foot); document.body.appendChild(root); // ---- 拖放遮罩 ---- const overlay = document.createElement('div'); overlay.id = 'dr-drop-overlay'; overlay.innerHTML = [ '
', '
{ }
', '
将 JSON / CSV 文件拖放到此处
', '
支持 .json .csv .tsv
', '
' ].join(''); document.body.appendChild(overlay); root.querySelectorAll('[data-ref]').forEach(function (el) { refs[el.getAttribute('data-ref')] = el; }); refs.root = root; refs.top = top; refs.colPanel = colPanel; refs.statPanel = statPanel; refs.left = left; refs.right = right; refs.editor = editor; refs.splitter = splitter; refs.foot = foot; refs.overlay = overlay; document.body.appendChild(colPanel); bindUi(); } /* ============================================================ * 5. 渲染 * ============================================================ */ function visibleHeaders() { const m = state.model; if (!m || m.kind !== 'json' && m.kind !== 'csv') return []; return m.headers.filter(function (h) { return !state.hidden[h]; }); } function compareVals(a, b, type) { if (a === undefined && b === undefined) return 0; if (a === undefined) return 1; // 空值恒排最后 if (b === undefined) return -1; if (type === 'number') return a - b; if (type === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); } /** 应用搜索 + 排序,返回 { indexes, rows } */ function buildView() { const m = state.model; const headers = visibleHeaders(); const idx = headers.map(function (h) { return m.headers.indexOf(h); }); const q = state.query.trim().toLowerCase(); const keep = []; for (let r = 0; r < m.rows.length; r++) { if (q) { let hit = false; for (let c = 0; c < idx.length; c++) { if (cellText(m.rows[r][idx[c]]).toLowerCase().indexOf(q) >= 0) { hit = true; break; } } if (!hit) continue; } keep.push(r); } if (state.sortKey && state.sortDir) { const ci = m.headers.indexOf(state.sortKey); if (ci >= 0) { const type = (m.types && m.types[state.sortKey]) || 'string'; const dir = state.sortDir; keep.sort(function (x, y) { const d = compareVals(castVal(m.rows[x][ci], type), castVal(m.rows[y][ci], type), type); return d !== 0 ? d * dir : x - y; // 同值时保持原始顺序 }); } } return { indexes: idx, rows: keep }; } /* 一次渲染会用到筛选结果好几遍(表头 / 表格 / 分页 / 统计), 5 万行时每遍都是全表扫描,所以按状态签名缓存一份。 */ let viewCache = { key: null, val: null }; let modelSeq = 0; function viewKey() { const m = state.model; if (!m) return 'none'; let hid = ''; for (const k of Object.keys(state.hidden)) if (state.hidden[k]) hid += k + '\u0001'; return [m.__id, state.query, state.sortKey, state.sortDir, hid].join('\u0000'); } function getView() { const key = viewKey(); if (viewCache.key === key) return viewCache.val; const val = buildView(); viewCache = { key: key, val: val }; return val; } function columnStats() { const m = state.model; const view = getView(); const hasNum = m.headers.some(function (h) { return (m.types && m.types[h]) === 'number'; }); const hasBool = m.headers.some(function (h) { return (m.types && m.types[h]) === 'boolean'; }); const canDistinct = view.rows.length <= 20000; const out = []; m.headers.forEach(function (h, ci) { const type = (m.types && m.types[h]) || 'string'; let nonEmpty = 0, sum = 0, mn = Infinity, mx = -Infinity, tCount = 0, fCount = 0; const seen = (type === 'string' && canDistinct) ? Object.create(null) : null; for (let i = 0; i < view.rows.length; i++) { const v = castVal(m.rows[view.rows[i]][ci], type); if (v === undefined) continue; nonEmpty++; if (type === 'number') { sum += v; if (v < mn) mn = v; if (v > mx) mx = v; } else if (type === 'boolean') { if (v) tCount++; else fCount++; } else if (seen) { seen[String(v)] = 1; } } out.push({ name: h, type: type, nonEmpty: nonEmpty, viewRows: view.rows.length, distinct: seen ? Object.keys(seen).length : null, sum: sum, min: type === 'number' ? (nonEmpty ? mn : null) : null, max: type === 'number' ? (nonEmpty ? mx : null) : null, tCount: tCount, fCount: fCount }); }); return { stats: out, viewRows: view.rows.length, hasNum: hasNum, hasBool: hasBool, canDistinct: canDistinct }; } function fmtNum(n) { if (n === null || n === undefined || !isFinite(n)) return '—'; if (Number.isInteger(n)) return n.toLocaleString('en-US'); return Number(n.toFixed(4)).toLocaleString('en-US'); } function typeLabel(t) { return t === 'number' ? '数字' : t === 'boolean' ? '布尔' : '文本'; } function renderTable() { const m = state.model; const left = refs.left; if (state.error) { left.innerHTML = [ '
解析失败', escapeHtml(state.error), '
左侧预览已暂停,右侧原文可继续编辑;改好后点「重新解析」。
', '
' ].join(''); return; } if (!m) { left.innerHTML = [ '
', '
{ }
', '
拖入 .json / .csv / .tsv 文件,或点右上角「列」旁的编辑区直接粘贴内容
', '
' ].join(''); return; } if (m.kind === 'tree') { left.innerHTML = '
' + renderTree(m.jsonValue, 0) + '
'; return; } if (!m.rows.length) { left.innerHTML = [ '
', '
∅
', '
' + escapeHtml(state.format === 'json' ? '这个 JSON 是个空数组 []' : 'CSV 只有表头,没有数据行') + '
', '
' ].join(''); return; } const headers = visibleHeaders(); const view = getView(); const rows = view.rows; const total = m.rows.length; const pages = Math.max(1, Math.ceil(rows.length / state.pageSize)); if (state.page > pages) state.page = pages; const from = (state.page - 1) * state.pageSize; const pageRows = rows.slice(from, from + state.pageSize); const html = ['
', '']; headers.forEach(function (h) { const type = (m.types && m.types[h]) || 'string'; const arrow = state.sortKey === h && state.sortDir ? '' + (state.sortDir > 0 ? '▲' : '▼') + '' : ''; html.push(''); }); html.push(''); for (let i = 0; i < pageRows.length; i++) { const srcRow = m.rows[pageRows[i]]; html.push(''); for (let c = 0; c < headers.length; c++) { const ci = m.headers.indexOf(headers[c]); const raw = srcRow[ci]; const type = (m.types && m.types[headers[c]]) || 'string'; const text = cellText(raw); let cls = ''; if (raw === null) cls = 'dr-null'; else if (raw !== undefined && typeof raw === 'object') cls = 'dr-deep'; else if (type === 'number') cls = 'dr-num'; else if (type === 'boolean') cls = 'dr-bool'; const title = cellTitle(raw); html.push(''); } html.push(''); } html.push('
#' + escapeHtml(h) + arrow + '' + typeLabel(type) + '
' + (pageRows[i] + 1) + '' + escapeHtml(text) + '
'); left.innerHTML = html.join(''); } /** 非数组 JSON 的可折叠树 */ function renderTree(v, depth) { const open = depth < 2 ? ' open' : ''; if (v === null) return 'null'; if (Array.isArray(v)) { if (!v.length) return '[]'; return 'Array' + '' + v.length + ' 项' + v.map(function (x, i) { return '
' + renderTree(x, depth + 1) + '
'; }).join('') + ''; } if (typeof v === 'object') { const keys = Object.keys(v); if (!keys.length) return '{}'; return 'Object' + '' + keys.length + ' 键' + keys.map(function (k) { return '
' + escapeHtml(k) + ': ' + renderTree(v[k], depth + 1) + '
'; }).join('') + ''; } if (typeof v === 'string') return '"' + escapeHtml(v) + '"'; if (typeof v === 'number') return '' + escapeHtml(String(v)) + ''; if (typeof v === 'boolean') return '' + escapeHtml(String(v)) + ''; return escapeHtml(String(v)); } function renderHead() { const m = state.model; const isCsv = state.format !== 'json'; refs.badge.textContent = isCsv ? state.format.toUpperCase() : 'JSON'; refs.badge.className = 'dr-badge' + (isCsv ? ' dr-badge-csv' : ''); refs.name.textContent = state.fileName; refs.dirty.textContent = state.text === state.original ? '' : '● 未保存'; if (state.error) { refs.meta.textContent = state.text.length.toLocaleString('en-US') + ' 字符 · 解析失败'; } else if (!m) { refs.meta.textContent = ''; } else if (m.kind === 'tree') { refs.meta.textContent = '顶层不是数组,已用树视图展示'; } else if (!m.rows.length) { refs.meta.textContent = (m.headers || []).length + ' 列 · 0 行'; } else { const filtered = state.query.trim() ? getView().rows.length : m.rows.length; refs.meta.textContent = (m.headers || []).length + ' 列 · ' + m.rows.length.toLocaleString('en-US') + ' 行' + (filtered !== m.rows.length ? '(筛出 ' + filtered.toLocaleString('en-US') + ')' : ''); } } function renderFoot() { const m = state.model; if (state.error || !m || m.kind === 'tree') { refs.foot.innerHTML = ''; return; } const rows = m.rows.length; if (!rows) { refs.foot.innerHTML = ''; return; } const view = getView(); const filtered = view.rows.length; const pages = Math.max(1, Math.ceil(filtered / state.pageSize)); const from = (state.page - 1) * state.pageSize; const shownFrom = filtered ? from + 1 : 0; const shownTo = Math.min(filtered, from + state.pageSize); refs.foot.innerHTML = [ '显示 ' + shownFrom.toLocaleString('en-US') + '–' + shownTo.toLocaleString('en-US') + ' / 筛选后 ' + filtered.toLocaleString('en-US') + ' / 总计 ' + rows.toLocaleString('en-US') + ' 行', '', '每页 ', '第 ' + state.page + ' / ' + pages + ' 页', '', '', '', '' ].join(''); } function renderStats() { if (!state.showStats) { refs.statPanel.hidden = true; return; } const m = state.model; if (state.error || !m || m.kind === 'tree') { refs.statPanel.hidden = false; refs.statPanel.innerHTML = '
' + (state.error ? '解析失败,无法统计。' : '树视图不提供列统计。') + '
'; return; } refs.statPanel.hidden = false; const r = state.stats || (state.stats = columnStats()); const head = ['列', '类型', '非空', '缺失']; if (r.hasNum) head.push('最小', '最大', '平均', '合计'); if (r.hasBool) head.push('true', 'false'); if (r.canDistinct) head.push('不同值'); const body = r.stats.map(function (s) { const td = ['' + escapeHtml(s.name) + '', '' + typeLabel(s.type) + '', '' + s.nonEmpty.toLocaleString('en-US') + '', '' + (s.viewRows - s.nonEmpty).toLocaleString('en-US') + '']; if (r.hasNum) { td.push('' + fmtNum(s.min) + '', '' + fmtNum(s.max) + '', '' + fmtNum(s.nonEmpty ? s.sum / s.nonEmpty : null) + '', '' + fmtNum(s.sum) + ''); } if (r.hasBool) { td.push('' + s.tCount.toLocaleString('en-US') + '', '' + s.fCount.toLocaleString('en-US') + ''); } if (r.canDistinct) td.push('' + (s.distinct === null ? '—' : s.distinct.toLocaleString('en-US')) + ''); return '' + td.join('') + ''; }).join(''); refs.statPanel.innerHTML = '' + head.join('') + '' + body + '
'; } function renderColPanel() { const m = state.model; if (!m || (m.kind !== 'json' && m.kind !== 'csv')) { refs.colPanel.hidden = true; return; } refs.colPanel.innerHTML = ['

显示的列(' + visibleHeaders().length + '/' + m.headers.length + ')

'] .concat(m.headers.map(function (h) { const on = !state.hidden[h]; const type = (m.types && m.types[h]) || 'string'; return ''; })) .concat(['']) .join(''); } function renderAll() { state.stats = null; renderHead(); renderTable(); renderFoot(); renderStats(); renderColPanel(); const btn = refs.top.querySelector('[data-act="stats"]'); if (btn) btn.classList.toggle('on', state.showStats); } /* ============================================================ * 6. 解析调度 / 加载 / 保存 * ============================================================ */ function reparse(keepView) { const text = refs.editor ? refs.editor.value : state.text; state.text = text; state.stats = null; try { state.model = parseData(text, state.fileName); state.model.__id = ++modelSeq; // 换模型 = 缓存作废 state.error = ''; // 换了文件结构时,清掉不再存在的排序列 if (state.sortKey && state.model.headers && state.model.headers.indexOf(state.sortKey) < 0) { state.sortKey = null; state.sortDir = 0; } } catch (e) { state.model = null; state.error = (e && e.message) ? e.message : String(e); } if (!keepView) state.page = 1; renderAll(); } function scheduleReparse() { clearTimeout(state.debounceTimer); state.debounceTimer = setTimeout(function () { reparse(true); }, PREVIEW_DEBOUNCE); } function loadText(text, fileName) { state.fileName = fileName || 'data'; state.original = stripBom(String(text == null ? '' : text)); state.text = state.original; state.query = ''; state.sortKey = null; state.sortDir = 0; state.page = 1; state.hidden = {}; state.stats = null; state.format = extOf(state.fileName) === 'csv' || extOf(state.fileName) === 'tsv' ? (extOf(state.fileName) === 'tsv' ? 'tsv' : 'csv') : 'json'; // 内容已经就位,拖放遮罩没有存在意义了(拖进来 / 自动解析两条路径都要收) showOverlay(false); if (refs.colPanel) refs.colPanel.hidden = true; // 换文件时把编辑区整体换掉 const ta = refs.editor; const pos = { top: ta.scrollTop, left: ta.scrollLeft }; ta.value = state.original; ta.scrollTop = pos.top; ta.scrollLeft = pos.left; if (refs.search) refs.search.value = ''; reparse(false); } async function loadFile(file) { if (!file) return; const ext = extOf(file.name); if (EXT_DATA.indexOf(ext) < 0) { alert('只支持 .json / .csv / .tsv 文件!'); return; } if (state.text !== state.original && !confirm('当前内容有未保存的修改,确定丢弃并打开 ' + file.name + ' 吗?')) return; ensureUi(); try { const buf = await file.arrayBuffer(); loadText(decodeText(new Uint8Array(buf)), file.name); } catch (e) { alert('读取文件失败:' + (e && e.message ? e.message : e)); } } function pickFiles() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,.csv,.tsv'; input.style.display = 'none'; input.addEventListener('change', function () { const f = input.files && input.files[0]; try { input.remove(); } catch (e) { /* 忽略 */ } if (f) loadFile(f); }); document.body.appendChild(input); input.click(); } /** 按目标格式拿到最终要写出的文本 */ function buildOutput(fmt) { const target = fmt || 'origin'; const srcFormat = state.format; const wantJson = target === 'json' || (target === 'origin' && srcFormat === 'json'); // 解析失败时不猜,直接把原文原样写出去,绝不吞掉用户内容 if (state.error || !state.model) { return { text: state.text, ext: srcFormat === 'csv' ? 'csv' : (srcFormat === 'tsv' ? 'tsv' : 'json') }; } const m = state.model; if (wantJson) { if (m.kind === 'json' || m.kind === 'tree') { return { text: JSON.stringify(m.jsonValue, null, 2), ext: 'json' }; } return { text: JSON.stringify(tableToJson(m), null, 2), ext: 'json' }; } // 树视图 / JSON → CSV:把对象数组铺平成表 if (m.kind === 'json') { const flat = modelFromJson(m.jsonValue); if (flat.kind !== 'json' || !flat.headers.length) { return { text: toCsv({ headers: ['值'], rows: [m.jsonValue].map(function (r) { return [r]; }), types: {} }, ','), ext: 'csv' }; } return { text: toCsv(flat, ','), ext: 'csv' }; } return { text: toCsv(m, m.delim), ext: target === 'csv' ? 'csv' : srcFormat }; } function save(fmt) { const out = buildOutput(fmt); if (!out.text) return; const name = baseName(state.fileName) + '.' + out.ext; const mime = out.ext === 'json' ? 'application/json;charset=utf-8' : 'text/csv;charset=utf-8'; downloadBlob(new Blob([out.text], { type: mime }), name); // 只有「按原格式保存」才认为已保存,格式转换另存不覆盖当前编辑状态 if (!fmt || fmt === 'origin') { state.original = out.text; state.text = out.text; if (refs.editor) refs.editor.value = out.text; renderHead(); } } /* ============================================================ * 7. 事件 * ============================================================ */ function onAction(act) { switch (act) { case 'save': save(undefined); break; case 'reparse': reparse(false); break; case 'revert': if (state.text !== state.original && !confirm('确定丢弃所有修改,回到载入时的内容吗?')) return; if (refs.editor) refs.editor.value = state.original; reparse(false); break; case 'stats': state.showStats = !state.showStats; renderAll(); break; case 'cols': refs.colPanel.hidden = !refs.colPanel.hidden; break; case 'first': state.page = 1; renderTable(); renderFoot(); break; case 'prev': if (state.page > 1) { state.page--; renderTable(); renderFoot(); } break; case 'next': if (state.page * state.pageSize < getView().rows.length) { state.page++; renderTable(); renderFoot(); } break; case 'last': state.page = Math.max(1, Math.ceil(getView().rows.length / state.pageSize)); renderTable(); renderFoot(); break; case 'pick': pickFiles(); break; default: break; } } function bindUi() { // 界面可能被浏览器的查看器冲掉后重建,这些 document 级监听不能重复挂 if (bindUi._done) return; bindUi._done = true; // 顶部按钮 + 分页条按钮统一委托 document.addEventListener('click', function (e) { const el = e.target && e.target.closest ? e.target.closest('[data-act]') : null; if (!el) return; const act = el.dataset.act; if (act === 'pagesize') return; // change 事件处理 if (act === 'save' || act === 'reparse' || act === 'revert' || act === 'stats' || act === 'cols' || act === 'pick') { e.preventDefault(); onAction(act); return; } if (act === 'first' || act === 'prev' || act === 'next' || act === 'last') { e.preventDefault(); if (!el.disabled) onAction(act); return; } // 点表格头排序 if (el.tagName === 'TH' && el.dataset.sort !== undefined) { const key = el.dataset.sort; if (state.sortKey === key) { if (state.sortDir === 1) state.sortDir = -1; else if (state.sortDir === -1) { state.sortDir = 0; state.sortKey = null; } } else { state.sortKey = key; state.sortDir = 1; } state.page = 1; renderTable(); renderFoot(); } }); // 分页条里的每页条数 document.addEventListener('change', function (e) { const el = e.target; if (!el || el.dataset.act !== 'pagesize') return; state.pageSize = Number(el.value) || DEFAULT_PAGE; state.page = 1; renderTable(); renderFoot(); }); // 列显隐 document.addEventListener('change', function (e) { const el = e.target; if (!el || !el.dataset || el.dataset.col === undefined) return; const m = state.model; if (!m || !m.headers.length) return; const col = el.dataset.col; if (col === '__all') { state.hidden = {}; // 全不勾选会得到一张空表没法恢复,所以至少保留第一列 if (!el.checked) m.headers.forEach(function (h, i) { if (i > 0) state.hidden[h] = true; }); } else if (el.checked) { delete state.hidden[col]; } else { state.hidden[col] = true; } state.stats = null; renderColPanel(); renderTable(); renderFoot(); renderStats(); }); // 点击面板外关闭列面板 document.addEventListener('mousedown', function (e) { if (refs.colPanel.hidden) return; if (refs.colPanel.contains(e.target)) return; const btn = e.target && e.target.closest ? e.target.closest('[data-act="cols"]') : null; if (btn) return; refs.colPanel.hidden = true; }); // 搜索 let searchTimer = 0; refs.search.addEventListener('input', function () { const v = this.value; clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.query = v; state.page = 1; state.stats = null; renderHead(); renderTable(); renderFoot(); renderStats(); }, 150); }); // 保存格式选择 refs.top.querySelector('[data-ref="savefmt"]').addEventListener('change', function () { save(this.value === 'origin' ? undefined : this.value); this.value = 'origin'; }); // 编辑区 refs.editor.addEventListener('input', function () { state.text = this.value; renderHead(); scheduleReparse(); }); refs.editor.addEventListener('keydown', function (e) { // Tab 插入两个空格而不是跳走(缩进敏感格式) if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); const ta = e.target; const s = ta.selectionStart, en = ta.selectionEnd; ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(en); ta.selectionStart = ta.selectionEnd = s + 2; state.text = ta.value; renderHead(); scheduleReparse(); return; } // Ctrl+S 保存 / Ctrl+Enter 强制重新解析 if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); save(undefined); return; } if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); clearTimeout(state.debounceTimer); reparse(false); } }); // 分割条拖动 let dragging = false, startX = 0, startW = 0; refs.splitter.addEventListener('mousedown', function (e) { dragging = true; startX = e.clientX; startW = refs.left.getBoundingClientRect().width; refs.splitter.classList.add('dragging'); document.body.style.cursor = 'col-resize'; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; const total = refs.root.querySelector('.dr-split').getBoundingClientRect().width; const spW = refs.splitter.getBoundingClientRect().width; const w = Math.min(Math.max(startW + (e.clientX - startX), total * 0.1), total * 0.9 - spW); refs.left.style.flex = '0 0 ' + w + 'px'; refs.right.style.flex = '0 0 ' + (total - w - spW) + 'px'; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; refs.splitter.classList.remove('dragging'); document.body.style.cursor = ''; }); // 未保存离开提醒 window.addEventListener('beforeunload', function (e) { if (state.text === state.original) return; e.preventDefault(); e.returnValue = ''; return ''; }); } /* ---------------- 拖放:只接管 .json/.csv/.tsv ---------------- */ function isDataDrag(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; } /** * 拖动中拿文件名;拿不到才看 MIME。 * MIME 只认 json/csv/tsv 专有类型。application/vnd.ms-excel 是 .xls 的类型, * text/plain 又和 markdown 的 .txt 重叠——这两类一旦在 dragover 里 preventDefault, * 浏览器就不会打开文件,另一个脚本也收不到这次拖放。 */ function dragHasDataFile(e) { const dt = e.dataTransfer; if (!dt) return false; const items = dt.items ? Array.prototype.slice.call(dt.items) : []; for (let i = 0; i < items.length; i++) { if (items[i].kind && items[i].kind !== 'file') continue; let f = null; try { f = items[i].getAsFile(); } catch (err) { f = null; } if (f && f.name) { if (EXT_DATA.indexOf(extOf(f.name)) >= 0) return true; continue; } const type = String(items[i].type || '').toLowerCase().split(';')[0].trim(); if (type === 'application/json' || type === 'text/json' || type === 'text/csv' || type === 'text/tab-separated-values') return true; } return false; } /** 拖放提示层。不走 buildUi,这样在别人的页面上拖一下不会把整页清掉。 */ function ensureOverlay() { if (refs.overlay && refs.overlay.parentNode) return refs.overlay; if (!document.body) return null; injectStyles(); const overlay = document.createElement('div'); overlay.id = 'dr-drop-overlay'; overlay.innerHTML = [ '
', '
{ }
', '
将 JSON / CSV 文件拖放到此处
', '
支持 .json .csv .tsv
', '
' ].join(''); document.body.appendChild(overlay); refs.overlay = overlay; return overlay; } function showOverlay(on) { if (on) ensureOverlay(); if (!refs.overlay) return; refs.overlay.classList.toggle('show', !!on); } function bindDrop() { document.addEventListener('dragenter', function (e) { if (!isDataDrag(e) || !dragHasDataFile(e)) return; e.preventDefault(); showOverlay(true); }, true); document.addEventListener('dragover', function (e) { if (!isDataDrag(e) || !dragHasDataFile(e)) return; e.preventDefault(); try { e.dataTransfer.dropEffect = 'copy'; } catch (err) { /* 忽略 */ } }, true); document.addEventListener('dragleave', function (e) { // 指针还在页面里时 relatedTarget 有值,这时收起遮罩会闪。 // 只有真正离开窗口才收。 if (e.relatedTarget) return; showOverlay(false); }, true); document.addEventListener('drop', function (e) { if (!isDataDrag(e)) return; showOverlay(false); const files = Array.prototype.slice.call((e.dataTransfer && e.dataTransfer.files) || []); const hit = files.filter(function (f) { return EXT_DATA.indexOf(extOf(f.name)) >= 0; }); if (!hit.length) return; // 不是数据文件 → 原样放行,浏览器自己处理 e.preventDefault(); e.stopPropagation(); loadFile(hit[0]); }, true); } /* ============================================================ * 8. 启动 * ============================================================ */ /** 当前页面是不是「直接打开的本地数据文件」 */ function autoTarget() { if (location.protocol !== 'file:') return ''; // 不接管 http(s),避免抢接口页面 const pathname = decodeURIComponent(location.pathname || ''); const name = pathname.split('/').pop() || ''; if (!name || EXT_DATA.indexOf(extOf(name)) < 0) return ''; return name; } /** 从浏览器渲染好的页面里取原文(Chrome 把 json/csv 放在
 里,JSON 查看器也是建在它旁边) */
    function readPageText() {
        return captureSource();
    }

    function registerMenus() {
        if (typeof GM_registerMenuCommand !== 'function') return;
        try {
            GM_registerMenuCommand('📂 打开 JSON / CSV 文件…', pickFiles);
            GM_registerMenuCommand('💾 保存当前内容', function () { save(undefined); });
            GM_registerMenuCommand('🔄 重新解析', function () { reparse(false); });
        } catch (e) { /* 忽略 */ }
    }

    /** 对外暴露一点口子,方便在控制台/自动化里驱动(和 word-reader.js 的做法一致) */
    function exposeApi() {
        const api = {
            version: VERSION,
            loadText: loadText,
            open: pickFiles,
            save: save,
            reparse: reparse,
            state: state
        };
        try { window.DataReader = api; } catch (e) { /* 忽略 */ }
        try { (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).DataReader = api; } catch (e) { /* 忽略 */ }
    }

    /**
     * 浏览器自带 JSON 查看器会重建 body 把界面冲掉。
     * 这里盯着 document,一旦发现自己的根节点没了就按快照重新挂回去。
     * 用 rAF 合并:一帧内几十次 DOM 变动只做一次检查,不会卡。
     */
    function guardNativeViewer() {
        let scheduled = false;
        let observer = null;
        const check = function () {
            scheduled = false;
            // markdown / word 已经声明接管时,不要把界面盖回去。
            if (!readerStillOurs()) {
                try { if (observer) observer.disconnect(); } catch (e) { /* 忽略 */ }
                return;
            }
            const root = document.getElementById(ROOT_ID);
            // 根节点必须直接挂在 body 下才算「界面还在」。
            // 被浏览器连同 
 一起重新插到别处,也当失效处理,重新挂一次。
            if (!root || root.parentNode !== document.body) remount();
            else killNativeViewer();
        };
        const schedule = function () {
            if (scheduled) return;
            scheduled = true;
            const raf = window.requestAnimationFrame || function (fn) { setTimeout(fn, 16); };
            raf(check);
        };
        try {
            observer = new MutationObserver(schedule);
            observer.observe(document.documentElement, {
                childList: true,
                subtree: true
            });
        } catch (e) { /* 忽略 */ }
    }

    /** 界面被冲掉后重建:用已经抓在手里的正文快照恢复 */
    function remount() {
        const fileName = state.fileName;
        buildUi();
        killNativeViewer();
        // 已经载入过的内容优先(用户可能改过)。页面原文快照只用于「还没 loadText 就被浏览器冲掉」。
        if (state.text) {
            loadText(state.text, fileName);
        } else if (capturedSource && capturedSource.trim() && fileName && fileName !== 'data') {
            loadText(capturedSource, fileName);
        } else {
            renderAll();
            showOverlay(true);
        }
    }

    /** 取不到原文时也照样给出界面(错误面板本身就是指引),绝不留一片空白让人以为脚本没装 */
    function showEmptyState(auto, msg) {
        state.fileName = auto;
        state.format = extOf(auto) === 'json' ? 'json' : extOf(auto);
        state.error = msg;
        state.text = '';
        state.model = null;
        refs.editor.value = '';
        renderAll();
    }

    function startAutoContent(text, auto) {
        if (text.length > MAX_AUTO_PARSE) {
            showEmptyState(auto, '文件超过 64 MB,已跳过自动解析。可以把内容粘贴到右侧再点「重新解析」。');
            showOverlay(true);
            return;
        }
        loadText(text, auto);
    }

    let viewerGuarding = false;
    function ensureUi() {
        if (!document.body) return;
        buildUi();
        if (viewerGuarding) return;
        viewerGuarding = true;
        guardNativeViewer();
    }

    function bufferToText(buf) {
        if (buf == null || buf === '') return '';
        if (typeof buf === 'string') return buf;
        try {
            if (buf instanceof ArrayBuffer) return decodeText(new Uint8Array(buf));
            if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(buf)) {
                return decodeText(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
            }
        } catch (e) { /* 忽略 */ }
        return '';
    }

    /** 页面自己的 XHR。油猴沙箱里的 XHR 经常读不到 file://,必须借页面的。 */
    function pageXhr(url, done, binary) {
        const XHR = (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.XMLHttpRequest)
            ? unsafeWindow.XMLHttpRequest
            : XMLHttpRequest;
        let xhr;
        try { xhr = new XHR(); } catch (e) { done(''); return; }
        try { xhr.open('GET', url, true); } catch (e) { done(''); return; }
        if (binary) { try { xhr.responseType = 'arraybuffer'; } catch (e) { /* 退回文本 */ } }
        xhr.onload = function () {
            if (xhr.status && xhr.status !== 200 && xhr.status !== 0) { done(''); return; }
            if (binary) {
                const text = bufferToText(xhr.response);
                if (text && text.trim()) { done(text); return; }
                pageXhr(url, done, false);
                return;
            }
            done(xhr.responseText || '');
        };
        xhr.onerror = function () {
            if (binary) pageXhr(url, done, false);
            else done('');
        };
        try { xhr.send(); } catch (e) {
            if (binary) pageXhr(url, done, false);
            else done('');
        }
    }

    /**
     * 按文件地址把字节读回来。
     * Chrome / Edge 打开 .csv 时经常不生成 
(JSON 才有内置查看器),
     * 只盯着 DOM 就会什么都不解析。
     */
    function readFileUrl(url, done) {
        let settled = false;
        const finish = function (text) {
            if (settled) return;
            settled = true;
            try { done(text || ''); } catch (e) { /* 忽略 */ }
        };
        pageXhr(url, function (text) {
            if (text && String(text).trim()) { finish(text); return; }
            if (typeof GM_xmlhttpRequest !== 'function') { finish(''); return; }
            try {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'arraybuffer',
                    timeout: 20000,
                    onload: function (res) {
                        const bufText = bufferToText(res && res.response);
                        finish(bufText || (res && res.responseText) || '');
                    },
                    onerror: function () { finish(''); },
                    ontimeout: function () { finish(''); }
                });
            } catch (e) { finish(''); }
        }, true);
    }

    function init() {
        // 拖放监听在所有页面都挂上。界面只在确认是本地数据文件,或用户真的拖入了数据文件时才建。
        bindDrop();
        registerMenus();
        exposeApi();

        const auto = autoTarget();
        if (!auto) return;

        // 读到原文再铺界面。提前 buildUi 会把 
 清掉,后面就补不回来了。
        const bootFromText = function (text) {
            if (!text || !text.trim()) return false;
            capturedSource = text;
            ensureUi();
            startAutoContent(text, auto);
            return true;
        };
        const fail = function () {
            ensureUi();
            showEmptyState(auto, '没能从这个页面读到文件原文(浏览器可能把它当成下载处理了)。'
                + '点「📂 打开」选择文件,或直接把 ' + auto + ' 拖进这个页面。');
        };

        // JSON 查看器同步就把原文放进 
,这条路径不能改成异步,否则会被它抢先冲掉。
        if (bootFromText(captureSource(true))) return;

        readFileUrl(location.href, function (text) {
            if (bootFromText(text) || bootFromText(captureSource(true))) return;
            fail();
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})();