유사배열객체(Array-like Object)

배열처럼 length 속성을 가지지만 배열이 아닌 객체를 말한다.

배열이 아니기 때문에 map, filter, reduce 등 배열 고유 메서드를 사용할 수 없다.

 

const arrayLike = {
  0: "apple",
  1: "banana",
  2: "peach",
  length: 3	// length를 반드시 입력
};

console.log(arrayLike[0]); // "apple"
console.log(arrayLike.length); // 3

 

 

 

 

 

Array.from( )

 

기본형

Array.from(iterable, mapFunction, thisArg)
  • iterable: 배열로 변환할 반복 가능한 객체( 문자열, Set, Map 등) 또는 유사 배열 객체.
  • mapFunction (optional): 배열의 각 요소에 적용할 함수. Array.from의 결과 배열을 생성하면서 동시에 매핑을 할 수 있다.
    • 첫번째 인자: 현재 요소의 값. 유사 배열 객체의 경우 보통 undefined
    • 두번째 인자: 현재 요소의 index
  • thisArg (optional): mapFunction 실행 시 this로 사용될 값.

 

예제

// 문자열을 쪼개서 배열로 바꾸기
const str = "Hello";
const arr = Array.from(str);
console.log(arr); // ['H', 'e', 'l', 'l', 'o']

 

// 유사배열객체를 배열로 바꾸기
const arrayLike = { 0: "a", 1: "b", 2: "c", length: 3 };
const arr = Array.from(arrayLike);
console.log(arr); // ['a', 'b', 'c']

 

// 각 요소 순회하며 함수 매핑하기
const arr = Array.from([1, 2, 3], x => x * 2);
console.log(arr); // [2, 4, 6]

 

// 길이가 5인 배열 생성하기
const arr = Array.from({ length: 5 }, (_, index) => index);
console.log(arr); // [0, 1, 2, 3, 4]

 

 

 

 

Array.from({ length: count }

위 코드는 "count라는 변수만큼의 length를 가진 배열을 생성한다"는 뜻이다.

 

 

 

 

 

 

 

 

 

이제 각 키를 실제 키보드로 입력했을 때 지정한 오디오가 출력되도록 만들어 보자.

 

 

 

초기 아이디어

type AudioType = "A" | "B" | "C" | "D" | "E";

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    const keyMap: { [key: string]: AudioType } = {	//문자열인 key: 값은 AudioType 중 하나
      a: "A",	// 우선 5개 키만 대충 지정한다
      b: "B",
      c: "C",
      d: "D",
      e: "E"
    };

    const audioType = keyMap[event.key];
    if (audioType) {	// keyMap에 누른 키가 존재하는지 확인
      playHandler(audioType);	// 있다면 해당 오디오 재생
    }
  };

  // window 객체에 이벤트 리스너 추가.
  // 각 button 요소를 추적하는 것이 아니라, 눌린 키가 keyMap객체에 있는지 확인하는 방식.
  window.addEventListener("keydown", handleKeyDown);

  return () => { // 클린업
    window.removeEventListener("keydown", handleKeyDown);
  };
}, []);

 

모든 키의 원래 이름(입력 시 브라우저가 해당 키라는 것을 인식하는 원본 네이밍)은 lowerCase로 작성되어야 한다.

따라서 event.key에 .toLowerCase() 를 추가해주고, 각 키를 다시 매핑해준다.

 

 

수정할 점 : mainKeys 배열 불러오기, 소문자로 변환하기

type AudioType = "A" | "B" | "C" | "D" | "E";

useEffect(() => {
  // mainKeys 배열에서 키보드 레이블과 오디오 타입을 짝지어주는 객체 생성하기
  const keyMap = mainKeys.flat().reduce((accumulator, key) => {
    accumulator[key.label.toLowerCase()] = key.audio;
    return accumulator;
  }, {} as { [key: string]: AudioType });

  const handleKeyDown = (event: KeyboardEvent) => {	// 키보드 이벤트 핸들러 생성
    const audioType = keyMap[event.key.toLowerCase()]; // 눌린 키의 레이블과 매칭되는 오디오 타입 찾기
    if (audioType) { // 방금 누른 키가 매칭되는 오디오의 키와 같다면
      playHandler(audioType); // 오디오 재생
    }
  };

  window.addEventListener("keydown", handleKeyDown); //handleKeyDown이라는 함수를 키보드 입력 시 실행

  return () => { // 클린업 함수
    window.removeEventListener("keydown", handleKeyDown);
  };
}, []);

 

 

