效果:对话框长按弹出弹框,里面有按钮,可以增加功能
检测对话框长按
- 要额外考虑按住对话框滑动对话列表的情况
- 封装hook实现
js
// src/hooks/useLongPressBubble.js
import { ref } from 'vue'
export function useLongPressBubble() {
const showBubble = ref(false)
const bubblePosition = ref({ top: 0, left: 0 })
const bubblePlacement = ref('top') // 出现方位(对话框上/下)
let longPressTimer = null
const LONG_PRESS_DELAY = 500 // ms
// 用于判断是否发生移动(按住对话框滑动列表时 不该显示气泡框)
let touchStartX = 0
let touchStartY = 0
const MOVE_THRESHOLD = 10 // 像素,超过即视为滑动
const clearTimer = () => {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
// 处理 touchmove,检测滑动
const onTouchMove = (event) => {
if (!longPressTimer) return // 没有定时器,无需处理
const touch = event.touches[0]
const dx = Math.abs(touch.clientX - touchStartX)
const dy = Math.abs(touch.clientY - touchStartY)
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
// 用户在滑动,取消长按
clearTimer()
}
}
/**
* 开始长按检测(兼容 Touch 和 Mouse)
* @param event - 触发事件(TouchEvent 或 MouseEvent)
*/
const startPress = (event) => {
clearTimer()
const target = event.currentTarget
if (!target) return
// 记录起始位置(仅 touch)
if (event.type.startsWith('touch')) {
const touch = event.touches[0]
touchStartX = touch.clientX
touchStartY = touch.clientY
// 监听 move 和 end
target.addEventListener('touchmove', onTouchMove, { passive: true })
target.addEventListener('touchend', endPress, { passive: true })
target.addEventListener('touchcancel', endPress, { passive: true })
}
longPressTimer = window.setTimeout(() => {
const rect = target.getBoundingClientRect()
const scrollTop = window.scrollY
const scrollLeft = window.scrollX
// 判断元素距离视口顶部的距离
const elementTopFromViewport = rect.top
const GAP = 10 // 气泡与元素的间距
let top, placement
// 如果元素太靠近顶部(比如 < 50px),气泡放下面
if (elementTopFromViewport < 50) {
top = rect.bottom + scrollTop + GAP
placement = 'bottom'
} else {
top = rect.top + scrollTop - 40 - GAP // 气泡高度约 40px
placement = 'top'
}
const left = rect.left + scrollLeft + 10 // 水平偏移
bubblePosition.value = { top, left }
bubblePlacement.value = placement
showBubble.value = true
// 清理事件监听(长按已触发) 判定时间内有滚动,就不认为是长按
cleanupTouchListeners(target)
}, LONG_PRESS_DELAY)
}
/**
* 结束长按(取消定时器)
*/
const endPress = () => {
clearTimer()
}
// 清理 touch 事件监听
const cleanupTouchListeners = (el) => {
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('touchend', endPress)
el.removeEventListener('touchcancel', endPress)
}
const closeBubble = () => {
showBubble.value = false
}
return {
// 状态
showBubble: showBubble,
bubblePosition: bubblePosition,
bubblePlacement: bubblePlacement,
// 方法
startPress,
endPress,
closeBubble,
}
}
气泡框弹出后点击外部关闭气泡
- 之所以不用element-plus自带的v-clickoutside是因为移动端里气泡框出来时,又长按另一个对话框/滚动页面,也要关闭气泡框。而v-clickoutside一般是在目标元素外按下又松开才会触发
js
// src/directives/clickOutside.js
// element-plus里自带的clickoutside需要外部按下并松开才能触发,本命令只用触碰了绑定元素外部就会触发
const handleWrapper = (el, handler) => {
const handleClick = (event) => {
// 如果点击的是元素本身或其子元素,则不触发
if (el.contains(event.target)) {
return
}
handler(event)
}
// 同时监听 mouse 和 touch(兼容移动端)
document.addEventListener('mousedown', handleClick)
document.addEventListener('touchstart', handleClick, { passive: true })
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('touchstart', handleClick)
}
}
export const vClickOutside = {
mounted(el, binding) {
const cleanup = handleWrapper(el, binding.value)
el.__clickOutsideCleanup__ = cleanup
},
unmounted(el) {
const cleanup = el.__clickOutsideCleanup__
if (cleanup) {
cleanup()
delete el.__clickOutsideCleanup__
}
},
}
在vue项目里使用
html
<template>
<div class="chat-list">
<!-- 消息列表 -->
<div
v-for="item in messages"
:key="item.id"
class="message-item"
@touchstart.prevent="startPress($event)"
@mousedown.prevent="startPress($event)"
@contextmenu.prevent
@touchend="endPress"
@mouseup="endPress"
@mouseleave="endPress"
>
{{ item.text }}
<!-- 气泡框 -->
<div
v-if="showBubble"
:class="['bubble', bubblePlacement]"
:style="{ top: bubblePosition.top + 'px', left: bubblePosition.left + 'px' }"
v-click-outside="closeBubble"
@click="copyText"
>
复制
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useLongPressBubble } from '@/hooks/useLongPressBubble'
import {vClickOutside} from '@/directives/clickOutside'
const messages = ref([
{ id: 1, text: '你好啊!' },
{ id: 2, text: '今天过得怎么样?' },
])
const { showBubble, bubblePosition, bubblePlacement, startPress, endPress, closeBubble } =
useLongPressBubble()
// 关闭气泡的函数
const closeBubble = () => {
showBubble.value = false
}
// 局部注册指令(适用于 <script setup>)
defineOptions({
directives: {
ClickOutside: vClickOutside,
},
})
const copyText = async () => {
try {
await navigator.clipboard.writeText(textToCopy.value)
showBubble.value = false
return true
} catch (err) {
console.error('复制失败:', err)
showBubble.value = false
return false
}
}
</script>
<style lang="sass">
.bubble {
--bubble-color: #000;
position: fixed;
background: var(--bubble-color);
color: white;
padding: 6px 12px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
cursor: pointer;
user-select: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
/* 当有 .top 类时,在底部居中显示朝下的三角形 */
&.top::after {
content: '';
position: absolute;
bottom: -5px; /* 根据三角形高度调整,这里假设高为10px */
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--bubble-color);
}
/* 当有 .bottom 类时,在顶部居中显示朝上的三角形 */
&.bottom::after {
content: '';
position: absolute;
top: -5px; /* 根据三角形高度调整 */
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid var(--bubble-color);
}
}
</style>
补充:
- 上面那种复制文字的方式会弹授权弹框,如果不复制复杂内容,用下面这种方式即可
scss
function copyToClipboard(text, successCB) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed' // 避免滚动问题
document.body.appendChild(textarea)
textarea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
messageOpen('复制成功!')
successCB && successCB()
} else {
messageError('本设备不支持复制!')
}
} catch (err) {
messageError('复制失败' + err)
}
document.body.removeChild(textarea) // 清理
}