From 3329382415ac80625ecd681b3f566acb29194965 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Tue, 12 Oct 2021 20:16:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20QuickSwitcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/i18n/langs/en-US/translation.json | 2 + shared/i18n/langs/zh-CN/translation.json | 2 + web/package.json | 2 +- web/src/components/Modal.tsx | 5 +- web/src/components/QuickSwitcher.tsx | 147 +++++++++++++++++++++++ web/src/hooks/useGlobalKeyDown.ts | 22 ++++ web/src/routes/Main/index.tsx | 2 + web/src/routes/Main/useShortcuts.tsx | 15 +++ web/src/utils/dom-helper.ts | 7 ++ web/src/utils/hot-key.ts | 6 + yarn.lock | 12 +- 11 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 web/src/components/QuickSwitcher.tsx create mode 100644 web/src/hooks/useGlobalKeyDown.ts create mode 100644 web/src/routes/Main/useShortcuts.tsx diff --git a/shared/i18n/langs/en-US/translation.json b/shared/i18n/langs/en-US/translation.json index 5a69b4fc..3c8ebfbd 100644 --- a/shared/i18n/langs/en-US/translation.json +++ b/shared/i18n/langs/en-US/translation.json @@ -16,6 +16,7 @@ "k249e23b9": "E-mail format is incorrect", "k24ccd723": "Refresh now", "k267cc491": "Me", + "k2a8031e": "Homepage", "k2d6cfb27": "Chat Channel", "k2ec4966c": "Selected {{num}} items", "k3172297b": "This feature is not yet open", @@ -53,6 +54,7 @@ "k5bec387": "Unable to get group information", "k5f91e72c": "Built Plugins", "k61a1db2": "Already applied", + "k621f7e70": "Quick search, jump", "k62f009e7": "Modify group name success", "k630ccbcb": "Plugin install successed", "k63857abf": "Confirm Create", diff --git a/shared/i18n/langs/zh-CN/translation.json b/shared/i18n/langs/zh-CN/translation.json index 850dcbbe..fe7ff9f7 100644 --- a/shared/i18n/langs/zh-CN/translation.json +++ b/shared/i18n/langs/zh-CN/translation.json @@ -16,6 +16,7 @@ "k249e23b9": "邮箱格式不正确", "k24ccd723": "立即刷新", "k267cc491": "我", + "k2a8031e": "个人主页", "k2d6cfb27": "聊天频道", "k2ec4966c": "已选择 {{num}} 项", "k3172297b": "该功能暂未开放", @@ -53,6 +54,7 @@ "k5bec387": "无法获取到群组信息", "k5f91e72c": "内置插件", "k61a1db2": "已申请", + "k621f7e70": "快速搜索、跳转", "k62f009e7": "修改群组名成功", "k630ccbcb": "插件安装成功", "k63857abf": "确认创建", diff --git a/web/package.json b/web/package.json index 61bc481a..3ef48773 100644 --- a/web/package.json +++ b/web/package.json @@ -48,7 +48,7 @@ "@testing-library/react-hooks": "^7.0.1", "@types/copy-webpack-plugin": "^8.0.0", "@types/dts-generator": "^2.1.6", - "@types/is-hotkey": "^0.1.3", + "@types/is-hotkey": "^0.1.5", "@types/mini-css-extract-plugin": "^1.4.3", "@types/node": "^15.12.5", "@types/react": "^17.0.11", diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index 06abc22a..3379a07d 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -13,6 +13,7 @@ import clsx from 'clsx'; import { useIsMobile } from '@/hooks/useIsMobile'; import './Modal.less'; +import { stopPropagation } from '@/utils/dom-helper'; const transitionEndListener = (node: HTMLElement, done: () => void) => node.addEventListener('transitionend', done, false); @@ -63,10 +64,6 @@ export const Modal: React.FC = React.memo((props) => { closeModal(); }, [maskClosable, closeModal]); - const stopPropagation = useCallback((e: React.BaseSyntheticEvent) => { - e.stopPropagation(); - }, []); - if (visible === false) { return null; } diff --git a/web/src/components/QuickSwitcher.tsx b/web/src/components/QuickSwitcher.tsx new file mode 100644 index 00000000..af573ad9 --- /dev/null +++ b/web/src/components/QuickSwitcher.tsx @@ -0,0 +1,147 @@ +import { stopPropagation } from '@/utils/dom-helper'; +import { Input } from 'antd'; +import React, { useCallback, useMemo, useState } from 'react'; +import { t } from 'tailchat-shared'; +import { PortalAdd, PortalRemove } from './Portal'; +import _take from 'lodash/take'; +import clsx from 'clsx'; +import { useGlobalKeyDown } from '@/hooks/useGlobalKeyDown'; +import { isArrowDown, isArrowUp, isEnterHotkey } from '@/utils/hot-key'; +import { useHistory } from 'react-router'; + +let currentQuickSwitcherKey: number | null = null; + +interface QuickActionContext { + navigate: (url: string) => void; +} + +interface QuickAction { + key: string; + label: string; + action: (context: QuickActionContext) => void; +} + +const builtinActions: QuickAction[] = [ + { + key: 'personal', + label: t('个人主页'), + action({ navigate }) { + navigate('/main/personal/friends'); + }, + }, + { + key: 'plugins', + label: t('插件中心'), + action({ navigate }) { + navigate('/main/personal/plugins'); + }, + }, +]; + +function useQuickSwitcherActionContext(): QuickActionContext { + const history = useHistory(); + return { + navigate: (url) => { + history.push(url); + }, + }; +} + +const QuickSwitcher: React.FC = React.memo(() => { + const [keyword, setKeyword] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const actionContext = useQuickSwitcherActionContext(); + + const handleClose = useCallback(() => { + if (!currentQuickSwitcherKey) { + return; + } + + PortalRemove(currentQuickSwitcherKey); + currentQuickSwitcherKey = null; + }, []); + + const filteredActions = useMemo(() => { + return _take( + builtinActions.filter((action) => action.label.includes(keyword)), + 5 + ); + }, [keyword]); + + useGlobalKeyDown((e) => { + if (isArrowUp(e)) { + const newIndex = selectedIndex - 1; + setSelectedIndex( + newIndex >= 0 ? newIndex : filteredActions.length + newIndex + ); + } else if (isArrowDown(e)) { + setSelectedIndex((selectedIndex + 1) % filteredActions.length); + } + + if (isEnterHotkey(e)) { + const selectedAction = filteredActions[selectedIndex]; + typeof selectedAction.action === 'function' && + selectedAction.action(actionContext); + handleClose(); + } + }); + + return ( +
+
+ setKeyword(e.target.value)} + /> + + {filteredActions.map((action, i) => ( +
+
{action.label}
+
+ {action.key} +
+
+ ))} +
+
+ ); +}); +QuickSwitcher.displayName = 'QuickSwitcher'; + +/** + * 打开快速开关 + */ +export function openQuickSwitcher() { + if (typeof currentQuickSwitcherKey === 'number') { + return; + } + + currentQuickSwitcherKey = PortalAdd(); +} diff --git a/web/src/hooks/useGlobalKeyDown.ts b/web/src/hooks/useGlobalKeyDown.ts new file mode 100644 index 00000000..7803313c --- /dev/null +++ b/web/src/hooks/useGlobalKeyDown.ts @@ -0,0 +1,22 @@ +import { useLayoutEffect } from 'react'; +import { useUpdateRef } from 'tailchat-shared'; + +/** + * keydown hooks + * 仅接受最初的函数 + */ +export function useGlobalKeyDown(fn: (e: KeyboardEvent) => void) { + const fnRef = useUpdateRef(fn); + + useLayoutEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + typeof fnRef.current === 'function' && fnRef.current(e); + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); +} diff --git a/web/src/routes/Main/index.tsx b/web/src/routes/Main/index.tsx index b49e2c42..12063559 100644 --- a/web/src/routes/Main/index.tsx +++ b/web/src/routes/Main/index.tsx @@ -3,9 +3,11 @@ import React from 'react'; import { MainContent } from './Content'; import { Navbar } from './Navbar'; import { MainProvider } from './Provider'; +import { useShortcuts } from './useShortcuts'; export const MainRoute: React.FC = React.memo(() => { useRecordMeasure('AppMainRenderStart'); + useShortcuts(); return (
diff --git a/web/src/routes/Main/useShortcuts.tsx b/web/src/routes/Main/useShortcuts.tsx new file mode 100644 index 00000000..fe7335d2 --- /dev/null +++ b/web/src/routes/Main/useShortcuts.tsx @@ -0,0 +1,15 @@ +import { openQuickSwitcher } from '@/components/QuickSwitcher'; +import { useGlobalKeyDown } from '@/hooks/useGlobalKeyDown'; +import { isQuickSwitcher } from '@/utils/hot-key'; + +/** + * 全局快捷键 + */ +export function useShortcuts() { + useGlobalKeyDown((e) => { + if (isQuickSwitcher(e)) { + // 显示快速开关 + openQuickSwitcher(); + } + }); +} diff --git a/web/src/utils/dom-helper.ts b/web/src/utils/dom-helper.ts index f66c28c0..5eb816ad 100644 --- a/web/src/utils/dom-helper.ts +++ b/web/src/utils/dom-helper.ts @@ -10,3 +10,10 @@ export function getPopupContainer() { return document.body; } + +/** + * 一个快捷方案用于直接在组件中执行 stopPropagation + */ +export function stopPropagation(e: React.BaseSyntheticEvent) { + e.stopPropagation(); +} diff --git a/web/src/utils/hot-key.ts b/web/src/utils/hot-key.ts index d658f669..8195c65d 100644 --- a/web/src/utils/hot-key.ts +++ b/web/src/utils/hot-key.ts @@ -1,3 +1,9 @@ import { isHotkey } from 'is-hotkey'; export const isEnterHotkey = isHotkey('enter'); + +export const isQuickSwitcher = isHotkey('mod+k'); + +export const isArrowUp = isHotkey('up'); + +export const isArrowDown = isHotkey('down'); diff --git a/yarn.lock b/yarn.lock index a5ca7763..cab18cba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1936,10 +1936,10 @@ dependencies: "@types/node" "*" -"@types/is-hotkey@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@types/is-hotkey/-/is-hotkey-0.1.3.tgz#1e86be048d3af160e8e676d5cd463f6f7061589a" - integrity sha512-Hz+eHHpMWLBX1CpDXSuQre9nYXN2e2VGVHvkkldxDzo9eFtRpHm5iOlJlZvnNGvele5584cUSkRnFRQb+Wcu0w== +"@types/is-hotkey@^0.1.5": + version "0.1.5" + resolved "https://registry.nlark.com/@types/is-hotkey/download/@types/is-hotkey-0.1.5.tgz#f3123ba21228c0408c10594abf378caddbb802f8" + integrity sha1-8xI7ohIowECMEFlKvzeMrdu4Avg= "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" @@ -6375,8 +6375,8 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: is-hotkey@^0.2.0: version "0.2.0" - resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.2.0.tgz#1835a68171a91e5c9460869d96336947c8340cef" - integrity sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw== + resolved "https://registry.npm.taobao.org/is-hotkey/download/is-hotkey-0.2.0.tgz?cache=0&sync_timestamp=1616273570971&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fis-hotkey%2Fdownload%2Fis-hotkey-0.2.0.tgz#1835a68171a91e5c9460869d96336947c8340cef" + integrity sha1-GDWmgXGpHlyUYIadljNpR8g0DO8= is-interactive@^1.0.0: version "1.0.0"