2024-10-29 10:20:49 +01:00
import { matchEmoji , matchMention , matchIssue } from '../../utils/match.ts' ;
2024-07-07 17:32:30 +02:00
import { emojiString } from '../emoji.ts' ;
2024-10-29 10:20:49 +01:00
import { svg } from '../../svg.ts' ;
2025-01-21 19:33:45 +08:00
import { parseIssueHref , parseRepoOwnerPathInfo } from '../../utils.ts' ;
2024-10-29 10:20:49 +01:00
import { createElementFromAttrs , createElementFromHTML } from '../../utils/dom.ts' ;
import { getIssueColor , getIssueIcon } from '../issue.ts' ;
import { debounce } from 'perfect-debounce' ;
2025-01-22 08:11:51 +01:00
import type TextExpanderElement from '@github/text-expander-element' ;
2025-01-30 00:38:53 +01:00
import type { TextExpanderChangeEvent , TextExpanderResult } from '@github/text-expander-element/dist/text-expander-element.d.ts' ;
2024-10-29 10:20:49 +01:00
2025-01-30 00:38:53 +01:00
async function fetchIssueSuggestions ( key : string , text : string ) : Promise < TextExpanderResult > {
2025-01-21 19:33:45 +08:00
const issuePathInfo = parseIssueHref ( window . location . href ) ;
if ( ! issuePathInfo . ownerName ) {
const repoOwnerPathInfo = parseRepoOwnerPathInfo ( window . location . pathname ) ;
issuePathInfo . ownerName = repoOwnerPathInfo . ownerName ;
issuePathInfo . repoName = repoOwnerPathInfo . repoName ;
// then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
}
2025-01-26 21:13:32 +08:00
if ( ! issuePathInfo . ownerName ) return { matched : false } ;
2024-10-31 04:06:36 +08:00
const matches = await matchIssue ( issuePathInfo . ownerName , issuePathInfo . repoName , issuePathInfo . indexString , text ) ;
2025-01-26 21:13:32 +08:00
if ( ! matches . length ) return { matched : false } ;
2024-10-29 10:20:49 +01:00
2024-10-31 04:06:36 +08:00
const ul = createElementFromAttrs ( 'ul' , { class : 'suggestions' } ) ;
2024-10-29 10:20:49 +01:00
for ( const issue of matches ) {
2024-10-31 04:06:36 +08:00
const li = createElementFromAttrs (
'li' , { role : 'option' , class : 'tw-flex tw-gap-2' , 'data-value' : ` ${ key } ${ issue . number } ` } ,
createElementFromHTML ( svg ( getIssueIcon ( issue ) , 16 , [ 'text' , getIssueColor ( issue ) ] ) ) ,
createElementFromAttrs ( 'span' , null , ` # ${ issue . number } ` ) ,
createElementFromAttrs ( 'span' , null , issue . title ) ,
) ;
2024-10-29 10:20:49 +01:00
ul . append ( li ) ;
}
2025-01-26 21:13:32 +08:00
return { matched : true , fragment : ul } ;
}
2023-05-09 07:22:52 +09:00
2025-01-22 08:11:51 +01:00
export function initTextExpander ( expander : TextExpanderElement ) {
2025-01-26 21:13:32 +08:00
if ( ! expander ) return ;
const textarea = expander . querySelector < HTMLTextAreaElement > ( 'textarea' ) ;
// help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
const shouldShowIssueSuggestions = ( ) = > {
const posVal = textarea . value . substring ( 0 , textarea . selectionStart ) ;
const lineStart = posVal . lastIndexOf ( '\n' ) ;
const keyStart = posVal . lastIndexOf ( '#' ) ;
return keyStart > lineStart ;
} ;
2025-01-30 00:38:53 +01:00
const debouncedIssueSuggestions = debounce ( async ( key : string , text : string ) : Promise < TextExpanderResult > = > {
2025-01-26 21:13:32 +08:00
// https://github.com/github/text-expander-element/issues/71
// Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
// To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
// then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
// There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
// check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
if ( ! shouldShowIssueSuggestions ( ) ) return { matched : false } ;
// await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
const ret = await fetchIssueSuggestions ( key , text ) ;
// check the input again to avoid text-expander using incorrect position (upstream bug)
if ( ! shouldShowIssueSuggestions ( ) ) return { matched : false } ;
return ret ;
} , 300 ) ; // to match onInputDebounce delay
expander . addEventListener ( 'text-expander-change' , ( e : TextExpanderChangeEvent ) = > {
const { key , text , provide } = e . detail ;
2023-05-09 07:22:52 +09:00
if ( key === ':' ) {
const matches = matchEmoji ( text ) ;
if ( ! matches . length ) return provide ( { matched : false } ) ;
const ul = document . createElement ( 'ul' ) ;
ul . classList . add ( 'suggestions' ) ;
for ( const name of matches ) {
const emoji = emojiString ( name ) ;
const li = document . createElement ( 'li' ) ;
li . setAttribute ( 'role' , 'option' ) ;
li . setAttribute ( 'data-value' , emoji ) ;
li . textContent = ` ${ emoji } ${ name } ` ;
ul . append ( li ) ;
}
provide ( { matched : true , fragment : ul } ) ;
} else if ( key === '@' ) {
const matches = matchMention ( text ) ;
if ( ! matches . length ) return provide ( { matched : false } ) ;
const ul = document . createElement ( 'ul' ) ;
ul . classList . add ( 'suggestions' ) ;
for ( const { value , name , fullname , avatar } of matches ) {
const li = document . createElement ( 'li' ) ;
li . setAttribute ( 'role' , 'option' ) ;
li . setAttribute ( 'data-value' , ` ${ key } ${ value } ` ) ;
const img = document . createElement ( 'img' ) ;
img . src = avatar ;
li . append ( img ) ;
const nameSpan = document . createElement ( 'span' ) ;
nameSpan . textContent = name ;
li . append ( nameSpan ) ;
if ( fullname && fullname . toLowerCase ( ) !== name ) {
const fullnameSpan = document . createElement ( 'span' ) ;
fullnameSpan . classList . add ( 'fullname' ) ;
fullnameSpan . textContent = fullname ;
li . append ( fullnameSpan ) ;
}
ul . append ( li ) ;
}
provide ( { matched : true , fragment : ul } ) ;
2024-10-29 10:20:49 +01:00
} else if ( key === '#' ) {
2025-01-26 21:13:32 +08:00
provide ( debouncedIssueSuggestions ( key , text ) ) ;
2023-05-09 07:22:52 +09:00
}
} ) ;
2025-01-26 21:13:32 +08:00
expander . addEventListener ( 'text-expander-value' , ( { detail } : Record < string , any > ) = > {
2023-05-09 07:22:52 +09:00
if ( detail ? . item ) {
2024-10-29 10:20:49 +01:00
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = [ '@' , '#' ] . includes ( detail . key ) ? ' ' : '' ;
2023-06-18 10:38:47 +02:00
detail . value = ` ${ detail . item . getAttribute ( 'data-value' ) } ${ suffix } ` ;
2023-05-09 07:22:52 +09:00
}
} ) ;
}