CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
mention-autocomplete.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 829a046 | 1 | /** |
| 2 | * @mention autocomplete for comment textareas. | |
| 3 | * | |
| 4 | * Injects a floating dropdown when the user types `@` followed by at least | |
| 5 | * one character. Fetches `/api/users/suggest?q=<prefix>` and renders up to | |
| 6 | * 8 matching usernames. Arrow-key navigation + Enter/Tab to select. | |
| 7 | * | |
| 8 | * Usage: call `mentionAutocompleteScript()` and inject the return value | |
| 9 | * into a `<script dangerouslySetInnerHTML={{ __html: ... }} />` tag inside | |
| 10 | * any page that has comment textareas. The script attaches itself to ALL | |
| 11 | * textareas on the page via event delegation. | |
| 12 | */ | |
| 13 | ||
| 14 | export function mentionAutocompleteScript(): string { | |
| 15 | return `(function(){ | |
| 16 | var _mc_popup=null,_mc_ta=null,_mc_idx=0,_mc_items=[]; | |
| 17 | var _mc_css=[ | |
| 18 | '.mc-popup{position:fixed;z-index:9999;background:var(--bg-elevated,#1c2128);border:1px solid var(--border,#30363d);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.40);overflow:hidden;min-width:180px;max-width:280px;}', | |
| 19 | '.mc-item{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:13px;cursor:pointer;color:var(--text,#e6edf3);}', | |
| 20 | '.mc-item:hover,.mc-item.is-sel{background:var(--bg-hover,#21262d);}', | |
| 21 | '.mc-item-at{color:var(--text-muted,#8b949e);font-size:12px;}', | |
| 22 | '.mc-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;}', | |
| 23 | ].join(''); | |
| 24 | function injectStyle(){if(!document.getElementById('mc-style')){var s=document.createElement('style');s.id='mc-style';s.textContent=_mc_css;document.head.appendChild(s);}} | |
| 25 | function getCaretCoords(ta){ | |
| 26 | var mirror=document.createElement('div'); | |
| 27 | var cs=getComputedStyle(ta); | |
| 28 | ['fontFamily','fontSize','fontWeight','letterSpacing','lineHeight','padding','border','boxSizing','whiteSpace','wordWrap','overflowWrap'].forEach(function(p){mirror.style[p]=cs[p];}); | |
| 29 | mirror.style.position='absolute';mirror.style.top='-9999px';mirror.style.left='-9999px';mirror.style.width=ta.offsetWidth+'px';mirror.style.height='auto'; | |
| 30 | var text=ta.value.slice(0,ta.selectionStart); | |
| 31 | mirror.textContent=text; | |
| 32 | var span=document.createElement('span');span.textContent='@';mirror.appendChild(span); | |
| 33 | document.body.appendChild(mirror); | |
| 34 | var r=ta.getBoundingClientRect(); | |
| 35 | var rect=span.getBoundingClientRect(); | |
| 36 | document.body.removeChild(mirror); | |
| 37 | return{top:rect.top,left:rect.left}; | |
| 38 | } | |
| 39 | function showPopup(ta,items,atPos){ | |
| 40 | injectStyle();hidePopup(); | |
| 41 | if(!items.length)return; | |
| 42 | _mc_ta=ta;_mc_items=items;_mc_idx=0; | |
| 43 | _mc_popup=document.createElement('div');_mc_popup.className='mc-popup'; | |
| 44 | items.forEach(function(u,i){ | |
| 45 | var d=document.createElement('div');d.className='mc-item'+(i===0?' is-sel':''); | |
| 46 | d.innerHTML='<span class="mc-item-at">@</span><span class="mc-item-name">'+escHtml(u.username)+'</span>'; | |
| 47 | d.addEventListener('mousedown',function(e){e.preventDefault();insertMention(u.username,atPos);}); | |
| 48 | _mc_popup.appendChild(d); | |
| 49 | }); | |
| 50 | var coords=getCaretCoords(ta); | |
| 51 | _mc_popup.style.top=(coords.top+20)+'px'; | |
| 52 | _mc_popup.style.left=coords.left+'px'; | |
| 53 | document.body.appendChild(_mc_popup); | |
| 54 | } | |
| 55 | function hidePopup(){if(_mc_popup){_mc_popup.remove();_mc_popup=null;}_mc_ta=null;_mc_items=[];_mc_idx=0;} | |
| 56 | function setSelection(i){_mc_idx=i;var els=_mc_popup?_mc_popup.querySelectorAll('.mc-item'):[];els.forEach(function(el,j){el.classList.toggle('is-sel',j===i);});} | |
| 57 | function insertMention(username,atPos){ | |
| 58 | if(!_mc_ta)return; | |
| 59 | var val=_mc_ta.value;var before=val.slice(0,atPos);var after=val.slice(_mc_ta.selectionStart); | |
| 60 | var newVal=before+'@'+username+' '+after; | |
| 61 | _mc_ta.value=newVal; | |
| 62 | var pos=atPos+username.length+2; | |
| 63 | _mc_ta.setSelectionRange(pos,pos); | |
| 64 | hidePopup(); | |
| 65 | _mc_ta.dispatchEvent(new Event('input',{bubbles:true})); | |
| 66 | } | |
| 67 | function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| 68 | var _mc_timer=null; | |
| 69 | document.addEventListener('input',function(e){ | |
| 70 | var ta=e.target; | |
| 71 | if(ta.tagName!=='TEXTAREA')return; | |
| 72 | clearTimeout(_mc_timer); | |
| 73 | var val=ta.value;var pos=ta.selectionStart; | |
| 74 | var lastAt=val.lastIndexOf('@',pos-1); | |
| 75 | if(lastAt===-1){hidePopup();return;} | |
| 76 | var afterAt=val.slice(lastAt+1,pos); | |
| 77 | if(/\\s/.test(afterAt)||afterAt.length===0){hidePopup();return;} | |
| 78 | if(afterAt.length>20){hidePopup();return;} | |
| 79 | var atPos=lastAt; | |
| 80 | _mc_timer=setTimeout(function(){ | |
| 81 | fetch('/api/users/suggest?q='+encodeURIComponent(afterAt)).then(function(r){return r.json();}).then(function(data){ | |
| 82 | if(!ta.isConnected){hidePopup();return;} | |
| 83 | showPopup(ta,data.users||[],atPos); | |
| 84 | }).catch(function(){hidePopup();}); | |
| 85 | },120); | |
| 86 | }); | |
| 87 | document.addEventListener('keydown',function(e){ | |
| 88 | if(!_mc_popup)return; | |
| 89 | if(e.key==='ArrowDown'){e.preventDefault();setSelection(Math.min(_mc_idx+1,_mc_items.length-1));} | |
| 90 | else if(e.key==='ArrowUp'){e.preventDefault();setSelection(Math.max(_mc_idx-1,0));} | |
| 91 | else if(e.key==='Enter'||e.key==='Tab'){ | |
| 92 | if(_mc_popup){e.preventDefault();insertMention(_mc_items[_mc_idx].username,_mc_popup._atPos||0); | |
| 93 | // re-read atPos from last known | |
| 94 | var u=_mc_items[_mc_idx];if(u&&_mc_ta){var val=_mc_ta.value;var p=_mc_ta.selectionStart;var lastAt=val.lastIndexOf('@',p-1);insertMention(u.username,lastAt);}} | |
| 95 | } | |
| 96 | else if(e.key==='Escape'){hidePopup();} | |
| 97 | }); | |
| 98 | document.addEventListener('click',function(e){if(_mc_popup&&!_mc_popup.contains(e.target))hidePopup();}); | |
| 99 | document.addEventListener('blur',function(e){if(e.target.tagName==='TEXTAREA')setTimeout(function(){if(_mc_popup&&!_mc_popup.matches(':hover'))hidePopup();},150);},true); | |
| 100 | })();`; | |
| 101 | } |