perf(system): 优化聊天框中常用功能

优化右键菜单复制功能
优化原生复制功能
优化网址识别功能
This commit is contained in:
nongyehong 2024-04-14 01:17:32 +08:00
parent eba6395966
commit 42d2453991
7 changed files with 387 additions and 316 deletions

View File

@ -47,6 +47,8 @@ HuLa is an instant messaging system developed with Tauri, Vite 5, Vue 3, and Typ
![img_3.png](preview/img_3.png) ![img_3.png](preview/img_3.png)
![img_4.png](preview/img_4.png)
HuLa adopts a modular architecture design, with the front end built using Vue 3 for the user interface, enhanced by TypeScript for better code readability and maintainability. On the backend, we use the Tauri framework for packaging and distributing the application, leveraging its native integration with the operating system to offer users more functionality and higher performance. HuLa adopts a modular architecture design, with the front end built using Vue 3 for the user interface, enhanced by TypeScript for better code readability and maintainability. On the backend, we use the Tauri framework for packaging and distributing the application, leveraging its native integration with the operating system to offer users more functionality and higher performance.
## Installation and Running ## Installation and Running

View File

@ -47,6 +47,8 @@ HuLa 是一个基于 Tauri、Vite 5、Vue 3 和 TypeScript 构建的即时通讯
![img_3.png](preview/img_3.png) ![img_3.png](preview/img_3.png)
![img_4.png](preview/img_4.png)
## 安装与运行 ## 安装与运行
```bash ```bash

BIN
preview/img_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View File

