Skip to content

Commit

Permalink
Autocomplete loadout hashtags in loadout drawer
Browse files Browse the repository at this point in the history
  • Loading branch information
robojumper committed Jan 3, 2023
1 parent 92f2bae commit b4be32a
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 107 deletions.
100 changes: 100 additions & 0 deletions src/app/dim-ui/text-complete/InputTextEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createCustomEvent, CursorOffset, Editor, SearchResult } from '@textcomplete/core';
import { calculateElementOffset, getLineHeightPx } from '@textcomplete/utils';
import getCaretCoordinates from 'app/utils/textarea-caret';
import { update } from 'undate';

/**
* An Editor for `textcomplete` so that we can show the completion dropdown
* on <input type="text" /> elements too.
*/
export class InputTextEditor extends Editor {
constructor(private readonly el: HTMLInputElement) {
super();
this.startListening();
}

destroy(): this {
super.destroy();
this.stopListening();
return this;
}

/**
* @implements {@link Editor#applySearchResult}
*/
applySearchResult(searchResult: SearchResult): void {
const beforeCursor = this.getBeforeCursor();
if (beforeCursor !== null) {
const replace = searchResult.replace(beforeCursor, this.getAfterCursor());
this.el.focus(); // Clicking a dropdown item removes focus from the element.
if (Array.isArray(replace)) {
// Commit a type crime because the update code works with both input and textarea
// even though the types don't advertise it
update(this.el as any, replace[0], replace[1]);
if (this.el) {
this.el.dispatchEvent(createCustomEvent('input'));
}
}
}
}

/**
* @implements {@link Editor#getCursorOffset}
*/
getCursorOffset(): CursorOffset {
const elOffset = calculateElementOffset(this.el);
const cursorPosition = getCaretCoordinates(this.el, this.el.selectionEnd ?? 0);
const lineHeight = getLineHeightPx(this.el);
const top = elOffset.top + lineHeight;
const left = elOffset.left + cursorPosition.left;
const clientTop = this.el.getBoundingClientRect().top;
if (this.el.dir !== 'rtl') {
return { top, left, lineHeight, clientTop };
} else {
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
return { top, right, lineHeight, clientTop };
}
}

/**
* @implements {@link Editor#getBeforeCursor}
*/
getBeforeCursor(): string | null {
return this.el.selectionStart !== this.el.selectionEnd
? null
: this.el.value.substring(0, this.el.selectionEnd ?? undefined);
}

private getAfterCursor(): string {
return this.el.value.substring(this.el.selectionEnd ?? 0);
}

private onInput = () => {
this.emitChangeEvent();
};

private onKeydown = (e: KeyboardEvent) => {
const code = this.getCode(e);
let event;
if (code === 'UP' || code === 'DOWN') {
event = this.emitMoveEvent(code);
} else if (code === 'ENTER') {
event = this.emitEnterEvent();
} else if (code === 'ESC') {
event = this.emitEscEvent();
}
if (event?.defaultPrevented) {
e.preventDefault();
}
};

private startListening(): void {
this.el.addEventListener('input', this.onInput);
this.el.addEventListener('keydown', this.onKeydown);
}

private stopListening(): void {
this.el.removeEventListener('input', this.onInput);
this.el.removeEventListener('keydown', this.onKeydown);
}
}
45 changes: 45 additions & 0 deletions src/app/dim-ui/text-complete/text-complete.m.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@use '../../variables.scss' as *;

.dropdownMenu {
color: black;
background-color: white;
border-radius: 4px;
overflow: hidden;
list-style: none;
padding: 0;
margin: 0;

:global(.textcomplete-header),
:global(.textcomplete-footer) {
display: none;
}
li {
padding: 4px 8px;
&:nth-child(2) {
border-top: none;
}
&:hover {
background-color: #e8a534;
}
}
:global(.active) {
background-color: #e8a534;
}
a {
color: black;
font-size: 12px;
&:hover {
cursor: pointer;
}
text-decoration-line: none;
}

@include phone-portrait {
a {
font-size: 16px;
}
li {
padding: 8px 10px;
}
}
}
7 changes: 7 additions & 0 deletions src/app/dim-ui/text-complete/text-complete.m.scss.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 69 additions & 0 deletions src/app/dim-ui/text-complete/text-complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { StrategyProps, Textcomplete } from '@textcomplete/core';
import { TextareaEditor } from '@textcomplete/textarea';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import clsx from 'clsx';
import { useEffect } from 'react';
import { InputTextEditor } from './InputTextEditor';

import styles from './text-complete.m.scss';

function createTagsCompleter(
textArea: React.RefObject<HTMLTextAreaElement | HTMLInputElement>,
tags: string[]
): StrategyProps {
return {
match: /#(\w*)$/,
search: (term, callback) => {
const termLower = term.toLowerCase();
// need to build this list from the element ref, because relying
// on liveNotes state would re-instantiate Textcomplete every keystroke
const existingTags = getHashtagsFromNote(textArea.current!.value).map((t) => t.toLowerCase());
const possibleTags: string[] = [];
for (const t of tags) {
const tagLower = t.toLowerCase();
// don't suggest duplicate tags
if (existingTags.includes(tagLower)) {
continue;
}
// favor startswith
if (tagLower.startsWith('#' + termLower)) {
possibleTags.unshift(t);
// over full text search
} else if (tagLower.includes(termLower)) {
possibleTags.push(t);
}
}
callback(possibleTags);
},
replace: (key) => `${key} `,
// to-do: for major tags, gonna use this to show what the notes icon will change to
// template: (key) => `<img src="${url}"/>&nbsp;<small>:${key}:</small>`,
};
}

