1
Fork 0
tildes-reextended/source/content-scripts/features/autocomplete.tsx

223 lines
6.6 KiB
TypeScript

import {offset, type Offset} from "caret-pos";
import {Component} from "preact";
import {type UserLabelsData} from "../../storage/exports.js";
import {log, querySelectorAll} from "../../utilities/exports.js";
type Props = {
anonymizeUsernamesEnabled: boolean;
knownGroups: Set<string>;
userLabels: UserLabelsData;
};
type State = {
groups: Set<string>;
groupsHidden: boolean;
groupsMatches: Set<string>;
groupsPosition: Offset | undefined;
usernames: Set<string>;
usernamesHidden: boolean;
usernamesMatches: Set<string>;
usernamesPosition: Offset | undefined;
};
export class AutocompleteFeature extends Component<Props, State> {
constructor(props: Props) {
super(props);
// Get all the groups without their leading tildes.
const groups = Array.from(props.knownGroups).map((value) =>
value.startsWith("~") ? value.slice(1) : value,
);
// Get all the usernames on the page without their leading @s, and get
// all the usernames from the saved user labels.
const usernameElements = querySelectorAll<HTMLElement>(".link-user");
const usernames = [
...usernameElements.map((value) => {
if (props.anonymizeUsernamesEnabled) {
return (value.dataset.trxUsername ?? "<unknown>").toLowerCase();
}
return value.textContent!.replace(/^@/, "").toLowerCase();
}),
...props.userLabels.map(({value}) => value.username),
].sort((a, b) => a.localeCompare(b));
this.state = {
groups: new Set(groups),
groupsHidden: true,
groupsMatches: new Set(groups),
groupsPosition: undefined,
usernames: new Set(usernames),
usernamesHidden: true,
usernamesMatches: new Set(usernames),
usernamesPosition: undefined,
};
// Add a keydown listener for the entire page.
document.addEventListener("keydown", this.globalInputHandler);
log(
`Autocomplete: Initialized with ${this.state.groups.size} groups and ` +
`${this.state.usernames.size} usernames.`,
);
}
globalInputHandler = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
// Only add the autocompletes to textareas.
if (activeElement.tagName !== "TEXTAREA") {
return;
}
// Helper function to create autocompletes with.
const createHandler = (
prefix: string,
target: string,
values: Set<string>,
) => {
const dataAttribute = `data-trx-autocomplete-${target}`;
if (event.key === prefix && !activeElement.getAttribute(dataAttribute)) {
activeElement.setAttribute(dataAttribute, "true");
activeElement.addEventListener("keyup", (event) => {
this.textareaInputHandler(event, prefix, target, values);
});
this.textareaInputHandler(event, prefix, target, values);
}
};
createHandler("~", "groups", this.state.groups);
createHandler("@", "usernames", this.state.usernames);
};
textareaInputHandler = (
event: KeyboardEvent,
prefix: string,
target: string,
values: Set<string>,
) => {
const textarea = event.target as HTMLTextAreaElement;
const text = textarea.value;
// If the prefix isn't in the textarea, return early.
if (!text.includes(prefix)) {
this.hide(target);
return;
}
// Grab the starting position of the caret (text cursor).
const position = textarea.selectionStart;
// Grab the last index of the prefix inside the beginning of the textarea
// and the starting position of the caret.
const prefixIndex = 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. Usernames cannot have whitespace in them.
if (/\s/.test(input) || input === "") {
this.hide(target);
return;
}
// Find all the values that match the input using `includes`.
const matches = new Set<string>(
[...values].filter((value) => value.includes(input.toLowerCase())),
);
// If there are no matches, return early.
if (matches.size === 0) {
this.hide(target);
return;
}
// Otherwise make sure the list is shown in the correct place and also
// has all the new matches.
this.show(target, offset(textarea));
this.update(target, matches);
};
update = (target: string, matches: Set<string>) => {
if (target === "groups") {
this.setState({
groupsMatches: matches,
});
} else if (target === "usernames") {
this.setState({
usernamesMatches: matches,
});
}
};
show = (target: string, position: Offset) => {
if (target === "groups") {
this.setState({
groupsHidden: false,
groupsPosition: position,
});
} else if (target === "usernames") {
this.setState({
usernamesHidden: false,
usernamesPosition: position,
});
}
};
hide = (target: string) => {
if (target === "groups") {
this.setState({groupsHidden: true});
} else if (target === "usernames") {
this.setState({usernamesHidden: true});
}
};
render() {
// Create the list of groups and usernames.
const groups = [...this.state.groupsMatches].map((value) => (
<li>~{value}</li>
));
const usernames = [...this.state.usernamesMatches].map((value) => (
<li>@{value}</li>
));
// Create the CSS class whether or not to hide the autocomplete.
const groupsHidden = this.state.groupsHidden ? "trx-hidden" : "";
const usernamesHidden = this.state.usernamesHidden ? "trx-hidden" : "";
// Create the position for the group and usernames autocomplete.
const groupsLeft = this.state.groupsPosition?.left ?? 0;
const groupsTop =
(this.state.groupsPosition?.top ?? 0) +
(this.state.groupsPosition?.height ?? 0);
const usernamesLeft = this.state.usernamesPosition?.left ?? 0;
const usernamesTop =
(this.state.usernamesPosition?.top ?? 0) +
(this.state.usernamesPosition?.height ?? 0);
return (
<>
<ul
id="trx-autocomplete-usernames"
class={`trx-autocomplete ${usernamesHidden}`}
style={`left: ${usernamesLeft}px; top: ${usernamesTop}px`}
>
{usernames}
</ul>
<ul
id="trx-autocomplete-groups"
class={`trx-autocomplete ${groupsHidden}`}
style={`left: ${groupsLeft}px; top: ${groupsTop}px`}
>
{groups}
</ul>
</>
);
}
}