feat: Re-enabled multiple selection on mobile, with improved UI (#2609)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user