import {
onBeforeUnmount, onMounted, unref, type Ref,
} from 'vue';
/**
* @description 핫키 조합에서 사용하는 수정 키 타입입니다.
*/
type ModifierKey = 'ctrl' | 'meta' | 'alt' | 'shift';
/**
* @description 핫키를 감지할 브라우저 키보드 이벤트 타입입니다.
*/
type HotKeyEventName = 'keydown' | 'keyup';
/**
* @description 핫키 이벤트를 등록할 수 있는 브라우저 타겟 타입입니다.
*/
type HotKeyTarget = Window | Document | HTMLElement;
/**
* @description 일반 값 또는 Vue Ref 값을 모두 받을 수 있도록 하는 유틸 타입입니다.
*/
type MaybeRef<T> = T | Ref<T>;
/**
* @description 핫키 정규화 시 수정 키의 고정 정렬 순서입니다.
*/
const MODIFIER_ORDER: ModifierKey[] = ['ctrl', 'meta', 'alt', 'shift'];
/**
* @description useHotKey 컴포저블의 옵션 타입입니다.
*/
export interface UseHotKeyOptions {
/** @description 핫키 동작 활성화 여부입니다. */
enabled?: MaybeRef<boolean>;
/** @description 감지할 키보드 이벤트 타입입니다. */
event?: HotKeyEventName;
/** @description 핫키 매칭 시 브라우저 기본 동작을 막을지 여부입니다. */
preventDefault?: boolean;
/** @description 핫키 매칭 시 이벤트 전파를 중단할지 여부입니다. */
stopPropagation?: boolean;
/** @description 입력 요소 포커스 상태에서 핫키를 무시할지 여부입니다. */
ignoreInput?: boolean;
/** @description 수정 키 포함 여부까지 정확히 매칭할지 여부입니다. */
exact?: boolean;
/** @description 이벤트를 등록할 타겟(window/document/element)입니다. */
target?: MaybeRef<HotKeyTarget | null | undefined>;
}
/**
* @description 전달된 문자열이 수정 키 타입인지 검사합니다.
*/
function isModifierKey(value: string): value is ModifierKey {
return MODIFIER_ORDER.includes(value as ModifierKey);
}
/**
* @description 키 토큰(키보드 기준)의 별칭을 표준 키 이름으로 정규화합니다.
*/
function normalizeKeyToken(value: string) {
const token = value.trim().toLowerCase();
if (token === 'cmd' || token === 'command') return 'meta';
if (token === 'control') return 'ctrl';
if (token === 'option') return 'alt';
if (token === 'esc') return 'escape';
if (token === 'return') return 'enter';
if (token === 'spacebar') return 'space';
return token;
}
/**
* @description 핫키 문자열을 내부 비교용 표준 문자열로 변환합니다.
*/
function normalizeHotKey(value: string) {
const tokens = value
.split('+')
.map(normalizeKeyToken)
.filter(Boolean);
const modifiers = new Set<ModifierKey>();
let key = '';
tokens.forEach((token) => {
if (isModifierKey(token)) {
modifiers.add(token);
return;
}
key = token;
});
const modifierText = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)).join('+');
if (!key) return modifierText;
if (!modifierText) return key;
return `${modifierText}+${key}`;
}
/**
* @description KeyboardEvent의 key 값을 내부 표준 키 문자열로 변환합니다.
*/
function normalizeEventKey(event: KeyboardEvent) {
let key = event.key.toLowerCase();
if (key === ' ') key = 'space';
if (key === 'arrowup') key = 'up';
if (key === 'arrowdown') key = 'down';
if (key === 'arrowleft') key = 'left';
if (key === 'arrowright') key = 'right';
key = normalizeKeyToken(key);
if (isModifierKey(key)) return '';
return key;
}
/**
* @description 이벤트 타겟이 입력 가능한 요소(input, textarea, select, editor)인지 여부를 확인합니다.
*/
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
if (target.isContentEditable) return true;
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') return true;
return Boolean(target.closest('[contenteditable="true"]'));
}
/**
* @description KeyboardEvent를 핫키 비교 문자열로 변환합니다.
*/
function eventToHotKey(event: KeyboardEvent, exact: boolean) {
const modifiers: ModifierKey[] = [];
if (event.ctrlKey) modifiers.push('ctrl');
if (event.metaKey) modifiers.push('meta');
if (event.altKey) modifiers.push('alt');
if (event.shiftKey) modifiers.push('shift');
const key = normalizeEventKey(event);
if (exact) {
const modifierText = modifiers.join('+');
if (!key) return modifierText;
return modifierText ? `${modifierText}+${key}` : key;
}
const normalizedModifierText = MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier)).join('+');
if (!key) return normalizedModifierText;
return normalizedModifierText ? `${normalizedModifierText}+${key}` : key;
}
/**
* @description 이벤트 등록 타겟을 안전하게 해석합니다.
*/
function resolveTarget(target?: MaybeRef<HotKeyTarget | null | undefined>) {
if (!target) return window;
return unref(target) ?? window;
}
/**
* @description 전역/특정 타겟 핫키를 등록하고 컴포넌트 생명주기에 맞춰 자동 해제하는 컴포저블입니다.
*/
export default function useHotKey(
hotKey: string | string[],
callback: (event: KeyboardEvent) => void,
options: UseHotKeyOptions = {},
) {
const {
enabled = true,
event = 'keydown',
preventDefault = false,
stopPropagation = false,
ignoreInput = true,
exact = true,
target,
} = options;
const hotKeySet = new Set((Array.isArray(hotKey) ? hotKey : [hotKey]).map(normalizeHotKey));
let isBound = false;
const handler = (keyboardEvent: KeyboardEvent) => {
if (!unref(enabled)) return;
if (ignoreInput && isEditableTarget(keyboardEvent.target)) return;
const pressedHotKey = eventToHotKey(keyboardEvent, exact);
if (!hotKeySet.has(pressedHotKey)) return;
if (preventDefault) keyboardEvent.preventDefault();
if (stopPropagation) keyboardEvent.stopPropagation();
callback(keyboardEvent);
};
const activate = () => {
if (isBound || typeof window === 'undefined') return;
const eventTarget = resolveTarget(target);
eventTarget.addEventListener(event, handler as EventListener);
isBound = true;
};
const deactivate = () => {
if (!isBound || typeof window === 'undefined') return;
const eventTarget = resolveTarget(target);
eventTarget.removeEventListener(event, handler as EventListener);
isBound = false;
};
onMounted(activate);
onBeforeUnmount(deactivate);
return {
activate,
deactivate,
};
}