✨ feat(im): MessageInput / MentionPicker / ChatPanel 三连修——粘贴文件、切群清空、命名规范
【ChatPanel.vue】
- 加 messageInputKey computed(type-targetId)+ MessageInput :key 绑它,
切会话强制 unmount + remount editor / mention range / 草稿全归零,
避免 A 群打了一半的字 / @ token 漏到 B 群被发出去
(早先用 inline template literal 做 :key,Vue SFC 编译没把表达式接到
vnode.key 上,hmr / 完整 reload 都看到 key=null;改 computed 后正常)
【MessageInput.vue】
- onPaste 加 clipboardData.items 扫一轮:image/* → uploadAndSendImage,
其它 file → uploadAndSendFile,纯文本兜底走 nativeExec('insertText');
截图 / 拖入图片 / 拖入文件不再被默默吞掉
- 抽 uploadAndSendImage / uploadAndSendFile 两个共用函数,
onImagePicked / onFilePicked 改成薄包装走它们,避免上传逻辑双份
- 删 nativeExec 里的 // eslint-disable-next-line @typescript-eslint/no-deprecated:
项目当前 @typescript-eslint v7 没有这条规则,加了会让 lint 报"规则不存在",
反而把 lint 拖红;改用单纯 JSDoc 解释为什么留着 execCommand
- 重命名 mentionPos → mentionPosition(prop / ref 一致),按"变量不缩写"
- 7 个方法补 JSDoc:onSelectionChange / insertText / onPaste / onInput /
onKeydown / onImagePicked / onFilePicked / onVoiceSend;复杂的
collectFromEditor 和 handleSend 加分步 1./2./3. 内联注释
- data-empty 改用属性"存在 / 缺失"模拟(template 里 data-empty="",JS 里
raw 为空就 set ''、否则 delete),CSS 选择器同步改 [data-empty],
比 [data-empty='true'] 直观
【MentionPicker.vue】
- prop pos → position(不缩写);ref / 内部解构 / 默认值都跟着改
- <el-icon><UserFilled /></el-icon> → <Icon icon="ep:user-filled">:
用全局 Icon 组件走 Iconify,少一个 EP 图标 import
- scrollToTop / scrollToActive 局部变量 wrap → scrollWrap、
itemH → itemHeight、activeTop → activeOffsetTop;
v-for 与 handleSelect 的 (m) → (member)
im
parent
678c2d6834
commit
cba5c15604
|
|
@ -9,9 +9,9 @@
|
||||||
v-show="visible && showMembers.length > 0"
|
v-show="visible && showMembers.length > 0"
|
||||||
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)]"
|
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="{
|
:style="{
|
||||||
left: pos.x + 'px',
|
left: position.x + 'px',
|
||||||
top: pos.top != null ? pos.top + 'px' : 'auto',
|
top: position.top != null ? position.top + 'px' : 'auto',
|
||||||
bottom: pos.bottom != null ? pos.bottom + 'px' : 'auto'
|
bottom: position.bottom != null ? position.bottom + 'px' : 'auto'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<el-scrollbar ref="scrollRef" max-height="300px">
|
<el-scrollbar ref="scrollRef" max-height="300px">
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--el-color-primary)] flex-shrink-0"
|
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>
|
<Icon icon="ep:user-filled" :size="18" />
|
||||||
</div>
|
</div>
|
||||||
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
|
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
|
||||||
{{ allItem.showNickName }}
|
{{ allItem.showNickName }}
|
||||||
|
|
@ -42,13 +42,13 @@
|
||||||
|
|
||||||
<!-- 真成员行 -->
|
<!-- 真成员行 -->
|
||||||
<ChatGroupMember
|
<ChatGroupMember
|
||||||
v-for="(m, idx) in memberItems"
|
v-for="(member, idx) in memberItems"
|
||||||
:key="m.userId"
|
:key="member.userId"
|
||||||
:member="m"
|
:member="member"
|
||||||
:height="40"
|
:height="40"
|
||||||
:active="activeIdx === (allItem ? idx + 1 : idx)"
|
:active="activeIdx === (allItem ? idx + 1 : idx)"
|
||||||
:clickable="false"
|
:clickable="false"
|
||||||
@click.stop="handleSelect(m)"
|
@click.stop="handleSelect(member)"
|
||||||
/>
|
/>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
|
||||||
|
|
@ -60,8 +60,8 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||||
import { ElScrollbar } from 'element-plus'
|
import { ElScrollbar } from 'element-plus'
|
||||||
import { UserFilled } from '@element-plus/icons-vue'
|
|
||||||
|
|
||||||
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
|
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
|
||||||
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
||||||
|
|
@ -72,14 +72,14 @@ const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean // 是否显示
|
visible: boolean // 是否显示
|
||||||
// 浮层位置:x 横坐标 + top / bottom 二选一(bottom 锚定时 picker 下沿贴 @ 上方)
|
// 浮层位置:x 横坐标 + top / bottom 二选一(bottom 锚定时 picker 下沿贴 @ 上方)
|
||||||
pos: { x: number; top?: number; bottom?: number }
|
position: { x: number; top?: number; bottom?: number }
|
||||||
members: GroupMemberLite[] // 当前群的成员列表
|
members: GroupMemberLite[] // 当前群的成员列表
|
||||||
searchText?: string // @ 后输入的过滤文本
|
searchText?: string // @ 后输入的过滤文本
|
||||||
ownerId?: number // 群主 id,判断是否能展示"所有人"
|
ownerId?: number // 群主 id,判断是否能展示"所有人"
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
searchText: '',
|
searchText: '',
|
||||||
pos: () => ({ x: 0, bottom: 0 })
|
position: () => ({ x: 0, bottom: 0 })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -151,31 +151,34 @@ watch(
|
||||||
|
|
||||||
/** el-scrollbar 没暴露 scrollTo,直接拿内部 wrap 调 scrollTop */
|
/** el-scrollbar 没暴露 scrollTo,直接拿内部 wrap 调 scrollTop */
|
||||||
function scrollToTop() {
|
function scrollToTop() {
|
||||||
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
|
const scrollWrap = scrollRef.value?.$el?.querySelector(
|
||||||
if (wrap) {
|
'.el-scrollbar__wrap'
|
||||||
wrap.scrollTop = 0
|
) as HTMLElement | null
|
||||||
|
if (scrollWrap) {
|
||||||
|
scrollWrap.scrollTop = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 键盘上下导航时把高亮项滚到可视区:超出底边下推、超出顶边上拉,否则不动 */
|
/** 键盘上下导航时把高亮项滚到可视区:超出底边下推、超出顶边上拉,否则不动 */
|
||||||
// TODO @AI:变量尽量完整!
|
|
||||||
function scrollToActive() {
|
function scrollToActive() {
|
||||||
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
|
const scrollWrap = scrollRef.value?.$el?.querySelector(
|
||||||
if (!wrap) {
|
'.el-scrollbar__wrap'
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (!scrollWrap) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const itemH = 40
|
const itemHeight = 40
|
||||||
const activeTop = activeIdx.value * itemH
|
const activeOffsetTop = activeIdx.value * itemHeight
|
||||||
if (activeTop + itemH > wrap.scrollTop + wrap.clientHeight) {
|
if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) {
|
||||||
wrap.scrollTop = activeTop + itemH - wrap.clientHeight
|
scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight
|
||||||
} else if (activeTop < wrap.scrollTop) {
|
} else if (activeOffsetTop < scrollWrap.scrollTop) {
|
||||||
wrap.scrollTop = activeTop
|
scrollWrap.scrollTop = activeOffsetTop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选中一项:emit 给 MessageInput 落 token,同时关掉浮层 */
|
/** 选中一项:emit 给 MessageInput 落 token,同时关掉浮层 */
|
||||||
function handleSelect(m: GroupMemberLite) {
|
function handleSelect(member: GroupMemberLite) {
|
||||||
emit('select', m)
|
emit('select', member)
|
||||||
emit('update:visible', false)
|
emit('update:visible', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
<MentionPicker
|
<MentionPicker
|
||||||
ref="mentionRef"
|
ref="mentionRef"
|
||||||
v-model:visible="mentionVisible"
|
v-model:visible="mentionVisible"
|
||||||
:pos="mentionPos"
|
:position="mentionPosition"
|
||||||
:members="groupMembers"
|
:members="groupMembers"
|
||||||
:search-text="mentionSearchText"
|
:search-text="mentionSearchText"
|
||||||
:owner-id="groupOwnerId"
|
:owner-id="groupOwnerId"
|
||||||
|
|
@ -171,7 +171,6 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
||||||
let text = ''
|
let text = ''
|
||||||
|
|
||||||
function walk(node: Node) {
|
function walk(node: Node) {
|
||||||
// 1. text 节点
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
text += (node.textContent || '').replace(//g, '')
|
text += (node.textContent || '').replace(//g, '')
|
||||||
return
|
return
|
||||||
|
|
@ -181,12 +180,10 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
||||||
}
|
}
|
||||||
const el = node as HTMLElement
|
const el = node as HTMLElement
|
||||||
const tag = el.tagName.toLowerCase()
|
const tag = el.tagName.toLowerCase()
|
||||||
// 2. br
|
|
||||||
if (tag === 'br') {
|
if (tag === 'br') {
|
||||||
text += '\n'
|
text += '\n'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 3. span[data-id]:mention token
|
|
||||||
if (tag === 'span' && el.dataset.id) {
|
if (tag === 'span' && el.dataset.id) {
|
||||||
text += el.textContent || ''
|
text += el.textContent || ''
|
||||||
const id = Number(el.dataset.id)
|
const id = Number(el.dataset.id)
|
||||||
|
|
@ -195,7 +192,6 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 4. div:行级换行容器
|
|
||||||
if (tag === 'div') {
|
if (tag === 'div') {
|
||||||
if (text && !text.endsWith('\n')) {
|
if (text && !text.endsWith('\n')) {
|
||||||
text += '\n'
|
text += '\n'
|
||||||
|
|
@ -203,10 +199,10 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
||||||
el.childNodes.forEach(walk)
|
el.childNodes.forEach(walk)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 5. 其他元素:递归
|
|
||||||
el.childNodes.forEach(walk)
|
el.childNodes.forEach(walk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 直接从 root.childNodes 开始,避免把 root 本身也当元素处理(虽然目前没有特殊样式,但以防未来改动)
|
||||||
root.childNodes.forEach(walk)
|
root.childNodes.forEach(walk)
|
||||||
return {
|
return {
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
|
|
@ -225,21 +221,18 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
||||||
* 5. 上送:atUserIds 非空才传,避免发空数组
|
* 5. 上送:atUserIds 非空才传,避免发空数组
|
||||||
*/
|
*/
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
// 1.
|
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
if (!canSend.value || !editor) {
|
if (!canSend.value || !editor) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 2.
|
|
||||||
const { text, atUserIds } = collectFromEditor(editor)
|
const { text, atUserIds } = collectFromEditor(editor)
|
||||||
// 3.
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 4.
|
// 1. 清空 + 同步状态
|
||||||
editor.innerHTML = ''
|
editor.innerHTML = ''
|
||||||
syncEditorState()
|
syncEditorState()
|
||||||
// 5.
|
// 2. 发送
|
||||||
await send(text, atUserIds.length > 0 ? { atUserIds } : undefined)
|
await send(text, atUserIds.length > 0 ? { atUserIds } : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,7 +244,22 @@ async function handleSend() {
|
||||||
*/
|
*/
|
||||||
let savedRange: Range | null = null
|
let savedRange: Range | null = null
|
||||||
|
|
||||||
// TODO @AI:方法注释、方法内注释;
|
/**
|
||||||
|
* 走 native execCommand,保留浏览器原生 undo 栈
|
||||||
|
*
|
||||||
|
* execCommand 在 lib.dom 里被标 @deprecated(IDE 显示删除线),但 '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() {
|
function onSelectionChange() {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
const sel = window.getSelection()
|
const sel = window.getSelection()
|
||||||
|
|
@ -299,10 +307,13 @@ onBeforeUnmount(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把字符串插入光标处。execCommand('insertText') 而非自己拼 DOM,是为了保留浏览器
|
* 把字符串插入光标处(emoji 面板等场景调用)
|
||||||
* 原生 undo 栈(Range API 替代实现会让 Ctrl+Z 失效)
|
*
|
||||||
|
* 1. editor 没挂直接返回
|
||||||
|
* 2. 焦点回到 editor + 把 savedRange 恢复成当前 selection(emoji 面板偷焦点后还能回原位)
|
||||||
|
* 3. nativeExec 插文本,保留浏览器原生 undo 栈
|
||||||
|
* 4. 同步 canSend / placeholder
|
||||||
*/
|
*/
|
||||||
// TODO @AI:方法注释、方法内注释;
|
|
||||||
function insertText(str: string) {
|
function insertText(str: string) {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
|
|
@ -316,21 +327,51 @@ function insertText(str: string) {
|
||||||
sel.addRange(savedRange)
|
sel.addRange(savedRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO @AI:linter 报错
|
// 1. nativeExec 插文本,保留浏览器原生 undo 栈
|
||||||
document.execCommand('insertText', false, str)
|
nativeExec('insertText', str)
|
||||||
|
// 2. 同步 canSend / placeholder
|
||||||
syncEditorState()
|
syncEditorState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 粘贴:剥掉外部样式 / 脚本,只留 plain text */
|
/**
|
||||||
|
* 粘贴处理
|
||||||
|
*
|
||||||
|
* 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
|
||||||
|
* - image/* → 走 IMAGE 上传发送
|
||||||
|
* - 其它 file → 走 FILE 上传发送
|
||||||
|
* - 一次粘贴只处理第一个文件,避免一次粘贴发出多条消息
|
||||||
|
* 2. 没文件再走 plain text:剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
|
||||||
|
* (contenteditable 默认粘贴会带 HTML,所以模板上 @paste.prevent 拦截)
|
||||||
|
*/
|
||||||
function onPaste(e: ClipboardEvent) {
|
function onPaste(e: ClipboardEvent) {
|
||||||
|
// 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (items?.length) {
|
||||||
|
// TODO @AI:这了有 linter 报错;
|
||||||
|
for (const item of items) {
|
||||||
|
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') || ''
|
const text = e.clipboardData?.getData('text/plain') || ''
|
||||||
if (text) {
|
if (text) {
|
||||||
// TODO @AI:linter 报错
|
nativeExec('insertText', text)
|
||||||
document.execCommand('insertText', false, text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:方法注释、方法内注释;
|
/** 编辑器内容变化的统一入口:先同步 canSend / placeholder,再判 @ 浮层是否要展开 */
|
||||||
function onInput() {
|
function onInput() {
|
||||||
syncEditorState()
|
syncEditorState()
|
||||||
detectAtMention()
|
detectAtMention()
|
||||||
|
|
@ -353,6 +394,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
|
||||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
// TODO @AI:g 变 group
|
||||||
const g = groupStore.getGroup(conversation.targetId)
|
const g = groupStore.getGroup(conversation.targetId)
|
||||||
return (g?.members || []).map((m) => ({
|
return (g?.members || []).map((m) => ({
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
|
|
@ -374,7 +416,7 @@ const mentionVisible = ref(false)
|
||||||
const mentionSearchText = ref('')
|
const mentionSearchText = ref('')
|
||||||
/** 浮层定位:x 是左边距;top / bottom 二选一—— bottom 锚定(picker 下沿贴 @)是默认,
|
/** 浮层定位:x 是左边距;top / bottom 二选一—— bottom 锚定(picker 下沿贴 @)是默认,
|
||||||
* 上方放不下时退化为 top 锚定(picker 上沿贴 @ 下方) */
|
* 上方放不下时退化为 top 锚定(picker 上沿贴 @ 下方) */
|
||||||
const mentionPos = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
|
const mentionPosition = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
|
||||||
|
|
||||||
/** MentionPicker 的容器宽度(与组件里的 w-50 对齐),用于视口右沿回弹;
|
/** MentionPicker 的容器宽度(与组件里的 w-50 对齐),用于视口右沿回弹;
|
||||||
* 高度不再用常量算位置——bottom 锚定后 picker 内容多寡都不影响下沿位置,自然贴 @ */
|
* 高度不再用常量算位置——bottom 锚定后 picker 内容多寡都不影响下沿位置,自然贴 @ */
|
||||||
|
|
@ -436,18 +478,18 @@ function detectAtMention() {
|
||||||
* 无论 1 项还是 N 项 picker 下沿都贴 @);不够则翻到 @ 下方走 top 锚定
|
* 无论 1 项还是 N 项 picker 下沿都贴 @);不够则翻到 @ 下方走 top 锚定
|
||||||
*/
|
*/
|
||||||
function positionMention(node: Node, atOffset: number) {
|
function positionMention(node: Node, atOffset: number) {
|
||||||
// 1.
|
// 1. 计算 @ 字符屏幕坐标 rect
|
||||||
const anchor = document.createRange()
|
const anchor = document.createRange()
|
||||||
anchor.setStart(node, atOffset)
|
anchor.setStart(node, atOffset)
|
||||||
anchor.collapse(true)
|
anchor.collapse(true)
|
||||||
const rect = anchor.getBoundingClientRect()
|
const rect = anchor.getBoundingClientRect()
|
||||||
// 2.
|
// 2. 横向:picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
|
||||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
|
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
|
||||||
// 3.
|
// 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定
|
||||||
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
|
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
|
||||||
mentionPos.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
|
mentionPosition.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
|
||||||
} else {
|
} else {
|
||||||
mentionPos.value = { x: left, top: rect.bottom + 8 }
|
mentionPosition.value = { x: left, top: rect.bottom + 8 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,9 +538,18 @@ function onMentionSelect(member: GroupMemberLite) {
|
||||||
syncEditorState()
|
syncEditorState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:方法注释、方法内注释;
|
/**
|
||||||
|
* 键盘事件分发
|
||||||
|
*
|
||||||
|
* 1. mention 浮层打开时
|
||||||
|
* 1.1 ↑/↓ 移动高亮
|
||||||
|
* 1.2 Enter 有候选 → 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
|
||||||
|
* 1.3 Esc 关浮层
|
||||||
|
* 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
|
||||||
|
* 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
|
||||||
|
*/
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
// @ 浮层打开时键盘上下 / Enter / Esc 由浮层消费
|
// 1. mention 浮层打开时
|
||||||
if (mentionVisible.value) {
|
if (mentionVisible.value) {
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -511,7 +562,6 @@ function onKeydown(e: KeyboardEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter' && !e.isComposing) {
|
if (e.key === 'Enter' && !e.isComposing) {
|
||||||
// 有候选才拦 Enter 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
|
|
||||||
if (mentionRef.value?.hasCandidates()) {
|
if (mentionRef.value?.hasCandidates()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
mentionRef.value?.pickActive()
|
mentionRef.value?.pickActive()
|
||||||
|
|
@ -523,14 +573,14 @@ function onKeydown(e: KeyboardEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Shift+Enter 强制走 br:浏览器默认会插 div,DOM walk 时拼接更复杂
|
// 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
|
||||||
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
|
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
document.execCommand('insertLineBreak')
|
nativeExec('insertLineBreak')
|
||||||
syncEditorState()
|
syncEditorState()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 普通 Enter 发送(IME composition 中除外)
|
// 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
handleSend()
|
||||||
|
|
@ -538,14 +588,8 @@ function onKeydown(e: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 图片 / 文件上传 ====================
|
// ==================== 图片 / 文件上传 ====================
|
||||||
// TODO @AI:方法注释、方法内注释;
|
/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */
|
||||||
async function onImagePicked(e: Event) {
|
async function uploadAndSendImage(file: File) {
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
input.value = ''
|
|
||||||
if (!file) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
|
@ -560,14 +604,8 @@ async function onImagePicked(e: Event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:方法注释、方法内注释;
|
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
||||||
async function onFilePicked(e: Event) {
|
async function uploadAndSendFile(file: File) {
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
input.value = ''
|
|
||||||
if (!file) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
|
|
@ -585,9 +623,29 @@ async function onFilePicked(e: Event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图片选完即上传 + 发送 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)
|
const voiceVisible = ref(false)
|
||||||
// TODO @AI:方法注释、方法内注释;
|
/** VoiceRecorder 录完后回传 blob,包成 webm 文件上传,发送 VOICE 消息 */
|
||||||
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
||||||
try {
|
try {
|
||||||
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue