Skip to content

Commit 1f84364

Browse files
committed
chore(master): merge markdown mention autocomplete fixes
2 parents d1ab2c3 + 8102891 commit 1f84364

16 files changed

Lines changed: 1237 additions & 302 deletions

app/components/dashboard/timeline/FloatingMarkdownEditor.vue

Lines changed: 262 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,22 @@
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
@@ -104,6 +116,21 @@
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">
@@ -135,9 +162,16 @@
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';
139166
import { 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';
141175
import GitHubAvatar from '~/components/ui/GitHubAvatar.vue';
142176
import MarkdownRenderer from '~/components/ui/MarkdownRenderer.vue';
143177
@@ -214,21 +248,41 @@ const emit = defineEmits<{
214248
}>();
215249
216250
const { t } = useI18n();
251+
const apiFetch = useGitPulseApiFetch();
217252
const { user } = useUserSession();
218253
const { isAnyModalOpen } = useModalState();
219254
220255
const textareaRef = useTemplateRef<HTMLTextAreaElement>('textareaRef');
256+
const mentionComponentId = useId();
257+
const mentionListboxId = `${mentionComponentId}-mentions`;
221258
const isExpanded = shallowRef(false);
222259
const internalSubmitting = shallowRef(false);
223260
const activeTab = shallowRef<'write' | 'preview'>('write');
224261
const draft = shallowRef(props.modelValue ?? '');
225262
const 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
227273
const trimmedDraft = computed(() => draft.value.trim());
228274
const currentUserLogin = computed(() => user.value?.login || 'You');
229275
const 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
234288
const 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+
258501
const focus = async () => {
259502
await nextTick();
260503
textareaRef.value?.focus();
@@ -275,13 +518,17 @@ const autoResizeTextarea = () => {
275518
const 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
281525
watch(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+
407668
defineExpose({ focus });
408669
</script>
409670

0 commit comments

Comments
 (0)