diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index 55e6100af..541e94d65 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -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( noAccountWarningController, searchOptionsController, linkingController, + notesController, } = application const { setPaneLayout, panes } = useResponsiveAppPane() @@ -285,7 +289,7 @@ const ContentListView = forwardRef( aria-label={'Notes & Files'} ref={mergeRefs([innerRef, setElement])} > - {isMobileScreen && ( + {isMobileScreen && !itemListController.isMultipleSelectionMode && ( )}
@@ -319,6 +323,33 @@ const ContentListView = forwardRef( />
+ {itemListController.isMultipleSelectionMode && ( +
+
+ + + +
+
{itemListController.selectedItemsCount} selected
+ + + +
+ )} {selectedAsTag && dailyMode && ( ( /> ) ) : null} + {isMobileScreen && itemListController.isMultipleSelectionMode && ( + + )}
{children}
diff --git a/packages/web/src/javascripts/Components/ContentListView/MobileMultiSelectionToolbar.tsx b/packages/web/src/javascripts/Components/ContentListView/MobileMultiSelectionToolbar.tsx new file mode 100644 index 000000000..04d2f5ea9 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/MobileMultiSelectionToolbar.tsx @@ -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 ( +
+ + + + +
+ ) +} + +export default MobileMultiSelectionToolbar diff --git a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx index 491e0ae43..1d77e67de 100644 --- a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx @@ -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> = ({ application, @@ -50,7 +51,17 @@ const NoteListItem: FunctionComponent> = ({ 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> = ({ } } - 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() const createDragPreview = () => { @@ -108,14 +128,18 @@ const NoteListItem: FunctionComponent> = ({ 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> = ({ } }} > - {!hideIcon ? ( + {application.itemListController.isMultipleSelectionMode ? ( +
+ +
+ ) : !hideIcon ? (
diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index b055e4aae..89a152698 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -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, diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index 21a636dde..d833574bf 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -289,7 +289,7 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => { )} - + 1 ? 'md:!mb-2' : ''}> {application.featuresController.isVaultsEnabled() && ( { )} - {notes.length === 1 ? ( + {notes.length === 1 && ( <> {notes[0].noteType === NoteType.Super && ( { - ) : ( -
)} diff --git a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts index ee0e8d28d..0b2742e3d 100644 --- a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts +++ b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts @@ -101,6 +101,8 @@ export class ItemListController selectedUuids: Set = observable(new Set()) selectedItems: Record = {} + 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), } diff --git a/packages/web/src/javascripts/Controllers/NotesController/NotesController.ts b/packages/web/src/javascripts/Controllers/NotesController/NotesController.ts index 3665d8b1a..55a2f17a7 100644 --- a/packages/web/src/javascripts/Controllers/NotesController/NotesController.ts +++ b/packages/web/src/javascripts/Controllers/NotesController/NotesController.ts @@ -293,6 +293,17 @@ export class NotesController }) } + async toggleArchiveSelectedNotes(): Promise { + 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 { const selectedNotes = this.getSelectedNotesList() if (protect) {