diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 16b54ebf4..9cc5b8d46 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -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 { /** @@ -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]); diff --git a/app/assets/javascripts/components/AutocompleteTagHint.tsx b/app/assets/javascripts/components/AutocompleteTagHint.tsx new file mode 100644 index 000000000..d4bfd6c6c --- /dev/null +++ b/app/assets/javascripts/components/AutocompleteTagHint.tsx @@ -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(); + + 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 && ( +
+ )} + + + ); + } +); diff --git a/app/assets/javascripts/components/AutocompleteTagInput.tsx b/app/assets/javascripts/components/AutocompleteTagInput.tsx new file mode 100644 index 000000000..8ccfb3d56 --- /dev/null +++ b/app/assets/javascripts/components/AutocompleteTagInput.tsx @@ -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('auto'); + + const dropdownRef = useRef(); + const inputRef = useRef(); + + 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 ( +
0 ? 'mt-2' : ''}`} + > + + 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) && ( + 0 ? 'w-80' : 'w-70 mr-10'} sn-dropdown flex flex-col py-2 absolute`} + style={{ maxHeight: dropdownMaxHeight, maxWidth: tagsContainerMaxWidth }} + > +
+ {autocompleteTagResults.map((tagResult) => ( + + ))} +
+ {autocompleteTagHintVisible && ( + + )} +
+ )} +
+
+ ); +}); diff --git a/app/assets/javascripts/components/AutocompleteTagResult.tsx b/app/assets/javascripts/components/AutocompleteTagResult.tsx new file mode 100644 index 000000000..0a7cf9cb8 --- /dev/null +++ b/app/assets/javascripts/components/AutocompleteTagResult.tsx @@ -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(); + + 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 ( + + ); + } +); diff --git a/app/assets/javascripts/components/NoteTag.tsx b/app/assets/javascripts/components/NoteTag.tsx new file mode 100644 index 000000000..3afcc3d35 --- /dev/null +++ b/app/assets/javascripts/components/NoteTag.tsx @@ -0,0 +1,112 @@ +import { Icon } from './Icon'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { AppState } from '@/ui_models/app_state'; +import { SNTag } from '@standardnotes/snjs/dist/@types'; +import { observer } from 'mobx-react-lite'; + +type Props = { + appState: AppState; + tag: SNTag; +}; + +export const NoteTag = observer(({ appState, tag }: Props) => { + const { focusedTagUuid, tags } = appState.noteTags; + + const [showDeleteButton, setShowDeleteButton] = useState(false); + const [tagClicked, setTagClicked] = useState(false); + const deleteTagRef = useRef(); + + const tagRef = useRef(); + + const deleteTag = () => { + appState.noteTags.focusPreviousTag(tag); + appState.noteTags.removeTagFromActiveNote(tag); + }; + + const onDeleteTagClick = (event: MouseEvent) => { + event.stopImmediatePropagation(); + event.stopPropagation(); + deleteTag(); + }; + + const onTagClick = (event: MouseEvent) => { + if (tagClicked && event.target !== deleteTagRef.current) { + setTagClicked(false); + appState.setSelectedTag(tag); + } else { + setTagClicked(true); + } + }; + + const onFocus = () => { + appState.noteTags.setFocusedTagUuid(tag.uuid); + setShowDeleteButton(true); + }; + + const onBlur = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget as Node; + if (relatedTarget !== deleteTagRef.current) { + appState.noteTags.setFocusedTagUuid(undefined); + setShowDeleteButton(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + const tagIndex = appState.noteTags.getTagIndex(tag, tags); + switch (event.key) { + case 'Backspace': + deleteTag(); + break; + case 'ArrowLeft': + appState.noteTags.focusPreviousTag(tag); + break; + case 'ArrowRight': + if (tagIndex === tags.length - 1) { + appState.noteTags.setAutocompleteInputFocused(true); + } else { + appState.noteTags.focusNextTag(tag); + } + break; + default: + return; + } + }; + + useEffect(() => { + if (focusedTagUuid === tag.uuid) { + tagRef.current.focus(); + appState.noteTags.setFocusedTagUuid(undefined); + } + }, [appState.noteTags, focusedTagUuid, tag]); + + return ( + + )} + + ); +}); diff --git a/app/assets/javascripts/components/NoteTagsContainer.tsx b/app/assets/javascripts/components/NoteTagsContainer.tsx new file mode 100644 index 000000000..8d94cdf42 --- /dev/null +++ b/app/assets/javascripts/components/NoteTagsContainer.tsx @@ -0,0 +1,41 @@ +import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; +import { toDirective } from './utils'; +import { AutocompleteTagInput } from './AutocompleteTagInput'; +import { NoteTag } from './NoteTag'; +import { useEffect } from 'preact/hooks'; + +type Props = { + appState: AppState; +}; + +const NoteTagsContainer = observer(({ appState }: Props) => { + const { + tags, + tagsContainerMaxWidth, + } = appState.noteTags; + + useEffect(() => { + appState.noteTags.reloadTagsContainerMaxWidth(); + }, [appState.noteTags]); + + return ( +
+ {tags.map((tag) => ( + + ))} + +
+ ); +}); + +export const NoteTagsContainerDirective = toDirective(NoteTagsContainer); diff --git a/app/assets/javascripts/components/NotesContextMenu.tsx b/app/assets/javascripts/components/NotesContextMenu.tsx index b215f0463..fa21ac7eb 100644 --- a/app/assets/javascripts/components/NotesContextMenu.tsx +++ b/app/assets/javascripts/components/NotesContextMenu.tsx @@ -1,8 +1,8 @@ import { AppState } from '@/ui_models/app_state'; -import { toDirective, useCloseOnBlur } from './utils'; +import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils'; import { observer } from 'mobx-react-lite'; import { NotesOptions } from './NotesOptions'; -import { useCallback, useEffect, useRef } from 'preact/hooks'; +import { useRef } from 'preact/hooks'; type Props = { appState: AppState; @@ -15,23 +15,15 @@ const NotesContextMenu = observer(({ appState }: Props) => { (open: boolean) => appState.notes.setContextMenuOpen(open) ); - const closeOnClickOutside = useCallback((event: MouseEvent) => { - if (!contextMenuRef.current?.contains(event.target as Node)) { - appState.notes.setContextMenuOpen(false); - } - }, [appState]); - - useEffect(() => { - document.addEventListener('click', closeOnClickOutside); - return () => { - document.removeEventListener('click', closeOnClickOutside); - }; - }, [closeOnClickOutside]); + useCloseOnClickOutside( + contextMenuRef, + (open: boolean) => appState.notes.setContextMenuOpen(open) + ); return appState.notes.contextMenuOpen ? (
(); const iconClass = 'color-neutral mr-2'; - const buttonClass = - 'flex items-center border-0 focus:inner-ring-info ' + - 'cursor-pointer hover:bg-contrast color-text bg-transparent px-3 ' + - 'text-left'; useEffect(() => { if (onSubmenuChange) { @@ -136,14 +132,14 @@ export const NotesOptions = observer( {appState.tags.tagsCount > 0 && ( { + onKeyDown={(event) => { if (event.key === 'Escape') { setTagsMenuOpen(false); } }} onBlur={closeOnBlur} ref={tagsButtonRef} - className={`${buttonClass} py-1.5 justify-between`} + className="sn-dropdown-item justify-between" >
@@ -152,7 +148,7 @@ export const NotesOptions = observer( { + onKeyDown={(event) => { if (event.key === 'Escape') { setTagsMenuOpen(false); tagsButtonRef.current.focus(); @@ -163,12 +159,12 @@ export const NotesOptions = observer( maxHeight: tagsMenuMaxHeight, position: 'fixed', }} - className="sn-dropdown flex flex-col py-2 max-h-120 max-w-80 fixed overflow-y-scroll" + className="sn-dropdown flex flex-col py-2 max-h-120 max-w-xs fixed overflow-y-scroll" > {appState.tags.tags.map((tag) => (