Files
Clipboard/renderer/settings.js
2026-09-20 23:35:03 +08:00

258 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ---------- 标签页 ----------
// 放在 IPC 解构之前:即使 preload 没注入,标签页也要能切换。
(() => {
const bar = document.getElementById('tab-bar');
if (!bar) return;
const tabs = Array.from(bar.querySelectorAll('[role="tab"]'));
if (!tabs.length) return;
function select(tab) {
for (const t of tabs) {
const on = t === tab;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
const panel = document.getElementById(t.getAttribute('aria-controls'));
if (panel) panel.hidden = !on;
}
}
for (const t of tabs) {
t.addEventListener('click', () => { select(t); t.focus(); });
}
bar.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
// 按「当前选中」而不是 document.activeElement 找位置:
// 窗口未获焦点时 activeElement 不可靠,测试里也无法聚焦隐藏窗口。
const i = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
const step = e.key === 'ArrowRight' ? 1 : tabs.length - 1;
const next = tabs[((i < 0 ? 0 : i) + step) % tabs.length];
select(next);
next.focus();
});
select(tabs[0]);
})();
const { get, set, clearHistory, openDir, chooseDir, openExternal } = window.settingsApi;
const $auto=document.getElementById('opt-autolaunch'),$hotkey=document.getElementById('opt-hotkey'),$max=document.getElementById('opt-maxitems'),$display=document.getElementById('opt-displaylimit'),$poll=document.getElementById('opt-pollms'),$dir=document.getElementById('opt-datadir'),$change=document.getElementById('btn-change-dir'),$open=document.getElementById('btn-open-dir'),$clear=document.getElementById('btn-clear'),$close=document.getElementById('close-btn'),$status=document.getElementById('status-left');
// 连发两条提示时,第一条的 2 秒定时器会把第二条提前刷回「就绪」——
// 每次 flash 先清掉上一个定时器,重置时间以最后一条消息为准。
let flashTimer=null;
function flash(msg){$status.textContent=msg;clearTimeout(flashTimer);flashTimer=setTimeout(()=>$status.textContent='就绪',2000)}
// settings:set 在主进程先改运行时状态、后写盘;写盘失败(磁盘满/只读/杀软锁文件)
// 会带 saved:false 回来 —— 本次会话已生效但重启还原,必须明确提示,
// 不能让用户以为设置已经保存。
function flashIfNotSaved(res){
if(res&&res.saved===false){flash('⚠ 设置未保存到磁盘,重启后还原');return true}
return false
}
async function refresh(){
const c=await get();
$auto.checked=!!c.autoLaunch;$hotkey.value=c.hotkey||'';$max.value=String(c.maxItems);$display.value=String(c.displayLimit);$poll.value=String(c.pollMs);
// 显示实际在用的目录(dataDirEffective,含 session 兜底);与配置目录不
// 一致时(配置位置暂不可用)两个都给出来,并解释当前数据在哪。
if(c.dataDirEffective&&c.dataDirEffective!==c.dataDir){
$dir.textContent=c.dataDirEffective;
$dir.title='配置位置 '+c.dataDir+' 当前不可用,本会话暂用此目录(重启后若恢复会自动切回)';
}else{
$dir.textContent=c.dataDir;
$dir.title='';
}
}
$auto.addEventListener('change',async()=>{const res=await set('autoLaunch',$auto.checked);if(!flashIfNotSaved(res))flash('已'+($auto.checked?'开启':'关闭')+'开机自启动')});
$max.addEventListener('change',async()=>{const res=await set('maxItems',parseInt($max.value,10));if(!flashIfNotSaved(res))flash('最大条数已设为 '+$max.value)});
$display.addEventListener('change',async()=>{const res=await set('displayLimit',parseInt($display.value,10));if(!flashIfNotSaved(res))flash('界面显示条数已设为 '+$display.value)});
$poll.addEventListener('change',async()=>{const res=await set('pollMs',parseInt($poll.value,10));if(!flashIfNotSaved(res))flash('轮询间隔已设为 '+$poll.value+' ms')});
$hotkey.addEventListener('change',async()=>{
const res=await set('hotkey',$hotkey.value);
if(flashIfNotSaved(res))return;
if($hotkey.value&&res&&res.hotkeyRegistered===false){
// 主进程 register 返回 false:组合键被别的程序占用。配置照存,
// 但必须告诉用户,否则他会以为快捷键坏了。
flash('快捷键可能被其他程序占用,未生效');
}else{
flash($hotkey.value?'唤起快捷键已设为 '+$hotkey.value:'已禁用唤起快捷键');
}
});
$open.addEventListener('click',()=>openDir());
$clear.addEventListener('click',async()=>{if(!confirm('确定清空所有非置顶历史记录?此操作不可撤销。'))return;flash('已清除 '+await clearHistory()+' 条')});
$change.addEventListener('click',async()=>{if(typeof chooseDir!=='function')return;const res=await chooseDir();if(res&&res.ok===false&&res.message)flash(res.message)});
$close.addEventListener('click',()=>window.close());document.addEventListener('keydown',e=>{if(e.key==='Escape')window.close()});refresh();
// ---------- 应用图标(标题栏左上角)----------
// 一次性从主进程拿 PNG data URL 喂给 <img>。与主窗口共用同一个 IPC,
// 主进程缓存命中后零成本;图标缺失静默失败,不影响设置面板功能。
window.api.getAppIcon().then((dataUrl) => {
if (!dataUrl) return;
const el = document.getElementById('app-icon');
if (el) el.src = dataUrl;
}).catch(() => { /* 图标缺失不影响功能 */ });
// ---------- 外链(关于页)----------
// 窗口不能直接导航到外部 URL(会把设置页整个顶掉),点击统一拦下来
// 走主进程 shell.openExternal 交给系统浏览器;scheme 白名单在主进程卡。
for (const a of document.querySelectorAll('a.ext-link')) {
a.addEventListener('click', (e) => { e.preventDefault(); if (typeof openExternal === 'function') openExternal(a.href); });
}
// ---------- 主题 ----------
// 不要用 `const themeApi`:contextBridge 暴露的 window.themeApi 是不可配置属性,
// 顶层同名 const 会让整个脚本在实例化阶段抛 SyntaxError(一行都不执行)。
const themeIpc = window.themeApi;
const { set: themeSet, setAccent, setFollowSystem, onSystemChanged } = themeIpc;
const THEMES = [
{ id: 'linear', name: 'Linear Dark', desc: '极简暗色专业', bg: '#0E0E12', text: '#E6E6E9', accent: '#5E6AD2' },
{ id: 'midnight', name: 'Midnight', desc: '极致暗色', bg: '#08090B', text: '#E8E8EA', accent: '#7C3AED' },
{ id: 'carbon', name: 'Carbon', desc: 'VS Code 灰', bg: '#1A1D23', text: '#D4D4D4', accent: '#3B82F6' },
{ id: 'solarized', name: 'Solarized', desc: '经典 Sola', bg: '#002B36', text: '#93A1A1', accent: '#268BD2' },
{ id: 'github', name: 'GitHub Dark', desc: 'GitHub 风', bg: '#0D1117', text: '#C9D1D9', accent: '#8957E5' },
{ id: 'light', name: 'Light', desc: '白天模式', bg: '#FAFAFA', text: '#111827', accent: '#5E6AD2' },
];
const ACCENTS = [
{ id: 'purple', color: '#5E6AD2' },
{ id: 'blue', color: '#3B82F6' },
{ id: 'green', color: '#10B981' },
{ id: 'orange', color: '#F59E0B' },
{ id: 'red', color: '#EF4444' },
];
function renderThemes(currentTheme) {
const grid = document.getElementById('theme-grid');
if (!grid) return;
grid.innerHTML = '';
for (const t of THEMES) {
const card = document.createElement('div');
card.className = 'theme-card' + (t.id === currentTheme ? ' active' : '');
card.dataset.theme = t.id;
// div + click 对键盘不可达:补 button 语义。Enter/Space 触发与鼠标一致的 click。
card.tabIndex = 0;
card.setAttribute('role', 'button');
card.setAttribute('aria-pressed', String(t.id === currentTheme));
card.innerHTML = `
<div class="theme-preview" style="background:${t.bg};color:${t.text}">
<span class="theme-preview-text">Aa</span>
<span class="theme-preview-accent" style="background:${t.accent}"></span>
</div>
<div class="theme-info">
<div class="theme-name">${t.name}</div>
<div class="theme-desc">${t.desc}</div>
</div>
`;
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
});
card.addEventListener('click', async () => {
// renderThemes 会销毁全部旧卡片:焦点卡没了焦点就掉到 body,键盘用户
// 迷失位置。重建后把焦点还到同名新卡(Chromium 会沿用 focus-visible 状态,
// 鼠标点击不会因此多出焦点框)。
const hadFocus = document.activeElement === card;
const res = await themeSet(t.id);
if (res) {
const eff = res.effectiveTheme || res.theme;
document.documentElement.setAttribute('data-theme', eff);
document.documentElement.setAttribute('data-accent', res.accent);
try {
// rawTheme 在 themeSet 路径上 = eff(顺手把 followSystem 关了),
// 但旧主进程没发 rawTheme 字段,这里用 eff 兜底。
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: res.rawTheme || eff,
effectiveTheme: eff,
accent: res.accent,
followSystem: res.followSystem,
}));
} catch (_) {}
document.getElementById('opt-follow-system').checked = false;
renderThemes(eff);
if (hadFocus) {
const next = grid.querySelector(`[data-theme="${t.id}"]`);
if (next) next.focus();
}
flashIfNotSaved(res);
}
});
grid.appendChild(card);
}
}
function renderAccents(currentAccent) {
const grid = document.getElementById('accent-grid');
if (!grid) return;
grid.innerHTML = '';
for (const a of ACCENTS) {
const sw = document.createElement('button');
sw.className = 'accent-swatch' + (a.id === currentAccent ? ' active' : '');
sw.dataset.accent = a.id;
sw.style.background = a.color;
sw.title = a.id;
sw.addEventListener('click', async () => {
const res = await setAccent(a.id);
if (res) {
document.documentElement.setAttribute('data-accent', res.accent);
try {
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: res.rawTheme || res.theme,
effectiveTheme: res.effectiveTheme || res.theme,
accent: res.accent,
followSystem: res.followSystem,
}));
} catch (_) {}
renderAccents(res.accent);
flashIfNotSaved(res);
}
});
grid.appendChild(sw);
}
}
const $follow = document.getElementById('opt-follow-system');
if ($follow) {
$follow.addEventListener('change', async () => {
const res = await setFollowSystem($follow.checked);
if (res) {
try {
localStorage.setItem('clipboard-theme', JSON.stringify({
theme: res.rawTheme || res.theme,
effectiveTheme: res.effectiveTheme || res.theme,
accent: res.accent,
followSystem: res.followSystem,
}));
} catch (_) {}
if (!flashIfNotSaved(res)) flash($follow.checked ? '已开启跟随系统' : '已关闭跟随系统');
}
});
}
onSystemChanged((payload) => {
// 同 renderer:只更新 DOM,不写 localStorage(payload 里没有 rawTheme)。
const eff = payload.effectiveTheme || payload.theme;
document.documentElement.setAttribute('data-theme', eff);
document.documentElement.setAttribute('data-accent', payload.accent);
renderThemes(eff);
});
async function initTheme() {
try {
// 先订阅再拉当前值,否则跟随系统改主题时这个窗口收不到广播
try { await themeIpc.subscribe(); }
catch (e) { console.error('[theme] subscribe failed:', e.message); }
const cfg = await themeIpc.get();
const eff = cfg.effectiveTheme || cfg.theme;
document.documentElement.setAttribute('data-theme', eff);
document.documentElement.setAttribute('data-accent', cfg.accent);
if ($follow) $follow.checked = cfg.followSystem;
renderThemes(eff);
renderAccents(cfg.accent);
} catch (e) {
console.error('[theme] initTheme failed:', e.message);
}
}
initTheme();