mirror of
https://github.com/nextcloud/server.git
synced 2026-06-10 17:23:59 -04:00
feat(files): implement shift-select
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
c6645cbc46
commit
8bef77235f
10 changed files with 145 additions and 23 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
<FileEntry :active="active"
|
||||
:index="index"
|
||||
:is-size-available="isSizeAvailable"
|
||||
:nodes="nodes"
|
||||
:source="item" />
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
/**
|
||||
|
|
|
|||
64
apps/files/src/store/keyboard.ts
Normal file
64
apps/files/src/store/keyboard.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue