Vue3+TS 实现页面选中文字后弹出悬浮操作按钮(插入 / 复制 / 收藏)

一、背景

在日常的内容编辑、学习笔记、阅读平台中,我们经常希望用户选中一段文字后,能立刻进行操作,比如:

📋 一键复制选中内容

⭐ 收藏重点段落

➕ 插入到编辑器中

当选中文字时,都会出现一个悬浮的操作按钮,在选区上方自动显示浮动操作框,支持「插入」、「复制」、「收藏」三种操作,也可自定义扩展。

二、实现思路

1.通过 container-selector=“.container” 限定监听范围,仅对绑定的页面class元素内的文字选中事件生效。
2.通过 window.getSelection() 获取用户选中的文本;
3.通过 Range.getClientRects() 计算选中文字的坐标位置;
4.通过 Teleport 将操作面板渲染到 body 中,避免被父级 overflow 或定位影响;
5.根据坐标动态设置悬浮按钮位置;
6.点击外部区域自动隐藏操作框;
7.触发父组件事件(insert / copy / collect)进行后续逻辑。

三、实现细节

1.获取选中文字 window.getSelection() 可以获取用户在页面中选中的文本对象。

const selection = window.getSelection();
const text = selection?.toString().trim();

2.计算选中文字位置 通过选区的 range 获取文字的矩形位置,用于定位悬浮按钮。

const range = selection!.getRangeAt(0);
const rect = range.getClientRects()[0];

监听全局鼠标事件
鼠标抬起:检测是否选中文字。
点击外部:关闭操作面板。

document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('click', handleClickOutside);

四、完整代码

子组件:TextSelectionActions.vue

<template>
  <teleport to="body">
    <div v-if="showInsertButton" class="insert-button" :style="{ top: insertButtonPosition.top + 'px', left: insertButtonPosition.left + 'px' }">
      <slot>
        <span class="insert-action" v-if="showInsert" @click.stop="handleInsertClick">
          <el-icon><Plus /></el-icon> 插入
        </span>
        <span class="insert-action" v-if="showCopy" @click.stop="handleCopyClick">
          <el-icon><DocumentCopy /></el-icon> 复制
        </span>
        <span class="insert-action" v-if="showCollect" @click.stop="handleCollectClick">
          <el-icon><Star /></el-icon> 收藏
        </span>
      </slot>
    </div>
  </teleport>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { ElMessage } from 'element-plus/es';
interface Props {
  containerSelector: string; // 要监听文本选中的容器选择器(必填)
  showInsert?: boolean; // 是否显示“插入”按钮
  showCopy?: boolean; // 是否显示“复制”按钮
  showCollect?: boolean; // 是否显示“收藏”按钮
}

// 设置默认值
const props = withDefaults(defineProps<Props>(), {
  showInsert: true,
  showCopy: true,
  showCollect: true
});

// 定义事件(用于向父组件传递选中文字)
const emit = defineEmits<{
  insert: [text: string]; // 插入事件,参数是选中的文字
  copy: [text: string]; // 复制事件
  collect: [text: string]; // 收藏事件
}>();

// --------------------- 状态变量定义 ---------------------
const showInsertButton = ref(false); // 是否显示浮动按钮框
const selectedText = ref(''); // 当前选中的文字
const insertButtonPosition = reactive({ top: 0, left: 0 }); // 按钮框的位置(绝对定位)

const copyText = (text: string) => {
  if (!text) {
    ElMessage.warning('没有可复制的内容');
    return;
  }

  const textarea = document.createElement('textarea');
  textarea.value = text;
  textarea.style.position = 'fixed';
  textarea.style.opacity = '0';
  document.body.appendChild(textarea);
  textarea.select();
  try {
    document.execCommand('copy');
    ElMessage.success('复制成功');
  } catch (err) {
    ElMessage.error('复制失败');
  }
  document.body.removeChild(textarea);
};

