diff --git a/src/app/dim-ui/text-complete/InputTextEditor.ts b/src/app/dim-ui/text-complete/InputTextEditor.ts new file mode 100644 index 00000000000..fdb28ec0804 --- /dev/null +++ b/src/app/dim-ui/text-complete/InputTextEditor.ts @@ -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 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); + } +} diff --git a/src/app/dim-ui/text-complete/text-complete.m.scss b/src/app/dim-ui/text-complete/text-complete.m.scss new file mode 100644 index 00000000000..a180b1c5ec4 --- /dev/null +++ b/src/app/dim-ui/text-complete/text-complete.m.scss @@ -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; + } + } +} diff --git a/src/app/dim-ui/text-complete/text-complete.m.scss.d.ts b/src/app/dim-ui/text-complete/text-complete.m.scss.d.ts new file mode 100644 index 00000000000..096fc3b6f7f --- /dev/null +++ b/src/app/dim-ui/text-complete/text-complete.m.scss.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'dropdownMenu': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/src/app/dim-ui/text-complete/text-complete.ts b/src/app/dim-ui/text-complete/text-complete.ts new file mode 100644 index 00000000000..45d25230fbc --- /dev/null +++ b/src/app/dim-ui/text-complete/text-complete.ts @@ -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, + 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) => ` :${key}:`, + }; +} + +/** + * Autocomplete a list of hashtags in this