From 561d2c77d4fdbfb7bfe98b58dc8c8c91a57eb086 Mon Sep 17 00:00:00 2001 From: Serge Zaitsev Date: Mon, 24 Nov 2025 19:35:31 +0800 Subject: [PATCH] feat: Add basic shortcuts (#1436) --- client/src/components/boards/Board/Board.jsx | 5 +- .../boards/Board/ShortcutsProvider.jsx | 227 ++++++++++++++++++ .../src/components/cards/Card/ActionsStep.jsx | 11 +- client/src/components/cards/Card/Card.jsx | 22 +- client/src/contexts/BoardShortcutsContext.js | 8 + client/src/contexts/index.js | 3 +- client/src/lib/popup/use-popup.jsx | 12 +- 7 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 client/src/components/boards/Board/ShortcutsProvider.jsx create mode 100644 client/src/contexts/BoardShortcutsContext.js 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 } = {}) => {
);