tuikit-atomicx-vue3 4.5.0 → 4.5.2

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.
Files changed (182) hide show
  1. package/dist/{MessageInput.vue_vue_type_script_setup_true_lang-jPzZ5INK.js → MessageInput.vue_vue_type_script_setup_true_lang-3RVYOdkv.js} +38 -38
  2. package/dist/{PopoverTrigger-L8abAry7.js → PopoverPortal-DV6zFXcf.js} +91 -136
  3. package/dist/PopoverTrigger-DIjW4PKa.js +54 -0
  4. package/dist/{PopperContent-XdhqL8Y2.js → PopperContent-D__dbwpA.js} +6 -6
  5. package/dist/{Teleport-CSEuZbpM.js → Teleport-98QrIYDI.js} +280 -270
  6. package/dist/baseComp/Modal/Modal.js +3 -3
  7. package/dist/components/AudioSettingPanel/index.js +14 -14
  8. package/dist/components/BarrageInput/EmojiPicker/EmojiPicker.js +10 -9
  9. package/dist/components/BarrageInput/TextEditor/CharacterCountExtension.js +1 -1
  10. package/dist/components/BarrageInput/TextEditor/EditorCore.js +16 -15
  11. package/dist/components/ConversationList/ConversationActions/ConversationActions.js +53 -50
  12. package/dist/components/ConversationList/ConversationActions/ConversationActions.vue.d.ts +2 -0
  13. package/dist/components/ConversationList/ConversationCreate/ConversationCreate.js +25 -25
  14. package/dist/components/ConversationList/ConversationList.vue.d.ts +16 -0
  15. package/dist/components/ConversationList/ConversationPreview/ConversationPreview.vue.d.ts +16 -0
  16. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewAbstract.js +62 -40
  17. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewTimestamp.js +28 -26
  18. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewTitle.js +27 -23
  19. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewTitle.vue.d.ts +1 -1
  20. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewUI.js +81 -70
  21. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewUI.vue.d.ts +4 -0
  22. package/dist/components/ConversationList/ConversationPreview/ConversationPreviewUnread.js +33 -31
  23. package/dist/components/ConversationList/ConversationPreview/utils.d.ts +2 -2
  24. package/dist/components/ConversationList/ConversationPreview/utils.js +81 -35
  25. package/dist/components/ConversationList/i18n/en-US.d.ts +11 -1
  26. package/dist/components/ConversationList/i18n/en-US.js +12 -2
  27. package/dist/components/ConversationList/i18n/zh-CN.d.ts +11 -1
  28. package/dist/components/ConversationList/i18n/zh-CN.js +12 -2
  29. package/dist/components/ConversationList/index.d.ts +48 -0
  30. package/dist/components/MessageInput/AttachmentPicker/index.js +10 -9
  31. package/dist/components/MessageInput/EmojiPicker/EmojiPicker.js +17 -16
  32. package/dist/components/MessageInput/MessageInput.js +1 -1
  33. package/dist/components/MessageInput/QuotedMessagePreview/index.js +35 -35
  34. package/dist/components/MessageInput/TextEditor/EditorCore.d.ts +13 -12
  35. package/dist/components/MessageInput/TextEditor/EditorCore.js +54 -89
  36. package/dist/components/MessageInput/TextEditor/extensions/MentionSuggestion.js +229 -0
  37. package/dist/components/MessageInput/TextEditor/extensions/MentionSuggestion.vue.d.ts +15 -0
  38. package/dist/components/MessageInput/TextEditor/extensions/characterCountExtension.js +1 -1
  39. package/dist/components/MessageInput/TextEditor/extensions/emojiExtension.d.ts +1 -0
  40. package/dist/components/MessageInput/TextEditor/extensions/emojiExtension.js +22 -0
  41. package/dist/components/MessageInput/TextEditor/extensions/enterKeyExtension.d.ts +3 -0
  42. package/dist/components/MessageInput/TextEditor/extensions/enterKeyExtension.js +15 -0
  43. package/dist/components/MessageInput/TextEditor/extensions/imageExtension.js +2 -2
  44. package/dist/components/MessageInput/TextEditor/extensions/index.d.ts +8 -0
  45. package/dist/components/MessageInput/TextEditor/extensions/index.js +12 -0
  46. package/dist/components/MessageInput/TextEditor/extensions/mentionExtension.d.ts +5 -0
  47. package/dist/components/MessageInput/TextEditor/extensions/mentionExtension.js +330 -0
  48. package/dist/components/MessageInput/TextEditor/index.js +62 -62
  49. package/dist/components/MessageInput/i18n/en-US.d.ts +3 -0
  50. package/dist/components/MessageInput/i18n/en-US.js +4 -1
  51. package/dist/components/MessageInput/i18n/index.d.ts +6 -0
  52. package/dist/components/MessageInput/i18n/zh-CN.d.ts +3 -0
  53. package/dist/components/MessageInput/i18n/zh-CN.js +4 -1
  54. package/dist/components/MessageInput/index.js +1 -1
  55. package/dist/components/MessageList/Message/ImageMessage/ImageMessage.js +119 -89
  56. package/dist/components/MessageList/Message/ImageMessage/ImagePreview.js +142 -0
  57. package/dist/components/MessageList/Message/ImageMessage/ImagePreview.vue.d.ts +20 -0
  58. package/dist/components/MessageList/Message/Message.vue.d.ts +8 -0
  59. package/dist/components/MessageList/Message/MessageLayout/MessageActionDropdown/MessageActionDropdown.js +3 -3
  60. package/dist/components/MessageList/Message/MessageLayout/MessageLayout.js +69 -58
  61. package/dist/components/MessageList/Message/MessageLayout/MessageLayout.vue.d.ts +8 -0
  62. package/dist/components/MessageList/Message/MessageLayout/MessageMeta/MessageMeta.js +37 -28
  63. package/dist/components/MessageList/Message/index.js +8 -4
  64. package/dist/components/MessageList/MessageList.js +109 -91
  65. package/dist/components/MessageList/MessageList.vue.d.ts +1 -1
  66. package/dist/components/MessageList/index.d.ts +21 -3
  67. package/dist/components/ScheduleRoomPanel/RoomDetail.js +22 -22
  68. package/dist/components/ScheduleRoomPanel/RoomEdit.js +1 -1
  69. package/dist/components/ScheduleRoomPanel/RoomShare.js +4 -4
  70. package/dist/components/ScheduleRoomPanel/ScheduleRoomPanel.js +1 -1
  71. package/dist/components/ScheduleRoomPanel/ScheduledRoomList.js +1 -1
  72. package/dist/components/Search/SearchResults/SearchResultsItem/Message/Message.js +6 -6
  73. package/dist/components/Search/SearchResults/SearchResultsItem/Message/Message.vue.d.ts +1 -1
  74. package/dist/components/UIKitModal/chatErrorModal/chatErrorModal.d.ts +72 -0
  75. package/dist/components/UIKitModal/chatErrorModal/chatErrorModal.js +95 -0
  76. package/dist/components/UIKitModal/chatErrorModal/i18n/en-US/index.d.ts +40 -0
  77. package/dist/components/UIKitModal/chatErrorModal/i18n/en-US/index.js +51 -0
  78. package/dist/components/UIKitModal/chatErrorModal/i18n/index.d.ts +4 -0
  79. package/dist/components/UIKitModal/chatErrorModal/i18n/index.js +6 -0
  80. package/dist/components/UIKitModal/chatErrorModal/i18n/zh-CN/index.d.ts +40 -0
  81. package/dist/components/UIKitModal/chatErrorModal/i18n/zh-CN/index.js +51 -0
  82. package/dist/components/UIKitModal/chatErrorModal/index.d.ts +3 -0
  83. package/dist/components/UIKitModal/chatErrorModal/index.js +11 -0
  84. package/dist/components/VideoSettingPanel/index.js +1 -1
  85. package/dist/hooks/useReadReceipt/useReadReceipt.js +44 -41
  86. package/dist/index-CTthrJb2.js +1461 -0
  87. package/dist/index-DXC5bPY4.js +2174 -0
  88. package/dist/{index-Do-2CngU.js → index-DuAffztD.js} +115 -142
  89. package/dist/{index-7vNB_Vx8.js → index-hHVD-MG2.js} +1 -1
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.js +150 -149
  92. package/dist/states/GroupSettingState/GroupSettingState.js +109 -102
  93. package/dist/states/LoginState.js +43 -43
  94. package/dist/states/MessageActionState/MessageActionState.js +83 -223
  95. package/dist/states/MessageInputState/MessageInputState.js +111 -82
  96. package/dist/states/MessageInputState/type.d.ts +36 -10
  97. package/dist/states/MessageInputState/utils.d.ts +1 -5
  98. package/dist/states/MessageListState/MessageListState.d.ts +1 -1
  99. package/dist/states/MessageListState/MessageListState.js +26 -23
  100. package/dist/states/RoomParticipantState/index.js +83 -24
  101. package/dist/states/RoomParticipantState/participantEventManager.d.ts +2 -2
  102. package/dist/states/RoomParticipantState/participantEventManager.js +217 -205
  103. package/dist/states/RoomParticipantState/participantManager.d.ts +4 -0
  104. package/dist/states/RoomParticipantState/participantManager.js +159 -127
  105. package/dist/states/RoomState/callManager.d.ts +3 -3
  106. package/dist/states/RoomState/callManager.js +20 -20
  107. package/dist/states/RoomState/common.d.ts +3 -2
  108. package/dist/states/RoomState/common.js +34 -24
  109. package/dist/states/RoomState/roomManager.d.ts +0 -1
  110. package/dist/states/RoomState/roomManager.js +21 -27
  111. package/dist/states/RoomState/scheduleManager.js +2 -2
  112. package/dist/styles/index.css +1 -1
  113. package/dist/{chat/index.d.ts → subEntry/chat/chat.d.ts} +2160 -2087
  114. package/dist/subEntry/chat/chat.js +89 -0
  115. package/dist/subEntry/chat/index.d.ts +11 -0
  116. package/dist/subEntry/chat/index.js +81 -0
  117. package/dist/{chat → subEntry/chat}/server.js +4 -4
  118. package/dist/subEntry/live/index.js +46 -45
  119. package/dist/subEntry/room/index.js +39 -38
  120. package/dist/types/beauty.d.ts +20 -0
  121. package/dist/types/index.js +37 -36
  122. package/dist/types/participant.d.ts +2 -0
  123. package/dist/types/room.d.ts +45 -1
  124. package/dist/types/room.js +4 -3
  125. package/dist/{useId-CtirfF0W.js → useId-B1VwPJLm.js} +1 -1
  126. package/dist/utils/call.js +77 -71
  127. package/dist/{utils-DaB7eSu5.js → utils-BU8IkP_V.js} +1 -1
  128. package/package.json +8 -7
  129. package/src/components/AudioSettingPanel/index.vue +4 -5
  130. package/src/components/ConversationList/ConversationActions/ConversationActions.vue +7 -1
  131. package/src/components/ConversationList/ConversationCreate/ConversationCreate.vue +5 -1
  132. package/src/components/ConversationList/ConversationList.vue +0 -1
  133. package/src/components/ConversationList/ConversationPreview/ConversationPreview.scss +10 -2
  134. package/src/components/ConversationList/ConversationPreview/ConversationPreviewAbstract.vue +32 -1
  135. package/src/components/ConversationList/ConversationPreview/ConversationPreviewTitle.vue +3 -2
  136. package/src/components/ConversationList/ConversationPreview/ConversationPreviewUI.vue +15 -0
  137. package/src/components/ConversationList/ConversationPreview/utils.ts +98 -28
  138. package/src/components/ConversationList/i18n/en-US.ts +11 -1
  139. package/src/components/ConversationList/i18n/zh-CN.ts +11 -1
  140. package/src/components/MessageInput/MessageInput.module.scss +1 -0
  141. package/src/components/MessageInput/QuotedMessagePreview/QuotedMessagePreview.vue +19 -22
  142. package/src/components/MessageInput/TextEditor/Editor.scss +25 -0
  143. package/src/components/MessageInput/TextEditor/EditorCore.ts +79 -99
  144. package/src/components/MessageInput/TextEditor/TextEditor.vue +64 -68
  145. package/src/components/MessageInput/TextEditor/extensions/MentionSuggestion.vue +449 -0
  146. package/src/components/MessageInput/TextEditor/extensions/emojiExtension.ts +22 -0
  147. package/src/components/MessageInput/TextEditor/extensions/enterKeyExtension.ts +22 -0
  148. package/src/components/MessageInput/TextEditor/extensions/index.ts +8 -0
  149. package/src/components/MessageInput/TextEditor/extensions/mentionExtension.ts +87 -0
  150. package/src/components/MessageInput/i18n/en-US.ts +3 -0
  151. package/src/components/MessageInput/i18n/zh-CN.ts +3 -0
  152. package/src/components/MessageList/Message/ImageMessage/ImageMessage.vue +49 -0
  153. package/src/components/MessageList/Message/ImageMessage/ImagePreview.vue +344 -0
  154. package/src/components/MessageList/Message/Message.vue +6 -0
  155. package/src/components/MessageList/Message/MessageLayout/MessageLayout.vue +8 -1
  156. package/src/components/MessageList/Message/MessageLayout/MessageMeta/MessageMeta.vue +12 -3
  157. package/src/components/MessageList/MessageList.vue +50 -14
  158. package/src/components/ScheduleRoomPanel/RoomDetail.vue +1 -0
  159. package/src/components/ScheduleRoomPanel/RoomEdit.vue +2 -1
  160. package/src/components/ScheduleRoomPanel/RoomShare.vue +1 -0
  161. package/src/components/ScheduleRoomPanel/ScheduleRoomPanel.vue +1 -0
  162. package/src/components/ScheduleRoomPanel/ScheduledRoomList.vue +1 -0
  163. package/src/components/Search/SearchResults/SearchResultsItem/Message/Message.vue +30 -31
  164. package/src/components/UIKitModal/chatErrorModal/chatErrorModal.ts +205 -0
  165. package/src/components/UIKitModal/chatErrorModal/i18n/en-US/index.ts +56 -0
  166. package/src/components/UIKitModal/chatErrorModal/i18n/index.ts +4 -0
  167. package/src/components/UIKitModal/chatErrorModal/i18n/zh-CN/index.ts +56 -0
  168. package/src/components/UIKitModal/chatErrorModal/index.ts +16 -0
  169. package/src/components/VideoSettingPanel/index.vue +1 -0
  170. package/src/hooks/useReadReceipt/useReadReceipt.ts +5 -4
  171. package/src/index.ts +1 -1
  172. package/src/{chat/index.ts → subEntry/chat/chat.ts} +25 -18
  173. package/src/subEntry/chat/index.ts +13 -0
  174. package/src/{chat → subEntry/chat}/server.ts +3 -3
  175. package/src/types/beauty.ts +20 -0
  176. package/src/types/participant.ts +3 -0
  177. package/src/types/room.ts +49 -1
  178. package/src/utils/call.ts +8 -0
  179. package/dist/chat/index.js +0 -59
  180. package/dist/index-ZILx4LYk.js +0 -4826
  181. package/dist/states/SearchState.d.ts +0 -314
  182. /package/dist/{chat → subEntry/chat}/server.d.ts +0 -0
@@ -3,10 +3,10 @@
3
3
  <div :class="styles['input-prefix']">
4
4
  <slot name="inputPrefix" />
5
5
  </div>
6
- <div
7
- ref="editorDomRef"
8
- :key="props.disabled ? 'disabled' : 'enabled'"
6
+ <EditorContent
7
+ :editor="editor"
9
8
  :class="styles['editor']"
9
+ class="message-input"
10
10
  />
11
11
  <div :class="styles['input-suffix']">
12
12
  <slot name="inputSuffix" />
@@ -15,13 +15,13 @@
15
15
  </template>
16
16
 
17
17
  <script setup lang="ts">
18
- import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
18
+ import { computed, watch, onBeforeUnmount } from 'vue';
19
19
  import { useUIKit } from '@tencentcloud/uikit-base-component-vue3';
20
+ import { useEditor, EditorContent } from '@tiptap/vue-3';
20
21
  import { useConversationListState } from '../../../states/ConversationListState';
21
22
  import { useMessageInputState } from '../../../states/MessageInputState';
22
- import { createEditor } from './EditorCore';
23
+ import { createExtensions, convertEditorContent } from './EditorCore';
23
24
  import styles from './TextEditor.module.scss';
24
- import type { Editor } from './EditorCore';
25
25
 
