mirror of
https://gitee.com/HuLaSpark/HuLa.git
synced 2024-11-29 18:28:30 +08:00
⚡ perf(system): 优化聊天框中常用功能
优化右键菜单复制功能 优化原生复制功能 优化网址识别功能
This commit is contained in:
parent
eba6395966
commit
42d2453991
@ -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
|
||||||
|
@ -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
BIN
preview/img_4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 250 KiB |
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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 右键粘贴动图的时候无法动起来右键粘贴没有获取到类型是gif而是html加上png的格式 (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' // 设置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 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
326
src/hooks/useMsgInput.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user