feat(ui): 极简页支持左右滑动切换分组(触屏/触控板/鼠标拖拽)

需求:极简页的内容框此前只有竖向滚动手势,加入横滑切换"页面"(分组)。

实现(web/navi.js 新增 bindTabSwipe + web/navi.css v89):
 · 契约:.minimal-headings-row > .minimal-section-heading.headings(当前项 .selected),
   切换 = 对该元素派发真实 click(实测有效,不触碰 Dashy 内部状态)。
 · 三种输入:pointer 事件覆盖触屏与鼠标拖拽;wheel 的 deltaX 累积覆盖触控板双指横滑。
 · 判定:|dx| >= 56 且 |dx| > 1.5|dy| 才切换;先判 |dy| > |dx| 则直接让给竖向滚动。
 · 边界:clamp —— 到头不动,给一次轻微回弹提示(prefers-reduced-motion 时改为淡闪)。
 · 动画:纯淡入淡出(不位移),并遵守 prefers-reduced-motion。
 · 防误触:横拖超过 20px 后吞掉紧随的一次 click,避免误开卡片链接。
 · 不抢横滑:手势起点若落在可横向滚动的容器内、或输入框内,直接放行。
 · 可访问性:键盘 ←/→ 也能切换;touch-action 用 pan-y pinch-zoom(保留双指缩放)。
 · 自检:匹配不到标签时在 <html> 上置 data-navi-swipe="unavailable"(不刷控制台)。
This commit is contained in:
HomeDash Bot
2026-09-12 21:54:35 +08:00
parent 6062c9cdc2
commit 3deffc1a77
2 changed files with 188 additions and 0 deletions

View File

@ -2472,3 +2472,21 @@ html[data-theme] body:not(:has(.minimal-home)) footer > .navi-footer > a:nth-of-
html[data-theme] body .navi-footer > .navi-quote:nth-last-of-type(2) { grid-column: 2 !important; grid-row: 1 !important; justify-self: end !important; }
html[data-theme] body .navi-footer > .navi-quote:last-of-type { grid-column: 2 !important; grid-row: 2 !important; justify-self: end !important; }
html[data-theme] .navi-footer { grid-template-rows: auto auto !important; grid-auto-rows: 0 !important; }
/* ═══ v89:极简页左右滑动切换分组(触屏 / 触控板 / 鼠标拖拽)═══
touch-action 用 pan-y pinch-zoom:
· 竖向滚动仍由浏览器原生处理(横滑事件才交给 JS);
· 保留双指缩放(不写成 pan-y,否则会禁掉 pinch-zoom,属可访问性倒退)。
overscroll-behavior-x: contain 抑制浏览器把横滑当成"后退/前进"。
拖拽期间禁止选中文字(否则鼠标横拖会拖出一片蓝色选区)。 */
html[data-theme] .minimal-home,
html[data-theme] .minimal-home > .item-group-container,
html[data-theme] .minimal-home > .item-group-container > .minimal-section-inner {
touch-action: pan-y pinch-zoom !important;
overscroll-behavior-x: contain !important;
}
html[data-theme] .minimal-home.navi-dragging,
html[data-theme] .minimal-home.navi-dragging * {
user-select: none !important;
-webkit-user-select: none !important;
}

View File

@ -611,6 +611,175 @@
}
}
/* ── 极简页(/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;
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) {
const frame = swipeFrameEl();
if (!frame || frame.dataset.naviHint === '1') return;
frame.dataset.naviHint = '1';
setTimeout(() => { delete frame.dataset.naviHint; }, 300);
if (reducedMotion()) {
frame.style.transition = 'opacity 110ms ease';
frame.style.opacity = '0.75';
setTimeout(() => { frame.style.opacity = ''; frame.style.transition = ''; }, 120);
return;
}
frame.style.transition = 'transform 150ms cubic-bezier(.22,1,.36,1)';
frame.style.transform = 'translateX(' + (dir > 0 ? -8 : 8) + 'px)';
setTimeout(() => {
frame.style.transform = '';
setTimeout(() => { frame.style.transition = ''; }, 170);
}, 150);
}
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;
const frame = swipeFrameEl();
const fade = frame && !reducedMotion();
if (fade) {
frame.style.transition = 'opacity 90ms ease';
frame.style.opacity = '0.28';
await later(90);
}
tabs[j].click();
await later(70);
if (fade) {
frame.style.transition = 'opacity 150ms ease';
frame.style.opacity = '1';
setTimeout(() => { frame.style.transition = ''; frame.style.opacity = ''; }, 190);
}
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) {
const dir = swipeAcc < 0 ? 1 : -1;
swipeAcc = 0;
swipeSwitch(dir);
}
}, { passive: true });
if (!swipeDocBound) {
swipeDocBound = true;
// 滑动后吞掉紧随的一次 click,避免误开卡片里的链接
document.addEventListener('click', (evt) => {
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(/\/+$/, '') || '/';
@ -631,6 +800,7 @@
seedWorkspacePane();
hideInstallEntry();
mountFooter();
bindTabSwipe();
}
if (document.readyState === 'loading') {