여기서 문제가 또 발생한다.

데이터 배열의 label에 실제 이름과 다른 키에 대해서는 매핑이 되지 않아 소리가 재생되지 않는다.

또한 caps lock 이나 ctrl 등 실제 브라우저에서 역할이 있는(?) 키들은 오디오를 재생하지 않고 자신의 기능을 우선 수행한다.

이 부분을 event.preventDefault()로 방지하는 기능까지 추가해보자.

 

수정할 점 : 이름 다른 키 수정하기, 각 키의 원기능 방지하기

useEffect(() => {

    const specialKeyMap: { [key: string]: AudioType } = {
      // 데이터의 label과 실제 값이 다른 키를 새로운 객체에 정리
      " ": "E", // 스페이스바
      escape: "B", // esc
      backspace: "A",
      enter: "B",
      tab: "D",
      capslock: "D",
      arrowup: "A",
      arrowdown: "A",
      arrowleft: "A",
      arrowright: "A",
      control: "C", // ctrl
      meta: "C", // Windows 키 (macOS에서는 Command 키)
      alt: "C",
      fn: "C", 
      "`": "A", // 백틱
      "=": "A",
    };

    // 실제 키 이름과 맞추기 ---> mainKeys에 specialKeyMap를 더하기
    const keyMap = mainKeys.flat().reduce((accumulator, currentValue) => {
      const keyLabel = currentValue.label.toLowerCase(); // 각 키의 실제 이름
      accumulator[keyLabel] = currentValue.audio;
      return accumulator;
    }, specialKeyMap); // 초기값으로 지정했기 때문에 accumulator는 specialKeyMap를 갖고 출발

    const handleKeyDown = (event: KeyboardEvent) => {
      const keyName = event.key.toLowerCase();  // 현재 눌린 키
      const audioType = keyMap[keyName];

      if (audioType) {
        event.preventDefault(); // 원래 이 키의 기능 방지
        playHandler(audioType);
      }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

 

이번에야말로 모든 키에 대해 정상적으로 오디오가 출력되는 것을 확인할 수 있다.

 

 

 

전체 코드

import { useRef, useEffect, useState } from "react";
import "../styles/styles.css";
import mainKeys from "../assets/data/keyArray";
import { functionKeys } from "../assets/data/keyArray";
import { AudioType } from "../assets/data/keyArray";
import AAudio from "../assets/audio/A.mp3";
import BAudio from "../assets/audio/B.mp3";
import CAudio from "../assets/audio/C.mp3";
import DAudio from "../assets/audio/D.mp3";
import EAudio from "../assets/audio/E.mp3";

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 createAudioArray = (audioSrc: string, count: number) =>
    Array.from({ length: count }, () => new Audio(audioSrc));

  // 각 오디오 타입별로 미리 10개의 오디오 객체 생성
  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({
    A: createAudioArray(AAudio, 10),
    B: createAudioArray(BAudio, 10),
    C: createAudioArray(CAudio, 10),
    D: createAudioArray(DAudio, 10),
    E: createAudioArray(EAudio, 10),
  });

  // 현재 재생할 복제본의 인덱스를 저장할 객체
  const audioIndex = useRef<{ [key in AudioType]: number }>({
    A: 0,
    B: 0,
    C: 0,
    D: 0,
    E: 0,
  });

  // 미리 오디오 파일 로드
  useEffect(() => {
    Object.values(audioMap.current).forEach((audioArray) =>
      audioArray.forEach((audio) => audio.load())
    );
  }, []);

  // 오디오 재생
  const playHandler = (audio: AudioType) => {
    const audioArray = audioMap.current[audio];
    const index = audioIndex.current[audio];

    // 현재 인덱스에 해당하는 오디오 객체 재생
    const audioElement = audioArray[index];
    audioElement.currentTime = 0; // 초기화
    audioElement.play();

    // 인덱스 순환하며 다음 오디오 재생
    audioIndex.current[audio] = (index + 1) % audioArray.length;
  };

  useEffect(() => {
    if (!typingOn) return; // 타이핑 모드에서만 실행

    const specialKeyMap: { [key: string]: AudioType } = {
      //데이터의 label과 실제 값이 다른 키
      " ": "E",
      escape: "B",
      backspace: "A",
      enter: "B",
	  ...
    };

    //실제 키 이름과 맞추기 ---> mainKeys에 specialKeyMap를 더하기
    const keyMap = mainKeys.flat().reduce((accumulator, currentValue) => {
      const keyLabel = currentValue.label.toLowerCase(); //소문자만 인식됨. 각 키의 실제 이름
      accumulator[keyLabel] = currentValue.audio;
      return accumulator;
    }, specialKeyMap); // 초기값으로 지정했기 때문에 accumulator는 specialKeyMap를 갖고 출발

    const handleKeyDown = (event: KeyboardEvent) => {
      const keyName = event.key.toLowerCase();  //현재 눌린 키
      const audioType = keyMap[keyName];

      if (audioType) {
        event.preventDefault(); //원래 이 키의 기능 방지
        playHandler(audioType);
      }
    };

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [typingOn]); //상위 컴포넌트에서 타이핑모드로 설정한 경우

  const handleKnobChange = (e: { value: number }) => {
    setKnobValue(e.value);
    onKnobChange(e.value);
  };

  return (
    <div className="key-container">
      <div className="quad-key-row">
        <button
          className={`esc ${!typingOn ? "hoverEffect" : ""}`}
        > // 타이핑 모드에서는 hover 스타일 제거
          <span className="key-span">ESC</span>
        </button>

        <div className="quad-container ">
          {functionKeys.map((row, rowIndex) => (
            <div className="quad-row" key={rowIndex}>
              {row.map((key) => (
                <button
                  className={`quad-keys ${!typingOn ? "hoverEffect" : ""}`}
                  key={key}
                >
                  <span className="key-span">{key}</span>
                </button>
              ))}
            </div>
          ))}
        </div>

        <button className="wheel">
          <span className="key-span">LED</span>
        </button>
      </div>

      <div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (
          <div className="key-main-row" key={rowIndex}>
            {row.map((label, labelIndex) => (
                className={`main-keys ${label.extraClass || ""} ${ !typingOn ? "hoverEffect" : ""}`}
                key={labelIndex}								
              >
                <span className="key-span">{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

 

 

 

 

 

배열의 각 요소에 대해 주어진 함수(callback)을 실행하여 단일한 결과값을 만들어낸다.

배열의 총합, 최대 or 최소값, 평균, 또는 배열을 객체로 변환할 때 유용하다.

 

 

기본형

array.reduce((accumulator) => { return.. }, initialValue);

 

  • 첫번째 매개변수: 배열의 각 요소에 대해 실행할 콜백함수
    • accumulator (누적값)
    • currentValue (현재 요소 값) : accumulator가 0이라면 1, 아닌 경우 0부터 시작
    • currentIndex (현재 인덱스)
    • array (배열 전체)
  • 두번째 매개변수: initialValue (선택 사항): accumulator의 초기값. 생략하면 첫 번째 요소가 초기값으로 설정된다.

 

 

예제

numbers 배열에 있는 모든 요소의 합을 구하는 경우, 다양한 방식이 있다.

// 메소드 없이 for문으로 순회
const numbers = [1, 2, 3, 4]
let total = 0
for (let i = 0, i <= numbers.length, i++) {
	total += numbers[i]
}
// forEach 메소드 활용
const numbers = [1, 2, 3, 4]
let total = 0
numbers.forEach((number) => {
	total = total + number
})

 

// reduce 사용한 경우
const numbers = [1, 2, 3, 4]

const total = numbers.reduce((accumulator, currentValue) => {
	return accumulator + currentValue
}, 0) // 0은 accumulator의 초기값

 

 

 

최대값을 구해보자.

// 최대값 구하기
const numbers = [8, 10, 24, 7]

numbers.reduce((accumulator, currentValue) => {
	if (accumulator < currentValue){
    	return currentValue	// accumulator의 값을 currentValue로 업데이트
    }
    else {
    	return accumulator
    }
}) //초기값을 지정하지 않았으므로 [0]번째부터 시작

 

 

이번에는 배열 내 객체 요소의 합을 구해보자.

const account = [
    {name: "A",
     cost: 100,
    },
    
    {name: "B",
     cost: 600,
    },
    
    {name: "C",
     cost: 20,
    },
]


account.reduce((accumulator, elem) => {
	return accumulator + account.cost
}, 0)

 

 

 

 

 

 

 

 

 

 

 

마음 같아서는 모든 키의 소리를 각각 녹음하고 싶었으나, 제작의 편의를 위해 크게 5가지로 나누어 소리를 녹음했다.

 

A: 정방형 비율에 가까운 일반 키

B: 엔터, Backspace, Shift 등 긴 키

C: Ctrl, Window 등 짧은 키

D: Tab, Caps Lock 등 중간 키

E: 스페이스바

 

 

 

각 녹음 파일의 잡음을 제거하고, 시작 및 끝 지점 (Duration)을 맞추어 추출한다. (재생 시 일정한 시간을 보장하기 위해)

 

 

 

지난 포스트에서 제작한 mainKeys 배열에 audio 항목을 추가하고, 각 키에 해당하는 오디오를 표시한다.

ESC와 FunctionKeys의 경우 소리가 모두 같다고 가정하여 배열에 작성하지 않고, 추후 함수에 "A" 로 통일했다.

export type AudioType = "A" | "B" | "C" | "D" | "E"; //	반드시 이 중 하나여야함을 명시

interface Key {
  label: string;
  audio: AudioType;
  extraClass?: string;
}

const mainKeys: Key[][] = [
...
  [
    { label: "CTRL", audio: "C", extraClass: "w-[8%]" },
    { label: "WIN", audio: "C", extraClass: "w-[7%]" },
    { label: "ALT", audio: "C", extraClass: "w-[7%]" },
    { label: "SPACE", audio: "E", extraClass: "w-[38.6%]" },
    { label: "FN", audio: "C", extraClass: "w-[7.2%]" },
    { label: "CTRL", audio: "C", extraClass: "w-[7.5%] mr-[3%]" },
    { label: "←", audio: "A", extraClass: "w-[5.6%]" },
    { label: "↓", audio: "A", extraClass: "w-[5.7%]" },
    { label: "→", audio: "A", extraClass: "w-[5.7%]" },
  ],
]

 

 

 

이제 오디오 Keys 컴포넌트에 오디오 파일을 불러오고, 오디오를 호출해보자.

import AAudio from "../assets/audio/A.mp3";
import BAudio from "../assets/audio/B.mp3";
import CAudio from "../assets/audio/C.mp3";
import DAudio from "../assets/audio/D.mp3";
import EAudio from "../assets/audio/E.mp3";


type AudioType = "A" | "B" | "C" | "D" | "E";

...

// 실제 오디오 파일과 AudioType을 연결
  const audioMap: { [key in AudioType]: string } = { // AudioType에 있는 요소 중 하나임을 명시
    A: AAudio,
    B: BAudio,
    C: CAudio,
    D: DAudio,
    E: EAudio,
  };

  const playHandler = (audio: AudioType) => {
    const audioFile = audioMap[audio];	// 지정한 오디오 파일을 인자에 맞게 가져옴(A~E 중 하나)
    const audioElement = new Audio(audioFile); //audioFile 경로를 사용하여 새로운 Audio 객체 생성
    audioElement.play(); // 함수 실행
  };

 

 

Audio 객체란?

  • JavaScript에서 제공하는 웹 오디오 API의 일부이다,
  • new Audio(audioFile) 처럼 파일 경로, 또는 URL을 인자로 받아 새로운 Audio 객체를 생성한다.
  • .play(), .pause(), volume, currentTime 등 메소드를 제공한다.

 

 

이제 이 핸들러 함수를 각 키에 연결하면 된다.

타이핑 버전, 클릭 버전, 호버 버전을 각각 만들 예정이지만, 우선 호버 (mouseEnter)으로 테스트해본다.

<div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (
          <div className="key-main-row" key={rowIndex}>
            {row.map((label, labelIndex) => (
              <button
                onMouseEnter={playHandler(label.audio)} // 핸들러 함수 연결
                className={`main-keys ${label.extraClass || ""}`}
                key={labelIndex}
              >
                <span>{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>

 

 

 

이 상태에서 키보드에 마우스를 올려보니, 다음과 같은 에러가 콘솔창에 나타났다.

 

 

Uncaught (in promise) DOMException: The play method is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
자동 재생은 사용자의 승인이 있어야 합니다. 사이트가 사용자에 의해서 활성화되지 않았으면 미디어의 소리가 나지 않습니다.

 

 

 

위 내용은 쉽게 말해서 유저의 승인 없이는 브라우저에서 소리를 자동 재생할 수 없다는 뜻이다.

브라우저에서 예상하지 못한 소리가 갑작스럽게 재생된다면 사용자의 경험을 저해할 수 있다.

따라서 사용자가 이를 인지하고, 어떤 액션을 취해야만 소리가 재생될 수 있다.

하다 못해 아무데나 클릭이라도 해야 재생된다.

 

 

우선 테스트이니, 화면을 한번 클릭하고 키 위에 마우스를 올려보면 오디오가 잘 재생되는 것을 확인할 수 있다.

 

 

 

 


 

 

 

 

오디오 파일의 앞 부분을 0.5초 미만으로 축소했음에도 불구하고, 매번 소리가 조금 늦게 재생되는 것을 느꼈다.

 

파헤쳐 보니, 핸들러 함수가 실행될 때마다 오디오 파일을 다시 로드하고, 새로운 Audio 객체를 생성하고 있었다.

 

파일 자체가 매우 작지만, 키보드 특성 상 빠르고 연속적으로 유저 상호작용이 일어날 때를 고려하면-

미리 오디오 파일을 로드해서 캐시하는 장점이 사라지고, 앞선 파일과 연속 재생이 어려워지며, 리소스 낭비로 이어진다.

 

 

이 객체를 최초 한 번만 생성하고 컴포넌트를 리렌더링 하더라도 유지하는 방법이 없을까?

이런 문제는 useRef를 통해 해결할 수 있다.

 

 

아래 두 코드를 비교해보자.

//기존 코드
  const audioMap: { [key in AudioType]: string } = {
    A: AAudio,
    B: BAudio,
    C: CAudio,
    D: DAudio,
    E: EAudio,
  };

  const playHandler = (audio: AudioType) => {
    const audioFile = audioMap[audio];
    const audioElement = new Audio(audioFile); 
    audioElement.play();
  };
//수정된 코드
  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement }>({ // 엄격한 타입 정의. 생성된 모든 Audio객체는 HTMLAudioElement 타입을 갖는다.
    A: new Audio(AAudio), // 최초 객체 생성하여 useRef에 저장
    B: new Audio(BAudio),
    C: new Audio(CAudio),
    D: new Audio(DAudio),
    E: new Audio(EAudio),
  });

  // 현재 오디오 객체 확인
  useEffect(() => {
  	console.log(audioMap.current)
  }, []);

  const playHandler = (audio: AudioType) => {
    const audioElement = audioMap.current[audio];
    audioElement.currentTime = 0; // 연속 재생을 위해 시간 초기화
    audioElement.play();
    console.log("재생", audio);
  };

 

 

이렇게 구성하면 객체는 최초 한 번만 생성되기 때문에 매번 Audio 객체가 새로 생성되는 것을 막을 수 있다.

 

 

다만, 여러 키를 계속해서 호버했더니 오디오가 심하게 끊긴다.

게다가 오히려 매번 객체를 생성할 때는 빠르게 입력하는 경우, 앞선 오디오가 끝나기도 전에 다음 오디오가 정상적으로 실행됐는데,

수정한 코드에서는 직전에 실행한 오디오가 모두 실행되기 전에는 다음 오디오가 재생되지 않았다.

 

 

키보드 특성 상 빠른 타이핑과 샷건 등 여러 키가 거의 동시에 눌리는 상황에도 부드러운 소리 재생을 원했다.

생성된 오디오 객체가 각각 하나 뿐이기 때문에 부자연스러운 재생이 이루어진 것이다.

 

 

 

그렇다면 오디오 객체를 여러개 생성하여 배열에 저장하고, 미처 다 재생되기 전에 또 재생하는 상황에서 그 다음 인덱스를 재생하는 방법은 어떨까?

 

 

 

 

 

우선 최초 Audio 객체를 여러개 중복으로 생성한다. 넉넉히 5개씩 생성해보았다.

  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({ //배열 형태임을 추가 명시
    A: [new Audio(AAudio), new Audio(AAudio), new Audio(AAudio), new Audio(AAudio), new Audio(AAudio)],
    B: [new Audio(BAudio), new Audio(BAudio), new Audio(BAudio), new Audio(BAudio), new Audio(BAudio)],
    C: [new Audio(CAudio), new Audio(CAudio), new Audio(CAudio), new Audio(CAudio), new Audio(CAudio)],
    D: [new Audio(DAudio), new Audio(DAudio), new Audio(DAudio), new Audio(DAudio), new Audio(DAudio)],
    E: [new Audio(EAudio), new Audio(EAudio), new Audio(EAudio), new Audio(EAudio), new Audio(EAudio)],
  });

 

그리고 현재 재생할 오디오에 대한 인덱스를 저장할 객체를 만들어준다.

  const audioIndex = useRef<{ [key in AudioType]: number }>({
    A: 0,
    B: 0,
    C: 0,
    D: 0,
    E: 0,
  });

 

마지막으로 이 인덱스를 순환하며 playHandler를 재생한다.

  const playHandler = (audio: AudioType) => {
    const audioArray = audioMap.current[audio];	//useRef로 생성한 객체의 오디오 가져오기
    const index = audioIndex.current[audio]; //해당 인덱스
    
    // 현재 인덱스와 일치하는 오디오 객체를 재생
    const audioElement = audioArray[index];
    audioElement.currentTime = 0; // 초기화 해주지 않으면 그 다음 재생되는 오디오가 같은 시점에 resume된다
    audioElement.play();

    // 다음 인덱스로 이동. 마지막이라면 0으로 돌아가며 순환
    audioIndex.current[audio] = (index + 1) % audioArray.length;
  };

 

이제 그 전보다 딜레이가 많이 줄어든 것을 확인할 수 있다.

물론, 아주 마우스 커서를 아주 빠르게 움직이니 어느 정도 렉이 걸리는데... 어떻게 해야 더더욱 부드럽게 만들 수 있을지 고민해보아야겠다.

 

 

 

 

 


 

 

 

 

 

 

추가로, 위 객체 복제본을 생성하는 코드를 조금 더 효율적으로 작성할 수도 있다.

  const createAudioArray = (audioSrc: string, count: number) => 
    Array.from({ length: count }, () => new Audio(audioSrc));
  
  const audioMap = useRef<{ [key in AudioType]: HTMLAudioElement[] }>({
    A: createAudioArray(AAudio, 10),
    B: createAudioArray(BAudio, 10),
    C: createAudioArray(CAudio, 10),
    D: createAudioArray(DAudio, 10),
    E: createAudioArray(EAudio, 10),
  });

 

 

 

 

 

 

 

최근 키보드를 새로 샀다.

요즘은 축이 다양해지고 커스텀 옵션의 폭도 넓어져 실제로 사용해보지 않는 한 키감은 정확히 알 수 없으나, 소리만큼은 유튜브 등에서 후기 포스트를 통해 들어본다.

다만 이런 식으로 키보드를 사는 나에게 가장 어려운 점은- 녹음 환경이 달라, 같은 제품이라도 영상마다 소리가 많이 다르다는 점이다.

 

소리가 너무나 마음에 들어 키보드 없이 노트북만 들고 외출한 경우에도 듣고 싶었다

그래서 이 키보드의 소리를 최대한 객관적으로 들을 수 있는 가상 키보드 프로젝트를 시작해보았다.

 

 

 

 


 

 

 

 

 

이번에는 Typescript 기반의 React에 Vite를 사용하고, 스타일링은 Tailwind를 사용한다.

 

Vite 설치 방법

npm create vite@latest
react-ts //원하는 프레임워크와 템플릿 작성

 

Vite 개발 서버 실행 방법

npm run dev
yarn dev

 

 

 

 

 


 

 

 

 

 

 

 

우선 피그마로 키보드의 레이아웃을 잡고, LED가 켜진 상태, 꺼진 상태 이미지를 제작했다.

브라우저 크기에 따라 이미지 화질이 저하되는 것을 방지하기 위해 벡터 기반으로 그려 SVG로 추출했다.

 

 

 

무지개색 LED 레이어는 추후 마스크로 사용하여 움직이는 배경 애니메이션을 제작할 예정이다.

우상단의 원형 버튼은 LED를 끄고 켜는 기능을 한다.

 

 

위 이미지를 브라우저 중앙에 위치시키고, LED 관련 레이어를 같은 좌표에 위치시켰다.

Keys 컴포넌트에 해당 기능을 먼저 만들고, 상위 컴포넌트에 props로 넘겨주어 on/off 여부에 따라 다른 레이어가 나타나도록 했다.

//Keys.tsx

<button onClick={onLightToggle} className="wheel">
  <span>LED</span>
</button>
//App.tsx

function App() {

  const [lightOn, setLightOn] = useState<boolean>(false);
  const lightHandler = () => {
    setLightOn(!lightOn);
  };

...

return (
...
 <div className="base">
    <Keys
    	onLightToggle={lightHandler}	//넘겨준 onLightToggle prop에 적절한 함수 연결
    />
    <img src={keyboard} alt="keyboard" className="w-full h-auto" />
    	{lightOn ? (
    <img src={keyboardOn} alt="keyboard" className="on" />
    ) : (
    <img src={keyboardOff} alt="keyboard" className="off" />
    )}
 </div>
 
 )
 }

 

 

나머지 버튼을 레이아웃 위치에 맞게 정렬했다.

이해를 돕기 위해 버튼마다 임시로 주황색을 입혔다. 분홍색 사각형은 각각을 묶은 단위.

 

 

 

정렬 방법은- 스타일이 많이 다른 ESC 키와 LED 키는 별개로 다루고, Fuction 키 열과 나머지 Main열을 각각 배열로 만들었다.

모든 키가 같은 규격을 갖지 않고, 각 열마다 조금씩 차이가 있었다.

따라서 다른 길이나 간격이 필요한 경우, extraClass 항목을 추가하여 스타일링했다.

//keyArray.ts

interface Key {
  label: string;
  extraClass?: string;
}

export const functionKeys = [
  ["F1", "F2", "F3", "F4"],
  ["F5", "F6", "F7", "F8"],
  ["F9", "F10", "F11", "F12"],
];

export const mainKeys: Key[][] = [	//2차원 배열
  // 첫번째 줄
  [
    { label: "~" },
    { label: "1" },
    { label: "2" },
    { label: "3" },
    ...
  ],
  // 두번째 줄
  [
    { label: "TAB", extraClass: "w-[9.4%]" },
    { label: "Q" },
    ...
  ],
  
  ...
  
  ];

 

 

 

 

이제 이 키를 map메소드를 통해 화면에 뿌려준다.

return (
    <div className="key-container">
      <div className="quad-key-row">
        <button className="esc">
          <span>ESC</span>
        </button>

        <div className="quad-container">
          {functionKeys.map((row, rowIndex) => ( // functionKeys 배열을 가져와 반복
            <div className="quad-row" key={rowIndex}>
              {row.map((key) => (
                <button
                  className="quad-keys"
                  key={key}
         		>
                  <span>{key}</span>
                </button>
              ))}
            </div>
          ))}
        </div>

        <button onClick={onLightToggle} className="wheel">
          <span>LED</span>
        </button>
      </div>

      <div className="key-main-container">
        {mainKeys.map((row, rowIndex) => (	// mainKeys 배열을 가져와 열마다 반복
          <div className="key-main-row" key={rowIndex}>	// 각 열을 지정했다면
            {row.map((label, labelIndex) => (	// 그 내부 키를 반복하여 지정
              <button
                className={`main-keys ${label.extraClass || ""}`} // 항목에 extraClass가 있다면 추가
                key={labelIndex}
              >
                <span>{label.label}</span>
              </button>
            ))}
          </div>
        ))}
      </div>
    </div>
  );

 

 

 

 

craco를 설치한다.

yarn add @craco/craco

 

 

package.json 의 script 객체를 아래와 같이 덮어쓴다.

"scripts": {
  "start": "craco start",
  "build": "craco build",
  "test": "craco test"
}

 

 

프로젝트 루트 디렉토리에 craco.config.ts 파일을 생성하고, 아래 내용을 추가한다.

module.exports = {
  style: {
    postcss: {
      plugins: [require('tailwindcss'), require('autoprefixer')],
    },
  },
};

 

 

그리고 아래 명령어를 통해 tailwind.config 파일을 생성한다.

yarn tailwindcss init -p

 

 

 

 

매번 프로젝트를 생성할 때마다 명령어를 잊어버려서 작성해둔다.

 

cd ~/Desktop

npx create-react-app new-project --template typescript

cd new-project

 

 

폴더 삭제

rm -rf project-name

 

 

Git 리포지토리 추가

git remote add origin https://github.com/<your-username>/my-new-repo.git

 

 

yarn 설치

brew install yarn
yarn --version

 

React Native에서 배경색, 폰트 색상 등의 기본값(default)은 app.json 파일에서 설정할 수 있다.

 

그러나 Stack.Navigator 를 사용하여 라우팅 구조를 만들면 이 기본값이 무시된다.

 

어떻게 수정할 수 있을까?

 

app.json에서 배경색을 설정해주었지만-
Stack.Navigator를 적용하자 app.json의 배경색이 무시된다.

 

 

 

 

 

Stack.Screen 태그의 속성에 주목해보자.

   <Stack.Screen
      name="Profile"  // 이 스크린의 이름
      component={ProfileScreen}	// 표시할 내용
      options={{
        title: "User Profile",	// 화면에 표시될 제목
        headerStyle: { backgroundColor: "#6200EA" },
        headerTintColor: "#FFFFFF",
        contentStyle: { backgroundColor: "#FAFAFA" },
        headerTitleAlign: "center",
        gestureEnabled: true
      }}
    />

 

주석에 표기한 것과 같이, Screen의 대표적인 속성을 알아두면 편리하다.

 

  • name : 해당 스크린의 이름. 다른 Screen에서 Navigating 할 때 작성할 좌표.
  • component : 표시할 컴포넌트의 내용
  • options : 객체 형태로 받는다. 전반적인 디자인 관련 속성을 모아둔 객체.
    • title : 헤더에 표시될 제목
    • headerStyle: 하위 객체에서 헤더의 스타일 결정
    • headerTintColor: 헤더 색. 디폴트는 #007AFF 
    • contentStyle: 스크린의 콘텐츠 영역에 대한 스타일을 하위 객체에서 결정. (전역적이지는 않음)
    • gestureEnabled: 스와이프를 통해 이전 화면으로 돌아가는 기능의 on, off

 

 

그러나 이렇게 스타일링을 하고 다른 스크린으로 넘어가면 이 스타일이 모든 스크린에 적용되지 않은 것을 확인할 수 있다.

이 때는 일일이 복사 + 붙여넣기 하기보다, Stack.Screen을 감싸고 있는 상위 Stack.Navigator에 이 options를 옮겨줄 수 있다.

<Stack.Navigator
  screenOptions={{
    headerStyle: { backgroundColor: "#6200EA" },  // 헤더 스타일
    headerTintColor: "#FFFFFF",  // 헤더 글씨 및 아이콘 색상
    contentStyle: { backgroundColor: "#FAFAFA" },  // 화면 배경 색상
    headerTitleAlign: "center",  // 헤더 제목 정렬
    gestureEnabled: true  // 스와이프 제스처 활성화
  }}
>

  <Stack.Screen 
    name="Profile" 
    component={ProfileScreen} 
    options={{ title: "User Profile" }}  // 특정 화면별 옵션 추가
  />
  
</Stack.Navigator>

 

이렇게 하면 화면을 옮겨다니더라도 스타일이 모두 적용된다.

 

 

 

+ Recent posts