diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea63b0..fa9016c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 当前版本:1.5.0。以下按版本记录主要变更,当前行为以 README 和使用说明为准。 +## 外观设置 + +- 新增外观设置:石墨紫、深海蓝、松林绿、暖白四套配色,支持快捷 / 自选强调色。 +- 支持 12–16 px 数据字号、紧凑 / 舒适表格密度和隔行底色;即时生效、本地记忆,并可一键恢复默认。 +- 表格、数据树、详情窗口和编辑标记统一使用主题颜色;字体变化时调整当前表格列宽。 + ## 1.5.0 后续优化 - 鼠标悬停字段整行后按 F2 直接编辑;表格按鼠标所在单元格定位,无需先点击数值。 diff --git a/README.md b/README.md index c98e618..f5cd06a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Chrome DevTools 中的 JSON 接口调试工具。把响应展开成可搜索的 | 请求复现 | 请求详情、请求体 JSON、Bash / WSL / Git Bash 格式 cURL | | 数据导出 | 原始响应、当前节点、全部筛选和排序后的 CSV / Markdown / JSON | | 数值精度 | 普通超安全整数字面量精确保留,贯穿浏览、搜索、排序、复制和 JSON 导出 | +| 个性化外观 | 四套配色、自选强调色、数据字号、表格密度和隔行底色;本地记忆,一键恢复默认 | | 面板布局 | 独立收起左侧列表、拖动分栏、统一滚动条、布局与分页偏好记忆 | 表头字段保持单行,内容自动列宽通常为 88–380 px;手动列宽通常最高 960 px,超长表头可突破上限。长内容省略展示,隐藏列不影响导出。 diff --git a/appearance.js b/appearance.js new file mode 100644 index 0000000..9c6f47f --- /dev/null +++ b/appearance.js @@ -0,0 +1,67 @@ +// Apply saved colors before the first paint; appearance never changes captured data. +const Appearance=(()=>{ + const storageKey='api-table-viewer.appearance.v1'; + const themes={graphite:{background:'#181a20',accent:'#8b9dff'},ocean:{background:'#171e28',accent:'#4ea1ff'},forest:{background:'#14201d',accent:'#52c9a5'},paper:{background:'#f3f5f9',accent:'#4f5bd5'}}; + const defaults={theme:'graphite',accent:themes.graphite.accent,fontSize:13,density:'comfortable',striped:true}; + function normalize(value){ + const data=value && typeof value==='object'?value:{}; + const theme=Object.hasOwn(themes,data.theme)?data.theme:defaults.theme; + return {theme,accent:typeof data.accent==='string' && /^#[\da-f]{6}$/i.test(data.accent)?data.accent:themes[theme].accent, + fontSize:[12,13,14,15,16].includes(data.fontSize)?data.fontSize:defaults.fontSize, + density:data.density==='compact'?'compact':'comfortable',striped:typeof data.striped==='boolean'?data.striped:true}; + } + let prefs={...defaults}; + try{prefs=normalize(JSON.parse(localStorage.getItem(storageKey)));}catch{} + function luminance(color){ + const rgb=color.match(/[\da-f]{2}/gi).map(c=>parseInt(c,16)/255).map(c=>c<=.04045?c/12.92:((c+.055)/1.055)**2.4); + return rgb[0]*.2126+rgb[1]*.7152+rgb[2]*.0722; + } + function readableAccent(color,background){ + const bg=luminance(background),contrast=c=>(Math.max(luminance(c),bg)+.05)/(Math.min(luminance(c),bg)+.05); + if(contrast(color)>=4.5)return color; + const rgb=color.match(/[\da-f]{2}/gi).map(c=>parseInt(c,16)); + const end=bg>.179?0:255; + for(let step=1;step<=20;step++){ + const mixed='#'+rgb.map(c=>Math.round(c+(end-c)*step/20).toString(16).padStart(2,'0')).join(''); + if(contrast(mixed)>=4.5)return mixed; + } + return end?'#ffffff':'#000000'; + } + function apply(){ + const root=document.documentElement; + root.dataset.theme=prefs.theme;root.dataset.density=prefs.density;root.dataset.striped=String(prefs.striped); + root.style.setProperty('--blue',readableAccent(prefs.accent,themes[prefs.theme].background)); + root.style.setProperty('--blue2',prefs.accent); + root.style.setProperty('--data-font-size',prefs.fontSize+'px'); + } + apply(); + function mount(){ + const menu=document.getElementById('appearanceMenu'),status=document.getElementById('appearanceStatus'); + const color=document.getElementById('appearanceAccent'),font=document.getElementById('appearanceFont'),density=document.getElementById('appearanceDensity'),striped=document.getElementById('appearanceStriped'); + const palettes=[...menu.querySelectorAll('[data-theme-choice]')],accents=[...menu.querySelectorAll('[data-accent-choice]')]; + function sync(){ + color.value=prefs.accent;font.value=String(prefs.fontSize);density.value=prefs.density;striped.checked=prefs.striped; + for(const button of palettes)button.setAttribute('aria-pressed',String(button.dataset.themeChoice===prefs.theme)); + for(const button of accents)button.setAttribute('aria-pressed',String(button.dataset.accentChoice.toLowerCase()===prefs.accent.toLowerCase())); + document.getElementById('appearanceAccentValue').textContent=prefs.accent.toUpperCase(); + } + function update(changes){ + const previousFont=prefs.fontSize;prefs=normalize({...prefs,...changes});apply();sync(); + try{localStorage.setItem(storageKey,JSON.stringify(prefs));status.textContent='已保存,下次打开继续使用';} + catch{status.textContent='已应用,本次设置无法保存';} + if(previousFont!==prefs.fontSize)document.dispatchEvent(new CustomEvent('appearancechange')); + } + for(const button of palettes)button.onclick=()=>update({theme:button.dataset.themeChoice,accent:themes[button.dataset.themeChoice].accent}); + for(const button of accents)button.onclick=()=>update({accent:button.dataset.accentChoice}); + color.oninput=()=>update({accent:color.value});font.onchange=()=>update({fontSize:Number(font.value)}); + density.onchange=()=>update({density:density.value});striped.onchange=()=>update({striped:striped.checked}); + document.getElementById('appearanceReset').onclick=()=>update(defaults); + document.getElementById('appearanceClose').onclick=()=>close(); + function close(){menu.open=false;document.getElementById('appearanceToggle').focus();} + document.addEventListener('pointerdown',e=>{if(menu.open && !menu.contains(e.target))menu.open=false;}); + document.addEventListener('keydown',e=>{if(e.key==='Escape' && menu.open){e.preventDefault();e.stopPropagation();close();}},true); + sync(); + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount,{once:true});else mount(); + return {get fontSize(){return prefs.fontSize;}}; +})(); diff --git a/docs/TESTING.md b/docs/TESTING.md index 24a67d8..e5ead98 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,6 +15,7 @@ npm test | `npm run test:fixtures` | 本地 HTTP 接口与 Worker 联动,包括节点阈值、50,000 条记录、精确整数和长文本 | | `npm run test:preview` | 示例 HAR、预览 HTTP 路由,以及生成预览的脚本语法和 HTML 标识 | | `npm run test:fields` | 15 项原位字段 Mock 检查:精确整数、类型、数组定位、大响应小补丁、Fetch / XHR、启动请求、会话恢复、站点隔离与 Worker 路径 | +| `npm run test:appearance` | 5 项外观检查:设置持久化与恢复默认、异常配置回退、强调色可读性、面板关闭与焦点恢复、字号变化后调整列宽 | | `npm run test:mock` | 18 项:Fetch / XHR 拦截、精确匹配、单次并发、取消、类型、规则更新、租约、面板控制和 HTTP 页面联动 | 回归测试在 Node.js 中运行 Worker 代码,通过模拟 DOM 和 DevTools 对象检查界面逻辑。接口测试使用随机端口,结束后自动关闭服务。2026-10-20 的自动检查结果:78 项回归、静态检查、8 个接口子案例通过。 diff --git a/docs/USAGE.md b/docs/USAGE.md index 6bf0bc1..7c8c62d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -71,3 +71,7 @@ $.data[0]["field.name"] ## 复制详情文本 进入字符串或其他简单值的详情后,内容区顶部显示“复制内容”。字符串按原文复制,保留真实换行和引号,不额外添加 JSON 外层引号或转义;即使正在查看某个万字符分段,也复制当前节点的全部内容。数字(包括超安全整数)、布尔和 null 使用对应的 JSON 数值文本。原有顶部“复制完整数据”继续复制 JSON 表示,便于保留类型。 + +## 外观设置 + +右上角“外观设置”可以选择石墨紫、深海蓝、松林绿或暖白,并自选强调色。数据字号为 12–16 px,表格可切换紧凑 / 舒适密度,隔行底色可单独关闭。选择即时生效,下次打开继续使用;“恢复默认”只重置外观,不清除接口记录或 Mock 规则。 diff --git a/package.json b/package.json index c80c56e..fade8f5 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ }, "scripts": { "preview": "node tests/preview/server.cjs", - "test": "node tests/regression.cjs && node tests/package-check.cjs && npm run test:fixtures && npm run test:mock && npm run test:preview && npm run test:fields", + "test": "node tests/regression.cjs && node tests/package-check.cjs && npm run test:fixtures && npm run test:mock && npm run test:preview && npm run test:fields && npm run test:appearance", "test:regression": "node tests/regression.cjs", "test:package": "node tests/package-check.cjs", "test:manual": "node tests/manual/server.cjs", "test:fixtures": "node --test tests/manual/check.cjs", "test:preview": "node --test tests/preview/check.cjs", "test:mock": "node --test tests/response-mock.cjs", - "test:fields": "node --test tests/field-mock.cjs" + "test:fields": "node --test tests/field-mock.cjs", + "test:appearance": "node --test tests/appearance.cjs" } } diff --git a/panel.html b/panel.html index 53023be..65a7397 100644 --- a/panel.html +++ b/panel.html @@ -5,12 +5,36 @@ API Table Viewer +
API Table Viewer
+
+ 外观设置 +
+
让界面更顺眼

