编辑器组件封装(一)
发表于:2023-09-19 |

前言

本篇文章将讲解我对于一个富文本编辑器的简单封装,我项目代码以及简单操作文档我放在了我的git仓库https://gitee.com/guJyang/chat-input-next

初始化

创建vite-vue项目

这个不多阐述了

修改style.css样式

清空默认样式,改成如下代码:

1
2
3
4
*,body,html{
margin:0;
padding:0;
}

修改目录结构并完成初始化

  1. 删除HelloWorld.vue
  2. 创建chat-input-next.vue文件
    写一个div,开启contenteditable属性,然后定义开放一个cusStyle,允许父级传样式下来,然后通过一个class类,定义初始样式
    通过下面这段代码允许我们的组件导出名,为后续发包做准备
    1
    2
    3
    4
    5
    <script lang="ts">
    export default {
    name: "ChatInputNext",
    };
    </script>
    添加代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    <template>
    <div contenteditable :style="{...cusStyle}" class="chat-input-next">

    </div>
    </template>
    <script lang="ts">
    export default {
    name: "ChatInputNext",
    };
    </script>
    <script lang="ts" setup>
    import { CSSProperties,withDefaults } from 'vue';
    type InlineStyles = Partial<CSSProperties>;
    withDefaults(defineProps<{
    cusStyle?:InlineStyles
    }>(),{

    });
    </script>
    <style scoped>
    .chat-input-next{
    width:500px;
    height:200px;
    }
    </style>
  3. App.vue中引入组件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <script setup lang="ts">
    import ChatInputNext from './components/chat-input-next.vue'
    </script>

    <template>
    <div class="chat-input-next-container">
    输入框组件:
    <ChatInputNext :cusStyle="{width:'80vw',height:'50vh',border:'1px solid #d3d3d3'}"></ChatInputNext>
    </div>
    </template>

    <style scoped>
    .chat-input-next-container{
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    width: 100vw;
    height: 100vh;
    background-color: #f5f5f5;
    }
    </style>

效果图

去除聚焦时的黑色粗线并且添加placeholder

在contenteditable属性下,当我们聚焦的时候,会出现一个黑色的粗线,这个是浏览器默认的样式,我们需要去除掉
效果图
在css中添加以下代码去除outline,并且在div中添加placeholder属性

css

1
2
3
4
5
6
7
[contenteditable]:empty:before {
content: attr(placeholder);
color: #ccc;
}
[contenteditable] {
outline: 0px solid transparent;
}

html

1
2
3
4
5
<template>
<div contenteditable :style="{...cusStyle}" class="chat-input-next" placeholder="请输入内容">

</div>
</template>

此时就有了以下的样式效果
效果图

支持插入图片

修改样式

首先我们给编辑器加个padding,让内容看起来不会贴边

1
2
3
4
5
.chat-input-next{
width:500px;
height:200px;
padding:5px;
}

然后我们适当修改下App.vue,添加一个添加图片的按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<script setup lang="ts">
import ChatInputNext from './components/chat-input-next.vue'
</script>

<template>
<div class="chat-input-next-container">
输入框组件:
<div class="op-line">
<input type="file">
</div>
<ChatInputNext :cusStyle="{width:'80vw',height:'50vh',border:'1px solid #d3d3d3'}"></ChatInputNext>
</div>
</template>

<style scoped>
.chat-input-next-container{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap:10px;
width: 100vw;
height: 100vh;
background-color: #f5f5f5;
}
.op-line{
display: flex;
flex-direction: row;
align-items: center;
gap:10px;
width:80vw
}
</style>

效果图

拿到图片的file对象

给input添加change事件

1
<input type="file"  @change="handleFileChange">

js中监听并获取file

1
2
3
4
5
6
7
const handleFileChange = (e:Event)=>{
const target=e.target as HTMLInputElement
if(target.files&&target.files.length>0){
const file=target.files[0]
console.log(file)
}
}

此时,我们就可以拿到我们的图片的file了
效果图

处理编辑器插入图片事件

此时,我们要做的就是要给我们的编辑器插入图片

子组件暴露方法父组件调用

父组件

定义ref然后调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script setup lang="ts">
import {ref} from "vue"
import ChatInputNext from './components/chat-input-next.vue'
const chatInputNext=ref()
const handleFileChange = (e:Event)=>{
const target=e.target as HTMLInputElement
if(target.files&&target.files.length>0){
const file=target.files[0]
chatInputNext.value&&chatInputNext.value.insertFile(file)
}
}
</script>

<template>
<div class="chat-input-next-container">
输入框组件:
<div class="op-line">
<input type="file" @change="handleFileChange">
</div>
<ChatInputNext :cusStyle="{width:'80vw',height:'50vh',border:'1px solid #d3d3d3'}" ref="chatInputNext"></ChatInputNext>
</div>
</template>
<style scoped>
.chat-input-next-container{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap:10px;
width: 100vw;
height: 100vh;
background-color: #f5f5f5;
}
.op-line{
display: flex;
flex-direction: row;
align-items: center;
gap:10px;
width:80vw
}
</style>

子组件

使用defineExpose暴露方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<template>
<div contenteditable :style="{...cusStyle}" class="chat-input-next" placeholder="请输入内容">

</div>
</template>
<script lang="ts">
export default {
name: "ChatInputNext",
};
</script>
<script lang="ts" setup>
import { CSSProperties,withDefaults,defineExpose } from 'vue';
type InlineStyles = Partial<CSSProperties>;
withDefaults(defineProps<{
cusStyle?:InlineStyles
}>(),{

});

const insertFile=(file:File)=>{
console.log(file)
}

defineExpose({
insertFile
})
</script>
<style scoped>
.chat-input-next{
width:500px;
height:200px;
padding:5px;
}

[contenteditable]:empty:before {
content: attr(placeholder);
color: #ccc;
}
[contenteditable] {
outline: 0px solid transparent;
}
</style>

生成图片url

我们需要将图片转成url,然后插入到编辑器中
这里的createObjectURL方法大家可以参考文档
https://developer.mozilla.org/zh-CN/docs/Web/API/URL/createObjectURL_static

通过这个方法之后,会返回一个blob对象的url

1
2
3
4
5
const insertFile=(file:File)=>{
const URL = window.URL || window.webkitURL;
const url = URL.createObjectURL(file);
console.log(url)
}

效果图

当然,这里我还定义了一个方法insertUrl,你可以通过提前上传图片得到oss地址等方式传入

传入图片url

子组件定义这个方法,并且导出,父组件进行调用,和上面的insertFile一样,这里就不过多阐述了

1
2
3
4
5
6
7
const insertUrl=(url:string,type:'img'|'file'|'aov'='img')=>{
console.log(url,type)
}

