aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js/features/comp/TextExpander.ts
blob: 1e6d46f977b70c68c24abdd1e908af48c87d0d88 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
import type TextExpanderElement from '@github/text-expander-element';

type TextExpanderProvideResult = {
  matched: boolean,
  fragment?: HTMLElement,
}

type TextExpanderChangeEvent = Event & {
  detail?: {
    key: string,
    text: string,
    provide: (result: TextExpanderProvideResult | Promise<TextExpanderProvideResult>) => void,
  }
}

async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderProvideResult> {
  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"
  }
  if (!issuePathInfo.ownerName) return {matched: false};

  const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
  if (!matches.length) return {matched: false};

  const ul = createElementFromAttrs('ul', {class: 'suggestions'});
  for (const issue of matches) {
    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),
    );
    ul.append(li);
  }
  return {matched: true, fragment: ul};
}

export function initTextExpander(expander: TextExpanderElement) {
  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;
  };

  const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderProvideResult> => {
    // 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;
    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});
    } else if (key === '#') {
      provide(debouncedIssueSuggestions(key, text));
    }
  });

  expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
    if (detail?.item) {
      // add a space after @mentions and #issue as it's likely the user wants one
      const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
      detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
    }
  });
}