Merge remote-tracking branch 'upstream/develop' into account-menu-react

This commit is contained in:
VardanHakobyan
2021-06-09 11:06:42 +04:00
27 changed files with 1039 additions and 375 deletions

View File

@@ -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>
</>
);
}
);

View File

@@ -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>
);
});

View File

@@ -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>
);
}
);

View File

@@ -23,9 +23,7 @@ export const ConfirmSignoutContainer = observer((props: Props) => {
});
const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(
application.hasAccount()
);
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
const cancelRef = useRef<HTMLButtonElement>();
function close() {

View File

@@ -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<HTMLButtonElement>();
const tagRef = useRef<HTMLButtonElement>();
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 (
<button
ref={tagRef}
className="sn-tag pl-1 pr-2 mr-2"
onClick={onTagClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
>
<Icon type="hashtag" className="sn-icon--small color-info mr-1" />
<span className="whitespace-nowrap overflow-hidden overflow-ellipsis max-w-290px">
{tag.title}
</span>
{showDeleteButton && (
<button
ref={deleteTagRef}
type="button"
className="ml-2 -mr-1 border-0 p-0 bg-transparent cursor-pointer flex"
onFocus={onFocus}
onBlur={onBlur}
onClick={onDeleteTagClick}
>
<Icon
type="close"
className="sn-icon--small color-neutral hover:color-info"
/>
</button>
)}
</button>
);
});

View File

@@ -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 (
<div
className="bg-default flex flex-wrap min-w-80 -mt-1 -mr-2"
style={{
maxWidth: tagsContainerMaxWidth,
}}
>
{tags.map((tag) => (
<NoteTag
key={tag.uuid}
appState={appState}
tag={tag}
/>
))}
<AutocompleteTagInput appState={appState} />
</div>
);
});
export const NoteTagsContainerDirective = toDirective<Props>(NoteTagsContainer);

View File

@@ -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 ? (
<div
ref={contextMenuRef}
className="sn-dropdown max-h-120 max-w-80 flex flex-col py-2 overflow-y-scroll fixed"
className="sn-dropdown max-h-120 max-w-xs flex flex-col py-2 overflow-y-scroll fixed"
style={{
...appState.notes.contextMenuPosition,
maxHeight: appState.notes.contextMenuMaxHeight,

View File

@@ -52,10 +52,6 @@ export const NotesOptions = observer(
const tagsButtonRef = useRef<HTMLButtonElement>();
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 && (
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
<DisclosureButton
onKeyUp={(event) => {
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"
>
<div className="flex items-center">
<Icon type="hashtag" className={iconClass} />
@@ -152,7 +148,7 @@ export const NotesOptions = observer(
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
onKeyUp={(event) => {
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) => (
<button
key={tag.title}
className={`${buttonClass} py-2 max-w-80`}
className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80"
onBlur={closeOnBlur}
onClick={() => {
appState.notes.isTagInSelectedNotes(tag)
@@ -194,7 +190,7 @@ export const NotesOptions = observer(
{unpinned && (
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setPinSelectedNotes(true);
}}
@@ -206,7 +202,7 @@ export const NotesOptions = observer(
{pinned && (
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setPinSelectedNotes(false);
}}
@@ -218,7 +214,7 @@ export const NotesOptions = observer(
{unarchived && (
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setArchiveSelectedNotes(true);
}}
@@ -230,7 +226,7 @@ export const NotesOptions = observer(
{archived && (
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={() => {
appState.notes.setArchiveSelectedNotes(false);
}}
@@ -242,7 +238,7 @@ export const NotesOptions = observer(
{notTrashed && (
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.setTrashSelectedNotes(true);
}}
@@ -255,7 +251,7 @@ export const NotesOptions = observer(
<>
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.setTrashSelectedNotes(false);
}}
@@ -265,7 +261,7 @@ export const NotesOptions = observer(
</button>
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.deleteNotesPermanently();
}}
@@ -275,7 +271,7 @@ export const NotesOptions = observer(
</button>
<button
onBlur={closeOnBlur}
className={`${buttonClass} py-1.5`}
className="sn-dropdown-item"
onClick={async () => {
await appState.notes.emptyTrash();
}}

View File

@@ -47,7 +47,7 @@ export const NotesOptionsPanel = observer(({ appState }: Props) => {
}}
>
<DisclosureButton
onKeyUp={(event) => {
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
}
@@ -59,28 +59,28 @@ export const NotesOptionsPanel = observer(({ appState }: Props) => {
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyUp={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current.focus();
}
}}
ref={panelRef}
style={{
...position,
maxHeight
}}
className="sn-dropdown sn-dropdown--animated max-h-120 max-w-80 flex flex-col py-2 overflow-y-scroll fixed"
>
{open && (
<NotesOptions
appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}
/>
)}
</DisclosurePanel>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current.focus();
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated max-h-120 max-w-xs flex flex-col py-2 overflow-y-scroll fixed"
>
{open && (
<NotesOptions
appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}
/>
)}
</DisclosurePanel>
</Disclosure>
);
});

View File

@@ -126,7 +126,7 @@ const SessionsModal: FunctionComponent<{
return (
<>
<Dialog onDismiss={close} className="sessions-modal">
<Dialog onDismiss={close} className="sessions-modal h-screen py-8">
<div className="sk-modal-content">
<div class="sn-component">
<div class="sk-panel">
@@ -145,7 +145,7 @@ const SessionsModal: FunctionComponent<{
</button>
</div>
</div>
<div class="sk-panel-content">
<div class="sk-panel-content overflow-y-scroll">
{refreshing ? (
<>
<div class="sk-spinner small info"></div>

View File

@@ -42,7 +42,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
<span
aria-hidden
className={`sn-switch-handle ${
checked ? 'sn-switch-handle-right' : ''
checked ? 'sn-switch-handle--right' : ''
}`}
/>
</CustomCheckboxContainer>

View File

@@ -1,5 +1,6 @@
import { FunctionComponent, h, render } from 'preact';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { useEffect } from 'react';
/**
* @returns a callback that will close a dropdown if none of its children has
@@ -30,6 +31,26 @@ export function useCloseOnBlur(
];
}
export function useCloseOnClickOutside(
container: { current: HTMLDivElement },
setOpen: (open: boolean) => void
): void {
const closeOnClickOutside = useCallback((event: { target: EventTarget | null }) => {
if (
!container.current?.contains(event.target as Node)
) {
setOpen(false);
}
}, [container, setOpen]);
useEffect(() => {
document.addEventListener('click', closeOnClickOutside);
return () => {
document.removeEventListener('click', closeOnClickOutside);
};
}, [closeOnClickOutside]);
}
export function toDirective<Props>(
component: FunctionComponent<Props>,
scope: Record<string, '=' | '&' | '@'> = {}