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:
18
web/navi.css
18
web/navi.css
@ -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;
|
||||
}
|
||||
|
||||
170
web/navi.js
170
web/navi.js
@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user