Articles

키 하나로 쓰는 단축키를 만들 때 지킨 것들

WhoIsLoafing에는 N, A, 슬래시처럼 키 하나만 누르면 되는 단축키가 있어요. 한글 자판, 입력창, 브라우저 기본 단축키와 부딪히지 않게 만든 방법과, Option을 누르는 동안만 단축키를 보여주는 방법을 정리했어요.

WhoIsLoafing에는 키 하나로 쓰는 단축키가 있어요. Command나 Ctrl을 같이 누르지 않아도 돼요.

키 하는 일
N 새 분석 화면으로 가기
A 아티클 보기
/ 레포 링크 입력창으로 가기
B 앱의 사이드바 열고 닫기
D 앱의 심층 분석 열기
1~4 심층 분석의 탭 고르기
R 지금 탭 다시 분석하기

B와 D는 macOS 앱에서만 쓸 수 있고, 나머지는 웹에서도 똑같이 동작해요.

키 하나로 쓰는 단축키는 편하지만 위험해요. 입력창에 글자를 치다가 단축키가 눌리거나, 브라우저 단축키와 겹칠 수 있어요. 이런 문제를 어떻게 피했는지 정리했어요.

글자가 아니라 키의 자리로 받아요

키보드 이벤트에는 key와 code가 있어요. key는 그 키를 눌렀을 때 입력되는 글자이고, code는 키보드에서의 자리예요.

한국어 사용자에게 이 차이가 중요해요. 한글 자판 상태에서 N 자리의 키를 누르면 key는 ㅜ가 되고, code는 그대로 KeyN이에요. key로 단축키를 걸면 한글 자판일 때 단축키가 전혀 안 먹어요. 사용자는 자판을 영어로 바꾸고 나서야 단축키를 쓸 수 있어요.

그래서 단축키는 code로 받아요. Shift를 같이 누르는 단축키는 앞에 Shift+를 붙여요.

const action = latest.current[`${event.shiftKey ? 'Shift+' : ''}${event.code}`] ?? latest.current[`Key:${event.key}`]

뒤쪽의 Key: 접두사는 예외를 위한 것이에요. 물음표처럼 자판마다 자리가 다른 기호는 자리로 걸면 오히려 틀려요. 그런 키만 글자로 걸 수 있게 길을 열어 뒀어요.

받지 않는 경우

단축키를 받지 않는 경우를 먼저 걸러요.

const onKeyDown = (event: KeyboardEvent) => {
  if (event.metaKey || event.ctrlKey || event.isComposing || event.repeat) return
  const target = event.target as HTMLElement | null
  if (target?.closest(TYPING)) return
  if (document.querySelector('[aria-modal="true"]')) return
  const action = latest.current[`${event.shiftKey ? 'Shift+' : ''}${event.code}`] ?? latest.current[`Key:${event.key}`]
  if (!action) return
  event.preventDefault()
  action()
}

하나씩 이유가 있어요.

  • Command나 Ctrl을 누르고 있을 때: Command R로 새로 고치거나 Command C로 복사하는 것을 막지 않기 위해서예요. R이 단축키여도 Command R은 그대로 새로 고침이에요.
  • 한글을 조합하는 중일 때: isComposing은 입력기가 글자를 만드는 중이라는 뜻이에요. 이때 오는 키 입력은 글자의 일부예요.
  • 키를 꾹 누르고 있을 때: 키를 누르고 있으면 같은 이벤트가 반복해서 와요. 단축키가 여러 번 실행되지 않게 첫 번째만 받아요.
  • 입력창, 텍스트 칸, 선택 칸에 있을 때: 레포 주소를 입력하다가 N을 쳤는데 새 분석 화면이 열리면 안 돼요.
  • 확인 창이 떠 있을 때: 화면 뒤쪽에서 단축키가 실행되면 사용자는 무슨 일이 일어났는지 모르게 돼요.

Option만은 막지 않았어요. 뒤에서 설명하겠지만 Option은 단축키를 보려고 누르고 있는 키라서, 누른 채로도 단축키가 실행돼야 해요.

단축키 목록이 바뀌어도 이벤트는 한 번만

화면마다 단축키가 달라요. 리액트 컴포넌트가 다시 그려질 때마다 단축키 목록 객체가 새로 만들어지는데, 그때마다 이벤트를 떼었다 붙이면 낭비예요.

