Repository navigation
Expand file tree
/
Copy pathrenderer.js
More file actions
317 lines (294 loc) · 11.7 KB
/
Copy pathrenderer.js
File metadata and controls
317 lines (294 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
'use strict';
const els = {
list: document.getElementById('accountList'),
empty: document.getElementById('empty'),
back: document.getElementById('navBack'),
fwd: document.getElementById('navFwd'),
reload: document.getElementById('navReload'),
home: document.getElementById('navHome'),
url: document.getElementById('urlInput'),
badge: document.getElementById('activeBadge'),
addBtn: document.getElementById('btnAdd'),
footer: document.getElementById('sbFooter'),
tabbar: document.getElementById('tabbar'),
tablist: document.getElementById('tablist'),
tabNew: document.getElementById('tabNew'),
banner: document.getElementById('banner'),
};
let state = {
accounts: [],
activeId: null,
activeTabId: null,
url: '',
title: '',
canGoBack: false,
canGoForward: false,
isLoading: false,
pending: null,
sidebarCollapsed: false,
crashRecovery: false,
};
let editing = false; // 添加/重命名进行中时跳过列表重渲染
window.api.onState((s) => {
state = s;
render();
});
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
}
function activeAccount() {
return state.accounts.find((a) => a.id === state.activeId) || null;
}
async function selectAccount(id) {
await window.api.switch(id);
}
function render() {
renderToolbar();
renderTabbar();
renderBanner();
document.body.classList.toggle('collapsed', !!state.sidebarCollapsed);
if (!editing) renderList();
els.empty.style.display = state.accounts.length === 0 ? 'flex' : 'none';
}
let tabSig = '';
function renderTabbar() {
const acc = activeAccount();
els.tabbar.style.display = acc ? 'flex' : 'none';
if (!acc) {
tabSig = '';
return;
}
// 签名未变则不重建 DOM:页面加载过程中状态推送很频繁,
// 重建会让 mousedown→mouseup 之间的点击丢失
const sig = JSON.stringify([acc.id, state.activeTabId, acc.tabs.map((t) => [t.id, t.title, t.url])]);
if (sig === tabSig) return;
tabSig = sig;
els.tablist.innerHTML = '';
let activeEl = null;
acc.tabs.forEach((tab) => {
const el = document.createElement('div');
const isActive = tab.id === state.activeTabId;
el.className = 'tab' + (isActive ? ' active' : '');
const title = tab.title || '新标签页';
el.innerHTML = `
<span class="tab-title" title="${escapeHtml(tab.url || '')}">${escapeHtml(title)}</span>
<span class="tab-close" title="关闭 (⌘W)">✕</span>`;
// 与 Chrome 一致:mousedown 即切换;关闭按钮用 click
el.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.classList.contains('tab-close')) return;
window.api.switchTab(tab.id);
});
el.querySelector('.tab-close').addEventListener('click', (e) => {
e.stopPropagation();
window.api.closeTab(tab.id);
});
el.addEventListener('auxclick', (e) => {
if (e.button === 1) window.api.closeTab(tab.id);
});
els.tablist.appendChild(el);
if (isActive) activeEl = el;
});
// tab 很多时保证活跃 tab 可见
if (activeEl) activeEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
let bannerSig = '';
function renderBanner() {
const p = state.pending;
// 优先级:保存密码提示 > 崩溃恢复横幅(两者不会同时出现:横幅只在未恢复会话时显示)
const recovery = !p && state.crashRecovery;
const sig = JSON.stringify(p) + (recovery ? '|R' : '');
if (sig === bannerSig) return;
bannerSig = sig;
if (!p && !recovery) {
els.banner.style.display = 'none';
els.banner.innerHTML = '';
return;
}
if (recovery) {
els.banner.style.display = 'flex';
els.banner.classList.remove('update');
els.banner.innerHTML = `
<span class="bn-icon">⚠️</span>
<span class="bn-text">
<span class="bn-title">CloudDock 上次未正常退出</span>
<span class="bn-sub">是否恢复上次打开的账号与标签页?(登录态不受影响)</span>
</span>
<button class="btn primary" id="bnRestore">恢复上次会话</button>
<button class="btn ghost" id="bnFresh">重新开始</button>`;
els.banner.querySelector('#bnRestore').addEventListener('click', () => window.api.resolveCrash(true));
els.banner.querySelector('#bnFresh').addEventListener('click', () => window.api.resolveCrash(false));
return;
}
const acc = state.accounts.find((a) => a.id === p.accountId);
const name = acc ? acc.name : '该账号';
els.banner.style.display = 'flex';
els.banner.classList.toggle('update', !!p.update);
els.banner.innerHTML = `
<span class="bn-icon">🔑</span>
<span class="bn-text">
<span class="bn-title">${p.update ? '检测到新密码,是否更新已保存的密码?' : '是否保存此登录的密码?'}</span>
<span class="bn-sub">${escapeHtml(p.host)} · ${escapeHtml(p.label)} · 账号「${escapeHtml(name)}」· 加密存于本机</span>
</span>
<button class="btn primary" id="bnSave">${p.update ? '更新密码' : '保存密码'}</button>
<button class="btn ghost" id="bnLater">暂不</button>
<button class="btn link" id="bnNever">从不保存此网站</button>`;
els.banner.querySelector('#bnSave').addEventListener('click', () => window.api.credSave());
els.banner.querySelector('#bnLater').addEventListener('click', () => window.api.credDismiss(false));
els.banner.querySelector('#bnNever').addEventListener('click', () => window.api.credDismiss(true));
}
function renderToolbar() {
els.back.disabled = !state.canGoBack;
els.fwd.disabled = !state.canGoForward;
if (document.activeElement !== els.url) {
els.url.value = state.url;
}
const acc = activeAccount();
if (acc) {
const dot = `<span class="dot" style="background:${acc.color}"></span>`;
const spin = state.isLoading ? '<span class="spin">⟳</span>' : '';
els.badge.innerHTML = `${dot}<span>${escapeHtml(acc.name)}</span>${spin}`;
} else {
els.badge.innerHTML = '<span class="badge-none">未选择账号</span>';
}
}
let listSig = '';
function renderList() {
// 签名未变则不重建,避免页面加载期间的频繁状态推送吞掉点击
const sig = JSON.stringify([state.activeId, state.accounts.map((a) => [a.id, a.name, a.note, a.color])]);
if (sig === listSig && els.list.childElementCount > 0) return;
listSig = sig;
els.list.innerHTML = '';
state.accounts.forEach((acc, i) => {
const item = document.createElement('div');
item.className = 'acc-item' + (acc.id === state.activeId ? ' active' : '');
const hint = i < 9 ? `<span class="acc-hint">⌘${i + 1}</span>` : '';
item.innerHTML = `
<span class="acc-avatar" title="${escapeHtml(acc.name)}" style="background:${acc.color}">${escapeHtml(acc.name.slice(0, 1).toUpperCase())}</span>
<span class="acc-label">${escapeHtml(acc.name)}</span>
<span class="acc-info">
<span class="acc-name" title="双击重命名">${escapeHtml(acc.name)}</span>
${acc.note ? `<span class="acc-note">${escapeHtml(acc.note)}</span>` : ''}
</span>
${hint}
<span class="acc-more" title="更多:重命名 / 清除 Cookie">•••</span>
<span class="acc-del" title="删除账号">✕</span>`;
item.addEventListener('click', (e) => {
if (e.target.classList.contains('acc-del') || e.target.classList.contains('acc-more')) return;
if (editing) return; // 正在改名时点击不要切换账号(切换会把焦点抢给网页)
selectAccount(acc.id);
});
item.querySelector('.acc-more').addEventListener('click', (e) => {
e.stopPropagation();
window.api.accountMenu(acc.id);
});
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
window.api.accountMenu(acc.id);
});
item.querySelector('.acc-del').addEventListener('click', async (e) => {
e.stopPropagation();
await window.api.remove(acc.id);
});
item.querySelector('.acc-name').addEventListener('dblclick', () => startRename(item, acc));
els.list.appendChild(item);
});
}
async function startRename(item, acc) {
if (editing) return;
const nameEl = item.querySelector('.acc-name');
if (!nameEl) return;
editing = true;
// 窄栏下名称是隐藏的,改名时临时展开显示输入框
const input = document.createElement('input');
input.className = 'rename-input';
input.value = acc.name;
input.maxLength = 30;
input.setAttribute('spellcheck', 'false');
nameEl.replaceWith(input);
item.classList.add('renaming');
let closed = false;
let composing = false; // 输入法组字中:Enter/Esc/失焦都不能当作确认
const done = async (save) => {
if (closed) return;
closed = true;
editing = false;
const v = input.value.trim();
item.classList.remove('renaming');
if (input.isConnected) input.replaceWith(nameEl);
if (save && v && v !== acc.name) await window.api.rename(acc.id, v);
listSig = ''; // 强制刷新列表
render();
};
input.addEventListener('compositionstart', () => { composing = true; });
input.addEventListener('compositionend', () => { composing = false; });
input.addEventListener('keydown', (e) => {
e.stopPropagation();
// keyCode 229 = 输入法处理中的按键(部分环境 isComposing 在 compositionend 之后的那一下已为 false)
if (composing || e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') done(true);
if (e.key === 'Escape') done(false);
});
input.addEventListener('blur', () => {
if (composing) return;
done(true);
});
// 先把键盘焦点从网页视图拿回 UI 页面,再聚焦输入框,输入法才会接管
await window.api.focusUi();
if (closed) return;
input.focus();
input.select();
}
/** 添加账号:无需输入,新建独立会话并打开起始页;登录任意网站后自动按用户名/站点命名 */
async function addAccount() {
await window.api.add();
}
/* ---------- 事件绑定 ---------- */
els.addBtn.addEventListener('click', addAccount);
document.getElementById('btnAddEmpty').addEventListener('click', addAccount);
els.tabNew.addEventListener('click', () => window.api.newTab());
els.back.addEventListener('click', () => window.api.nav('back'));
els.fwd.addEventListener('click', () => window.api.nav('forward'));
els.reload.addEventListener('click', () => window.api.nav('reload'));
els.home.addEventListener('click', () => window.api.nav('home'));
document.getElementById('sbToggle').addEventListener('click', () => window.api.toggleSidebar());
// 主菜单 / 右键菜单的「重命名」
window.api.onRenameStart((id) => {
const acc = state.accounts.find((a) => a.id === id);
if (!acc || editing) return;
const idx = state.accounts.indexOf(acc);
const item = els.list.children[idx];
if (!item) return;
// 窄栏下名称不可见,改名前临时展开侧边栏
const go = () => startRename(item, acc);
if (state.sidebarCollapsed) {
window.api.toggleSidebar(false).then(() => setTimeout(go, 80));
} else {
go();
}
});
els.url.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.isComposing || e.keyCode === 229) return; // 输入法选词时的 Enter 不是「前往」
if (e.key === 'Enter') {
window.api.openUrl(els.url.value.trim());
els.url.blur();
}
});
window.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && /^Digit[1-9]$/.test(e.code)) {
const acc = state.accounts[Number(e.code.slice(5)) - 1];
if (acc) {
e.preventDefault();
selectAccount(acc.id);
}
}
});
/* ---------- 初始化 ---------- */
// 上次使用的账号由主进程恢复(ui.json),渲染端只负责首屏渲染
(async function init() {
const list = await window.api.list();
if (state.accounts.length === 0) state.accounts = list;
render();
})();