admin-vue3/src/views/im/home/pages/conversation/components/input/MessageInput.vue

1027 lines
39 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<!--
外层底色与消息流bg-color-page保持一致"消息 → 输入"无色差过渡
padding 给内层白卡片呼吸空间卡片自带边框就够区分输入区不再需要一条 border-t
-->
<div class="relative bg-[var(--el-bg-color-page)] px-3 pt-2 pb-3">
<!--
内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信"输入框"边界
避免之前"无框 Web 输入"的散开感border scoped CSSUnoCSS 不带 border-style preflight
-->
<div class="message-input__card relative flex flex-col bg-[var(--el-bg-color)] rounded-lg">
<!--
输入区在上contenteditable div取代 textarea对齐微信 PC输入区在上操作在下
- @ 浮层能拿到真实光标 recttextarea 拿不到
- @ 成员以 <span data-id> token 节点存在 token 即删 id避免 stale atUserIds
- placeholder 通过 data-empty + ::before 模拟contenteditable 没有原生 placeholder
-->
<div
ref="editorRef"
class="message-input__editor"
contenteditable="true"
data-placeholder=" Enter Shift+Enter "
data-empty=""
role="textbox"
@keydown="onKeydown"
@input="onInput"
@scroll.passive="onEditorScroll"
@paste.prevent="onPaste"
></div>
<!--
底部工具栏左侧操作图标 + 右侧发送按钮对齐微信 PC操作图标统一放底部
- relative EmojiPicker 提供 absolute 锚点picker bottom-full 向上弹出
- 图标统一 30×30 点击区18px icon + p-1.5gap-1 让间距贴合微信观感
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线scoped CSS 避绕 UnoCSS preflight 缺失
-->
<div
class="message-input__toolbar relative flex items-center justify-between gap-2 px-3 py-2"
>
<div class="flex items-center gap-1">
<!--
所有 icon 统一走 Iconifyant-design outlined 系列
- 视觉风格更接近微信 PC线性圆角 Element Plus 内置的更轻量
- 笑脸 / 图片 / 文件夹 / 麦克风 同源避免一个走 ep 一个走 antd 视觉割裂
- 外层 span 复用 .message-input__tool padding / hover 样式scoped CSS :deep(svg) 仍能命中
-->
<el-tooltip content="表情" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click.stop="toggleEmoji"
>
<Icon icon="ant-design:smile-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送图片" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="imageInputRef?.click()"
>
<Icon icon="ant-design:picture-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送文件" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="fileInputRef?.click()"
>
<Icon icon="ant-design:folder-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="语音消息" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="voiceVisible = true"
>
<Icon icon="ant-design:audio-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送视频" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="videoInputRef?.click()"
>
<Icon icon="ant-design:video-camera-outlined" :size="18" />
</span>
</el-tooltip>
</div>
<!-- 群聊发送按钮 + 下拉菜单点主按钮普通发送 / 发送回执消息对齐微信 PC -->
<el-dropdown
v-if="isGroup"
split-button
type="primary"
:disabled="!canSend"
@click="handleSend()"
@command="handleSendCommand"
>
发 送
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="receipt">发送回执消息</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 私聊:普通发送按钮(私聊没有群回执概念) -->
<el-button v-else type="primary" :disabled="!canSend" @click="handleSend()">
</el-button>
<!-- 表情面板bottom-full picker 下沿贴工具栏顶部向上弹出对齐工具栏左侧首图标 -->
<EmojiPicker
v-model:visible="emojiVisible"
class="bottom-full left-3 mb-2"
@select="insertText"
/>
</div>
</div>
<!-- @ 选择浮层群聊才启用 -->
<MentionPicker
ref="mentionRef"
v-model:visible="mentionVisible"
:position="mentionPosition"
:members="groupMembers"
:search-text="mentionSearchText"
:owner-id="groupOwnerId"
@select="onMentionSelect"
/>
<!-- 语音录制对话框 -->
<VoiceRecorder v-model="voiceVisible" @send="onVoiceSend" />
<!-- 隐藏的文件选择器 -->
<input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" />
<input ref="fileInputRef" type="file" hidden @change="onFilePicked" />
<input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" />
</div>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { updateFile } from '@/api/infra/file'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore'
import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
serializeMessage,
type ImageMessage,
type FileMessage,
type AudioMessage,
type VideoMessage
} from '@/views/im/utils/message'
import EmojiPicker from './EmojiPicker.vue'
import MentionPicker from './MentionPicker.vue'
import VoiceRecorder from './VoiceRecorder.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
defineOptions({ name: 'ImMessageInput' })
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const { send, sendRaw } = useMessageSender()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef')
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
// ==================== 文本 / 发送 ====================
const canSend = ref(false) // editor 是否有可发送内容contenteditable 没 v-model靠 input 事件主动同步
/** 维护 canSend + data-empty撑起 placeholder不写草稿restoreDraftToEditor 复用避免回流 */
function applyEditorUiState(editor: HTMLDivElement) {
const raw = editor.textContent || ''
// canSend 按 trim 后判断(空格 / 换行不算可发送内容)
canSend.value = !!raw.trim() && !!conversationStore.activeConversation
// data-empty 按原始内容判断:用户敲一个空格也要让 placeholder 隐藏,避免视觉叠加
// 用属性"存在 / 缺失"而非 'true'/'false' 字符串CSS [data-empty]::before 命中即可,
// 比 [data-empty='true'] 直观;浏览器删空后留 <br> → :empty 不命中,所以必须 JS 维护
if (raw) {
delete editor.dataset.empty
} else {
editor.dataset.empty = ''
}
}
/** 用户编辑入口的统一收尾UI 状态同步 + 草稿写回 store列表立即出 [草稿] 前缀) */
function syncEditorState() {
const editor = editorRef.value
if (!editor) {
return
}
applyEditorUiState(editor)
syncDraftToStore(editor)
}
/** 把 editor 当前内容写到 draftStoreplain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
function syncDraftToStore(editor: HTMLDivElement) {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
// collectFromEditor 已 trimplain 为空时 store 内部按 clearDraft 处理
const { text } = collectFromEditor(editor)
draftStore.setDraft(conversation, { html: editor.innerHTML, plain: text })
}
/** 切会话时把 store 里的草稿还原到 editor只更 UI 不回写草稿,避免 store→editor→store 回流 */
function restoreDraftToEditor() {
const editor = editorRef.value
if (!editor) {
return
}
const conversation = conversationStore.activeConversation
const draft = conversation ? draftStore.getDraft(conversation) : undefined
editor.innerHTML = draft?.html || ''
applyEditorUiState(editor)
// 把光标移到末尾,让用户接着输入;空内容直接 focus 即可
if (draft?.html) {
placeCaretAtEnd(editor)
}
}
/** 把光标放到 contenteditable 元素的末尾——切回有草稿的会话时光标自然落在尾部,对齐微信 */
function placeCaretAtEnd(el: HTMLElement) {
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
if (!sel) {
return
}
sel.removeAllRanges()
sel.addRange(range)
}
/**
* 走 DOM 把 editor 内容拼回 plain text + atUserIds
*
* 节点分支:
* 1. text 节点:直接拼 textContent过程中滤掉 ZWSPtoken 首位锚点用,不进发送内容)
* 2. br拼 \nShift+Enter 走 execCommand('insertLineBreak') 产物)
* 3. span[data-id]:拼 token 显示文本 + 把 dataset.id 收到 atUserIds不递归 span 内部)
* 4. div浏览器在 contenteditable 里默认换行容器,前置 \n 后递归子节点
* 5. 其他元素:透传,递归子节点
*
* atUserIds 走 Set 去重:用户 @ 张三两次时 atUserIds 只出现一次trim 末尾空白
*/
function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number[] } {
const userIds: number[] = []
let text = ''
function walk(node: Node) {
if (node.nodeType === Node.TEXT_NODE) {
text += (node.textContent || '').replace(//g, '')
return
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return
}
const el = node as HTMLElement
const tag = el.tagName.toLowerCase()
if (tag === 'br') {
text += '\n'
return
}
if (tag === 'span' && el.dataset.id) {
text += el.textContent || ''
const id = Number(el.dataset.id)
if (!Number.isNaN(id)) {
userIds.push(id)
}
return
}
if (tag === 'div') {
if (text && !text.endsWith('\n')) {
text += '\n'
}
el.childNodes.forEach(walk)
return
}
el.childNodes.forEach(walk)
}
// 直接从 root.childNodes 开始,避免把 root 本身也当元素处理(虽然目前没有特殊样式,但以防未来改动)
root.childNodes.forEach(walk)
return {
text: text.trim(),
atUserIds: [...new Set(userIds)]
}
}
/**
* 发送:从 DOM 收 text + atUserIds → 清空编辑器 → 走 useMessageSender.send
*
* 1. 防御canSend 是 falsetrim 后空 / 没激活会话)或 editor 没 mount → 直接 return
* 2. 收集DOM walk 拿到要发送的文本 + atUserIds
* 3. 二次防御collectFromEditor 走 trim可能比 syncEditorState 更严格(例如全 ZWSP仍空就 return
* 4. 清空 + 同步状态:先清 innerHTML 再 syncEditorState 让 placeholder / canSend 一起回归
* (顺序很重要:先清后 sync否则 sync 看到旧内容会误判)
* 5. 上送atUserIds 非空才传,避免发空数组
*/
async function handleSend(options?: { receipt?: boolean }) {
const editor = editorRef.value
if (!canSend.value || !editor) {
return
}
const { text, atUserIds } = collectFromEditor(editor)
if (!text) {
return
}
// 1. 清空 editor + 当前会话草稿syncEditorState 后 plain 已为空store 内部会自动清,
// 但显式 clearDraft 能立即同步、不依赖 debounce 时序,列表上的 [草稿] 立即消失
editor.innerHTML = ''
if (conversationStore.activeConversation) {
draftStore.clearDraft(conversationStore.activeConversation)
}
syncEditorState()
// 2. 发送
await send(text, {
atUserIds: atUserIds.length > 0 ? atUserIds : undefined,
receipt: options?.receipt
})
}
/** 发送按钮 dropdown 菜单回调:选"发送回执消息"时这一次带 receipt=true每次独立决定 */
function handleSendCommand(command: string) {
if (command === 'receipt') {
handleSend({ receipt: true })
}
}
// ==================== 选区 / 插入 ====================
/**
* 上次落在 editor 内的 selection焦点被表情面板等夺走时用来回到原插入点
*
* 监听 document.selectionchange 比 editor.@blur 更可靠blur 时 selection 已经移走
*/
let savedRange: Range | null = null
/**
* 走 native execCommand保留浏览器原生 undo 栈
*
* execCommand 在 lib.dom 里被标 @deprecatedIDE 显示删除线),但 'insertText' /
* 'insertLineBreak' 没有等价的 W3C 标准替代——Range/Selection 自己拼 DOM 会让 Ctrl+Z 失效。
* 集中到这里调用,调用点不必散落 ts-expect-error不引入 eslint disable项目当前
* @typescript-eslint v7 没有 no-deprecated 规则,加了也无效,反而让 lint 报"规则不存在"
*/
function nativeExec(command: 'insertText' | 'insertLineBreak', value?: string) {
document.execCommand(command, false, value)
}
/**
* document selectionchange 监听:把落在 editor 内的 selection 缓存到 savedRange
* insertText 在焦点被偷走后用它把光标恢复到原插入点
*/
function onSelectionChange() {
const editor = editorRef.value
const sel = window.getSelection()
if (!editor || !sel || sel.rangeCount === 0) {
return
}
const range = sel.getRangeAt(0)
if (editor.contains(range.startContainer)) {
savedRange = range.cloneRange()
}
}
/**
* 点击 editor / picker 外部时关掉浮层,避免输入 @keyword 后用户点别处浮层不消失
*
* 用 mousedown 而非 clickclick 在某些浏览器里 picker 元素消失后回不到原 target会被吞掉
*/
function onDocMousedown(e: MouseEvent) {
if (!mentionVisible.value) {
return
}
const target = e.target as Node | null
if (!target) {
return
}
if (editorRef.value?.contains(target)) {
return
}
// picker 是 fixed 定位的兄弟节点,不在 editor 子树里;用类名定位
const pickerEl = document.querySelector('.message-input__mention-picker')
if (pickerEl?.contains(target)) {
return
}
closeMention()
}
onMounted(() => {
document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('mousedown', onDocMousedown)
restoreDraftToEditor()
})
onBeforeUnmount(() => {
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('mousedown', onDocMousedown)
})
/**
* 切会话时还原对方的草稿到 editor
*
* 同步关 @ / 表情 / 语音浮层并清 savedRange
* - mentionRange / savedRange 旧引用还指向上一会话的 DOM 节点,不清下次插 token 会落错位置
* - 语音录制弹窗保留时,录完触发的 onVoiceSend 会读当前 activeConversation把语音发到新会话
*/
watch(
() =>
conversationStore.activeConversation
? getConversationKey(conversationStore.activeConversation)
: null,
() => {
closeMention()
emojiVisible.value = false
voiceVisible.value = false
savedRange = null
restoreDraftToEditor()
}
)
/**
* 把字符串插入光标处emoji 面板等场景调用)
*
* 1. editor 没挂直接返回
* 2. 焦点回到 editor + 把 savedRange 恢复成当前 selectionemoji 面板偷焦点后还能回原位)
* 3. nativeExec 插文本,保留浏览器原生 undo 栈
* 4. 同步 canSend / placeholder
*/
function insertText(str: string) {
const editor = editorRef.value
if (!editor) {
return
}
editor.focus()
if (savedRange) {
const sel = window.getSelection()
if (sel) {
sel.removeAllRanges()
sel.addRange(savedRange)
}
}
// 1. nativeExec 插文本,保留浏览器原生 undo 栈
nativeExec('insertText', str)
// 2. 同步 canSend / placeholder
syncEditorState()
}
/**
* 粘贴处理
*
* 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
* - image/* → 走 IMAGE 上传发送
* - 其它 file → 走 FILE 上传发送
* - 一次粘贴只处理第一个文件,避免一次粘贴发出多条消息
* 2. 没文件再走 plain text剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
* contenteditable 默认粘贴会带 HTML所以模板上 @paste.prevent 拦截)
*/
function onPaste(e: ClipboardEvent) {
// 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
const items = e.clipboardData?.items
if (items?.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.kind !== 'file') {
continue
}
const file = item.getAsFile()
if (!file) {
continue
}
if (item.type.startsWith('image/')) {
void uploadAndSendImage(file)
} else {
void uploadAndSendFile(file)
}
return
}
}
// 2. 没文件再走 plain text剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
const text = e.clipboardData?.getData('text/plain') || ''
if (text) {
nativeExec('insertText', text)
// @paste.prevent 阻断了浏览器默认 input 事件,需手动同步草稿 / canSend与 insertText() 路径一致
syncEditorState()
}
}
/** 编辑器内容变化的统一入口:先同步 canSend / placeholder再判 @ 浮层是否要展开 */
function onInput() {
syncEditorState()
detectAtMention()
}
// ==================== 表情 ====================
const emojiVisible = ref(false)
function toggleEmoji() {
emojiVisible.value = !emojiVisible.value
}
// ==================== @ 成员选择(群聊) ====================
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
/** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */
const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
}
const group = groupStore.getGroup(conversation.targetId)
return (group?.members || []).map((member) => {
const friend = friendStore.getFriend(member.userId)
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
})
const groupOwnerId = computed<number | undefined>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined
}
return groupStore.getGroup(conversation.targetId)?.ownerUserId
})
const mentionVisible = ref(false)
const mentionSearchText = ref('')
/** 浮层定位x 是左边距top / bottom 二选一—— bottom 锚定picker 下沿贴 @)是默认,
* 上方放不下时退化为 top 锚定picker 上沿贴 @ 下方) */
const mentionPosition = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
/** MentionPicker 的容器宽度(与组件里的 w-50 对齐),用于视口右沿回弹;
* 高度不再用常量算位置——bottom 锚定后 picker 内容多寡都不影响下沿位置,自然贴 @ */
const MENTION_WIDTH = 200
/** 上方剩余空间至少这么多才放上方,否则翻到下方(避免 picker 被视口顶 / 顶部 chat header 切掉) */
const MENTION_MIN_FIT_ABOVE = 120
/** 当前 @ 关键词在 editor 里的范围onMentionSelect 用它定位删除 + 插入 token */
let mentionRange: Range | null = null
/** 关闭浮层 + 清掉 range避免上次残留的 range 被下一次 onMentionSelect 误用 */
function closeMention() {
mentionVisible.value = false
mentionRange = null
}
/** 在光标当前文本节点里向前找 @keyword命中则展开浮层 */
function detectAtMention() {
if (!isGroup.value) {
closeMention()
return
}
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) {
closeMention()
return
}
const range = sel.getRangeAt(0)
if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) {
closeMention()
return
}
const node = range.startContainer
const offset = range.startOffset
const before = (node.textContent || '').slice(0, offset)
// 直接找最近的 @:不限制前置字符,对齐微信"中文紧贴 @ 也能联想"(你好@张三、,@张三 都触发);
// 代价是 email-like "test@example.com" 也会触发,但聊天输入里粘 email 的概率低,
// 且用户按 Esc 即可关浮层;兜底由 [^\s@] 保证一旦输入空格 / 第二个 @ 就停下
const match = before.match(/@([^\s@]*)$/)
if (!match) {
closeMention()
return
}
const atOffset = offset - match[1].length - 1
mentionRange = document.createRange()
mentionRange.setStart(node, atOffset)
mentionRange.setEnd(node, offset)
mentionSearchText.value = match[1]
// 锚定在 @ 符号本身,而非当前 caret——否则用户每多敲一个字浮层就跟着右移"飘"
positionMention(node, atOffset)
mentionVisible.value = true
}
/**
* 浮层位置:默认 bottom 锚定picker 下沿贴 @ 上方 8px上方不够才翻成 top 锚定
*
* 1. 计算 @ 字符屏幕坐标 rect
* 2. 横向picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
* 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定(不依赖 picker 实际高度,
* 无论 1 项还是 N 项 picker 下沿都贴 @);不够则翻到 @ 下方走 top 锚定
*/
function positionMention(node: Node, atOffset: number) {
// 1. 计算 @ 字符屏幕坐标 rect
const anchor = document.createRange()
anchor.setStart(node, atOffset)
anchor.collapse(true)
const rect = anchor.getBoundingClientRect()
// 2. 横向picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
// 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
mentionPosition.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
} else {
mentionPosition.value = { x: left, top: rect.bottom + 8 }
}
}
/** editor 内部滚动时同步浮层位置(多行 + 触发滚动条场景) */
function onEditorScroll() {
if (!mentionVisible.value || !mentionRange) {
return
}
positionMention(mentionRange.startContainer, mentionRange.startOffset)
}
function onMentionSelect(member: GroupMemberLite) {
const editor = editorRef.value
if (!editor || !mentionRange) {
return
}
// 删 @keyword插入 contenteditable=false 的 token
// 删除时整段消除 + 不会被光标拆穿data-id 是后续 collectFromEditor 收 atUserIds 的钩子
mentionRange.deleteContents()
const span = document.createElement('span')
span.className = 'mention-token'
span.dataset.id = String(member.userId)
span.contentEditable = 'false'
span.textContent = `@${member.showName}`
mentionRange.insertNode(span)
// token 在 editor 首位时contenteditable=false 边缘会让光标无法挪到 token 前
// 补一个零宽空格 当锚点DOM walk 时会被滤掉,不进入发送内容
const prev = span.previousSibling
if (!prev || (prev.nodeType === Node.TEXT_NODE && !prev.textContent)) {
span.parentNode?.insertBefore(document.createTextNode(''), span)
}
// 在 token 后补一个 NBSP让光标可以继续输入NBSP 比普通空格更稳,避免被浏览器折叠
const space = document.createTextNode(' ')
span.parentNode?.insertBefore(space, span.nextSibling)
// 光标移到 NBSP 之后
const sel = window.getSelection()
if (sel) {
const newRange = document.createRange()
newRange.setStartAfter(space)
newRange.collapse(true)
sel.removeAllRanges()
sel.addRange(newRange)
}
closeMention()
editor.focus()
syncEditorState()
}
/**
* 键盘事件分发
*
* 1. mention 浮层打开时
* 1.1 ↑/↓ 移动高亮
* 1.2 Enter 有候选 → 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
* 1.3 Esc 关浮层
* 2. Shift+Enter 换行:强制走 br浏览器默认会插 divDOM walk 拼接更复杂)
* 3. 普通 Enter 发送IME composition 期间不触发,避免选词被误发)
*/
function onKeydown(e: KeyboardEvent) {
// 1. mention 浮层打开时
if (mentionVisible.value) {
if (e.key === 'ArrowUp') {
e.preventDefault()
mentionRef.value?.moveUp()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
mentionRef.value?.moveDown()
return
}
if (e.key === 'Enter' && !e.isComposing) {
if (mentionRef.value?.hasCandidates()) {
e.preventDefault()
mentionRef.value?.pickActive()
return
}
}
if (e.key === 'Escape') {
closeMention()
return
}
}
// 2. Shift+Enter 换行:强制走 br浏览器默认会插 divDOM walk 拼接更复杂)
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
e.preventDefault()
nativeExec('insertLineBreak')
syncEditorState()
return
}
// 3. 普通 Enter 发送IME composition 期间不触发,避免选词被误发)
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
e.preventDefault()
handleSend()
}
}
// ==================== 图片 / 文件上传 ====================
/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */
async function uploadAndSendImage(file: File) {
const form = new FormData()
form.append('file', file)
const url = ((await updateFile(form)) as { data?: string })?.data
if (!url) {
return
}
await sendRaw(ImMessageType.IMAGE, serializeMessage<ImageMessage>({ url }))
}
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
async function uploadAndSendFile(file: File) {
const form = new FormData()
form.append('file', file)
const url = ((await updateFile(form)) as { data?: string })?.data
if (!url) {
return
}
await sendRaw(
ImMessageType.FILE,
serializeMessage<FileMessage>({ url, name: file.name, size: file.size })
)
}
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor整体走 sendRaw */
async function onImagePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) {
await uploadAndSendImage(file)
}
}
/** 文件选完即上传 + 发送 FILE 消息(携带原始 name / size 元数据) */
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) {
await uploadAndSendFile(file)
}
}
// ==================== 语音 ====================
const voiceVisible = ref(false)
/** VoiceRecorder 录完后回传 blob包成 webm 文件上传,发送 VOICE 消息 */
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
const form = new FormData()
form.append('file', file)
// request.upload 返回完整 axios response不是 res.data跟 get/post/put 不一样URL 在 .data 里取
const url = ((await updateFile(form)) as { data?: string })?.data
if (!url) {
return
}
await sendRaw(
ImMessageType.VOICE,
serializeMessage<AudioMessage>({ url, duration: payload.duration })
)
}
// ==================== 视频 ====================
type VideoProbe = {
duration?: number
width?: number
height?: number
cover?: Blob
}
const VIDEO_COVER_MAX_DIM = 720 // 封面最长边 cap聊天列表里的视频封面没必要原视频分辨率4K 原尺寸 jpeg 1-3MB 太浪费
/**
* 加载视频本地预览,一次性拿到 metadataduration / 宽高)+ 首帧封面 blob
*
* 一个 video 元素串两件事是为了避免重复 decodemetadata 解完后直接 seek 首帧再截图。
* 截图失败不抛异常,只让 cover 缺失,保证主流程仍能上传视频本体。
*
* finally 里显式断引用是因为:仅 revokeObjectURL 不足以让 video decoder 立即释放,
* 部分浏览器版本上 4K 视频解码 buffer 可滞留数十 MB 几秒到十几秒,连发几条会累计放大。
*/
async function probeVideoFile(file: File): Promise<VideoProbe> {
// 1. 准备离屏 video
// 1.1 muted + preload=metadata只下载文件头不预加载整条流
const objectUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.src = objectUrl
try {
// 1.2 等 metadata 加载:解出 duration / 宽高才有 seek + 截图的依据
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve()
video.onerror = () => reject(new Error('video metadata load error'))
})
// 1.3 抽元信息duration 偶有 NaN极少数损坏文件软处理为 undefined
const meta = {
duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined,
width: video.videoWidth || undefined,
height: video.videoHeight || undefined
}
// 2. 截首帧封面(独立 try失败仅降级 cover 为空,不影响 meta
let cover: Blob | undefined
try {
// 2.1 算 seek 时间0.1s 避开常见的纯黑首帧;时长 < 0.2s 的极短视频退化为 0
const seekTo = video.duration > 0.2 ? 0.1 : 0
// 2.2 seek + 3s 超时currentTime 设为当前值(譬如已经是 0 的极短视频)
// 部分浏览器不触发 onseekedpromise 会一直 pending 卡死整条链路
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000)
video.onseeked = () => {
clearTimeout(timer)
resolve()
}
video.onerror = () => {
clearTimeout(timer)
reject(new Error('video seek error'))
}
video.currentTime = seekTo
})
// 2.3 离屏 canvas 等比缩放:长边 cap 720VIDEO_COVER_MAX_DIM
const canvas = document.createElement('canvas')
const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
canvas.width = Math.round(video.videoWidth * ratio)
canvas.height = Math.round(video.videoHeight * ratio)
const ctx = canvas.getContext('2d')
if (ctx && canvas.width && canvas.height) {
// 2.4 当前帧绘到 canvas → toBlob 拿 jpeg0.8 质量是聊天封面常用甜点
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
cover =
(await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
)) ?? undefined
// 2.5 提前释放 canvas backing store4K 原尺寸 33MB别等 GC
canvas.width = 0
canvas.height = 0
}
} catch (e) {
console.warn('[IM] 视频封面截取失败', e)
}
return { ...meta, cover }
} finally {
// 3. 显式释放 video 资源
// 3.1 revoke 本地 objectUrl
URL.revokeObjectURL(objectUrl)
// 3.2 解绑事件 + 触发 unload让 decoder buffer 立即释放(不然可滞留数十 MB 数秒)
video.onloadedmetadata = null
video.onseeked = null
video.onerror = null
video.removeAttribute('src')
video.load()
}
}
/**
* 上传并发送 VIDEO 消息
*
* 1. probe 与视频上传同步起跑;封面上传等 probe 出 cover 后与视频上传竞速
* probe 解码 + 封面上传通常被视频上传时长完全遮蔽,体感节省几百 ms 起步)
* 2. 视频本体上传必须成功,拿不到 url 就直接 return
* 3. 封面是锦上添花上传失败仅日志coverUrl 留空,气泡 <video> 自带黑底播放按钮兜底
*
* 视频链路耗时长probe + 双上传),上传期间用户切会话则放弃发送,
* 否则会落到错误的会话里切走再切回来不算变化key 仍相等)。
*/
async function uploadAndSendVideo(file: File) {
// 1. 锁定起始会话 key
// 1.1 上传期间用户切走则不发到错误目标;切走再切回来 key 仍相等,不算变化
const startConversation = conversationStore.activeConversation
if (!startConversation) {
return
}
const startKey = getConversationKey(startConversation)
// 2. 三路并行起跑probe 与两条上传无依赖,封面上传等 probe 出 cover 后立即接力)
// 2.1 视频本体上传:立即 catch 兜底为 url=undefined由 step 3.2 拿不到 url 时放弃;同时让 promise 不再 floating
const videoForm = new FormData()
videoForm.append('file', file)
const videoUploadPromise = (updateFile(videoForm) as Promise<{ data?: string }>).catch((e) => {
console.warn('[IM] 视频本体上传失败', e)
return { data: undefined as string | undefined }
})
// 2.2 probe 拿元信息 + 封面 blob解码失败降级为空 probe不阻断视频上传
const probePromise = probeVideoFile(file).catch((e): VideoProbe => {
console.warn('[IM] 视频元信息加载失败,降级为仅 url + size', e)
return {}
})
// 2.3 封面上传:等 probe.cover 出来后接力起跑,与视频上传竞速;失败降级 coverUrl 为空
const coverUploadPromise = probePromise.then(async (probe) => {
if (!probe.cover) {
return { probe, coverUrl: undefined as string | undefined }
}
try {
const coverForm = new FormData()
coverForm.append(
'file',
new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' })
)
const coverUrl = ((await updateFile(coverForm)) as { data?: string })?.data || undefined
return { probe, coverUrl }
} catch (e) {
console.warn('[IM] 视频封面上传失败', e)
return { probe, coverUrl: undefined as string | undefined }
}
})
// 3. 收口校验
// 3.1 等两条上传链路汇合
const [videoRes, { probe, coverUrl }] = await Promise.all([
videoUploadPromise,
coverUploadPromise
])
// 3.2 视频本体没 url 直接放弃(封面也不再有意义)
const url = videoRes?.data
if (!url) {
return
}
// 3.3 校验会话仍是发送时锁定的那个,否则放弃;视频链路耗时长,这个窗口很实际
const currentConversation = conversationStore.activeConversation
if (!currentConversation || getConversationKey(currentConversation) !== startKey) {
console.warn('[IM] 视频上传期间切换了会话,放弃发送', { startKey })
return
}
// 4. 拼 VideoMessage payload 走通用 sendRaw与图片 / 文件 / 语音同链路)
await sendRaw(
ImMessageType.VIDEO,
serializeMessage<VideoMessage>({
url,
coverUrl,
duration: probe.duration,
width: probe.width,
height: probe.height,
size: file.size
})
)
}
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor整体走 sendRaw */
async function onVideoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) {
await uploadAndSendVideo(file)
}
}
</script>
<style scoped>
/* 输入框卡片外框 + 编辑区与工具栏之间的分隔线UnoCSS 不带 border-style preflight
border-* 类只设色 / 宽不出线,统一走 scoped 显式 shorthand 兜底 */
.message-input__card {
border: 1px solid var(--el-border-color-lighter);
}
.message-input__toolbar {
border-top: 1px solid var(--el-border-color-lighter);
}
/* el-icon 全局规则 .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em}
会盖过 UnoCSS 原子类;用字面选择器 + !important 兜底。
颜色取 Element Plus 主题变量,暗色自动切到浅灰 */
.message-input__tool,
.message-input__tool:deep(svg) {
font-size: 18px !important;
color: var(--el-text-color-regular) !important;
fill: currentColor !important;
}
.message-input__tool:hover,
.message-input__tool:hover:deep(svg) {
color: var(--el-color-primary) !important;
}
/* 输入区在上、工具栏在下时,编辑区视觉上承担"主体"min-height / padding 都比早期版本撑大,
贴近微信 PC 的"大输入框"观感max-height 限内部滚动,避免聊天列表被挤太短 */
.message-input__editor {
position: relative;
min-height: 120px;
max-height: 200px;
overflow-y: auto;
padding: 14px 16px;
font-size: 14px;
line-height: 1.5;
outline: none;
white-space: pre-wrap;
word-break: break-word;
}
/* 用 data-empty 而非 :empty浏览器在删空后会留下 <br>:empty 不命中data-empty 由 syncEditorState 维护 */
.message-input__editor[data-empty]::before {
content: attr(data-placeholder);
color: var(--el-text-color-placeholder);
pointer-events: none;
position: absolute;
}
/* @ token 走主色高亮contenteditable=false 让 backspace 整段删而不是逐字符 */
.message-input__editor :deep(.mention-token) {
color: var(--el-color-primary);
}
</style>