feat: Re-enabled multiple selection on mobile, with improved UI (#2609)

This commit is contained in:
Aman Harwara
2023-10-27 17:54:51 +05:30
committed by GitHub
parent 66a6c61ffa
commit c8dec9bfec
7 changed files with 172 additions and 31 deletions

View File

@@ -33,6 +33,9 @@ import EmptyFilesView from './EmptyFilesView'
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
import { usePaneSwipeGesture } from '../Panes/usePaneGesture'
import { mergeRefs } from '@/Hooks/mergeRefs'
import Icon from '../Icon/Icon'
import MobileMultiSelectionToolbar from './MobileMultiSelectionToolbar'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
type Props = {
application: WebApplication
@@ -53,6 +56,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
noAccountWarningController,
searchOptionsController,
linkingController,
notesController,
} = application
const { setPaneLayout, panes } = useResponsiveAppPane()
@@ -285,7 +289,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
aria-label={'Notes & Files'}
ref={mergeRefs([innerRef, setElement])}
>
{isMobileScreen && (
{isMobileScreen && !itemListController.isMultipleSelectionMode && (
<FloatingAddButton onClick={addNewItem} label={addButtonLabel} style={dailyMode ? 'danger' : 'info'} />
)}
<div id="items-title-bar" className="section-title-bar border-b border-solid border-border">
@@ -319,6 +323,33 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
/>
</div>
</div>
{itemListController.isMultipleSelectionMode && (
<div className="flex items-center border-b border-l-2 border-border border-l-transparent py-2.5 pr-4">
<div className="px-4">
<StyledTooltip label="Select all items" showOnHover showOnMobile>
<button
className="ml-auto rounded border border-border p-1 hover:bg-contrast"
onClick={() => {
itemListController.selectAll()
}}
>
<Icon type="select-all" size="medium" />
</button>
</StyledTooltip>
</div>
<div className="text-base font-semibold md:text-sm">{itemListController.selectedItemsCount} selected</div>
<StyledTooltip label="Cancel multiple selection" showOnHover showOnMobile>
<button
className="ml-auto rounded border border-border p-1 hover:bg-contrast"
onClick={() => {
itemListController.cancelMultipleSelection()
}}
>
<Icon type="close" size="medium" />
</button>
</StyledTooltip>
</div>
)}
{selectedAsTag && dailyMode && (
<DailyContentList
items={items}
@@ -350,6 +381,9 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
/>
)
) : null}
{isMobileScreen && itemListController.isMultipleSelectionMode && (
<MobileMultiSelectionToolbar notesController={notesController} />
)}
<div className="absolute bottom-0 h-safe-bottom w-full" />
{children}
</div>

View File

@@ -0,0 +1,43 @@
import { NotesController } from '@/Controllers/NotesController/NotesController'
import Icon from '../Icon/Icon'
type Props = {
notesController: NotesController
}
const MobileMultiSelectionToolbar = ({ notesController }: Props) => {
const { selectedNotes } = notesController
const archived = selectedNotes.some((note) => note.archived)
return (
<div className="flex w-full bg-contrast pb-safe-bottom">
<button
className="flex-grow px-2 py-3 active:bg-passive-3"
onClick={() => notesController.togglePinSelectedNotes()}
>
<Icon type="pin" className="mx-auto text-info" size="large" />
</button>
<button
className="flex-grow px-2 py-3 active:bg-passive-3"
onClick={() => notesController.toggleArchiveSelectedNotes().catch(console.error)}
>
<Icon type={archived ? 'unarchive' : 'archive'} className="mx-auto text-info" size="large" />
</button>
<button
className="flex-grow px-2 py-3 active:bg-passive-3"
onClick={() => notesController.setTrashSelectedNotes(true).catch(console.error)}
>
<Icon type="trash" className="mx-auto text-info" size="large" />
</button>
<button
className="flex-grow px-2 py-3 active:bg-passive-3"
onClick={() => notesController.setContextMenuOpen(true)}
>
<Icon type="more" className="mx-auto text-info" size="large" />
</button>
</div>
)
}
export default MobileMultiSelectionToolbar

View File

@@ -1,6 +1,6 @@
import { isFile, SNNote } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useRef } from 'react'
import { FunctionComponent, MouseEvent, useCallback, useRef } from 'react'
import Icon from '@/Components/Icon/Icon'
import ListItemConflictIndicator from './ListItemConflictIndicator'
import ListItemFlagIcons from './ListItemFlagIcons'
@@ -17,6 +17,7 @@ import ListItemVaultInfo from './ListItemVaultInfo'
import { NoteDragDataFormat } from '../Tags/DragNDrop'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import useItem from '@/Hooks/useItem'
import CheckIndicator from '../Checkbox/CheckIndicator'
const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
application,
@@ -50,7 +51,17 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
notesController.setContextMenuOpen(true)
}
const openContextMenu = async (posX: number, posY: number) => {
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const handleContextMenuEvent = async (posX: number, posY: number) => {
if (isMobileScreen) {
if (!application.itemListController.isMultipleSelectionMode) {
application.itemListController.replaceSelection(item)
}
application.itemListController.enableMultipleSelectionMode()
return
}
let shouldOpenContextMenu = selected
if (!selected) {
@@ -65,17 +76,26 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
}
}
const onClick = useCallback(async () => {
await onSelect(item, true)
}, [item, onSelect])
const onClick = useCallback(
(event: MouseEvent) => {
if ((event.ctrlKey || event.metaKey) && !application.itemListController.isMultipleSelectionMode) {
application.itemListController.enableMultipleSelectionMode()
}
if (selected && !application.itemListController.isMultipleSelectionMode) {
application.itemListController.openSingleSelectedItem({ userTriggered: true }).catch(console.error)
return
}
onSelect(item, true).catch(console.error)
},
[application.itemListController, item, onSelect, selected],
)
useContextMenuEvent(listItemRef, openContextMenu)
useContextMenuEvent(listItemRef, handleContextMenuEvent)
log(LoggingDomain.ItemsList, 'Rendering note list item', item.title)
const hasOffsetBorder = !isNextItemTiled
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const dragPreview = useRef<HTMLDivElement>()
const createDragPreview = () => {
@@ -108,14 +128,18 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
ref={listItemRef}
role="button"
className={classNames(
'content-list-item flex w-full cursor-pointer items-stretch text-text',
selected && `selected border-l-2 border-solid border-accessory-tint-${tint}`,
isPreviousItemTiled && 'mt-3 border-t border-solid border-t-border',
isNextItemTiled && 'mb-3 border-b border-solid border-b-border',
'content-list-item flex w-full cursor-pointer items-stretch border-l-2 text-text',
selected
? `selected ${
application.itemListController.isMultipleSelectionMode ? 'border-info' : `border-accessory-tint-${tint}`
}`
: 'border-transparent',
isPreviousItemTiled && 'mt-3 border-t border-t-border',
isNextItemTiled && 'mb-3 border-b border-b-border',
)}
id={item.uuid}
onClick={onClick}
draggable={!isMobileScreen}
draggable={!isMobileScreen && !application.itemListController.isMultipleSelectionMode}
onDragStart={(event) => {
if (!listItemRef.current) {
return
@@ -133,7 +157,11 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
}
}}
>
{!hideIcon ? (
{application.itemListController.isMultipleSelectionMode ? (
<div className="mr-0 flex flex-col items-center justify-between gap-2 p-4 pr-4">
<CheckIndicator className="md:!h-5 md:!w-5" checked={selected} />
</div>
) : !hideIcon ? (
<div className="mr-0 flex flex-col items-center justify-between gap-2 p-4 pr-4">
<Icon type={icon} className={`text-accessory-tint-${tint}`} />
</div>

View File

@@ -65,6 +65,7 @@ export const IconNameToSvgMapping = {
'premium-feature': icons.PremiumFeatureIcon,
'rich-text': icons.RichTextIcon,
'safe-square': icons.SafeSquareIcon,
'select-all': icons.SelectAllIcon,
'sort-descending': icons.SortDescendingIcon,
'star-circle-filled': icons.StarCircleFilled,
'star-filled': icons.StarFilledIcon,

View File

@@ -289,7 +289,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
</MenuSection>
)}
<MenuSection>
<MenuSection className={notes.length > 1 ? 'md:!mb-2' : ''}>
{application.featuresController.isVaultsEnabled() && (
<AddToVaultMenuOption
iconClassName={iconClass}
@@ -507,7 +507,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
)}
</MenuSection>
{notes.length === 1 ? (
{notes.length === 1 && (
<>
{notes[0].noteType === NoteType.Super && (
<SuperNoteOptions
@@ -538,8 +538,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
<NoteSizeWarning note={notes[0]} />
</>
) : (
<div className="h-2" />
)}
<ModalOverlay isOpen={showExportSuperModal} close={closeSuperExportModal}>

View File

@@ -101,6 +101,8 @@ export class ItemListController
selectedUuids: Set<UuidString> = observable(new Set<UuidString>())
selectedItems: Record<UuidString, ListableContentItem> = {}
isMultipleSelectionMode = false
override deinit() {
super.deinit()
;(this.noteFilterText as unknown) = undefined
@@ -169,6 +171,10 @@ export class ItemListController
setSelectedItems: action,
hydrateFromPersistedValue: action,
isMultipleSelectionMode: observable,
enableMultipleSelectionMode: action,
cancelMultipleSelection: action,
})
eventBus.addEventHandler(this, CrossControllerEvent.TagChanged)
@@ -257,6 +263,17 @@ export class ItemListController
),
)
this.disposers.push(
reaction(
() => this.selectedItemsCount,
() => {
if (this.selectedItemsCount === 0) {
this.cancelMultipleSelection()
}
},
),
)
window.onresize = () => {
this.resetPagination(true)
}
@@ -1079,6 +1096,9 @@ export class ItemListController
runInAction(() => {
this.setSelectedUuids(this.selectedUuids.add(item.uuid))
this.lastSelectedItem = item
if (this.selectedItemsCount > 1 && !this.isMultipleSelectionMode) {
this.enableMultipleSelectionMode()
}
})
}
}
@@ -1086,6 +1106,8 @@ export class ItemListController
cancelMultipleSelection = () => {
this.keyboardService.cancelAllKeyboardModifiers()
this.isMultipleSelectionMode = false
const firstSelectedItem = this.firstSelectedItem
if (firstSelectedItem) {
@@ -1095,7 +1117,7 @@ export class ItemListController
}
}
private replaceSelection = (item: ListableContentItem): void => {
replaceSelection = (item: ListableContentItem): void => {
this.deselectAll()
runInAction(() => this.setSelectedUuids(this.selectedUuids.add(item.uuid)))
@@ -1103,10 +1125,11 @@ export class ItemListController
}
selectAll = () => {
void this.selectItemsRange({
startingIndex: 0,
endingIndex: this.listLength - 1,
})
const allItems = this.items.filter((item) => !item.protected)
const lastItem = allItems[allItems.length - 1]
this.setSelectedUuids(new Set(Uuids(allItems)))
this.lastSelectedItem = lastItem
this.enableMultipleSelectionMode()
}
deselectAll = (): void => {
@@ -1136,6 +1159,10 @@ export class ItemListController
}
}
enableMultipleSelectionMode = () => {
this.isMultipleSelectionMode = true
}
selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
@@ -1152,14 +1179,13 @@ export class ItemListController
log(LoggingDomain.Selection, 'Select item', item.uuid)
const supportsMultipleSelection = this.options.allowMultipleSelection
const hasMeta = this.keyboardService.activeModifiers.has(KeyboardModifier.Meta)
const hasCtrl = this.keyboardService.activeModifiers.has(KeyboardModifier.Ctrl)
const hasShift = this.keyboardService.activeModifiers.has(KeyboardModifier.Shift)
const hasMoreThanOneSelected = this.selectedItemsCount > 1
const isAuthorizedForAccess = await this.protections.authorizeItemAccess(item)
if (supportsMultipleSelection && userTriggered && (hasMeta || hasCtrl)) {
if (userTriggered && hasShift) {
await this.selectItemsRange({ selectedItem: item })
} else if (userTriggered && this.isMultipleSelectionMode) {
if (this.selectedUuids.has(uuid) && hasMoreThanOneSelected) {
this.removeSelectedItem(uuid)
} else if (isAuthorizedForAccess) {
@@ -1167,17 +1193,17 @@ export class ItemListController
this.setSelectedUuids(this.selectedUuids)
this.lastSelectedItem = item
}
} else if (supportsMultipleSelection && userTriggered && hasShift) {
await this.selectItemsRange({ selectedItem: item })
if (this.selectedItemsCount === 1) {
this.cancelMultipleSelection()
}
} else {
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedUuids.has(uuid)
if (shouldSelectNote && isAuthorizedForAccess) {
this.replaceSelection(item)
await this.openSingleSelectedItem({ userTriggered: userTriggered ?? false })
}
}
await this.openSingleSelectedItem({ userTriggered: userTriggered ?? false })
return {
didSelect: this.selectedUuids.has(uuid),
}

View File

@@ -293,6 +293,17 @@ export class NotesController
})
}
async toggleArchiveSelectedNotes(): Promise<void> {
const notes = this.selectedNotes
const archived = notes.some((note) => note.archived)
if (!archived) {
await this.setArchiveSelectedNotes(true)
} else {
await this.setArchiveSelectedNotes(false)
}
}
async setProtectSelectedNotes(protect: boolean): Promise<void> {
const selectedNotes = this.getSelectedNotesList()
if (protect) {