@ -186,15 +186,6 @@ const { createWebviewWindow } = useWindow()
const selectKey = ref() const selectKey = ref()
const activeBubble = ref(-1) const activeBubble = ref(-1)
const userId = ref(10086) const userId = ref(10086)
const copyright = ref('-HuLa©-版权所有')
const copyrightComputed = computed(() => {
const copy = (index: number) => {
items.value[index].content.endsWith(copyright.value)
? navigator.clipboard.writeText(items.value[index].content)
: navigator.clipboard.writeText(items.value[index].content + copyright.value)
}
return { copy }
})
/* 提醒框标题 */ /* 提醒框标题 */
const tips = ref() const tips = ref()
const modalShow = ref(false) const modalShow = ref(false)
@ -242,13 +233,14 @@ const activeItemRef = ref({ ...activeItem })
// }) // })
// const message = computed(() => msg.value) // const message = computed(() => msg.value)
/* 右键消息菜单列表 */ /* 右键消息菜单列表 */
//
const menuList = ref<OPT.RightMenu[]>([ const menuList = ref<OPT.RightMenu[]>([
{ {
label: '复制', label: '复制',
icon: 'copy', icon: 'copy',
click: (item: any) => { click: (item: any) => {
// const content = items.value[item.key].content
copyrightComputed.value.copy(item.key) handleCopy(content)
} }
}, },
{ {
@ -295,6 +287,39 @@ watchEffect(() => {
activeItemRef.value = { ...activeItem } activeItemRef.value = { ...activeItem }
}) })
/**
* 处理复制事件
* @param content 复制的内容
*/
const handleCopy = (content: string) => {
//
// TODO (nyh -> 2024-04-14 01:14:56)
if (content.includes('data:image')) {
//
const img = new Image()
img.src = content
//
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
ctx?.drawImage(img, 0, 0, img.width, img.height)
// base64
canvas.toBlob((blob) => {
const item = new ClipboardItem({ 'image/png': blob! })
navigator.clipboard.write([item])
})
}
} else {
//
navigator.clipboard.writeText(removeTag(content))
}
}
/* 去除字符串中的元素标记 */
const removeTag = (fragment: any) => new DOMParser().parseFromString(fragment, 'text/html').body.textContent || ''
/* 处理滚动事件(用于页脚显示功能) */ /* 处理滚动事件(用于页脚显示功能) */
const handleScroll = (e: Event) => { const handleScroll = (e: Event) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
@ -332,7 +357,8 @@ const handleMsgClick = (item: any) => {
// //
const handleKeyPress = (e: KeyboardEvent) => { const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'c') { if (e.ctrlKey && e.key === 'c') {
copyrightComputed.value.copy(item.key) const content = items.value[item.key].content
handleCopy(content)
// //
document.removeEventListener('keydown', handleKeyPress) document.removeEventListener('keydown', handleKeyPress)
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<n-scrollbar style="max-height: 290px" class="p-[14px_14px_0_14px] box-border w-450px h-290px"> <n-scrollbar style="max-height: 290px" class="p-[14px_14px_0_14px] box-border w-450px h-290px select-none">
<transition name="fade" mode="out-in" appear> <transition name="fade" mode="out-in" appear>
<!-- 默认表情页面 --> <!-- 默认表情页面 -->
<div v-if="activeIndex === 0"> <div v-if="activeIndex === 0">

View File

@ -66,70 +66,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { lightTheme, darkTheme } from 'naive-ui' import { lightTheme, darkTheme } from 'naive-ui'
import { MittEnum, MsgEnum, RoomTypeEnum, ThemeEnum } from '@/enums' import { MittEnum, RoomTypeEnum, ThemeEnum } from '@/enums'
import Mitt from '@/utils/Bus.ts' import Mitt from '@/utils/Bus.ts'
import { createFileOrVideoDom } from '@/utils/CreateDom.ts'
import { MockList } from '@/mock'
import { MockItem } from '@/services/types.ts' import { MockItem } from '@/services/types.ts'
import { useDebounceFn } from '@vueuse/core'
import { emit, listen } from '@tauri-apps/api/event' import { emit, listen } from '@tauri-apps/api/event'
import { setting } from '@/stores/setting.ts' import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { sendOptions } from '@/views/home-window/more/settings/config.ts' import { sendOptions } from '@/views/home-window/more/settings/config.ts'
import { useMsgInput } from '@/hooks/useMsgInput.ts'
const settingStore = setting() const settingStore = setting()
const { themes, chat } = storeToRefs(settingStore) const { themes } = storeToRefs(settingStore)
const chatKey = ref(chat.value.sendKey)
const ait = ref(false)
/* 发送按钮旁的箭头 */ /* 发送按钮旁的箭头 */
const arrow = ref(false) const arrow = ref(false)
const menuList = ref([
{ label: '剪切', icon: 'screenshot', disabled: true },
{ label: '复制', icon: 'copy', disabled: true },
{
label: '粘贴',
icon: 'intersection',
click: () => {
navigator.clipboard.read().then((items) => {
const clipboardItem = items[0] //
if (clipboardItem.types.includes('text/plain')) {
// 使 readText()
navigator.clipboard.readText().then((text) => {
insertNode(MsgEnum.TEXT, text)
triggerInputEvent(messageInputDom.value)
})
} else if (clipboardItem.types.find((type) => type.startsWith('image/'))) {
//
// TODO gifhtmlpng (nyh -> 2024-02-27 03:27:10)
let imageType = clipboardItem.types.find((type) => type.startsWith('image/'))
clipboardItem.getType(imageType as any).then((blob) => {
imgPaste(blob)
})
}
})
}
},
{ label: '另存为', icon: 'download', disabled: true },
{ label: '全部选择', icon: 'check-one' }
])
const msgInput = ref('')
// dom // dom
const messageInputDom = ref() const messageInputDom = ref()
const activeItem = ref(inject('activeItem') as MockItem) const activeItem = ref(inject('activeItem') as MockItem)
/* 艾特后的关键字的key */ /* 引入useMsgInput的相关方法 */
const aitKey = ref('') const {
// MockList handlePaste,
const filteredList = computed(() => { insertNode,
if (aitKey.value) { triggerInputEvent,
return MockList.value.filter((item) => item.accountName.includes(aitKey.value)) inputKeyDown,
} else { handleAit,
return MockList.value handleInput,
} send,
}) filteredList,
ait,
watchEffect(() => { msgInput,
chatKey.value = chat.value.sendKey chatKey,
}) menuList
} = useMsgInput(messageInputDom)
/* 当切换聊天对象时,重新获取焦点 */ /* 当切换聊天对象时,重新获取焦点 */
watch(activeItem, () => { watch(activeItem, () => {
@ -139,258 +106,6 @@ watch(activeItem, () => {
}) })
}) })
watch(chatKey, (v) => {
chat.value.sendKey = v
})
/**
* 将指定节点插入到光标位置
* @param { MsgEnum } type 插入的类型
* @param dom dom节点
*/
const insertNode = (type: MsgEnum, dom: any) => {
//
const selection = window.getSelection()
//
const range = selection?.getRangeAt(0)
//
range?.deleteContents()
//
if (type === MsgEnum.AIT) {
// span
const spanNode = document.createElement('span')
spanNode.id = 'aitSpan' // idaitSpan
spanNode.contentEditable = 'false' //
spanNode.classList.add('text-#13987f')
spanNode.classList.add('select-none')
spanNode.classList.add('cursor-default')
// span
spanNode.appendChild(document.createTextNode(`@${dom}`))
// span
range?.insertNode(spanNode)
range?.setStart(messageInputDom.value, messageInputDom.value.childNodes.length)
//
const spaceNode = document.createTextNode('\u00A0')
//
range?.insertNode(spaceNode)
} else if (type === MsgEnum.TEXT) {
range?.insertNode(document.createTextNode(dom))
} else {
range?.insertNode(dom)
}
//
selection?.collapseToEnd()
}
/* 处理粘贴事件 */
const handlePaste = (e: any) => {
e.preventDefault()
if (e.clipboardData.files.length > 0) {
if (e.clipboardData.files.length > 5) {
window.$message.warning('一次性只能上传5个文件')
return
}
for (let file of e.clipboardData.files) {
//
let fileSizeInMB = file.size / 1024 / 1024 // (MB)
if (fileSizeInMB > 300) {
window.$message.warning(`文件 ${file.name} 超过300MB`)
continue // 300MB
}
let fileType = file.type as string
if (fileType.startsWith('image/')) {
//
imgPaste(file)
} else if (fileType.startsWith('video/')) {
//
FileOrVideoPaste(file, MsgEnum.VIDEO)
} else {
//
FileOrVideoPaste(file, MsgEnum.FILE)
}
}
} else {
//
const plainText = e.clipboardData.getData('text/plain')
insertNode(MsgEnum.TEXT, plainText)
triggerInputEvent(messageInputDom.value)
}
}
/* 处理图片粘贴事件 */
const imgPaste = (file: any) => {
const reader = new FileReader()
reader.onload = (e: any) => {
const img = document.createElement('img')
img.src = e.target.result
//
img.style.maxHeight = '88px'
img.style.maxWidth = '140px'
img.style.marginRight = '6px'
//
insertNode(MsgEnum.IMAGE, img)
triggerInputEvent(messageInputDom.value)
}
reader.readAsDataURL(file)
}
/**
* 处理视频或者文件粘贴事件
* @param file 文件
* @param type 类型
*/
const FileOrVideoPaste = (file: File, type: MsgEnum) => {
const reader = new FileReader()
// 使
createFileOrVideoDom(file).then((imgTag) => {
// img
insertNode(type, imgTag)
triggerInputEvent(messageInputDom.value)
})
reader.readAsDataURL(file)
}
/* 触发输入框事件(粘贴的时候需要重新触发这个方法) */
const triggerInputEvent = (element: HTMLElement) => {
if (element) {
const event = new Event('input', {
bubbles: true,
cancelable: true
})
element.dispatchEvent(event)
}
}
/* 获取messageInputDom输入框中的内容类型 */
const getMessageContentType = () => {
let hasText = false
let hasImage = false
let hasVideo = false
let hasFile = false
const elements = messageInputDom.value.childNodes
for (let element of elements) {
if (element.nodeType === Node.TEXT_NODE && element.nodeValue.trim() !== '') {
hasText = true
} else if (element.tagName === 'IMG') {
if (element.dataset.type === 'file-canvas') {
hasFile = true
} else {
hasImage = true
}
} else if (element.tagName === 'VIDEO' || (element.tagName === 'A' && element.href.match(/\.(mp4|webm)$/i))) {
hasVideo = true
}
}
if (hasFile) {
return MsgEnum.FILE
} else if (hasVideo) {
return MsgEnum.VIDEO
} else if (hasText && hasImage) {
return MsgEnum.MIXED
} else if (hasImage) {
return MsgEnum.IMAGE
} else {
return MsgEnum.TEXT
}
}
/* 处理发送信息事件 */
// TODO (nyh -> 2024-03-01 07:03:43)
const send = () => {
ait.value = false
const contentType = getMessageContentType()
const msg = {
type: contentType,
content: msgInput.value,
hyperlinks: [] as any
}
const hyperlinkRegex = /(\bhttps?:\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi
const foundHyperlinks = msg.content.match(hyperlinkRegex)
// TODO (nyh -> 2024-03-28 19:41:06)
if (foundHyperlinks && foundHyperlinks.length > 0) {
msg.hyperlinks = foundHyperlinks
msg.content = msg.content.replace(hyperlinkRegex, (match) => {
return `<a class="color-inherit" href="${match}" target="_blank" rel="noopener noreferrer">${match}</a>`
})
}
//
if (msg.type === MsgEnum.TEXT && msg.content.length > 2000) {
window.$message.info('消息内容超过限制2000请删减内容')
return
}
// TODO (nyh -> 2024-02-28 06:32:13)
if (msg.type === MsgEnum.MIXED) {
window.$message.error('暂不支持混合类型消息发送')
return
}
Mitt.emit(MittEnum.SEND_MESSAGE, msg)
msgInput.value = ''
messageInputDom.value.innerHTML = ''
}
/* 获取@字符后面输入的内容 */
/* 当输入框手动输入值的时候触发input事件(使用vueUse的防抖) */
const handleInput = useDebounceFn((e: Event) => {
msgInput.value = (e.target as HTMLInputElement).innerHTML
ait.value = msgInput.value.includes('@')
/* 处理输入@时候弹出框 */
if (ait.value) {
const atIndex = msgInput.value.lastIndexOf('@')
aitKey.value = msgInput.value.slice(atIndex + 1)
if (filteredList.value.length > 0) {
//
const selection = window.getSelection()
//
const range = selection?.getRangeAt(0)
const res = range?.getBoundingClientRect() as any
nextTick(() => {
const dom = document.querySelector('.ait') as HTMLElement
dom.style.position = 'fixed'
dom.style.left = `${res?.x - 20}px`
dom.style.top = `${res?.y - (dom.offsetHeight + 5)}px`
})
} else {
ait.value = false
}
}
}, 100)
/* input的keydown事件 */
const inputKeyDown = (e: KeyboardEvent) => {
if (msgInput.value === '' || msgInput.value.trim() === '') {
e?.preventDefault()
return
}
if (
(chat.value.sendKey === 'Enter' && e.key === 'Enter' && !e.ctrlKey) ||
(chat.value.sendKey === 'Ctrl+Enter' && e.ctrlKey && e.key === 'Enter')
) {
e?.preventDefault()
send()
}
}
/* 处理点击@提及框事件 */
const handleAit = (item: MockItem) => {
// @
const atIndex = msgInput.value.lastIndexOf('@')
// @
msgInput.value = msgInput.value.substring(0, atIndex)
messageInputDom.value.innerHTML = msgInput.value
// ()
messageInputDom.value.focus()
const sel = window.getSelection()
const res = sel?.getRangeAt(0)
res?.setStart(messageInputDom.value, messageInputDom.value.childNodes.length)
insertNode(MsgEnum.AIT, item.accountName)
triggerInputEvent(messageInputDom.value)
ait.value = false
}
const closeMenu = (event: any) => { const closeMenu = (event: any) => {
/* 需要判断点击如果不是.context-menu类的元素的时候menu才会关闭 */ /* 需要判断点击如果不是.context-menu类的元素的时候menu才会关闭 */
if (!event.target.matches('#message-input, #message-input *')) { if (!event.target.matches('#message-input, #message-input *')) {

326
src/hooks/useMsgInput.ts Normal file
View File

@ -0,0 +1,326 @@
import { MittEnum, MsgEnum } from '@/enums'
import { createFileOrVideoDom } from '@/utils/CreateDom.ts'
import { Ref } from 'vue'
import { MockItem } from '@/services/types.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
import { useDebounceFn } from '@vueuse/core'
import Mitt from '@/utils/Bus.ts'
import { MockList } from '@/mock'
export const useMsgInput = (messageInputDom: Ref) => {
const settingStore = setting()
const { chat } = storeToRefs(settingStore)
const chatKey = ref(chat.value.sendKey)
const msgInput = ref('')
const ait = ref(false)
/* 艾特后的关键字的key */
const aitKey = ref('')
// 过滤MockList
const filteredList = computed(() => {
if (aitKey.value) {
return MockList.value.filter((item) => item.accountName.includes(aitKey.value))
} else {
return MockList.value
}
})
/* 右键菜单列表 */
const menuList = ref([
{ label: '剪切', icon: 'screenshot', disabled: true },
{ label: '复制', icon: 'copy', disabled: true },
{
label: '粘贴',
icon: 'intersection',
click: () => {
navigator.clipboard.read().then((items) => {
const clipboardItem = items[0] // 获取剪贴板的第一项
if (clipboardItem.types.includes('text/plain')) {
// 如果是文本,使用 readText() 读取文本内容
navigator.clipboard.readText().then((text) => {
insertNode(MsgEnum.TEXT, text)
triggerInputEvent(messageInputDom.value)
})
} else if (clipboardItem.types.find((type) => type.startsWith('image/'))) {
// 检查第一项是否是图像
// TODO 右键粘贴动图的时候无法动起来右键粘贴没有获取到类型是gif而是html加上png的格式 (nyh -> 2024-02-27 03:27:10)
const imageType = clipboardItem.types.find((type) => type.startsWith('image/'))
clipboardItem.getType(imageType as any).then((blob) => {
imgPaste(blob)
})
}
})
}
},
{ label: '另存为', icon: 'download', disabled: true },
{ label: '全部选择', icon: 'check-one' }
])
watchEffect(() => {
chatKey.value = chat.value.sendKey
})
watch(chatKey, (v) => {
chat.value.sendKey = v
})
/* 触发输入框事件(粘贴的时候需要重新触发这个方法) */
const triggerInputEvent = (element: HTMLElement) => {
if (element) {
const event = new Event('input', {
bubbles: true,
cancelable: true
})
element.dispatchEvent(event)
}
}
/**
*
* @param { MsgEnum } type
* @param dom dom节点
*/
const insertNode = (type: MsgEnum, dom: any) => {
// 获取光标
const selection = window.getSelection()
// 获取选中的内容
const range = selection?.getRangeAt(0)
// 删除选中的内容
range?.deleteContents()
// 将节点插入范围最前面添加节点
if (type === MsgEnum.AIT) {
// 创建一个span标签节点
const spanNode = document.createElement('span')
spanNode.id = 'aitSpan' // 设置id为aitSpan
spanNode.contentEditable = 'false' // 设置为不可编辑
spanNode.classList.add('text-#13987f')
spanNode.classList.add('select-none')
spanNode.classList.add('cursor-default')
// 在span标签后面添加一个空格
spanNode.appendChild(document.createTextNode(`@${dom}`))
// 将span标签插入到光标位置
range?.insertNode(spanNode)
range?.setStart(messageInputDom.value, messageInputDom.value.childNodes.length)
// 创建一个空格文本节点
const spaceNode = document.createTextNode('\u00A0')
// 将空格文本节点插入到光标位置
range?.insertNode(spaceNode)
} else if (type === MsgEnum.TEXT) {
range?.insertNode(document.createTextNode(dom))
} else {
range?.insertNode(dom)
}
// 将光标移到选中范围的最后面
selection?.collapseToEnd()
}
/* 处理图片粘贴事件 */
const imgPaste = (file: any) => {
const reader = new FileReader()
reader.onload = (e: any) => {
const img = document.createElement('img')
img.src = e.target.result
// 设置图片的最大高度和最大宽度
img.style.maxHeight = '88px'
img.style.maxWidth = '140px'
img.style.marginRight = '6px'
// 插入图片
insertNode(MsgEnum.IMAGE, img)
triggerInputEvent(messageInputDom.value)
}
reader.readAsDataURL(file)
}
/**
*
* @param file
* @param type
*/
const FileOrVideoPaste = (file: File, type: MsgEnum) => {
const reader = new FileReader()
// 使用函数
createFileOrVideoDom(file).then((imgTag) => {
// 将生成的img标签插入到页面中
insertNode(type, imgTag)
triggerInputEvent(messageInputDom.value)
})
reader.readAsDataURL(file)
}
/* 处理粘贴事件 */
const handlePaste = (e: any) => {
e.preventDefault()
if (e.clipboardData.files.length > 0) {
if (e.clipboardData.files.length > 5) {
window.$message.warning('一次性只能上传5个文件')
return
}
for (const file of e.clipboardData.files) {
// 检查文件大小
const fileSizeInMB = file.size / 1024 / 1024 // 将文件大小转换为兆字节(MB)
if (fileSizeInMB > 300) {
window.$message.warning(`文件 ${file.name} 超过300MB`)
continue // 如果文件大小超过300MB就跳过这个文件处理下一个文件
}
const fileType = file.type as string
if (fileType.startsWith('image/')) {
// 处理图片粘贴
imgPaste(file)
} else if (fileType.startsWith('video/')) {
// 处理视频粘贴
FileOrVideoPaste(file, MsgEnum.VIDEO)
} else {
// 处理文件粘贴
FileOrVideoPaste(file, MsgEnum.FILE)
}
}
} else {
// 如果没有文件,而是文本,处理纯文本粘贴
const plainText = e.clipboardData.getData('text/plain')
insertNode(MsgEnum.TEXT, plainText)
triggerInputEvent(messageInputDom.value)
}
}
/* 获取messageInputDom输入框中的内容类型 */
const getMessageContentType = () => {
let hasText = false
let hasImage = false
let hasVideo = false
let hasFile = false
const elements = messageInputDom.value.childNodes
for (const element of elements) {
if (element.nodeType === Node.TEXT_NODE && element.nodeValue.trim() !== '') {
hasText = true
} else if (element.tagName === 'IMG') {
if (element.dataset.type === 'file-canvas') {
hasFile = true
} else {
hasImage = true
}
} else if (element.tagName === 'VIDEO' || (element.tagName === 'A' && element.href.match(/\.(mp4|webm)$/i))) {
hasVideo = true
}
}
if (hasFile) {
return MsgEnum.FILE
} else if (hasVideo) {
return MsgEnum.VIDEO
} else if (hasText && hasImage) {
return MsgEnum.MIXED
} else if (hasImage) {
return MsgEnum.IMAGE
} else {
return MsgEnum.TEXT
}
}
/* 处理发送信息事件 */
// TODO 输入框中的内容当我切换消息的时候需要记录之前输入框的内容 (nyh -> 2024-03-01 07:03:43)
const send = () => {
ait.value = false
const contentType = getMessageContentType()
const msg = {
type: contentType,
content: msgInput.value
}
const hyperlinkRegex = /(\b(?:https?:\/\/|www)[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi
const foundHyperlinks = msg.content.match(hyperlinkRegex)
if (foundHyperlinks && foundHyperlinks.length > 0) {
msg.content = msg.content.replace(hyperlinkRegex, (match) => {
const href = match.startsWith('www.') ? 'https://' + match : match
return `<a style="color: inherit" href="${href}" target="_blank" rel="noopener noreferrer">${match}</a>`
})
}
// 判断文本信息是否超过限制
if (msg.type === MsgEnum.TEXT && msg.content.length > 2000) {
window.$message.info('消息内容超过限制2000请删减内容')
return
}
// TODO 当输入的类型是混合类型如输入文本加上图片的类型需要处理 (nyh -> 2024-02-28 06:32:13)
if (msg.type === MsgEnum.MIXED) {
window.$message.error('暂不支持混合类型消息发送')
return
}
Mitt.emit(MittEnum.SEND_MESSAGE, msg)
msgInput.value = ''
messageInputDom.value.innerHTML = ''
}
/* 当输入框手动输入值的时候触发input事件(使用vueUse的防抖) */
const handleInput = useDebounceFn(async (e: Event) => {
msgInput.value = (e.target as HTMLInputElement).innerHTML
ait.value = msgInput.value.includes('@')
/* 处理输入@时候弹出框 */
if (ait.value) {
const atIndex = msgInput.value.lastIndexOf('@')
aitKey.value = msgInput.value.slice(atIndex + 1)
if (filteredList.value.length > 0) {
// 获取光标
const selection = window.getSelection()
// 获取选中的内容
const range = selection?.getRangeAt(0)
const res = range?.getBoundingClientRect() as any
await nextTick(() => {
const dom = document.querySelector('.ait') as HTMLElement
dom.style.position = 'fixed'
dom.style.left = `${res?.x - 20}px`
dom.style.top = `${res?.y - (dom.offsetHeight + 5)}px`
})
} else {
ait.value = false
}
}
}, 100)
/* input的keydown事件 */
const inputKeyDown = (e: KeyboardEvent) => {
if (msgInput.value === '' || msgInput.value.trim() === '') {
e?.preventDefault()
return
}
if (
(chat.value.sendKey === 'Enter' && e.key === 'Enter' && !e.ctrlKey) ||
(chat.value.sendKey === 'Ctrl+Enter' && e.ctrlKey && e.key === 'Enter')
) {
e?.preventDefault()
send()
}
}
/* 处理点击@提及框事件 */
const handleAit = (item: MockItem) => {
// 查找最后一个@字符的位置
const atIndex = msgInput.value.lastIndexOf('@')
// 截取@字符以及其后面的内容
msgInput.value = msgInput.value.substring(0, atIndex)
messageInputDom.value.innerHTML = msgInput.value
// 重新聚焦输入框(聚焦到输入框开头)
messageInputDom.value.focus()
const sel = window.getSelection()
const res = sel?.getRangeAt(0)
res?.setStart(messageInputDom.value, messageInputDom.value.childNodes.length)
insertNode(MsgEnum.AIT, item.accountName)
triggerInputEvent(messageInputDom.value)
ait.value = false
}
return {
handlePaste,
insertNode,
triggerInputEvent,
imgPaste,
FileOrVideoPaste,
inputKeyDown,
handleAit,
handleInput,
send,
filteredList,
ait,
msgInput,
chatKey,
menuList
}
}