tuikit-atomicx-vue3 4.5.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{PopoverTrigger-L8abAry7.js → PopoverPortal-DghpKKm8.js} +91 -136
- package/dist/PopoverTrigger-BajjNkGO.js +54 -0
- package/dist/{PopperContent-XdhqL8Y2.js → PopperContent-SLoFuK7k.js} +6 -6
- package/dist/{Teleport-CSEuZbpM.js → Teleport-DFrneqLM.js} +4 -4
- package/dist/baseComp/Modal/Modal.js +3 -3
- package/dist/components/BarrageInput/EmojiPicker/EmojiPicker.js +10 -9
- package/dist/components/BarrageInput/TextEditor/CharacterCountExtension.js +1 -1
- package/dist/components/BarrageInput/TextEditor/EditorCore.js +16 -15
- package/dist/components/ConversationList/ConversationPreview/ConversationPreviewAbstract.js +62 -40
- package/dist/components/ConversationList/ConversationPreview/ConversationPreviewTimestamp.js +29 -27
- package/dist/components/ConversationList/ConversationPreview/ConversationPreviewTitle.js +27 -23
- package/dist/components/ConversationList/ConversationPreview/ConversationPreviewTitle.vue.d.ts +1 -1
- package/dist/components/ConversationList/ConversationPreview/ConversationPreviewUI.js +67 -65
- package/dist/components/ConversationList/ConversationPreview/ConversationPreviewUnread.js +32 -30
- package/dist/components/ConversationList/ConversationPreview/utils.d.ts +2 -2
- package/dist/components/ConversationList/ConversationPreview/utils.js +81 -35
- package/dist/components/ConversationList/i18n/en-US.d.ts +10 -1
- package/dist/components/ConversationList/i18n/en-US.js +11 -2
- package/dist/components/ConversationList/i18n/zh-CN.d.ts +10 -1
- package/dist/components/ConversationList/i18n/zh-CN.js +11 -2
- package/dist/components/LiveScenePanel/index.js +10 -9
- package/dist/components/MessageInput/AttachmentPicker/index.js +10 -9
- package/dist/components/MessageInput/EmojiPicker/EmojiPicker.js +17 -16
- package/dist/components/MessageInput/QuotedMessagePreview/index.js +35 -35
- package/dist/components/MessageInput/TextEditor/EditorCore.d.ts +13 -12
- package/dist/components/MessageInput/TextEditor/EditorCore.js +54 -89
- package/dist/components/MessageInput/TextEditor/extensions/MentionSuggestion.js +229 -0
- package/dist/components/MessageInput/TextEditor/extensions/MentionSuggestion.vue.d.ts +15 -0
- package/dist/components/MessageInput/TextEditor/extensions/characterCountExtension.js +1 -1
- package/dist/components/MessageInput/TextEditor/extensions/emojiExtension.d.ts +1 -0
- package/dist/components/MessageInput/TextEditor/extensions/emojiExtension.js +22 -0
- package/dist/components/MessageInput/TextEditor/extensions/enterKeyExtension.d.ts +3 -0
- package/dist/components/MessageInput/TextEditor/extensions/enterKeyExtension.js +15 -0
- package/dist/components/MessageInput/TextEditor/extensions/imageExtension.js +2 -2
- package/dist/components/MessageInput/TextEditor/extensions/index.d.ts +8 -0
- package/dist/components/MessageInput/TextEditor/extensions/index.js +12 -0
- package/dist/components/MessageInput/TextEditor/extensions/mentionExtension.d.ts +5 -0
- package/dist/components/MessageInput/TextEditor/extensions/mentionExtension.js +330 -0
- package/dist/components/MessageInput/TextEditor/index.js +62 -62
- package/dist/components/MessageInput/i18n/en-US.d.ts +3 -0
- package/dist/components/MessageInput/i18n/en-US.js +4 -1
- package/dist/components/MessageInput/i18n/index.d.ts +6 -0
- package/dist/components/MessageInput/i18n/zh-CN.d.ts +3 -0
- package/dist/components/MessageInput/i18n/zh-CN.js +4 -1
- package/dist/components/MessageList/Message/ImageMessage/ImageMessage.js +119 -89
- package/dist/components/MessageList/Message/ImageMessage/ImagePreview.js +142 -0
- package/dist/components/MessageList/Message/ImageMessage/ImagePreview.vue.d.ts +20 -0
- package/dist/components/MessageList/Message/Message.vue.d.ts +8 -0
- package/dist/components/MessageList/Message/MessageLayout/MessageActionDropdown/MessageActionDropdown.js +3 -3
- package/dist/components/MessageList/Message/MessageLayout/MessageLayout.js +69 -58
- package/dist/components/MessageList/Message/MessageLayout/MessageLayout.vue.d.ts +8 -0
- package/dist/components/MessageList/Message/index.js +8 -4
- package/dist/components/MessageList/MessageList.js +98 -90
- package/dist/components/MessageList/MessageList.vue.d.ts +1 -1
- package/dist/components/MessageList/index.d.ts +21 -3
- package/dist/components/Search/SearchResults/SearchResultsItem/Message/Message.js +6 -6
- package/dist/components/Search/SearchResults/SearchResultsItem/Message/Message.vue.d.ts +1 -1
- package/dist/index-BvFYOUyz.js +2936 -0
- package/dist/{index-Do-2CngU.js → index-C8Jw_xE4.js} +1621 -1731
- package/dist/{index-7vNB_Vx8.js → index-CiYL_XsE.js} +1 -1
- package/dist/index-CzCDLp99.js +2174 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +103 -103
- package/dist/states/MessageActionState/MessageActionState.js +83 -223
- package/dist/states/MessageInputState/MessageInputState.js +115 -83
- package/dist/states/MessageInputState/type.d.ts +36 -10
- package/dist/states/MessageInputState/utils.d.ts +1 -5
- package/dist/states/MessageListState/MessageListState.d.ts +1 -1
- package/dist/styles/index.css +1 -1
- package/dist/{chat/index.d.ts → subEntry/chat/chat.d.ts} +2112 -2087
- package/dist/subEntry/chat/chat.js +89 -0
- package/dist/subEntry/chat/index.d.ts +11 -0
- package/dist/subEntry/chat/index.js +81 -0
- package/dist/{chat → subEntry/chat}/server.js +4 -4
- package/dist/{useId-CtirfF0W.js → useId-D5WE76CM.js} +1 -1
- package/dist/{utils-DaB7eSu5.js → utils-CttDpxqz.js} +1 -1
- package/package.json +8 -6
- package/src/components/ConversationList/ConversationPreview/ConversationPreview.scss +8 -0
- package/src/components/ConversationList/ConversationPreview/ConversationPreviewAbstract.vue +32 -1
- package/src/components/ConversationList/ConversationPreview/ConversationPreviewTitle.vue +3 -2
- package/src/components/ConversationList/ConversationPreview/utils.ts +98 -28
- package/src/components/ConversationList/i18n/en-US.ts +10 -1
- package/src/components/ConversationList/i18n/zh-CN.ts +10 -1
- package/src/components/LiveScenePanel/index.vue +1 -0
- package/src/components/MessageInput/QuotedMessagePreview/QuotedMessagePreview.vue +19 -22
- package/src/components/MessageInput/TextEditor/Editor.scss +25 -0
- package/src/components/MessageInput/TextEditor/EditorCore.ts +79 -99
- package/src/components/MessageInput/TextEditor/TextEditor.vue +64 -68
- package/src/components/MessageInput/TextEditor/extensions/MentionSuggestion.vue +449 -0
- package/src/components/MessageInput/TextEditor/extensions/emojiExtension.ts +22 -0
- package/src/components/MessageInput/TextEditor/extensions/enterKeyExtension.ts +22 -0
- package/src/components/MessageInput/TextEditor/extensions/index.ts +8 -0
- package/src/components/MessageInput/TextEditor/extensions/mentionExtension.ts +87 -0
- package/src/components/MessageInput/i18n/en-US.ts +3 -0
- package/src/components/MessageInput/i18n/zh-CN.ts +3 -0
- package/src/components/MessageList/Message/ImageMessage/ImageMessage.vue +49 -0
- package/src/components/MessageList/Message/ImageMessage/ImagePreview.vue +344 -0
- package/src/components/MessageList/Message/Message.vue +6 -0
- package/src/components/MessageList/Message/MessageLayout/MessageLayout.vue +8 -1
- package/src/components/MessageList/MessageList.vue +36 -14
- package/src/components/Search/SearchResults/SearchResultsItem/Message/Message.vue +30 -31
- package/src/index.ts +1 -1
- package/src/{chat/index.ts → subEntry/chat/chat.ts} +25 -18
- package/src/subEntry/chat/index.ts +13 -0
- package/src/{chat → subEntry/chat}/server.ts +3 -3
- package/dist/chat/index.js +0 -59
- package/dist/index-ZILx4LYk.js +0 -4826
- package/dist/states/SearchState.d.ts +0 -314
- /package/dist/{chat → subEntry/chat}/server.d.ts +0 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch, onBeforeUnmount, onMounted } from 'vue';
|
|
3
|
+
import {
|
|
4
|
+
PopoverRoot,
|
|
5
|
+
PopoverContent,
|
|
6
|
+
PopoverAnchor,
|
|
7
|
+
PopoverPortal,
|
|
8
|
+
} from 'reka-ui';
|
|
9
|
+
import { throttle } from '../../../../utils/lodash';
|
|
10
|
+
import { TUIChatEngine } from '@tencentcloud/chat-uikit-engine';
|
|
11
|
+
import { useUIKit } from '@tencentcloud/uikit-base-component-vue3';
|
|
12
|
+
import { Avatar } from '../../../Avatar';
|
|
13
|
+
import { useGroupSettingState } from '../../../../states/GroupSettingState';
|
|
14
|
+
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
|
|
15
|
+
import type { GroupMember } from '../../../../states/GroupSettingState';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<SuggestionProps>();
|
|
18
|
+
|
|
19
|
+
const { t } = useUIKit();
|
|
20
|
+
|
|
21
|
+
const selectedIndex = ref(0);
|
|
22
|
+
const isOpen = ref(true);
|
|
23
|
+
const listboxRef = ref<HTMLDivElement | null>(null);
|
|
24
|
+
const sentinelRef = ref<HTMLDivElement | null>(null);
|
|
25
|
+
const isLoading = ref(false);
|
|
26
|
+
const resizeKey = ref(0); // Trigger virtualElement recalculation on window resize
|
|
27
|
+
|
|
28
|
+
const { allMembers, currentUserID, groupID, memberCount, getGroupMemberList } = useGroupSettingState();
|
|
29
|
+
|
|
30
|
+
const availableMembers = computed<Partial<GroupMember>[]>(() => {
|
|
31
|
+
if (!allMembers.value || !currentUserID.value || !memberCount.value) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const excludeSelfMembers: Partial<GroupMember>[] = allMembers.value.filter(member => member.userID !== currentUserID.value);
|
|
36
|
+
|
|
37
|
+
if (memberCount.value && memberCount.value > 2) {
|
|
38
|
+
excludeSelfMembers.unshift({
|
|
39
|
+
userID: TUIChatEngine.TYPES.MSG_AT_ALL,
|
|
40
|
+
nick: t('MessageInput.at_all_members') || 'all',
|
|
41
|
+
avatar: '/at_all_members.png',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return excludeSelfMembers;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const hasMore = computed(() => (allMembers.value?.length || 0) < (memberCount.value || 0));
|
|
49
|
+
|
|
50
|
+
const filteredItems = computed(() => {
|
|
51
|
+
const normalizedQuery = props.query.toLowerCase().trim();
|
|
52
|
+
|
|
53
|
+
if (!normalizedQuery) {
|
|
54
|
+
return availableMembers.value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return availableMembers.value.filter(item =>
|
|
58
|
+
item.nick!.toLowerCase().includes(normalizedQuery),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const selectedMember = computed(() => filteredItems.value[selectedIndex.value] || null);
|
|
63
|
+
|
|
64
|
+
const showSentinel = computed(() => hasMore.value || isLoading.value || filteredItems.value.length === 0);
|
|
65
|
+
|
|
66
|
+
const loadMore = async () => {
|
|
67
|
+
if (isLoading.value || !hasMore.value || !groupID.value) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
isLoading.value = true;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await getGroupMemberList({
|
|
75
|
+
count: 100,
|
|
76
|
+
offset: allMembers.value?.length || 0,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.error('[MentionSuggestion] Failed to load more members:', error);
|
|
81
|
+
} finally {
|
|
82
|
+
isLoading.value = false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Handle side effects: Open state and Auto-load
|
|
87
|
+
watch(
|
|
88
|
+
[() => props.query, filteredItems, hasMore],
|
|
89
|
+
([query, items, more]) => {
|
|
90
|
+
const normalizedQuery = query.toLowerCase().trim();
|
|
91
|
+
|
|
92
|
+
// 1. Control Open State
|
|
93
|
+
if (!normalizedQuery) {
|
|
94
|
+
isOpen.value = true;
|
|
95
|
+
} else if (items.length === 0 && !more) {
|
|
96
|
+
isOpen.value = false;
|
|
97
|
+
} else {
|
|
98
|
+
isOpen.value = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Auto load if items are few
|
|
102
|
+
if (items.length < 10 && more && !isLoading.value) {
|
|
103
|
+
loadMore();
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ immediate: true }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const virtualElement = computed(() => {
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
111
|
+
resizeKey.value; // Dependency to trigger recalculation on window resize
|
|
112
|
+
|
|
113
|
+
const rect = props.clientRect?.();
|
|
114
|
+
if (!rect) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
getBoundingClientRect: () => ({
|
|
120
|
+
width: 0,
|
|
121
|
+
height: rect.height,
|
|
122
|
+
x: rect.left,
|
|
123
|
+
y: rect.top,
|
|
124
|
+
top: rect.top,
|
|
125
|
+
left: rect.left,
|
|
126
|
+
right: rect.left,
|
|
127
|
+
bottom: rect.top + rect.height,
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Reset selection when filtered items change (but not when loading more)
|
|
133
|
+
watch(filteredItems, (newItems, oldItems) => {
|
|
134
|
+
// Only reset if it's a filter change (length decreased or query changed)
|
|
135
|
+
// Don't reset if it's loading more (length increased)
|
|
136
|
+
if (!oldItems || newItems.length < oldItems.length || newItems.length === 0) {
|
|
137
|
+
selectedIndex.value = 0;
|
|
138
|
+
} else if (newItems.length > oldItems.length) {
|
|
139
|
+
// If loading more, keep the current selection valid
|
|
140
|
+
// Ensure selectedIndex is still within bounds
|
|
141
|
+
if (selectedIndex.value >= newItems.length) {
|
|
142
|
+
selectedIndex.value = newItems.length - 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}, { immediate: true });
|
|
146
|
+
|
|
147
|
+
let observer: IntersectionObserver | null = null;
|
|
148
|
+
|
|
149
|
+
const setupObserver = () => {
|
|
150
|
+
if (observer) {
|
|
151
|
+
observer.disconnect();
|
|
152
|
+
observer = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!listboxRef.value || !sentinelRef.value) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
observer = new IntersectionObserver(
|
|
160
|
+
(entries) => {
|
|
161
|
+
entries.forEach((entry) => {
|
|
162
|
+
if (entry.isIntersecting) {
|
|
163
|
+
loadMore();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
root: listboxRef.value,
|
|
169
|
+
rootMargin: '0px',
|
|
170
|
+
threshold: 0.1,
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
observer.observe(sentinelRef.value);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Watch for sentinel element and setup observer
|
|
178
|
+
// Added filteredItems and isLoading to dependencies to ensure robust observer setup during search/load
|
|
179
|
+
watch([listboxRef, sentinelRef, showSentinel], () => {
|
|
180
|
+
if (showSentinel.value && listboxRef.value && sentinelRef.value) {
|
|
181
|
+
setTimeout(setupObserver, 0);
|
|
182
|
+
}
|
|
183
|
+
}, { flush: 'post' });
|
|
184
|
+
|
|
185
|
+
onMounted(() => {
|
|
186
|
+
window.addEventListener('resize', throttle(handleWindowResize, 30));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
onBeforeUnmount(() => {
|
|
190
|
+
if (observer) {
|
|
191
|
+
observer.disconnect();
|
|
192
|
+
observer = null;
|
|
193
|
+
}
|
|
194
|
+
window.removeEventListener('resize', handleWindowResize);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const handleWindowResize = () => {
|
|
198
|
+
resizeKey.value += 1;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleSelectMember = (index: number) => {
|
|
202
|
+
const member = filteredItems.value[index];
|
|
203
|
+
if (member) {
|
|
204
|
+
props.command({ id: member.userID, label: member.nick, mentionSuggestionChar: '@' });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const scrollToSelected = () => {
|
|
209
|
+
if (!listboxRef.value) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const selectedItem = listboxRef.value.children[selectedIndex.value] as HTMLElement;
|
|
214
|
+
if (!selectedItem) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 1. Ensure the selected member is visible
|
|
219
|
+
selectedItem.scrollIntoView({ block: 'nearest' });
|
|
220
|
+
|
|
221
|
+
// 2. If it's the last item and there's more data, ensure the loading indicator (sentinel) is also visible
|
|
222
|
+
// This provides visual feedback that more items are being loaded
|
|
223
|
+
if (selectedIndex.value === filteredItems.value.length - 1 && hasMore.value && sentinelRef.value) {
|
|
224
|
+
sentinelRef.value.scrollIntoView({ block: 'nearest' });
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const onKeyDown = (keyDownProps: SuggestionKeyDownProps): boolean => {
|
|
229
|
+
const { event } = keyDownProps;
|
|
230
|
+
|
|
231
|
+
if (event.key === 'ArrowDown') {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
const nextIndex = Math.min(selectedIndex.value + 1, filteredItems.value.length - 1);
|
|
234
|
+
selectedIndex.value = nextIndex;
|
|
235
|
+
scrollToSelected();
|
|
236
|
+
|
|
237
|
+
// If reached the last item and has more data to load, trigger load
|
|
238
|
+
if (nextIndex === filteredItems.value.length - 1 && hasMore.value && !isLoading.value) {
|
|
239
|
+
loadMore();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (event.key === 'ArrowUp') {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
|
|
248
|
+
scrollToSelected();
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (event.key === 'Enter') {
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
if (selectedMember.value) {
|
|
255
|
+
handleSelectMember(selectedIndex.value);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (event.key === 'Escape') {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return false;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleOpenAutoFocus = (event: Event) => {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
defineExpose({
|
|
272
|
+
onKeyDown,
|
|
273
|
+
});
|
|
274
|
+
</script>
|
|
275
|
+
|
|
276
|
+
<template>
|
|
277
|
+
<PopoverRoot v-model:open="isOpen" :modal="false">
|
|
278
|
+
<!-- Virtual anchor for cursor positioning -->
|
|
279
|
+
<PopoverAnchor v-if="virtualElement" :reference="virtualElement" />
|
|
280
|
+
|
|
281
|
+
<PopoverPortal>
|
|
282
|
+
<PopoverContent
|
|
283
|
+
class="mention-suggestion__content"
|
|
284
|
+
side="top"
|
|
285
|
+
align="start"
|
|
286
|
+
:side-offset="8"
|
|
287
|
+
:collision-padding="8"
|
|
288
|
+
:avoid-collisions="false"
|
|
289
|
+
@open-auto-focus="handleOpenAutoFocus"
|
|
290
|
+
>
|
|
291
|
+
<!-- Member list with manual keyboard navigation -->
|
|
292
|
+
<div ref="listboxRef" class="mention-suggestion__listbox">
|
|
293
|
+
<!-- Member items -->
|
|
294
|
+
<div
|
|
295
|
+
v-for="(item, index) in filteredItems"
|
|
296
|
+
:key="item.userID"
|
|
297
|
+
class="mention-suggestion__item"
|
|
298
|
+
:class="{ 'mention-suggestion__item--selected': index === selectedIndex }"
|
|
299
|
+
@click="handleSelectMember(index)"
|
|
300
|
+
@mouseenter="selectedIndex = index"
|
|
301
|
+
>
|
|
302
|
+
<Avatar
|
|
303
|
+
v-if="item.avatar !== '/at_all_members.png'"
|
|
304
|
+
class="mention-suggestion__avatar"
|
|
305
|
+
:src="item.avatar"
|
|
306
|
+
/>
|
|
307
|
+
<span class="mention-suggestion__name">{{ item.nick || item.userID }}</span>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<!-- Sentinel element for loading more / showing states -->
|
|
311
|
+
<div
|
|
312
|
+
v-if="showSentinel"
|
|
313
|
+
ref="sentinelRef"
|
|
314
|
+
class="mention-suggestion__sentinel"
|
|
315
|
+
>
|
|
316
|
+
<!-- Loading state -->
|
|
317
|
+
<div v-if="hasMore" class="mention-suggestion__loading">
|
|
318
|
+
<div class="mention-suggestion__spinner" />
|
|
319
|
+
<span>{{ t('MessageInput.loading') }}</span>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<!-- No results after search -->
|
|
323
|
+
<div
|
|
324
|
+
v-else-if="filteredItems.length === 0 && !hasMore"
|
|
325
|
+
class="mention-suggestion__empty"
|
|
326
|
+
>
|
|
327
|
+
{{ t('MessageInput.no_matching_members') }}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
</PopoverContent>
|
|
332
|
+
</PopoverPortal>
|
|
333
|
+
</PopoverRoot>
|
|
334
|
+
</template>
|
|
335
|
+
|
|
336
|
+
<style scoped lang="scss">
|
|
337
|
+
@use '../../../../styles/mixins/index.scss' as mixins;
|
|
338
|
+
|
|
339
|
+
:deep(.mention-suggestion__content) {
|
|
340
|
+
width: 160px;
|
|
341
|
+
background: var(--dropdown-color-default);
|
|
342
|
+
border-radius: 8px;
|
|
343
|
+
box-shadow: 0 4px 12px var(--shadow-color);
|
|
344
|
+
overflow: hidden;
|
|
345
|
+
z-index: 9999;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
:deep(.mention-suggestion__listbox) {
|
|
349
|
+
max-height: 200px;
|
|
350
|
+
overflow-y: auto;
|
|
351
|
+
overflow-x: hidden;
|
|
352
|
+
padding: 4px 0;
|
|
353
|
+
|
|
354
|
+
@include mixins.scrollbar-default();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
:deep(.mention-suggestion__item) {
|
|
358
|
+
display: flex;
|
|
359
|
+
align-items: center;
|
|
360
|
+
height: 40px;
|
|
361
|
+
width: 100%;
|
|
362
|
+
box-sizing: border-box;
|
|
363
|
+
padding: 8px 12px;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
transition: background-color 0.15s;
|
|
366
|
+
outline: none;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
:deep(.mention-suggestion__item--selected) {
|
|
370
|
+
background-color: var(--dropdown-color-active);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
:deep(.mention-suggestion__item:hover) {
|
|
374
|
+
background-color: var(--dropdown-color-hover);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
:deep(.mention-suggestion__sentinel) {
|
|
378
|
+
display: flex;
|
|
379
|
+
align-items: center;
|
|
380
|
+
justify-content: center;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
:deep(.mention-suggestion__loading) {
|
|
384
|
+
display: flex;
|
|
385
|
+
align-items: center;
|
|
386
|
+
gap: 8px;
|
|
387
|
+
padding: 4px;
|
|
388
|
+
color: var(--text-color-primary);
|
|
389
|
+
font-size: 13px;
|
|
390
|
+
width: 100%;
|
|
391
|
+
justify-content: center;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
:deep(.mention-suggestion__spinner) {
|
|
395
|
+
width: 16px;
|
|
396
|
+
height: 16px;
|
|
397
|
+
border: 2px solid rgba(0, 122, 255, 0.2);
|
|
398
|
+
border-top-color: rgba(0, 122, 255, 0.8);
|
|
399
|
+
border-radius: 50%;
|
|
400
|
+
animation: spin 0.6s linear infinite;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@keyframes spin {
|
|
404
|
+
to {
|
|
405
|
+
transform: rotate(360deg);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
:deep(.mention-suggestion__load-trigger) {
|
|
410
|
+
height: 20px;
|
|
411
|
+
width: 100%;
|
|
412
|
+
background: transparent;
|
|
413
|
+
display: flex;
|
|
414
|
+
align-items: center;
|
|
415
|
+
justify-content: center;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
:deep(.mention-suggestion__load-hint) {
|
|
419
|
+
font-size: 12px;
|
|
420
|
+
color: var(--text-color-primary);
|
|
421
|
+
padding: 6px 0;
|
|
422
|
+
user-select: none;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
:deep(.mention-suggestion__empty) {
|
|
426
|
+
padding: 4px 0px;
|
|
427
|
+
text-align: center;
|
|
428
|
+
color: var(--text-color-primary);
|
|
429
|
+
font-size: 14px;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
:deep(.mention-suggestion__avatar) {
|
|
433
|
+
width: 20px;
|
|
434
|
+
height: 20px;
|
|
435
|
+
border-radius: 50%;
|
|
436
|
+
margin-right: 12px;
|
|
437
|
+
flex-shrink: 0;
|
|
438
|
+
object-fit: cover;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
:deep(.mention-suggestion__name) {
|
|
442
|
+
font-size: 14px;
|
|
443
|
+
color: var(--text-color-primary);
|
|
444
|
+
font-weight: 500;
|
|
445
|
+
white-space: nowrap;
|
|
446
|
+
overflow: hidden;
|
|
447
|
+
text-overflow: ellipsis;
|
|
448
|
+
}
|
|
449
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emoji Extension - Display inline emojis as images
|
|
3
|
+
*/
|
|
4
|
+
import Image from '@tiptap/extension-image';
|
|
5
|
+
import { MessageContentType } from '../../../../states/MessageInputState';
|
|
6
|
+
|
|
7
|
+
export function createEmojiExtension() {
|
|
8
|
+
return Image.extend({
|
|
9
|
+
name: MessageContentType.EMOJI,
|
|
10
|
+
inline: true,
|
|
11
|
+
group: 'inline',
|
|
12
|
+
draggable: true,
|
|
13
|
+
addOptions() {
|
|
14
|
+
return {
|
|
15
|
+
...this.parent?.(),
|
|
16
|
+
HTMLAttributes: {
|
|
17
|
+
class: 'message-emoji',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enter Key Extension - Handle Enter key for sending message and Mod-Enter for line break
|
|
3
|
+
*/
|
|
4
|
+
import { Extension } from '@tiptap/vue-3';
|
|
5
|
+
|
|
6
|
+
export function createEnterKeyExtension(onEnter?: () => void) {
|
|
7
|
+
return Extension.create({
|
|
8
|
+
name: 'enterKey',
|
|
9
|
+
addKeyboardShortcuts() {
|
|
10
|
+
return {
|
|
11
|
+
'Enter': () => {
|
|
12
|
+
onEnter?.();
|
|
13
|
+
return true;
|
|
14
|
+
},
|
|
15
|
+
'Mod-Enter': ({ editor }) => {
|
|
16
|
+
editor.commands.setHardBreak();
|
|
17
|
+
return true;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extensions Index - Centralized export for all Tiptap extensions
|
|
3
|
+
*/
|
|
4
|
+
export { CharacterCount } from './characterCountExtension';
|
|
5
|
+
export { createEmojiExtension } from './emojiExtension';
|
|
6
|
+
export { createEnterKeyExtension } from './enterKeyExtension';
|
|
7
|
+
export { createImageExtension } from './imageExtension';
|
|
8
|
+
export { createMentionExtension } from './mentionExtension';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TipTap Mention Extension Configuration
|
|
3
|
+
* @description Enables @ mention functionality in the editor
|
|
4
|
+
* Data management is handled by MentionSuggestion component via GroupSettingState
|
|
5
|
+
*/
|
|
6
|
+
import Mention from '@tiptap/extension-mention';
|
|
7
|
+
import { VueRenderer } from '@tiptap/vue-3';
|
|
8
|
+
import { useConversationListState } from '../../../../states/ConversationListState';
|
|
9
|
+
import { useGroupSettingState, GroupType } from '../../../../states/GroupSettingState';
|
|
10
|
+
import { ConversationType } from '../../../../types/engine';
|
|
11
|
+
import MentionSuggestion from './MentionSuggestion.vue';
|
|
12
|
+
import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion';
|
|
13
|
+
|
|
14
|
+
interface MentionComponentRef {
|
|
15
|
+
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create Mention extension for @ member functionality
|
|
20
|
+
* @returns Configured Mention extension
|
|
21
|
+
*/
|
|
22
|
+
export function createMentionExtension() {
|
|
23
|
+
return Mention.configure({
|
|
24
|
+
deleteTriggerWithBackspace: true,
|
|
25
|
+
HTMLAttributes: {
|
|
26
|
+
class: 'uikit-message-input-mention-tag',
|
|
27
|
+
},
|
|
28
|
+
suggestion: {
|
|
29
|
+
char: '@',
|
|
30
|
+
// Items are now managed inside the component via GroupSettingState
|
|
31
|
+
items: () => [],
|
|
32
|
+
render: () => {
|
|
33
|
+
let component: VueRenderer | null = null;
|
|
34
|
+
let element: HTMLElement | null = null;
|
|
35
|
+
|
|
36
|
+
const destroy = () => {
|
|
37
|
+
if (element?.parentNode) {
|
|
38
|
+
element.parentNode.removeChild(element);
|
|
39
|
+
}
|
|
40
|
+
component?.destroy();
|
|
41
|
+
component = null;
|
|
42
|
+
element = null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
onStart: (props: SuggestionProps) => {
|
|
47
|
+
const { activeConversation } = useConversationListState();
|
|
48
|
+
const { memberCount, groupType } = useGroupSettingState();
|
|
49
|
+
if (
|
|
50
|
+
activeConversation.value?.type === ConversationType.GROUP
|
|
51
|
+
&& memberCount.value
|
|
52
|
+
&& memberCount.value > 1
|
|
53
|
+
&& groupType.value !== GroupType.AVCHATROOM
|
|
54
|
+
) {
|
|
55
|
+
component = new VueRenderer(MentionSuggestion, {
|
|
56
|
+
props,
|
|
57
|
+
editor: props.editor,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (component.element) {
|
|
61
|
+
element = component.element as HTMLElement;
|
|
62
|
+
document.body.appendChild(element);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
onUpdate(props: SuggestionProps) {
|
|
68
|
+
component?.updateProps(props);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
onKeyDown(props: SuggestionKeyDownProps) {
|
|
72
|
+
if (props.event.key === 'Escape') {
|
|
73
|
+
destroy();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (component?.ref as unknown as MentionComponentRef)?.onKeyDown?.(props) || false;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
onExit() {
|
|
81
|
+
destroy();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|