그래서 목록은 ref에 넣어 두고, 이벤트는 처음에 한 번만 붙여요. 이벤트가 일어났을 때 ref에서 가장 최신 목록을 꺼내 써요.

export function useShortcuts(keys: ShortcutMap, enabled = true) {
  const latest = useRef(keys)
  latest.current = keys

  useEffect(() => {
    if (!enabled) return
    window.addEventListener('keydown', onKeyDown)
    return () => window.removeEventListener('keydown', onKeyDown)
  }, [enabled])
}

단축키는 평소에 숨겨 둬요

단축키를 버튼 옆에 항상 표시하면 화면이 지저분해져요. 이 서비스는 여백 위주의 미니멀한 디자인이라 더 그래요. 그래서 평소에는 숨겨 두고, Option을 누르고 있는 동안만 보여줘요.

Option이 눌렸는지는 화면 전체가 공유하는 작은 저장소에 담아요. 키를 누르면 held가 켜지고, 떼면 꺼져요.

window.addEventListener('keydown', (event) => {
  if (event.key === 'Alt') setHints({ held: true })
})
window.addEventListener('keyup', (event) => {
  if (event.key === 'Alt') setHints({ held: false })
})
window.addEventListener('blur', () => setHints({ held: false }))

마지막 줄이 중요해요. Option을 누른 채로 다른 창으로 넘어가면 키를 뗀 이벤트가 이 창에 오지 않아요. 그러면 돌아왔을 때 단축키가 계속 보이는 상태로 남아요. 창이 포커스를 잃으면 뗀 것으로 보고 숨겨요.

컴포넌트는 리액트의 useSyncExternalStore로 이 저장소를 구독해요. 저장소가 바뀌면 단축키 표시가 필요한 곳만 다시 그려져요.

나타나도 글자가 밀리지 않게

단축키 표시는 키 모양의 작은 칸이에요. 버튼 글자 옆에 칸이 갑자기 나타나면 글자가 옆으로 밀려서 화면이 출렁여요.

그래서 줄 안에 넣는 표시는 평소에도 자리를 차지하고, 보이지만 않게 해 뒀어요.

<kbd
  aria-hidden={!shown}
  className={`... ${corner ? 'absolute -top-1.5 -right-1.5 z-10' : ''} ${shown ? '' : 'invisible'}`}
>
  {children}
</kbd>

invisible은 자리는 그대로 두고 보이지만 않게 해요. 아이콘만 있는 작은 버튼처럼 옆에 자리가 없는 곳은 버튼의 오른쪽 위 모서리에 띄워서, 버튼 크기가 바뀌지 않게 했어요.

늘 보고 싶은 사람을 위해

단축키를 익히는 동안에는 늘 보이는 편이 편해요. 그래서 계정 메뉴에서 단축키 힌트 표시를 켤 수 있게 했어요. 켜 두면 Option을 누르지 않아도 항상 보이고, 이 설정은 기기에 저장해서 다음에 열었을 때도 그대로예요.

단축키가 보이는지는 "Option을 누르고 있거나, 설정에서 켜 뒀거나" 둘 중 하나예요.

export function useShortcutHints(): boolean {
  return useSyncExternalStore(
    subscribeHints,
    () => hints.held || hints.pinned,
    () => false,
  )
}

Option을 누르면 단축키가 보인다는 사실도 알려 줘야 해요. 앱을 켜면 오른쪽 아래 구석에 "option을 눌러 단축키 표시"라는 작은 안내가 6초 동안 떠요. Option을 눌러 보면 바로 사라지고, 힌트 표시를 켜 둔 사람에게는 띄우지 않아요.

정리

  • 단축키는 글자(key)가 아니라 자리(code)로 받아야 한글 자판에서도 동작해요.
  • Command, Ctrl, 한글 조합 중, 키 반복, 입력창, 확인 창에서는 단축키를 받지 않아요.
  • 단축키 목록은 ref에 두고 이벤트는 한 번만 붙여요.
  • 단축키 표시는 평소에 숨기고 Option을 누르는 동안만 보여줘요. 창을 벗어나면 뗀 것으로 봐요.
  • 표시가 나타나도 글자가 밀리지 않게 자리를 미리 잡아 둬요.
다음 글 웹 화면을 그대로 macOS 앱 안에서 쓰고, 로그인은 브라우저에 맡기기