即时生效,自动记住你的选择

+
整体配色
+ + + + +
+
强调色
+ + + + + + +
#8B9DFF
+
+ + +
+
diff --git a/scripts/package.py b/scripts/package.py index 839748a..d2a3326 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parent.parent RUNTIME = ( "manifest.json", "devtools.html", "devtools.js", "panel.html", "panel.js", - "style.css", "json-codec.js", "worker.js", "viewer.js", "viewer-expanded.js", + "style.css", "appearance.js", "json-codec.js", "worker.js", "viewer.js", "viewer-expanded.js", "developer.js", "table-ui.js", "layout.js", "shortcuts.js", "response-editor.js", "mock-page.js", "response-mock.js", "field-patch-core.js", "field-mock.js", "field-mock-page.js", "field-mock-bridge.js", "field-mock-background.js", diff --git a/style.css b/style.css index f58ce25..503ff9c 100644 --- a/style.css +++ b/style.css @@ -243,3 +243,131 @@ select{background:#242426;color:#ddd;border:1px solid #444;border-radius:4px;pad /* Scalar editing keeps modified fields visible without a full-response diff. */ [hidden]{display:none!important}.field-mock-bar{flex:none;padding:9px 14px;background:#243e3e;border-bottom:1px solid #49665e;color:#b8e0ce;max-height:210px;overflow:auto}.field-mock-heading{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}.field-mock-bar summary{cursor:pointer;margin:7px 0}.field-mock-bar .hint{display:block;margin-top:5px}.field-mock-change{display:flex;gap:8px;align-items:center;padding:5px 0}.field-mock-change>span{flex:1;min-width:0;overflow-wrap:anywhere;white-space:pre-wrap;max-height:64px;overflow:auto}.field-mock-edited{background:#233b4d!important;box-shadow:inset 3px 0 #76bca7}.field-mock-edited .cell-text::after,[data-field-mock].field-mock-edited::after{content:' •';color:#8fddbf}[data-field-mock]{cursor:text}.field-mock-input{font:inherit;color:#e4f3ff;background:#152a3b;border:1px solid #78baa7;border-radius:3px;min-width:70px;max-width:100%;width:100%;padding:3px 5px;box-sizing:border-box}.field-mock-input[aria-invalid=true]{border-color:#f08c8c} + +/* Appearance: neutral surfaces, independent accents, and distinct data types. */ +:root{ + --data-font-size:13px;--row-height:42px;--row-pad:8px; + --bg:#181a20;--panel:#20232c;--panel2:#2b303d;--line:#3d4352;--text:#e6e8ef;--muted:#a6adbd; + --hover:#2c3241;--selection:#343954;--control:#1b1e27;--header:#2b3040;--row:#1e222b;--row-alt:#252a35; + --number:#9fcaff;--string:#a3d5a4;--boolean:#edb974;--null:#b4a1d6;--key:#c3b4f3; + --edited:#213d37;--edited-line:#71c5a4;--match:#5a4820;--match-text:#fff0ba;--match-line:#edbf5d; + --scroll-track:var(--bg);--scroll-thumb:#50596e;--scroll-hover:#69758c;--scroll-active:#8793ac; +} +:root[data-theme=ocean]{ + --bg:#171e28;--panel:#202b3b;--panel2:#293b52;--line:#43536c;--text:#e6edf8;--muted:#a5b4c8; + --hover:#2c4059;--selection:#2d4561;--control:#162130;--header:#293b52;--row:#1e2836;--row-alt:#253244; + --number:#a8d4ff;--string:#b8dda7;--boolean:#ffd193;--null:#c0afdc;--key:#b1d7ff; + --scroll-thumb:#536780;--scroll-hover:#718eaf;--scroll-active:#91b8e0; +} +:root[data-theme=forest]{ + --bg:#14201d;--panel:#1c2a27;--panel2:#283b35;--line:#3c554c;--text:#e0eee7;--muted:#a3bcb0; + --hover:#2a4138;--selection:#2e4c40;--control:#17241f;--header:#293d35;--row:#192a23;--row-alt:#203329; + --number:#a7c8ff;--string:#a3dfa7;--boolean:#eec782;--null:#c7b2df;--key:#a5d7c5; + --scroll-thumb:#506d60;--scroll-hover:#6b8879;--scroll-active:#8ba797; +} +:root[data-theme=paper]{ + color-scheme:light;--bg:#f3f5f9;--panel:#fff;--panel2:#e8edf6;--line:#cbd3e0;--text:#263249;--muted:#59677d; + --hover:#e8edf6;--selection:#dfe5f8;--control:#f7f9fc;--header:#e6ebf5;--row:#fff;--row-alt:#f0f3f9; + --number:#245ba6;--string:#246438;--boolean:#8b5100;--null:#775293;--key:#5a439e; + --green:#197145;--red:#b52c3b;--yellow:#8b5a00;--edited:#e0f2e9;--edited-line:#247b57; + --match:#ffe6a0;--match-text:#533300;--match-line:#946400; + --scroll-thumb:#b0bbce;--scroll-hover:#8e9db5;--scroll-active:#6f819d; +} +:root[data-density=compact]{--row-height:32px;--row-pad:3px} +body{background:var(--bg);color:var(--text)} +.topbar{background:linear-gradient(105deg,var(--panel),var(--panel2));border-color:var(--line)} +.brand .logo{color:var(--blue)} +.sidebar,.pagination,.request-head,.tabs,.side-title,.request-filters,.subtoolbar,.raw-toolbar,.viewer-toolbar,.modal-head,.modal-actions{background:var(--panel);border-color:var(--line)} +.sidebar-resizer{background:var(--bg)} +.sidebar-resizer:hover,.sidebar-resizer:focus-visible{background:var(--blue)} +.side-title,.url,.req-url,.detail-value,.viewer-scalar,.node-head>.node-preview{color:var(--text)} +.req-status,.req-time,.hint,.side-title .count,.th-kind,.table-meta,.page-info,.viewer-page,.detail-key,.node-head>.node-type,.request-search>label{color:var(--muted)} +.count,.tab-badge,.method,.status{background:var(--panel2);color:var(--muted)} +.method.get,.status.ok,.status-dot.ok{color:var(--green)} +.method.post{color:var(--number)}.method.put,.method.patch{color:var(--boolean)}.method.delete,.status.bad{color:var(--red)} +.request-item{border-color:var(--line)} +.request-item:hover,.node-head:hover,.viewer-row:hover,.viewer-flat-row:hover{background:var(--hover)} +.request-item.active{background:var(--selection);border-left-color:var(--blue);box-shadow:inset 0 0 0 1px var(--line)} +.search,select,.detail-value,.field-mock-input{background:var(--control);border-color:var(--line);color:var(--text)} +.search:focus,select:focus-visible{border-color:var(--blue);box-shadow:0 0 0 2px color-mix(in srgb,var(--blue) 20%,transparent)} +.btn,.page-btn,.mini-btn,.copy-mini,.expand-cell,.table-wrap .expand-cell,.viewer-content-actions>.mini-btn{background:var(--panel2);border-color:var(--line);color:var(--text)} +.btn.ghost{background:transparent} +.btn:hover,.page-btn:not(:disabled):hover,.mini-btn:hover,.copy-mini:hover,.expand-cell:hover,.table-wrap .expand-cell:hover,.viewer-content-actions>.mini-btn:hover{background:var(--hover);border-color:var(--blue);color:var(--text)} +.tab{color:var(--muted)}.tab.active,.tab:hover{color:var(--blue);border-bottom-color:var(--blue)} +.tab-badge{color:var(--text)} +.table-toolbar{background:var(--panel);border-color:var(--line)} +.table-path,.viewer-path,.viewer-key,.request-section h3{color:var(--key)} +.table-search-label,.tree-search-controls label{color:var(--muted)} +.table-wrap{background:var(--bg);border-color:var(--line)} +.table-wrap table,.table-wrap thead th,.tree,.raw,.viewer-scalar,.viewer-row,.viewer-flat-row{font-size:var(--data-font-size)} +.table-wrap thead th{background:var(--header);color:var(--text);border-color:var(--line);border-bottom-color:var(--blue)} +.table-wrap thead th:hover,.table-wrap thead th.sorted-column{background:var(--selection);border-bottom-color:var(--blue)} +.th-sort,.sorted-column .th-sort{color:var(--blue)} +.table-wrap tbody td{background:var(--row);color:var(--text);border-color:var(--line);height:var(--row-height);padding-top:var(--row-pad);padding-bottom:var(--row-pad)} +.table-wrap tbody tr:nth-child(even) td{background:var(--row-alt)} +:root[data-striped=false] .table-wrap tbody tr:nth-child(even) td{background:var(--row)} +.table-wrap tbody tr:hover td,.table-wrap tbody td:focus-within{background:var(--hover)} +.table-wrap tbody td:hover{box-shadow:inset 0 0 0 1px var(--blue)} +.table-wrap td.cell-type-number .cell-text,.node-head>.node-type.type-number+.node-preview,.viewer-value.type-number,.viewer-flat-row .type-number{color:var(--number)} +.table-wrap td.cell-type-string .cell-text,.node-head>.node-type.type-string+.node-preview,.viewer-value.type-string{color:var(--string)} +.table-wrap td.cell-type-boolean .cell-text,.node-head>.node-type.type-boolean+.node-preview,.viewer-value.type-boolean{color:var(--boolean)} +.table-wrap td.cell-type-null .cell-text,.viewer-value.type-null{color:var(--null)} +.table-wrap td.cell-type-undefined .cell-text{color:var(--muted)} +.table-wrap .cell-complex{color:var(--boolean)}.cell-viewable:hover{color:var(--text);text-decoration-color:var(--blue)} +.raw,.modal-content,.request-details,.tree{background:var(--bg);color:var(--text)} +.modal-card,.column-popover,.viewer-results button{background:var(--panel);border-color:var(--line);color:var(--text)} +.request-section h3{background:var(--panel2)} +.request-section,.detail-pair,.viewer-children,.viewer-flat-row{border-color:var(--line)} +mark,.table-wrap mark{background:var(--match);color:var(--match-text)} +.table-wrap tbody td.search-current,.table-wrap tbody tr:hover td.search-current,.viewer-row.viewer-search-target{background:var(--match);box-shadow:inset 0 0 0 2px var(--match-line);color:var(--match-text)} +.viewer-search-target .viewer-key{color:var(--match-text)} +.field-mock-bar{background:var(--edited);border-color:var(--edited-line);color:var(--text)} +.field-mock-edited{background:var(--edited)!important;box-shadow:inset 3px 0 var(--edited-line)} +.field-mock-edited .cell-text::after,[data-field-mock].field-mock-edited::after{color:var(--edited-line)} +.field-mock-input{border-color:var(--edited-line)} +.field-mock-selected{outline-color:var(--blue)} +.toast{background:var(--panel2);border-color:var(--line);color:var(--text)} +:root[data-theme=paper] .response-editor-card,:root[data-theme=paper] .response-editor-head,:root[data-theme=paper] .response-mock-controls{background:var(--panel);color:var(--text)} +:root[data-theme=paper] .response-editor-head h2,:root[data-theme=paper] .response-editor-input,:root[data-theme=paper] .response-original{background:var(--control);color:var(--text)} + +/* A small disclosure stays available even when the data controls are collapsed. */ +.appearance-menu{margin-left:auto;flex:none;position:relative} +.appearance-toggle{list-style:none;display:flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap} +.appearance-toggle::-webkit-details-marker{display:none} +.appearance-toggle>span{color:var(--blue);font-size:17px;line-height:17px} +.appearance-menu[open]>.appearance-toggle{border-color:var(--blue);background:var(--selection)} +.appearance-panel{position:absolute;right:0;top:calc(100% + 10px);z-index:20;width:min(322px,calc(100vw - 20px));max-height:calc(100vh - 62px);overflow:auto;overscroll-behavior:contain;white-space:normal;padding:16px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 50px #0006;color:var(--text)} +.appearance-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:14px} +.appearance-heading strong{font-size:14px}.appearance-heading p{margin:4px 0 0;color:var(--muted);font-size:11px} +.appearance-panel fieldset{border:0;min-width:0;padding:0;margin:0 0 16px} +.appearance-panel legend{padding:0;margin-bottom:8px;color:var(--muted);font-size:11px} +.appearance-palettes{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px} +.appearance-palette{border:1px solid var(--line);border-radius:7px;padding:5px;background:var(--control);color:var(--text);cursor:pointer;font-size:11px;font-family:inherit} +.appearance-palette[aria-pressed=true]{border-color:var(--blue);box-shadow:0 0 0 1px var(--blue)} +.palette-preview{height:32px;border-radius:4px;margin-bottom:5px;display:flex;gap:3px;align-items:flex-end;padding:5px;background:var(--swatch-bg)} +.palette-preview i{flex:1;height:70%;background:var(--swatch-panel);border-radius:2px}.palette-preview i:nth-child(2){height:90%;background:var(--swatch-accent)} +.palette-graphite{--swatch-bg:#181a20;--swatch-panel:#454b62;--swatch-accent:#8b9dff} +.palette-ocean{--swatch-bg:#171e28;--swatch-panel:#365371;--swatch-accent:#4ea1ff} +.palette-forest{--swatch-bg:#14201d;--swatch-panel:#365f4e;--swatch-accent:#52c9a5} +.palette-paper{--swatch-bg:#eef1f7;--swatch-panel:#c1c9dc;--swatch-accent:#4f5bd5} +.appearance-accents{display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.accent-choice{height:24px;width:24px;padding:0;border:2px solid transparent;border-radius:50%;background:var(--swatch-accent);cursor:pointer;box-shadow:inset 0 0 0 1px #0002} +.accent-choice[aria-pressed=true]{outline:2px solid var(--blue);outline-offset:2px;border-color:var(--panel)} +.accent-violet{--swatch-accent:#8b9dff}.accent-blue{--swatch-accent:#4ea1ff}.accent-teal{--swatch-accent:#52c9a5}.accent-amber{--swatch-accent:#edb35c}.accent-rose{--swatch-accent:#ed87a8} +.appearance-custom{display:flex;align-items:center;gap:5px;margin-left:auto;color:var(--muted);font-size:11px} +.appearance-custom input{width:29px;height:26px;padding:1px;border:1px solid var(--line);border-radius:5px;background:var(--panel);cursor:pointer} +.appearance-color-value{display:block;margin-top:8px;color:var(--muted);font:10px/1.4 Consolas,monospace} +.appearance-options{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px} +.appearance-options>label{display:flex;flex-direction:column;gap:6px;color:var(--muted);font-size:11px} +.appearance-options select{width:100%;padding:6px;border-radius:5px;font:inherit;font-size:12px} +.appearance-check{display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:15px}.appearance-check input{accent-color:var(--blue)} +.appearance-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:12px;border-top:1px solid var(--line)} +.appearance-footer>span{color:var(--muted);font-size:10px;max-width:170px} +.appearance-footer .btn{white-space:nowrap;font-size:11px;padding:4px 6px} +.appearance-menu :is(button,summary,input,select):focus-visible{outline:2px solid var(--blue);outline-offset:3px} +@media(max-width:420px){.appearance-panel{position:fixed;top:50px;right:10px}.appearance-toggle{padding:5px 7px}} +@media(forced-colors:active){.appearance-palette[aria-pressed=true],.accent-choice[aria-pressed=true]{outline:2px solid Highlight}.palette-preview{border:1px solid CanvasText}.appearance-panel{border:1px solid CanvasText}} + +:root[data-striped=false] .table-wrap tbody tr:nth-child(even):hover td{background:var(--hover)} +.table-wrap td.bool-true .cell-text{color:var(--green)} +.table-wrap td.bool-false .cell-text{color:var(--boolean)} diff --git a/table-ui.js b/table-ui.js index 1f2cd7c..87eb64f 100644 --- a/table-ui.js +++ b/table-ui.js @@ -1,10 +1,11 @@ // Content previews are capped; full field names always define the minimum width. let tableMeasureContext; function measureTableText(value,header=false){ + const size=typeof Appearance!=='undefined'?Appearance.fontSize:13; const text=header?String(value??''):String(value??'').slice(0,240); if(tableMeasureContext===undefined){try{tableMeasureContext=document.createElement('canvas').getContext?.('2d')||null;}catch{tableMeasureContext=null;}} - if(tableMeasureContext){tableMeasureContext.font=header?'650 13px "Segoe UI","Microsoft YaHei",sans-serif':'13px Consolas,Monaco,monospace';return tableMeasureContext.measureText(text).width;} - return [...text].reduce((width,c)=>width+(c.codePointAt(0)>255?13:7.8),0); + if(tableMeasureContext){tableMeasureContext.font=header?`650 ${size}px "Segoe UI","Microsoft YaHei",sans-serif`:`${size}px Consolas,Monaco,monospace`;return tableMeasureContext.measureText(text).width;} + return [...text].reduce((width,c)=>width+(c.codePointAt(0)>255?size:size*.6),0); } function tableHeaderMinWidth(col){ // Padding, sort arrow, gap and resize handle must not cover the field name. @@ -44,7 +45,7 @@ function createTableHeader(col,rows,width){ // Keep widths stable across paging/sorting, with a bounded cache per response and array. const tableWidthStates=new Map(); let currentTableWidths=new Map(),currentTableColumns=[],currentWidthKey=null; -function tableWidthKey(){return selected?.rid && tableState.path?JSON.stringify([selected.rid,tableState.path]):null;} +function tableWidthKey(){return selected?.rid && tableState.path?JSON.stringify([selected.rid,tableState.path,typeof Appearance!=='undefined'?Appearance.fontSize:13]):null;} function widthState(){ const key=tableWidthKey();if(!key)return {auto:new Map(),manual:new Map()}; if(!tableWidthStates.has(key)){tableWidthStates.set(key,{auto:new Map(),manual:new Map()});if(tableWidthStates.size>32)tableWidthStates.delete(tableWidthStates.keys().next().value);} @@ -81,3 +82,18 @@ function bindColumnResize(th,col){ } async function refitTableColumns(){const state=widthState();state.manual.clear();state.auto.clear();return renderTable();} $('#refitColumns').onclick=safe(refitTableColumns); + +// A font change only resizes rendered columns; response parsing and DOM rows stay intact. +document.addEventListener('appearancechange',()=>{ + if(!currentTableColumns.length || !tableState.rows.length || !tableWidthKey())return; + const widths=resolvedTableWidths(currentTableColumns,tableState.rows); + for(const [index,col] of currentTableColumns.entries()){ + const width=widths.get(col),column=$('#tableColumns').children[index],header=$('#dataTable thead').children[0]?.children[index]; + if(column)column.style.width=width+'px'; + if(header){ + header.style.width=width+'px';const grip=header.querySelector('.column-resizer'); + if(grip){grip.setAttribute('aria-valuenow',String(width));grip.setAttribute('aria-valuemin',String(tableHeaderMinWidth(col)));grip.setAttribute('aria-valuemax',String(Math.max(960,tableHeaderMinWidth(col))));} + } + } + $('#dataTable').style.width=[...widths.values()].reduce((sum,width)=>sum+width,0)+'px'; +}); diff --git a/tests/appearance.cjs b/tests/appearance.cjs new file mode 100644 index 0000000..ba25d8c --- /dev/null +++ b/tests/appearance.cjs @@ -0,0 +1,48 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'); +const source=fs.readFileSync(path.join(__dirname,'../appearance.js'),'utf8'); +const key='api-table-viewer.appearance.v1'; +function panel(storage=new Map(),blocked=false){ + const nodes=new Map(),events=new Map(),changes=[]; + function element(data={}){return {dataset:data,value:'',checked:false,open:false,textContent:'',attributes:{},setAttribute(k,v){this.attributes[k]=v;},focus(){this.focused=true;},contains(target){return target===this;}};} + function get(id){if(!nodes.has(id))nodes.set(id,element());return nodes.get(id);} + const palettes=['graphite','ocean','forest','paper'].map(themeChoice=>element({themeChoice})),accents=['#8b9dff','#4ea1ff','#52c9a5','#edb35c','#ed87a8'].map(accentChoice=>element({accentChoice})); + get('appearanceMenu').querySelectorAll=s=>s==='[data-theme-choice]'?palettes:accents; + const root={dataset:{},style:{values:{},setProperty(k,v){this.values[k]=v;}}}; + const context=vm.createContext({localStorage:{getItem:k=>{if(blocked)throw Error('blocked');return storage.get(k)??null;},setItem:(k,v)=>{if(blocked)throw Error('blocked');storage.set(k,v);}}, + document:{documentElement:root,readyState:'complete',getElementById:get,addEventListener:(name,fn)=>events.set(name,fn),dispatchEvent:e=>changes.push(e.type)},CustomEvent:class{constructor(type){this.type=type;}}}); + vm.runInContext(source,context);return {get,root,palettes,accents,changes,events,storage,context}; +} +test('appearance choices survive reopening, preserve unrelated preferences and apply immediately',()=>{ + const p=panel(new Map([['layout','keep']]));p.palettes[2].onclick();p.accents[3].onclick();p.get('appearanceFont').value='16';p.get('appearanceFont').onchange();p.get('appearanceDensity').value='compact';p.get('appearanceDensity').onchange();p.get('appearanceStriped').checked=false;p.get('appearanceStriped').onchange(); + assert.equal(p.root.dataset.theme,'forest');assert.equal(p.root.dataset.density,'compact');assert.equal(p.root.dataset.striped,'false');assert.equal(p.root.style.values['--data-font-size'],'16px');assert.equal(p.changes.length,1); + const reopened=panel(p.storage);assert.equal(reopened.get('appearanceFont').value,'16');assert.equal(reopened.get('appearanceAccent').value,'#edb35c');assert.equal(reopened.palettes[2].attributes['aria-pressed'],'true');assert.equal(p.storage.get('layout'),'keep'); + reopened.get('appearanceReset').onclick();const reset=JSON.parse(p.storage.get(key));assert.equal(reset.theme,'graphite');assert.equal(reset.fontSize,13);assert.equal(reset.striped,true);assert.equal(p.storage.get('layout'),'keep'); +}); +test('malformed, outdated and blocked storage leave the panel usable',()=>{ + for(const raw of ['bad json','null','{"theme":"toString","accent":"url(bad)","fontSize":999,"striped":null}']){ + const p=panel(new Map([[key,raw]]));assert.equal(p.root.dataset.theme,'graphite');assert.equal(p.get('appearanceFont').value,'13');assert.equal(p.get('appearanceAccent').value,'#8b9dff'); + } + const p=panel(new Map(),true);p.palettes[3].onclick();assert.equal(p.root.dataset.theme,'paper');assert(p.get('appearanceStatus').textContent.includes('无法保存')); +}); +test('extreme custom accents remain legible against light and dark backgrounds',()=>{ + const p=panel();p.get('appearanceAccent').value='#000000';p.get('appearanceAccent').oninput();assert.notEqual(p.root.style.values['--blue'],'#000000'); + p.palettes[3].onclick();p.get('appearanceAccent').value='#ffffff';p.get('appearanceAccent').oninput();assert.notEqual(p.root.style.values['--blue'],'#ffffff');assert.equal(p.get('appearanceAccent').value,'#ffffff'); +}); +test('Escape and an outside click close appearance settings; Escape restores focus',()=>{ + const p=panel(),menu=p.get('appearanceMenu');menu.open=true;p.events.get('pointerdown')({target:menu});assert(menu.open);p.events.get('pointerdown')({target:{}});assert(!menu.open); + menu.open=true;let prevented=false;p.events.get('keydown')({key:'Escape',preventDefault(){prevented=true;},stopPropagation(){}});assert(!menu.open);assert(prevented);assert(p.get('appearanceToggle').focused); +}); + +test('larger data fonts expand header widths without replacing rows or parsing responses again',()=>{ + let resized;const nodes=new Map(); + const get=id=>{if(!nodes.has(id))nodes.set(id,{style:{},children:[],querySelector:()=>null});return nodes.get(id);}; + const header={style:{},querySelector:()=>null};get('#tableColumns').children=[{style:{}}];get('#dataTable thead').children=[{children:[header]}]; + const column='aLongHeaderThatMustStayOnOneLine'; + const rows=[{[column]:{text:'short',type:'string'}}],state={path:['records'],rows}; + const context=vm.createContext({Appearance:{fontSize:13},selected:{rid:'r'},tableState:state,$:get,safe:fn=>fn,document:{createElement:()=>({getContext:()=>null}),addEventListener:(_,fn)=>{resized=fn;}}}); + vm.runInContext(fs.readFileSync(path.join(__dirname,'../table-ui.js'),'utf8'),context);context.column=column; + const before=vm.runInContext('resolvedTableWidths([column],tableState.rows).get(column)',context); + context.Appearance.fontSize=16;resized(); + assert(parseFloat(header.style.width)>before);assert.equal(context.tableState.rows,rows);assert.equal(get('#tableColumns').children[0].style.width,header.style.width); +}); diff --git a/tests/preview/check.cjs b/tests/preview/check.cjs index 24f31f0..2b50602 100644 --- a/tests/preview/check.cjs +++ b/tests/preview/check.cjs @@ -30,7 +30,7 @@ test('preview server serves panel/runtime assets and rejects unrelated files', a assert.equal(response.status, 200); const html = await response.text(); assert(html.includes('/preview/env.js')); assert(html.includes('id="responseEditor"')); - for (const route of ['/preview/env.js', '/preview/boot.js', '/worker.js', '/response-editor.js', '/style.css']) assert.equal((await fetch(origin + route)).status, 200); + for (const route of ['/preview/env.js', '/preview/boot.js', '/worker.js', '/appearance.js', '/response-editor.js', '/style.css']) assert.equal((await fetch(origin + route)).status, 200); assert.equal((await fetch(origin + '/package.json')).status, 404); assert.equal((await fetch(origin + '/', {method: 'POST'})).status, 405); } finally {await new Promise(resolve => server.close(resolve));} diff --git a/tests/preview/server.cjs b/tests/preview/server.cjs index 4b4da14..33e1ea5 100644 --- a/tests/preview/server.cjs +++ b/tests/preview/server.cjs @@ -4,7 +4,7 @@ const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const root = path.resolve(__dirname, '../..'); -const runtime = new Set(['json-codec.js', 'panel.js', 'table-ui.js', 'viewer.js', 'viewer-expanded.js', 'developer.js', 'layout.js', 'shortcuts.js', 'response-editor.js', 'mock-page.js', 'response-mock.js', 'worker.js', 'style.css', 'field-patch-core.js', 'field-mock.js']); +const runtime = new Set(['appearance.js', 'json-codec.js', 'panel.js', 'table-ui.js', 'viewer.js', 'viewer-expanded.js', 'developer.js', 'layout.js', 'shortcuts.js', 'response-editor.js', 'mock-page.js', 'response-mock.js', 'worker.js', 'style.css', 'field-patch-core.js', 'field-mock.js']); function createPreviewServer() { return http.createServer((req, res) => { const pathname = new URL(req.url, 'http://localhost').pathname;