// --------------------- 核心逻辑:选中文字后显示按钮 ---------------------
const handleMouseUp = (event: MouseEvent) => {
  // 当鼠标抬起时触发
  const container = document.querySelector(props.containerSelector); // 获取目标容器
  const buttonEl = document.querySelector('.insert-button'); // 获取当前浮动按钮元素

  // 如果鼠标点在按钮上,则不处理(防止选中后点击按钮时消失)
  if (buttonEl && buttonEl.contains(event.target as Node)) return;

  // 如果鼠标不在容器区域内,隐藏按钮
  if (!container || !container.contains(event.target as Node)) {
    showInsertButton.value = false;
    return;
  }

  // 获取当前用户选中的文本内容
  const selection = window.getSelection();
  const text = selection?.toString().trim(); // 去掉前后空格
  if (!text) {
    // 没有选中文字则隐藏按钮
    showInsertButton.value = false;
    return;
  }

  // 保存选中的文字
  selectedText.value = text;

  // 通过选区位置计算按钮应显示的位置
  setTimeout(() => {
    // 延迟执行,确保选区渲染完成
    const range = selection!.getRangeAt(0); // 获取选区范围
    const rects = range.getClientRects(); // 获取选中文字的矩形区域
    const rect = rects[0] ?? range.getBoundingClientRect(); // 优先取第一个矩形

    // 计算浮动按钮的位置(在选中区域上方)
    insertButtonPosition.top = rect.top + window.scrollY - 60; // 向上偏移 60 像素
    insertButtonPosition.left = rect.left + window.scrollX + 70; // 向右偏移 70 像素

    showInsertButton.value = true; // 显示按钮
  }, 0);
};

// --------------------- 点击外部关闭按钮框 ---------------------
const handleClickOutside = (event: MouseEvent) => {
  const target = event.target as Node;
  const buttonEl = document.querySelector('.insert-button');

  if (buttonEl && buttonEl.contains(target)) {
    return; // 如果点击的是按钮本身,不关闭
  }

  showInsertButton.value = false; // 否则关闭按钮框
};

// --------------------- 点击各操作按钮 ---------------------
const handleInsertClick = () => {
  // 点击“插入”
  emit('insert', selectedText.value); // 触发父组件事件
  showInsertButton.value = false; // 隐藏按钮框
};

const handleCopyClick = async () => {
  // 点击“复制”
  try {
    copyText(selectedText.value); // 调用工具函数复制文字
    emit('copy', selectedText.value); // 触发复制事件
  } catch (err) {
    console.error('复制失败', err);
  }
  showInsertButton.value = false; // 操作完成后隐藏按钮
};

const handleCollectClick = () => {
  // 点击“收藏”
  emit('collect', selectedText.value); // 触发收藏事件
  showInsertButton.value = false; // 隐藏按钮框
};

// --------------------- 生命周期 ---------------------
onMounted(() => {
  // 在组件挂载后监听全局事件
  document.addEventListener('mouseup', handleMouseUp); // 鼠标抬起检测选中文字
  document.addEventListener('click', handleClickOutside); // 点击外部关闭
});

onBeforeUnmount(() => {
  // 组件卸载前移除事件监听,防止内存泄漏
  document.removeEventListener('mouseup', handleMouseUp);
  document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.insert-button {
  position: absolute;
  z-index: 9999;
  display: flex;
  gap: 12px;
  padding: 6px 10px;
  background-color: white;
  border: 1px solid #ddd;
  border-radius: 6px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transform: translateX(-50%);
}

.insert-action {
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  transition: background-color 0.2s;
  user-select: none;

  display: inline-flex;
  align-items: center;
  justify-content: center; /* 水平居中 */
  gap: 4px; /* 图标和文字间距 */
}

.insert-action:hover {
  background-color: #409eff;
  color: #fff;
}
</style>

五、父组件使用示例

<template>
  <div class="container">
    Vue 是一款用于构建用户界面的 JavaScript 框架。它基于标准 HTMLCSS 和 JavaScript
    构建,并提供了一套声明式的、组件化的编程模型,帮助你高效地开发用户界面。无论是简单还是复杂的界面,Vue 都可以胜任。
  </div>
  <text-selection-actions container-selector=".container" @insert="insertText" @copy="copyText" @collect="collectText" />
</template>
<script lang="ts" setup>
import TextSelectionActions from '@/components/TextSelectionActions/index.vue';

const insertText = (text: string) => {
  console.log('%c [ 插入text ]-9', 'font-size:13px; background:pink; color:#bf2c9f;', text);
};
const copyText = (text: string) => {
  console.log('%c [ 复制回调text ]-9', 'font-size:13px; background:pink; color:#bf2c9f;', text);
};
const collectText = (text: string) => {
  console.log('%c [ 收藏text ]-9', 'font-size:13px; background:pink; color:#bf2c9f;', text);
};
</script>

六、实现效果

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值