diff --git a/client/src/components/boards/Board/Board.jsx b/client/src/components/boards/Board/Board.jsx
index 401fe508..e87a84b7 100644
--- a/client/src/components/boards/Board/Board.jsx
+++ b/client/src/components/boards/Board/Board.jsx
@@ -12,6 +12,7 @@ import { BoardContexts, BoardViews } from '../../../constants/Enums';
import KanbanContent from './KanbanContent';
import FiniteContent from './FiniteContent';
import EndlessContent from './EndlessContent';
+import ShortcutsProvider from './ShortcutsProvider';
import CardModal from '../../cards/CardModal';
import BoardActivitiesModal from '../../activities/BoardActivitiesModal';
@@ -53,7 +54,9 @@ const Board = React.memo(() => {
return (
<>
-
+
+
+
{modalNode}
>
);
diff --git a/client/src/components/boards/Board/ShortcutsProvider.jsx b/client/src/components/boards/Board/ShortcutsProvider.jsx
new file mode 100644
index 00000000..45f0bc0c
--- /dev/null
+++ b/client/src/components/boards/Board/ShortcutsProvider.jsx
@@ -0,0 +1,227 @@
+/*!
+ * Copyright (c) 2024 PLANKA Software GmbH
+ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
+ */
+
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { useDispatch, useSelector } from 'react-redux';
+import { push } from '../../../lib/redux-router';
+import { useDidUpdate } from '../../../lib/hooks';
+
+import store from '../../../store';
+import selectors from '../../../selectors';
+import entryActions from '../../../entry-actions';
+import { isListArchiveOrTrash } from '../../../utils/record-helpers';
+import { isActiveTextElement } from '../../../utils/element-helpers';
+import { isModifierKeyPressed } from '../../../utils/event-helpers';
+import { BoardShortcutsContext } from '../../../contexts';
+import Paths from '../../../constants/Paths';
+import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums';
+
+const canEditCardName = (boardMembership, list) => {
+ if (isListArchiveOrTrash(list)) {
+ return false;
+ }
+
+ return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
+};
+
+const canArchiveCard = (boardMembership, list) => {
+ if (list.type === ListTypes.ARCHIVE) {
+ return false;
+ }
+
+ return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
+};
+
+const canUseCardLabels = (boardMembership, list) => {
+ if (isListArchiveOrTrash(list)) {
+ return false;
+ }
+
+ return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
+};
+
+const ShortcutsProvider = React.memo(({ children }) => {
+ const { cardId, boardId } = useSelector(selectors.selectPath);
+
+ const dispatch = useDispatch();
+
+ const selectedCardRef = useRef(null);
+
+ const handleCardMouseEnter = useCallback((id, editName, archive) => {
+ selectedCardRef.current = {
+ id,
+ editName,
+ archive,
+ };
+ }, []);
+
+ const handleCardMouseLeave = useCallback(() => {
+ selectedCardRef.current = null;
+ }, []);
+
+ const contextValue = useMemo(
+ () => [handleCardMouseEnter, handleCardMouseLeave],
+ [handleCardMouseEnter, handleCardMouseLeave],
+ );
+
+ useDidUpdate(() => {
+ selectedCardRef.current = null;
+ }, [cardId, boardId]);
+
+ useEffect(() => {
+ const handleCardOpen = () => {
+ if (!selectedCardRef.current) {
+ return;
+ }
+
+ const state = store.getState();
+ const card = selectors.selectCardById(state, selectedCardRef.current.id);
+
+ if (!card || !card.isPersisted) {
+ return;
+ }
+
+ dispatch(push(Paths.CARDS.replace(':id', card.id)));
+ };
+
+ const handleCardNameEdit = () => {
+ if (!selectedCardRef.current) {
+ return;
+ }
+
+ const state = store.getState();
+ const card = selectors.selectCardById(state, selectedCardRef.current.id);
+
+ if (!card || !card.isPersisted) {
+ return;
+ }
+
+ const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
+ const list = selectors.selectListById(state, card.listId);
+
+ if (!canEditCardName(boardMembership, list)) {
+ return;
+ }
+
+ selectedCardRef.current.editName();
+ };
+
+ const handleCardArchive = () => {
+ if (!selectedCardRef.current) {
+ return;
+ }
+
+ const state = store.getState();
+ const card = selectors.selectCardById(state, selectedCardRef.current.id);
+
+ if (!card || !card.isPersisted) {
+ return;
+ }
+
+ const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
+ const list = selectors.selectListById(state, card.listId);
+
+ if (!canArchiveCard(boardMembership, list)) {
+ return;
+ }
+
+ selectedCardRef.current.archive();
+ };
+
+ const handleLabelToCardAdd = (index) => {
+ if (!selectedCardRef.current) {
+ return;
+ }
+
+ const state = store.getState();
+ const card = selectors.selectCardById(state, selectedCardRef.current.id);
+
+ if (!card || !card.isPersisted) {
+ return;
+ }
+
+ const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
+ const list = selectors.selectListById(state, card.listId);
+
+ if (!canUseCardLabels(boardMembership, list)) {
+ return;
+ }
+
+ const label = selectors.selectLabelsForCurrentBoard(state)[index];
+
+ if (!label) {
+ return;
+ }
+
+ const labelIds = selectors.selectLabelIdsByCardId(state, card.id);
+
+ if (labelIds.includes(label.id)) {
+ dispatch(entryActions.removeLabelFromCard(label.id, card.id));
+ } else {
+ dispatch(entryActions.addLabelToCard(label.id, card.id));
+ }
+ };
+
+ const handleKeyDown = (event) => {
+ if (isActiveTextElement(event.target)) {
+ return;
+ }
+
+ if (isModifierKeyPressed(event)) {
+ return;
+ }
+
+ switch (event.code) {
+ case 'KeyE':
+ case 'Enter':
+ handleCardOpen();
+
+ break;
+ case 'KeyT':
+ event.preventDefault();
+ handleCardNameEdit();
+
+ break;
+ case 'KeyV':
+ handleCardArchive();
+
+ break;
+ case 'Digit1':
+ case 'Digit2':
+ case 'Digit3':
+ case 'Digit4':
+ case 'Digit5':
+ case 'Digit6':
+ case 'Digit7':
+ case 'Digit8':
+ case 'Digit9':
+ case 'Digit0': {
+ const index = event.code === 'Digit0' ? 10 : parseInt(event.code.slice(-1), 10) - 1;
+ handleLabelToCardAdd(index);
+
+ break;
+ }
+ default:
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [dispatch]);
+
+ return (
+ {children}
+ );
+});
+
+ShortcutsProvider.propTypes = {
+ children: PropTypes.element.isRequired,
+};
+
+export default ShortcutsProvider;
diff --git a/client/src/components/cards/Card/ActionsStep.jsx b/client/src/components/cards/Card/ActionsStep.jsx
index 5adae2c6..237610fa 100644
--- a/client/src/components/cards/Card/ActionsStep.jsx
+++ b/client/src/components/cards/Card/ActionsStep.jsx
@@ -36,7 +36,7 @@ const StepTypes = {
DELETE: 'DELETE',
};
-const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
+const ActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -104,7 +104,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
const dispatch = useDispatch();
const [t] = useTranslation();
- const [step, openStep, handleBack] = useSteps();
+ const [step, openStep, handleBack] = useSteps(defaultStep || null);
const handleTypeSelect = useCallback(
(type) => {
@@ -397,8 +397,15 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
ActionsStep.propTypes = {
cardId: PropTypes.string.isRequired,
+ defaultStep: PropTypes.string,
onNameEdit: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
+ActionsStep.defaultProps = {
+ defaultStep: undefined,
+};
+
+ActionsStep.StepTypes = StepTypes;
+
export default ActionsStep;
diff --git a/client/src/components/cards/Card/Card.jsx b/client/src/components/cards/Card/Card.jsx
index 37e614c0..cacdae9e 100755
--- a/client/src/components/cards/Card/Card.jsx
+++ b/client/src/components/cards/Card/Card.jsx
@@ -5,7 +5,7 @@
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
-import React, { useCallback, useMemo, useRef, useState } from 'react';
+import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -14,6 +14,7 @@ import { push } from '../../../lib/redux-router';
import { closePopup, usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
+import { BoardShortcutsContext } from '../../../contexts';
import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent';
@@ -50,6 +51,7 @@ const Card = React.memo(({ id, isInline }) => {
const dispatch = useDispatch();
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
+ const [handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
const actionsPopupRef = useRef(null);
@@ -61,6 +63,22 @@ const Card = React.memo(({ id, isInline }) => {
dispatch(push(Paths.CARDS.replace(':id', id)));
}, [id, dispatch]);
+ const handleMouseEnter = useCallback(() => {
+ handleCardMouseEnter(
+ id,
+ () => {
+ setIsEditNameOpened(true);
+ },
+ () => {
+ closePopup();
+
+ actionsPopupRef.current.open({
+ defaultStep: ActionsStep.StepTypes.ARCHIVE,
+ });
+ },
+ );
+ }, [id, handleCardMouseEnter]);
+
const handleContextMenu = useCallback((event) => {
if (!actionsPopupRef.current) {
return;
@@ -115,6 +133,8 @@ const Card = React.memo(({ id, isInline }) => {
return (
{card.isPersisted ? (
<>
diff --git a/client/src/contexts/BoardShortcutsContext.js b/client/src/contexts/BoardShortcutsContext.js
new file mode 100644
index 00000000..054473ec
--- /dev/null
+++ b/client/src/contexts/BoardShortcutsContext.js
@@ -0,0 +1,8 @@
+/*!
+ * Copyright (c) 2024 PLANKA Software GmbH
+ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
+ */
+
+import { createContext } from 'react';
+
+export default createContext([null, null]);
diff --git a/client/src/contexts/index.js b/client/src/contexts/index.js
index 654eba28..dbf5a22b 100644
--- a/client/src/contexts/index.js
+++ b/client/src/contexts/index.js
@@ -4,5 +4,6 @@
*/
import ClosableContext from './ClosableContext';
+import BoardShortcutsContext from './BoardShortcutsContext';
-export { ClosableContext }; // eslint-disable-line import/prefer-default-export
+export { ClosableContext, BoardShortcutsContext };
diff --git a/client/src/lib/popup/use-popup.jsx b/client/src/lib/popup/use-popup.jsx
index d0f59110..3c487b83 100644
--- a/client/src/lib/popup/use-popup.jsx
+++ b/client/src/lib/popup/use-popup.jsx
@@ -13,13 +13,13 @@ import styles from './Popup.module.css';
export default (Step, { position, onOpen, onClose } = {}) => {
return useMemo(() => {
const Popup = React.forwardRef(({ children, ...stepProps }, ref) => {
- const [isOpened, setIsOpened] = useState(false);
+ const [stepParams, setStepParams] = useState(null);
const wrapperRef = useRef(null);
const resizeObserverRef = useRef(null);
- const open = useCallback(() => {
- setIsOpened(true);
+ const open = useCallback((params = {}) => {
+ setStepParams(params);
if (onOpen) {
onOpen();
@@ -31,7 +31,7 @@ export default (Step, { position, onOpen, onClose } = {}) => {
}, [open]);
const handleClose = useCallback(() => {
- setIsOpened(false);
+ setStepParams(null);
}, []);
const handleMouseDown = useCallback((event) => {
@@ -96,7 +96,7 @@ export default (Step, { position, onOpen, onClose } = {}) => {
ref={wrapperRef}
trigger={tigger}
on="click"
- open={isOpened}
+ open={!!stepParams}
position={position || 'bottom left'}
popperModifiers={[
{
@@ -118,7 +118,7 @@ export default (Step, { position, onOpen, onClose } = {}) => {
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+
);