feat(im): MessageInput 切 contenteditable + MentionPicker 对齐微信,修一堆 @ 浮层 bug

【MessageInput.vue】
- textarea → contenteditable div:拿真·光标 rect 给浮层定位(textarea 拿不到),@ 成员
  以 <span data-id contenteditable=false> token 节点存在,删 token 即删 atUserIds
- collectFromEditor:DOM walk 还原 plain text + atUserIds(text / br / span[data-id] /
  div / 其他元素 五种节点分支),过滤零宽空格
- handleSend:从 DOM 收集而非 ref<string>,atUserIds 走 Set 去重;分步注释
- placeholder 用 [data-empty]::before + JS 维护属性"存在 / 缺失"模拟,避开浏览器删空
  后留 <br> 让 :empty 不命中
- @ 浮层位置:bottom 锚定(picker 下沿贴 @ 上方 8px),无论候选多寡下沿固定,不再
  随 picker 高度变化漂移;上方放不下才翻成 top 锚定到 @ 下方
- @ 浮层规则:regex 改成 `(?:^|\s)@([^\s@]*)$`,避免 email-like "test@example.com"
  误触发;锚定在 @ 字符位置而非 caret,否则用户每多敲一字浮层右移
- click outside 关浮层:document mousedown 监听,target 不在 editor / picker 内即关
- Enter 兜底:mention 浮层无候选时 fall through 到正常发送,避免按 Enter 没反应
- token 首位 ZWSP:token 是 editor 第一个节点时 contenteditable=false 边缘会让光标
  无法挪到 token 前,补一个零宽空格当锚点;DOM walk 滤掉
- Shift+Enter 强制 br(execCommand insertLineBreak),DOM walk 不必处理多换行容器
- onPaste 用 execCommand('insertText') 剥光所有 HTML,不留外部样式 / 脚本
- onEditorScroll 同步浮层位置,多行 + 滚动条场景下 picker 跟随 caret
- selection 保存:document selectionchange 监听 + 仅 editor 内时记录,emoji 面板偷
  焦点后能回到原位

【MentionPicker.vue】
- 视觉对齐微信 PC:顶部"所有人"虚拟项(蓝方块 + UserFilled 图标)+ "群成员"分组
  header + 底部三角指针;rounded-md + soft shadow
- "全体成员" → "所有人";userId=-1 / 文案常量化到 utils/constants.ts
  (IM_AT_ALL_USER_ID / IM_AT_ALL_NICKNAME),三个文件共用,不再散落
- !fixed + !h-75 / max-height:用 UnoCSS important 变体压过 Element Plus 的
  .el-scrollbar { position:relative; height:100% } 默认 CSS——之前 picker 落到父
  容器坐标系导致 y=1326 飞出视口外,肉眼看不到的根因
- pos prop 从 {x, y} → {x, top?, bottom?},配合 MessageInput 的 bottom 锚定
- allItem / memberItems 拆成两个 computed,showMembers 做扁平合并供键盘导航;
  群成员上限 100 去掉,浮层本就支持滚动
- 5 个内部函数 / watch 全部补 JSDoc(showMembers / visible 两个 watch、scrollToTop /
  scrollToActive / handleSelect)
im
YunaiV 2026-04-27 13:21:27 +08:00
parent 3ea04663f2
commit 678c2d6834
2 changed files with 445 additions and 153 deletions

View File

