用 Vue 写一个选中文本高亮、分享菜单

1,892 阅读3分钟

最近闲来无事写了个组件,达到了这样的效果:

大概讲一下具体是怎么写的吧。文中出现的 popover 以及菜单都是描述这个组件……我不大知道如何称呼这种东西。

初始化

我使用了一个叫做 vue-sfc-rollup 的工具初始化 SFC。它是这么描述自己的:

Quickly generate redistributable Vue components with Rollup

也就是说:

使用 Rollup 快速生成可再使用的 Vue 组件

如果你也想用这个,先全局安装一下:

# 全局安装
npm install -g vue-sfc-rollup
sfc-init

或者如果你只是一次性使用就 npx 一下好了:

# 不用安装
npx vue-sfc-rollup

然后:

# cd 到目录
cd path/to/my-component-or-lib
npm install

# serve
npm run serve

# build
npm run build

# publish 吧!

使用的时候会有一些选项,如果你想按着这篇文章的步骤来实现这么一个划词菜单,那么这么选:

✔ Which version of Vue are you writing for? › Vue 2Is this a single component or a library? › Single Component
✔ What is the npm name of your component? … foobar
✔ What is the kebab-case tag name for your component? … foo-bar
✔ Will this component be written in JavaScript or TypeScript? › JavaScript
✔ Enter a location to save the component files: … ./foo-bar

选完了之后,应该就会得到这样一个目录:

➜  tut ll
total 16
-rw-r--r--  1 admin  staff   414B Jun  6 19:18 babel.config.js
drwxr-xr-x  3 admin  staff    96B Jun  6 19:18 build
drwxr-xr-x  4 admin  staff   128B Jun  6 19:18 dev
-rw-r--r--  1 admin  staff   1.4K Jun  6 19:18 package.json
drwxr-xr-x  5 admin  staff   160B Jun  6 19:18 src

再执行 npm install 或者 yarn,装依赖,得到 node_modules 文件夹。

代码

在 src 目录下,有这么几个文件:

➜  src ll
total 24
-rw-r--r--  1 admin  staff   721B  6 Jun 19:18 entry.esm.js
-rw-r--r--  1 admin  staff   465B  6 Jun 19:18 entry.js
-rw-r--r--  1 admin  staff   1.6K  6 Jun 19:18 foo-bar.vue

其中,entry.js 以及 entry.esm.js 这两个文件不用管,只要看 foo-bar.vue,因为它就是我们的组件。

点进去,你会发现里面已经有了内容。但是这个内容是可以删掉的,因为它就是一个默认生成的 counter 组件。删掉之后,输入如下内容:

<template>
	
</template>

<script>
export default {

}
</script>

<style>

</style>

这就是我们的大纲了。现在,就要实现选中文本菜单功能了。

菜单

先写 template 里头需要的代码:

<template>
    <div>
        <div
            v-show="showMenu"
            class="popover"
        >
            <span class="item">
                Share
            </span>
            <span class="item">
                Highlight
            </span>
            <!-- 更多按钮 -->
        </div>
        
        <!-- 文字 -->
        <slot />
    </div>
</template>

在这里,我在 v-show 后面使用了 showMenu,但是我们还没有创建它,所以现在在 script 里头把它写出来:

<script>
export default {
    data () {
        return {
            x: 0,
            y: 0,
            showMenu: false,
            selectedText: ''
        }
    }
}
</script>

其中:

  • x 以及 y 是菜单的位置
  • showMenu 决定菜单开关
  • selectedText 就是选中的文本

然后,移步 computed

computed: {
    highlightableEl () {
        return this.$slots.default[0].elm
    }
}

highlightableEl 定义了可以出现菜单的范围。也就是说,规定了在 <VueSelectionShare> 以及 </VueSelectionShare> 里头可以使用。

然后,加入 mounted 以及 beforeDestroy 钩子:

mounted () {
    window.addEventListener('mouseup', this.onMouseup)
},

beforeDestroy () {
    window.removeEventListener('mouseup', this.onMouseup)
}

这些是用来监听在 onMouseup 方法中处理的 mouseup 事件的。

然后,创建 onMouseup 方法:

onMouseup () {
    const selection = window.getSelection()
    const selectionRange = selection.getRangeAt(0)

    // startNode 是选取开始的元素
    const startNode = selectionRange.startContainer.parentNode
    // endNode 是选取结束的元素
    const endNode = selectionRange.endContainer.parentNode

    // 如果选中部分不在 <VueSelectionShare> 里头
    // 或者
    // 如果 startNode !== endNode
    // 不显示菜单
    if (!startNode.isSameNode(this.highlightableEl) || !startNode.isSameNode(endNode)) {
            this.showMenu = false
            return
    }

    // 获取选中文本的 x, y, width
    const { x, y, width } = selectionRange.getBoundingClientRect()

    // 如果 width === 0,隐藏菜单
    if (!width) {
            this.showMenu = false
            return
    }

    // 设置菜单的位置
    // 设置 selectedText 设置成选中的文本
    // 显示菜单
    this.x = x + (width / 2)
    this.y = y + window.scrollY - 10
    this.selectedText = selection.toString()
    this.showMenu = true
},

这里注释都解释得很清楚了,就不多说了。

然后,更改 template 内容:

<template>
    <div>
        <div
            v-show="showMenu"
            class="popover"
            :style="{
                left: `${x}px`,
                top: `${y}px`
            }"
            @mousedown.prevent=""
        >
            <span
                class="item"
                @mousedown.prevent="handleAction('share')"
            >
                Share
            </span>
            <span
                class="item"
                @mousedown.prevent="handleAction('highlight')"
            >
                Highlight
            </span>
            <!-- more buttons here -->
        </div>

        <!-- insterted text is displayed here -->
        <slot />
    </div>
</template>

主要改动:

  • x, y 应用于菜单
  • 向菜单添加了 @mousedown.prevent="",防止在单击菜单内部时关闭他就自己关掉了
  • 在按钮上添加了@mousedown.prevent="handleAction('share')" 来处理点击。

有些细心的人可能要问了,为什么用 mousedown 不用 click 呢?——主要是为了防止文本被取消选择。这样一来菜单就不会直接被关掉了。

最后,添加 handleAction 方法:

handleAction (action) {
    this.$emit(action, this.selectedText)
}

当然不要忘了 style

.popover {
    height: 30px;
    padding: 5px 10px;
    background: #333;
    border-radius: 3px;
    position: absolute;
    top: 0;
    left: 0;
    transform: translate(-50%, -100%);
    transition: 0.2s all;
    display: flex;
    justify-content: center;
    align-items: center;
}

.popover:after {
    content: '';
    position: absolute;
    left: 50%;
    bottom: -5px;
    transform: translateX(-50%);
    width: 0;
    height: 0;
    border-left: 6px solid transparent;
    border-right: 6px solid transparent;
    border-top: 6px solid #333;
}

.item {
    /* hover 前颜色 */
    color: #FFF;
    cursor: pointer;
}

.item:hover {
    /* hover 后颜色(粉色) */
    color: #ff69b4;
}

.item + .item {
    margin-left: 10px;
}

这个就不多作解释了。


到这里就结束了。如果有帮到你,记得给我的 repo 点个 star。