From e3392e1e22d99933b8ec381e45d3eb0e2457484b Mon Sep 17 00:00:00 2001 From: Uhrendoktor <36703334+Uhrendoktor@users.noreply.github.com> Date: Wed, 12 Jul 2023 10:45:09 +0200 Subject: [PATCH] Feature: Improved Keyboard and Mouse Control and selection capabilities --- package.json | 4 +- src/components/Common/Table/Table.js | 2 +- src/components/Common/TableOld/Table.js | 17 ++-- src/components/MainView/DataView/DataView.js | 93 ++++++++++++++++-- src/components/MainView/DataViewOld/Table.js | 99 ++++++++++++++++++-- src/mixins/DataStore/DataStore.js | 36 ++++--- src/sdk/hotkeys.ts | 20 +++- src/sdk/keymap.ts | 48 ++++++++-- src/stores/Tabs/tab_selected_items.js | 25 +++++ 9 files changed, 296 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index def2fc26..abde61b2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "start": "node dev-server.js", "test": "echo \"Error: no test specified\" && exit 0", "lint": "yarn run eslint ./src" - }, + }, "author": "Heartex, Inc.", "license": "ISC", "devDependencies": { @@ -79,7 +79,7 @@ "react-datepicker": "^3.6.0", "react-dom": "^17.0.2", "react-hot-loader": "^4.12.20", - "react-hotkeys-hook": "^2.4.0", + "react-hotkeys-hook": "^4.0.0", "react-icons": "^3.11.0", "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.8.6", diff --git a/src/components/Common/Table/Table.js b/src/components/Common/Table/Table.js index 0ce9e796..768a7f95 100644 --- a/src/components/Common/Table/Table.js +++ b/src/components/Common/Table/Table.js @@ -218,7 +218,7 @@ export const Table = observer( const highlightedElement = tableWrapper.current?.children[highlightedIndex]; if (highlightedElement) highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, [tableWrapper.current]); + }, [tableWrapper.current, focusedItem]); return ( <> diff --git a/src/components/Common/TableOld/Table.js b/src/components/Common/TableOld/Table.js index 95ea4cb1..e9937070 100644 --- a/src/components/Common/TableOld/Table.js +++ b/src/components/Common/TableOld/Table.js @@ -245,22 +245,23 @@ export const Table = observer( const cachedScrollOffset = useRef(); - const initialScrollOffset = useCallback((height) => { + const initialScrollOffset = useMemo(() => { if (isDefined(cachedScrollOffset.current)) { return cachedScrollOffset.current; } - const { rowHeight: h } = props; + const { rowHeight } = props; const index = data.indexOf(focusedItem); + if (index >= 0) { - const scrollOffset = index * h - height / 2 + h / 2; // + headerHeight + const scrollOffset = (index+(index+1)/data.length) * rowHeight; return cachedScrollOffset.current = scrollOffset; } else { return 0; } - }, []); + }, [focusedItem, props.rowHeight]); const itemKey = useCallback( (index) => { @@ -276,9 +277,11 @@ export const Table = observer( const listComponent = listRef.current?._listRef; if (listComponent) { - listComponent.scrollToItem(data.indexOf(focusedItem), "center"); + const isLast = data[data.length-1].id === focusedItem?.id; + + listComponent.scrollToItem(data.indexOf(focusedItem), isLast?"start":"center"); } - }, [data]); + }, [data, focusedItem]); const tableWrapper = useRef(); const right = tableWrapper.current?.firstChild?.firstChild.offsetWidth - @@ -410,7 +413,7 @@ const StickyList = observer( itemData={itemData} itemSize={itemSize} onItemsRendered={onItemsRendered} - initialScrollOffset={initialScrollOffset?.(height) ?? 0} + initialScrollOffset={initialScrollOffset} > {ItemWrapper} diff --git a/src/components/MainView/DataView/DataView.js b/src/components/MainView/DataView/DataView.js index 3d93b150..08080cbb 100644 --- a/src/components/MainView/DataView/DataView.js +++ b/src/components/MainView/DataView/DataView.js @@ -2,7 +2,7 @@ import { inject, observer } from "mobx-react"; import { getRoot } from "mobx-state-tree"; import { useCallback, useMemo, useState } from "react"; import { FaQuestionCircle } from "react-icons/fa"; -import { useShortcut } from "../../../sdk/hotkeys"; +import { isShortcutPressed, useShortcut } from "../../../sdk/hotkeys"; import { Block, Elem } from "../../../utils/bem"; import { FF_DEV_2536, FF_DEV_4008, isFF } from '../../../utils/feature-flags'; import * as CellViews from "../../CellViews"; @@ -16,6 +16,7 @@ import { Tooltip } from "../../Common/Tooltip/Tooltip"; import { GridView } from "../GridView/GridView"; import { CandidateTaskView } from "../../CandidateTaskView"; import { modal } from "../../Common/Modal/Modal"; +import { isDefined } from "../../../utils/utils"; import "./DataView.styl"; const injector = inject(({ store }) => { @@ -36,7 +37,7 @@ const injector = inject(({ store }) => { isLoading: dataStore?.loading ?? true, isLocked: currentView?.locked ?? false, hasData: (store.project?.task_count ?? store.project?.task_number ?? dataStore?.total ?? 0) > 0, - focusedItem: dataStore?.selected ?? dataStore?.highlighted, + focusedItem: dataStore?.scroll ?? dataStore?.selected ?? dataStore?.highlighted ?? dataStore?.list[0], }; return props; @@ -114,8 +115,32 @@ export const DataView = injector( }, [view]); const onRowSelect = useCallback((id) => { - view.toggleSelected(id); - }, [view]); + const { highlightedId } = dataStore; + + const bulk_select = isShortcutPressed("dm.bulk-select-mouse"); + const bulk_deselect = isShortcutPressed("dm.bulk-deselect-mouse"); + + if(!bulk_select && !bulk_deselect){ + view.toggleSelected(id); + } + if (isDefined(highlightedId)){ + const ids = dataStore.list.map(({ id })=>id); + const _id_high = ids.indexOf(highlightedId); + const _id_curr = ids.indexOf(id); + const range = ids.slice( + Math.min(_id_high, _id_curr), + Math.max(_id_high, _id_curr)+1, + ); + + if (bulk_select){ + view.selected.selectItems(...range); + } + else if (bulk_deselect){ + view.selected.deselectItems(...range); + } + } + dataStore.focusItem(id, false); + }, [view, dataStore.highlightedId, dataStore.list]); const onRowClick = useCallback( (item, e) => { @@ -273,7 +298,7 @@ export const DataView = injector( useShortcut("dm.focus-previous", () => { if (document.activeElement !== document.body) return; - const task = dataStore.focusPrev(); + const task = dataStore.focusPrev(true); if (isFF(FF_DEV_4008)) getRoot(view).startLabeling(task); }); @@ -281,7 +306,7 @@ export const DataView = injector( useShortcut("dm.focus-next", () => { if (document.activeElement !== document.body) return; - const task = dataStore.focusNext(); + const task = dataStore.focusNext(true); if (isFF(FF_DEV_4008)) getRoot(view).startLabeling(task); }); @@ -298,7 +323,61 @@ export const DataView = injector( const { highlighted } = dataStore; // don't close QuickView by Enter - if (highlighted && !highlighted.isSelected) store.startLabeling(highlighted); + if (highlighted && !highlighted.isSelected){ + store.startLabeling(highlighted); + } + }); + + useShortcut("dm.toggle-focused", ()=>{ + if (document.activeElement !== document.body) return; + + const { highlighted } = dataStore; + + if (highlighted) onRowSelect(highlighted.id); + }); + + useShortcut("dm.select-next", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.selectItems( + dataStore.highlightedId, + dataStore.focusNext(true).id, + ); + document.getSelection().removeAllRanges(); + }); + + useShortcut("dm.select-prev", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.selectItems( + dataStore.highlightedId, + dataStore.focusPrev(true).id, + ); + document.getSelection().removeAllRanges(); + }); + + useShortcut("dm.deselect-next", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.deselectItems( + dataStore.highlightedId, + dataStore.focusNext(true).id, + ); + }); + + useShortcut("dm.deselect-prev", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.deselectItems( + dataStore.highlightedId, + dataStore.focusPrev(true).id, + ); + }); + + useShortcut("dm.select-all", ()=>{ + if (document.activeElement !== document.body) return; + + onSelectAll(); }); // Render the UI for the table diff --git a/src/components/MainView/DataViewOld/Table.js b/src/components/MainView/DataViewOld/Table.js index 0dad51d0..5a5a0d22 100644 --- a/src/components/MainView/DataViewOld/Table.js +++ b/src/components/MainView/DataViewOld/Table.js @@ -2,7 +2,7 @@ import { inject } from "mobx-react"; import { getRoot } from "mobx-state-tree"; import { useCallback, useMemo } from "react"; import { FaQuestionCircle } from "react-icons/fa"; -import { useShortcut } from "../../../sdk/hotkeys"; +import { isShortcutPressed, useShortcut } from "../../../sdk/hotkeys"; import { Block, Elem } from "../../../utils/bem"; import { FF_DEV_2536, FF_DEV_4008, isFF } from '../../../utils/feature-flags'; import * as CellViews from "../../CellViews"; @@ -16,6 +16,7 @@ import { GridView } from "../GridViewOld/GridView"; import { CandidateTaskView } from "../../CandidateTaskView"; import "./Table.styl"; import { modal } from "../../Common/Modal/Modal"; +import { isDefined } from "../../../utils/utils"; const injector = inject(({ store }) => { const { dataStore, currentView } = store; @@ -35,7 +36,7 @@ const injector = inject(({ store }) => { isLoading: dataStore?.loading ?? true, isLocked: currentView?.locked ?? false, hasData: (store.project?.task_count ?? store.project?.task_number ?? dataStore?.total ?? 0) > 0, - focusedItem: dataStore?.selected ?? dataStore?.highlighted, + focusedItem: dataStore?.scroll ?? dataStore?.selected ?? dataStore?.highlighted ?? dataStore?.list[0], }; return props; @@ -107,11 +108,37 @@ export const DataView = injector( [], ); - const onSelectAll = useCallback(() => view.selectAll(), [view]); + const onSelectAll = useCallback(() => { + view.selectAll(); + }, [view]); - const onRowSelect = useCallback((id) => view.toggleSelected(id), [ - view, - ]); + const onRowSelect = useCallback((id) => { + const { highlightedId } = dataStore; + + const bulk_select = isShortcutPressed("dm.bulk-select-mouse"); + const bulk_deselect = isShortcutPressed("dm.bulk-deselect-mouse"); + + if(!bulk_select && !bulk_deselect){ + view.toggleSelected(id); + } + if (isDefined(highlightedId)){ + const ids = dataStore.list.map(({ id })=>id); + const _id_high = ids.indexOf(highlightedId); + const _id_curr = ids.indexOf(id); + const range = ids.slice( + Math.min(_id_high, _id_curr), + Math.max(_id_high, _id_curr)+1, + ); + + if (bulk_select){ + view.selected.selectItems(...range); + } + else if (bulk_deselect){ + view.selected.deselectItems(...range); + } + } + dataStore.focusItem(id, false); + },[view, dataStore.highlightedId, dataStore.list]); const onRowClick = useCallback( (item, e) => { @@ -274,7 +301,7 @@ export const DataView = injector( useShortcut("dm.focus-previous", () => { if (document.activeElement !== document.body) return; - const task = dataStore.focusPrev(); + const task = dataStore.focusPrev(true); if (isFF(FF_DEV_4008)) getRoot(view).startLabeling(task); }); @@ -282,7 +309,7 @@ export const DataView = injector( useShortcut("dm.focus-next", () => { if (document.activeElement !== document.body) return; - const task = dataStore.focusNext(); + const task = dataStore.focusNext(true); if (isFF(FF_DEV_4008)) getRoot(view).startLabeling(task); }); @@ -299,7 +326,61 @@ export const DataView = injector( const { highlighted } = dataStore; // don't close QuickView by Enter - if (highlighted && !highlighted.isSelected) store.startLabeling(highlighted); + if (highlighted && !highlighted.isSelected){ + store.startLabeling(highlighted); + } + }); + + useShortcut("dm.toggle-focused", ()=>{ + if (document.activeElement !== document.body) return; + + const { highlighted } = dataStore; + + if (highlighted) onRowSelect(highlighted.id); + }); + + useShortcut("dm.select-next", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.selectItems( + dataStore.highlightedId, + dataStore.focusNext(true).id, + ); + document.getSelection().removeAllRanges(); + }); + + useShortcut("dm.select-prev", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.selectItems( + dataStore.highlightedId, + dataStore.focusPrev(true).id, + ); + document.getSelection().removeAllRanges(); + }); + + useShortcut("dm.deselect-next", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.deselectItems( + dataStore.highlightedId, + dataStore.focusNext(true).id, + ); + }); + + useShortcut("dm.deselect-prev", ()=>{ + if (document.activeElement !== document.body) return; + + view.selected.deselectItems( + dataStore.highlightedId, + dataStore.focusPrev(true).id, + ); + }); + + useShortcut("dm.select-all", ()=>{ + if (document.activeElement !== document.body) return; + + onSelectAll(); }); // Render the UI for your table diff --git a/src/mixins/DataStore/DataStore.js b/src/mixins/DataStore/DataStore.js index c251b994..ad6f5875 100644 --- a/src/mixins/DataStore/DataStore.js +++ b/src/mixins/DataStore/DataStore.js @@ -142,6 +142,7 @@ export const DataStore = ( list: types.optional(types.array(listItemType), []), selectedId: types.optional(types.maybeNull(types.number), null), highlightedId: types.optional(types.maybeNull(types.number), null), + scrollId: types.optional(types.maybeNull(types.number), null), ...(associatedItemType ? { associatedList: types.optional(types.array(associatedItemType), []) } : {}), }) .views((self) => ({ @@ -153,6 +154,10 @@ export const DataStore = ( return self.list.find(({ id }) => id === self.highlightedId); }, + get scroll() { + return self.list.find(({ id }) => id === self.scrollId); + }, + set selected(item) { self.selectedId = item?.id ?? item; }, @@ -160,6 +165,10 @@ export const DataStore = ( set highlighted(item) { self.highlightedId = item?.id ?? item; }, + + set scroll(item) { + self.scrollId = item?.id ?? item; + }, })) .volatile(() => ({ requestId: null, @@ -229,7 +238,7 @@ export const DataStore = ( const data = yield getRoot(self).apiCall(apiMethod, params); // We cancel current request processing if request id - // cnhaged during the request. It indicates that something + // changed during the request. It indicates that something // triggered another request while current one is not yet finished if (requestId !== self.requestId) { console.log(`Request ${requestId} was cancelled by another request`); @@ -267,25 +276,30 @@ export const DataStore = ( yield self.fetch({ id, query, reload: true, interaction }); }), - focusPrev() { - const index = Math.max(0, self.list.indexOf(self.highlighted) - 1); - - self.highlighted = self.list[index]; - self.updated = guidGenerator(); + focusItem(item, scrollTo = false) { + /** + * item: item / itemID + */ + self.highlighted = item; + if (scrollTo) self.scroll = item; + //self.updated = guidGenerator(); return self.highlighted; }, - focusNext() { + focusPrev(scrollTo = false) { + const index = Math.max(0, self.list.indexOf(self.highlighted) - 1); + + return self.focusItem(self.list[index], scrollTo); + }, + + focusNext(scrollTo = false) { const index = Math.min( self.list.length - 1, self.list.indexOf(self.highlighted) + 1, ); - self.highlighted = self.list[index]; - self.updated = guidGenerator(); - - return self.highlighted; + return self.focusItem(self.list[index], scrollTo); }, })); diff --git a/src/sdk/hotkeys.ts b/src/sdk/hotkeys.ts index 54c2475e..ee5b379c 100644 --- a/src/sdk/hotkeys.ts +++ b/src/sdk/hotkeys.ts @@ -1,4 +1,4 @@ -import { useHotkeys } from "react-hotkeys-hook"; +import { useHotkeys, isHotkeyPressed } from "react-hotkeys-hook"; import { toStudlyCaps } from "strman"; import { keymap } from "./keymap"; @@ -9,11 +9,14 @@ export type Hotkey = { other?: string } -const readableShortcut = (shortcut: string) => { +const readableShortcut = (shortcut: string|[string]) => { + if (Array.isArray(shortcut)) { + return shortcut.map(sc=>sc.split('+').map(str => toStudlyCaps(str)).join(' + ')).join(' or '); + } return shortcut.split('+').map(str => toStudlyCaps(str)).join(' + '); }; -export const useShortcut = ( +const useShortcut = ( actionName: keyof typeof keymap, callback: () => void, options = { showShortcut: true }, @@ -34,3 +37,14 @@ export const useShortcut = ( return title; }; + +const isShortcutPressed = ( + actionName: keyof typeof keymap +) => { + const action = keymap[actionName] as Hotkey; + const isMacos = /mac/i.test(navigator.platform); + const shortcut = action.shortcut ?? (isMacos ? action.macos : action.other) as string; + return isHotkeyPressed(shortcut); +} + +export { useShortcut, isShortcutPressed }; diff --git a/src/sdk/keymap.ts b/src/sdk/keymap.ts index d4daebf5..a93de4da 100644 --- a/src/sdk/keymap.ts +++ b/src/sdk/keymap.ts @@ -1,19 +1,51 @@ export const keymap = { "dm.focus-previous": { title: "Focus previous task", - shortcut: "shift+up", + shortcut: "up", }, "dm.focus-next": { - title: "Focus previous task", - shortcut: "shift+down", + title: "Focus next task", + shortcut: "down", }, "dm.close-labeling": { - title: "Focus previous task", - shortcut: "shift+left", + title: "Open labeling UI", + shortcut: "escape", }, "dm.open-labeling": { - title: "Focus previous task", - shortcut: "shift+right", + title: "Close labeling UI", + shortcut: "enter", + }, + "dm.toggle-focused": { + title: "toggle selection of focused task", + shortcut: "space", + }, + "dm.bulk-select-mouse": { + title: "Bulk select tasks with mouse", + shortcut: "shift", + }, + "dm.bulk-deselect-mouse": { + title: "Bulk deselect tasks with mouse", + shortcut: "ctrl", + }, + "dm.select-next": { + title: "Select next task", + shortcut: "shift+down", + }, + "dm.select-prev": { + title: "Select previous task", + shortcut: "shift+up", + }, + "dm.deselect-next": { + title: "Deselect next task", + shortcut: "ctrl+down", + }, + "dm.deselect-prev": { + title: "Deselect previous task", + shortcut: "ctrl+up", + }, + "dm.select-all": { + title: "Select all tasks", + shortcut: "shift+a", }, "lsf.save-annotation": { title: "Save results", @@ -34,5 +66,5 @@ export const keymap = { title: "Redo last action", macos: "cmd+shift+z", other: "ctrl+shidt+z", - }, + } }; diff --git a/src/stores/Tabs/tab_selected_items.js b/src/stores/Tabs/tab_selected_items.js index fca3d0af..45713ee6 100644 --- a/src/stores/Tabs/tab_selected_items.js +++ b/src/stores/Tabs/tab_selected_items.js @@ -1,5 +1,6 @@ import { getRoot, types } from "mobx-state-tree"; import { StringOrNumber } from "../types"; +import { isDefined } from "../../utils/utils"; export const TabSelectedItems = types .model("TabSelectedItems", { @@ -66,6 +67,30 @@ export const TabSelectedItems = types self._invokeChangeEvent(); }, + selectItems(...ids){ + for (const id of ids) { + if (!isDefined(id)) { + continue; + } + if (!self.list.includes(id)) { + self.list.push(id); + } + } + self._invokeChangeEvent(); + }, + + deselectItems(...ids){ + for (const id of ids) { + if (!isDefined(id)) { + continue; + } + if (self.list.includes(id)) { + self.list.splice(self.list.indexOf(id), 1); + } + } + self._invokeChangeEvent(); + }, + addItem(id) { self.list.push(id); self._invokeChangeEvent();