diff --git a/web/navi.css b/web/navi.css index a81baf3..35e9fc9 100644 --- a/web/navi.css +++ b/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; +} diff --git a/web/navi.js b/web/navi.js index 7ef04bb..3d4b091 100644 --- a/web/navi.js +++ b/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(); + // 契约自检:标签匹配不到就在 上留标记(可编程检查,不刷控制台) + 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); + }); + } + } + // 当前视图标记到 上,供 navi.css 针对工作空间做「控件收边」。 function markView() { const p = location.pathname.replace(/\/+$/, '') || '/'; @@ -631,6 +800,7 @@ seedWorkspacePane(); hideInstallEntry(); mountFooter(); + bindTabSwipe(); } if (document.readyState === 'loading') {