defineExpose({
insertUrl
})

插入光标

这里我们需要插入光标,不然图片会插入到最后,我们需要插入到光标处,这里使用的核心逻辑是通过window.getSelection()获取到光标对象,然后通过getRangeAt(0)获取到光标对象的range,然后通过range.insertNode()插入到光标处
参考文档:

  1. https://developer.mozilla.org/zh-CN/docs/Web/API/Window/getSelection
  2. https://developer.mozilla.org/zh-CN/docs/Web/API/Selection/getRangeAt
src下新建文件夹hooks,然后新建useInput.ts

注:

  • editorKey是div的id
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    export const useInput = ({
    editorKey,
    }: {
    editorKey: string;
    }) => {
    // 保证光标在最后
    const keepCursorEnd = (isReturn: boolean) => {
    const curEditor = document.getElementById(editorKey);
    if (window.getSelection && curEditor) {
    curEditor.focus();
    const sel = window.getSelection(); // 创建range
    if(sel){
    sel.selectAllChildren(curEditor); // range 选择obj下所有子内容
    sel.collapseToEnd(); // 光标移至最后
    }
    if (isReturn) return sel;
    }
    };

    // 插入内容
    const insertContent = (html: string) => {
    let sel, range;
    const curEditor = document.getElementById(editorKey);
    if (window.getSelection && curEditor) {
    sel = window.getSelection();
    if (sel && sel.rangeCount) range = sel.getRangeAt(0);
    if (!range) {
    // 如果div没有光标,则在div内容末尾插入
    range = keepCursorEnd(true)?.getRangeAt(0);
    } else {
    const contentRange = document.createRange();
    contentRange.selectNode(curEditor);
    // 对比range,检查光标是否在输入范围内
    const compareStart = range?.compareBoundaryPoints(
    Range.START_TO_START,
    contentRange
    );
    const compareEnd = range?.compareBoundaryPoints(
    Range.END_TO_END,
    contentRange
    );
    const compare = compareStart !== -1 && compareEnd !== 1;
    // 如果不在可输入范围内,则在div内容末尾插入
    if (!compare) range = keepCursorEnd(true)?.getRangeAt(0);
    }
    const inputRange=range as Range
    const inputSel=sel as Selection
    const input = inputRange.createContextualFragment(html);
    const lastNode = input.lastChild; // 记录插入input之后的最后节点位置
    inputRange.insertNode(input);
    if (lastNode) {
    // 如果有最后的节点
    range = inputRange.cloneRange();
    range.setStartAfter(lastNode);
    range.collapse(true);
    inputSel.removeAllRanges();
    inputSel.addRange(range);
    }
    }
    };

    return {
    insertContent,
    };
    };

测试插入一段html

修改下我们的组件,在这里我把图片直接插入进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<template>
<div contenteditable :style="{...cusStyle}" class="chat-input-next" placeholder="请输入内容" id="chat-input-next-editor">

</div>
</template>
<script lang="ts">
export default {
name: "ChatInputNext",
};
</script>
<script lang="ts" setup>
import { CSSProperties,withDefaults,defineExpose } from 'vue';
import { useInput } from "../hooks/useInput";

type InlineStyles = Partial<CSSProperties>;
withDefaults(defineProps<{
cusStyle?:InlineStyles
}>(),{

});

const insertFile=(file:File)=>{
const URL = window.URL || window.webkitURL;
const url = URL.createObjectURL(file);
console.log(url)
insertContent(`<img src="${url}" alt=""/>`)
}

const insertUrl=(url:string,type:'img'|'file'|'aov'='img')=>{
console.log(url,type)
}

const {insertContent} =useInput({
editorKey: "chat-input-next-editor",
})


defineExpose({
insertFile,
insertUrl
})
</script>
<style scoped>
.chat-input-next{
width:500px;
height:200px;
padding:5px;
}

[contenteditable]:empty:before {
content: attr(placeholder);
color: #ccc;
}
[contenteditable] {
outline: 0px solid transparent;
}
</style>

此时我们可以看到我们的图片已经可以正常插入了,只是大小有点问题,我们需要调整下

定义insertImage

这里我们定义一个InsertImage方法,用来插入图片,使用imgMap来存储图片的file对象,这样我们就可以在最后提交的时候,拿到所有的图片file对象,然后将insertImage方法暴露出去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
 const imgMap=ref({} as {[key:string]:File})
