1
Fork 0

Add suggested lists for user mentions and group links.

This commit is contained in:
Bauke 2020-10-03 18:52:12 +02:00
parent 7b94bcdf92
commit 3f4df6c615
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
7 changed files with 319 additions and 2 deletions

View File

@ -20,6 +20,7 @@
"test": "xo && stylelint 'source/scss/**/*.scss'" "test": "xo && stylelint 'source/scss/**/*.scss'"
}, },
"dependencies": { "dependencies": {
"caret-pos": "^2.0.0",
"debounce": "^1.2.0", "debounce": "^1.2.0",
"modern-normalize": "^1.0.0", "modern-normalize": "^1.0.0",
"platform": "^1.3.6", "platform": "^1.3.6",
@ -82,6 +83,7 @@
"@typescript-eslint/no-implicit-any-catch": "off", "@typescript-eslint/no-implicit-any-catch": "off",
"@typescript-eslint/member-ordering": "off", "@typescript-eslint/member-ordering": "off",
"@typescript-eslint/no-loop-func": "off", "@typescript-eslint/no-loop-func": "off",
"unicorn/prefer-dataset": "off",
"unicorn/prefer-modern-dom-apis": "off", "unicorn/prefer-modern-dom-apis": "off",
"capitalized-comments": "off", "capitalized-comments": "off",
"no-await-in-loop": "off" "no-await-in-loop": "off"

View File

@ -23,6 +23,9 @@
</header> </header>
<main id="main"> <main id="main">
<div id="settings-list"> <div id="settings-list">
<a id="autocomplete-list">
Autocomplete
</a>
<a id="back-to-top-list"> <a id="back-to-top-list">
Back To Top Back To Top
</a> </a>
@ -43,6 +46,16 @@
</a> </a>
</div> </div>
<div id="settings-content"> <div id="settings-content">
<div id="autocomplete" class="setting">
<header>
<h2>Autocomplete</h2>
<button id="autocomplete-button"></button>
</header>
<p>
Adds autocompletion for user mentions (starting with <code>@</code>)
and groups (starting with <code>~</code>) in textareas.
</p>
</div>
<div id="back-to-top" class="setting"> <div id="back-to-top" class="setting">
<header> <header>
<h2>Back To Top</h2> <h2>Back To Top</h2>

View File

@ -40,14 +40,16 @@
"./scss/scripts/jump-to-new-comment.scss", "./scss/scripts/jump-to-new-comment.scss",
"./scss/scripts/back-to-top.scss", "./scss/scripts/back-to-top.scss",
"./scss/scripts/user-labels.scss", "./scss/scripts/user-labels.scss",
"./scss/scripts/markdown-toolbar.scss" "./scss/scripts/markdown-toolbar.scss",
"./scss/scripts/autocomplete.scss"
], ],
"js": [ "js": [
"./ts/scripts/jump-to-new-comment.ts", "./ts/scripts/jump-to-new-comment.ts",
"./ts/scripts/back-to-top.ts", "./ts/scripts/back-to-top.ts",
"./ts/scripts/user-labels.ts", "./ts/scripts/user-labels.ts",
"./ts/scripts/markdown-toolbar.ts", "./ts/scripts/markdown-toolbar.ts",
"./ts/scripts/hide-votes.ts" "./ts/scripts/hide-votes.ts",
"./ts/scripts/autocomplete.ts"
] ]
} }
], ],

View File

@ -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;
}
}

View File

@ -0,0 +1,222 @@
import {offset, Offset} from 'caret-pos';
import {
Settings,
getSettings,
createElementFromString,
extractAndSaveGroups
} from '../utilities';
const knownGroups: Set<string> = new Set();
const knownUsers: Set<string> = new Set();
(async (): Promise<void> => {
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(`<li>${value}</li>`);
}
const autocompleteFormTemplate = `<form id="trx-autocomplete-${id}-form"
class="trx-autocomplete-form trx-hidden">
<ul>${options.join('\n')}</ul>
</form>`;
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(`<li>${value}</li>`);
}
const list: HTMLUListElement = createElementFromString(
`<ul>${options.join('\n')}</ul>`
);
form.append(list);
return form;
}

View File

@ -17,11 +17,13 @@ export interface Settings {
ownTopics: boolean; ownTopics: boolean;
[index: string]: boolean; [index: string]: boolean;
}; };
knownGroups: string[];
latestActiveFeatureTab: string; latestActiveFeatureTab: string;
userLabels: UserLabel[]; userLabels: UserLabel[];
version?: string; version?: string;
}; };
features: { features: {
autocomplete: boolean;
backToTop: boolean; backToTop: boolean;
debug: boolean; debug: boolean;
hideVotes: boolean; hideVotes: boolean;
@ -40,10 +42,48 @@ export const defaultSettings: Settings = {
ownComments: true, ownComments: true,
ownTopics: 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', latestActiveFeatureTab: 'debug',
userLabels: [] userLabels: []
}, },
features: { features: {
autocomplete: true,
backToTop: true, backToTop: true,
debug: false, debug: false,
hideVotes: 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 /^[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<Settings> {
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;
}

View File

@ -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" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001142.tgz#a8518fdb5fee03ad95ac9f32a9a1e5999469c250"
integrity sha512-pDPpn9ankEpBFZXyCv2I4lh1v/ju+bqb78QfKf+w9XgDAFWBwSYPswXqprRdrgQWK0wQnpIbfwRjNHO1HWqvoQ== 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: caseless@~0.12.0:
version "0.12.0" version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"