Add suggested lists for user mentions and group links.
This commit is contained in:
parent
7b94bcdf92
commit
3f4df6c615
|
@ -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"
|
||||
|
|
|
@ -23,6 +23,9 @@
|
|||
</header>
|
||||
<main id="main">
|
||||
<div id="settings-list">
|
||||
<a id="autocomplete-list">
|
||||
Autocomplete
|
||||
</a>
|
||||
<a id="back-to-top-list">
|
||||
Back To Top
|
||||
</a>
|
||||
|
@ -43,6 +46,16 @@
|
|||
</a>
|
||||
</div>
|
||||
<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">
|
||||
<header>
|
||||
<h2>Back To Top</h2>
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue