diff --git a/app/assets/icons/ic-archive.svg b/app/assets/icons/ic-archive.svg new file mode 100644 index 000000000..bc703480c --- /dev/null +++ b/app/assets/icons/ic-archive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/icons/ic-chevron-right.svg b/app/assets/icons/ic-chevron-right.svg new file mode 100644 index 000000000..6d39ca8a9 --- /dev/null +++ b/app/assets/icons/ic-chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic_close.svg b/app/assets/icons/ic-close.svg similarity index 85% rename from app/assets/icons/ic_close.svg rename to app/assets/icons/ic-close.svg index 8d3b13d49..cd22c9a5c 100644 --- a/app/assets/icons/ic_close.svg +++ b/app/assets/icons/ic-close.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/icons/ic-hashtag.svg b/app/assets/icons/ic-hashtag.svg new file mode 100644 index 000000000..4fbec792c --- /dev/null +++ b/app/assets/icons/ic-hashtag.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-more.svg b/app/assets/icons/ic-more.svg new file mode 100644 index 000000000..ec8fe1101 --- /dev/null +++ b/app/assets/icons/ic-more.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pencil-off.svg b/app/assets/icons/ic-pencil-off.svg new file mode 100644 index 000000000..060d7c877 --- /dev/null +++ b/app/assets/icons/ic-pencil-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pin-off.svg b/app/assets/icons/ic-pin-off.svg new file mode 100644 index 000000000..813118ccc --- /dev/null +++ b/app/assets/icons/ic-pin-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pin.svg b/app/assets/icons/ic-pin.svg new file mode 100644 index 000000000..cf2de7c7f --- /dev/null +++ b/app/assets/icons/ic-pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-restore.svg b/app/assets/icons/ic-restore.svg new file mode 100644 index 000000000..ab1c44201 --- /dev/null +++ b/app/assets/icons/ic-restore.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-text-rich.svg b/app/assets/icons/ic-text-rich.svg new file mode 100644 index 000000000..87f57dd41 --- /dev/null +++ b/app/assets/icons/ic-text-rich.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-textbox-password.svg b/app/assets/icons/ic-textbox-password.svg new file mode 100644 index 000000000..46c189122 --- /dev/null +++ b/app/assets/icons/ic-textbox-password.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-trash-sweep.svg b/app/assets/icons/ic-trash-sweep.svg new file mode 100644 index 000000000..dab4177a4 --- /dev/null +++ b/app/assets/icons/ic-trash-sweep.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-trash.svg b/app/assets/icons/ic-trash.svg new file mode 100644 index 000000000..0890f3967 --- /dev/null +++ b/app/assets/icons/ic-trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic_tune.svg b/app/assets/icons/ic-tune.svg similarity index 77% rename from app/assets/icons/ic_tune.svg rename to app/assets/icons/ic-tune.svg index fbf26572a..7fa38a00e 100644 --- a/app/assets/icons/ic_tune.svg +++ b/app/assets/icons/ic-tune.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/icons/ic-unarchive.svg b/app/assets/icons/ic-unarchive.svg new file mode 100644 index 000000000..74f0a8d92 --- /dev/null +++ b/app/assets/icons/ic-unarchive.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/il-notes.svg b/app/assets/icons/il-notes.svg new file mode 100644 index 000000000..2d9cfc0bc --- /dev/null +++ b/app/assets/icons/il-notes.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index bcd28cf2f..8adffd559 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -60,6 +60,10 @@ import { NoAccountWarningDirective } from './components/NoAccountWarning'; import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning'; import { SearchOptionsDirective } from './components/SearchOptions'; import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; +import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; +import { NotesContextMenuDirective } from './components/NotesContextMenu'; +import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; +import { IconDirective } from './components/Icon'; function reloadHiddenFirefoxTab(): boolean { /** @@ -149,7 +153,11 @@ const startApplication: StartApplication = async function startApplication( .directive('noAccountWarning', NoAccountWarningDirective) .directive('protectedNotePanel', NoProtectionsdNoteWarningDirective) .directive('searchOptions', SearchOptionsDirective) - .directive('confirmSignout', ConfirmSignoutDirective); + .directive('confirmSignout', ConfirmSignoutDirective) + .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective) + .directive('notesContextMenu', NotesContextMenuDirective) + .directive('notesOptionsPanel', NotesOptionsPanelDirective) + .directive('icon', IconDirective); // Filters angular.module('app').filter('trusted', ['$sce', trusted]); diff --git a/app/assets/javascripts/components/ConfirmSignoutModal.tsx b/app/assets/javascripts/components/ConfirmSignoutModal.tsx index 6d3cc19f2..b432caf83 100644 --- a/app/assets/javascripts/components/ConfirmSignoutModal.tsx +++ b/app/assets/javascripts/components/ConfirmSignoutModal.tsx @@ -83,14 +83,14 @@ const ConfirmSignoutModal = observer(({ application, appState }: Props) => { )}
); -} +}); export const NoAccountWarningDirective = toDirective(NoAccountWarning); diff --git a/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx index 074bf9288..e3e9ec291 100644 --- a/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx +++ b/app/assets/javascripts/components/NoProtectionsNoteWarning.tsx @@ -13,14 +13,14 @@ function NoProtectionsNoteWarning({ appState, onViewNote }: Props) {

-
diff --git a/app/assets/javascripts/components/NotesContextMenu.tsx b/app/assets/javascripts/components/NotesContextMenu.tsx new file mode 100644 index 000000000..3058c035f --- /dev/null +++ b/app/assets/javascripts/components/NotesContextMenu.tsx @@ -0,0 +1,45 @@ +import { AppState } from '@/ui_models/app_state'; +import { toDirective, useCloseOnBlur } from './utils'; +import { observer } from 'mobx-react-lite'; +import { NotesOptions } from './NotesOptions'; +import { useCallback, useEffect, useRef } from 'preact/hooks'; + +type Props = { + appState: AppState; +}; + +const NotesContextMenu = observer(({ appState }: Props) => { + const contextMenuRef = useRef(); + const [closeOnBlur] = useCloseOnBlur( + contextMenuRef, + (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]); + + return appState.notes.contextMenuOpen ? ( +
+ +
+ ) : null; +}); + +export const NotesContextMenuDirective = toDirective(NotesContextMenu); diff --git a/app/assets/javascripts/components/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions.tsx new file mode 100644 index 000000000..5bca358e1 --- /dev/null +++ b/app/assets/javascripts/components/NotesOptions.tsx @@ -0,0 +1,298 @@ +import { AppState } from '@/ui_models/app_state'; +import { Icon } from './Icon'; +import { Switch } from './Switch'; +import { observer } from 'mobx-react-lite'; +import { useRef, useState, useEffect } from 'preact/hooks'; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from '@reach/disclosure'; +import { SNNote } from '@standardnotes/snjs/dist/@types'; + +type Props = { + appState: AppState; + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; + onSubmenuChange?: (submenuOpen: boolean) => void; +}; + +export const NotesOptions = observer( + ({ appState, closeOnBlur, onSubmenuChange }: Props) => { + const [tagsMenuOpen, setTagsMenuOpen] = useState(false); + const [tagsMenuPosition, setTagsMenuPosition] = useState({ + top: 0, + right: 0, + }); + const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState('auto'); + + const toggleOn = (condition: (note: SNNote) => boolean) => { + const notesMatchingAttribute = notes.filter(condition); + const notesNotMatchingAttribute = notes.filter( + (note) => !condition(note) + ); + return notesMatchingAttribute.length > notesNotMatchingAttribute.length; + }; + + const notes = Object.values(appState.notes.selectedNotes); + const hidePreviews = toggleOn((note) => note.hidePreview); + const locked = toggleOn((note) => note.locked); + const protect = toggleOn((note) => note.protected); + const archived = notes.some((note) => note.archived); + const unarchived = notes.some((note) => !note.archived); + const trashed = notes.some((note) => note.trashed); + const notTrashed = notes.some((note) => !note.trashed); + const pinned = notes.some((note) => note.pinned); + const unpinned = notes.some((note) => !note.pinned); + + const tagsButtonRef = useRef(); + + 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) { + onSubmenuChange(tagsMenuOpen); + } + }, [tagsMenuOpen, onSubmenuChange]); + + const openTagsMenu = () => { + const defaultFontSize = window.getComputedStyle( + document.documentElement + ).fontSize; + const maxTagsMenuSize = parseFloat(defaultFontSize) * 20; + const { clientWidth, clientHeight } = document.body; + const buttonRect = tagsButtonRef.current.getBoundingClientRect(); + const { offsetTop, offsetWidth } = tagsButtonRef.current; + const footerHeight = 32; + + if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) { + setTagsMenuMaxHeight(clientHeight - buttonRect.top - footerHeight - 2); + } + + setTagsMenuPosition({ + top: offsetTop, + right: + buttonRect.right + maxTagsMenuSize > + clientWidth + ? offsetWidth + : -offsetWidth, + }); + + setTagsMenuOpen(!tagsMenuOpen); + }; + + return ( + <> + { + appState.notes.setLockSelectedNotes(!locked); + }} + > + + + Prevent editing + + + { + appState.notes.setHideSelectedNotePreviews(!hidePreviews); + }} + > + + + Show preview + + + { + appState.notes.setProtectSelectedNotes(!protect); + }} + > + + + Protect + + +
+ {appState.tags.tagsCount > 0 && ( + + { + if (event.key === 'Escape') { + setTagsMenuOpen(false); + } + }} + onBlur={closeOnBlur} + ref={tagsButtonRef} + className={`${buttonClass} py-1.5 justify-between`} + > +
+ + {'Add tag'} +
+ +
+ { + if (event.key === 'Escape') { + setTagsMenuOpen(false); + tagsButtonRef.current.focus(); + } + }} + style={{ + ...tagsMenuPosition, + maxHeight: tagsMenuMaxHeight, + }} + className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 max-h-80 overflow-y-scroll" + > + {appState.tags.tags.map((tag) => ( + + ))} + +
+ )} + {unpinned && ( + + )} + {pinned && ( + + )} + {unarchived && ( + + )} + {archived && ( + + )} + {notTrashed && ( + + )} + {trashed && ( + <> + + + + + )} + + ); + } +); diff --git a/app/assets/javascripts/components/NotesOptionsPanel.tsx b/app/assets/javascripts/components/NotesOptionsPanel.tsx new file mode 100644 index 000000000..886eda79f --- /dev/null +++ b/app/assets/javascripts/components/NotesOptionsPanel.tsx @@ -0,0 +1,88 @@ +import { AppState } from '@/ui_models/app_state'; +import { Icon } from './Icon'; +import VisuallyHidden from '@reach/visually-hidden'; +import { toDirective, useCloseOnBlur } from './utils'; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from '@reach/disclosure'; +import { Portal } from '@reach/portal'; +import { useRef, useState } from 'preact/hooks'; +import { observer } from 'mobx-react-lite'; +import { NotesOptions } from './NotesOptions'; + +type Props = { + appState: AppState; +}; + +export const NotesOptionsPanel = observer(({ appState }: Props) => { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ + top: 0, + right: 0, + }); + const buttonRef = useRef(); + const panelRef = useRef(); + const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen); + const [submenuOpen, setSubmenuOpen] = useState(false); + + const onSubmenuChange = (open: boolean) => { + setSubmenuOpen(open); + }; + + return ( + { + const rect = buttonRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, + }); + setOpen(!open); + }} + > + { + if (event.key === 'Escape' && !submenuOpen) { + setOpen(false); + } + }} + onBlur={closeOnBlur} + ref={buttonRef} + className="sn-icon-button" + > + Actions + + + +
+ { + if (event.key === 'Escape' && !submenuOpen) { + setOpen(false); + buttonRef.current.focus(); + } + }} + ref={panelRef} + style={{ + ...position, + }} + className="sn-dropdown flex flex-col py-2" + > + {open && ( + + )} + +
+
+
+ ); +}); + +export const NotesOptionsPanelDirective = toDirective(NotesOptionsPanel); diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index 2c24e6c44..7edad0eb2 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -1,5 +1,6 @@ import { AppState } from '@/ui_models/app_state'; -import { toDirective, useAutorunValue } from './utils'; +import { Icon } from './Icon'; +import { toDirective, useCloseOnBlur } from './utils'; import { useRef, useState } from 'preact/hooks'; import { WebApplication } from '@/ui_models/application'; import VisuallyHidden from '@reach/visually-hidden'; @@ -8,56 +9,35 @@ import { DisclosureButton, DisclosurePanel, } from '@reach/disclosure'; -import { FocusEvent } from 'react'; import { Switch } from './Switch'; -import TuneIcon from '../../icons/ic_tune.svg'; +import { observer } from 'mobx-react-lite'; type Props = { appState: AppState; application: WebApplication; }; -function SearchOptions({ appState }: Props) { +const SearchOptions = observer(({ appState }: Props) => { const { searchOptions } = appState; const { includeProtectedContents, includeArchived, includeTrashed, - } = useAutorunValue( - () => ({ - includeProtectedContents: searchOptions.includeProtectedContents, - includeArchived: searchOptions.includeArchived, - includeTrashed: searchOptions.includeTrashed, - }), - [searchOptions] - ); - - const [ - togglingIncludeProtectedContents, - setTogglingIncludeProtectedContents, - ] = useState(false); - - async function toggleIncludeProtectedContents() { - setTogglingIncludeProtectedContents(true); - try { - await searchOptions.toggleIncludeProtectedContents(); - } finally { - setTogglingIncludeProtectedContents(false); - } - } + } = searchOptions; const [open, setOpen] = useState(false); const [optionsPanelTop, setOptionsPanelTop] = useState(0); const buttonRef = useRef(); const panelRef = useRef(); + const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen); - function closeOnBlur(event: FocusEvent) { - if ( - !togglingIncludeProtectedContents && - !panelRef.current.contains(event.relatedTarget as Node) - ) { - setOpen(false); + async function toggleIncludeProtectedContents() { + setLockCloseOnBlur(true); + try { + await searchOptions.toggleIncludeProtectedContents(); + } finally { + setLockCloseOnBlur(false); } } @@ -73,10 +53,10 @@ function SearchOptions({ appState }: Props) { Search options - + Include protected contents

Include archived notes

); -} +}); export const SearchOptionsDirective = toDirective(SearchOptions); diff --git a/app/assets/javascripts/components/SessionsModal.tsx b/app/assets/javascripts/components/SessionsModal.tsx index b8c82daf4..6079e3aca 100644 --- a/app/assets/javascripts/components/SessionsModal.tsx +++ b/app/assets/javascripts/components/SessionsModal.tsx @@ -15,8 +15,9 @@ import { AlertDialogDescription, AlertDialogLabel, } from '@reach/alert-dialog'; -import { toDirective, useAutorunValue } from './utils'; +import { toDirective } from './utils'; import { WebApplication } from '@/ui_models/application'; +import { observer } from 'mobx-react-lite'; type Session = RemoteSession & { revoking?: true; @@ -171,7 +172,7 @@ const SessionsModal: FunctionComponent<{ {formatter.format(session.updated_at)}