// 插入图片格式
const insertImage = (url: string, file: File) => {
const img = new Image();
// 在这里插入图片
const imgSrc = url;
const imgElement = document.createElement("img") as any;
imgElement.src = imgSrc;
const reader = new FileReader();
reader.onload = function (event: any) {
img.onload = () => {
// 计算一个合适的图片展示效果
let originWidth = img.width;
let originHeight = img.height;
let orginScale = originWidth / originHeight;
imgElement.width = null;
imgElement.height = null;
while (originWidth > 200) {
originWidth = originWidth - 30;
originHeight = originWidth / orginScale;
}
while (originHeight > 200) {
originHeight = originHeight - 30;
originWidth = originHeight * orginScale;
}
imgElement.width = originWidth;
imgElement.height = originHeight;
imgElement.style.margin = "0 5px";
imgMap.value[imgSrc] = file;
insertContent(imgElement.outerHTML);
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
return {
insertContent,
insertImage
};

调用

此时在组件里面调用

1
2
3
4
5
6
const insertFile=(file:File)=>{
const URL = window.URL || window.webkitURL;
const url = URL.createObjectURL(file);
console.log(url)
insertImage(url,file)
}

这时候我们就可以看到图片已经可以正常插入了

插入文件和视频

其实实现这个也很简单,我们只需要将文件和视频也变成图片就好了,这里我用到了dom-to-image

安装dom-to-image

1
yarn add dom-to-image

安装完之后有类型报错,随便搞一下,新建个types文件夹,新建domToImage.d.ts文件

1
2
3
4
5
declare module 'dom-to-image'{
export default {
toSvg: any,
};
}

这里我不同的文件用不同的代表图片展示
素材图

定义hooks中的insertFile

这里我搞了一个img参数,这个是封面图,就是需要我从对应文件对应的封面图,isAov是一个标识,用来区分是文件还是视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import domtoimage from "dom-to-image";
// 文件的Map
const fileImageMap=ref({} as {[key:string]:File})
// 插入文件格式
const insertFile = (img: string, file: File, isAov = false) => {
const fileElement = document.createElement("div") as any;
// 生成随机数
const random = Math.floor(Math.random() * 0xffffff).toString();
fileElement.id = random;
fileElement.setAttribute(
"style",
"display:flex;flex-direction:column;width:72px;height:100px;align-items:center;justify-content:end;"
);
fileElement.innerHTML = `
<img src=${img} style="width: 46px;height: 52px;margin-bottom:6px"/>
<div style="font-size:12px;width:72px;color:#7795C2;word-break:keep-all;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; text-align:center">
${file.name}
</div>
`;
const appDom = document.querySelector("#app") as any;
appDom.appendChild(fileElement);
const cacheFile = file;
domtoimage
.toSvg(document.getElementById(random) as any, {})
.then(function (dataUrl: string) {
const imgElement = document.createElement("img") as any;
imgElement.src = dataUrl;
imgElement.width = 72;
imgElement.height = 100;
imgElement.style.margin = "0 5px";
imgElement.id = random;
if (isAov) {
imgElement.setAttribute("type", "aov");
}
fileImageMap.value[random] = cacheFile;
insertContent(imgElement.outerHTML);
appDom.removeChild(fileElement);
})
.catch(function () {
console.log("文件转化失败");
});
};

新建utils文件夹下的data.ts

配置@使用

  1. 为了使用@,我们安装依赖@types/node
  2. 然后修改vite.config.ts
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import { join } from "path";

    const resolve = (path: string) => join(__dirname, path);
    // https://vitejs.dev/config/
    export default defineConfig({
    plugins: [vue()],
    resolve: {
    // 根据需要新增
    alias: {
    "@": resolve("src"),
    },
    },
    })
  3. tsconfig.json添加
    1
    2
    3
    "paths": {
    "@/*": ["src/*"]
    }
    这样,我们@就可以正常使用了

data.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 允许接受的类型
export const acceptList:string[]=[
".jpg,.jpeg,.png,.gif,.bmp,.JPG,.JPEG,.PBG,.GIF",
".doc,.docx,.zip,.txt,.xls,.xlsx,.csv,.md,.rar,.7z,.pdf",
".mp4, .flv, .avi, .mov, .wmv"
]

// 需要展示的图片
export const showFileImageList:string[] = [
new URL("@/assets/word.jpg", import.meta.url).href,
new URL("@/assets/excel.jpg", import.meta.url).href,
new URL("@/assets/zip.jpg", import.meta.url).href,
new URL("@/assets/txt.jpg", import.meta.url).href,
new URL("@/assets/pdf.jpg", import.meta.url).href,
new URL("@/assets/video.png", import.meta.url).href,
];

修改组件中的insertFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useInput } from "@/hooks/useInput";
import {acceptList,showFileImageList} from "@/utils/data"

const emits=defineEmits(['error'])

const insertFile=(file:File)=>{
const URL = window.URL || window.webkitURL;
const url = URL.createObjectURL(file);
if(file.type.includes("image")){
insertImage(url,file)
}
else{
const fileSuffix = file.name.substring(file.name.lastIndexOf("."));
if (acceptList[1].includes(fileSuffix.toLowerCase())) {
const fileType = [
[".doc", ".docx"],
[".xlsx", ".xls", ".csv"],
[".zip", ".rar", ".7z"],
[".txt", ".md"],
[".pdf"],
];
const index = fileType.findIndex((item) =>
item.includes(fileSuffix.toLowerCase())
);
if (~index) {
const img = showFileImageList[index];
insertFiles(img,file)
} else {
emits("error", "暂不支持该文件类型进行该操作")
}
}
// 视频文件
if (acceptList[2].includes(fileSuffix.toLowerCase())) {
const img = showFileImageList[5];
insertFiles(img,file,true)
}
}
}

const {insertImage,insertFile:insertFiles} =useInput({
editorKey: "chat-input-next-editor",
})

此时,我们已经实现了基础的富文本编辑器的效果

实现内容粘贴

接下来我讲解下内容的粘贴如何实现
文字的复制自然不用动,接下来我们只需要监听下触发了粘贴事件,然后进行对应的处理即可

监听粘贴事件

1
2
3
4
5
<template>
<div contenteditable :style="{...cusStyle}" class="chat-input-next" placeholder="请输入内容" id="chat-input-next-editor" @paste="handleParse">

</div>
</template>

处理监听事件

这里我使用e.preventDefault()组织默认粘贴事件,然后进行判断,如果是普通文字就照常插入,使用document.execCommand(“insertText”, false, result);如果是文件类型,进行一层简单的文件大小判断就执行插入事件即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 处理粘贴
const handleParse = async (e: any) => {
e.preventDefault();
try{
const result = (await onPaste(e)) as string;
if (result) {
document.execCommand("insertText", false, result);
}
}catch(err){
emits("error",err)
}
};

// 粘贴
const onPaste = (e: any) => {
if (!(e.clipboardData && e.clipboardData.items)) {
return;
}
return new Promise((resolve, reject) => {
for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
const item = e.clipboardData.items[i];
if (item.kind === "string") {
item.getAsString((str: string) => {
resolve(str);
});
} else if (item.kind === "file") {
handleFileAndImageInsert(item);
} else {
reject(new Error("不允许复制这种类型!"));
}
}
});
};

// 处理图片和文件在input框中的显示逻辑
const handleFileAndImageInsert = (item: any) => {
const file = item.getAsFile();
const isLt8M = file.size / 1024 / 1024 < 8;
if (!isLt8M) {
emits("error",'文件不能大于8M')
return;
}
insertFile(file)
};

实现内容拖拽

组件监听

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div
contenteditable
:style="{...cusStyle}"
class="chat-input-next"
placeholder="请输入内容"
id="chat-input-next-editor"
@paste="handleParse"
:ondragover="handleAllowDrop"
:ondrop="handleDrop">
</div>
</template>

监听方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 阻止默认事件
const handleAllowDrop = (e: Event) => {
e.preventDefault();
};

// 放置
const handleDrop = (e: any) => {
e.preventDefault();
const copyItems = e.dataTransfer.items;
for (let i = 0; i < copyItems.length; i++) {
// 字符串
if (copyItems[i].kind === "string") {
if (e.dataTransfer.effectAllowed === "copy") {
copyItems[i].getAsString((str: string) => {
insertContent(str);
});
}
}
// 文件
if (copyItems[i].kind === "file") {
handleFileAndImageInsert(copyItems[i]);
}
}
};

小调整1

将placeholder通过props来传递,将error事件在父组件使用,这个不多讲解了

实现@人的逻辑

完成基础样式

简单绘制at.popover.vue

src/components目录下新建一个at.popover.vue组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<template>
<div class="at-popover" v-if="visible">
<div class="at-popover-content">
<ul>
<li class="at-popover-line active">codesigner</li>
<li class="at-popover-line">react</li>
<li class="at-popover-line">vue</li>
<li class="at-popover-line">webgl</li>
<li class="at-popover-line">gis</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { withDefaults } from 'vue';
withDefaults(defineProps<{
visible:boolean
}>(),{
visible:false
});
</script>
<style scoped>
.at-popover {
display: block; /* 默认隐藏 */
position: absolute;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
top:-155px;
left:0;
}

.at-popover-line{
display: flex;
align-items: center;
cursor: pointer;
padding: 5px 10px;
}

.at-popover-line:hover{
background-color: #f1f1f1;
}

.active{
background-color: #f1f1f1;
}
</style>

调整chat-input-next.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<div class="chat-input-next-wrap">
<div
contenteditable
:style="{...cusStyle}"
class="chat-input-next"
:placeholder="placeholder"
id="chat-input-next-editor"
@paste="handleParse"
:ondragover="handleAllowDrop"
:ondrop="handleDrop">
</div>
<at-popover
ref="atPopoverRef"
:visible="atPopoverVisible"
/>
</div>
</template>
<script setup lang='ts'>
import atPopover from './at-popover.vue';
// @功能
// @列表是否显示
const atPopoverVisible = ref(true);
</script>
<style scoped>
.chat-input-next-wrap{
position: relative;
box-sizing: border-box;
}
</style>

效果图

完善@功能

点击弹出列表进行内容选中点击其他地方隐藏列表

  1. 在popver组件中添加点击事件
    我顺便将代码优化了一下,点击通过confirm传给父组件方法执行
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    <template>
    <div class="at-popover" v-if="visible" id="at-popover">
    <div class="at-popover-content">
    <ul>
    <li v-for="(item,index) in atPopoverList" :key="index" class="at-popover-line" :class="{active:index===activeLine}" @click="handleSelect(item.name)">{{item.name}}</li>
    </ul>
    </div>
    </div>
    </template>
    <script setup lang="ts">
    import { withDefaults,ref } from 'vue';
    withDefaults(defineProps<{
    visible:boolean
    }>(),{
    visible:false
    });
    // @列表
    const atPopoverList = ref([{
    name: 'codesigner',
    },
    {
    name: 'react',
    },
    {
    name: 'vue',
    },
    {
    name: 'webgl',
    },
    {
    name: 'gis',
    }]);

    // 当前选中@
    const activeLine=ref(0);
    // 父组件确定方法
    const emits=defineEmits(['confirm'])
    // 选中的人
    const handleSelect=(name:string)=>{
    emits("confirm",name)
    }
    </script>
    <style scoped>
    .at-popover {
    display: block; /* 默认隐藏 */
    position: absolute;
    background-color: #fff;
    border: 1px solid #ccc;
    border-radius: 5px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
    top:-155px;
    left:0;
    }

    .at-popover-line{
    display: flex;
    align-items: center;
    cursor: pointer;
    padding: 5px 10px;
    }

    .at-popover-line:hover{
    background-color: #f1f1f1;
    }

    .active{
    background-color: #f1f1f1;
    }
    </style>
  2. 在input组件中添加事件
    input组件接受@confirm触发handleAtConfirm并且添加点击外部popver隐藏的逻辑
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 点击popver外部隐藏
    const handlePopoverClick=(e:Event)=>{
    const target = e.target as HTMLElement;
    const isSelf = document.getElementById('at-popover')?.contains(target);
    if(!isSelf){
    atPopoverVisible.value=false
    }
    }
    // 监听@列表显示时的点击事件
    watch(()=>atPopoverVisible.value,(newVal)=>{
    if(newVal){
    document.addEventListener('click',handlePopoverClick)
    }
    else{
    document.removeEventListener('click',handlePopoverClick)
    }
    },{immediate:true})

    // 处理@确定事件
    const handleAtConfirm=(name:string)=>{
    alert(name)
    atPopoverVisible.value=false
    }

实现键盘方向键切换popver列表选中内容

在popver组件中添加,下键就往下,如果是最后一个就不要动,上键就往上,如果是第一个就不要动,回车键就选中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// @功能上下键切换
const handleKeyUp = (e:KeyboardEvent) => {
if (props.visible) {
// 下键
if (e.keyCode === 40) {
activeLine.value=Math.min(activeLine.value+1,atPopoverList.value.length-1)
}
// 上键
else if (e.keyCode === 38) {
activeLine.value=Math.max(activeLine.value-1,0)
}
// 回车键
else if (e.keyCode === 13) {
handleSelect(atPopoverList.value[activeLine.value].name)
}
}
};

watch(()=>props.visible,(newVal)=>{
if(newVal){
activeLine.value=0;
document.addEventListener("keyup", handleKeyUp);
}
else{
document.removeEventListener("keyup", handleKeyUp);
}
},{
immediate:true
})

实现键盘方向键切换popver列表选中内容滚动优化

虽然上面我们解决了上下切换选中内容的问题,但是如果我们的@列表很长,就会出现问题

我们先增加列表长度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// @列表
const atPopoverList = ref([{
name: 'codesigner',
},
{
name: 'react',
},
{
name: 'vue',
},
{
name: 'webgl',
},
{
name: 'gis',
},{
name:'cesium'
},{
name:'three'
},{
name:'cannon-es'
}]);
然后调整下样式
1
2
3
4
5
6
7
8
.at-popover-content{
max-height: 200px;
overflow: auto;
}

.at-popover-content::-webkit-scrollbar {
display: none;
}
效果

这里我们可以清楚看到我们的滚动并没有跟随我们的下键将内容挤下去,这里我们需要做下优化

优化代码

我这里根据每一行的高度和容器的高度,来判断是否需要滚动,设置scrollTop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 容器
const atPopoverContent=ref<HTMLElement|null>(null)

// 容器的高度
const atPopoverContentHeight=computed(()=>{
return atPopoverContent.value?.clientHeight||0
})

// 每一行的高度
const atPopoverLineHeight=computed(()=>{
return document.querySelector(".at-popover-line")?.clientHeight||0
})

// 处理键盘上下键长度较长滚动
const handleKeyScrollContainer=()=>{
// 多少时会超出范围
const maxShowNum=Math.floor(atPopoverContentHeight.value/atPopoverLineHeight.value)
if(activeLine.value>=maxShowNum-1){
atPopoverContent.value?.scrollTo(0,(activeLine.value-maxShowNum+2)*atPopoverLineHeight.value)
}
else{
atPopoverContent.value?.scrollTo(0,0)
}
}

// @功能上下键切换
const handleKeyUp = (e:KeyboardEvent) => {
if (props.visible) {
// 下键
if (e.keyCode === 40) {
handleKeyScrollContainer()
activeLine.value=Math.min(activeLine.value+1,atPopoverList.value.length-1)
}
// 上键
else if (e.keyCode === 38) {
handleKeyScrollContainer()
activeLine.value=Math.max(activeLine.value-1,0)
}
// 回车键
else if (e.keyCode === 13) {
handleSelect(atPopoverList.value[activeLine.value].name)
}
}
};

设置@列表显示逻辑

小调整

这里先做个小调整,将我们的组件设置个最大高度

input组件中css调整
1
2
3
4
5
6
7
8
9
10
11
.chat-input-next{
width:500px;
height:200px;
max-height: 200px;
overflow-y: auto;
padding:5px;
}

.chat-input-next::-webkit-scrollbar {
display: none;
}
App.vue中调用的时候样式调整

注意:这里需要设置height和maxHeight相同

1
<ChatInputNext :cusStyle="{width:'80vw',height:'50vh',border:'1px solid #d3d3d3',maxHeight:'50vh'}" ref="chatInputNext" @error="handleError"></ChatInputNext>

此时我们的显示效果就正常了,光标就不会跑到输入框容器外面去
效果图

添加@显示逻辑

给输入框组件添加input监听事件
  1. 安装loadsh和@types/lodash

    1
    yarn add loadsh @types/lodash
  2. 添加input监听事件
    html

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
     <div 
    contenteditable
    :style="{...cusStyle}"
    class="chat-input-next"
    ref="chatInputNext"
    :placeholder="placeholder"
    id="chat-input-next-editor"
    @paste="handleParse"
    @input="handleInput"
    :ondragover="handleAllowDrop"
    :ondrop="handleDrop">
    </div>

    js方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import { throttle } from "lodash";
    const chatInputNext=ref<HTMLElement|null>(null)
    // 保持输入框滚动条在最下方
    const keepScrollBottom = throttle(
    () => {
    const scrollEle=chatInputNext.value
    if (scrollEle) {
    scrollEle.scrollTop = scrollEle?.scrollHeight;
    }
    },
    200,
    {
    leading: false,
    trailing: true,
    }
    );

    // 处理input事件
    const handleInput=()=>{
    keepScrollBottom();
    }

    通过上面这串代码就可以让我们的输入框保持滚动条在最下方了

添加@显示逻辑
  1. 在input组件中:
    默认值设为false

    1
    2
    // @列表是否显示
    const atPopoverVisible = ref(false);

    将输入内容时的@监听,判断是@将弹窗打开

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 处理input事件
    const handleInput=(e:Event)=>{
    // 保持输入框滚动条在最底部
    keepScrollBottom();
    const inputE=e as InputEvent
    // 如果没有内容则隐藏@列表
    if (inputE.data !== null && !inputE.data.trim()) {
    atPopoverVisible.value = false;
    }
    else{
    if (
    inputE.inputType === "insertText" ||
    inputE.inputType === "insertCompositionText"
    ){
    // 输入字符
    if (inputE.data === "@") {
    atPopoverVisible.value=true
    }
    }
    }
    }
  2. 在popover组件当中
    修改样式将at-popover设置为fixed

    1
    2
    3
    4
    5
    6
    7
    8
    .at-popover {
    display: block; /* 默认隐藏 */
    position: fixed;
    background-color: #fff;
    border: 1px solid #ccc;
    border-radius: 5px;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
    }

监听显示的时候改变positionStyle,并将位置信息传递给popover组件,这里获取到的位置信息时相对于window的,为了更好的显示,y方向需要减去popover的高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
watch(()=>props.visible,(newVal)=>{
if(newVal){
activeLine.value=0;
changePosition()
document.addEventListener("keyup", handleKeyUp);
}
else{
document.removeEventListener("keyup", handleKeyUp);
}
},{
immediate:true
})

// 改变popover弹出的位置
type InlineStyles = Partial<CSSProperties>;
// 位置样式
const positionStyle=ref<InlineStyles>({})
// 改变位置样式
const changePosition = () => {
nextTick(()=>{
if(window&&window.getSelection()){
const sel= window.getSelection() as Selection;
let range = sel.getRangeAt(0); // 获取当前光标
let position = range.getBoundingClientRect(); // 获取标签位置
const {x,y}=position;
positionStyle.value = {
top: y-atPopoverContentHeight.value + "px",
left: x + "px",
};
}
})
};
1
2
3
4
5
6
7
8
9
<template>
<div class="at-popover" v-if="visible" id="at-popover" :style="{...positionStyle}">
<div class="at-popover-content" ref="atPopoverContent">
<ul>
<li v-for="(item,index) in atPopoverList" :key="index" class="at-popover-line" :class="{active:index===activeLine}" @click="handleSelect(item.name)">{{item.name}}</li>
</ul>
</div>
</div>
</template>

此时,我们就将@显示的逻辑给加上了

将@选中的内容放置到输入框

这里只需要修改@的确认事件就可以了

1
2
3
4
5
6
7
8
9
// 处理@确定事件
const handleAtConfirm=(name:string)=>{
const newAtEle = document.createElement("a");
newAtEle.setAttribute("contenteditable", "false");
newAtEle.setAttribute("style", "user-select: all");
newAtEle.innerText = `@${name} `;
insertContent(newAtEle.outerHTML)
atPopoverVisible.value=false
}

这里我们的@添加内容已经新增了,但是还有两个问题,一个是换行了,还有一个是之前的@没有清除

解决回车换行以及清除输入的@符号

解决回车换行

添加监听keydown事件,然后只有回车的时候把换行给阻止,但是不阻止shift+enter的组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div class="chat-input-next-wrap" ref="chatInputNextWrap">
<div
contenteditable
:style="{...cusStyle}"
class="chat-input-next"
ref="chatInputNext"
:placeholder="placeholder"
id="chat-input-next-editor"
@paste="handleParse"
@input="handleInput"
@keydown="handleKeyDown"
:ondragover="handleAllowDrop"
:ondrop="handleDrop">
</div>
<at-popover
ref="atPopoverRef"
:visible="atPopoverVisible"
@confirm="handleAtConfirm"
/>
</div>
</template>
1
2
3
4
5
6
// 去除默认事件
const handleKeyDown=(e:KeyboardEvent)=>{
if(e.keyCode===13&&!e.shiftKey){
e.preventDefault()
}
}
清除输入的@符号

记录下@的字符串并且创建内容时传入这个值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// @的字符串
const atStr = ref("");
// 处理input事件
const handleInput=(e:Event)=>{
// 保持输入框滚动条在最底部
keepScrollBottom();
const inputE=e as InputEvent
const sel=window.getSelection() as Selection
// 如果没有内容则隐藏@列表
if (inputE.data !== null && !inputE.data.trim()) {
atPopoverVisible.value = false;
}
else{
const inputEle = e.target as any;
let searchStr = "";
let range = sel.getRangeAt(0); // 获取当前光标
// 如果当前光标所在位置的节点类型是文本节点,且文本内容包含@符号,则获取@符号后面的文本就是搜索的内容
for (let i = 0; i < inputEle.childNodes.length; i++) {
const curEle = inputEle.childNodes[i];
if (curEle.nodeName === "#text" && curEle.nodeValue.includes("@")) {
const splitArr = curEle.nodeValue
.substring(0, range.endOffset)
.split("@");
searchStr = splitArr[splitArr.length - 1];
}
}
atStr.value = searchStr;
if (
inputE.inputType === "insertText" ||
inputE.inputType === "insertCompositionText"
){
// 输入字符
if (inputE.data === "@") {
atPopoverVisible.value=true
}
}
}
}

// 处理@确定事件
const handleAtConfirm=(name:string)=>{
const newAtEle = document.createElement("a");
newAtEle.setAttribute("contenteditable", "false");
newAtEle.setAttribute("style", "user-select: all");
newAtEle.innerText = `@${name} `;
insertContent(newAtEle.outerHTML,atStr.value)
atPopoverVisible.value=false
}

修改useInput中的insertContent,支持atStr传入,isNil我使用的是loadsh的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 插入内容
const insertContent = (html: string|HTMLElement,atStr?:string) => {
let sel, range;
const curEditor = document.getElementById(editorKey);
if (window.getSelection && curEditor) {
sel = window.getSelection();
if (sel && sel.rangeCount) range = sel.getRangeAt(0);
if (!range) {
// 如果div没有光标,则在div内容末尾插入
range = keepCursorEnd(true)?.getRangeAt(0);
} else {
const contentRange = document.createRange();
contentRange.selectNode(curEditor);
// 对比range,检查光标是否在输入范围内
const compareStart = range?.compareBoundaryPoints(
Range.START_TO_START,
contentRange
);
const compareEnd = range?.compareBoundaryPoints(
Range.END_TO_END,
contentRange
);
const compare = compareStart !== -1 && compareEnd !== 1;
if (!compare) range = keepCursorEnd(true)?.getRangeAt(0);
}
const inputRange=range as Range
const inputSel=sel as Selection
const input = inputRange.createContextualFragment(html);
const lastNode = input.lastChild; // 记录插入input之后的最后节点位置
inputRange.insertNode(input);
if (lastNode) {
// 如果有最后的节点
range = inputRange.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
inputSel.removeAllRanges();
inputSel.addRange(range);
}
!isNil(atStr)&&removeOverrageContent(curEditor, atStr, html);
}
};

书写方法removeOverrageContent,判断节点,将节点是@后面跟的内容部分全部替换掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 删除@内容之前的原始文本
const removeOverrageContent = (
editor: HTMLElement,
atStr: string,
atNode: HTMLElement
) => {
const childLen = editor.childNodes.length || 0;
for (let i = 0; i < childLen; i++) {
const curEle = editor.childNodes[i];
if (curEle && curEle.nodeName !== "#text") {
const curOuterHTML = curEle.outerHTML;
if (curOuterHTML === atNode) {
// 当前节点是最新插入的@节点
const preEle = editor.childNodes[i - 1];
if (preEle && preEle.nodeName === "#text") {
// @节点插入位置之前的文本节点就是需要替换内容的节点
const preEleText = preEle.textContent;
const re = new RegExp(`(.*)@${atStr}`);
preEle.textContent = preEleText.replace(re, "$1");
}
}
}
}
};

添加@列表搜索功能

接下来我们给@列表添加一个搜索功能
这段我改动比较大,我就不一一贴代码了,直接全贴,然后讲解下我改了啥

安装依赖

首先安装下pinyin-engine用来判断搜索字符

1
yarn add pinyin-engine

然后自己定义一下类型

1
2
3
declare module "pinyin-engine"{
export default Class
}

App.vue

这里改动不大,我将list单独提出来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<script setup lang="ts">
import {ref} from "vue"
import ChatInputNext from './components/chat-input-next.vue'
const chatInputNext=ref()
const handleFileChange = (e:Event)=>{
const target=e.target as HTMLInputElement
if(target.files&&target.files.length>0){
const file=target.files[0]
chatInputNext.value&&chatInputNext.value.insertFile(file)
}
}
const handleError=(msg:string)=>{
console.log(msg)
}

// @列表
const atPopoverList = ref([{
name: 'codesigner',
},
{
name: 'react',
},
{
name: 'vue',
},
{
name: 'webgl',
},
{
name: 'gis',
},{
name:'cesium'
},{
name:'three'
},{
name:'cannon-es'
}]);
</script>

<template>
<div class="chat-input-next-container">
输入框组件:
<div class="op-line">
<input type="file" @change="handleFileChange">
</div>
<ChatInputNext :cusStyle="{width:'80vw',height:'50vh',border:'1px solid #d3d3d3',maxHeight:'50vh'}" ref="chatInputNext" @error="handleError" :atUserList="atPopoverList"></ChatInputNext>
</div>
</template>
<style scoped>
.chat-input-next-container{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap:10px;
width: 100vw;
height: 100vh;
background-color: #f5f5f5;
}
.op-line{
display: flex;
flex-direction: row;
align-items: center;
gap:10px;
width:80vw
}
</style>

类型定义

我将类型定义提取了出来

1
2
3
4
5
6
7
import { CSSProperties } from 'vue';
export type InlineStyles = Partial<CSSProperties>;
export type AtEachMember ={
name: string;
id?: string;
avatar?: string;
};

chat-input-next

这里我引入了PinyinEngine进行查询使用,然后将不同的值传给@的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
<template>
<div class="chat-input-next-wrap" ref="chatInputNextWrap">
<div
contenteditable
:style="{...cusStyle}"
class="chat-input-next"
ref="chatInputNext"
:placeholder="placeholder"
id="chat-input-next-editor"
@paste="handleParse"
@input="handleInput"
@keydown="handleKeyDown"
:ondragover="handleAllowDrop"
:ondrop="handleDrop">
</div>
<at-popover
v-if="!isEmpty(atUserList)"
ref="atPopoverRef"
:visible="atPopoverVisible"
@confirm="handleAtConfirm"
:list="atStr?searchAtList:atUserList"
/>
</div>
</template>
<script lang="ts">
export default {
name: "ChatInputNext",
};
</script>
<script lang="ts" setup>
import { withDefaults,defineExpose,defineEmits,ref,watch } from 'vue';
import { useInput } from "@/hooks/useInput";
import {acceptList,showFileImageList} from "@/utils/data"
import { throttle,isEmpty } from "lodash";
import PinyinEngine from "pinyin-engine";
import atPopover from './at-popover.vue';
import type {InlineStyles,AtEachMember} from "./types/index"

const props=withDefaults(defineProps<{
cusStyle?:InlineStyles
placeholder?:string
atUserList?:Array<AtEachMember>
}>(),{
placeholder:"请输入内容",
atUserList:()=>[]
});

const emits=defineEmits(['error'])

// 插入文件
const insertFile=(file:File)=>{
const URL = window.URL || window.webkitURL;
const url = URL.createObjectURL(file);
if(file.type.includes("image")){
insertImage(url,file)
}
else{
const fileSuffix = file.name.substring(file.name.lastIndexOf("."));
if (acceptList[1].includes(fileSuffix.toLowerCase())) {
const fileType = [
[".doc", ".docx"],
[".xlsx", ".xls", ".csv"],
[".zip", ".rar", ".7z"],
[".txt", ".md"],
[".pdf"],
];
const index = fileType.findIndex((item) =>
item.includes(fileSuffix.toLowerCase())
);
if (~index) {
const img = showFileImageList[index];
insertFiles(img,file)
} else {
emits("error", "暂不支持该文件类型进行该操作")
}
}
// 视频文件
if (acceptList[2].includes(fileSuffix.toLowerCase())) {
const img = showFileImageList[5];
insertFiles(img,file,true)
}
}
}
// 插入文件地址
const insertUrl=(url:string,type:'img'|'file'|'aov'='img')=>{
console.log(url,type)
}

// 处理粘贴
const handleParse = async (e: Event) => {
e.preventDefault();
try{
const result = (await onPaste(e)) as string;
if (result) {
document.execCommand("insertText", false, result);
}
}catch(err){
emits("error",err)
}
};

// 粘贴
const onPaste = (e: any) => {
if (!(e.clipboardData && e.clipboardData.items)) {
return;
}
return new Promise((resolve, reject) => {
for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
const item = e.clipboardData.items[i];
if (item.kind === "string") {
item.getAsString((str: string) => {
resolve(str);
});
} else if (item.kind === "file") {
handleFileAndImageInsert(item);
} else {
reject(new Error("不允许复制这种类型!"));
}
}
});
};

