├── example ├── postcss.config.js ├── tsconfig.json ├── index.html └── index.tsx ├── src ├── enums │ ├── index.ts │ └── modifier.ts ├── global.d.ts ├── utils │ ├── tool.ts │ ├── clsx.ts │ ├── merge.ts │ ├── debounce.ts │ ├── css.ts │ ├── input.ts │ └── chunk.ts ├── tsconfig.build.json ├── scripts │ ├── empty-project │ │ └── package.json │ └── netlify.sh ├── tsconfig.cjs.json ├── index.ts ├── tsconfig.json ├── hooks │ ├── index.ts │ ├── use-state-to-ref.ts │ ├── use-shortcut-options.ts │ ├── use-shortcut-list.ts │ ├── use-shortcut.ts │ └── use-media-color.ts ├── components │ ├── GuidePanelAnimated.module.css │ ├── Guide.module.css │ ├── Provider.tsx │ ├── Guide.tsx │ └── GuidePanelAnimated.tsx ├── helper │ └── index.ts ├── constants │ └── key-map.ts ├── types │ ├── index.ts │ └── key.ts └── context │ └── ShortcutContext.tsx ├── .prettierrc.js ├── .husky └── pre-commit ├── .npmrc ├── scripts ├── empty-project │ └── package.json └── netlify.sh ├── netlify.toml ├── .vscode └── settings.json ├── postcss.config.js ├── .eslintrc.js ├── vitest.config.ts ├── renovate.json ├── vite.config.js ├── tsconfig.json ├── .github └── workflows │ └── build.yml ├── .gitignore ├── rollup.config.js ├── package.json └── readme.md /example/postcss.config.js: -------------------------------------------------------------------------------- 1 | ../postcss.config.js -------------------------------------------------------------------------------- /src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './modifier' 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@innei-util/prettier') 2 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global {} 2 | export * from 'vite/client' 3 | export {} 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | lint-staged 5 | -------------------------------------------------------------------------------- /src/utils/tool.ts: -------------------------------------------------------------------------------- 1 | export const uniqueArray = (arr: T[]): T[] => [...new Set(arr)] 2 | -------------------------------------------------------------------------------- /src/utils/clsx.ts: -------------------------------------------------------------------------------- 1 | export const clsx = (...args: any[]): string => args.filter(Boolean).join(' ') 2 | -------------------------------------------------------------------------------- /src/utils/merge.ts: -------------------------------------------------------------------------------- 1 | export const merge = (...args: T[]): T => { 2 | return Object.assign({}, ...args) 3 | } 4 | -------------------------------------------------------------------------------- /src/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "__tests__/**/*.ts" 5 | ] 6 | } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | # https://zenn.dev/haxibami/scraps/083718c1beec04 3 | strict-peer-dependencies=false 4 | -------------------------------------------------------------------------------- /scripts/empty-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This is an empty project used as the destination of netlify npm install." 3 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "bash ./scripts/netlify.sh" 3 | [build.environment] 4 | NPM_FLAGS="--prefix=./scripts/empty-project/" -------------------------------------------------------------------------------- /src/scripts/empty-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This is an empty project used as the destination of netlify npm install." 3 | } -------------------------------------------------------------------------------- /src/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "../lib", 6 | } 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "exportall.config.relExclusion": [ 3 | "/src/hooks" 4 | ], 5 | "exportall.config.folderListener": [ 6 | "/src/hooks" 7 | ] 8 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'postcss-nested': {}, 3 | 'postcss-preset-env': {}, 4 | // cssnano: { 5 | // preset: ['default'], 6 | // }, 7 | } 8 | -------------------------------------------------------------------------------- /scripts/netlify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | test "$CI" = true || exit 1 4 | npx pnpm install -r --store-dir=node_modules/.pnpm-store 5 | npm run build:vite 6 | -------------------------------------------------------------------------------- /src/scripts/netlify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | test "$CI" = true || exit 1 4 | npx pnpm install -r --store-dir=node_modules/.pnpm-store 5 | npm run build 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/Guide' 2 | export * from './components/Provider' 3 | export * from './context/ShortcutContext' 4 | export * from './enums' 5 | export * from './hooks' 6 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../esm", 6 | "baseUrl": ".", 7 | "jsx": "react" 8 | } 9 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true, 7 | }, 8 | extends: ['@innei-util/eslint-config-react-ts'], 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-media-color' 2 | export * from './use-shortcut-list' 3 | export * from './use-shortcut-options' 4 | export * from './use-shortcut' 5 | export * from './use-state-to-ref' 6 | -------------------------------------------------------------------------------- /src/components/GuidePanelAnimated.module.css: -------------------------------------------------------------------------------- 1 | .panel { 2 | opacity: 1; 3 | visibility: 1; 4 | } 5 | 6 | .panel.disappear { 7 | opacity: 0; 8 | visibility: hidden; 9 | transition: all 0.3s ease-in-out; 10 | } 11 | -------------------------------------------------------------------------------- /src/enums/modifier.ts: -------------------------------------------------------------------------------- 1 | export enum Modifier { 2 | Alt = 'Alt', 3 | Option = 'Alt', 4 | Control = 'Control', 5 | 6 | Meta = 'Meta', 7 | Command = 'Meta', 8 | Shift = 'Shift', 9 | 10 | None = '', 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = (fn: (...args: any[]) => any, wait: number) => { 2 | let timeout: any 3 | return (...args: any[]) => { 4 | clearTimeout(timeout) 5 | timeout = setTimeout(() => fn.call(null, ...args), wait) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/css.ts: -------------------------------------------------------------------------------- 1 | export const injectCSS = (css: string) => { 2 | const $style = document.createElement('style') 3 | $style.innerHTML = css 4 | document.head.appendChild($style) 5 | 6 | return () => { 7 | document.head.removeChild($style) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/use-state-to-ref.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | 3 | export const useStateToRef = (state: T) => { 4 | const ref = React.useRef(state) 5 | useEffect(() => { 6 | ref.current = state 7 | }, [state]) 8 | 9 | return ref 10 | } 11 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react", 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": [ 8 | "../src/*" 9 | ], 10 | "~": [ 11 | "../src" 12 | ] 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/hooks/use-shortcut-options.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | import { ShortcutContext } from '..' 4 | 5 | export const useShortcutOptions = () => { 6 | const { options, setOptions } = useContext(ShortcutContext) 7 | return { 8 | options, 9 | setOptions, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsPath from 'vite-tsconfig-paths' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | // eslint-disable-next-line import/no-default-export 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | include: ['src/__tests__/**/*.(spec|test).ts'], 9 | }, 10 | plugins: [tsPath()], 11 | }) 12 | -------------------------------------------------------------------------------- /src/hooks/use-shortcut-list.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | import { ShortcutContext } from '../context/ShortcutContext' 4 | 5 | /** 6 | * omit `hidden in panel` 7 | */ 8 | export const useShortcutList = () => { 9 | const { shortcuts } = useContext(ShortcutContext) 10 | return shortcuts.filter(({ hiddenInPanel }) => !hiddenInPanel) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/input.ts: -------------------------------------------------------------------------------- 1 | export const checkIsPressInInputEl = () => { 2 | const $activeElement = document.activeElement as HTMLElement 3 | 4 | if ( 5 | $activeElement && 6 | (['input', 'textarea'].includes($activeElement.tagName.toLowerCase()) || 7 | $activeElement.getAttribute('contenteditable') === 'true') 8 | ) { 9 | return true 10 | } 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /src/helper/index.ts: -------------------------------------------------------------------------------- 1 | import { RegisterShortcutType } from '~/types' 2 | 3 | class GuideStatic { 4 | private _registerShortcut: RegisterShortcutType | null = null 5 | setRegister(fn: RegisterShortcutType | null) { 6 | this._registerShortcut = fn 7 | } 8 | 9 | public get registerShortcut() { 10 | return this._registerShortcut ?? (() => () => void 0) 11 | } 12 | } 13 | 14 | export const Guide = new GuideStatic() 15 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": false, 3 | "extends": [ 4 | "config:base", 5 | ":semanticCommits", 6 | ":automergePatch", 7 | ":automergeTypes", 8 | ":automergeTesters", 9 | ":automergeLinters", 10 | ":automergeMinor", 11 | ":rebaseStalePrs" 12 | ], 13 | "packageRules": [ 14 | { 15 | "updateTypes": [ 16 | "major" 17 | ], 18 | "labels": [ 19 | "UPDATE-MAJOR" 20 | ] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /src/hooks/use-shortcut.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | import { Guide } from '~/helper' 4 | import { CleanupFn, RegisterShortcutType } from '~/types' 5 | 6 | export const useShortcut: ( 7 | ...rest: Parameters 8 | ) => CleanupFn = (...rest) => { 9 | const cleanupRef = useRef<() => void>() 10 | useEffect(() => { 11 | const cleanup = Guide.registerShortcut(...rest) 12 | cleanupRef.current = cleanup 13 | return cleanup 14 | }, [rest]) 15 | 16 | return () => cleanupRef.current?.() 17 | } 18 | -------------------------------------------------------------------------------- /src/hooks/use-media-color.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useMediaColor = () => { 4 | const [dark, setDark] = useState(false) 5 | useEffect(() => { 6 | const mediaList = window.matchMedia('(prefers-color-scheme: dark)') 7 | const isDark = mediaList.matches 8 | setDark(isDark) 9 | const handler = (e: MediaQueryListEvent) => { 10 | setDark(e.matches) 11 | } 12 | mediaList.addEventListener('change', handler) 13 | return () => { 14 | mediaList.removeEventListener('change', handler) 15 | } 16 | }, []) 17 | 18 | return { dark } 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import tsconfigPaths from 'vite-tsconfig-paths' 3 | 4 | import react from '@vitejs/plugin-react' 5 | 6 | const { resolve } = require('path') 7 | 8 | export default defineConfig({ 9 | base: '', 10 | plugins: [ 11 | react(), 12 | tsconfigPaths({ 13 | projects: [ 14 | resolve(__dirname, './example/tsconfig.json'), 15 | resolve(__dirname, './tsconfig.json'), 16 | ], 17 | }), 18 | ], 19 | root: resolve(__dirname, './example'), 20 | build: { 21 | emptyOutDir: true, 22 | 23 | rollupOptions: { 24 | input: resolve(__dirname, './example/index.html'), 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/constants/key-map.ts: -------------------------------------------------------------------------------- 1 | import { Modifier } from '~/enums/modifier' 2 | import { SpecialKey } from '~/types/key' 3 | 4 | export const macosMetaKeyCharMap: Record = { 5 | Meta: '⌘', 6 | Command: '⌘', 7 | None: '', 8 | Option: '⌥', 9 | 10 | Alt: '⌥', 11 | Control: '⌃', 12 | Shift: '⇧', 13 | } 14 | 15 | export const otherKeyCharMap: Record = { 16 | Enter: '↩', 17 | Tab: '⇥', 18 | Backspace: '⌫', 19 | Escape: '⎋', 20 | ArrowUp: '↑', 21 | ArrowDown: '↓', 22 | ArrowLeft: '←', 23 | ArrowRight: '→', 24 | Delete: '⌦', 25 | Home: '⇱', 26 | End: '⇲', 27 | PageUp: '⇞', 28 | PageDown: '⇟', 29 | Insert: '⌤', 30 | Space: '␣', 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "ESNext", 6 | "DOM", 7 | "DOM.Iterable" 8 | ], 9 | "module": "ESNext", 10 | "moduleResolution": "node", 11 | "jsx": "react", 12 | "strict": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "sourceMap": true, 18 | "declaration": true, 19 | "outDir": "./dist", 20 | "baseUrl": "./src", 21 | "types": [ 22 | "vite/client" 23 | ], 24 | "paths": { 25 | "~/*": [ 26 | "*" 27 | ] 28 | }, 29 | "plugins": [ 30 | { 31 | "transform": "@zerollup/ts-transform-paths", 32 | } 33 | ] 34 | } 35 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Modifier } from '..' 2 | import { Key } from './key' 3 | 4 | export type ShortcutType = { 5 | title: string 6 | keys: string[] 7 | jointKey: string 8 | action: (e: KeyboardEvent) => any 9 | } & Required 10 | 11 | export type RegisterShortcutOptions = { 12 | /** 13 | * 在输入框上禁用快捷键 14 | * @default true 15 | */ 16 | preventInput?: boolean 17 | /** 18 | * 不在 Guide 上显示这个快捷键指令 19 | * @default false 20 | */ 21 | hiddenInPanel?: boolean 22 | } 23 | 24 | export type RegisterShortcutType = ( 25 | key: Key, 26 | modifierFlags: Modifier[], 27 | action: (e: KeyboardEvent) => any, 28 | discoverabilityTitle: string, 29 | options?: RegisterShortcutOptions, 30 | ) => CleanupFn 31 | 32 | export type CleanupFn = () => void 33 | -------------------------------------------------------------------------------- /src/utils/chunk.ts: -------------------------------------------------------------------------------- 1 | export function chunkTwo(array: T[]): T[][] { 2 | const length = array == null ? 0 : array.length 3 | const result = [[], []] as T[][] 4 | if (!length) { 5 | return result 6 | } 7 | 8 | if (length == 2) { 9 | return [[array[0]], [array[1]]] 10 | } 11 | 12 | const midIndex = Math.ceil(array.length / 2) 13 | 14 | return [array.slice(0, midIndex), array.slice(midIndex, array.length)] 15 | } 16 | 17 | export const chunk = (array: T[], size: number) => { 18 | const length = array == null ? 0 : array.length 19 | if (!length || !size || size < 1) { 20 | return [] 21 | } 22 | 23 | const result = [] 24 | let index = 0 25 | while (index < length) { 26 | result.push(array.slice(index, (index += size))) 27 | } 28 | 29 | return result 30 | } 31 | -------------------------------------------------------------------------------- /src/context/ShortcutContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { RegisterShortcutType, ShortcutType } from '~/types' 4 | 5 | export type ShortcutOptions = { 6 | darkMode?: 'media' | 'class' 7 | /** 8 | * @default 'body.dark' 9 | */ 10 | darkClassName?: string 11 | 12 | /** 13 | * 长按 Command 呼出的时间 14 | * @default 1000 15 | */ 16 | holdCommandTimeout?: number 17 | 18 | /** 19 | * 释放 Command 后的 Guide Panel 停留时间 20 | * @default 1000 21 | */ 22 | stayCommandTimeout?: number 23 | 24 | /** 25 | * Guide 打开事件 26 | */ 27 | onGuidePanelOpen?: () => any 28 | /** 29 | * Guide 关闭事件 30 | */ 31 | onGuidePanelClose?: () => any 32 | /** 33 | * 每页最大个数,分页 34 | * @default 12 35 | */ 36 | maxItemEveryPage?: number 37 | /** 38 | * 受控态 39 | * @default false 40 | */ 41 | open?: boolean 42 | } 43 | export type ShortcutContextValue = { 44 | shortcuts: ShortcutType[] 45 | registerShortcut: RegisterShortcutType 46 | options: ShortcutOptions 47 | setOptions: (options: Partial) => void 48 | } 49 | export const ShortcutContext = React.createContext({ 50 | shortcuts: [], 51 | registerShortcut() { 52 | return () => {} 53 | }, 54 | options: {}, 55 | setOptions: () => void 0, 56 | }) 57 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Cache pnpm modules 27 | uses: actions/cache@v2 28 | env: 29 | cache-name: cache-pnpm-modules 30 | with: 31 | path: ~/.pnpm-store 32 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/package.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 35 | 36 | - uses: pnpm/action-setup@v2.0.1 37 | with: 38 | version: 7.x.x 39 | run_install: true 40 | - run: pnpm run package 41 | - run: pnpm run test 42 | - run: pnpm run deploy 43 | env: 44 | CI: true 45 | -------------------------------------------------------------------------------- /src/types/key.ts: -------------------------------------------------------------------------------- 1 | export type NormalKey = 2 | | 'A' 3 | | 'B' 4 | | 'C' 5 | | 'D' 6 | | 'E' 7 | | 'F' 8 | | 'G' 9 | | 'H' 10 | | 'I' 11 | | 'J' 12 | | 'K' 13 | | 'L' 14 | | 'M' 15 | | 'N' 16 | | 'O' 17 | | 'P' 18 | | 'Q' 19 | | 'R' 20 | | 'S' 21 | | 'T' 22 | | 'U' 23 | | 'V' 24 | | 'W' 25 | | 'X' 26 | | 'Y' 27 | | 'Z' 28 | | '0' 29 | | '1' 30 | | '2' 31 | | '3' 32 | | '4' 33 | | '5' 34 | | '6' 35 | | '7' 36 | | '8' 37 | | '9' 38 | 39 | export type SymbolKey = 40 | | '(' 41 | | ')' 42 | | '-' 43 | | '=' 44 | | '[' 45 | | ']' 46 | | '\\' 47 | | ';' 48 | | "'" 49 | | ',' 50 | | '.' 51 | | '/' 52 | | '`' 53 | | '~' 54 | | '!' 55 | | '@' 56 | | '#' 57 | | '$' 58 | | '%' 59 | | '^' 60 | | '&' 61 | | '*' 62 | | '_' 63 | | '+' 64 | | '|' 65 | | ':' 66 | | '"' 67 | | '<' 68 | | '>' 69 | | '?' 70 | | ' ' 71 | 72 | export type FunctionKey = 73 | | 'F1' 74 | | 'F2' 75 | | 'F3' 76 | | 'F4' 77 | | 'F5' 78 | | 'F6' 79 | | 'F7' 80 | | 'F8' 81 | | 'F9' 82 | | 'F10' 83 | | 'F11' 84 | | 'F12' 85 | 86 | export type SpecialKey = 87 | | 'Home' 88 | | 'End' 89 | | 'PageUp' 90 | | 'PageDown' 91 | | 'Insert' 92 | | 'Backspace' 93 | | 'Delete' 94 | | 'Space' 95 | | 'Enter' 96 | | 'Tab' 97 | | 'Escape' 98 | | 'ArrowUp' 99 | | 'ArrowDown' 100 | | 'ArrowLeft' 101 | | 'ArrowRight' 102 | 103 | export type Key = NormalKey | SymbolKey | FunctionKey | SpecialKey 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | node_modules 3 | /node_modules 4 | .DS_Store 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | 85 | # Gatsby files 86 | .cache/ 87 | 88 | # vuepress build output 89 | .vuepress/dist 90 | 91 | # Serverless directories 92 | .serverless/ 93 | 94 | # FuseBox cache 95 | .fusebox/ 96 | 97 | # DynamoDB Local files 98 | .dynamodb/ 99 | 100 | # TernJS port file 101 | .tern-port 102 | 103 | # Stores VSCode versions used for testing VSCode extensions 104 | .vscode-test 105 | 106 | # yarn v2 107 | 108 | .yarn/cache 109 | .yarn/unplugged 110 | .yarn/build-state.yml 111 | .pnp.* 112 | 113 | # vim 114 | ctag 115 | /tags 116 | .undodir 117 | 118 | # idea 119 | # .idea/* 120 | 121 | /dist 122 | /publish 123 | /build 124 | /lib 125 | /esm 126 | /types 127 | 128 | example/dist 129 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as esbuild_ from 'rollup-plugin-esbuild' 3 | import peerDepsExternal from 'rollup-plugin-peer-deps-external' 4 | import postcss from 'rollup-plugin-postcss' 5 | 6 | import commonjs from '@rollup/plugin-commonjs' 7 | import { nodeResolve } from '@rollup/plugin-node-resolve' 8 | import typescript from '@rollup/plugin-typescript' 9 | 10 | const { minify } = esbuild_ 11 | 12 | const packageJson = require('./package.json') 13 | 14 | const globals = { 15 | // @ts-ignore 16 | ...packageJson.dependencies, 17 | } 18 | 19 | const dir = 'dist' 20 | 21 | /** 22 | * @type {import('rollup').RollupOptions[]} 23 | */ 24 | const config = [ 25 | { 26 | input: 'src/index.ts', 27 | // ignore lib 28 | external: [ 29 | 'react', 30 | 'react-dom', 31 | 'lodash', 32 | 'lodash-es', 33 | ...Object.keys(globals), 34 | ], 35 | 36 | output: [ 37 | { 38 | file: `${dir}/index.cjs.js`, 39 | format: 'cjs', 40 | sourcemap: true, 41 | }, 42 | { 43 | file: `${dir}/index.cjs.min.js`, 44 | format: 'cjs', 45 | sourcemap: true, 46 | plugins: [minify()], 47 | }, 48 | { 49 | file: `${dir}/index.esm.mjs`, 50 | format: 'es', 51 | sourcemap: true, 52 | }, 53 | { 54 | file: `${dir}/index.esm.min.mjs`, 55 | format: 'es', 56 | sourcemap: true, 57 | plugins: [minify()], 58 | }, 59 | ], 60 | plugins: [ 61 | nodeResolve(), 62 | postcss({ 63 | // config: './postcss.config.js', 64 | minimize: true, 65 | }), 66 | commonjs({ include: 'node_modules/**' }), 67 | typescript({ 68 | tsconfig: './src/tsconfig.json', 69 | declaration: false, 70 | jsx: 'react', 71 | }), 72 | // esbuild({ 73 | // include: /\.[jt]sx?$/, 74 | // exclude: /node_modules/, 75 | // sourceMap: false, 76 | // minify: process.env.NODE_ENV === 'production', 77 | // target: 'es2017', 78 | // jsxFactory: 'React.createElement', 79 | // jsxFragment: 'React.Fragment', 80 | // define: { 81 | // __VERSION__: '"x.y.z"', 82 | // }, 83 | // tsconfig: './src/tsconfig.json', 84 | // loaders: { 85 | // '.json': 'json', 86 | // '.js': 'jsx', 87 | // }, 88 | // }), 89 | // @ts-ignore 90 | peerDepsExternal(), 91 | ], 92 | 93 | treeshake: true, 94 | }, 95 | ] 96 | 97 | export default config 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-shortcut-guide", 3 | "version": "1.0.0", 4 | "description": "Long-press command or press `?` to present a shortcut guide for your Web application.", 5 | "author": "Innei", 6 | "license": "MIT", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.esm.mjs", 9 | "types": "dist/index.d.ts", 10 | "files": [ 11 | "dist", 12 | "readme.md", 13 | "tsconfig.json" 14 | ], 15 | "exports": { 16 | ".": { 17 | "type": "./dist/index.d.ts", 18 | "import": "./dist/index.esm.mjs", 19 | "require": "./dist/index.cjs.js" 20 | }, 21 | "./src": { 22 | "import": "./src" 23 | }, 24 | "./src/*": { 25 | "import": "./src/*" 26 | } 27 | }, 28 | "husky": { 29 | "hooks": { 30 | "pre-commit": "lint-staged" 31 | } 32 | }, 33 | "bump": { 34 | "before": [ 35 | "npm run build" 36 | ], 37 | "publish": true 38 | }, 39 | "lint-staged": { 40 | "*.{js,jsx,ts,tsx}": [ 41 | "prettier --ignore-path ./.prettierignore --write ", 42 | "eslint --cache" 43 | ] 44 | }, 45 | "scripts": { 46 | "prepare": "husky install", 47 | "predeploy": "rm -rf example/dist", 48 | "prebuild": "rm -rf rm -rf lib && rm -rf esm", 49 | "build": "NODE_ENV=production rollup -c --bundleConfigAsCjs", 50 | "postbuild": "dts-bundle-generator -o dist/index.d.ts src/index.ts --project tsconfig.json --no-check", 51 | "dev": "vite", 52 | "build:vite": "vite build", 53 | "preview": "vite preview --port 2323", 54 | "deploy": "vite build && gh-pages -d example/dist", 55 | "test": "vitest" 56 | }, 57 | "devDependencies": { 58 | "@geist-ui/core": "2.3.8", 59 | "@innei-util/eslint-config-react-ts": "0.8.2", 60 | "@innei-util/eslint-config-ts": "latest", 61 | "@innei-util/prettier": "latest", 62 | "@rollup/plugin-commonjs": "24.0.1", 63 | "@rollup/plugin-node-resolve": "15.0.1", 64 | "@rollup/plugin-typescript": "11.0.0", 65 | "@types/node": "18.15.3", 66 | "@types/react": "^18.0.28", 67 | "@types/react-dom": "^18.0.11", 68 | "@vitejs/plugin-react": "3.1.0", 69 | "@zerollup/ts-transform-paths": "1.7.18", 70 | "cssnano": "^5.1.15", 71 | "dts-bundle-generator": "7.2.0", 72 | "esbuild": "0.17.11", 73 | "gh-pages": "5.0.0", 74 | "husky": "8.0.3", 75 | "lint-staged": "13.2.0", 76 | "postcss": "8.4.21", 77 | "postcss-nested": "6.0.1", 78 | "postcss-preset-env": "8.0.1", 79 | "prettier": "2.8.4", 80 | "react": "^18.2.0", 81 | "react-dom": "^18.2.0", 82 | "rollup": "3.19.1", 83 | "rollup-plugin-esbuild": "5.0.0", 84 | "rollup-plugin-peer-deps-external": "2.2.4", 85 | "rollup-plugin-postcss": "4.0.2", 86 | "tslib": "2.5.0", 87 | "ttypescript": "1.5.15", 88 | "typescript": "4.9.5", 89 | "vite": "4.1.4", 90 | "vite-tsconfig-paths": "4.0.7", 91 | "vitest": "0.29.3" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # React Shortcut Guide 2 | 3 | Status: Alpha 4 | 5 | Long-press command or press `?` to present a shortcut guide for your Web application. 6 | 7 | 0 dependency, Gzip+minify ~ 3kB 8 | 9 | ![](https://fastly.jsdelivr.net/gh/Innei/fancy@master/2022/0530221552.png) 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm i react-shortcut-guide 15 | ``` 16 | 17 | ## BREAKING CHANGE 18 | 19 | After upgrade to v0.5.0, you must put react children out provider, use it as a single component without children. 20 | 21 | ```tsx 22 | <> 23 | 24 | 25 | 26 | ``` 27 | 28 | ## Usage 29 | 30 | 1. Add `ShortcutProvider` as a child element on root App component. 31 | 32 | ```tsx 33 | import React from 'react' 34 | import { render } from 'react-dom' 35 | import { ShortcutProvider } from 'react-shortcut-guide' 36 | 37 | render(, document.getElementById('app')) 38 | 39 | function App() { 40 | return ( 41 | <> 42 | 47 | 48 | 49 | ) 50 | } 51 | ``` 52 | 53 | 2. Register a shortcut by hook. 54 | 55 | ```ts 56 | import { useShortcut } from 'react-shortcut-guide' 57 | 58 | useShortcut( 59 | 'A', 60 | [Modifier.Meta], 61 | (e) => { 62 | console.log('a') 63 | }, 64 | 'Print A', 65 | options, 66 | ) 67 | ``` 68 | 69 | ## Options 70 | 71 | ProviderOptions: 72 | 73 | ```ts 74 | type ShortcutOptions = { 75 | darkMode?: 'media' | 'class' 76 | /** 77 | * @default 'body.dark' 78 | */ 79 | darkClassName?: string 80 | 81 | /** 82 | * 长按 Command 呼出的时间 83 | * @default 1000 84 | */ 85 | holdCommandTimeout?: number 86 | 87 | /** 88 | * 释放 Command 后的 Guide Panel 停留时间 89 | * @default 1000 90 | */ 91 | stayCommandTimeout?: number 92 | 93 | /** 94 | * Guide 打开事件 95 | */ 96 | onGuidePanelOpen?: () => any 97 | /** 98 | * Guide 关闭事件 99 | */ 100 | onGuidePanelClose?: () => any 101 | /** 102 | * 每页最大个数,分页 103 | * @default 12 104 | */ 105 | maxItemEveryPage?: number 106 | /** 107 | * 受控态 108 | */ 109 | controlledOpen?: boolean 110 | } 111 | ``` 112 | 113 | Hook Options: 114 | 115 | ```ts 116 | type RegisterShortcutOptions = { 117 | /** 118 | * 在输入框上禁用快捷键 119 | * @default true 120 | */ 121 | preventInput?: boolean 122 | /** 123 | * 不在 Guide 上显示这个快捷键指令 124 | * @default false 125 | */ 126 | hiddenInPanel?: boolean 127 | } 128 | ``` 129 | 130 | ## Reference 131 | 132 | - [ShortcutGuide](https://github.com/Lessica/ShortcutGuide) 133 | - [How to use UIKeyCommand to add keyboard shortcuts](https://www.hackingwithswift.com/example-code/uikit/how-to-use-uikeycommand-to-add-keyboard-shortcuts) 134 | 135 | ## TODO 136 | 137 | - [x] Pagination 138 | - [x] Controlled status 139 | - [ ] Shortcut priority 140 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Shortcut Guide 8 | 16 | 17 | 18 | 19 | 76 |
77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { Modifier, useShortcut, useShortcutOptions } from '~' 4 | 5 | import { 6 | Button, 7 | CssBaseline, 8 | GeistProvider, 9 | Page, 10 | Text, 11 | Tooltip, 12 | } from '@geist-ui/core' 13 | 14 | import { ShortcutProvider } from '~/components/Provider' 15 | 16 | render( 17 | 18 | 19 | 20 | 21 | 22 | , 23 | document.getElementById('app'), 24 | ) 25 | 26 | function App() { 27 | return ( 28 | <> 29 | 37 | 38 | 39 | ) 40 | } 41 | 42 | function Comp() { 43 | useShortcut( 44 | 'A', 45 | [Modifier.Meta], 46 | () => { 47 | console.log('a') 48 | }, 49 | 'Print A', 50 | ) 51 | 52 | useShortcut( 53 | 'B', 54 | [Modifier.Control, Modifier.Meta, Modifier.Meta], 55 | () => { 56 | console.log('b') 57 | }, 58 | 'Print B', 59 | ) 60 | 61 | useShortcut( 62 | 'Escape', 63 | [Modifier.Control, Modifier.Meta, Modifier.Shift], 64 | () => { 65 | console.log('c') 66 | }, 67 | 'Print C', 68 | ) 69 | 70 | const cleanup = useShortcut( 71 | 'D', 72 | [Modifier.None], 73 | () => { 74 | console.log('d') 75 | }, 76 | 'Print D', 77 | ) 78 | 79 | console.log(cleanup) 80 | 81 | useShortcut( 82 | 'O', 83 | [Modifier.Command, Modifier.Alt, Modifier.Shift], 84 | () => { 85 | console.log('d') 86 | }, 87 | 'Long Item Long Item Long Item Long Item Long Item', 88 | ) 89 | 90 | useShortcut('?', [Modifier.None], () => {}, 'Open Guide') 91 | useShortcut( 92 | 'K', 93 | [Modifier.Command], 94 | () => { 95 | alert('Search') 96 | }, 97 | 'Search', 98 | ) 99 | 100 | useShortcut( 101 | 'P', 102 | [Modifier.Command], 103 | (e) => { 104 | e.preventDefault() 105 | alert('Search') 106 | }, 107 | 'Hidden', 108 | { 109 | preventInput: false, 110 | }, 111 | ) 112 | 113 | const alphabet = 'abcde'.toUpperCase() 114 | 115 | alphabet.split('').forEach((letter, index) => { 116 | // eslint-disable-next-line react-hooks/rules-of-hooks 117 | useShortcut( 118 | // @ts-ignore 119 | letter, 120 | [Modifier.Command, Modifier.Control], 121 | (e) => { 122 | e.preventDefault() 123 | alert(letter) 124 | }, 125 | `${letter}`, 126 | { 127 | preventInput: false, 128 | }, 129 | ) 130 | }) 131 | 132 | const { setOptions, options } = useShortcutOptions() 133 | return ( 134 |
135 | React Shortcut Guideline 136 | Long press ⌘, or press ? to open the guide. 137 | 138 | 152 | 153 | 154 | 157 |
158 | ) 159 | } 160 | -------------------------------------------------------------------------------- /src/components/Guide.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | --rsg-font: PingFang SC, Helvetica, sans-serif; 3 | --rsg-bg: rgba(255, 255, 255, 0.72); 4 | --rsg-pad: 60px; 5 | --rsg-text-color: #464b4d; 6 | --rsg-panel-width: 825px; 7 | --rsg-item-gap: 30px; 8 | } 9 | 10 | .root.dark { 11 | --rsg-bg: rgba(29, 29, 31, 0.72); 12 | --rsg-text-color: #e6e6e6; 13 | } 14 | 15 | .container { 16 | font-family: var(--rsg-font); 17 | line-height: 1.6; 18 | position: fixed; 19 | width: var(--rsg-panel-width); 20 | max-width: 80vw; 21 | margin: auto; 22 | display: flex; 23 | align-items: center; 24 | inset: 0; 25 | z-index: 100000; 26 | color: var(--rsg-text-color); 27 | pointer-events: none; 28 | } 29 | 30 | .panel { 31 | pointer-events: all; 32 | backdrop-filter: saturate(180%) blur(20px); 33 | -webkit-backdrop-filter: saturate(180%) blur(20px); 34 | background-color: var(--rsg-bg); 35 | border-radius: 30px; 36 | overflow: hidden; 37 | font-size: 16px; 38 | 39 | width: 100%; 40 | position: relative; 41 | white-space: nowrap; 42 | 43 | overflow-x: auto; 44 | scroll-snap-type: x mandatory; 45 | display: flex; 46 | scroll-behavior: smooth; 47 | scroll-snap-destination: calc(var(--rsg-panel-width) / 3) 0; 48 | box-sizing: content-box; 49 | } 50 | 51 | .panel::-webkit-scrollbar { 52 | display: none; 53 | width: 0; 54 | height: 0; 55 | } 56 | 57 | .slide { 58 | flex-shrink: 0; 59 | padding: var(--rsg-pad); 60 | scroll-snap-align: start; 61 | overflow: hidden; 62 | width: calc(min(var(--rsg-panel-width), 80vw) - var(--rsg-pad) * 2); 63 | display: inline-block; 64 | position: relative; 65 | margin-right: var(--rsg-pad); 66 | box-sizing: content-box; 67 | } 68 | 69 | .slide:last-child { 70 | margin-right: 0; 71 | } 72 | 73 | .slide::before { 74 | content: ''; 75 | position: absolute; 76 | left: 50%; 77 | top: var(--rsg-pad); 78 | bottom: var(--rsg-pad); 79 | 80 | width: 0.5px; 81 | background-color: currentColor; 82 | } 83 | 84 | .panel .left, 85 | .panel .right { 86 | width: calc(50% - 40px); 87 | margin: auto; 88 | float: left; 89 | } 90 | 91 | .panel .right { 92 | float: right; 93 | } 94 | 95 | @media (max-width: 700px) { 96 | .panel .left, 97 | .panel .right { 98 | width: 100%; 99 | float: none; 100 | } 101 | 102 | .root { 103 | --rsg-item-gap: 20px; 104 | } 105 | 106 | .panel .left { 107 | margin-bottom: var(--rsg-item-gap); 108 | } 109 | 110 | .slide::before { 111 | display: none; 112 | } 113 | } 114 | 115 | .shortcut-item { 116 | display: flex; 117 | justify-content: space-between; 118 | align-items: center; 119 | margin-bottom: var(--rsg-item-gap); 120 | min-width: 0; 121 | 122 | max-width: 100%; 123 | width: 100%; 124 | } 125 | 126 | .shortcut-item > span { 127 | word-break: break-all; 128 | min-width: 0; 129 | } 130 | 131 | .shortcut-item .title { 132 | overflow: hidden; 133 | text-overflow: ellipsis; 134 | white-space: nowrap; 135 | } 136 | 137 | .shortcut-item .keys { 138 | display: inline-flex; 139 | flex-shrink: 0; 140 | align-items: center; 141 | gap: 20px; 142 | min-width: 0; 143 | } 144 | 145 | .key { 146 | font-variant-numeric: tabular-nums; 147 | min-width: 2ch; 148 | display: inline-flex; 149 | justify-content: center; 150 | } 151 | 152 | .indicator { 153 | position: sticky; 154 | right: 0; 155 | display: flex; 156 | align-items: flex-end; 157 | gap: 10px; 158 | transform: translate( 159 | calc(min(var(--rsg-panel-width), 80vw) * -1 / 2), 160 | calc(var(--rsg-pad) / 2 * -1) 161 | ); 162 | } 163 | 164 | .indicator-ball { 165 | height: 6px; 166 | width: 6px; 167 | border-radius: 50%; 168 | background-color: var(--rsg-text-color); 169 | opacity: 0.5; 170 | display: inline-block; 171 | transition: opacity 0.5s ease-in-out; 172 | } 173 | 174 | .indicator-ball.active { 175 | opacity: 1; 176 | } 177 | -------------------------------------------------------------------------------- /src/components/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | memo, 4 | useCallback, 5 | useEffect, 6 | useMemo, 7 | useRef, 8 | useState, 9 | } from 'react' 10 | 11 | import { macosMetaKeyCharMap, otherKeyCharMap } from '~/constants/key-map' 12 | import { Modifier } from '~/enums/modifier' 13 | import { Guide } from '~/helper' 14 | import { RegisterShortcutType, ShortcutType } from '~/types' 15 | import { checkIsPressInInputEl } from '~/utils/input' 16 | import { merge } from '~/utils/merge' 17 | import { uniqueArray } from '~/utils/tool' 18 | 19 | import { ShortcutContext, ShortcutOptions } from '..' 20 | import { GuidePanelAnimated } from './GuidePanelAnimated' 21 | 22 | const modifiers = Object.keys(Modifier) 23 | const isModifierKey = (key: string) => !!-~modifiers.indexOf(key) 24 | 25 | export const ShortcutProvider: FC<{ options?: ShortcutOptions }> = memo( 26 | (props) => { 27 | const { options } = props 28 | const [currentOptions, setCurrentOptions] = 29 | useState>(null) 30 | const [shortcuts, setShortcuts] = React.useState([]) 31 | 32 | const actionMap = useRef( 33 | new Map() as Map any>, 34 | ) 35 | 36 | const action2ShortcutWeakMap = useRef( 37 | new WeakMap<(e: KeyboardEvent) => any, ShortcutType>(), 38 | ) 39 | 40 | useEffect(() => { 41 | const globalHandler = (event: KeyboardEvent) => { 42 | let key = event.key 43 | if (typeof key == 'undefined') { 44 | return 45 | } 46 | if (isModifierKey(key)) { 47 | return 48 | } 49 | 50 | const isShiftHold = event.shiftKey 51 | 52 | const isAltHold = event.altKey 53 | const isCtrlHold = event.ctrlKey 54 | const isMetaHold = event.metaKey 55 | 56 | const modifierJoint = [ 57 | isShiftHold && 'Shift', 58 | isAltHold && 'Alt', 59 | isCtrlHold && 'Control', 60 | isMetaHold && 'Meta', 61 | ] 62 | .filter(Boolean) 63 | .sort() 64 | .join('+') 65 | 66 | key = key.length == 1 ? key.toUpperCase() : key 67 | 68 | const concatKey = `${modifierJoint ? `${modifierJoint}+` : ''}${key}` 69 | 70 | if (actionMap.current.has(concatKey)) { 71 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 72 | const action = actionMap.current.get(concatKey)! 73 | 74 | const shortcut = action2ShortcutWeakMap.current.get(action) 75 | 76 | if (shortcut?.preventInput) { 77 | if (checkIsPressInInputEl()) { 78 | return 79 | } 80 | } 81 | 82 | action?.(event) 83 | } 84 | } 85 | 86 | window.addEventListener('keydown', globalHandler) 87 | return () => { 88 | window.removeEventListener('keydown', globalHandler) 89 | } 90 | }, []) 91 | 92 | const registerShortcut: RegisterShortcutType = useCallback( 93 | (key, modifierFlags, action, discoverabilityTitle, options) => { 94 | const { hiddenInPanel = false, preventInput = true } = options || {} 95 | 96 | const uniqueModifierFlags = uniqueArray(modifierFlags).filter(Boolean) 97 | 98 | const jointKey = `${ 99 | uniqueModifierFlags.length 100 | ? `${uniqueModifierFlags.sort().join('+')}+` 101 | : '' 102 | }${key}` 103 | setShortcuts((shortcuts) => { 104 | if (actionMap.current.has(jointKey)) { 105 | return shortcuts 106 | } 107 | 108 | actionMap.current.set(jointKey, action) 109 | const newShortcut = { 110 | keys: [ 111 | ...uniqueModifierFlags.map( 112 | // @ts-ignore 113 | (modifier) => macosMetaKeyCharMap[modifier], 114 | ), 115 | // @ts-ignore 116 | otherKeyCharMap[key] ?? key.toUpperCase(), 117 | ], 118 | title: discoverabilityTitle, 119 | jointKey, 120 | hiddenInPanel, 121 | action, 122 | preventInput, 123 | } 124 | action2ShortcutWeakMap.current.set(action, newShortcut) 125 | 126 | return [...shortcuts, newShortcut] 127 | }) 128 | 129 | return () => { 130 | if (!actionMap.current.has(jointKey)) { 131 | return 132 | } 133 | setShortcuts((shortcuts) => { 134 | actionMap.current.delete(jointKey) 135 | return shortcuts.filter( 136 | (shortcut) => shortcut.jointKey !== jointKey, 137 | ) 138 | }) 139 | } 140 | }, 141 | [], 142 | ) 143 | 144 | useEffect(() => { 145 | Guide.setRegister(registerShortcut) 146 | 147 | return () => { 148 | Guide.setRegister(null) 149 | } 150 | }, [registerShortcut]) 151 | 152 | return ( 153 | ({ 156 | shortcuts, 157 | registerShortcut, 158 | options: merge(options, currentOptions) ?? {}, 159 | setOptions: setCurrentOptions, 160 | }), 161 | [currentOptions, options, registerShortcut, shortcuts], 162 | )} 163 | > 164 | 165 | 166 | ) 167 | }, 168 | ) 169 | -------------------------------------------------------------------------------- /src/components/Guide.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, memo, useContext, useEffect, useRef, useState } from 'react' 2 | 3 | import { useMediaColor } from '~/hooks/use-media-color' 4 | import { useShortcutList } from '~/hooks/use-shortcut-list' 5 | import { ShortcutType } from '~/types' 6 | import { chunk, chunkTwo } from '~/utils/chunk' 7 | import { clsx } from '~/utils/clsx' 8 | import { injectCSS } from '~/utils/css' 9 | import { debounce } from '~/utils/debounce' 10 | 11 | import { ShortcutContext } from '../context/ShortcutContext' 12 | import styles from './Guide.module.css' 13 | 14 | export const GuidePanel: FC<{ className?: string }> = memo((props) => { 15 | const { className } = props 16 | const shortcuts = useShortcutList() 17 | 18 | const { options } = useContext(ShortcutContext) 19 | const { 20 | darkClassName = 'body.dark', 21 | darkMode = 'media', 22 | maxItemEveryPage = 12, 23 | } = options 24 | 25 | const { dark: isDark } = useMediaColor() 26 | useEffect(() => { 27 | if (darkMode === 'class') { 28 | const triggerClassName = darkClassName 29 | return injectCSS(` 30 | ${triggerClassName} .${styles.root} { 31 | --rsg-bg: rgba(29, 29, 31, 0.72) !important; 32 | --rsg-text-color: #e6e6e6 !important; 33 | } 34 | `) 35 | } 36 | }, [darkClassName, darkMode]) 37 | 38 | useEffect(() => { 39 | let cleanup: any 40 | requestAnimationFrame(() => { 41 | cleanup = injectCSS( 42 | `.${styles.root} { transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out; }`, 43 | ) 44 | }) 45 | 46 | return () => { 47 | cleanup && cleanup() 48 | } 49 | }, []) 50 | 51 | const $scrollRef = useRef(null) 52 | const len = shortcuts.length 53 | 54 | const totalPage = 55 | len <= maxItemEveryPage ? 1 : Math.ceil(len / maxItemEveryPage) 56 | const [currentPage, setCurrentPage] = useState(0) 57 | useEffect(() => { 58 | if (totalPage < 2) { 59 | return 60 | } 61 | 62 | if (!$scrollRef.current) { 63 | return 64 | } 65 | const $scroll = $scrollRef.current 66 | const pageWidth = $scroll.scrollWidth / totalPage 67 | const handler = () => { 68 | const currentX = $scroll.scrollLeft 69 | 70 | setCurrentPage(Math.ceil(currentX / pageWidth)) 71 | } 72 | 73 | $scroll.onscroll = debounce(handler, 16) 74 | 75 | handler() 76 | 77 | return () => { 78 | $scroll.onscroll = null 79 | } 80 | }, [totalPage]) 81 | 82 | if (!len) { 83 | return null 84 | } 85 | 86 | const splitShortcutsIntoTwoParts = chunk(shortcuts, maxItemEveryPage).map( 87 | (chunk) => chunkTwo(chunk), 88 | ) 89 | 90 | return ( 91 |
100 |
109 | {splitShortcutsIntoTwoParts.map(([left, right], i) => { 110 | return ( 111 |
112 |
113 | {left.map((shortcut, i) => ( 114 | 119 | ))} 120 |
121 |
122 | {right.map((shortcut, i) => ( 123 | 128 | ))} 129 |
130 |
131 | ) 132 | })} 133 | 134 |
135 |
136 | ) 137 | }) 138 | 139 | const ShortcutItem: FC = memo((props) => { 140 | const { keys, title, isEnd } = props 141 | 142 | return ( 143 |
147 | {title} 148 | 149 | {keys.map((key) => ( 150 | 151 | {key} 152 | 153 | ))} 154 | 155 |
156 | ) 157 | }) 158 | 159 | const Indicator: FC<{ total: number; current: number }> = memo((props) => { 160 | const { total, current } = props 161 | if (total < 2) { 162 | return null 163 | } 164 | return ( 165 |
166 | {Array.from({ length: props.total }).map((_, i) => ( 167 | 176 | ))} 177 |
178 | ) 179 | }) 180 | -------------------------------------------------------------------------------- /src/components/GuidePanelAnimated.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useContext, useEffect, useRef, useState } from 'react' 2 | import { createPortal } from 'react-dom' 3 | 4 | import { clsx } from '~/utils/clsx' 5 | import { checkIsPressInInputEl } from '~/utils/input' 6 | 7 | import { ShortcutContext, useStateToRef } from '..' 8 | import { GuidePanel } from './Guide' 9 | import styles from './GuidePanelAnimated.module.css' 10 | 11 | export const GuidePanelAnimated = memo(() => { 12 | const { options } = useContext(ShortcutContext) 13 | const { 14 | holdCommandTimeout = 1000, 15 | stayCommandTimeout = 1000, 16 | onGuidePanelClose, 17 | onGuidePanelOpen, 18 | open: controlledOpen, 19 | } = options || {} 20 | 21 | const currentControllerOpen = useRef(null) 22 | 23 | const [open, setOpen] = useState(controlledOpen ? true : false) 24 | 25 | useEffect(() => { 26 | if (controlledOpen) { 27 | setOpen(true) 28 | setAnimated('in') 29 | currentControllerOpen.current = true 30 | } else { 31 | if (currentControllerOpen.current) { 32 | setAnimated('out') 33 | currentControllerOpen.current = null 34 | } 35 | } 36 | }, [controlledOpen]) 37 | 38 | const [animated, setAnimated] = useState<'in' | 'out' | 'none'>('none') 39 | 40 | const openStatus = useStateToRef(open) 41 | 42 | useEffect(() => { 43 | if (open) { 44 | onGuidePanelOpen && onGuidePanelOpen() 45 | } else { 46 | onGuidePanelClose && onGuidePanelClose() 47 | } 48 | }, [onGuidePanelClose, onGuidePanelOpen, open]) 49 | 50 | useEffect(() => { 51 | if (controlledOpen) { 52 | return 53 | } 54 | let disappearTimer = null as any 55 | 56 | let isHoldCommandKey = false 57 | 58 | const handleKeyDown = (e: KeyboardEvent) => { 59 | if (checkIsPressInInputEl()) { 60 | return 61 | } 62 | 63 | if (e.key === '?') { 64 | disappearTimer && (disappearTimer = clearTimeout(disappearTimer)) 65 | setOpen((open) => { 66 | if (open) { 67 | setAnimated('out') 68 | return open 69 | } else { 70 | setAnimated('in') 71 | return !open 72 | } 73 | }) 74 | } 75 | 76 | if (e.key === 'Escape') { 77 | setAnimated('out') 78 | } 79 | } 80 | 81 | let holdCommandTimer: any 82 | 83 | const handleCommandKey = (e: KeyboardEvent) => { 84 | const key = e.key 85 | const isCommandKey = key == 'Meta' || key == 'Control' 86 | 87 | if (!isCommandKey) { 88 | holdCommandTimer = clearTimeout(holdCommandTimer) 89 | return 90 | } 91 | holdCommandTimer = setTimeout(() => { 92 | holdCommandTimer = null 93 | 94 | if (!document.hasFocus()) { 95 | return 96 | } 97 | e.stopPropagation() 98 | 99 | if (isCommandKey) { 100 | disappearTimer = clearTimeout(disappearTimer) 101 | setOpen(true) 102 | setAnimated('in') 103 | isHoldCommandKey = true 104 | } 105 | }, holdCommandTimeout) 106 | } 107 | 108 | const handleKeyUp = (e: KeyboardEvent) => { 109 | if (!isHoldCommandKey) { 110 | return 111 | } 112 | 113 | if (e.key == 'Meta' || e.key == 'Controll') { 114 | disappearTimer = setTimeout(() => { 115 | setAnimated('out') 116 | isHoldCommandKey = false 117 | }, stayCommandTimeout) 118 | } 119 | } 120 | 121 | const handleFocus = () => { 122 | if (openStatus.current) { 123 | disappearTimer = setTimeout(() => { 124 | setAnimated('out') 125 | isHoldCommandKey = false 126 | }, stayCommandTimeout) 127 | } 128 | } 129 | 130 | const handleReleaseCommandKey = () => { 131 | holdCommandTimer = clearTimeout(holdCommandTimer) 132 | } 133 | 134 | const handleBlur = handleReleaseCommandKey 135 | 136 | window.addEventListener('keydown', handleKeyDown) 137 | window.addEventListener('keyup', handleKeyUp) 138 | window.addEventListener('focus', handleFocus) 139 | 140 | window.addEventListener('keydown', handleCommandKey) 141 | window.addEventListener('keyup', handleReleaseCommandKey) 142 | window.addEventListener('blur', handleBlur) 143 | 144 | return () => { 145 | window.removeEventListener('keydown', handleKeyDown) 146 | window.removeEventListener('keydown', handleCommandKey) 147 | window.removeEventListener('keyup', handleKeyUp) 148 | window.removeEventListener('keyup', handleReleaseCommandKey) 149 | window.removeEventListener('blur', handleBlur) 150 | 151 | clearTimeout(disappearTimer) 152 | } 153 | }, [holdCommandTimeout, stayCommandTimeout, controlledOpen]) 154 | 155 | const [panelWrapperRef, setPanelWrapperRef] = useState( 156 | null, 157 | ) 158 | 159 | useEffect(() => { 160 | if (!panelWrapperRef) { 161 | return 162 | } 163 | 164 | if (animated == 'none') { 165 | return 166 | } else if (animated == 'out') { 167 | panelWrapperRef.classList.add(styles.disappear) 168 | } 169 | 170 | panelWrapperRef.ontransitionend = () => { 171 | if (animated === 'out') { 172 | setOpen(false) 173 | setAnimated('none') 174 | } 175 | } 176 | 177 | return () => { 178 | panelWrapperRef.ontransitionend = null 179 | } 180 | }, [animated, panelWrapperRef]) 181 | 182 | if (typeof window == 'undefined') { 183 | return null 184 | } 185 | 186 | return ( 187 | <> 188 | {open && 189 | createPortal( 190 |
194 | 195 |
, 196 | document.body, 197 | )} 198 | 199 | ) 200 | }) 201 | --------------------------------------------------------------------------------