import debounce from 'debounce'; import {Component, render} from 'preact'; import {html} from 'htm/preact'; import { createElementFromString, isColorBright, isValidHexColor, log, querySelectorAll, setSettings, Settings, themeColors } from '..'; type Props = { settings: Settings; }; type State = { color: string; selectedColor: string; hidden: boolean; id: number | null; priority: number; target: HTMLElement | null; text: string; username: string; }; const colorPattern: string = [ '^(?:#(?:', // (?:) are non-capturing groups. '[a-f\\d]{8}|', // The order of 8 -> 6 -> 4 -> 3 character hex colors matters. '[a-f\\d]{6}|', '[a-f\\d]{4}|', '[a-f\\d]{3})', '|transparent)$' // "Transparent" is also allowed in the input. ].join(''); export class UserLabelsFeature extends Component { constructor(props: Props) { super(props); const selectedColor = window .getComputedStyle(document.body) .getPropertyValue(themeColors[1].value) .trim(); this.state = { color: selectedColor, hidden: true, id: null, text: '', priority: 0, selectedColor, target: null, username: '' }; const count = this.addLabelsToUsernames(querySelectorAll('.link-user')); log(`User Labels: Initialized for ${count} user links.`); } hide = () => { this.setState({hidden: true}); }; addLabelsToUsernames = (elements: Element[], onlyID?: number): number => { const settings = this.props.settings; const inTopicListing = document.querySelector('.topic-listing') !== null; // Sort the labels by priority or alphabetically, so 2 labels with the same // priority will be sorted alphabetically. const sortedLabels = settings.data.userLabels.sort((a, b): number => { if (inTopicListing) { // If we're in the topic listing sort with highest priority first. if (a.priority !== b.priority) { return b.priority - a.priority; } } else if (a.priority !== b.priority) { // If we're not in the topic listing, sort with lowest priority first. // We will add elements backwards, so the first label will be // behind all the other labels. return a.priority - b.priority; } return b.text.localeCompare(a.text); }); for (const element of elements) { const username: string = element .textContent!.replace(/@/g, '') .toLowerCase(); const userLabels = sortedLabels.filter( (value) => value.username === username && (onlyID === undefined ? true : value.id === onlyID) ); const addLabel = html` this.addLabelHandler(event, username)} > [+] `; if (!inTopicListing && onlyID === undefined) { const addLabelPlaceholder = document.createElement('span'); element.after(addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder); } if (userLabels.length === 0 && onlyID === undefined) { if ( inTopicListing && (element.nextElementSibling === null || !element.nextElementSibling.className.includes('trx-user-label')) ) { const addLabelPlaceholder = document.createElement('span'); element.after(addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder); } continue; } for (const userLabel of userLabels) { const bright = isColorBright(userLabel.color.trim()) ? 'trx-bright' : ''; const label = createElementFromString(` ${userLabel.text} `); label.addEventListener('click', (event: MouseEvent) => this.editLabelHandler(event, userLabel.id) ); element.after(label); label.setAttribute('style', `background-color: ${userLabel.color};`); // If we're in the topic listing, stop after adding 1 label. if (inTopicListing) { break; } } } return elements.length; }; addLabelHandler = (event: MouseEvent, username: string) => { event.preventDefault(); const target = event.target as HTMLElement; if (this.state.target === target && !this.state.hidden) { this.hide(); } else { const selectedColor = window .getComputedStyle(document.body) .getPropertyValue(themeColors[1].value) .trim(); this.setState({ hidden: false, target, username, color: selectedColor, id: null, text: '', priority: 0, selectedColor }); } }; editLabelHandler = (event: MouseEvent, id: number) => { event.preventDefault(); const target = event.target as HTMLElement; if (this.state.target === target && !this.state.hidden) { this.hide(); } else { const label = this.props.settings.data.userLabels.find( (value) => value.id === id ); if (label === undefined) { log( 'User Labels: Tried to edit label with ID that could not be found.', true ); return; } this.setState({ hidden: false, target, ...label }); } }; colorChange = (event: Event) => { let color: string = (event.target as HTMLInputElement).value.toLowerCase(); if (!color.startsWith('#') && !color.startsWith('t') && color.length > 0) { color = `#${color}`; } if (color !== 'transparent' && !isValidHexColor(color)) { log('User Labels: Color must be a valid hex color or "transparent".'); } // If the color was changed through the preset values, also change the // selected color state. if ((event.target as HTMLElement).tagName === 'SELECT') { this.setState({color, selectedColor: color}); } else { this.setState({color}); } }; labelChange = (event: Event) => { this.setState({text: (event.target as HTMLInputElement).value}); }; priorityChange = (event: Event) => { this.setState({ priority: Number((event.target as HTMLInputElement).value) }); }; save = (event: MouseEvent) => { event.preventDefault(); const {color, id, text, priority, username} = this.state; if (color === '' || username === '') { log('Cannot save user label without all values present.'); return; } const {settings} = this.props; // If no ID is present then save a new label otherwise edit the existing one. if (id === null) { let newID = 1; if (settings.data.userLabels.length > 0) { newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1; } settings.data.userLabels.push({ color, id: newID, priority, text, username }); this.addLabelsToUsernames(querySelectorAll('.link-user'), newID); } else { const index = settings.data.userLabels.findIndex( (value) => value.id === id ); settings.data.userLabels.splice(index, 1); settings.data.userLabels.push({ id, color, priority, text, username }); const elements = querySelectorAll(`[data-trx-label-id="${id}"]`); const bright = isColorBright(color); for (const element of elements) { element.textContent = text; element.setAttribute('style', `background-color: ${color};`); if (bright) { element.classList.add('trx-bright'); } else { element.classList.remove('trx-bright'); } } } void setSettings(settings); this.props.settings = settings; this.hide(); }; remove = (event: MouseEvent) => { event.preventDefault(); const {id} = this.state; if (id === null) { log('User Labels: Tried remove label when ID was null.'); return; } const {settings} = this.props; const index = settings.data.userLabels.findIndex( (value) => value.id === id ); if (index === undefined) { log( `User Labels: Tried to remove label with ID ${id} that could not be found.`, true ); return; } querySelectorAll(`[data-trx-label-id="${id}"]`).map((value) => value.remove() ); settings.data.userLabels.splice(index, 1); void setSettings(settings); this.props.settings = settings; this.hide(); }; render() { const bodyStyle = window.getComputedStyle(document.body); const themeSelectOptions = themeColors.map( ({name, value}) => html` ` ); const bright = isColorBright(this.state.color) ? 'trx-bright' : ''; const hidden = this.state.hidden ? 'trx-hidden' : ''; const {color, text: label, priority, selectedColor, username} = this.state; let top = 0; let left = 0; const target = this.state.target; if (target !== null) { const bounds = target.getBoundingClientRect(); top = bounds.y + bounds.height + 4 + window.scrollY; left = bounds.x + window.scrollX; } const position = `left: ${left}px; top: ${top}px;`; const previewStyle = `background-color: ${color}`; return html`

${label}

`; } }