// 处理图片和文件在input框中的显示逻辑
const handleFileAndImageInsert = (item: any) => {
const file = item.getAsFile();
const isLt8M = file.size / 1024 / 1024 < 8;
if (!isLt8M) {
emits("error",'文件不能大于8M')
return;
}
insertFile(file)
};

// 阻止默认事件
const handleAllowDrop = (e: Event) => {
e.preventDefault();
};

// 放置
const handleDrop = (e: any) => {
e.preventDefault();
const copyItems = e.dataTransfer.items;
for (let i = 0; i < copyItems.length; i++) {
// 字符串
if (copyItems[i].kind === "string") {
if (e.dataTransfer.effectAllowed === "copy") {
copyItems[i].getAsString((str: string) => {
insertContent(str);
});
}
}
// 文件
if (copyItems[i].kind === "file") {
handleFileAndImageInsert(copyItems[i]);
}
}
};

const {insertImage,insertFile:insertFiles,insertContent} =useInput({
editorKey: "chat-input-next-editor",
})


// @功能
// @列表是否显示
const atPopoverVisible = ref(false);

// popver的ref
const atPopoverRef=ref<any>(null)

// 点击popver外部隐藏
const handlePopoverClick=(e:Event)=>{
const target = e.target as HTMLElement;
const isSelf = document.getElementById('at-popover')?.contains(target);
if(!isSelf){
atPopoverVisible.value=false
}
}
// 监听@列表显示时的点击事件
watch(()=>atPopoverVisible.value,(newVal)=>{
if(newVal){
document.addEventListener('click',handlePopoverClick)
}
else{
document.removeEventListener('click',handlePopoverClick)
}
},{immediate:true})

