Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 10 commits into from
Oct 27, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +56,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
noAccountWarningController,
searchOptionsController,
linkingController,
notesController,
} = application

const { setPaneLayout, panes } = useResponsiveAppPane()
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -507,7 +507,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
)}
</MenuSection>

{notes.length === 1 ? (
{notes.length === 1 && (
<>
{notes[0].noteType === NoteType.Super && (
<SuperNoteOptions
Expand Down Expand Up @@ -538,8 +538,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {

<NoteSizeWarning note={notes[0]} />
</>
) : (
<div className="h-2" />
)}

<ModalOverlay isOpen={showExportSuperModal} close={closeSuperExportModal}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -169,6 +171,10 @@ export class ItemListController
setSelectedItems: action,

hydrateFromPersistedValue: action,

isMultipleSelectionMode: observable,
enableMultipleSelectionMode: action,
cancelMultipleSelection: action,
})

eventBus.addEventHandler(this, CrossControllerEvent.TagChanged)
Expand Down Expand Up @@ -257,6 +263,17 @@ export class ItemListController
),
)

this.disposers.push(
reaction(
() => this.selectedItemsCount,
() => {
if (this.selectedItemsCount === 0) {
this.cancelMultipleSelection()
}
},
),
)

window.onresize = () => {
this.resetPagination(true)
}
Expand Down Expand Up @@ -1079,13 +1096,18 @@ export class ItemListController
runInAction(() => {
this.setSelectedUuids(this.selectedUuids.add(item.uuid))
this.lastSelectedItem = item
if (this.selectedItemsCount > 1 && !this.isMultipleSelectionMode) {
this.enableMultipleSelectionMode()
}
})
}
}

cancelMultipleSelection = () => {
this.keyboardService.cancelAllKeyboardModifiers()

this.isMultipleSelectionMode = false

const firstSelectedItem = this.firstSelectedItem

if (firstSelectedItem) {
Expand All @@ -1095,18 +1117,19 @@ export class ItemListController
}
}

private replaceSelection = (item: ListableContentItem): void => {
replaceSelection = (item: ListableContentItem): void => {
this.deselectAll()
runInAction(() => this.setSelectedUuids(this.selectedUuids.add(item.uuid)))

this.lastSelectedItem = item
}

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 => {
Expand Down Expand Up @@ -1136,6 +1159,10 @@ export class ItemListController
}
}

enableMultipleSelectionMode = () => {
this.isMultipleSelectionMode = true
}

selectItem = async (
uuid: UuidString,
userTriggered?: boolean,
Expand All @@ -1152,32 +1179,31 @@ 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) {
this.selectedUuids.add(uuid)
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),
}
Expand Down