- bindSearchKeys:Enter 展开搜索并聚焦(搜索框已展开/焦点在可编辑元素时放行), Space 激活焦点所在的 a[href]/[role=button](preventDefault 防滚动) - bindSearchKeys/enablePeekGuard/mountViewSwitch 加幂等守卫: DOM 抖动场景下 HEAD 版泄漏 914 个 keydown 监听器,修复后 0 - MutationObserver 自触发加 selfWrite 计数 + 100ms 节流 - showWorkspaceBlockedModal 的 Escape 从 document 改挂 overlay - 验收脚本:verify-search-key.js(真实按键,页面内计数断言)、 verify-swipe.js(CDP 注入真实触摸,断言分组真的切换) - README/AGENTS:快捷键表、手势验收方法、改样式前先确认设计基准(§9)
1046 lines
44 KiB
JavaScript
1046 lines
44 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
// 浏览器插件在 document_start 阶段写入这个属性(见 extension/*/detect.js)。
|
||
const EXT_ATTR = 'data-navi-ext';
|
||
const INSTALL_SELECTOR = 'a[href*="/extension/install"]';
|
||
|
||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||
|
||
function svg(paths, opts = {}) {
|
||
const el = document.createElementNS(SVG_NS, 'svg');
|
||
el.setAttribute('viewBox', opts.viewBox || '0 0 24 24');
|
||
el.setAttribute('fill', 'none');
|
||
el.setAttribute('stroke', 'currentColor');
|
||
el.setAttribute('stroke-width', opts.width || '2.2');
|
||
el.setAttribute('stroke-linecap', 'round');
|
||
el.setAttribute('stroke-linejoin', 'round');
|
||
el.setAttribute('aria-hidden', 'true');
|
||
if (opts.size) {
|
||
el.setAttribute('width', opts.size);
|
||
el.setAttribute('height', opts.size);
|
||
}
|
||
paths.forEach((p) => {
|
||
const node = document.createElementNS(SVG_NS, p.tag);
|
||
Object.keys(p.attrs).forEach((k) => node.setAttribute(k, p.attrs[k]));
|
||
el.append(node);
|
||
});
|
||
return el;
|
||
}
|
||
|
||
// 收起态是放大镜,展开时通过 transform/opacity 平滑形变为向左箭头,
|
||
// 让图标动效与搜索框的宽度动画保持一致。
|
||
const magnifierIcon = () => svg([
|
||
{ tag: 'circle', attrs: { class: 'navi-magnifier-ring', cx: '11', cy: '11', r: '7.5' } },
|
||
{ tag: 'line', attrs: { class: 'navi-magnifier-handle', x1: '20.5', y1: '20.5', x2: '16.2', y2: '16.2' } },
|
||
{ tag: 'polyline', attrs: { class: 'navi-collapse-arrow', points: '14.5 6.5 9 12 14.5 17.5' } },
|
||
], { size: '20' });
|
||
|
||
const arrowIcon = () => svg([
|
||
{ tag: 'line', attrs: { x1: '5', y1: '12', x2: '19', y2: '12' } },
|
||
{ tag: 'polyline', attrs: { points: '13 6 19 12 13 18' } },
|
||
]);
|
||
|
||
const gearIcon = () => svg([
|
||
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '3.2' } },
|
||
{ tag: 'path', attrs: { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.7 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6h.09A1.7 1.7 0 0 0 10.6 3.04V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.03z' } },
|
||
], { size: '20', width: '1.9' });
|
||
|
||
function extensionInstalled() {
|
||
return document.documentElement.hasAttribute(EXT_ATTR);
|
||
}
|
||
|
||
// 已装插件的浏览器不需要安装入口:连外层网格单元一起隐藏,
|
||
// 否则 .item-wrapper 会留下一个空位。
|
||
function hideInstallEntry() {
|
||
if (!extensionInstalled()) return;
|
||
document.querySelectorAll(INSTALL_SELECTOR).forEach((link) => {
|
||
const cell = link.closest('.item-wrapper') || link.parentElement || link;
|
||
if (cell.style.display !== 'none') cell.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
// 底部搜索栏:默认收起成一个放大镜悬浮球(左下角),
|
||
// 点放大镜展开成输入框,再点输入框里的放大镜收起。
|
||
// 收起态下 input.tabIndex=-1,键盘无法聚焦,所以另配了全局快捷键(见 bindSearchKeys)。
|
||
let searchCtl = null;
|
||
|
||
function mountSearch() {
|
||
if (document.querySelector('.navi-search-bar')) return;
|
||
|
||
const form = document.createElement('form');
|
||
form.className = 'navi-search-bar';
|
||
form.dataset.state = 'collapsed';
|
||
form.method = 'GET';
|
||
form.action = '/search';
|
||
form.target = '_blank';
|
||
form.setAttribute('role', 'search');
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.type = 'button';
|
||
toggle.className = 'navi-search-toggle';
|
||
toggle.setAttribute('aria-label', '展开搜索');
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
toggle.append(magnifierIcon());
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'search';
|
||
input.name = 'q';
|
||
input.placeholder = '搜索…';
|
||
input.autocomplete = 'off';
|
||
input.tabIndex = -1;
|
||
input.setAttribute('aria-label', '搜索内容');
|
||
|
||
const submit = document.createElement('button');
|
||
submit.type = 'submit';
|
||
submit.className = 'navi-search-submit';
|
||
submit.setAttribute('aria-label', '开始搜索');
|
||
submit.append(arrowIcon());
|
||
|
||
const setState = (state) => {
|
||
const open = state === 'open';
|
||
form.dataset.state = open ? 'open' : 'collapsed';
|
||
toggle.setAttribute('aria-expanded', String(open));
|
||
toggle.setAttribute('aria-label', open ? '收起搜索' : '展开搜索');
|
||
input.tabIndex = open ? 0 : -1;
|
||
if (open) {
|
||
input.focus({ preventScroll: true });
|
||
} else {
|
||
input.value = '';
|
||
input.blur();
|
||
}
|
||
};
|
||
|
||
toggle.addEventListener('click', (evt) => {
|
||
evt.preventDefault();
|
||
setState(form.dataset.state === 'open' ? 'collapsed' : 'open');
|
||
});
|
||
|
||
// Esc 收起(不阻止其它默认行为)
|
||
form.addEventListener('keydown', (evt) => {
|
||
if (evt.key === 'Escape' && form.dataset.state === 'open') {
|
||
evt.stopPropagation();
|
||
setState('collapsed');
|
||
}
|
||
});
|
||
|
||
form.append(toggle, input, submit);
|
||
document.body.append(form);
|
||
|
||
// 把控制权交出去,供全局快捷键调用(Enter 唤起搜索)。
|
||
// 之前 setState 只是个局部函数,外部拿不到。
|
||
searchCtl = {
|
||
open: () => setState('open'),
|
||
close: () => setState('collapsed'),
|
||
isOpen: () => form.dataset.state === 'open',
|
||
};
|
||
}
|
||
|
||
// Dashy 页面没有管理入口:右下角补一个悬浮链接到 /admin/。
|
||
function mountAdminEntry() {
|
||
if (document.querySelector('.navi-admin-entry')) return;
|
||
const link = document.createElement('a');
|
||
link.className = 'navi-admin-entry';
|
||
link.href = '/admin/';
|
||
link.title = '导航管理';
|
||
link.setAttribute('aria-label', '导航管理');
|
||
link.append(gearIcon());
|
||
mountDock().append(link);
|
||
}
|
||
|
||
// 视图切换:Dashy 原生的切换按钮挤在侧栏底部、且是方形图标。
|
||
// 这里换成圆形悬浮按钮,紧挨管理入口左侧,点开是一组视图链接。
|
||
const VIEWS = [
|
||
{ path: '/', label: '首页' },
|
||
{ path: '/minimal', label: '极简' },
|
||
{ path: '/workspace', label: '工作空间' },
|
||
];
|
||
|
||
const viewIcon = () => svg([
|
||
{ tag: 'rect', attrs: { x: '3', y: '3.5', width: '18', height: '17', rx: '3.4' } },
|
||
{ tag: 'line', attrs: { x1: '9.4', y1: '3.5', x2: '9.4', y2: '20.5' } },
|
||
{ tag: 'line', attrs: { x1: '12.8', y1: '9.4', x2: '17.8', y2: '9.4' } },
|
||
{ tag: 'line', attrs: { x1: '12.8', y1: '14.2', x2: '17.8', y2: '14.2' } },
|
||
], { size: '20', width: '1.9' });
|
||
|
||
function currentView() {
|
||
const p = location.pathname.replace(/\/+$/, '') || '/';
|
||
if (p.endsWith('/minimal')) return '/minimal';
|
||
if (p.endsWith('/workspace')) return '/workspace';
|
||
return '/';
|
||
}
|
||
|
||
// 右下角两个悬浮按钮(视图切换 + 管理入口)放进同一个 dock:
|
||
// 这样在工作空间可以把它们作为一组收到屏幕右边缘,既不会互相重叠,
|
||
// 也让出中间的应用区域。
|
||
function mountDock() {
|
||
let dock = document.querySelector('.navi-dock');
|
||
if (!dock) {
|
||
dock = document.createElement('div');
|
||
dock.className = 'navi-dock';
|
||
document.body.append(dock);
|
||
}
|
||
return dock;
|
||
}
|
||
|
||
function mountViewSwitch() {
|
||
if (document.querySelector('.navi-view-switch')) return;
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'navi-view-switch';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'navi-view-toggle';
|
||
btn.setAttribute('aria-label', '切换视图');
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
btn.append(viewIcon());
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'navi-view-menu';
|
||
menu.hidden = true;
|
||
const active = currentView();
|
||
VIEWS.forEach((v) => {
|
||
const a = document.createElement('a');
|
||
a.className = 'navi-view-option';
|
||
a.href = v.path;
|
||
a.textContent = v.label;
|
||
if (v.path === active) {
|
||
a.classList.add('is-active');
|
||
a.setAttribute('aria-current', 'page');
|
||
}
|
||
menu.append(a);
|
||
});
|
||
|
||
const close = () => {
|
||
menu.hidden = true;
|
||
btn.setAttribute('aria-expanded', 'false');
|
||
};
|
||
|
||
btn.addEventListener('click', (evt) => {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
const open = menu.hidden;
|
||
menu.hidden = !open;
|
||
btn.setAttribute('aria-expanded', String(open));
|
||
});
|
||
|
||
document.addEventListener('click', (evt) => {
|
||
if (!menu.hidden && !wrap.contains(evt.target)) close();
|
||
});
|
||
|
||
// 本函数整体有上面的 `if (...navi-view-switch) return`,所以这里的 document 监听
|
||
// 实际只会挂一次。下面的守卫是冗余的:保留它是为了让"本函数新增 document 监听"
|
||
// 这件事不再依赖调用方自觉 —— 将来若有人删掉那个 if,监听就会累积。
|
||
if (!document.__naviViewSwitchKeysBound) {
|
||
document.__naviViewSwitchKeysBound = true;
|
||
document.addEventListener('keydown', (evt) => {
|
||
if (evt.key === 'Escape' && !menu.hidden) close();
|
||
});
|
||
}
|
||
|
||
wrap.append(btn, menu);
|
||
mountDock().append(wrap);
|
||
}
|
||
|
||
// ── 工作空间:点侧栏应用 → 在中间内嵌打开 ──
|
||
//
|
||
// Dashy 的面板就是一个 iframe,但它的 launchApp() 只在 target 不是 newtab 时
|
||
// 才把 URL 灌进 iframe;我们全局 openingMethod: newtab,所以点击走的是
|
||
// window.open(开新标签页),面板永远空。这里在 /workspace 内接管
|
||
// window.open,把目标装进面板。
|
||
//
|
||
// 但并非所有应用都允许被嵌:实测 24 个应用里 19 个可以,5 个拒绝
|
||
// (Gitea / docker.com 为 SAMEORIGIN,Dify 为 DENY)。被拒绝时浏览器
|
||
// 只给一片空白,且 JS 无法区分「被拒绝」与「加载中」(onload 一样触发),
|
||
// 所以先问服务端 /admin/api/frameable,再决定内嵌还是给出明确提示。
|
||
const wsFrameCache = new Map();
|
||
let wsPendingUrl = '';
|
||
let wsLastUrl = '';
|
||
let wsDeciding = '';
|
||
let nativeWindowOpen = null;
|
||
|
||
function wsText(cls, text) {
|
||
const el = document.createElement('div');
|
||
el.className = cls;
|
||
el.textContent = text;
|
||
return el;
|
||
}
|
||
|
||
function wsParts() {
|
||
const ws = document.querySelector('.work-space');
|
||
if (!ws) return null;
|
||
const frame = ws.querySelector('.web-content iframe');
|
||
if (!frame) return null;
|
||
let notice = ws.querySelector('.navi-ws-notice');
|
||
if (!notice) {
|
||
notice = document.createElement('div');
|
||
notice.className = 'navi-ws-notice';
|
||
ws.append(notice);
|
||
}
|
||
return { ws, frame, notice };
|
||
}
|
||
|
||
function setWorkspaceState(state, url, reason, note) {
|
||
const parts = wsParts();
|
||
if (!parts) return;
|
||
const { ws, notice } = parts;
|
||
ws.dataset.naviWs = state;
|
||
while (notice.firstChild) notice.removeChild(notice.firstChild);
|
||
|
||
if (state === 'frame') {
|
||
wsToolbar(url, note);
|
||
return;
|
||
}
|
||
if (state === 'hint') {
|
||
notice.append(wsText('navi-ws-notice-title', '从左侧选择一个应用'));
|
||
notice.append(wsText('navi-ws-notice-text', '网页会在中间打开,不用跳走。'));
|
||
return;
|
||
}
|
||
if (state === 'loading') {
|
||
notice.append(wsText('navi-ws-notice-title', '正在打开…'));
|
||
notice.append(wsText('navi-ws-notice-url', url || ''));
|
||
return;
|
||
}
|
||
if (state === 'blocked') {
|
||
notice.append(wsText('navi-ws-notice-title', '这个应用禁止被内嵌'));
|
||
notice.append(wsText('navi-ws-notice-text', reason
|
||
? '服务器返回:' + reason
|
||
: '服务器返回了禁止内嵌的响应头。'));
|
||
// 注意:这里只能用 url。早先本函数第 4 个参数叫 openUrl,改成 note 之后
|
||
// 本分支漏改,导致每次被拒内嵌都抛 ReferenceError,后面的弹层根本执行不到。
|
||
notice.append(wsText('navi-ws-notice-url', url || ''));
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'navi-ws-open';
|
||
btn.textContent = '在新标签页打开';
|
||
btn.addEventListener('click', (evt) => {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
if (nativeWindowOpen) nativeWindowOpen(url, '_blank');
|
||
});
|
||
notice.append(btn);
|
||
}
|
||
}
|
||
|
||
function hostOf(url) {
|
||
try {
|
||
return new URL(url, location.origin).host || url;
|
||
} catch (err) {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
// 面板工具条:常驻的退路。即使内嵌失败(被 WAF 拦、应用在框里崩),
|
||
// 也总能一键在新标签页打开,不会走进空白死胡同。
|
||
function wsToolbar(url, note) {
|
||
const ws = document.querySelector('.work-space');
|
||
const content = ws && ws.querySelector('.web-content');
|
||
if (!ws || !content) return;
|
||
let bar = ws.querySelector('.navi-ws-bar');
|
||
if (!bar) {
|
||
bar = document.createElement('div');
|
||
bar.className = 'navi-ws-bar';
|
||
|
||
const host = document.createElement('span');
|
||
host.className = 'navi-ws-bar-host';
|
||
const hint = document.createElement('span');
|
||
hint.className = 'navi-ws-bar-note';
|
||
const spacer = document.createElement('span');
|
||
spacer.className = 'navi-ws-bar-spacer';
|
||
const reload = document.createElement('button');
|
||
reload.type = 'button';
|
||
reload.className = 'navi-ws-bar-btn';
|
||
reload.textContent = '刷新';
|
||
reload.addEventListener('click', () => {
|
||
const f = ws.querySelector('.web-content iframe');
|
||
if (f && f.getAttribute('src')) f.src = f.getAttribute('src');
|
||
});
|
||
const open = document.createElement('button');
|
||
open.type = 'button';
|
||
open.className = 'navi-ws-bar-btn';
|
||
open.textContent = '新标签页打开';
|
||
open.addEventListener('click', () => {
|
||
const f = ws.querySelector('.web-content iframe');
|
||
const target = f && f.getAttribute('src');
|
||
if (target && nativeWindowOpen) nativeWindowOpen(target, '_blank');
|
||
});
|
||
|
||
bar.append(host, hint, spacer, reload, open);
|
||
ws.insertBefore(bar, content);
|
||
}
|
||
const hostEl = bar.querySelector('.navi-ws-bar-host');
|
||
const noteEl = bar.querySelector('.navi-ws-bar-note');
|
||
if (hostEl) hostEl.textContent = hostOf(url);
|
||
if (noteEl) noteEl.textContent = note || '';
|
||
return bar;
|
||
}
|
||
|
||
// 被拒内嵌时明确弹一个提示(不只是面板里的一行字):
|
||
// 用户点了却什么也没发生是最差的体验。
|
||
let wsModal = null;
|
||
|
||
function closeWorkspaceModal() {
|
||
if (!wsModal) return;
|
||
const { overlay } = wsModal;
|
||
wsModal = null;
|
||
// 先加上 closing 让它淡出,淡完再摘节点(否则关闭是硬切、没有动画)
|
||
overlay.classList.add('closing');
|
||
window.setTimeout(() => {
|
||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||
}, 170);
|
||
}
|
||
|
||
function showWorkspaceBlockedModal(url, reason) {
|
||
closeWorkspaceModal();
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'navi-ws-modal';
|
||
const card = document.createElement('div');
|
||
card.className = 'navi-ws-modal-card';
|
||
const title = document.createElement('div');
|
||
title.className = 'navi-ws-modal-title';
|
||
title.textContent = '该应用不支持在工作区显示';
|
||
const host = document.createElement('div');
|
||
host.className = 'navi-ws-modal-host';
|
||
host.textContent = hostOf(url);
|
||
const text = document.createElement('div');
|
||
text.className = 'navi-ws-modal-text';
|
||
text.textContent = reason
|
||
? ('服务器返回:' + reason + ',所以它不允许被嵌在其它页面里。')
|
||
: '服务器返回了禁止内嵌的响应头,所以它不允许被嵌在其它页面里。';
|
||
const actions = document.createElement('div');
|
||
actions.className = 'navi-ws-modal-actions';
|
||
const openBtn = document.createElement('button');
|
||
openBtn.type = 'button';
|
||
openBtn.className = 'navi-ws-open';
|
||
openBtn.textContent = '在新标签页打开';
|
||
openBtn.addEventListener('click', () => {
|
||
closeWorkspaceModal();
|
||
if (nativeWindowOpen) nativeWindowOpen(url, '_blank');
|
||
});
|
||
const okBtn = document.createElement('button');
|
||
okBtn.type = 'button';
|
||
okBtn.className = 'navi-ws-modal-ok';
|
||
okBtn.textContent = '知道了';
|
||
okBtn.addEventListener('click', closeWorkspaceModal);
|
||
actions.append(openBtn, okBtn);
|
||
card.append(title, host, text, actions);
|
||
overlay.append(card);
|
||
overlay.addEventListener('click', (evt) => {
|
||
if (evt.target === overlay) closeWorkspaceModal();
|
||
});
|
||
// Escape 监听挂在 overlay 上,不挂 document:
|
||
// 挂在 document 上时,每次弹窗都会永久多出一个监听(overlay 移除后监听还在),
|
||
// 弹 N 次窗就有 N 个 Escape 监听器长期驻留。
|
||
overlay.addEventListener('keydown', (evt) => {
|
||
if (evt.key === 'Escape') closeWorkspaceModal();
|
||
});
|
||
// overlay 自己得能收到焦点,否则 keydown 不会冒到它身上
|
||
overlay.tabIndex = -1;
|
||
window.setTimeout(() => overlay.focus(), 0);
|
||
document.body.append(overlay);
|
||
wsModal = { overlay };
|
||
}
|
||
|
||
async function frameable(url) {
|
||
if (wsFrameCache.has(url)) return wsFrameCache.get(url);
|
||
let info = { frameable: true, conclusive: false, reason: '' };
|
||
try {
|
||
const res = await fetch('/admin/api/frameable?url=' + encodeURIComponent(url),
|
||
{ credentials: 'same-origin' });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data && typeof data.frameable === 'boolean') info = data;
|
||
}
|
||
} catch (err) {
|
||
// 探测失败就按可内嵌处理,交给浏览器判断,并在工具条里说明
|
||
}
|
||
wsFrameCache.set(url, info);
|
||
return info;
|
||
}
|
||
|
||
// 统一的唯一入口:无论目标 URL 是来自 window.open(带 target 的项目)
|
||
// 还是 Dashy 自己把 iframe 的 src 改掉(无 target 的项目,占多数),
|
||
// 都先探测再决定「内嵌」还是「明确告知被拒」。
|
||
async function verifyAndShow(url) {
|
||
if (!url || url === wsLastUrl || url === wsDeciding) return;
|
||
const parts = wsParts();
|
||
if (!parts) return;
|
||
wsDeciding = url;
|
||
wsPendingUrl = url;
|
||
setWorkspaceState('loading', url);
|
||
const info = await frameable(url);
|
||
wsDeciding = '';
|
||
if (wsPendingUrl !== url) return;
|
||
// 只有「探测拿到明确的拒绝证据」才阻止内嵌;无法确认时宁可试一次,
|
||
// 因为服务端探测可能被 WAF 拦成 403,而浏览器未必被拦。
|
||
if (info.conclusive && !info.frameable) {
|
||
wsLastUrl = '';
|
||
parts.frame.removeAttribute('src');
|
||
setWorkspaceState('blocked', url, info.reason, url);
|
||
showWorkspaceBlockedModal(url, info.reason);
|
||
return;
|
||
}
|
||
wsLastUrl = url;
|
||
if (parts.frame.getAttribute('src') !== url) parts.frame.src = url;
|
||
setWorkspaceState('frame', url, '', info.conclusive ? '' : (info.reason || '无法确认是否允许内嵌'));
|
||
}
|
||
|
||
// openingMethod 为 sametab 的项目不经 window.open,而是由 Vue 直接改 iframe 的
|
||
// src;监听属性变化把这部分也纳入同一套判定,避免出现「面板空着但 iframe 已经
|
||
// 加载了」的静默失败。
|
||
function watchWorkspaceFrame() {
|
||
const parts = wsParts();
|
||
if (!parts || parts.frame.__naviWatched) return;
|
||
parts.frame.__naviWatched = true;
|
||
new MutationObserver(() => {
|
||
const src = parts.frame.getAttribute('src') || '';
|
||
if (/^https?:/.test(src) && src !== wsLastUrl) verifyAndShow(src);
|
||
}).observe(parts.frame, { attributes: true, attributeFilter: ['src'] });
|
||
}
|
||
|
||
function enableWorkspacePane() {
|
||
if (window.__naviWorkspacePane) return;
|
||
window.__naviWorkspacePane = true;
|
||
nativeWindowOpen = window.open.bind(window);
|
||
window.open = function (url, name, features) {
|
||
if (
|
||
typeof url === 'string' && url && name !== '_self'
|
||
&& /^\/workspace/.test(location.pathname)
|
||
&& document.querySelector('.work-space .web-content iframe')
|
||
) {
|
||
verifyAndShow(url);
|
||
return null;
|
||
}
|
||
return nativeWindowOpen(url, name, features);
|
||
};
|
||
}
|
||
|
||
// 进入工作空间时先摆好提示态,避免右侧一片空白。
|
||
let wsSeeded = false;
|
||
function seedWorkspacePane() {
|
||
if (wsSeeded) return;
|
||
if (!/^\/workspace/.test(location.pathname)) return;
|
||
if (!document.querySelector('.work-space .web-content iframe')) return;
|
||
wsSeeded = true;
|
||
watchWorkspaceFrame();
|
||
const parts = wsParts();
|
||
if (parts && !parts.ws.dataset.naviWs) setWorkspaceState('hint');
|
||
}
|
||
|
||
// 工作空间的控件默认收在屏幕边缘:第一次点/触摸只把它滑出来,
|
||
// 展开之后的第二次点击才真正生效。否则贴着边的按钮会被直接点中
|
||
// (比如想看搜索框,结果把管理后台打开了)。
|
||
// 判据用「元素是否真的露在视口内」,所以鼠标悬停展开后点击仍然一次生效。
|
||
const PEEK_CLASS = 'navi-peek-open';
|
||
|
||
function isPeekTucked(el) {
|
||
const r = el.getBoundingClientRect();
|
||
return r.left < 0 || r.right > window.innerWidth
|
||
|| r.top < 0 || r.bottom > window.innerHeight;
|
||
}
|
||
|
||
function openPeek() {
|
||
document.documentElement.classList.add(PEEK_CLASS);
|
||
if (openPeek.timer) window.clearTimeout(openPeek.timer);
|
||
// 展开后一段时间没人操作就收回,避免长期占着工作空间
|
||
openPeek.timer = window.setTimeout(() => {
|
||
document.documentElement.classList.remove(PEEK_CLASS);
|
||
}, 6000);
|
||
}
|
||
|
||
function closePeek() {
|
||
if (openPeek.timer) window.clearTimeout(openPeek.timer);
|
||
document.documentElement.classList.remove(PEEK_CLASS);
|
||
}
|
||
|
||
function guardPeek(el) {
|
||
if (!el || el.__naviPeekGuarded) return;
|
||
el.__naviPeekGuarded = true;
|
||
// 捕获阶段拦住:收起状态下第一次点击只展开,不触发按钮/链接本身
|
||
el.addEventListener('click', (evt) => {
|
||
if (!/^\/workspace/.test(location.pathname)) return;
|
||
if (!isPeekTucked(el)) return;
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
openPeek();
|
||
}, true);
|
||
el.addEventListener('mouseleave', () => {
|
||
// 鼠标离开且没有聚焦在内部时才收回
|
||
if (!el.contains(document.activeElement)) closePeek();
|
||
});
|
||
}
|
||
|
||
function enablePeekGuard() {
|
||
guardPeek(document.querySelector('.navi-search-bar'));
|
||
guardPeek(document.querySelector('.navi-dock'));
|
||
guardPeek(document.querySelector('.navi-footer-host'));
|
||
// 幂等守卫:run() 会被 MutationObserver 反复触发,这里挂的是 document 级
|
||
// keydown,不设守卫会持续累积(实测同 bindSearchKeys)。
|
||
if (document.__naviPeekKeysBound) return;
|
||
document.__naviPeekKeysBound = true;
|
||
document.addEventListener('keydown', (evt) => {
|
||
if (evt.key === 'Escape') closePeek();
|
||
});
|
||
}
|
||
|
||
// 极简视图(/minimal)与工作空间视图不渲染 Dashy 页脚,备案号会整段消失。
|
||
// 这里从只读的 pageinfo 取回页脚 HTML 补一份,保证每个视图都能看到备案信息。
|
||
let footerPending = false;
|
||
// 区分「Dashy 自己渲染的页脚」与「我们补的页脚」:
|
||
// 我们补的那份一定在 .navi-footer-host 里。
|
||
// 判据有两层:不在 host 里的 .navi-footer,或 Dashy 的 <footer> 容器本身
|
||
// (首页会渲染,而 /minimal 与 /workspace 不渲染)。
|
||
function dashyFooterNode() {
|
||
const host = document.querySelector('.navi-footer-host');
|
||
const outside = Array.from(document.querySelectorAll('.navi-footer'))
|
||
.find((el) => !host || !host.contains(el));
|
||
if (outside) return outside;
|
||
const wrap = document.querySelector('#dashy footer');
|
||
if (wrap && (!host || !host.contains(wrap))) return wrap;
|
||
return null;
|
||
}
|
||
|
||
async function mountFooter() {
|
||
const own = document.querySelector('.navi-footer-host');
|
||
|
||
// 首页 Dashy 自己会渲染页脚。Vue 是异步挂载的,navi.js 可能先补了一份,
|
||
// 之后 Dashy 的页脚才出现 —— 这时必须回收我们补的那份,否则首页会出现
|
||
// 两个备案号(用户实测)。
|
||
if (dashyFooterNode()) {
|
||
if (own) own.remove();
|
||
return;
|
||
}
|
||
|
||
if (footerPending || (own && own.querySelector('.navi-footer'))) return;
|
||
footerPending = true;
|
||
try {
|
||
const res = await fetch('/admin/api/pageinfo', { credentials: 'same-origin' });
|
||
if (!res.ok) return;
|
||
const info = await res.json();
|
||
const html = (info && info.footer) || '';
|
||
if (!html) return;
|
||
// 用 DOMParser 解析(不用 innerHTML),只取 .navi-footer 节点。
|
||
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
||
const node = parsed.querySelector('.navi-footer');
|
||
if (!node || dashyFooterNode()) return;
|
||
if (document.querySelector('.navi-footer-host')) return;
|
||
const host = document.createElement('div');
|
||
host.className = 'navi-footer-host';
|
||
host.append(node);
|
||
const bar = document.querySelector('.navi-search-bar');
|
||
if (bar && bar.parentNode) bar.parentNode.insertBefore(host, bar);
|
||
else document.body.append(host);
|
||
} catch (err) {
|
||
// 补页脚失败不影响搜索等主功能
|
||
} finally {
|
||
footerPending = false;
|
||
}
|
||
}
|
||
|
||
|
||
/* ── 极简页(/minimal):左右滑动切换分组 ──
|
||
结构契约(Dashy 原生 DOM):.minimal-headings-row > .minimal-section-heading.headings,
|
||
当前分组带 .selected;"切分组"= 对该元素派发一次真实 click(实测有效)。
|
||
三种输入:触屏 / 鼠标拖拽(pointer 事件)、触控板双指横滑(wheel 的 deltaX)。
|
||
边界:clamp —— 到头不动,只给一次轻微回弹提示(禁用动效时改为淡闪)。
|
||
动画:只做淡入淡出(不位移),避免和滚动位置/布局打架。 */
|
||
const SWIPE = {
|
||
armTouch: 10, armMouse: 16, // 认作"横滑"的最小位移
|
||
fire: 56, // 触发切换的最小横移
|
||
ratio: 1.5, // |dx| 需大于 |dy| 的倍数,避免抢竖滑
|
||
suppressClick: 20, // 超过此位移就吞掉随后的一次 click(防误开卡片)
|
||
wheelShown: 60, wheelIdle: 420, // 触控板累计阈值 / 重置间隔
|
||
cooldown: 380
|
||
};
|
||
let swipeEl = null; // 已绑定手势的 .minimal-home(元素重建时重绑)
|
||
let swipeDocBound = false; // document 级监听只装一次
|
||
let swipeBusy = false, swipeAcc = 0, swipeAccAt = 0, swipeCoolUntil = 0, swipeEatClick = 0;
|
||
let swipeSelfClick = false; // 我们自己的切换 click 不能被自己的防误触拦掉
|
||
|
||
function swipeTabs() {
|
||
return Array.from(document.querySelectorAll('.minimal-headings-row > .minimal-section-heading.headings'));
|
||
}
|
||
function swipeFrameEl() {
|
||
return document.querySelector('.minimal-home > .item-group-container');
|
||
}
|
||
function reducedMotion() {
|
||
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||
}
|
||
function later(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||
function swipeEditable(node, root) {
|
||
for (let el = node; el && el !== root; el = el.parentElement) {
|
||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable) return true;
|
||
}
|
||
return false;
|
||
}
|
||
function swipeHScrollable(node, root) {
|
||
for (let el = node; el && el !== root; el = el.parentElement) {
|
||
if (el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 4) return true;
|
||
}
|
||
return false;
|
||
}
|
||
// 到头时的一次性提示:轻微回弹(transform / 仅 8px),禁用动效时退化为淡闪
|
||
function swipeEdgeHint(dir) {
|
||
// 只动"页框内容"(.minimal-section-inner),不动页签行与框体本身
|
||
const el = document.querySelector('.minimal-section-inner.selected');
|
||
if (!el || el.dataset.naviHint === '1') return;
|
||
el.dataset.naviHint = '1';
|
||
setTimeout(() => { delete el.dataset.naviHint; }, 320);
|
||
if (reducedMotion()) {
|
||
el.style.transition = 'opacity 110ms ease';
|
||
el.style.opacity = '0.78';
|
||
setTimeout(() => { el.style.opacity = ''; el.style.transition = ''; }, 120);
|
||
return;
|
||
}
|
||
el.style.transition = 'transform 150ms cubic-bezier(.22,1,.36,1)';
|
||
el.style.transform = 'translateX(' + (dir > 0 ? -8 : 8) + 'px)';
|
||
setTimeout(() => {
|
||
el.style.transform = '';
|
||
setTimeout(() => { el.style.transition = ''; }, 170);
|
||
}, 150);
|
||
}
|
||
// 页签行跟随:实测 .minimal-headings-row 可横向滚动(scrollWidth 1306 > clientWidth 828),
|
||
// 但 scrollLeft 恒为 0 —— 切到第 7/8 个分组时选中页签落在视口外,
|
||
// 用户会以为"手势没生效 / 页签没联动"。这里把选中项滚进可视区(居中)。
|
||
function swipeRevealTab(tab) {
|
||
const row = tab && tab.closest ? tab.closest('.minimal-headings-row') : null;
|
||
if (!row) return;
|
||
const tr = tab.getBoundingClientRect(), rr = row.getBoundingClientRect();
|
||
if (tr.left >= rr.left - 1 && tr.right <= rr.right + 1) return; // 已完整可见
|
||
const delta = (tr.left + tr.width / 2) - (rr.left + rr.width / 2);
|
||
const max = Math.max(0, row.scrollWidth - row.clientWidth);
|
||
const left = Math.max(0, Math.min(max, row.scrollLeft + delta));
|
||
if (reducedMotion() || !row.scrollTo) row.scrollLeft = left;
|
||
else row.scrollTo({ left: left, behavior: 'smooth' });
|
||
}
|
||
|
||
async function swipeSwitch(dir) {
|
||
if (swipeBusy || Date.now() < swipeCoolUntil) return false;
|
||
const tabs = swipeTabs();
|
||
const i = tabs.findIndex((t) => t.classList.contains('selected'));
|
||
if (i < 0 || tabs.length < 2) return false;
|
||
const j = i + dir;
|
||
if (j < 0 || j >= tabs.length) { swipeEdgeHint(dir); return false; } // clamp
|
||
swipeBusy = true;
|
||
swipeCoolUntil = Date.now() + SWIPE.cooldown;
|
||
// 淡出/淡入只作用于"页框内的元素"(.minimal-section-inner.selected > *),
|
||
// 页签行与框体本身保持不动(用户反馈:此前整框一起闪,页签也被带着淡出了)。
|
||
const home = document.querySelector('.minimal-home');
|
||
const fade = !!home && !reducedMotion();
|
||
if (fade) { home.classList.add('navi-switching'); await later(90); }
|
||
// 关键:这是我们自己发起的切换 click —— 必须绕开"防误触"拦截,
|
||
// 否则会出现"手势收到了、手势条件也满足,但页面没切"的怪象(实测踩过)。
|
||
swipeEatClick = 0;
|
||
swipeSelfClick = true;
|
||
try { tabs[j].click(); } finally { swipeSelfClick = false; }
|
||
await later(70);
|
||
if (fade) {
|
||
home.classList.remove('navi-switching');
|
||
home.classList.add('navi-fadein');
|
||
setTimeout(() => home.classList.remove('navi-fadein'), 220);
|
||
}
|
||
swipeRevealTab(tabs[j]); // 页签行跟随选中项
|
||
swipeBusy = false;
|
||
return true;
|
||
}
|
||
function bindTabSwipe() {
|
||
const frame = document.querySelector('.minimal-home');
|
||
const tabs = swipeTabs();
|
||
// 契约自检:标签匹配不到就在 <html> 上留标记(可编程检查,不刷控制台)
|
||
document.documentElement.dataset.naviSwipe = (frame && tabs.length > 1) ? 'on' : 'unavailable';
|
||
if (!frame || swipeEl === frame) return;
|
||
swipeEl = frame;
|
||
let pid = null, x0 = 0, y0 = 0, armed = false, isMouse = false, armPx = SWIPE.armTouch;
|
||
|
||
frame.addEventListener('pointerdown', (evt) => {
|
||
isMouse = evt.pointerType === 'mouse';
|
||
if (isMouse && evt.button !== 0) return;
|
||
if (swipeEditable(evt.target, frame) || swipeHScrollable(evt.target, frame)) return;
|
||
pid = evt.pointerId; x0 = evt.clientX; y0 = evt.clientY; armed = false;
|
||
armPx = isMouse ? SWIPE.armMouse : SWIPE.armTouch;
|
||
}, { passive: true });
|
||
|
||
frame.addEventListener('pointermove', (evt) => {
|
||
if (pid === null || evt.pointerId !== pid) return;
|
||
const dx = evt.clientX - x0, dy = evt.clientY - y0;
|
||
if (!armed) {
|
||
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > armPx) { pid = null; return; } // 让给竖向滚动
|
||
if (Math.abs(dx) < armPx) return;
|
||
armed = true;
|
||
if (isMouse) frame.classList.add('navi-dragging');
|
||
}
|
||
if (isMouse && evt.cancelable) evt.preventDefault(); // 鼠标拖拽时不选中文字
|
||
if (Math.abs(dx) >= SWIPE.suppressClick) swipeEatClick = Date.now() + 400;
|
||
});
|
||
|
||
function swipeFinish(evt) {
|
||
if (pid === null || evt.pointerId !== pid) return;
|
||
const dx = evt.clientX - x0, dy = evt.clientY - y0;
|
||
pid = null;
|
||
frame.classList.remove('navi-dragging');
|
||
if (!armed) return;
|
||
armed = false;
|
||
if (Math.abs(dx) >= SWIPE.fire && Math.abs(dx) > Math.abs(dy) * SWIPE.ratio) {
|
||
swipeSwitch(dx < 0 ? 1 : -1); // 向左滑 → 下一个分组
|
||
}
|
||
}
|
||
frame.addEventListener('pointerup', swipeFinish, { passive: true });
|
||
frame.addEventListener('pointercancel', () => {
|
||
pid = null; armed = false; frame.classList.remove('navi-dragging');
|
||
}, { passive: true });
|
||
|
||
// 触控板双指横滑:wheel 的 deltaX 累计到阈值即切换
|
||
frame.addEventListener('wheel', (evt) => {
|
||
const ax = Math.abs(evt.deltaX), ay = Math.abs(evt.deltaY);
|
||
if (ax < 2 || ax <= ay) { swipeAcc = 0; return; }
|
||
const now = Date.now();
|
||
if (now - swipeAccAt > SWIPE.wheelIdle) swipeAcc = 0;
|
||
swipeAccAt = now;
|
||
swipeAcc += evt.deltaX;
|
||
if (Math.abs(swipeAcc) >= SWIPE.wheelShown && !swipeBusy && now >= swipeCoolUntil) {
|
||
// 方向语义实测:两指向左滑 → 浏览器给 deltaX > 0 → 应是"下一个分组"
|
||
const dir = swipeAcc > 0 ? 1 : -1;
|
||
swipeAcc = 0;
|
||
swipeSwitch(dir);
|
||
}
|
||
}, { passive: true });
|
||
|
||
// 用户自己点页签时,页签行同样跟随(把选中项滚进可视区)
|
||
frame.addEventListener('click', (evt) => {
|
||
const tab = evt.target && evt.target.closest ? evt.target.closest('.minimal-section-heading.headings') : null;
|
||
if (tab) setTimeout(() => swipeRevealTab(tab), 80);
|
||
}, { passive: true });
|
||
|
||
if (!swipeDocBound) {
|
||
swipeDocBound = true;
|
||
// 滑动后吞掉紧随的一次 click,避免误开卡片里的链接
|
||
document.addEventListener('click', (evt) => {
|
||
if (swipeSelfClick) return; // 放行我们自己的切换 click
|
||
if (Date.now() < swipeEatClick) {
|
||
swipeEatClick = 0;
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
}
|
||
}, true);
|
||
// 键盘 ←/→(可访问性)
|
||
document.addEventListener('keydown', (evt) => {
|
||
if (evt.key !== 'ArrowLeft' && evt.key !== 'ArrowRight') return;
|
||
if (!document.querySelector('.minimal-home')) return;
|
||
if (document.querySelector('.modal-overlay')) return;
|
||
if (swipeEditable(evt.target, document.body)) return;
|
||
if (swipeBusy || Date.now() < swipeCoolUntil) return;
|
||
swipeSwitch(evt.key === 'ArrowRight' ? 1 : -1);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 当前视图标记到 <html> 上,供 navi.css 针对工作空间做「控件收边」。
|
||
function markView() {
|
||
const p = location.pathname.replace(/\/+$/, '') || '/';
|
||
const view = p.endsWith('/workspace') ? 'workspace'
|
||
: (p.endsWith('/minimal') ? 'minimal' : 'home');
|
||
if (document.documentElement.dataset.naviView !== view) {
|
||
document.documentElement.dataset.naviView = view;
|
||
}
|
||
}
|
||
|
||
// 焦点是否落在可编辑元素(输入框/多行文本/下拉/可编辑区域)里。
|
||
function focusInEditable(node) {
|
||
for (let el = node; el && el !== document.body; el = el.parentElement) {
|
||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|
||
|| el.tagName === 'SELECT' || el.isContentEditable) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── 键盘快捷键 ──
|
||
//
|
||
// ① Enter:展开搜索框并把焦点送进输入框。
|
||
// 收起态下 input.tabIndex=-1,键盘用户没法聚焦它,原本只能去点那个球。
|
||
// 搜索框自己已经打开时**不接管** —— 那时 Enter 必须走表单原生提交。
|
||
//
|
||
// ② Space:打开**焦点所在的导航项**。
|
||
// 实测(真实 Dashy V4.4.4 + 真实按键):焦点落在卡片 <a> 上时,
|
||
// 原生 Enter 会被 Dashy 接管并打开该站点,但 **Space 完全无反应**。
|
||
// 所以这里补一次显式激活;焦点在可编辑元素里时放过,否则打不出空格。
|
||
function bindSearchKeys() {
|
||
// ⚠️ 幂等守卫(实测抓到,必需):
|
||
// run() 由 MutationObserver 反复触发,而本函数直接往 document 上挂 keydown。
|
||
// 没有守卫时:实测 DOM 抖动 200 次 → document 上多出 402 个 keydown 监听器,
|
||
// 随后按一次 Space 会把同一张卡片 click 213 次(每次一个监听器各点一下)。
|
||
// 表现是"点一下卡片弹出十几个标签页",且抖动越多越严重。
|
||
if (document.__naviSearchKeysBound) return;
|
||
document.__naviSearchKeysBound = true;
|
||
document.addEventListener('keydown', (evt) => {
|
||
if (!searchCtl) return;
|
||
if (evt.metaKey || evt.ctrlKey || evt.altKey) return; // 不抢浏览器/系统快捷键
|
||
|
||
if (evt.key === 'Enter' || evt.key === 'NumpadEnter') {
|
||
if (searchCtl.isOpen()) return; // 展开中:交给表单提交
|
||
if (focusInEditable(evt.target)) return; // 后台等输入框里不抢
|
||
const inSearch = !!(evt.target && evt.target.closest
|
||
&& evt.target.closest('.navi-search-bar'));
|
||
if (inSearch) return;
|
||
evt.preventDefault();
|
||
searchCtl.open();
|
||
return;
|
||
}
|
||
|
||
if (evt.key === ' ' || evt.code === 'Space') {
|
||
if (focusInEditable(evt.target)) return; // 空格是字符
|
||
if (searchCtl.isOpen()) return; // 搜索开着时不抢
|
||
const t = evt.target;
|
||
if (!t || !t.closest) return;
|
||
// 导航卡片在两个视图下类名不同(Dashy 渲染的,不能写死):
|
||
// 首页 A.nav-item
|
||
// 极简页 A.item.size-medium(包在 .item-wrapper 里)
|
||
// 所以不挑类名,只认「可激活元素」。
|
||
const item = t.closest('a[href], [role="button"]');
|
||
if (!item) return;
|
||
// Space 的默认行为会滚动页面,必须拦掉
|
||
evt.preventDefault();
|
||
if (typeof item.click === 'function') item.click();
|
||
}
|
||
});
|
||
}
|
||
|
||
// run() 会往 DOM 里插入节点(搜索框、dock、页脚…),而它自己又是由
|
||
// MutationObserver 驱动的 ⇒ 自己触发自己。实测(真实 Dashy V4.4.4):
|
||
// 挂上这个 observer 后,空载页面的回调次数在 3 秒内就超过 500 次,
|
||
// 主线程被占满,evaluate/DOMContentLoaded 全部超时。
|
||
//
|
||
// 两道抑制,缺一不可:
|
||
// ① selfWrite:run() 执行期间挂起的 DOM 变更,全部是"我们自己写的",
|
||
// 不能拿来再触发一次 run();
|
||
// ② 节流:即便有外部变更(如 Dashy 异步渲染),也不做无意义的连跑。
|
||
let selfWrite = 0;
|
||
let lastRun = 0;
|
||
|
||
function run() {
|
||
selfWrite++;
|
||
try {
|
||
markView();
|
||
mountSearch();
|
||
mountAdminEntry();
|
||
mountViewSwitch();
|
||
enableWorkspacePane();
|
||
enablePeekGuard();
|
||
seedWorkspacePane();
|
||
hideInstallEntry();
|
||
mountFooter();
|
||
bindTabSwipe();
|
||
bindSearchKeys();
|
||
} finally {
|
||
selfWrite--;
|
||
}
|
||
}
|
||
|
||
// 真正被外部变更驱动的入口(与 run 分开,以便做节流与变更过滤)
|
||
function onExternalMutation() {
|
||
if (selfWrite > 0) return; // ① 自己写的不算
|
||
const now = Date.now();
|
||
if (now - lastRun < 100) return; // ② 100ms 内不重复跑
|
||
lastRun = now;
|
||
run();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', run, { once: true });
|
||
} else {
|
||
run();
|
||
}
|
||
new MutationObserver(onExternalMutation).observe(document.documentElement, { childList: true, subtree: true });
|
||
})();
|
||
|
||
/* ── 自动深浅色(跟随系统)──
|
||
Dashy 原生的日/夜开关在设置面板里,而该面板被隐藏(用户要求隐藏原生设置),
|
||
所以这里由我们自己跟随系统偏好:
|
||
· 默认按 prefers-color-scheme 决定
|
||
· 手动切换过(localStorage.navi-theme)则以手动为准
|
||
· 系统偏好变化时实时跟随
|
||
Dashy 挂载后可能重设 data-theme,故用短暂的定时对齐 + 属性观察收口。 */
|
||
(function () {
|
||
var KEY = 'navi-theme';
|
||
var mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
|
||
if (!mq) return;
|
||
|
||
function wantDark() {
|
||
var saved = null;
|
||
try { saved = localStorage.getItem(KEY); } catch (e) { /* 隐私模式 */ }
|
||
return saved === 'dark' ? true : saved === 'light' ? false : mq.matches;
|
||
}
|
||
|
||
function apply() {
|
||
var dark = wantDark();
|
||
var root = document.documentElement;
|
||
// 注意:绝不能改写 data-theme —— Dashy 按主题名输出变量作用域
|
||
// (本站主题是 jianf-glass),改掉它会让整套主题变量失配,
|
||
// 表现就是"有的地方没变深、有的地方直角圆角混杂"。
|
||
// 我们只加自己的类,深色变量在 navi.css 里整套覆盖。
|
||
root.classList.toggle('navi-dark', dark);
|
||
root.style.colorScheme = dark ? 'dark' : 'light';
|
||
// 同一个类也打到 body:customCss 是 Dashy 运行时注入的、排在 navi.css 之后,
|
||
// 只靠 html.navi-dark body 与它同特异度会打成平局而输给它(实测:
|
||
// 主页背景仍是亮色)。html.navi-dark body.navi-dark 特异度更高,稳定胜出。
|
||
if (document.body) document.body.classList.toggle('navi-dark', dark);
|
||
}
|
||
|
||
apply();
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', apply);
|
||
}
|
||
if (mq.addEventListener) mq.addEventListener('change', apply);
|
||
else if (mq.addListener) mq.addListener(apply);
|
||
|
||
// Dashy 挂载后可能覆写 data-theme:前 15 秒内对齐,之后交给下面的观察器
|
||
var tries = 0;
|
||
var timer = setInterval(function () {
|
||
apply();
|
||
if (++tries >= 30) clearInterval(timer);
|
||
}, 500);
|
||
|
||
// 只在"与期望不一致"时回写,避免与自身写入形成循环
|
||
try {
|
||
new MutationObserver(function () {
|
||
var name = wantDark() ? 'dark' : 'light';
|
||
if (document.documentElement.getAttribute('data-theme') !== name) apply();
|
||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||
} catch (e) { /* 老浏览器忽略 */ }
|
||
})();
|
||
|
||
/* 导航项点击计数:独立于浏览器历史,用于「常用服务」按真实点击频次排序。
|
||
用 sendBeacon 异步上报,不阻塞跳转;失败也不影响用户。 */
|
||
document.addEventListener('click', function (e) {
|
||
var a = e.target && e.target.closest ? e.target.closest('a[href^="http"]') : null;
|
||
if (!a || !a.href) return;
|
||
try {
|
||
var body = JSON.stringify({ url: a.href });
|
||
if (navigator.sendBeacon) {
|
||
navigator.sendBeacon('/api/history/nav-visit', new Blob([body], { type: 'application/json' }));
|
||
} else {
|
||
fetch('/api/history/nav-visit', { method: 'POST', body: body, keepalive: true }).catch(function () {});
|
||
}
|
||
} catch (_) {}
|
||
}, true);
|
||
|
||
(function installDailyQuote() {
|
||
// v79:改为固定座右铭(原实现随机取 hitokoto + 随机兜底,观感"中二"且不可控)。
|
||
// 用户座右铭:「不念往昔之繁华,无谓前路之沧桑」—— 分作两句,分居两行右侧。
|
||
var QUOTE = ['不念往昔之繁华', '无谓前路之沧桑'];
|
||
function render() {
|
||
var f = document.querySelector('.navi-footer'); if (!f) return false;
|
||
if (f.querySelector('.navi-quote')) return true;
|
||
// 两行 = 两个分组行内各放右侧一言;此处仅确保元素存在与文案
|
||
QUOTE.forEach(function (line, idx) {
|
||
var sp = document.createElement('span');
|
||
sp.className = 'navi-quote' + (idx === 1 ? ' navi-quote-2' : '');
|
||
sp.textContent = line;
|
||
f.appendChild(sp);
|
||
});
|
||
return true;
|
||
}
|
||
if (!render()) { var n = 0; var iv = setInterval(function () { n++; if (render() || n > 20) clearInterval(iv); }, 500); }
|
||
})();
|
||
|