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'"
|
"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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -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;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue