지난 글에서 키보드 입력값을 받아 각 버튼에 매핑된 code 값을 매칭시키고, 이에 따라 오디오 잭생 및 동적으로 클래스를 추가하는 방법을 구현했다.
테스트를 통해 나타난 몇가지 오류 사항을 검토하고 고쳐본다.
오류
기대와는 다르게 몇몇 키가 작동하지 않는다.
제작 중인 키보드 배열과 테스트 중인 맥북의 배열이 다르다는 것을 감안하더라도, 문제되는 키는 다음과 같다.
- 좌, 우 Alt, Shift, Ctrl 키가 구분되지 않고, 모두 좌측 키로 인식함
- \ 키의 오디오는 출력되지만 클래스가 추가되지 않음
- Caps Lock이 한번에 3번 연달아 재생되고, 클래스가 제거되지 않음
- ESC, f1 ~ f12키에 preventDefault()가 적용되지 않음
참고로 Alt, Shift, Ctrl, Caps lock등과 같은 키는 통칭 Modifier 키 라고 한다.
좌우 Modifier key 구분하기
중복되는 키 중 shift 키를 예로 들어보겠다.
중복되는 키가 몇개 되지 않으니, 데이터에 location이라는 항목을 옵션으로 추가해준다.
type LocationType = "L" | "R"
interface Key {
code: string;
label: string;
audio: AudioType;
extraClass?: string;
location?: LocationType;
}
const mainKeys: Key[][] = [
[
...
// location 외에는 모두 같은 조건이었다.
{ code: "shift", label: "SHIFT", audio: "B", location: "L" },
{ code: "shift", label: "SHIFT", audio: "B", location: "R" },
...
]
]
입력되는 값의 위치를 event.location으로 받아올 수 있다.
event.location === 1 : 왼쪽
event.location === 2 : 오른쪽
const handleKeyDown = (event: KeyboardEvent) => {
const pressedKey = event.key.toLowerCase(); // 하단에 설명 첨부
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : ""; // 좌: 1, 우: 2
// 중요: location이 없는 경우 "" 처리해주지 않으면 추후 data-location이 작동하지 않음
const audioFile = findAudioFile(pressedKey);
if (audioFile) {
event.preventDefault();
playHandler(audioFile);
// location이 있는 경우 왼쪽("L") 또는 오른쪽("R")으로 매핑
const locationSelector = pressedLocation // "L" 또는 "R"에 맞춰 data-attribute 매핑
? `[data-location="${pressedLocation}"]`
: "";
const buttonElement = document.querySelector(
`button[data-code="${pressedKey}"]${locationSelector}`
);
if (buttonElement) {
buttonElement.classList.add("test");
console.log(buttonElement, "추가");
}
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const pressedKey = event.key.toLowerCase();
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : "";
const locationSelector = pressedLocation
? `[data-location="${pressedLocation}"]`
: "";
const buttonElement = document.querySelector(
`button[data-code="${pressedKey}"]${locationSelector}`
);
if (buttonElement) {
buttonElement.classList.remove("test");
console.log(buttonElement, "제거");
}
};
...
return (
//중략
<div className="key-main-container">
{mainKeys.map((row, rowIndex) => (
<div className="key-main-row" key={rowIndex}>
{row.map((label, labelIndex) => (
<button
className={`main-keys ${label.extraClass || ""} ${
mouseEnterOn ? "hoverEffect" : ""
}`}
key={labelIndex}
data-code={label.code.toLowerCase()}
data-location={label.location || ""} // location 항목이 있는 경우 이를 따라 매핑된다.
>
<span className="key-span">{label.label}</span>
</button>
))}
</div>
))}
</div>
)
}
비교적 간단하게 좌, 우 중복 키를 구분하여 매핑할 수 있다.
여기서 중요한 점은, 해당 키보드의 배열과 무관하게 표준 키보드 입력값에 맞추어 location 값을 넣어줘야한다.
위의 코드 시작 부분을 다시 한 번 주목하자.
const pressedKey = event.key.toLowerCase();
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : "";
우리는 location이 있다면 받아온다는 조건을 걸었다.
우리가 아는 보통의 키보드에는 Shift, Alt, Ctrl 등이 2개씩 있고, 이것은 항상 location 값을 가진다.
입력한 키의 location 값을 받아오기로 했으니, 현재 사용하는 키보드 배열이 어떻든 브라우저는 두개 중 하나라고 인식한다.
따라서 만일 통상적으로 2개인 Modifier 키를
- 현재 디자인 중인 가상 키보드 배열에 없거나,
- 테스트 중인 키보드 (맥북 키보드 등) 에 없다는 이유로
location 값이 없는 것처럼 취급하면 인식하지 못하는 오류가 발생할 수 있다.
따라서 mainKeys 데이터로 돌아가, 중복될 수 있는 모든 키에 location 항목을 추가한다.
만일 보통은 2개지만 구현하고 있는 가상 키보드 배열에는 하나인 키가 있다면-
들어오는 키의 방향을 콘솔로 확인한 뒤, "L" 또는 "R"로 확정해준다.
"\" 키 클래스 추가하기
"\" 키는 code 값으로 그냥 넣을 수 없다. 자바스크립트의 이스케이프 문자와 중복되기 때문이다.
그래서 데이터 상의 code에는 "\\" 으로 표기했다.
{ code: "\\", label: "\\", audio: "D"}
그러나 이 경우 querySelector가 정확하게 인식하지 못하는 경우가 있다고 한다.
"\\" 자체를 특수문자로 인식하여 올바르게 해석하지 못할 수 있다.
따라서 앞뒤로 이스케이프 문자를 추가해줌으로써 올바르게 추적할 수 있도록 했다.
이제 pressedKey가 "\\" 인 경우에는 "\\\\"로 인식하도록 처리해준다.
const handleKeyUp = (event: KeyboardEvent) => {
const pressedKey = event.key.toLowerCase(); // 입력된 키
const pressedLocation = // 좌우 구분
event.location === 1 ? "L" : event.location === 2 ? "R" : "";
const locationSelector = pressedLocation // location이 있는 키라면 좌우 구분
? `[data-location="${pressedLocation}"]`
: "";
const selectedKey = pressedKey === "\\" ? "\\\\" : pressedKey; // "//" 문자에 대한 처리
const buttonElement = document.querySelector(
`button[data-code="${selectedKey}"]${locationSelector}` // 새로운 변수명으로 교체
);
Capslock 문제 해결하기
Capslock 키의 이벤트가 3번 연달아 실행되지만 클래스는 제거되지 않는 문제가 있다.
Capslock은 다른 키와는 다르게 상태 toggle형 키이기 때문에, keyDown과 keyUp이 순차적으로 실행된다기 보다는, 키를 떼더라도 그 상태가 유지된다.
또한 일부 브라우저에서는 Capslock을 한 번 누르더라도 이벤트가 여러번 실행될 수 있다.
이에 따라 event.getModifierState()를 통해 현재 capslock 상태인지 확인하고, 이미 활성화된 상태라면 keydown을 막아준다.
const handleKeyDown = (event: KeyboardEvent) => {
const pressedKey = event.key.toLowerCase();
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : "";
if (pressedKey === "capslock" && !event.getModifierState("CapsLock")) //추가
return;
const audioFile = findAudioFile(pressedKey);
if (audioFile) {
event.preventDefault();
playHandler(audioFile);
이런 식으로 구현하면 오디오도 한 번, 클래스도 한 번만 추가된다.
그러나 keyUp을 하더라도 클래스가 제거되지 않아 계속 활성화된 것처럼 보이게 된다.
클래스를 토글하자니, 키를 한번 누르면 활성화되고 다시 한번 누르면 비활성화 되는 것은 의도한 방식이 아니다.
이 점은 단순하게 setTimeout( )을 통해 키가 눌리면 오디오를 재생, 클래스를 추가하고 0.15초 후 클래스가 해제되도록 구현했다.
if (pressedKey === "capslock") {
// CapsLock이 활성화된 상태가 아니라면 return하여 다중 토글을 방지
if (!event.getModifierState("CapsLock")) return;
const audioFile = findAudioFile(pressedKey);
if (audioFile) {
playHandler(audioFile); // CapsLock일 때도 오디오 재생
}
const buttonElement = document.querySelector(
`button[data-code="capslock"]`
);
if (buttonElement) {
buttonElement.classList.add("test");
setTimeout(() => {
buttonElement.classList.remove("test");
}, 150);
}
return; // CapsLock 로직은 여기서 종료
}
ESC, F1 ~ F12키 매핑하기
이 키들의 경우, preventDefault()를 무시하고 오디오 재생이나 클래스 추가를 하지 않고 원래 기능을 하는 문제가 있었다.
이것은 일부 브라우저와 운영 체제에서 특정 기능을 부여했기 때문에 preventDefault()가 항상 정상적으로 적용되지 않기 때문이다.
특히, 브라우저 자체 단축키로 사용되는 경우 이를 무시하고 브라우저가 우선하여 처리한다.
따라서 이 부분은 타이핑 모드에서는 비활성화된 듯한 디자인을 추가하여 (그레이 배경, 톤다운 배경, 알림 등) 사용하지 않도록 유도하기로 했다.
전체 코드
위 오류를 모두 해결한 Key 컴포넌트이다.
import { useEffect, useState } from "react";
import "../styles/styles.css";
import { Knob } from "primereact/knob";
import "primereact/resources/themes/lara-light-cyan/theme.css";
import mainKeys, {
functionKeys,
escKey,
AudioType,
} from "../assets/data/keyArray";
import useAudioPlayer from "../components/hooks/AudioPlay";
interface KeysProps {
onLightToggle: () => void;
typingOn: boolean;
onClickOn: boolean;
mouseEnterOn: boolean;
onKnobChange: (value: number) => void;
onLightOn: boolean;
}
export default function Keys({
onLightToggle,
typingOn,
onClickOn,
mouseEnterOn,
onKnobChange,
onLightOn,
}: KeysProps) {
const [knobValue, setKnobValue] = useState<number>(0);
const { playHandler } = useAudioPlayer();
useEffect(() => {
if (!typingOn) return;
const keyMap = new Map<string, AudioType>(
mainKeys.flat().map((key) => [key.code, key.audio])
);
function findAudioFile(audioFile: string) {
return keyMap.get(audioFile);
}
const handleKeyDown = (event: KeyboardEvent) => {
const pressedKey = event.key.toLowerCase();
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : "";
if (pressedKey === "capslock") {
// CapsLock이 활성화된 상태가 아니라면 return
if (!event.getModifierState("CapsLock")) return;
const audioFile = findAudioFile(pressedKey);
if (audioFile) {
playHandler(audioFile); // CapsLock일 때도 오디오 재생
}
const buttonElement = document.querySelector(
`button[data-code="capslock"]`
);
if (buttonElement) {
buttonElement.classList.add("test");
setTimeout(() => {
buttonElement.classList.remove("test");
}, 150);
}
return; // CapsLock의 경우 여기서 종료
}
const audioFile = findAudioFile(pressedKey);
if (audioFile) {
event.preventDefault();
playHandler(audioFile);
const locationSelector = pressedLocation
? `[data-location="${pressedLocation}"]`
: "";
const selectedKey = pressedKey === "\\" ? "\\\\" : pressedKey;
const buttonElement = document.querySelector(
`button[data-code="${selectedKey}"]${locationSelector}`
);
if (buttonElement) {
buttonElement.classList.add("test");
console.log(buttonElement, "추가");
}
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const pressedKey = event.key.toLowerCase();
const pressedLocation =
event.location === 1 ? "L" : event.location === 2 ? "R" : "";
const locationSelector = pressedLocation
? `[data-location="${pressedLocation}"]`
: "";
const selectedKey = pressedKey === "\\" ? "\\\\" : pressedKey;
const buttonElement = document.querySelector(
`button[data-code="${selectedKey}"]${locationSelector}`
);
if (buttonElement) {
buttonElement.classList.remove("test");
console.log(buttonElement, "제거");
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
};
}, [typingOn]);
const handleKnobChange = (e: { value: number }) => {
setKnobValue(e.value);
onKnobChange(e.value);
};
return (
<div className="key-container">
<div className="quad-key-row">
{escKey[0].map((esc) => (
<button
key={esc.code}
onMouseEnter={
mouseEnterOn ? () => playHandler(esc.audio) : undefined
}
onClick={onClickOn ? () => playHandler(esc.audio) : undefined}
className={`esc ${mouseEnterOn ? "hoverEffect" : ""}`}
data-code={esc.code.toLowerCase()}
>
<span className="key-span">{esc.label}</span>
</button>
))}
<div className="quad-container">
{functionKeys.map((row, rowIndex) => (
<div className="quad-row" key={rowIndex}>
{row.map((label, labelIndex) => (
<button
className={`quad-keys ${mouseEnterOn ? "hoverEffect" : ""}`}
key={labelIndex}
onMouseEnter={
mouseEnterOn ? () => playHandler("A") : undefined
}
onClick={onClickOn ? () => playHandler("A") : undefined}
data-code={label.code.toLowerCase()}
>
<span className="key-span">{label.label}</span>
</button>
))}
</div>
))}
</div>
<button onClick={onLightToggle} className="wheel">
<span>LED</span>
</button>
{onLightOn && (
<Knob
value={knobValue}
width={2}
min={0}
max={70}
showValue={false}
onChange={handleKnobChange}
strokeWidth={8}
className={`knob ${onLightOn ? "knob-show" : "knob-hide"}`}
/>
)}
</div>
<div className="key-main-container">
{mainKeys.map((row, rowIndex) => (
<div className="key-main-row" key={rowIndex}>
{row.map((label, labelIndex) => (
<button
className={`main-keys ${label.extraClass || ""} ${
mouseEnterOn ? "hoverEffect" : ""
}`}
key={labelIndex}
onMouseEnter={
mouseEnterOn ? () => playHandler(label.audio) : undefined
}
onClick={onClickOn ? () => playHandler(label.audio) : undefined}
data-code={label.code.toLowerCase()}
data-location={label.location || ""}
>
<span className="key-span">{label.label}</span>
</button>
))}
</div>
))}
</div>
</div>
);
}
'React.ts' 카테고리의 다른 글
리액트 식물 검색 서비스 - Redux (구버전) 로 상태 관리하기 (1) | 2024.11.25 |
---|---|
리액트 식물 검색 서비스 - 다중 API 호출과 매핑, Promise.all (1) | 2024.11.14 |
리액트 가상 키보드 만들기 - 키보드 매핑하기 2, 키보드 eventHandler, 클래스 동적으로 추가하기, HTML data attribute 활용 (0) | 2024.10.31 |
리액트 가상 키보드 만들기 - Audio 객체 (추가) (0) | 2024.10.28 |
리액트 가상 키보드 만들기 - 키보드 매핑하기 1 (0) | 2024.10.28 |