26
26
  interface TextEditorProps {
27
27
  autoFocus?: boolean;
@@ -41,85 +41,81 @@ const { t, language } = useUIKit();
41
41
  const { activeConversation } = useConversationListState();
42
42
  const { updateRawValue, sendMessage, setEditorInstance, setContent } = useMessageInputState();
43
43
 
44
- const editorDomRef = ref<HTMLDivElement | null>(null);
45
- const isFocused = ref(props.autoFocus);
46
-
47
44
  const computedPlaceholder = computed(() => props.placeholder ?? t('MessageInput.enter_a_message'));
48
45
 
49
- let editorInstance: Editor | null = null;
46
+ // Handle Enter key to send message
47
+ const handleEnter = () => {
48
+ sendMessage();
49
+ setContent('');
50
+ };
50
51
 
51
- onMounted(() => {
52
- const element = editorDomRef.value;
53
- if (!element) {
54
- return;
52
+ // Create editor using Tiptap's official useEditor composable
53
+ const editor = useEditor({
54
+ autofocus: props.autoFocus,
55
+ editable: !props.disabled,
56
+ extensions: createExtensions({
57
+ placeholder: computedPlaceholder.value,
58
+ maxLength: props.maxLength,
59
+ showPlaceholderOnlyWhenEditable: props.placeholder === undefined,
60
+ onEnter: handleEnter,
61
+ }),
62
+ onUpdate: ({ editor: editorInstance }) => {
63
+ const content = convertEditorContent(editorInstance.getJSON());
64
+ updateRawValue(content);
65
+ },
66
+ });
67
+
68
+ // Sync editor instance to global state
69
+ watch(editor, (newEditor) => {
70
+ setEditorInstance(newEditor ?? null);
71
+ }, { immediate: true });
72
+
73
+ // Reactive: disabled prop
74
+ watch(() => props.disabled, (newDisabled) => {
75
+ editor.value?.setEditable(!newDisabled);
76
+ if (newDisabled) {
77
+ setContent('');
55
78
  }
79
+ });
56
80
 
57
- element.classList.add('message-input');
81
+ // Reactive: placeholder (including language change)
82
+ watch([computedPlaceholder, language], () => {
83
+ if (!editor.value) {
84
+ return;
85
+ }
58
86
 
59
- if (!element.dataset.editorCreated) {
60
- editorInstance = createEditor({
61
- element,
62
- placeholder: computedPlaceholder.value,
63
- isPlaceholderOnlyShowWhenEditable: props.placeholder === undefined,
64
- autoFocus: props.autoFocus,
65
- disabled: props.disabled,
66
- maxLength: props.maxLength,
67
- onUpdate: (content) => {
68
- updateRawValue(content);
69
- },
70
- onEnter: () => {
71
- sendMessage();
72
- setContent('');
73
- },
74
- onFocus: () => {
75
- isFocused.value = true;
76
- },
77
- onBlur: () => {
78
- isFocused.value = false;
79
- },
80
- });
81
- element.dataset.editorCreated = 'true';
82
- setEditorInstance(editorInstance);
87
+ const placeholderExtension = editor.value.extensionManager.extensions.find(
88
+ ext => ext.name === 'placeholder',
89
+ );
90
+ if (placeholderExtension) {
91
+ placeholderExtension.options.placeholder = computedPlaceholder.value;
92
+ editor.value.view.updateState(editor.value.state);
83
93
  }
84
94
  });
85
95
 
86
- onUnmounted(() => {
87
- const element = editorDomRef.value;
88
- if (editorInstance && element) {
89
- editorInstance.destroy();
90
- element.removeAttribute('data-editor-created');
91
- setEditorInstance(null);
96
+ // Reactive: maxLength
97
+ watch(() => props.maxLength, (newMaxLength) => {
98
+ if (!editor.value) {
99
+ return;
100
+ }
101
+
102
+ const characterCountExtension = editor.value.extensionManager.extensions.find(
103
+ ext => ext.name === 'characterCount',
104
+ );
105
+ if (characterCountExtension) {
106
+ characterCountExtension.options.limit = newMaxLength;
92
107
  }
93
108
  });
94
109
 
110
+ // Clear content when conversation changes
95
111
  watch(activeConversation, (newConversation, oldConversation) => {
96
112
  if (newConversation?.conversationID !== oldConversation?.conversationID) {
97
113
  setContent('');
98
114
  }
99
115
  });
100
116
 
101
- // Watch language change and update placeholder using Tiptap's extensionManager
102
- watch(language, () => {
103
- if (editorInstance && props.placeholder === undefined) {
104
- // Update placeholder extension options
105
- const placeholderExtension = editorInstance.extensionManager.extensions.find(
106
- ext => ext.name === 'placeholder',
107
- );
108
- if (placeholderExtension) {
109
- // eslint-disable-next-line no-param-reassign
110
- placeholderExtension.options.placeholder = computedPlaceholder.value;
111
- // Force re-render to apply new placeholder
112
- editorInstance.view.updateState(editorInstance.state);
113
- }
114
- }
115
- });
116
-
117
- watch(() => props.disabled, (newDisabled) => {
118
- if (editorInstance) {
119
- editorInstance.setEditable(!newDisabled);
120
- if (newDisabled) {
121
- setContent('');
122
- }
123
- }
117
+ // Cleanup on unmount
118
+ onBeforeUnmount(() => {
119
+ setEditorInstance(null);
124
120
  });
125
121
  </script>
@@ -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';