Skip to content

Commit

Permalink
Merge pull request #567 from standardnotes/feature/autocomplete-tags
Browse files Browse the repository at this point in the history
feat: autocomplete tags
  • Loading branch information
Antonella Sgarlatta authored Jun 8, 2021
2 parents dc5b166 + d89aa06 commit 6f2ea2a
Show file tree
Hide file tree
Showing 24 changed files with 1,024 additions and 369 deletions.
4 changes: 3 additions & 1 deletion app/assets/javascripts/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNot
import { NotesContextMenuDirective } from './components/NotesContextMenu';
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
import { IconDirective } from './components/Icon';
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';

function reloadHiddenFirefoxTab(): boolean {
/**
Expand Down Expand Up @@ -159,7 +160,8 @@ const startApplication: StartApplication = async function startApplication(
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
.directive('notesContextMenu', NotesContextMenuDirective)
.directive('notesOptionsPanel', NotesOptionsPanelDirective)
.directive('icon', IconDirective);
.directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective);

// Filters
angular.module('app').filter('trusted', ['$sce', trusted]);
Expand Down
79 changes: 79 additions & 0 deletions app/assets/javascripts/components/AutocompleteTagHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { useRef, useEffect } from 'preact/hooks';
import { Icon } from './Icon';

type Props = {
appState: AppState;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
};

export const AutocompleteTagHint = observer(
({ appState, closeOnBlur }: Props) => {
const { autocompleteTagHintFocused } = appState.noteTags;

const hintRef = useRef<HTMLButtonElement>();

const { autocompleteSearchQuery, autocompleteTagResults } =
appState.noteTags;

const onTagHintClick = async () => {
await appState.noteTags.createAndAddNewTag();
};

const onFocus = () => {
appState.noteTags.setAutocompleteTagHintFocused(true);
};

const onBlur = (event: FocusEvent) => {
closeOnBlur(event);
appState.noteTags.setAutocompleteTagHintFocused(false);
};

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
if (autocompleteTagResults.length > 0) {
const lastTagResult =
autocompleteTagResults[autocompleteTagResults.length - 1];
appState.noteTags.setFocusedTagResultUuid(lastTagResult.uuid);
} else {
appState.noteTags.setAutocompleteInputFocused(true);
}
}
};

useEffect(() => {
if (autocompleteTagHintFocused) {
hintRef.current.focus();
}
}, [appState.noteTags, autocompleteTagHintFocused]);

return (
<>
{autocompleteTagResults.length > 0 && (
<div className="h-1px my-2 bg-border"></div>
)}
<button
ref={hintRef}
type="button"
className="sn-dropdown-item"
onClick={onTagHintClick}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
<span>Create new tag:</span>
<span className="bg-contrast rounded text-xs color-text py-1 pl-1 pr-2 flex items-center ml-2">
<Icon
type="hashtag"
className="sn-icon--small color-neutral mr-1"
/>
<span className="max-w-40 whitespace-nowrap overflow-hidden overflow-ellipsis">
{autocompleteSearchQuery}
</span>
</span>
</button>
</>
);
}
);
140 changes: 140 additions & 0 deletions app/assets/javascripts/components/AutocompleteTagInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { Disclosure, DisclosurePanel } from '@reach/disclosure';
import { useCloseOnBlur } from './utils';
import { AppState } from '@/ui_models/app_state';
import { AutocompleteTagResult } from './AutocompleteTagResult';
import { AutocompleteTagHint } from './AutocompleteTagHint';
import { observer } from 'mobx-react-lite';

type Props = {
appState: AppState;
};

export const AutocompleteTagInput = observer(({ appState }: Props) => {
const {
autocompleteInputFocused,
autocompleteSearchQuery,
autocompleteTagHintVisible,
autocompleteTagResults,
tags,
tagsContainerMaxWidth,
} = appState.noteTags;

const [dropdownVisible, setDropdownVisible] = useState(false);
const [dropdownMaxHeight, setDropdownMaxHeight] =
useState<number | 'auto'>('auto');

const dropdownRef = useRef<HTMLDivElement>();
const inputRef = useRef<HTMLInputElement>();

const [closeOnBlur] = useCloseOnBlur(dropdownRef, (visible: boolean) => {
setDropdownVisible(visible);
appState.noteTags.clearAutocompleteSearch();
});

const showDropdown = () => {
const { clientHeight } = document.documentElement;
const inputRect = inputRef.current.getBoundingClientRect();
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2);
setDropdownVisible(true);
};

const onSearchQueryChange = (event: Event) => {
const query = (event.target as HTMLInputElement).value;
appState.noteTags.setAutocompleteSearchQuery(query);
appState.noteTags.searchActiveNoteAutocompleteTags();
};

const onFormSubmit = async (event: Event) => {
event.preventDefault();
await appState.noteTags.createAndAddNewTag();
};

const onKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Backspace':
case 'ArrowLeft':
if (autocompleteSearchQuery === '' && tags.length > 0) {
appState.noteTags.setFocusedTagUuid(tags[tags.length - 1].uuid);
}
break;
case 'ArrowDown':
event.preventDefault();
if (autocompleteTagResults.length > 0) {
appState.noteTags.setFocusedTagResultUuid(autocompleteTagResults[0].uuid);
} else if (autocompleteTagHintVisible) {
appState.noteTags.setAutocompleteTagHintFocused(true);
}
break;
default:
return;
}
};

const onFocus = () => {
showDropdown();
appState.noteTags.setAutocompleteInputFocused(true);
};

const onBlur = (event: FocusEvent) => {
closeOnBlur(event);
appState.noteTags.setAutocompleteInputFocused(false);
};

useEffect(() => {
appState.noteTags.searchActiveNoteAutocompleteTags();
}, [appState.noteTags]);

useEffect(() => {
if (autocompleteInputFocused) {
inputRef.current.focus();
appState.noteTags.setAutocompleteInputFocused(false);
}
}, [appState.noteTags, autocompleteInputFocused]);

return (
<form
onSubmit={onFormSubmit}
className={`${tags.length > 0 ? 'mt-2' : ''}`}
>
<Disclosure open={dropdownVisible} onChange={showDropdown}>
<input
ref={inputRef}
className={`${tags.length > 0 ? 'w-80' : 'w-70 mr-10'} bg-default text-xs
color-text no-border h-7 focus:outline-none focus:shadow-none focus:border-bottom`}
value={autocompleteSearchQuery}
onChange={onSearchQueryChange}
type="text"
placeholder="Add tag"
onBlur={onBlur}
onFocus={onFocus}
onKeyDown={onKeyDown}
/>
{dropdownVisible && (autocompleteTagResults.length > 0 || autocompleteTagHintVisible) && (
<DisclosurePanel
ref={dropdownRef}
className={`${tags.length > 0 ? 'w-80' : 'w-70 mr-10'} sn-dropdown flex flex-col py-2 absolute`}
style={{ maxHeight: dropdownMaxHeight, maxWidth: tagsContainerMaxWidth }}
>
<div className="overflow-y-scroll">
{autocompleteTagResults.map((tagResult) => (
<AutocompleteTagResult
key={tagResult.uuid}
appState={appState}
tagResult={tagResult}
closeOnBlur={closeOnBlur}
/>
))}
</div>
{autocompleteTagHintVisible && (
<AutocompleteTagHint
appState={appState}
closeOnBlur={closeOnBlur}
/>
)}
</DisclosurePanel>
)}
</Disclosure>
</form>
);
});
109 changes: 109 additions & 0 deletions app/assets/javascripts/components/AutocompleteTagResult.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { AppState } from '@/ui_models/app_state';
import { SNTag } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { useEffect, useRef } from 'preact/hooks';
import { Icon } from './Icon';

type Props = {
appState: AppState;
tagResult: SNTag;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
};

export const AutocompleteTagResult = observer(
({ appState, tagResult, closeOnBlur }: Props) => {
const {
autocompleteSearchQuery,
autocompleteTagHintVisible,
autocompleteTagResults,
focusedTagResultUuid,
} = appState.noteTags;

const tagResultRef = useRef<HTMLButtonElement>();

const onTagOptionClick = async (tag: SNTag) => {
await appState.noteTags.addTagToActiveNote(tag);
appState.noteTags.clearAutocompleteSearch();
appState.noteTags.setAutocompleteInputFocused(true);
};

const onKeyDown = (event: KeyboardEvent) => {
const tagResultIndex = appState.noteTags.getTagIndex(
tagResult,
autocompleteTagResults
);
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
if (tagResultIndex === 0) {
appState.noteTags.setAutocompleteInputFocused(true);
} else {
appState.noteTags.focusPreviousTagResult(tagResult);
}
break;
case 'ArrowDown':
event.preventDefault();
if (
tagResultIndex === autocompleteTagResults.length - 1 &&
autocompleteTagHintVisible
) {
appState.noteTags.setAutocompleteTagHintFocused(true);
} else {
appState.noteTags.focusNextTagResult(tagResult);
}
break;
default:
return;
}
};

const onFocus = () => {
appState.noteTags.setFocusedTagResultUuid(tagResult.uuid);
};

const onBlur = (event: FocusEvent) => {
closeOnBlur(event);
appState.noteTags.setFocusedTagResultUuid(undefined);
};

useEffect(() => {
if (focusedTagResultUuid === tagResult.uuid) {
tagResultRef.current.focus();
appState.noteTags.setFocusedTagResultUuid(undefined);
}
}, [appState.noteTags, focusedTagResultUuid, tagResult]);

return (
<button
ref={tagResultRef}
type="button"
className="sn-dropdown-item"
onClick={() => onTagOptionClick(tagResult)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
<Icon type="hashtag" className="color-neutral mr-2 min-h-5 min-w-5" />
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis">
{autocompleteSearchQuery === ''
? tagResult.title
: tagResult.title
.split(new RegExp(`(${autocompleteSearchQuery})`, 'gi'))
.map((substring, index) => (
<span
key={index}
className={`${
substring.toLowerCase() ===
autocompleteSearchQuery.toLowerCase()
? 'font-bold whitespace-pre-wrap'
: 'whitespace-pre-wrap '
}`}
>
{substring}
</span>
))}
</span>
</button>
);
}
);
Loading

0 comments on commit 6f2ea2a

Please sign in to comment.