diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-07 11:46:41 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-11 12:36:58 +0200 |
commit | 8bef77235f4dc23df0e0f0feb959e69d1693d9be (patch) | |
tree | c74eb30753c08662ebab40185e882f54e15eee42 /apps/files | |
parent | c6645cbc46291d2621992b7f0bb087f115e849eb (diff) | |
download | nextcloud-server-8bef77235f4dc23df0e0f0feb959e69d1693d9be.tar.gz nextcloud-server-8bef77235f4dc23df0e0f0feb959e69d1693d9be.zip |
feat(files): implement shift-select
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 57 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 1 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 1 | ||||
-rw-r--r-- | apps/files/src/store/files.ts | 5 | ||||
-rw-r--r-- | apps/files/src/store/keyboard.ts | 64 | ||||
-rw-r--r-- | apps/files/src/store/paths.ts | 3 | ||||
-rw-r--r-- | apps/files/src/store/selection.ts | 20 | ||||
-rw-r--r-- | apps/files/src/types.ts | 11 |
8 files changed, 142 insertions, 20 deletions
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index fca83cabbd1..eb6ad9f7e18 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -25,9 +25,10 @@ <td class="files-list__row-checkbox"> <NcCheckboxRadioSwitch v-if="active" :aria-label="t('files', 'Select the row for {displayName}', { displayName })" - :checked.sync="selectedFiles" - :value="fileid.toString()" - name="selectedFiles" /> + :checked="selectedFiles" + :value="fileid" + name="selectedFiles" + @update:checked="onSelectionChange" /> </td> <!-- Link to file --> @@ -120,6 +121,7 @@ import { getFileActions } from '../services/FileAction.ts' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' import { useUserConfigStore } from '../store/userconfig.ts' +import { useKeyboardStore } from '../store/keyboard.ts' import CustomElementRender from './CustomElementRender.vue' import CustomSvgIconRender from './CustomSvgIconRender.vue' import logger from '../logger.js' @@ -159,16 +161,22 @@ export default Vue.extend({ type: Number, required: true, }, + nodes: { + type: Array, + required: true, + }, }, setup() { const filesStore = useFilesStore() const selectionStore = useSelectionStore() const userConfigStore = useUserConfigStore() + const keyboardStore = useKeyboardStore() return { filesStore, selectionStore, userConfigStore, + keyboardStore, } }, @@ -199,7 +207,7 @@ export default Vue.extend({ }, fileid() { - return this.source.attributes.fileid + return this.source?.fileid?.toString?.() }, displayName() { return this.source.attributes.displayName @@ -242,14 +250,8 @@ export default Vue.extend({ } }, - selectedFiles: { - get() { - return this.selectionStore.selected - }, - set(selection) { - logger.debug('Changed nodes selection', { selection }) - this.selectionStore.set(selection) - }, + selectedFiles() { + return this.selectionStore.selected }, cropPreviews() { @@ -454,6 +456,37 @@ export default Vue.extend({ } }, + onSelectionChange(selection) { + const newSelectedIndex = this.index + const lastSelectedIndex = this.selectionStore.lastSelectedIndex + + // Get the last selected and select all files in between + if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) { + const isAlreadySelected = this.selectedFiles.includes(this.fileid) + + const start = Math.min(newSelectedIndex, lastSelectedIndex) + const end = Math.max(lastSelectedIndex, newSelectedIndex) + + const lastSelection = this.selectionStore.lastSelection + const filesToSelect = this.nodes + .map(file => file.fileid?.toString?.()) + .slice(start, end + 1) + + // If already selected, update the new selection _without_ the current file + const selection = [...lastSelection, ...filesToSelect] + .filter(fileId => !isAlreadySelected || fileId !== this.fileid) + + logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected }) + // Keep previous lastSelectedIndex to be use for further shift selections + this.selectionStore.set(selection) + return + } + + logger.debug('Updating selection', { selection }) + this.selectionStore.set(selection) + this.selectionStore.setLastIndex(newSelectedIndex) + }, + t: translate, formatFileSize, }, diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 288154e3c91..58ec46afba5 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -183,6 +183,7 @@ export default Vue.extend({ if (selected) { const selection = this.nodes.map(node => node.attributes.fileid.toString()) logger.debug('Added all nodes to selection', { selection }) + this.selectionStore.setLastIndex(null) this.selectionStore.set(selection) } else { logger.debug('Cleared selection') diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 7891128a1eb..b5f6c5caf80 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -36,6 +36,7 @@ <FileEntry :active="active" :index="index" :is-size-available="isSizeAvailable" + :nodes="nodes" :source="item" /> </template> diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index 27b6b12f348..d276b6bf0b7 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -27,6 +27,7 @@ import { defineStore } from 'pinia' import { subscribe } from '@nextcloud/event-bus' import Vue from 'vue' import logger from '../logger' +import { FileId } from '../types' export const useFilesStore = () => { const store = defineStore('files', { @@ -39,13 +40,13 @@ export const useFilesStore = () => { /** * Get a file or folder by id */ - getNode: (state) => (id: number): Node|undefined => state.files[id], + getNode: (state) => (id: FileId): Node|undefined => state.files[id], /** * Get a list of files or folders by their IDs * Does not return undefined values */ - getNodes: (state) => (ids: number[]): Node[] => ids + getNodes: (state) => (ids: FileId[]): Node[] => ids .map(id => state.files[id]) .filter(Boolean), /** diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts new file mode 100644 index 00000000000..1ba8285b960 --- /dev/null +++ b/apps/files/src/store/keyboard.ts @@ -0,0 +1,64 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +/* eslint-disable */ +import { defineStore } from 'pinia' +import Vue from 'vue' + +/** + * Observe various events and save the current + * special keys states. Useful for checking the + * current status of a key when executing a method. + */ +export const useKeyboardStore = () => { + const store = defineStore('keyboard', { + state: () => ({ + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }), + + actions: { + onEvent(event: MouseEvent | KeyboardEvent) { + if (!event) { + event = window.event as MouseEvent | KeyboardEvent + } + Vue.set(this, 'altKey', !!event.altKey) + Vue.set(this, 'ctrlKey', !!event.ctrlKey) + Vue.set(this, 'metaKey', !!event.metaKey) + Vue.set(this, 'shiftKey', !!event.shiftKey) + }, + } + }) + + const keyboardStore = store() + // Make sure we only register the listeners once + if (!keyboardStore._initialized) { + window.addEventListener('keydown', keyboardStore.onEvent) + window.addEventListener('keyup', keyboardStore.onEvent) + window.addEventListener('mousemove', keyboardStore.onEvent) + + keyboardStore._initialized = true + } + + return keyboardStore +} diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts index 8e458eb87b0..28cd55be25a 100644 --- a/apps/files/src/store/paths.ts +++ b/apps/files/src/store/paths.ts @@ -25,6 +25,7 @@ import type { PathOptions, ServicesState } from '../types.ts' import { defineStore } from 'pinia' import Vue from 'vue' import { subscribe } from '@nextcloud/event-bus' +import { FileId } from '../types' export const usePathsStore = () => { const store = defineStore('paths', { @@ -32,7 +33,7 @@ export const usePathsStore = () => { getters: { getPath: (state) => { - return (service: string, path: string): number|undefined => { + return (service: string, path: string): FileId|undefined => { if (!state[service]) { return undefined } diff --git a/apps/files/src/store/selection.ts b/apps/files/src/store/selection.ts index 56e1b3dcbb0..0d67420e963 100644 --- a/apps/files/src/store/selection.ts +++ b/apps/files/src/store/selection.ts @@ -22,25 +22,39 @@ /* eslint-disable */ import { defineStore } from 'pinia' import Vue from 'vue' +import { FileId, SelectionStore } from '../types' export const useSelectionStore = defineStore('selection', { state: () => ({ - selected: [] as number[] - }), + selected: [], + lastSelection: [], + lastSelectedIndex: null, + } as SelectionStore), actions: { /** * Set the selection of fileIds */ - set(selection = [] as number[]) { + set(selection = [] as FileId[]) { Vue.set(this, 'selected', selection) }, /** + * Set the last selected index + */ + setLastIndex(lastSelectedIndex = null as FileId | null) { + // Update the last selection if we provided a new selection starting point + Vue.set(this, 'lastSelection', lastSelectedIndex ? this.selected : []) + Vue.set(this, 'lastSelectedIndex', lastSelectedIndex) + }, + + /** * Reset the selection */ reset() { Vue.set(this, 'selected', []) + Vue.set(this, 'lastSelection', []) + Vue.set(this, 'lastSelectedIndex', null) } } }) diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index c0eeb7c176e..9bbb572faaf 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -25,6 +25,7 @@ import type { Node } from '@nextcloud/files' // Global definitions export type Service = string +export type FileId = number // Files store export type FilesState = { @@ -33,7 +34,7 @@ export type FilesState = { } export type FilesStore = { - [fileid: number]: Node + [fileid: FileId]: Node } export type RootsStore = { @@ -57,7 +58,7 @@ export type PathsStore = { export interface PathOptions { service: Service path: string - fileid: number + fileid: FileId } // Sorting store @@ -79,3 +80,9 @@ export interface UserConfig { export interface UserConfigStore { userConfig: UserConfig } + +export interface SelectionStore { + selected: FileId[] + lastSelection: FileId[] + lastSelectedIndex: number | null +} |