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) // 清理
}
相关推荐
佛系打工仔21 小时前
绘制K线第二章:背景网格绘制
android·前端·架构
明天好,会的1 天前
分形生成实验(五):人机协同破局--30万token揭示Actix-web状态管理的微妙边界
运维·服务器·前端
C_心欲无痕1 天前
nginx - alias 和 root 的区别详解
运维·前端·nginx
我是苏苏1 天前
Web开发:C#通过ProcessStartInfo动态调用执行Python脚本
java·服务器·前端
无羡仙1 天前
Vue插槽
前端·vue.js
用户6387994773051 天前
每组件(Per-Component)与集中式(Centralized)i18n
前端·javascript
SsunmdayKT1 天前
React + Ts eslint配置
前端
开始学java1 天前
useEffect 空依赖 + 定时器 = 闭包陷阱?count 永远停在 1 的坑我踩透了
前端
zerosrat1 天前
从零实现 React Native(2): 跨平台支持
前端·react native