diff --git a/package.json b/package.json index 9baf5fa..ac8f75d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "xo && stylelint 'source/scss/**/*.scss'" }, "dependencies": { + "caret-pos": "^2.0.0", "debounce": "^1.2.0", "modern-normalize": "^1.0.0", "platform": "^1.3.6", @@ -82,6 +83,7 @@ "@typescript-eslint/no-implicit-any-catch": "off", "@typescript-eslint/member-ordering": "off", "@typescript-eslint/no-loop-func": "off", + "unicorn/prefer-dataset": "off", "unicorn/prefer-modern-dom-apis": "off", "capitalized-comments": "off", "no-await-in-loop": "off" diff --git a/source/html/options.html b/source/html/options.html index ea391fe..a761583 100644 --- a/source/html/options.html +++ b/source/html/options.html @@ -23,6 +23,9 @@
+ + Autocomplete + Back To Top @@ -43,6 +46,16 @@
+
+
+

Autocomplete

+ +
+

+ Adds autocompletion for user mentions (starting with @) + and groups (starting with ~) in textareas. +

+

Back To Top

diff --git a/source/manifest.json b/source/manifest.json index 07b5aa2..fd7431f 100644 --- a/source/manifest.json +++ b/source/manifest.json @@ -40,14 +40,16 @@ "./scss/scripts/jump-to-new-comment.scss", "./scss/scripts/back-to-top.scss", "./scss/scripts/user-labels.scss", - "./scss/scripts/markdown-toolbar.scss" + "./scss/scripts/markdown-toolbar.scss", + "./scss/scripts/autocomplete.scss" ], "js": [ "./ts/scripts/jump-to-new-comment.ts", "./ts/scripts/back-to-top.ts", "./ts/scripts/user-labels.ts", "./ts/scripts/markdown-toolbar.ts", - "./ts/scripts/hide-votes.ts" + "./ts/scripts/hide-votes.ts", + "./ts/scripts/autocomplete.ts" ] } ], diff --git a/source/scss/scripts/autocomplete.scss b/source/scss/scripts/autocomplete.scss new file mode 100644 index 0000000..e12d5bb --- /dev/null +++ b/source/scss/scripts/autocomplete.scss @@ -0,0 +1,13 @@ +.trx-autocomplete-form { + background-color: var(--background-secondary-color); + border: 1px solid var(--border-color); + font-size: 80%; + max-height: 8rem; + overflow-x: hidden; + overflow-y: auto; + position: absolute; + + li { + margin: 0; + } +} diff --git a/source/ts/scripts/autocomplete.ts b/source/ts/scripts/autocomplete.ts new file mode 100644 index 0000000..55346dd --- /dev/null +++ b/source/ts/scripts/autocomplete.ts @@ -0,0 +1,222 @@ +import {offset, Offset} from 'caret-pos'; +import { + Settings, + getSettings, + createElementFromString, + extractAndSaveGroups +} from '../utilities'; + +const knownGroups: Set = new Set(); +const knownUsers: Set = new Set(); + +(async (): Promise => { + let settings: Settings = await getSettings(); + if (!settings.features.autocomplete) { + return; + } + + try { + settings = await extractAndSaveGroups(settings); + } catch { + // This will intentionally error when we're not in "/groups". + } + + for (const group of settings.data.knownGroups) { + if (group.startsWith('~')) { + knownGroups.add(group.slice(1)); + } else { + knownGroups.add(group); + } + } + + // Add usernames from all linked users on the page. + const userLinks = document.querySelectorAll('.link-user'); + for (const link of userLinks) { + const username: string = link.textContent!.replace(/@/g, '').toLowerCase(); + knownUsers.add(username); + } + + // Add usernames we have saved in the user labels. + for (const label of settings.data.userLabels) { + knownUsers.add(label.username); + } + + document.addEventListener('keydown', globalInputHandler); +})(); + +function globalInputHandler(event: KeyboardEvent) { + const activeElement: HTMLElement = document.activeElement as HTMLElement; + // Only add the autocompletes to textareas. + if (activeElement.tagName !== 'TEXTAREA') { + return; + } + + // If a ~ is entered in a textarea and that textarea doesn't already have + // the group input handler running, add it. + if ( + event.key === '~' && + !activeElement.getAttribute('data-trx-autocomplete-group') + ) { + activeElement.setAttribute('data-trx-autocomplete-group', 'true'); + + // Sort the groups alphabetically. + const groups: string[] = [...knownGroups]; + groups.sort((a, b) => a.localeCompare(b)); + + if (!document.querySelector('#trx-autocomplete-group-form')) { + const form: HTMLFormElement = createOrGetAutocompleteForm( + 'group', + groups + ); + document.body.append(form); + } + + textareaInputHandler(event, '~', 'group', groups); + activeElement.addEventListener('keyup', (event) => + textareaInputHandler(event, '~', 'group', groups) + ); + } + + // If an @ is entered in a textarea and that textarea doesn't already have + // the user input handler running, add it. + if ( + event.key === '@' && + !activeElement.getAttribute('data-trx-autocomplete-user') + ) { + activeElement.setAttribute('data-trx-autocomplete-user', 'true'); + + // Sort the usernames alphabetically. + const users: string[] = [...knownUsers]; + users.sort((a, b) => a.localeCompare(b)); + + if (!document.querySelector('#trx-autocomplete-user-form')) { + const form: HTMLFormElement = createOrGetAutocompleteForm('user', users); + document.body.append(form); + } + + textareaInputHandler(event, '@', 'user', users); + activeElement.addEventListener('keyup', (event) => + textareaInputHandler(event, '@', 'user', users) + ); + } +} + +function textareaInputHandler( + event: KeyboardEvent, + prefix: string, + id: string, + values: string[] +) { + const textarea: HTMLTextAreaElement = event.target as HTMLTextAreaElement; + const text: string = textarea.value; + + // If the prefix isn't in the textarea, return early. + if (!text.includes(prefix)) { + hideAutocompleteForm(id); + return; + } + + // Grab the starting position of the caret (text cursor). + const position: number = textarea.selectionStart; + + // Grab the last index of the prefix inside the beginning of the textarea and + // the starting position of the caret. Basically doing a reversed index of. + const prefixIndex: number = text.slice(0, position).lastIndexOf(prefix); + + // Grab the input between the prefix and the caret position, which will be + // what the user is currently typing. + const input = text.slice(prefixIndex + prefix.length, position); + + // If there is any whitespace in the input or there is no input at all, return + // early. + if (/\s/.exec(input) || input === '') { + hideAutocompleteForm(id); + return; + } + + // Find all the values that match the input. + const matches: string[] = []; + for (const value of values) { + if (value.includes(input.toLocaleLowerCase())) { + matches.push(value); + } + } + + // If there are no matches, return early. + if (matches.length === 0) { + hideAutocompleteForm(id); + return; + } + + // If the autocomplete form is hidden, unhide it. + if (document.querySelector(`#trx-autocomplete-${id}-form.trx-hidden`)) { + showAutocompleteForm(id, offset(textarea)); + } + + // And finally update the values in the autocomplete form. + updateAutocompleteFormValues(id, matches); +} + +function createOrGetAutocompleteForm( + id: string, + values: string[] +): HTMLFormElement { + const existing: Element | null = document.querySelector( + `#trx-autocomplete-${id}-form` + ); + if (existing !== null) { + return existing as HTMLFormElement; + } + + const options: string[] = []; + for (const value of values) { + options.push(`
  • ${value}
  • `); + } + + const autocompleteFormTemplate = `
    +
      ${options.join('\n')}
    +
    `; + const form: HTMLFormElement = createElementFromString( + autocompleteFormTemplate + ); + + return form; +} + +function hideAutocompleteForm(id: string): HTMLFormElement { + const form: HTMLFormElement = createOrGetAutocompleteForm(id, []); + form.classList.add('trx-hidden'); + return form; +} + +function showAutocompleteForm(id: string, offset: Offset): HTMLFormElement { + const form: HTMLFormElement = createOrGetAutocompleteForm(id, []); + form.classList.remove('trx-hidden'); + + form.setAttribute( + 'style', + `left: ${offset.left}px; top: ${offset.top + offset.height}px;` + ); + return form; +} + +function updateAutocompleteFormValues( + id: string, + values: string[] +): HTMLFormElement { + const form: HTMLFormElement = createOrGetAutocompleteForm(id, []); + form.firstElementChild!.remove(); + + const options: string[] = []; + for (const value of values) { + options.push(`
  • ${value}
  • `); + } + + const list: HTMLUListElement = createElementFromString( + `
      ${options.join('\n')}
    ` + ); + + form.append(list); + return form; +} diff --git a/source/ts/utilities.ts b/source/ts/utilities.ts index 91f5257..cc8d357 100644 --- a/source/ts/utilities.ts +++ b/source/ts/utilities.ts @@ -17,11 +17,13 @@ export interface Settings { ownTopics: boolean; [index: string]: boolean; }; + knownGroups: string[]; latestActiveFeatureTab: string; userLabels: UserLabel[]; version?: string; }; features: { + autocomplete: boolean; backToTop: boolean; debug: boolean; hideVotes: boolean; @@ -40,10 +42,48 @@ export const defaultSettings: Settings = { ownComments: true, ownTopics: true }, + // If groups are added or removed from Tildes this does not necessarily need + // to be updated. There is a helper function available to update it whenever + // the user goes to "/groups", where all the groups are easily available. So + // scripts that use this should call that function when they are run. + knownGroups: [ + '~anime', + '~arts', + '~books', + '~comp', + '~creative', + '~design', + '~enviro', + '~finance', + '~food', + '~games', + '~games.game_design', + '~games.tabletop', + '~health', + '~health.coronavirus', + '~hobbies', + '~humanities', + '~lgbt', + '~life', + '~misc', + '~movies', + '~music', + '~news', + '~science', + '~space', + '~sports', + '~talk', + '~tech', + '~test', + '~tildes', + '~tildes.official', + '~tv' + ], latestActiveFeatureTab: 'debug', userLabels: [] }, features: { + autocomplete: true, backToTop: true, debug: false, hideVotes: false, @@ -268,3 +308,23 @@ export function isValidTildesUsername(username: string): boolean { /^[a-z\d]([a-z\d]|[_-](?![_-]))*[a-z\d]$/i.exec(username) !== null ); } + +// This function will update the saved known groups when we're in the Tildes +// group listing. Any script that uses the known groups should call this before +// running. +export async function extractAndSaveGroups( + settings: Settings +): Promise { + if (window.location.pathname !== '/groups') { + return Promise.reject(new Error('Not in /groups.')); + } + + const groups: string[] = [...document.querySelectorAll('.link-group')].map( + (value) => value.textContent! + ); + + settings.data.knownGroups = groups; + await setSettings(settings); + log('Updated saved groups.', true); + return settings; +} diff --git a/yarn.lock b/yarn.lock index e28585a..d1f4f60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2136,6 +2136,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001142.tgz#a8518fdb5fee03ad95ac9f32a9a1e5999469c250" integrity sha512-pDPpn9ankEpBFZXyCv2I4lh1v/ju+bqb78QfKf+w9XgDAFWBwSYPswXqprRdrgQWK0wQnpIbfwRjNHO1HWqvoQ== +caret-pos@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caret-pos/-/caret-pos-2.0.0.tgz#f4a222a14951a1f5fa6543d73f79ec6892223326" + integrity sha512-cOIiBS1SjzXg+LXSiQAzGg89dHDKq/y4c30+tB5hkVN7GbtXh1BNypOmjti4LwAWQrvP4y+bNG7RJFxLGoL3bA== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"