8484 :value =" draft"
8585 class =" textarea floating-markdown-editor__textarea"
8686 :class =" { 'floating-markdown-editor__textarea--hidden': activeTab !== 'write' }"
87+ role =" combobox"
8788 :rows =" compact ? 4 : 6"
8889 :placeholder =" placeholder"
8990 :disabled =" isSubmitting"
91+ :aria-expanded =" mentionOpen"
92+ :aria-controls =" mentionListboxId"
93+ :aria-activedescendant ="
94+ mentionActiveIndex >= 0 ? `${mentionComponentId}-opt-${mentionActiveIndex}` : undefined
95+ "
96+ aria-haspopup =" listbox"
97+ autocomplete =" off"
9098 @input =" handleDraftInput"
99+ @keydown =" handleTextareaKeydown"
100+ @keyup =" handleTextareaKeyup"
101+ @click =" refreshMentionTrigger"
102+ @select =" refreshMentionTrigger"
91103 />
92104
93105 <div
104116 {{ t('floatingMarkdownEditor.previewEmpty') }}
105117 </p >
106118 </div >
119+
120+ <AutocompleteMenu
121+ :open =" mentionOpen "
122+ :suggestions =" mentionSuggestions "
123+ :query =" mentionQuery "
124+ :active-index =" mentionActiveIndex "
125+ :listbox-id =" mentionListboxId "
126+ :option-id-prefix =" mentionComponentId "
127+ :panel-style =" mentionPanelStyle "
128+ :loading =" mentionLoading "
129+ :empty-message =" mentionEmptyMessage "
130+ :aria-label =" t (' floatingMarkdownEditor.mentionSuggestions' )"
131+ @select =" insertMentionSuggestion "
132+ @activate =" mentionActiveIndex = $event "
133+ />
107134 </div >
108135
109136 <div class =" floating-markdown-editor__footer" >
135162</template >
136163
137164<script setup lang="ts">
138- import { computed , nextTick , shallowRef , useTemplateRef , watch } from ' vue' ;
165+ import { computed , nextTick , onBeforeUnmount , shallowRef , useId , useTemplateRef , watch } from ' vue' ;
139166import { useI18n } from ' vue-i18n' ;
140167
168+ import type { MentionSuggestionsResponse } from ' #shared/types/mention-suggestions' ;
169+ import {
170+ findMarkdownMentionTrigger ,
171+ type MarkdownMentionTrigger ,
172+ } from ' #shared/utils/markdown-mentions' ;
173+ import type { AutocompleteSuggestion } from ' ~/components/ui/autocomplete' ;
174+ import AutocompleteMenu from ' ~/components/ui/AutocompleteMenu.vue' ;
141175import GitHubAvatar from ' ~/components/ui/GitHubAvatar.vue' ;
142176import MarkdownRenderer from ' ~/components/ui/MarkdownRenderer.vue' ;
143177
@@ -214,21 +248,41 @@ const emit = defineEmits<{
214248}>();
215249
216250const { t } = useI18n ();
251+ const apiFetch = useGitPulseApiFetch ();
217252const { user } = useUserSession ();
218253const { isAnyModalOpen } = useModalState ();
219254
220255const textareaRef = useTemplateRef <HTMLTextAreaElement >(' textareaRef' );
256+ const mentionComponentId = useId ();
257+ const mentionListboxId = ` ${mentionComponentId }-mentions ` ;
221258const isExpanded = shallowRef (false );
222259const internalSubmitting = shallowRef (false );
223260const activeTab = shallowRef <' write' | ' preview' >(' write' );
224261const draft = shallowRef (props .modelValue ?? ' ' );
225262const errorMessage = shallowRef (' ' );
263+ const mentionTrigger = shallowRef <MarkdownMentionTrigger | null >(null );
264+ const mentionQuery = shallowRef (' ' );
265+ const mentionSuggestions = shallowRef <AutocompleteSuggestion []>([]);
266+ const mentionLoading = shallowRef (false );
267+ const mentionLoadFailed = shallowRef (false );
268+ const mentionActiveIndex = shallowRef (- 1 );
269+
270+ let mentionSearchTimer: ReturnType <typeof setTimeout > | null = null ;
271+ let mentionSearchRequestId = 0 ;
226272
227273const trimmedDraft = computed (() => draft .value .trim ());
228274const currentUserLogin = computed (() => user .value ?.login || ' You' );
229275const currentUserAvatar = computed (
230276 () => user .value ?.avatar_url || ' https://github.com/placeholder.png'
231277);
278+ const mentionOpen = computed (
279+ () => Boolean (mentionTrigger .value ) && activeTab .value === ' write' && ! isSubmitting .value
280+ );
281+ const mentionEmptyMessage = computed (() =>
282+ mentionLoadFailed .value
283+ ? t (' floatingMarkdownEditor.mentionSuggestionsUnavailable' )
284+ : t (' floatingMarkdownEditor.mentionSuggestionsEmpty' )
285+ );
232286
233287// Determine submission mode
234288const isSelfSubmitMode = computed (() => props .itemNumber != null );
@@ -255,6 +309,195 @@ const submittingLabel = computed(
255309 () => props .submittingLabel || t (' floatingMarkdownEditor.submitting' )
256310);
257311
312+ const MENTION_PANEL_GAP = 4 ;
313+ const MENTION_PANEL_VIEWPORT_MARGIN = 8 ;
314+ const MENTION_PANEL_MIN_WIDTH = 240 ;
315+ const MENTION_PANEL_MAX_WIDTH = 360 ;
316+ const MENTION_PANEL_MAX_HEIGHT = 280 ;
317+ const MENTION_PANEL_MIN_HEIGHT = 120 ;
318+
319+ const getMentionSuggestionsUrl = () =>
320+ ` /api/repos/${encodeURIComponent (props .repoOwner )}/${encodeURIComponent (
321+ props .repoName
322+ )}/mention-suggestions ` ;
323+
324+ const closeMentionAutocomplete = () => {
325+ mentionTrigger .value = null ;
326+ mentionActiveIndex .value = - 1 ;
327+ mentionLoading .value = false ;
328+ mentionLoadFailed .value = false ;
329+ if (mentionSearchTimer ) {
330+ clearTimeout (mentionSearchTimer );
331+ mentionSearchTimer = null ;
332+ }
333+ mentionSearchRequestId += 1 ;
334+ };
335+
336+ const { panelStyle : mentionPanelStyle, updatePanelPosition : updateMentionPanelPosition } =
337+ useAutocompletePanel ({
338+ isOpen: mentionOpen ,
339+ listboxId: mentionListboxId ,
340+ getAnchor : () => textareaRef .value ,
341+ onClose: closeMentionAutocomplete ,
342+ gap: MENTION_PANEL_GAP ,
343+ viewportMargin: MENTION_PANEL_VIEWPORT_MARGIN ,
344+ minWidth: MENTION_PANEL_MIN_WIDTH ,
345+ maxWidth: MENTION_PANEL_MAX_WIDTH ,
346+ maxHeight: MENTION_PANEL_MAX_HEIGHT ,
347+ minHeight: MENTION_PANEL_MIN_HEIGHT ,
348+ });
349+
350+ const loadMentionSuggestions = async (query : string ) => {
351+ const requestId = ++ mentionSearchRequestId ;
352+ mentionLoading .value = true ;
353+ mentionLoadFailed .value = false ;
354+
355+ try {
356+ const response = await apiFetch <MentionSuggestionsResponse >(getMentionSuggestionsUrl (), {
357+ query: {
358+ q: query ,
359+ },
360+ });
361+
362+ if (requestId !== mentionSearchRequestId ) {
363+ return ;
364+ }
365+
366+ mentionSuggestions .value = response .items .map ((item ) => ({
367+ value: item .login ,
368+ label: item .login ,
369+ description: item .name && item .name !== item .login ? item .name : undefined ,
370+ avatarUrl: item .avatarUrl ,
371+ }));
372+ mentionLoadFailed .value = false ;
373+ mentionActiveIndex .value = mentionSuggestions .value .length > 0 ? 0 : - 1 ;
374+ } catch {
375+ if (requestId === mentionSearchRequestId ) {
376+ mentionSuggestions .value = [];
377+ mentionLoadFailed .value = true ;
378+ mentionActiveIndex .value = - 1 ;
379+ }
380+ } finally {
381+ if (requestId === mentionSearchRequestId ) {
382+ mentionLoading .value = false ;
383+ }
384+ }
385+ };
386+
387+ const scheduleMentionSearch = (query : string ) => {
388+ if (mentionSearchTimer ) {
389+ clearTimeout (mentionSearchTimer );
390+ }
391+
392+ mentionLoadFailed .value = false ;
393+ mentionSearchTimer = setTimeout (() => {
394+ mentionSearchTimer = null ;
395+ void loadMentionSuggestions (query );
396+ }, 150 );
397+ };
398+
399+ const refreshMentionTrigger = () => {
400+ const el = textareaRef .value ;
401+ if (! el || activeTab .value !== ' write' || isSubmitting .value ) {
402+ closeMentionAutocomplete ();
403+ return ;
404+ }
405+
406+ if (el .selectionStart !== el .selectionEnd ) {
407+ closeMentionAutocomplete ();
408+ return ;
409+ }
410+
411+ const trigger = findMarkdownMentionTrigger (draft .value , el .selectionStart );
412+ if (! trigger ) {
413+ closeMentionAutocomplete ();
414+ return ;
415+ }
416+
417+ const queryChanged = trigger .query !== mentionQuery .value || ! mentionTrigger .value ;
418+ mentionTrigger .value = trigger ;
419+ mentionQuery .value = trigger .query ;
420+ updateMentionPanelPosition ();
421+
422+ if (queryChanged ) {
423+ scheduleMentionSearch (trigger .query );
424+ }
425+ };
426+
427+ const moveMentionActive = (direction : 1 | -1 ) => {
428+ const len = mentionSuggestions .value .length ;
429+ if (! len ) return ;
430+
431+ if (mentionActiveIndex .value < 0 ) {
432+ mentionActiveIndex .value = direction === 1 ? 0 : len - 1 ;
433+ } else {
434+ mentionActiveIndex .value = (mentionActiveIndex .value + direction + len ) % len ;
435+ }
436+ };
437+
438+ const insertMentionSuggestion = async (suggestion : AutocompleteSuggestion ) => {
439+ const trigger = mentionTrigger .value ;
440+ if (! trigger ) return ;
441+
442+ const mentionText = ` @${suggestion .value } ` ;
443+ const nextDraft =
444+ draft .value .slice (0 , trigger .start ) + mentionText + draft .value .slice (trigger .end );
445+ const nextCaret = trigger .start + mentionText .length ;
446+
447+ setDraft (nextDraft );
448+ closeMentionAutocomplete ();
449+ await nextTick ();
450+ const el = textareaRef .value ;
451+ if (el ) {
452+ el .focus ();
453+ el .setSelectionRange (nextCaret , nextCaret );
454+ }
455+ autoResizeTextarea ();
456+ };
457+
458+ const handleTextareaKeydown = (event : KeyboardEvent ) => {
459+ if (! mentionOpen .value ) {
460+ return ;
461+ }
462+
463+ if (event .key === ' Escape' ) {
464+ event .preventDefault ();
465+ closeMentionAutocomplete ();
466+ return ;
467+ }
468+
469+ if (event .key === ' ArrowDown' ) {
470+ event .preventDefault ();
471+ moveMentionActive (1 );
472+ return ;
473+ }
474+
475+ if (event .key === ' ArrowUp' ) {
476+ event .preventDefault ();
477+ moveMentionActive (- 1 );
478+ return ;
479+ }
480+
481+ if ((event .key === ' Enter' || event .key === ' Tab' ) && ! event .isComposing ) {
482+ const suggestion =
483+ mentionSuggestions .value [mentionActiveIndex .value ] ?? mentionSuggestions .value [0 ];
484+ if (! suggestion ) {
485+ return ;
486+ }
487+
488+ event .preventDefault ();
489+ void insertMentionSuggestion (suggestion );
490+ }
491+ };
492+
493+ const handleTextareaKeyup = (event : KeyboardEvent ) => {
494+ if (mentionOpen .value && [' ArrowDown' , ' ArrowUp' , ' Enter' , ' Escape' , ' Tab' ].includes (event .key )) {
495+ return ;
496+ }
497+
498+ refreshMentionTrigger ();
499+ };
500+
258501const focus = async () => {
259502 await nextTick ();
260503 textareaRef .value ?.focus ();
@@ -275,13 +518,17 @@ const autoResizeTextarea = () => {
275518const handleDraftInput = (event : Event ) => {
276519 setDraft ((event .target as HTMLTextAreaElement ).value );
277520 autoResizeTextarea ();
521+ refreshMentionTrigger ();
278522};
279523
280524// Auto-resize textarea when switching to write tab
281525watch (activeTab , async (tab ) => {
282526 if (tab === ' write' ) {
283527 await nextTick ();
284528 autoResizeTextarea ();
529+ refreshMentionTrigger ();
530+ } else {
531+ closeMentionAutocomplete ();
285532 }
286533});
287534
@@ -293,6 +540,7 @@ watch(
293540 draft .value = value ;
294541 await nextTick ();
295542 autoResizeTextarea ();
543+ closeMentionAutocomplete ();
296544 }
297545);
298546
@@ -315,6 +563,7 @@ const reset = () => {
315563 setDraft (' ' );
316564 errorMessage .value = ' ' ;
317565 activeTab .value = ' write' ;
566+ closeMentionAutocomplete ();
318567 // Reset textarea height
319568 const el = textareaRef .value ;
320569 if (el ) {
@@ -404,6 +653,18 @@ if (props.compact) {
404653 void nextTick ().then (() => autoResizeTextarea ());
405654}
406655
656+ watch (
657+ () => [props .repoOwner , props .repoName ],
658+ () => {
659+ closeMentionAutocomplete ();
660+ mentionSuggestions .value = [];
661+ }
662+ );
663+
664+ onBeforeUnmount (() => {
665+ closeMentionAutocomplete ();
666+ });
667+
407668defineExpose ({ focus });
408669 </script >
409670
0 commit comments