// 去除默认事件
const handleKeyDown=(e:KeyboardEvent)=>{
if(e.keyCode===13&&!e.shiftKey){
e.preventDefault()
}
}

// 处理@确定事件
const handleAtConfirm=(name:string)=>{
const newAtEle = document.createElement("a");
newAtEle.setAttribute("contenteditable", "false");
newAtEle.setAttribute("style", "user-select: all");
newAtEle.innerText = `@${name} `;
insertContent(newAtEle.outerHTML,atStr.value)
atPopoverVisible.value=false
}

const chatInputNext=ref<HTMLElement|null>(null)
// 保持输入框滚动条在最下方
const keepScrollBottom = throttle(
() => {
const scrollEle=chatInputNext.value
if (scrollEle) {
scrollEle.scrollTop = scrollEle?.scrollHeight;
}
},
200,
{
leading: false,
trailing: true,
}
);

// @的字符串
const atStr = ref("");

// 搜索出来的@列表
const searchAtList = ref<Array<AtEachMember>>([]);

// 处理input事件
const handleInput=(e:Event)=>{
// 保持输入框滚动条在最底部
keepScrollBottom();
const inputE=e as InputEvent
const sel=window.getSelection() as Selection
// 如果没有内容则隐藏@列表
if (inputE.data !== null && !inputE.data.trim()) {
atPopoverVisible.value = false;
}
else{
const inputEle = e.target as any;
let searchStr = "";
let range = sel.getRangeAt(0); // 获取当前光标
// 如果当前光标所在位置的节点类型是文本节点,且文本内容包含@符号,则获取@符号后面的文本就是搜索的内容
for (let i = 0; i < inputEle.childNodes.length; i++) {
const curEle = inputEle.childNodes[i];
if (curEle.nodeName === "#text" && curEle.nodeValue.includes("@")) {
const splitArr = curEle.nodeValue
.substring(0, range.endOffset)
.split("@");
searchStr = splitArr[splitArr.length - 1];
}
}
atStr.value = searchStr;
if (
inputE.inputType === "insertText" ||
inputE.inputType === "insertCompositionText"
){
// 输入字符
if (inputE.data === "@") {
atPopoverVisible.value=true
}else{
const pinyinSearchList = new PinyinEngine(props.atUserList, [
"name",
]); // 拼音搜索
const newList = pinyinSearchList.query(searchStr);
searchAtList.value = newList;
if (searchStr && newList.length !== 0) {
atPopoverVisible.value=true
} else if (newList.length === 0) {
atPopoverVisible.value = false;
}
}
}
}
}

defineExpose({
insertFile,
insertUrl
})
</script>
<style scoped>
.chat-input-next-wrap{
position: relative;
box-sizing: border-box;
}
.chat-input-next{
width:500px;
height:200px;
max-height: 200px;
overflow-y: auto;
padding:5px;
}

.chat-input-next::-webkit-scrollbar {
display: none;
}

[contenteditable]:empty:before {
content: attr(placeholder);
color: #ccc;
}
[contenteditable] {
outline: 0px solid transparent;
}
</style>

at-popover组件

这个组件我修改了获取组件高度的逻辑,获取传下来的list

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
<template>
<div class="at-popover" v-if="visible" id="at-popover" :style="{...positionStyle}">
<div class="at-popover-content" ref="atPopoverContent">
<ul>
<li v-for="(item,index) in list" :key="index" class="at-popover-line" :class="{active:index===activeLine}" @click="handleSelect(item.name)">{{item.name}}</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { withDefaults,ref,watch,computed,nextTick } from 'vue';
import type {InlineStyles,AtEachMember} from "./types/index"
const props=withDefaults(defineProps<{
visible:boolean
list:AtEachMember[]
}>(),{
visible:false
});

// 当前选中@
const activeLine=ref(0);
// 父组件确定方法
const emits=defineEmits(['confirm'])
// 选中的人
const handleSelect=(name:string)=>{
emits("confirm",name)
}

