Files
HomeDash/web/navi.js
JianFeeeee 270445a516 feat(navi): Enter 唤起搜索框 + Space 激活焦点卡片;监听器幂等守卫
- 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)
2026-10-02 11:24:55 +08:00

1046 lines
44 KiB
JavaScript
Raw Permalink 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';
// 浏览器插件在 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); }
})();