/**
* Autocomplete a list of hashtags in this <textarea /> or <input type="text" />.
* `tags` must have a stable object identity when using this hook (unless the set of tags changes).
* selectors should ensure this, useMemo doesn't guarantee it per contract but works now.
*/
export function useAutocomplete(
textArea: React.RefObject<HTMLTextAreaElement | HTMLInputElement>,
tags: string[]
) {
useEffect(() => {
if (textArea.current) {
const isInput = textArea.current instanceof HTMLInputElement;
const editor = isInput
? new InputTextEditor(textArea.current)
: new TextareaEditor(textArea.current);
const textcomplete = new Textcomplete(editor, [createTagsCompleter(textArea, tags)], {
dropdown: {
className: clsx(styles.dropdownMenu, 'textcomplete-dropdown'),
},
});
return () => {
textcomplete.destroy();
};
}
}, [tags, textArea]);
}
46 changes: 0 additions & 46 deletions src/app/item-popup/NotesArea.m.scss
Original file line number Diff line number Diff line change
Expand Up @@ -44,49 +44,3 @@
width: 100%;
text-align: right;
}

/* Textcomplete */

.dropdownMenu {
color: black;
background-color: white;
border-radius: 4px;
overflow: hidden;
list-style: none;
padding: 0;
margin: 0;

:global(.textcomplete-header),
:global(.textcomplete-footer) {
display: none;
}
li {
padding: 4px 8px;
&:nth-child(2) {
border-top: none;
}
&:hover {
background-color: #e8a534;
}
}
:global(.active) {
background-color: #e8a534;
}
a {
color: black;
font-size: 12px;
&:hover {
cursor: pointer;
}
text-decoration-line: none;
}

@include phone-portrait {
a {
font-size: 16px;
}
li {
padding: 8px 10px;
}
}
}
1 change: 0 additions & 1 deletion src/app/item-popup/NotesArea.m.scss.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 5 additions & 57 deletions src/app/item-popup/NotesArea.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { Textcomplete } from '@textcomplete/core';
import { TextareaEditor } from '@textcomplete/textarea';
import { useAutocomplete } from 'app/dim-ui/text-complete/text-complete';
import { t } from 'app/i18next-t';
import { setNote } from 'app/inventory/actions';
import { itemNoteSelector } from 'app/inventory/dim-item-info';
import { DimItem } from 'app/inventory/item-types';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { allNotesHashtagsSelector } from 'app/inventory/selectors';
import { AppIcon, editIcon } from 'app/shell/icons';
import { useIsPhonePortrait } from 'app/shell/selectors';
import { useThunkDispatch } from 'app/store/thunk-dispatch';
import { isiOSBrowser } from 'app/utils/browsers';
import clsx from 'clsx';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import TextareaAutosize from 'react-textarea-autosize';
import styles from './NotesArea.m.scss';
Expand Down Expand Up @@ -116,7 +114,9 @@ function NotesEditor({
e.stopPropagation();
};

useTagsAutocomplete(textArea);
const tags = useSelector(allNotesHashtagsSelector);

useAutocomplete(textArea, tags);

// On iOS at least, focusing the keyboard pushes the content off the screen
const nativeAutoFocus = !isPhonePortrait && !isiOSBrowser();
Expand All @@ -143,55 +143,3 @@ function NotesEditor({
</form>
);
}

function useTagsAutocomplete(textArea: React.RefObject<HTMLTextAreaElement>) {
const tags = useSelector(allNotesHashtagsSelector);
useEffect(() => {
if (textArea.current) {
const editor = new TextareaEditor(textArea.current);
const textcomplete = new Textcomplete(
editor,
[
{
match: /#(\w*)$/,
search: (term, callback) => {
const termLower = term.toLowerCase();
// need to build this list from the element ref, because relying
// on liveNotes state would re-instantiate Textcomplete every keystroke
const existingTags = getHashtagsFromNote(textArea.current!.value).map((t) =>
t.toLowerCase()
);
const possibleTags: string[] = [];
for (const t of tags) {
const tagLower = t.toLowerCase();
// don't suggest duplicate tags
if (existingTags.includes(tagLower)) {
continue;
}
// favor startswith
if (tagLower.startsWith('#' + termLower)) {
possibleTags.unshift(t);
// over full text search
} else if (tagLower.includes(termLower)) {
possibleTags.push(t);
}
}
callback(possibleTags);
},
replace: (key) => `${key} `,
// to-do: for major tags, gonna use this to show what the notes icon will change to
// template: (key) => `<img src="${url}"/>&nbsp;<small>:${key}:</small>`,
},
],
{
dropdown: {
className: clsx(styles.dropdownMenu, 'textcomplete-dropdown'),
},
}
);
return () => {
textcomplete.destroy();
};
}
}, [textArea, tags]);
}
Loading

0 comments on commit b4be32a

Please sign in to comment.