// 容器
const atPopoverContent=ref<HTMLElement|null>(null)

// 容器的高度
const atPopoverContentHeight=ref(0)

// 每一行的高度
const atPopoverLineHeight=computed(()=>{
return document.querySelector(".at-popover-line")?.clientHeight||0
})

// 处理键盘上下键长度较长滚动
const handleKeyScrollContainer=()=>{
// 多少时会超出范围
const maxShowNum=Math.floor(atPopoverContentHeight.value/atPopoverLineHeight.value)
if(activeLine.value>=maxShowNum-1){
atPopoverContent.value?.scrollTo(0,(activeLine.value-maxShowNum+2)*atPopoverLineHeight.value)
}
else{
atPopoverContent.value?.scrollTo(0,0)
}
}

// @功能上下键切换
const handleKeyUp = (e:KeyboardEvent) => {
if (props.visible) {
// 下键
if (e.keyCode === 40) {
handleKeyScrollContainer()
activeLine.value=Math.min(activeLine.value+1,props.list.length-1)
}
// 上键
else if (e.keyCode === 38) {
handleKeyScrollContainer()
activeLine.value=Math.max(activeLine.value-1,0)
}
// 回车键
else if (e.keyCode === 13) {
handleSelect(props.list[activeLine.value].name)
}
}
};

