fix(diy-article, diy-image-nav): 修复H5端拖拽后误触发跳转的问题

1.  重构两个组件的H5端鼠标事件绑定逻辑,改用document级别capture监听解决uni-app自定义元素事件拦截问题
2.  添加拖拽阈值检测,避免点击误触发滚动
3.  新增click拦截逻辑,拖拽结束后自动屏蔽后续一次click事件防止误跳转
4.  优化滚动容器获取方式,兼容uni-app编译后的嵌套DOM结构
This commit is contained in:
2026-07-11 10:03:23 +08:00
parent 75bc8876b5
commit 7a76824fcf
2 changed files with 167 additions and 36 deletions

View File

@@ -10,7 +10,6 @@
<view v-if="list.length > 0 && useScroll"
class="auto-scroll-list"
:style="autoScrollListStyle"
ref="scrollContainer"
@touchstart="onMarqueeTouchStart"
@touchmove.stop.prevent="onMarqueeTouchMove"
@touchend="onMarqueeTouchEnd">
@@ -130,9 +129,15 @@ export default {
// ========== 跑马灯状态(JS 驱动 transform,同时支持用户手动拖拽)==========
// marqueeResumeTimer: touchend 后延迟恢复自动滚动
marqueeResumeTimer: null,
// 用户拖拽状态
// user 拖拽状态
isUserDragging: false,
dragStartY: 0,
// H5 端:标记 mousedown 已成功触发(mousemove 在没有这个标记时忽略)
_mouseDownActive: false,
// H5 端:标记 mousedown 后是否真的拖拽超过 5px 阈值
// 拖拽结束后用来判断 mouseup 后是否需要屏蔽 click(防止 uni-app @tap.stop 误触发跳转)
_mouseDidDrag: false,
_suppressNextClick: false,
dragStartTranslateY: 0,
// ========== 连续滚动模式字段(marqueeMode === 'continuous')==========
// 驱动 transform: translateY 在 A+A 双份内容的 track 上连续滚动
@@ -737,14 +742,31 @@ export default {
// #ifdef H5
// 防止重复绑定(mounted + list.length watcher 都会调用)
if (this._marqueeEventsBound) return;
// 关键:list.length 变化触发 watcher 时,vue 已把 list 改了但 v-if 还没渲染到 DOM,
// $nextTick 也只保证本组件渲染完毕,但 v-if 子树内的 DOM 还在等父级渲染;
// 经验上需要再等一段时间(测试 1000ms 后能找到,vue 异步 + skeleton 内部)才能 querySelector。
var self = this;
setTimeout(function() {
self._doBindMarqueeMouseEvents();
}, 1000);
// #endif
},
_doBindMarqueeMouseEvents() {
// #ifdef H5
if (this._marqueeEventsBound) return;
var el = this._getMarqueeEl();
if (!el) return;
this._marqueeEl = el;
this._marqueeEventsBound = true;
el.addEventListener('mousedown', this.onMarqueeMouseDown);
el.addEventListener('wheel', this.onMarqueeWheel);
// 与 diy-image-nav 同款经验:uni-app 的自定义元素(uni-view)在 H5 下
// 也会拦截/重定向 mousedown 到 HTML,导致绑到 .auto-scroll-list 上的监听器收不到。
// 解决:document 级别 capture + 用 clientX/clientY 判断是否落在容器内。
document.addEventListener('mousedown', this.onMarqueeMouseDown, true);
document.addEventListener('wheel', this.onMarqueeWheel, { passive: false, capture: true });
window.addEventListener('mousemove', this.onMarqueeMouseMove);
window.addEventListener('mouseup', this.onMarqueeMouseUp);
// click 拦截:拖拽后屏蔽接下来的 click(防止 uni-app @tap.stop 误触发跳转)
document.addEventListener('click', this.onMarqueeMaybeClick, true);
// 全局禁用 selectstart,防止拖拽时选中文字
this._suppressSelect = function(ev) {
if (ev && ev.preventDefault) ev.preventDefault();
@@ -754,25 +776,26 @@ export default {
},
unbindMarqueeMouseEvents() {
// #ifdef H5
if (this._marqueeEl) {
this._marqueeEl.removeEventListener('mousedown', this.onMarqueeMouseDown);
this._marqueeEl.removeEventListener('wheel', this.onMarqueeWheel);
this._marqueeEl = null;
}
document.removeEventListener('mousedown', this.onMarqueeMouseDown, true);
document.removeEventListener('wheel', this.onMarqueeWheel, true);
window.removeEventListener('mousemove', this.onMarqueeMouseMove);
window.removeEventListener('mouseup', this.onMarqueeMouseUp);
document.removeEventListener('click', this.onMarqueeMaybeClick, true);
if (this._suppressSelect) {
document.removeEventListener('selectstart', this._suppressSelect);
this._suppressSelect = null;
}
this._marqueeEl = null;
this._marqueeEventsBound = false;
// #endif
},
_getMarqueeEl() {
// #ifdef H5
var ref = this.$refs.scrollContainer;
if (!ref) return null;
var el = ref.$el || ref;
// .auto-scroll-list 是 v-if 渲染的,Vue $refs 不收集。
// x-skeleton 是 web component,querySelector 不能穿透其 shadow DOM,
// 但 .auto-scroll-list 直接在 document 下可被查找到 — 而本组件只有这一个 .auto-scroll-list,
// 不会与其他组件冲突。
var el = document.querySelector('.auto-scroll-list');
if (el && el.nodeType === 1) return el;
return null;
// #endif
@@ -780,21 +803,35 @@ export default {
onMarqueeMouseDown(e) {
// #ifdef H5
if (!this.useScroll) return;
// document 级别监听,用 bounding box 判断是否落在容器内
if (!this._marqueeEl) return;
var rect = this._marqueeEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
this.stopAllMarquee();
this.isUserDragging = false;
this.dragStartY = (e.clientY != null) ? e.clientY : 0;
this._mouseDownActive = true; // 标记 mousedown 成功触发,mousemove 用此开关
this._mouseDidDrag = false; // mousedown 时清零,5px 阈值后才置 true
this._suppressNextClick = false;
this.isUserDragging = false; // 5px 阈值后才进入真正拖动状态
this.dragStartY = y;
this.dragStartTranslateY = this.marqueeMode === 'step' ? this.stepTranslateY : this.continuousTranslateY;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
// #endif
},
onMarqueeMouseMove(e) {
// #ifdef H5
// 关键:必须在 mousedown 成功触发后才响应。
// 否则:hover 状态下的鼠标随意移动,deltaY 累积超过 5px 阈值后会误触发滚动。
if (!this._mouseDownActive) return;
var y = (e.clientY != null) ? e.clientY : 0;
var deltaY = y - this.dragStartY;
if (!this.isUserDragging) {
// 5px 阈值:只有真正滑动时才进入手动滚动,避免点击触发
if (Math.abs(deltaY) < 5) return;
this.isUserDragging = true;
this._mouseDidDrag = true;
}
var newTranslateY = this.dragStartTranslateY - deltaY;
var firstH = this.firstListHeight;
@@ -816,6 +853,11 @@ export default {
document.removeEventListener('selectstart', this._suppressSelect);
this._suppressSelect = null;
}
this._mouseDownActive = false;
// 如果确实拖拽过,标记接下来一次 click 应当被拦截(防止拖拽结束后误触发跳转)
if (this._mouseDidDrag) {
this._suppressNextClick = true;
}
// 复用 touchend 的松手逻辑:纯点击立即恢复,拖拽后 2s 恢复
if (this.marqueeResumeTimer) clearTimeout(this.marqueeResumeTimer);
if (!this.isUserDragging) {
@@ -829,11 +871,32 @@ export default {
}, 2000);
// #endif
},
// click 拦截器:如果 mouseup 设置了 _suppressNextClick,拦截这一次 click 并清除标记。
// 即使 uni-app 把 @tap.stop 编译成 inline onclick,我们的 capture 阶段也能拦截到。
onMarqueeMaybeClick(e) {
// #ifdef H5
if (!this._suppressNextClick) return;
if (!e.target) return;
var el = this._marqueeEl;
if (!el || !el.contains(e.target)) return;
this._suppressNextClick = false;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
// #endif
},
// 鼠标滚轮支持:H5 用户在 PC 浏览器滚动鼠标滚轮时,把 deltaY 转译为 translateY 推动
onMarqueeWheel(e) {
// #ifdef H5
if (!this.useScroll) return;
// document 级别监听,用 bounding box 判断
if (!this._marqueeEl) return;
var rect = this._marqueeEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
var dy = (e.deltaY != null) ? e.deltaY : 0;
if (dy === 0) return;
// 滚轮向下 → 内容向上滚 → translateY 增加

View File

@@ -1,8 +1,7 @@
<template>
<view :style="componentStyle" data-component-name="diy-image-nav">
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
:scroll-x="value.showStyle == 'singleSlide'"
ref="scrollContainer">
:scroll-x="value.showStyle == 'singleSlide'">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
style="margin-right: 28rpx;">
@@ -58,8 +57,10 @@ export default {
swiperCurrent: 0,
// H5 鼠标拖拽状态(singleSlide 模式专用)
slideIsDragging: false,
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
slideStartX: 0,
slideStartScrollLeft: 0
slideStartScrollLeft: 0,
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
}
},
created() {
@@ -152,49 +153,88 @@ export default {
// #ifdef H5
// 防止重复绑定
if (this._slideEventsBound) return;
var el = this.getSlideContainer();
if (!el) return;
// 容器上绑定 mousedown/wheel;mouseup/mousemove 挂在 window,避免鼠标移出容器后丢失
this._slideEl = el;
var outerEl = this.getSlideOuterEl();
var innerEl = this.getSlideContainer();
if (!outerEl || !innerEl) return;
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
this._slideOuterEl = outerEl;
this._slideEl = innerEl;
this._slideEventsBound = true;
el.addEventListener('mousedown', this.onSlideMouseDown);
el.addEventListener('wheel', this.onSlideWheel);
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
document.addEventListener('mousedown', this.onSlideMouseDown, true);
document.addEventListener('wheel', this.onSlideWheel, { passive: false, capture: true });
// click 拦截:如果在 mousemove 中真的拖动了容器(mousedown 后 deltaX > 5),
// mouseup 时进入"忽略接下来一次 click"状态,防止拖拽结束后误触发跳转。
document.addEventListener('click', this.onSlideMaybeClick, true);
window.addEventListener('mousemove', this.onSlideMouseMove);
window.addEventListener('mouseup', this.onSlideMouseUp);
// #endif
},
unbindSlideMouseEvents() {
// #ifdef H5
if (this._slideEl) {
this._slideEl.removeEventListener('mousedown', this.onSlideMouseDown);
this._slideEl.removeEventListener('wheel', this.onSlideWheel);
this._slideEl = null;
}
document.removeEventListener('mousedown', this.onSlideMouseDown, true);
document.removeEventListener('wheel', this.onSlideWheel, true);
document.removeEventListener('click', this.onSlideMaybeClick, true);
window.removeEventListener('mousemove', this.onSlideMouseMove);
window.removeEventListener('mouseup', this.onSlideMouseUp);
this._slideOuterEl = null;
this._slideEventsBound = false;
// #endif
},
getSlideContainer() {
// #ifdef H5
var ref = this.$refs.scrollContainer;
if (!ref) return null;
// uni-app 中 $refs 对应组件实例,scroll-view 编译为 div 后,可通过 $el 拿到原生 DOM
var el = ref.$el || ref;
// 兼容:部分版本直接返回 DOM 节点
if (el && el.nodeType === 1) return el;
return null;
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
// 嵌套结构:
// <uni-scroll-view> ← 自定义元素
// <div class="uni-scroll-view"> ← 第1层,visible
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
// 真正能响应 scrollLeft 的是第 2 层 div。
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
if (!componentRoot) return null;
// 找 overflowX:auto 的 div.uni-scroll-view
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
for (var i = 0; i < candidates.length; i++) {
var s = getComputedStyle(candidates[i]).overflowX;
if (s === 'auto' || s === 'scroll') {
return candidates[i];
}
}
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
// #endif
},
getSlideOuterEl() {
// #ifdef H5
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
if (!componentRoot) return null;
var sc = componentRoot.querySelector('uni-scroll-view');
return sc || null;
// #endif
},
onSlideMouseDown(e) {
// #ifdef H5
if (this.value.showStyle != 'singleSlide') return;
// document 级别监听,只处理落在容器内的事件
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
if (!this._slideOuterEl) return;
var rect = this._slideOuterEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
var el = this.getSlideContainer();
if (!el) return;
this.slideIsDragging = true;
this.slideStartX = (e.clientX != null) ? e.clientX : 0;
this.slideStartX = x;
this.slideStartScrollLeft = el.scrollLeft;
this.slideDidDrag = false; // mousedown 时清零:5px 阈值后才置 true
this._suppressNextClick = false;
if (e.preventDefault) e.preventDefault();
// 注意:这里不调 stopPropagation,否则纯点击也不能触发 click/tap → 跳转
// #endif
},
onSlideMouseMove(e) {
@@ -204,6 +244,10 @@ export default {
if (!el) return;
var x = (e.clientX != null) ? e.clientX : 0;
var deltaX = x - this.slideStartX;
// 5px 阈值:超过后标记已经拖动,后续 mouseup 会忽略接下来一次 click
if (!this.slideDidDrag && Math.abs(deltaX) >= 5) {
this.slideDidDrag = true;
}
// 反向:手指/鼠标往右拖 → 内容向右滚 → scrollLeft 减小
el.scrollLeft = this.slideStartScrollLeft - deltaX;
if (e.preventDefault) e.preventDefault();
@@ -212,11 +256,34 @@ export default {
onSlideMouseUp() {
// #ifdef H5
this.slideIsDragging = false;
// 如果确实拖动过,标记接下来一次 click 应当被拦截(防止拖拽结束后误触发跳转)
if (this.slideDidDrag) {
this._suppressNextClick = true;
}
// #endif
},
// click 拦截:如果 mouseup 时设置了 _suppressNextClick,就拦截这一次 click 并清除标记
onSlideMaybeClick(e) {
// #ifdef H5
if (!this._suppressNextClick) return;
if (!e.target) return;
var outer = this._slideOuterEl;
if (!outer || !outer.contains(e.target)) return;
this._suppressNextClick = false;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
// #endif
},
onSlideWheel(e) {
// #ifdef H5
if (this.value.showStyle != 'singleSlide') return;
if (!this._slideOuterEl) return;
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
var rect = this._slideOuterEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
var el = this.getSlideContainer();
if (!el) return;
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
@@ -226,6 +293,7 @@ export default {
var step = dx !== 0 ? dx : dy;
el.scrollLeft += step;
if (e.preventDefault) e.preventDefault();
if (e.stopPropagation) e.stopPropagation();
// #endif
}
}