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

1148 lines
43 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">
<!-- 禁言 / 封禁覆盖层优先级 封禁 > 全群禁言 > 成员禁言 -->
<div
v-if="muteOverlay"
class="message-input__mute-overlay"
:class="{
'message-input__mute-overlay--banned': muteOverlay.icon === 'ant-design:stop-outlined'
}"
>
<Icon :icon="muteOverlay.icon" :size="18" />
<span>{{ muteOverlay.text }}</span>
</div>
<!--
内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信"输入框"边界,
避免之前"无框 Web 输入"的散开感border 走 scoped CSSUnoCSS 不带 border-style preflight
-->
<div
class="relative flex flex-col bg-[var(--el-bg-color)] rounded-lg border border-[var(--el-border-color-lighter)]"
>
<!--
输入区在上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 relative min-h-[120px] max-h-[200px] overflow-y-auto py-3.5 px-4 text-sm leading-normal outline-none whitespace-pre-wrap break-words"
contenteditable="true"
data-placeholder="按 Enter 发送Shift+Enter 换行"
data-empty=""
role="textbox"
@keydown="onKeydown"
@input="onInput"
@scroll.passive="onEditorScroll"
@paste.prevent="onPaste"
></div>
<!-- 引用预览条 -->
<ReplyPreview
v-if="replyTarget"
:quote="replyTarget"
closable
class="mx-3 mb-1.5"
@close="clearReply"
/>
<!--
底部工具栏:左侧操作图标 + 右侧发送按钮(对齐微信 PC操作图标统一放底部
- relative 给 EmojiPicker 提供 absolute 锚点picker 用 bottom-full 向上弹出
- 图标统一 30×30 点击区18px icon + p-1.5gap-1 让间距贴合微信观感
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线scoped CSS 避绕 UnoCSS preflight 缺失)
-->
<div
class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-[var(--el-border-color-lighter)]"
>
<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.stop="openVoice"
>
<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"
/>
<!-- 语音录制面板与表情面板同处工具栏bottom-full 向上弹出避免离触发的麦克风图标过远 -->
<VoiceRecorder v-model="voiceVisible" class="bottom-full left-3 mb-2" @send="onVoiceSend" />
</div>
</div>
<!-- @ 选择浮层群聊才启用 -->
<MentionPicker
ref="mentionRef"
v-model:visible="mentionVisible"
:position="mentionPosition"
:members="groupMembers"
:search-text="mentionSearchText"
:owner-id="groupOwnerId"
@select="onMentionSelect"
/>
<!-- 隐藏的文件选择器 -->
<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 { useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
import {
serializeMessage,
type QuoteMessage,
withQuotePayload
} from '@/views/im/utils/message'
import EmojiPicker from './EmojiPicker.vue'
import MentionPicker from './MentionPicker.vue'
import VoiceRecorder from './VoiceRecorder.vue'
import ReplyPreview from '../message/ReplyPreview.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import type { Conversation } from '@/views/im/home/types'
defineOptions({ name: 'ImMessageInput' })
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const { send } = useMessageSender()
const {
uploadAndSendMedia,
insertMediaPlaceholder,
markMediaFailed,
commitMediaPlaceholder,
createUploadProgressHandler,
verifyMediaUploadStillAllowed,
requireMediaHandler
} = useMediaUploader()
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 && !muteOverlay.value
// 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 处理
// reply 透传当前快照setDraft 是整对象替换,不读旧 reply 会让用户每敲一个键就把引用条擦掉
const { text } = collectFromEditor(editor)
const existing = draftStore.getDraft(conversation)
draftStore.setDraft(conversation, {
html: editor.innerHTML,
plain: text,
reply: existing?.reply
})
}
/** 切会话时把 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 非空才传避免发空数组quote 由 clearDraft 前抓取,确保引用条立即消失
*/
async function handleSend(options?: { receipt?: boolean }) {
const editor = editorRef.value
if (!canSend.value || !editor || muteOverlay.value) {
return
}
const { text, atUserIds } = collectFromEditor(editor)
if (!text) {
return
}
// 1. 抓 quote 后清空 editor + 当前会话草稿(包含 reply);syncEditorState 后 plain / reply 都为空,
// store 内部会自动清,但显式 clearDraft 能立即同步、不依赖 debounce 时序,列表上的 [草稿] 立即消失
const replyQuote = replyTarget.value
editor.innerHTML = ''
if (conversationStore.activeConversation) {
draftStore.clearDraft(conversationStore.activeConversation)
}
syncEditorState()
// 2. 发送
await send(text, {
atUserIds: atUserIds.length > 0 ? atUserIds : undefined,
receipt: options?.receipt,
quote: replyQuote
})
}
/** 发送按钮 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()
}
// ==================== 引用 / 回复 ====================
/** 当前会话的「正在回复」对象,从 draftStore 派生(MessageItem 写、MessageInput 读) */
const replyTarget = computed<QuoteMessage | undefined>(() => {
const conversation = conversationStore.activeConversation
if (!conversation) {
return undefined
}
return draftStore.getDraft(conversation)?.reply
})
/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */
function clearReply() {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
draftStore.clearReply(conversation)
}
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
function consumeReply(): QuoteMessage | undefined {
const quote = replyTarget.value
if (quote) {
clearReply()
}
return quote
}
// ==================== 表情 ====================
const emojiVisible = ref(false)
/** 切换表情面板;打开时互斥关掉语音面板 */
function toggleEmoji() {
emojiVisible.value = !emojiVisible.value
if (emojiVisible.value) {
voiceVisible.value = false
}
}
// ==================== @ 成员选择(群聊) ====================
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
// ==================== 禁言 / 封禁状态 ====================
/** 禁言 / 封禁覆盖层handleResend 重试 / uploadAndSendMedia 上传完后也共用同一份,避免绕过 overlay */
const muteOverlay = useMuteOverlay()
/** 从 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()
}
}
// ==================== 图片 / 文件 / 语音 上传 ====================
/** 上传前的统一拦截:禁言态 / 无激活会话直接放弃;返回当前 conversation 与抓走的 quote */
function prepareMediaUpload(): { conversation: Conversation; quote?: QuoteMessage } | undefined {
if (muteOverlay.value) {
return undefined
}
const conversation = conversationStore.activeConversation
if (!conversation) {
return undefined
}
return { conversation, quote: consumeReply() }
}
/** 上传并发送 IMAGE 消息(占位 + 进度 + 真实 url ack 由 useMediaUploader 处理) */
async function uploadAndSendImage(file: File) {
const context = prepareMediaUpload()
if (!context) {
return
}
await uploadAndSendMedia({
file,
type: ImMessageType.IMAGE,
quote: context.quote,
conversation: context.conversation
})
}
/** 上传并发送 FILE 消息payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */
async function uploadAndSendFile(file: File) {
const context = prepareMediaUpload()
if (!context) {
return
}
await uploadAndSendMedia({
file,
type: ImMessageType.FILE,
quote: context.quote,
conversation: context.conversation
})
}
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor由 useMediaUploader 接管占位 / 进度 / ack */
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)
/** 打开语音录制面板;互斥关掉表情面板 */
function openVoice() {
voiceVisible.value = true
emojiVisible.value = false
}
/** VoiceRecorder 录完回传 blob包成 webm File 后走通用 uploadAndSendMediaduration 走 context */
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
const context = prepareMediaUpload()
if (!context) {
return
}
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
await uploadAndSendMedia({
file,
type: ImMessageType.VOICE,
quote: context.quote,
conversation: context.conversation,
context: { voiceDuration: 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 context = canvas.getContext('2d')
if (context && canvas.width && canvas.height) {
// 2.4 当前帧绘到 canvas → toBlob 拿 jpeg0.8 质量是聊天封面常用甜点
context.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. 立即占位blob URL 既当 video src 也当 poster浏览器会拿首帧绘制 coverstatus=SENDING + 进度条
* 2. probe 与视频上传同步起跑;封面上传等 probe 出 cover 后与视频上传竞速
* probe 解码 + 封面上传通常被视频上传时长完全遮蔽,体感节省几百 ms 起步)
* 3. 视频本体上传必须成功,拿不到 url 就把占位置 FAILED封面是锦上添花失败仅日志
* 4. 视频链路耗时长上传期间用户切会话则放弃发送避免落到错误会话里切走再切回来不算变化key 仍相等)
*/
async function uploadAndSendVideo(file: File) {
const context = prepareMediaUpload()
if (!context) {
return
}
const { conversation } = context
const replyQuote = context.quote
const startKey = getConversationKey(conversation)
// 1. 立即占位url 走 blob 让 <video src> 拉首字节渲染coverUrl 不设 blob
// <video poster> 期待图片资源,传 video blob 在部分浏览器会退化成黑底,不是稳定行为)
// cover 等 probe 异步出真实 URL 后由 commit 阶段一起 patch_localFile 留 file 供失败重试
// payload 拼装走 mediaTypeHandlers[VIDEO].build 与 commit 阶段共享同一份逻辑
const videoHandler = requireMediaHandler(ImMessageType.VIDEO)
const buildPlaceholderContent = (blobUrl: string): string =>
serializeMessage(withQuotePayload(videoHandler.build(file, blobUrl, {}), replyQuote))
const { clientMessageId } = insertMediaPlaceholder({
file,
type: ImMessageType.VIDEO,
conversation,
buildContent: buildPlaceholderContent
})
// 2. 三路并行起跑probe 与两条上传无依赖,封面上传等 probe 出 cover 后立即接力)
// 2.1 视频本体上传async IIFE 包一层让 await 显式可见lint 不再误判 floating promise
// 失败兜底为 url=undefined由 step 3 拿不到 url 时收尾
const videoForm = new FormData()
videoForm.append('file', file)
const videoUploadPromise: Promise<{ data?: string }> = (async () => {
try {
return (await updateFile(
videoForm,
createUploadProgressHandler(conversation, clientMessageId)
)) as { data?: string }
} catch (e) {
console.warn('[IM] 视频本体上传失败', e)
return { data: 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占位置 FAILED让用户决定重试 / 删除_localFile 在内存里)
const url = videoRes?.data
if (!url) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return
}
// 3.3 上传后会话校验 + muteOverlay 复查(与 useMediaUploader.uploadAndSendMedia 同一道)
if (!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId)) {
return
}
// 4. 拼真实 VideoMessage payloadpatch 进占位 + 走 sendRaw 复用占位发送
const realContent = serializeMessage(
withQuotePayload(
videoHandler.build(file, url, {
videoProbe: { duration: probe.duration, width: probe.width, height: probe.height },
videoCoverUrl: coverUrl
}),
replyQuote
)
)
await commitMediaPlaceholder({
type: ImMessageType.VIDEO,
conversation,
clientMessageId,
realContent
})
}
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor独立链路probe + 双上传,最终走 commitMediaPlaceholder 收尾) */
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>
/* 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;
}
/* 用 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);
}
/* 禁言 / 封禁覆盖层:绝对定位在外层容器上,遮挡整个输入卡片 */
.message-input__mute-overlay {
position: absolute;
inset: 8px 12px 12px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
font-size: 14px;
color: var(--el-color-warning-dark-2);
background-color: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
}
/* 封禁态:红底,区别于禁言的橙底 */
.message-input__mute-overlay--banned {
color: var(--el-color-danger-dark-2);
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-5);
}
</style>