watch(()=>props.visible,(newVal)=>{
if(newVal){
activeLine.value=0;
document.addEventListener("keyup", handleKeyUp);
}
else{
document.removeEventListener("keyup", handleKeyUp);
}
})

watch(()=>[props.list.length,props.visible,atPopoverContent.value],([newVal1,newVal2,newVal3])=>{
if([newVal1,newVal2,newVal3].some(item=>item)){
activeLine.value=0;
nextTick(()=>{
atPopoverContentHeight.value=atPopoverContent.value?.clientHeight||0
changePosition()
})
}
},{
deep:true
})

// 改变popover弹出的位置
// 位置样式
const positionStyle=ref<InlineStyles>({})
// 改变位置样式
const changePosition = () => {
nextTick(()=>{
if(window&&window.getSelection()){
const sel= window.getSelection() as Selection;
let range = sel.getRangeAt(0); // 获取当前光标
let position = range.getBoundingClientRect(); // 获取标签位置
const {x,y}=position;
positionStyle.value = {
top: y-atPopoverContentHeight.value + "px",
left: x + "px",
};
}
})
};

</script>
<style scoped>
.at-popover {
display: block; /* 默认隐藏 */
position: fixed;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
top:-200px;
left:0;
}

.at-popover-line{
display: flex;
align-items: center;
cursor: pointer;
padding: 5px 10px;
}

.at-popover-content{
max-height: 200px;
overflow: auto;
}

.at-popover-content::-webkit-scrollbar {
display: none;
}

.at-popover-line:hover{
background-color: #f1f1f1;
}

.active{
background-color: #f1f1f1;
}
</style>

此时就能够实现@搜索的功能了

添加删除事件

我就搜索方法单独拆了出来

1
2
3
4
5
6
const previousNode = range.startContainer.childNodes[range.startOffset - 1];
if(previousNode&&previousNode.nodeName==='A'){
e.preventDefault();
// 删除整个 <a> 节点
previousNode.remove();
}

上面一段代码是为了避免a标签换行时无法删除的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 搜索内容
const searchFun=(searchStr:string)=>{
const pinyinSearchList = new PinyinEngine(props.atUserList, [
"name",
]);
// 拼音搜索
const newList = pinyinSearchList.query(searchStr);
searchAtList.value = newList;
if (searchStr && newList.length !== 0) {
atPopoverVisible.value=true
} else if (newList.length === 0) {
atPopoverVisible.value = false;
}
}

// 处理input事件
const handleInput=(e:Event)=>{
// 保持输入框滚动条在最底部
keepScrollBottom();
const inputE=e as InputEvent
const sel=window.getSelection() as Selection
// 如果没有内容则隐藏@列表
if (inputE.data !== null && !inputE.data.trim()) {
atPopoverVisible.value = false;
}
else{
const inputEle = e.target as any;
let searchStr = "";
let range = sel.getRangeAt(0); // 获取当前光标
let isOnlyAt=false
// 如果当前光标所在位置的节点类型是文本节点,且文本内容包含@符号,则获取@符号后面的文本就是搜索的内容
for (let i = 0; i < inputEle.childNodes.length; i++) {
const curEle = inputEle.childNodes[i];
if (curEle.nodeName === "#text" && curEle.nodeValue.includes("@")) {
const splitArr = curEle.nodeValue
.substring(0, range.endOffset)
.split("@");
searchStr = splitArr[splitArr.length - 1];
if (!searchStr || !searchStr.length) {
isOnlyAt = true;
}
}
}
atStr.value = searchStr;
if (
inputE.inputType === "insertText" ||
inputE.inputType === "insertCompositionText"
){
// 输入字符
if (inputE.data === "@") {
atPopoverVisible.value=true
}else{
searchFun(searchStr)
}
}else if (inputE.inputType === "deleteContentBackward") {
const previousNode = range.startContainer.childNodes[range.startOffset - 1];
if(previousNode&&previousNode.nodeName==='A'){
e.preventDefault();
// 删除整个 <a> 节点
previousNode.remove();
}
//删除字符
else if (!searchStr&&!isOnlyAt) {
atPopoverVisible.value = false;
} else {
searchFun(searchStr)
}
}
}
}

发布

这里可以参考我之前的npm发包文章

结语

本篇文章已经内容太长了,我先到这里,后续的文章继续封装这个组件

上一篇:
编辑器组件封装(二)
下一篇:
【可视化学习】45-3D图表实现