diff --git a/app/assets/icons/ic-archive.svg b/app/assets/icons/ic-archive.svg new file mode 100644 index 000000000..baebb7932 --- /dev/null +++ b/app/assets/icons/ic-archive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/icons/ic_close.svg b/app/assets/icons/ic-close.svg similarity index 100% rename from app/assets/icons/ic_close.svg rename to app/assets/icons/ic-close.svg diff --git a/app/assets/icons/ic-more.svg b/app/assets/icons/ic-more.svg new file mode 100644 index 000000000..7512c412f --- /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..2ebdc93de --- /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..aff807dae --- /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..c19d600d8 --- /dev/null +++ b/app/assets/icons/ic-pin.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..4fcb62677 --- /dev/null +++ b/app/assets/icons/ic-text-rich.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..4bbb76144 --- /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 100% rename from app/assets/icons/ic_tune.svg rename to app/assets/icons/ic-tune.svg diff --git a/app/assets/icons/ic-unarchive.svg b/app/assets/icons/ic-unarchive.svg new file mode 100644 index 000000000..0506ec471 --- /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 a2bcbd446..1db921aef 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -59,6 +59,7 @@ import { SessionsModalDirective } from './components/SessionsModal'; import { NoAccountWarningDirective } from './components/NoAccountWarning'; import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNoteWarning'; import { SearchOptionsDirective } from './components/SearchOptions'; +import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; function reloadHiddenFirefoxTab(): boolean { /** @@ -147,7 +148,8 @@ const startApplication: StartApplication = async function startApplication( .directive('sessionsModal', SessionsModalDirective) .directive('noAccountWarning', NoAccountWarningDirective) .directive('protectedNotePanel', NoProtectionsdNoteWarningDirective) - .directive('searchOptions', SearchOptionsDirective); + .directive('searchOptions', SearchOptionsDirective) + .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective); // Filters angular.module('app').filter('trusted', ['$sce', trusted]); diff --git a/app/assets/javascripts/components/MultipleSelectedNotes.tsx b/app/assets/javascripts/components/MultipleSelectedNotes.tsx new file mode 100644 index 000000000..d28da4a1d --- /dev/null +++ b/app/assets/javascripts/components/MultipleSelectedNotes.tsx @@ -0,0 +1,192 @@ +import { AppState } from '@/ui_models/app_state'; +import VisuallyHidden from '@reach/visually-hidden'; +import { toDirective, useCloseOnBlur } from './utils'; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from '@reach/disclosure'; +import MoreIcon from '../../icons/ic-more.svg'; +import PencilOffIcon from '../../icons/ic-pencil-off.svg'; +import RichTextIcon from '../../icons/ic-text-rich.svg'; +import TrashIcon from '../../icons/ic-trash.svg'; +import PinIcon from '../../icons/ic-pin.svg'; +import UnpinIcon from '../../icons/ic-pin-off.svg'; +import ArchiveIcon from '../../icons/ic-archive.svg'; +import UnarchiveIcon from '../../icons/ic-unarchive.svg'; +import NotesIcon from '../../icons/il-notes.svg'; +import { useRef, useState } from 'preact/hooks'; +import { Switch } from './Switch'; +import { observer } from 'mobx-react-lite'; +import { SNApplication } from '@standardnotes/snjs'; + +type Props = { + application: SNApplication; + appState: AppState; +}; + +const MultipleSelectedNotes = observer(({ appState }: Props) => { + const count = appState.notes.selectedNotesCount; + const [open, setOpen] = useState(false); + const [optionsPanelPosition, setOptionsPanelPosition] = useState({ + top: 0, + right: 0, + }); + const buttonRef = useRef(); + const panelRef = useRef(); + const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen); + + const notes = Object.values(appState.notes.selectedNotes); + const hidePreviews = !notes.some((note) => !note.hidePreview); + const locked = !notes.some((note) => !note.locked); + const archived = !notes.some((note) => !note.archived); + const trashed = !notes.some((note) => !note.trashed); + const pinned = !notes.some((note) => !note.pinned); + + const iconClass = 'fill-current color-neutral mr-2.5'; + const buttonClass = + 'flex items-center border-0 capitalize focus:inner-ring-info ' + + 'cursor-pointer hover:bg-contrast color-text bg-transparent h-10 px-3 ' + + 'text-left'; + + return ( +
+
+

{count} selected notes

+ { + const rect = buttonRef.current.getBoundingClientRect(); + setOptionsPanelPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, + }); + setOpen((prevOpen) => !prevOpen); + }} + > + { + if (event.key === 'Escape') { + setOpen(false); + } + }} + onBlur={closeOnBlur} + ref={buttonRef} + className={ + 'bg-transparent border-solid border-1 border-gray-300 ' + + 'cursor-pointer w-32px h-32px rounded-full p-0 ' + + 'flex justify-center items-center' + } + > + Actions + + + { + if (event.key === 'Escape') { + setOpen(false); + buttonRef.current.focus(); + } + }} + ref={panelRef} + style={{ + ...optionsPanelPosition, + }} + className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 select-none" + > + { + appState.notes.setLockSelectedNotes(!locked); + }} + > + + + Prevent editing + + + { + appState.notes.setHideSelectedNotePreviews(!hidePreviews); + }} + > + + + Show Preview + + +
+ + + +
+
+
+
+ +

+ {count} selected notes +

+

+ Actions will be performed on all selected notes. +

+
+
+ ); +}); + +export const MultipleSelectedNotesDirective = toDirective( + MultipleSelectedNotes +); diff --git a/app/assets/javascripts/components/NoAccountWarning.tsx b/app/assets/javascripts/components/NoAccountWarning.tsx index 680393604..0b24927e7 100644 --- a/app/assets/javascripts/components/NoAccountWarning.tsx +++ b/app/assets/javascripts/components/NoAccountWarning.tsx @@ -1,13 +1,12 @@ -import { toDirective, useAutorunValue } from './utils'; -import Close from '../../icons/ic_close.svg'; +import { toDirective } from './utils'; +import Close from '../../icons/ic-close.svg'; import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; type Props = { appState: AppState }; -function NoAccountWarning({ appState }: Props) { - const canShow = useAutorunValue(() => appState.noAccountWarning.show, [ - appState, - ]); +const NoAccountWarning = observer(({ appState }: Props) => { + const canShow = appState.noAccountWarning.show; if (!canShow) { return null; } @@ -39,6 +38,6 @@ function NoAccountWarning({ appState }: Props) { ); -} +}); export const NoAccountWarningDirective = toDirective(NoAccountWarning); diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx index 2c24e6c44..91d7f6409 100644 --- a/app/assets/javascripts/components/SearchOptions.tsx +++ b/app/assets/javascripts/components/SearchOptions.tsx @@ -1,5 +1,5 @@ import { AppState } from '@/ui_models/app_state'; -import { toDirective, useAutorunValue } from './utils'; +import { toDirective, useCloseOnBlur } from './utils'; import { useRef, useState } from 'preact/hooks'; import { WebApplication } from '@/ui_models/application'; import VisuallyHidden from '@reach/visually-hidden'; @@ -10,54 +10,35 @@ import { } from '@reach/disclosure'; import { FocusEvent } from 'react'; import { Switch } from './Switch'; -import TuneIcon from '../../icons/ic_tune.svg'; +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); } } @@ -86,6 +67,7 @@ function SearchOptions({ appState }: Props) { className="sn-dropdown sn-dropdown-anchor-right grid gap-2 py-2" > 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..facaa3dc4 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; @@ -242,16 +243,12 @@ const SessionsModal: FunctionComponent<{ const Sessions: FunctionComponent<{ appState: AppState; application: WebApplication; -}> = ({ appState, application }) => { - const showModal = useAutorunValue(() => appState.isSessionsModalVisible, [ - appState, - ]); - - if (showModal) { +}> = observer(({ appState, application }) => { + if (appState.isSessionsModalVisible) { return ; } else { return null; } -}; +}); export const SessionsModalDirective = toDirective(Sessions); diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx index 44dbf7dc5..1f4185021 100644 --- a/app/assets/javascripts/components/Switch.tsx +++ b/app/assets/javascripts/components/Switch.tsx @@ -11,6 +11,7 @@ import '@reach/checkbox/styles.css'; export type SwitchProps = HTMLProps & { checked?: boolean; onChange: (checked: boolean) => void; + className?: string; children: ComponentChildren; }; @@ -19,8 +20,9 @@ export const Switch: FunctionalComponent = ( ) => { const [checkedState, setChecked] = useState(props.checked || false); const checked = props.checked ?? checkedState; + const className = props.className ?? ''; return ( -