228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
'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');
|
||
function flash(msg){$status.textContent=msg;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);$dir.textContent=c.dataDir}
|
||
$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;
|
||
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('click', async () => {
|
||
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);
|
||
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();
|