diff --git a/app/assets/icons/ic-arrows-sort-down.svg b/app/assets/icons/ic-arrows-sort-down.svg new file mode 100644 index 000000000..b7793bb25 --- /dev/null +++ b/app/assets/icons/ic-arrows-sort-down.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/assets/icons/ic-arrows-sort-up.svg b/app/assets/icons/ic-arrows-sort-up.svg new file mode 100644 index 000000000..ddb85688f --- /dev/null +++ b/app/assets/icons/ic-arrows-sort-up.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 09ae253cb..ddd208fab 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -64,6 +64,7 @@ import { IconDirective } from './components/Icon'; import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; import { PreferencesDirective } from './preferences'; import { AppVersion, IsWebPlatform } from '@/version'; +import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; import { PurchaseFlowDirective } from './purchaseFlow'; import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu'; @@ -164,6 +165,7 @@ const startApplication: StartApplication = async function startApplication( .directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective) .directive('notesContextMenu', NotesContextMenuDirective) .directive('notesOptionsPanel', NotesOptionsPanelDirective) + .directive('notesListOptionsMenu', NotesListOptionsDirective) .directive('icon', IconDirective) .directive('noteTagsContainer', NoteTagsContainerDirective) .directive('preferences', PreferencesDirective) diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index 051ac9c19..0ccc69b60 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -48,11 +48,15 @@ import ServerIcon from '../../icons/ic-server.svg'; import EyeIcon from '../../icons/ic-eye.svg'; import EyeOffIcon from '../../icons/ic-eye-off.svg'; import LockIcon from '../../icons/ic-lock.svg'; +import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg'; +import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg'; import { toDirective } from './utils'; import { FunctionalComponent } from 'preact'; const ICONS = { + 'arrows-sort-up': ArrowsSortUpIcon, + 'arrows-sort-down': ArrowsSortDownIcon, lock: LockIcon, eye: EyeIcon, 'eye-off': EyeOffIcon, diff --git a/app/assets/javascripts/components/NotesListOptionsMenu.tsx b/app/assets/javascripts/components/NotesListOptionsMenu.tsx new file mode 100644 index 000000000..1d22e14f2 --- /dev/null +++ b/app/assets/javascripts/components/NotesListOptionsMenu.tsx @@ -0,0 +1,244 @@ +import { WebApplication } from '@/ui_models/application'; +import { CollectionSort, PrefKey } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useState } from 'preact/hooks'; +import { Icon } from './Icon'; +import { Menu } from './menu/Menu'; +import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem'; +import { toDirective } from './utils'; + +type Props = { + application: WebApplication; + setShowMenuFalse: () => void; +}; + +export const NotesListOptionsMenu: FunctionComponent = observer( + ({ setShowMenuFalse, application }) => { + const menuClassName = + 'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \ +border-1 border-solid border-gray-300 text-sm z-index-dropdown-menu \ +flex flex-col py-2 bottom-0 left-2 absolute'; + const [sortBy, setSortBy] = useState(() => + application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt) + ); + const [sortReverse, setSortReverse] = useState(() => + application.getPreference(PrefKey.SortNotesReverse, false) + ); + const [hidePreview, setHidePreview] = useState(() => + application.getPreference(PrefKey.NotesHideNotePreview, false) + ); + const [hideDate, setHideDate] = useState(() => + application.getPreference(PrefKey.NotesHideDate, false) + ); + const [hideTags, setHideTags] = useState(() => + application.getPreference(PrefKey.NotesHideTags, true) + ); + const [hidePinned, setHidePinned] = useState(() => + application.getPreference(PrefKey.NotesHidePinned, false) + ); + const [showArchived, setShowArchived] = useState(() => + application.getPreference(PrefKey.NotesShowArchived, false) + ); + const [showTrashed, setShowTrashed] = useState(() => + application.getPreference(PrefKey.NotesShowTrashed, false) + ); + const [hideProtected, setHideProtected] = useState(() => + application.getPreference(PrefKey.NotesHideProtected, false) + ); + + const toggleSortReverse = () => { + application.setPreference(PrefKey.SortNotesReverse, !sortReverse); + setSortReverse(!sortReverse); + }; + + const toggleSortBy = (sort: CollectionSort) => { + if (sortBy === sort) { + toggleSortReverse(); + } else { + setSortBy(sort); + application.setPreference(PrefKey.SortNotesBy, sort); + } + }; + + const toggleSortByDateModified = () => { + toggleSortBy(CollectionSort.UpdatedAt); + }; + + const toggleSortByCreationDate = () => { + toggleSortBy(CollectionSort.CreatedAt); + }; + + const toggleSortByTitle = () => { + toggleSortBy(CollectionSort.Title); + }; + + const toggleHidePreview = () => { + setHidePreview(!hidePreview); + application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview); + }; + + const toggleHideDate = () => { + setHideDate(!hideDate); + application.setPreference(PrefKey.NotesHideDate, !hideDate); + }; + + const toggleHideTags = () => { + setHideTags(!hideTags); + application.setPreference(PrefKey.NotesHideTags, !hideTags); + }; + + const toggleHidePinned = () => { + setHidePinned(!hidePinned); + application.setPreference(PrefKey.NotesHidePinned, !hidePinned); + }; + + const toggleShowArchived = () => { + setShowArchived(!showArchived); + application.setPreference(PrefKey.NotesShowArchived, !showArchived); + }; + + const toggleShowTrashed = () => { + setShowTrashed(!showTrashed); + application.setPreference(PrefKey.NotesShowTrashed, !showTrashed); + }; + + const toggleHideProtected = () => { + setHideProtected(!hideProtected); + application.setPreference(PrefKey.NotesHideProtected, !hideProtected); + }; + + return ( + + + + Sort by + + + + Date modified + {sortBy === CollectionSort.UpdatedAt ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} + + + + + Creation date + {sortBy === CollectionSort.CreatedAt ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} + + + + + Title + {sortBy === CollectionSort.Title ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} + + + + + View + + + Show note preview + + + Show date + + + Show tags + + + + Other + + + Show pinned notes + + + Show protected notes + + + Show archived notes + + + Show trashed notes + + + + ); + } +); + +export const NotesListOptionsDirective = toDirective( + NotesListOptionsMenu, + { + setShowMenuFalse: '=', + state: '&', + } +); diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx index 49da00dc9..06b5a2881 100644 --- a/app/assets/javascripts/components/Switch.tsx +++ b/app/assets/javascripts/components/Switch.tsx @@ -13,6 +13,7 @@ export type SwitchProps = HTMLProps & { onChange: (checked: boolean) => void; className?: string; children?: ComponentChildren; + role?: string; }; export const Switch: FunctionalComponent = ( @@ -24,6 +25,7 @@ export const Switch: FunctionalComponent = ( return ( {props.children} void; +}; + +export const Menu: FunctionComponent = forwardRef( + ( + { children, className = '', style, a11yLabel, closeMenu }, + ref: Ref + ) => { + const [currentIndex, setCurrentIndex] = useState(0); + const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const handleKeyDown: JSXInternal.KeyboardEventHandler = ( + event + ) => { + switch (event.key) { + case 'Home': + setCurrentIndex(0); + break; + case 'End': + setCurrentIndex( + menuItemRefs.current.length ? menuItemRefs.current.length - 1 : 0 + ); + break; + case 'ArrowDown': + setCurrentIndex((index) => { + if (index + 1 < menuItemRefs.current.length) { + return index + 1; + } else { + return 0; + } + }); + break; + case 'ArrowUp': + setCurrentIndex((index) => { + if (index - 1 > -1) { + return index - 1; + } else { + return menuItemRefs.current.length - 1; + } + }); + break; + case 'Escape': + closeMenu(); + break; + } + }; + + useEffect(() => { + if (menuItemRefs.current[currentIndex]) { + menuItemRefs.current[currentIndex]?.focus(); + } + }, [currentIndex]); + + const pushRefToArray: RefCallback = (instance) => { + if (instance && instance.children) { + Array.from(instance.children).forEach((child) => { + if ( + child.getAttribute('role')?.includes('menuitem') && + !menuItemRefs.current.includes(child as HTMLButtonElement) + ) { + menuItemRefs.current.push(child as HTMLButtonElement); + } + }); + } + }; + + const mapMenuItems = ( + child: ComponentChild, + index: number, + array: ComponentChild[] + ) => { + if (!child) return; + + const _child = child as VNode; + const isFirstMenuItem = + index === + array.findIndex((child) => (child as VNode).type === MenuItem); + + const hasMultipleItems = Array.isArray(_child.props.children) + ? Array.from(_child.props.children as ComponentChild[]).some( + (child) => (child as VNode).type === MenuItem + ) + : false; + + const items = hasMultipleItems + ? [...(_child.props.children as ComponentChild[])] + : [_child]; + + return items.map((child) => { + return ( + + {child} + + ); + }); + }; + + return ( + + {Array.isArray(children) ? children.map(mapMenuItems) : null} + + ); + } +); diff --git a/app/assets/javascripts/components/menu/MenuItem.tsx b/app/assets/javascripts/components/menu/MenuItem.tsx new file mode 100644 index 000000000..3bae9bff9 --- /dev/null +++ b/app/assets/javascripts/components/menu/MenuItem.tsx @@ -0,0 +1,111 @@ +import { + ComponentChild, + ComponentChildren, + FunctionComponent, + VNode, +} from 'preact'; +import { forwardRef, Ref } from 'preact/compat'; +import { JSXInternal } from 'preact/src/jsx'; +import { Icon, IconType } from '../Icon'; +import { Switch, SwitchProps } from '../Switch'; + +export enum MenuItemType { + IconButton, + RadioButton, + SwitchButton, +} + +type MenuItemProps = { + type: MenuItemType; + children: ComponentChildren; + onClick?: JSXInternal.MouseEventHandler; + onChange?: SwitchProps['onChange']; + className?: string; + checked?: boolean; + icon?: IconType; + iconClassName?: string; + tabIndex?: number; +}; + +export const MenuItem: FunctionComponent = forwardRef( + ( + { + children, + onClick, + onChange, + className = '', + type, + checked, + icon, + iconClassName, + tabIndex, + }, + ref: Ref + ) => { + return type === MenuItemType.SwitchButton && + typeof onChange === 'function' ? ( + + {children} + + ) : ( + + {type === MenuItemType.IconButton && icon ? ( + + ) : null} + {type === MenuItemType.RadioButton && typeof checked === 'boolean' ? ( + + ) : null} + {children} + + ); + } +); + +export const MenuItemSeparator: FunctionComponent = () => ( + +); + +type ListElementProps = { + isFirstMenuItem: boolean; + children: ComponentChildren; +}; + +export const MenuItemListElement: FunctionComponent = + forwardRef(({ children, isFirstMenuItem }, ref: Ref) => { + const child = children as VNode; + + return ( + + {{ + ...child, + props: { + ...(child.props ? { ...child.props } : {}), + ...(child.type === MenuItem + ? { + tabIndex: isFirstMenuItem ? 0 : -1, + } + : {}), + }, + }} + + ); + }); diff --git a/app/assets/javascripts/views/notes/notes-view.pug b/app/assets/javascripts/views/notes/notes-view.pug index c05a8cd59..d373eb36a 100644 --- a/app/assets/javascripts/views/notes/notes-view.pug +++ b/app/assets/javascripts/views/notes/notes-view.pug @@ -49,71 +49,12 @@ | Options .sk-app-bar-item-column .sk-sublabel {{self.optionsSubtitle()}} - #notes-options-menu.sk-menu-panel.dropdown-menu( - ng-show='self.state.mutable.showMenu' - ) - .sk-menu-panel-header - .sk-menu-panel-header-title Sort By - a.info.sk-h5(ng-click='self.toggleReverseSort()') - | {{self.state.sortReverse === true ? 'Disable Reverse Sort' : 'Enable Reverse Sort'}} - menu-row( - action="self.selectedMenuItem(); self.selectedSortByCreated()" - circle="self.state.sortBy == 'created_at' && 'success'" - desc="'Sort notes by newest first'" - label="'Date Added'" - ) - menu-row( - action="self.selectedMenuItem(); self.selectedSortByUpdated()" - circle="self.state.sortBy == 'userModifiedDate' && 'success'" - desc="'Sort notes with the most recently updated first'" - label="'Date Modified'" - ) - menu-row( - action="self.selectedMenuItem(); self.selectedSortByTitle()" - circle="self.state.sortBy == 'title' && 'success'" - desc="'Sort notes alphabetically by their title'" - label="'Title'" - ) - .sk-menu-panel-section - .sk-menu-panel-header - .sk-menu-panel-header-title Display - menu-row( - action="self.selectedMenuItem(); self.togglePrefKey('showArchived')" - circle="self.state.showArchived ? 'success' : 'danger'" - desc=`'Archived notes are usually hidden. - You can explicitly show them with this option.'` - faded="!self.state.showArchived" - label="'Archived Notes'" - ) - menu-row( - action="self.selectedMenuItem(); self.togglePrefKey('hidePinned')" - circle="self.state.hidePinned ? 'danger' : 'success'" - desc=`'Pinned notes always appear on top. You can hide them temporarily - with this option so you can focus on other notes in the list.'` - faded="self.state.hidePinned" - label="'Pinned Notes'" - ) - menu-row( - action="self.selectedMenuItem(); self.togglePrefKey('hideNotePreview')" - circle="self.state.hideNotePreview ? 'danger' : 'success'" - desc="'Hide the note preview for a more condensed list of notes'" - faded="self.state.hideNotePreview" - label="'Note Preview'" - ) - menu-row( - action="self.selectedMenuItem(); self.togglePrefKey('hideDate')" - circle="self.state.hideDate ? 'danger' : 'success'" - desc="'Hide the date displayed in each row'" - faded="self.state.hideDate" - label="'Date'" - ) - menu-row( - action="self.selectedMenuItem(); self.togglePrefKey('hideTags')" - circle="self.state.hideTags ? 'danger' : 'success'" - desc="'Hide the list of tags associated with each note'" - faded="self.state.hideTags" - label="'Tags'" - ) + notes-list-options-menu( + ng-if='self.state.mutable.showMenu' + app-state='self.appState' + application='self.application' + set-show-menu-false='self.setShowMenuFalse' + ) p.empty-notes-list.faded( ng-if="self.state.completedFullSync && !self.state.renderedNotes.length" ) No notes. diff --git a/app/assets/javascripts/views/notes/notes_view.ts b/app/assets/javascripts/views/notes/notes_view.ts index 6ab6e3348..d8b69852b 100644 --- a/app/assets/javascripts/views/notes/notes_view.ts +++ b/app/assets/javascripts/views/notes/notes_view.ts @@ -96,6 +96,7 @@ class NotesViewCtrl extends PureViewCtrl { this.onWindowResize = this.onWindowResize.bind(this); this.onPanelResize = this.onPanelResize.bind(this); this.onPanelWidthEvent = this.onPanelWidthEvent.bind(this); + this.setShowMenuFalse = this.setShowMenuFalse.bind(this); window.addEventListener('resize', this.onWindowResize, true); this.registerKeyboardShortcuts(); this.autorun(async () => { @@ -441,7 +442,7 @@ class NotesViewCtrl extends PureViewCtrl { includeTrashed = this.state.searchOptions.includeTrashed; } else { includeArchived = this.state.showArchived ?? false; - includeTrashed = false; + includeTrashed = this.state.showTrashed ?? false; } const criteria = NotesDisplayCriteria.Create({ @@ -451,6 +452,7 @@ class NotesViewCtrl extends PureViewCtrl { includeArchived, includeTrashed, includePinned: !this.state.hidePinned, + includeProtected: !this.state.hideProtected, searchQuery: { query: searchText, includeProtectedNoteText: this.state.searchOptions.includeProtectedContents @@ -554,10 +556,18 @@ class NotesViewCtrl extends PureViewCtrl { PrefKey.NotesShowArchived, false ); + viewOptions.showTrashed = this.application.getPreference( + PrefKey.NotesShowTrashed, + false + ) as boolean; viewOptions.hidePinned = this.application.getPreference( PrefKey.NotesHidePinned, false ); + viewOptions.hideProtected = this.application.getPreference( + PrefKey.NotesHideProtected, + false + ); viewOptions.hideNotePreview = this.application.getPreference( PrefKey.NotesHideNotePreview, false @@ -576,6 +586,8 @@ class NotesViewCtrl extends PureViewCtrl { viewOptions.sortReverse !== state.sortReverse || viewOptions.hidePinned !== state.hidePinned || viewOptions.showArchived !== state.showArchived || + viewOptions.showTrashed !== state.showTrashed || + viewOptions.hideProtected !== state.hideProtected || viewOptions.hideTags !== state.hideTags ); await this.setNotesState({ @@ -672,9 +684,15 @@ class NotesViewCtrl extends PureViewCtrl { if (this.state.showArchived) { base += " | + Archived"; } + if (this.state.showTrashed) { + base += " | + Trashed"; + } if (this.state.hidePinned) { base += " | – Pinned"; } + if (this.state.hideProtected) { + base += " | – Protected"; + } if (this.state.sortReverse) { base += " | Reversed"; } diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index aa520fac6..259767b39 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -258,6 +258,14 @@ margin-bottom: 0.5rem; } +.max-w-3\/4 { + max-width: 75%; +} + +.max-w-72 { + max-width: 18rem; +} + .mb-4 { margin-bottom: 1rem; } @@ -306,6 +314,10 @@ min-width: 3.75rem; } +.min-w-70 { + min-width: 17.5rem; +} + .min-w-24 { min-width: 6rem; } @@ -582,6 +594,10 @@ top: 50%; } +.left-2 { + left: 0.5rem; +} + .left-1\/2 { left: 50%; } @@ -615,3 +631,7 @@ .focus\:bg-info-backdrop:focus { background-color: var(--sn-stylekit-info-backdrop-color); } + +.list-style-none { + list-style-type: none; +}