diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue index 52617cf44a7..bd46f59d893 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -144,6 +144,8 @@ import { eventBus, useCapabilityFilesFullTextSearch } from 'web-pkg' import ItemFilter from 'web-pkg/src/components/ItemFilter.vue' import { isLocationCommonActive } from 'web-app-files/src/router' import ItemFilterToggle from 'web-pkg/src/components/ItemFilterToggle.vue' +import { useKeyboardActions } from 'web-pkg/src/composables/keyboardActions' +import { useKeyboardActionsSearchTable } from 'web-app-files/src/composables/keyboardActions' const visibilityObserver = new VisibilityObserver() @@ -193,6 +195,10 @@ export default defineComponent({ const scopeQuery = useRouteQuery('scope') const doUseScope = useRouteQuery('useScope') + const resourcesView = useResourcesViewDefaults() + const keyActions = useKeyboardActions('files-view') + useKeyboardActionsSearchTable(keyActions, resourcesView.paginatedResources) + const searchTerm = computed(() => { return queryItemAsString(unref(searchTermQuery)) }) @@ -294,7 +300,7 @@ export default defineComponent({ return { ...useFileActions({ store }), - ...useResourcesViewDefaults(), + ...resourcesView, loadAvailableTagsTask, fileListHeaderY, fullTextSearchEnabled, diff --git a/packages/web-app-files/src/composables/keyboardActions/index.ts b/packages/web-app-files/src/composables/keyboardActions/index.ts new file mode 100644 index 00000000000..517fa84cf97 --- /dev/null +++ b/packages/web-app-files/src/composables/keyboardActions/index.ts @@ -0,0 +1 @@ +export * from './useKeyboardActionsSearchTable' diff --git a/packages/web-app-files/src/composables/keyboardActions/useKeyboardActionsSearchTable.ts b/packages/web-app-files/src/composables/keyboardActions/useKeyboardActionsSearchTable.ts new file mode 100644 index 00000000000..84392c63cc4 --- /dev/null +++ b/packages/web-app-files/src/composables/keyboardActions/useKeyboardActionsSearchTable.ts @@ -0,0 +1,168 @@ +import { computed, onBeforeUnmount, onMounted, ref, unref } from 'vue' +import { useStore } from 'web-pkg/src/composables' +import { useScrollTo } from 'web-app-files/src/composables/scrollTo' +import { Key, ModifierKey } from 'web-pkg/src/composables/keyboardActions' +import { eventBus } from 'web-pkg' +import { useGettext } from 'vue3-gettext' + +export const useKeyboardActionsSearchTable = (keyActions, paginatedResources) => { + const store = useStore() + const { scrollToResource } = useScrollTo() + const selectionCursor = ref(0) + const latestSelectedId = computed(() => store.state.Files.latestSelectedId) + const language = useGettext() + + let fileListClickedEvent + let fileListClickedMetaEvent + let fileListClickedShiftEvent + + keyActions.bindKeyAction({ primary: Key.ArrowUp }, () => handleNavigateAction(true)) + + keyActions.bindKeyAction({ primary: Key.ArrowDown }, () => handleNavigateAction()) + + keyActions.bindKeyAction({ primary: Key.Esc }, () => { + resetSelectionCursor() + store.dispatch('Files/resetFileSelection') + }) + + keyActions.bindKeyAction({ primary: Key.Space }, () => { + store.dispatch('Files/toggleFileSelection', { id: unref(latestSelectedId) }) + }) + + keyActions.bindKeyAction({ modifier: ModifierKey.Shift, primary: Key.ArrowUp }, () => + handleShiftUpAction() + ) + + keyActions.bindKeyAction({ modifier: ModifierKey.Shift, primary: Key.ArrowDown }, () => + handleShiftDownAction() + ) + + keyActions.bindKeyAction({ modifier: ModifierKey.Ctrl, primary: Key.A }, () => + handleSelectAllAction() + ) + + keyActions.bindKeyAction({ modifier: ModifierKey.Ctrl, primary: Key.C }, () => { + store.dispatch('Files/copySelectedFiles', { + ...language, + resources: store.getters['Files/selectedFiles'] + }) + }) + + const handleNavigateAction = async (up = false) => { + const nextId = !unref(latestSelectedId) ? getFirstResourceId() : getNextResourceId(up) + if (nextId === -1) { + return + } + resetSelectionCursor() + await store.dispatch('Files/resetFileSelection') + store.commit('Files/ADD_FILE_SELECTION', { id: nextId }) + scrollToResource({ id: nextId } as any) + } + + const getNextResourceId = (previous = false) => { + const latestSelectedResourceIndex = paginatedResources.value.findIndex( + (resource) => resource.id === latestSelectedId.value + ) + if (latestSelectedResourceIndex === -1) { + return -1 + } + const nextResourceIndex = latestSelectedResourceIndex + (previous ? -1 : 1) + if (nextResourceIndex < 0 || nextResourceIndex >= paginatedResources.value.length) { + return -1 + } + return paginatedResources.value[nextResourceIndex].id + } + + const getFirstResourceId = () => { + return paginatedResources.value.length ? paginatedResources.value[0].id : -1 + } + + const resetSelectionCursor = () => { + selectionCursor.value = 0 + } + + const handleSelectAllAction = () => { + resetSelectionCursor() + store.commit('Files/SET_FILE_SELECTION', paginatedResources.value) + } + + const handleShiftUpAction = async () => { + const nextResourceId = getNextResourceId(true) + if (nextResourceId === -1) { + return + } + if (unref(selectionCursor) > 0) { + // deselect + await store.dispatch('Files/toggleFileSelection', { id: unref(latestSelectedId) }) + store.commit('Files/SET_LATEST_SELECTED_FILE_ID', nextResourceId) + } else { + // select + store.commit('Files/ADD_FILE_SELECTION', { id: nextResourceId }) + } + scrollToResource({ id: nextResourceId } as any) + selectionCursor.value = unref(selectionCursor) - 1 + } + const handleShiftDownAction = () => { + const nextResourceId = getNextResourceId() + if (nextResourceId === -1) { + return + } + if (unref(selectionCursor) < 0) { + // deselect + store.dispatch('Files/toggleFileSelection', { id: unref(latestSelectedId) }) + store.commit('Files/SET_LATEST_SELECTED_FILE_ID', nextResourceId) + } else { + // select + store.commit('Files/ADD_FILE_SELECTION', { id: nextResourceId }) + } + scrollToResource({ id: nextResourceId } as any) + selectionCursor.value = unref(selectionCursor) + 1 + } + + const handleCtrlClickAction = async (resource) => { + await store.dispatch('Files/toggleFileSelection', { id: resource.id }) + } + + const handleShiftClickAction = ({ resource, skipTargetSelection }) => { + const parent = document.querySelectorAll(`[data-item-id='${resource.id}']`)[0] + const resourceNodes = Object.values(parent.parentNode.children) + const latestNode = resourceNodes.find( + (r) => r.getAttribute('data-item-id') === unref(latestSelectedId) + ) + const clickedNode = resourceNodes.find((r) => r.getAttribute('data-item-id') === resource.id) + + let latestNodeIndex = resourceNodes.indexOf(latestNode) + latestNodeIndex = latestNodeIndex === -1 ? 0 : latestNodeIndex + + const clickedNodeIndex = resourceNodes.indexOf(clickedNode) + const minIndex = Math.min(latestNodeIndex, clickedNodeIndex) + const maxIndex = Math.max(latestNodeIndex, clickedNodeIndex) + + for (let i = minIndex; i <= maxIndex; i++) { + const nodeId = resourceNodes[i].getAttribute('data-item-id') + if (skipTargetSelection && nodeId === resource.id) { + continue + } + store.commit('Files/ADD_FILE_SELECTION', { id: nodeId }) + } + store.commit('Files/SET_LATEST_SELECTED_FILE_ID', resource.id) + } + + onMounted(() => { + fileListClickedEvent = eventBus.subscribe('app.files.list.clicked', resetSelectionCursor) + fileListClickedMetaEvent = eventBus.subscribe( + 'app.files.list.clicked.meta', + handleCtrlClickAction + ) + fileListClickedShiftEvent = eventBus.subscribe( + 'app.files.list.clicked.shift', + handleShiftClickAction + ) + }) + + onBeforeUnmount(() => { + eventBus.unsubscribe('app.files.list.clicked', fileListClickedEvent) + eventBus.unsubscribe('app.files.list.clicked.meta', fileListClickedMetaEvent) + eventBus.unsubscribe('app.files.list.clicked.shift', fileListClickedShiftEvent) + }) +} diff --git a/packages/web-pkg/src/composables/keyboardActions/index.ts b/packages/web-pkg/src/composables/keyboardActions/index.ts new file mode 100644 index 00000000000..0f30902929d --- /dev/null +++ b/packages/web-pkg/src/composables/keyboardActions/index.ts @@ -0,0 +1 @@ +export * from './useKeyboardActions' diff --git a/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts b/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts new file mode 100644 index 00000000000..80741a519fa --- /dev/null +++ b/packages/web-pkg/src/composables/keyboardActions/useKeyboardActions.ts @@ -0,0 +1,79 @@ +import { onBeforeUnmount, onMounted, ref } from 'vue' + +export enum Key { + C = 'c', + V = 'v', + A = 'a', + Space = ' ', + ArrowUp = 'ArrowUp', + ArrowDown = 'ArrowDown', + Esc = 'Escape' +} + +export enum ModifierKey { + Ctrl = 'Control', + Shift = 'Shift' +} + +export const useKeyboardActions = (keyBindOnElementId: string | null = null) => { + const actions = ref([]) + const listener = (event: KeyboardEvent): void => { + event.preventDefault() + const { key, ctrlKey, metaKey, shiftKey } = event + let modifier = null + if (metaKey || ctrlKey) { + modifier = ModifierKey.Ctrl + } else if (shiftKey) { + modifier = ModifierKey.Shift + } + const action = actions.value + .filter((action) => action !== null) + .find((action) => { + return action.primary === key && action.modifier === modifier + }) + if (action) { + action.callback(event) + } + } + const bindKeyAction = ( + keys: { primary: Key; modifier?: ModifierKey }, + callback: () => void + ): number => { + return ( + actions.value.push({ + ...keys, + modifier: keys.modifier ?? null, + callback + }) - 1 + ) + } + + const removeKeyAction = (index): void => { + actions.value[index] = null + } + + onMounted(() => { + let element = null + if (keyBindOnElementId) { + element = document.getElementById(keyBindOnElementId) + } + element + ? element.addEventListener('keydown', listener) + : document.addEventListener('keydown', listener) + }) + + onBeforeUnmount(() => { + let element = null + if (keyBindOnElementId) { + element = document.getElementById(keyBindOnElementId) + } + element + ? element.removeEventListener('keydown', listener) + : document.removeEventListener('keydown', listener) + }) + + return { + bindKeyAction, + removeKeyAction + } +}