vue移动端开发长按对话复制功能

效果:对话框长按弹出弹框,里面有按钮,可以增加功能

检测对话框长按

  • 要额外考虑按住对话框滑动对话列表的情况
  • 封装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) // 清理
}
相关推荐
AAA阿giao2 小时前
深入理解 JavaScript 的 Array.prototype.map() 方法及其经典陷阱:从原理到面试实战
前端·javascript·面试
excel2 小时前
HBuilderX 配置 adb.exe + 模拟器端口一体化完整指南
前端
拖拉斯旋风3 小时前
与 AI 协作的新范式:以文档为中心的开发实践
前端
dualven_in_csdn3 小时前
【electron】解决CS里的全屏问题
前端·javascript·electron
库克表示3 小时前
MessageChannel-通信机制
前端
拖拉斯旋风3 小时前
深入理解 Ajax:从原理到实战,附大厂高频面试题
前端·ajax
用户4099322502123 小时前
Vue 3响应式系统的底层机制:Proxy如何实现依赖追踪与自动更新?
前端·ai编程·trae
却尘3 小时前
一个"New Chat"按钮,为什么要重构整个架构?
前端·javascript·next.js
ERIC_s3 小时前
记一次 Next.js + K8s + CDN 缓存导致 RSC 泄漏的排查与修复
前端·react.js·程序员