一、背景
在日常的内容编辑、学习笔记、阅读平台中,我们经常希望用户选中一段文字后,能立刻进行操作,比如:
📋 一键复制选中内容
⭐ 收藏重点段落
➕ 插入到编辑器中
当选中文字时,都会出现一个悬浮的操作按钮,在选区上方自动显示浮动操作框,支持「插入」、「复制」、「收藏」三种操作,也可自定义扩展。
二、实现思路
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 框架。它基于标准 HTML、CSS 和 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>
六、实现效果

2826

被折叠的 条评论
为什么被折叠?