@ -1,33 +1,69 @@
<template>
<!--
@ 成员选择浮层
@ 成员选择浮层对齐微信 PC所有人在顶 + 群成员分组 + 底部三角指针
- 父组件通过 v-model:visible 控制显隐searchText 过滤
- 父组件通过 ref moveUp / moveDown / pickActive 实现键盘上下 + Enter 选中
- @select 发射被选中的成员
-->
<el-scrollbar
<div
v-show="visible && showMembers.length > 0"
ref="scrollRef"
class="fixed z-100 w-50 h-75 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
:style="{
left: pos.x + 'px',
top: pos.top != null ? pos.top + 'px' : 'auto',
bottom: pos.bottom != null ? pos.bottom + 'px' : 'auto'
}"
>
<ChatGroupMember
v-for="(m, idx) in showMembers"
:key="m.userId"
:member="m"
:height="40"
:active="activeIdx === idx"
:clickable="false"
@click.stop="handleSelect(m)"
/>
</el-scrollbar>
<el-scrollbar ref="scrollRef" max-height="300px">
<!-- 所有人虚拟项仅群主可选蓝色方块 + 群体图标跟下面的成员头像区分 -->
<div
v-if="allItem"
class="flex items-center gap-2.5 px-[5px] h-10 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ 'bg-[#e1eaf7] dark:bg-[var(--el-color-primary-light-9)]': activeIdx === 0 }"
@click.stop="handleSelect(allItem)"
>
<div
class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--el-color-primary)] flex-shrink-0"
>
<el-icon :size="18"><UserFilled /></el-icon>
</div>
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
{{ allItem.showNickName }}
</span>
</div>
<!-- 群成员 section header只有"所有人 + 真成员"两边都在时才出现避免单一来源时显得多余 -->
<div
v-if="allItem && memberItems.length > 0"
class="px-2 pt-2 pb-1 text-12px text-[var(--el-text-color-secondary)]"
>
群成员
</div>
<!-- 真成员行 -->
<ChatGroupMember
v-for="(m, idx) in memberItems"
:key="m.userId"
:member="m"
:height="40"
:active="activeIdx === (allItem ? idx + 1 : idx)"
:clickable="false"
@click.stop="handleSelect(m)"
/>
</el-scrollbar>
<!-- 底部三角指针旋转 45° 的方块半露出底边指向输入区里的 @ 字符 -->
<div class="absolute left-4 -bottom-1.5 w-3 h-3 rotate-45 bg-[var(--el-bg-color)]"></div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, useTemplateRef, watch } from 'vue'
import { ElScrollbar } from 'element-plus'
import { UserFilled } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
defineOptions({ name: 'ImMentionPicker' })
@ -35,14 +71,15 @@ defineOptions({ name: 'ImMentionPicker' })
const props = withDefaults(
defineProps<{
visible: boolean //
pos: { x: number; y: number } // @
// x + top / bottom bottom picker 沿 @
pos: { x: number; top?: number; bottom?: number }
members: GroupMemberLite[] //
searchText?: string // @
ownerId?: number // id""
ownerId?: number // id""
}>(),
{
searchText: '',
pos: () => ({ x: 0, y: 0 })
pos: () => ({ x: 0, bottom: 0 })
}
)
@ -55,45 +92,53 @@ const userStore = useUserStore()
const scrollRef = useTemplateRef<InstanceType<typeof ElScrollbar>>('scrollRef')
const activeIdx = ref(0)
/** 当前登录用户 id成员过滤掉自己) */
/** 当前登录用户 id成员列表过滤掉自己) */
const selfUserId = computed(() => Number(userStore.getUser?.id) || 0)
/** 是否群主(只有群主能 @ 全体成员 */
/** 是否群主(只有群主能 @ 所有人,对齐微信 */
const isOwner = computed(() => {
return props.ownerId != null && props.ownerId === selfUserId.value
})
/** 过滤后的显示列表(最多 100 条) */
const showMembers = computed<GroupMemberLite[]>(() => {
const result: GroupMemberLite[] = []
const keyword = props.searchText
// + """"
const allTag = '全体成员'
if (isOwner.value && allTag.startsWith(keyword)) {
result.push({ userId: -1, showNickName: allTag })
/**
* 虚拟"所有人"群主 + 关键字命中"所有人"前缀时存在
*
* MessageInput token data-id 收集 atUserIds不依赖文案字符串
* 这里的 userId / 文案都从 im/utils/constants 避免散落
*/
const allItem = computed<GroupMemberLite | null>(() => {
if (!isOwner.value) {
return null
}
for (const m of props.members) {
if (result.length > 100) {
break
}
if (m.userId === selfUserId.value) {
continue
}
if (m.quit) {
continue
}
if (m.showNickName && m.showNickName.startsWith(keyword)) {
result.push(m)
}
if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) {
return null
}
return result
return { userId: IM_AT_ALL_USER_ID, showNickName: IM_AT_ALL_NICKNAME }
})
/** 真成员:过滤自己 / 退群 / 不匹配关键字;不截断数量,浮层 max-height + el-scrollbar 撑滚动 */
const memberItems = computed<GroupMemberLite[]>(() =>
props.members.filter(
(m) =>
m.userId !== selfUserId.value &&
!m.quit &&
!!m.showNickName &&
m.showNickName.startsWith(props.searchText)
)
)
/** 键盘导航与 pickActive 走的扁平列表allItem 在前、memberItems 在后 */
const showMembers = computed<GroupMemberLite[]>(() => {
return allItem.value ? [allItem.value, ...memberItems.value] : memberItems.value
})
/** 候选列表变化(用户输入关键词在过滤)→ 重置高亮到首项 + 滚回顶 */
watch(showMembers, (list) => {
activeIdx.value = list.length > 0 ? 0 : -1
scrollToTop()
})
/** 浮层重新打开 → 重置高亮 + 滚回顶(避免上次的中间状态残留) */
watch(
() => props.visible,
(v) => {
@ -104,6 +149,7 @@ watch(
}
)
/** el-scrollbar 没暴露 scrollTo直接拿内部 wrap 调 scrollTop */
function scrollToTop() {
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
if (wrap) {
@ -111,6 +157,8 @@ function scrollToTop() {
}
}
/** 键盘上下导航时把高亮项滚到可视区:超出底边下推、超出顶边上拉,否则不动 */
// TODO @AI
function scrollToActive() {
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
if (!wrap) {
@ -125,6 +173,7 @@ function scrollToActive() {
}
}
/** 选中一项emit 给 MessageInput 落 token同时关掉浮层 */
function handleSelect(m: GroupMemberLite) {
emit('select', m)
emit('update:visible', false)
@ -152,4 +201,3 @@ defineExpose({
hasCandidates: () => showMembers.value.length > 0
})
</script>

View File

@ -2,12 +2,8 @@
<div
class="relative flex flex-col bg-[var(--el-bg-color)] border-t border-[var(--el-border-color-lighter)]"
>
<!-- 顶部工具栏表情/图片/文件/语音/历史 -->
<!-- 顶部工具栏表情 / 图片 / 文件 / 语音 / 历史 -->
<div class="relative flex items-center gap-2 h-9 px-3">
<!--
注意el-icon 默认 box-sizing:border-box + width:1em所以这里显式 box-content 才能让 p-1.5
不把 1em 的图标挤瘪hover 态走 UnoCSS hover: 前缀
-->
<el-tooltip content="表情" placement="top">
<el-icon
class="message-input__tool box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@ -49,26 +45,28 @@
</el-icon>
</el-tooltip>
<!-- 浮层表情面板绝对定位到工具栏左上方 -->
<EmojiPicker
v-model:visible="emojiVisible"
class="bottom-9 left-3"
@select="insertText"
/>
<!-- 浮层表情面板绝对定位到工具栏左上方 -->
<EmojiPicker v-model:visible="emojiVisible" class="bottom-9 left-3" @select="insertText" />
</div>
<!-- 输入区 -->
<el-input
ref="inputRef"
v-model="text"
type="textarea"
:rows="4"
resize="none"
placeholder="按 Enter 发送Shift+Enter 换行"
class="message-input__textarea"
<!--
输入区contenteditable div取代 textarea
- @ 浮层能拿到真实光标 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>
<!-- 发送按钮 -->
<div class="flex justify-end px-3 pt-1.5 pb-2.5">
@ -96,9 +94,9 @@
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import { Sunny, Picture, Paperclip, Microphone, Tickets } from '@element-plus/icons-vue'
import { ElInput, ElMessage } from 'element-plus'
import { ElMessage } from 'element-plus'
import { CommonStatusEnum } from '@/utils/constants'
import { updateFile } from '@/api/infra/file'
@ -121,78 +119,221 @@ import type { GroupMemberLite } from '../ChatGroupMember.vue'
defineOptions({ name: 'ImMessageInput' })
defineEmits<{
openHistory: [] // ChatPanel MessagePage
openHistory: [] // 打开历史消息抽屉(由 ChatPanel / MessagePage
}>()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const { send, sendRaw } = useMessageSender()
const inputRef = useTemplateRef<InstanceType<typeof ElInput>>('inputRef')
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
// ==================== / ====================
const text = ref('')
const canSend = computed(() => !!text.value.trim() && !!conversationStore.activeConversation)
/** editor 是否有可发送内容contenteditable 没 v-model靠 input 事件主动同步 */
const canSend = ref(false)
/** 维护 canSend + data-empty撑起 placeholder */
function syncEditorState() {
const editor = editorRef.value
if (!editor) {
return
}
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 = ''
}
}
/**
* 提交时从文本里实际存在的 @ 段重新收集 atUserIds
* - 用户先 @ 后又把 "@张三 " 整段删掉时 push 模型仍会带上张三的 id与文本不一致
* - "@全体成员" 走虚拟 userId=-1对齐 MentionPicker 里的 allTag 约定
* - 同名成员碰撞时第一条匹配胜出textarea 没有不可编辑 token也只能这么做
* 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 collectAtUserIds(): number[] {
if (!isGroup.value) {
return []
}
const userIds = new Set<number>()
const regex = /@([^\s@]+)/g
let match: RegExpExecArray | null
while ((match = regex.exec(text.value)) !== null) {
const name = match[1]
if (name === '全体成员') {
userIds.add(-1)
continue
function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number[] } {
const userIds: number[] = []
let text = ''
function walk(node: Node) {
// 1. text
if (node.nodeType === Node.TEXT_NODE) {
text += (node.textContent || '').replace(//g, '')
return
}
const member = groupMembers.value.find((m) => m.showNickName === name)
if (member?.userId != null) {
userIds.add(member.userId)
if (node.nodeType !== Node.ELEMENT_NODE) {
return
}
const el = node as HTMLElement
const tag = el.tagName.toLowerCase()
// 2. br
if (tag === 'br') {
text += '\n'
return
}
// 3. span[data-id]mention token
if (tag === 'span' && el.dataset.id) {
text += el.textContent || ''
const id = Number(el.dataset.id)
if (!Number.isNaN(id)) {
userIds.push(id)
}
return
}
// 4. div
if (tag === 'div') {
if (text && !text.endsWith('\n')) {
text += '\n'
}
el.childNodes.forEach(walk)
return
}
// 5.
el.childNodes.forEach(walk)
}
root.childNodes.forEach(walk)
return {
text: text.trim(),
atUserIds: [...new Set(userIds)]
}
return Array.from(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() {
if (!canSend.value) {
// 1.
const editor = editorRef.value
if (!canSend.value || !editor) {
return
}
const atUserIds = collectAtUserIds()
const txt = text.value
text.value = ''
await send(txt, atUserIds.length > 0 ? { atUserIds } : undefined)
// 2.
const { text, atUserIds } = collectFromEditor(editor)
// 3.
if (!text) {
return
}
// 4.
editor.innerHTML = ''
syncEditorState()
// 5.
await send(text, atUserIds.length > 0 ? { atUserIds } : undefined)
}
// ==================== / ====================
/**
* 上次落在 editor 内的 selection焦点被表情面板等夺走时用来回到原插入点
*
* 监听 document.selectionchange editor.@blur 更可靠blur selection 已经移走
*/
let savedRange: Range | null = null
// TODO @AI
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)
})
onBeforeUnmount(() => {
document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('mousedown', onDocMousedown)
})
/**
* 把字符串插入光标处execCommand('insertText') 而非自己拼 DOM是为了保留浏览器
* 原生 undo Range API 替代实现会让 Ctrl+Z 失效
*/
// TODO @AI
function insertText(str: string) {
const ta = getTextarea()
if (!ta) {
text.value += str
const editor = editorRef.value
if (!editor) {
return
}
const start = ta.selectionStart ?? text.value.length
const end = ta.selectionEnd ?? text.value.length
text.value = text.value.slice(0, start) + str + text.value.slice(end)
nextTick(() => {
ta.focus()
const caret = start + str.length
ta.setSelectionRange(caret, caret)
})
editor.focus()
if (savedRange) {
const sel = window.getSelection()
if (sel) {
sel.removeAllRanges()
sel.addRange(savedRange)
}
}
// TODO @AIlinter
document.execCommand('insertText', false, str)
syncEditorState()
}
function getTextarea(): HTMLTextAreaElement | null {
// ElInput type=textarea <textarea> DOM
return (inputRef.value?.$el?.querySelector('textarea') as HTMLTextAreaElement) || null
/** 粘贴:剥掉外部样式 / 脚本,只留 plain text */
function onPaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text/plain') || ''
if (text) {
// TODO @AIlinter
document.execCommand('insertText', false, text)
}
}
// TODO @AI
function onInput() {
syncEditorState()
detectAtMention()
}
// ==================== ====================
@ -220,6 +361,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
quit: m.status === CommonStatusEnum.DISABLE
}))
})
const groupOwnerId = computed<number | undefined>(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
@ -230,56 +372,133 @@ const groupOwnerId = computed<number | undefined>(() => {
const mentionVisible = ref(false)
const mentionSearchText = ref('')
const mentionPos = ref({ x: 0, y: 0 })
/** 浮层定位x 是左边距top / bottom 二选一 bottom 锚定picker 下沿贴 @是默认
* 上方放不下时退化为 top 锚定picker 上沿贴 @ 下方 */
const mentionPos = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
/** 当前输入里 @ 符号的起始光标位置,用于选中成员后做替换 */
let atStartPos = -1
/** MentionPicker w-50 沿
* 高度不再用常量算位置bottom 锚定后 picker 内容多寡都不影响下沿位置自然贴 @ */
const MENTION_WIDTH = 200
/** 上方剩余空间至少这么多才放上方,否则翻到下方(避免 picker 被视口顶 / 顶部 chat header 切掉) */
const MENTION_MIN_FIT_ABOVE = 120
function onInput() {
/** 当前 @ 关键词在 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 ta = getTextarea()
if (!ta) {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) {
closeMention()
return
}
const caret = ta.selectionStart ?? 0
const before = text.value.slice(0, caret)
// @ @
const match = before.match(/@([^\s@]*)$/)
if (match) {
atStartPos = caret - match[0].length
mentionSearchText.value = match[1]
//
const rect = ta.getBoundingClientRect()
mentionPos.value = { x: rect.left + 20, y: rect.top - 10 }
mentionVisible.value = true
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)
// (?:^|\s) @ email-like "test@example.com"
// match[1] @ = offset - keyword.length - 1
const match = before.match(/(?:^|\s)@([^\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.
const anchor = document.createRange()
anchor.setStart(node, atOffset)
anchor.collapse(true)
const rect = anchor.getBoundingClientRect()
// 2.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
// 3.
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
mentionPos.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
} else {
mentionVisible.value = false
atStartPos = -1
mentionPos.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 ta = getTextarea()
if (!ta || atStartPos < 0) {
const editor = editorRef.value
if (!editor || !mentionRange) {
return
}
const caret = ta.selectionStart ?? atStartPos
const insert = `@${member.showNickName} `
text.value = text.value.slice(0, atStartPos) + insert + text.value.slice(caret)
// userId push collectAtUserIds send text
nextTick(() => {
ta.focus()
const newCaret = atStartPos + insert.length
ta.setSelectionRange(newCaret, newCaret)
})
mentionVisible.value = false
atStartPos = -1
// @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.showNickName}`
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 NBSPNBSP
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()
}
// TODO @AI
function onKeydown(e: KeyboardEvent) {
// @ + Enter
// @ / Enter / Esc
if (mentionVisible.value) {
if (e.key === 'ArrowUp') {
e.preventDefault()
@ -291,19 +510,27 @@ function onKeydown(e: KeyboardEvent) {
mentionRef.value?.moveDown()
return
}
if (e.key === 'Enter') {
e.preventDefault()
if (e.key === 'Enter' && !e.isComposing) {
// Enter fall through Enter
if (mentionRef.value?.hasCandidates()) {
e.preventDefault()
mentionRef.value?.pickActive()
return
}
}
if (e.key === 'Escape') {
mentionVisible.value = false
closeMention()
return
}
}
// Enter Shift+Enter / Ctrl+Enter
// Shift+Enter br divDOM walk
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
e.preventDefault()
document.execCommand('insertLineBreak')
syncEditorState()
return
}
// Enter IME composition
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
e.preventDefault()
handleSend()
@ -311,6 +538,7 @@ function onKeydown(e: KeyboardEvent) {
}
// ==================== / ====================
// TODO @AI
async function onImagePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
@ -332,6 +560,7 @@ async function onImagePicked(e: Event) {
}
}
// TODO @AI
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
@ -358,6 +587,7 @@ async function onFilePicked(e: Event) {
// ==================== ====================
const voiceVisible = ref(false)
// TODO @AI
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
try {
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
@ -393,15 +623,29 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) {
color: var(--el-color-primary) !important;
}
/* el-textarea 是 ElInput 内部的 textarea需要 :deep() 去掉默认边框 / 圆角 */
.message-input__textarea :deep(.el-textarea__inner) {
.message-input__editor {
position: relative;
min-height: 80px;
max-height: 160px;
overflow-y: auto;
padding: 8px 12px;
border: none;
border-radius: 0;
box-shadow: none;
font-size: 14px;
line-height: 1.5;
outline: none;
white-space: pre-wrap;
word-break: break-word;
}
.message-input__textarea :deep(.el-textarea__inner):focus {
box-shadow: none;
/* 用 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>