개발 이야기/React

한글 타이핑 시스템을 구현해보았다.

2023. 6. 2. 22:21

완성된 작업물은 여기!

  • Simhansol-Box
 

GitHub - SIMHANSOL/Simhansol-Box: 심한솔 박스

심한솔 박스. Contribute to SIMHANSOL/Simhansol-Box development by creating an account on GitHub.

github.com

 


 

사실 한글 타이핑은 이미 라이브러리가 존재한다.

근데 라이브러리만 사용하면 재미가 없으니 직접 만들어보며 원리를 깨달아보고자 했다. 

 

일단 던지고 보자 ★

 

일단 가장 먼저 React 환경에서 만들어볼 것이기 때문에 컴포넌트로 개발할 예정이다.

컴포넌트에서는 문자열 타입의 children props을 전달받아 children의 문자열을 천천히  타이핑하는 기능을 담당할 것이다.

 

// how to use

<TypingEffect>타이핑 예제입니다.</TypingEffect>

 

먼저 구현을 하기 위해서는 한글과 영어의 특성을 잘 알고 있어야 한다.

 

영어같은 경우는 "Apple is iphone" 문자열의 타이핑 효과를 내기 위해서는 순차적으로 표시만 해주면 별다른 세팅없이 끝나지만 한글은 영어처럼 쉽지 않다.

 

모음과 자음이 복잡하게 연결되어 하나의 글자를 나타낸다.

그리고 이 문자를 어떻게 표기하고 있는지 유니코드 지식을 갖추고 있어야 한다.

 

"안녕"은 "ㅇㅏㄴㄴㅕㅇ"을 입력해서 만든다.

 

그래서 가장 먼저 타이핑 효과를 내기 위해서 타이핑할 문자열을 분해해버리도록 하였다.

분해한 다음에 천천히 재조립을 하면 타이핑 효과를 낼 수 있겠단 생각을 했다.

 

현대 한글의 음절
초성: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ (19개)
중성: ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ (21개)
종성: ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ (27개)
위의 초성, 중성, 종성을 조합해서 만들 수 있는 글자 수는 다음과 같다. 19 × 21 × ( 27 + 1 ) = 11172

일단 초성, 중성, 종성으로 분해를 해버리기 위해 타입을 정의했다.

 

// type.ts

export const chosung = [
  'ㄱ',
  'ㄲ',
  'ㄴ',
  'ㄷ',
  'ㄸ',
  'ㄹ',
  'ㅁ',
  'ㅂ',
  'ㅃ',
  'ㅅ',
  'ㅆ',
  'ㅇ',
  'ㅈ',
  'ㅉ',
  'ㅊ',
  'ㅋ',
  'ㅌ',
  'ㅍ',
  'ㅎ',
];

export const jungsung = [
  'ㅏ',
  'ㅐ',
  'ㅑ',
  'ㅒ',
  'ㅓ',
  'ㅔ',
  'ㅕ',
  'ㅖ',
  'ㅗ',
  'ㅘ',
  'ㅙ',
  'ㅚ',
  'ㅛ',
  'ㅜ',
  'ㅝ',
  'ㅞ',
  'ㅟ',
  'ㅠ',
  'ㅡ',
  'ㅢ',
  'ㅣ',
];

export const jongsung = [
  '',
  'ㄱ',
  'ㄲ',
  'ㄳ',
  'ㄴ',
  'ㄵ',
  'ㄶ',
  'ㄷ',
  'ㄹ',
  'ㄺ',
  'ㄻ',
  'ㄼ',
  'ㄽ',
  'ㄾ',
  'ㄿ',
  'ㅀ',
  'ㅁ',
  'ㅂ',
  'ㅄ',
  'ㅅ',
  'ㅆ',
  'ㅇ',
  'ㅈ',
  'ㅊ',
  'ㅋ',
  'ㅌ',
  'ㅍ',
  'ㅎ',
];
"안" 문자를 분해하면 "ㅇㅏㄴ"이다.
초성은 "ㅇ", 중성은 "ㅏ", 종성은 "ㄴ"이다.
"가" 문자를 분해하면 "가"이다.
초성은 "ㄱ", 중성은 "ㅏ", 종성은 없다.

 

이처럼 나는 초성, 중성, 종성을 이용해 문자열을 분해하고 천천히 조립하여 타이핑 효과를 내고자 했다.

근데 문제는 "안" 이라는 글자를 어떻게 코드 상에서 분해를 할 수 있는지에 대해서는 정작 고민해보지 않았던 것이다.

 

생각좀 하고 시도하라고....

 

다행히도 검색 결과,

한글 유니코드에 대한 규칙을 상세하게 설명해주고 있는 블로그를 찾았고 많은 도움이 되었다.

도움이 됐을 뿐만 아니라 아예 초성, 중성, 종성을 통해 어떻게 조합하고 분해하는지 로직 자체를 알려주고 있다.

 

힌트만 얻고 싶었는데 정답을 알게 됐다.

 

이에 참고하여 함수를 작성하기 시작했다.

 

// function.ts

import { chosung, jongsung, jungsung } from './type';

function isHangul(charCode: number) {
  if (charCode >= 0xac00 && charCode <= 0xd7a3) return true;
  
  return false;
}

function combineCharCode({
  returnText,
  cho,
  jung,
  jong,
  index,
  splice,
}: {
  returnText: string[];
  cho: number;
  jung: number;
  jong: number;
  index: number;
  splice: number;
}) {
  return returnText.splice(
    index,
    splice,
    String.fromCharCode(0xac00 + cho * Math.floor(0xae4c - 0xac00) + jung * Math.floor(0xac1c - 0xac00) + jong)
  );
}

export function decomposeText(text: string) {
  let returnText = '';

  for (let i = 0; i < text.length; i += 1) {
    const char = text.charAt(i);
    const code = char.charCodeAt(0);

    if (isHangul(code)) {
      const currentHangul = char.charCodeAt(0) - 0xac00;
      const cho = Math.floor(currentHangul / 21 / 28);
      const jung = Math.floor(currentHangul / 28) % 21;
      const jong = currentHangul % 28;

      returnText += `${chosung[cho]}${jungsung[jung]}${jongsung[jong]}`;
    } else {
      returnText += char;
    }
  }

  return returnText;
}

export function combineText(text: string) {
  let cho = -1;
  let jung = -1;
  let jong = -1;
  let decompose = '';
  let currentText = '';
  let nextText = '';

  let index;
  for (index = 0; index < text.length; index += 1) {
    currentText = text.charAt(index);
    nextText = text.charAt(index + 1);
    decompose = decomposeText(currentText);

    const tempCho = decompose.charAt(0);
    const tempJung = decompose.charAt(1);
    const tempJong = decompose.charAt(2);

    if (
      // 한글 타이핑이 가능한 경우 INDEX 반환 후 반복문 종료
      !(
        (chosung.includes(currentText) && chosung.includes(nextText)) || // 자음 + 자음
        (tempCho !== '' && tempJung !== '' && tempJong !== '') || // 자음 + 모음 + 자음
        (tempCho !== '' && tempJung !== '' && tempJong === '' && chosung.indexOf(nextText) === -1) || // 자음 + 모음 + 자음이 아닌 문자
        currentText === ' ' || // 공백
        nextText === ' ' || // 공백
        !(
          // 한글이 아닌 문자
          (
            isHangul(text.charCodeAt(index)) ||
            chosung.includes(currentText) ||
            jongsung.includes(currentText) ||
            jungsung.includes(currentText)
          )
        )
      )
    ) {
      cho = chosung.indexOf(tempCho || currentText);
      jung = jungsung.indexOf(tempJung);
      jong = jongsung.indexOf(tempJong);
      break;
    }
  }

  const nextHangul = nextText || currentText;

  if (nextHangul === '') return text;

  const returnText = [...text];

  if (jungsung.includes(currentText)) {
    const prevDecompose = decomposeText(text.charAt(index - 1));

    cho = chosung.indexOf(prevDecompose.charAt(0));
    jung = jungsung.indexOf(prevDecompose.charAt(1));
    combineCharCode({
      returnText,
      cho,
      jung,
      jong: 0,
      index: index - 1,
      splice: 1,
    });

    cho = chosung.indexOf(prevDecompose.charAt(2));
    jung = jungsung.indexOf(currentText);
    combineCharCode({
      returnText,
      cho,
      jung,
      jong,
      index,
      splice: 1,
    });
  } else if (jungsung.includes(nextHangul)) {
    jung = jungsung.indexOf(nextHangul);
    combineCharCode({
      returnText,
      cho,
      jung,
      jong,
      index,
      splice: 2,
    });
  } else if (jungsung.indexOf(decompose.charAt(decompose.length - 1)) !== -1) {
    cho = chosung.indexOf(decompose.charAt(0));
    jung = jungsung.indexOf(decompose.charAt(decompose.length - 1));
    jong = jongsung.indexOf(nextHangul);
    combineCharCode({
      returnText,
      cho,
      jung,
      jong,
      index,
      splice: 2,
    });
  }

  return returnText.join('');
}

 

function decomposeText(text: string)
input return
"안녕하세요." "ㅇㅏㄴㄴㅕㅇㅎㅏㅅㅔㅇㅛ.

이 함수는 매개 변수 text에 들어온 문자열을 초성, 중성, 종성으로 분해하는 기능을 하고 있다.

조건문을 통해 한글이면 분해하고 그렇지 않으면 문자 그대로 반환한다.

 

function combileText(text: string)
input return
"ㅇㅏㄴㅕㅇ" "안녕"

이 함수는 매개 변수 text에 들어온 분해된 문자열을 다시 재조립하는데 사용한다.

로직 자체는 한눈에 이해하기 힘들지만 간단히 설명하면 첫 문자를 꺼내와서 초성이면 다음 문자에 중성이 있는지 확인하고 이 다음 종성이 있으면 하나의 글자로 조립하는 형태이다. 당연히 중성이나 종성이 없다면 하나의 글자로 조립한다. 이를 문자열이 끝날 때까지 반복한다.

 

최적화는 모르겠고.... 작동은 하잖아?

 

이제 이 함수를 통해 분해하고, 조립할 수 있게 되었다.

남은 건 컴포넌트 상에서 props의 children으로 받아온 텍스트를 decomposeText 함수로 분해하고

특정 시간마다 글자 하나 하나 추가하면서 combineText 함수에 집어넣으면 된다.

이렇게 되면 타이핑 효과처럼 한글이 차차 작성되어 나가는 걸 볼 수 있다. 

 

커서 효과도 넣고, 옵저버를 통해 화면 상에 보일 때만 타이핑 효과가 진행되도록 만들었다.

옵저버를 넣은 이유는 타이핑 효과를 받는 엘리먼트가 화면 밖에 있을 때 보이기도 전에 글자가 완성되어 있는 것을 방지하고자 넣어뒀다.

 

아래 코드는 mui를 함께 사용하고 있어서 mui에 맞게 작성했다.

// typing-effect.tsx

import type { TypographyProps } from '@mui/material';
import { useCallback, useEffect, useRef, useState } from 'react';
import { observer as observerFunction } from '../../../modules';
import { combineText, decomposeText } from './function';
import { Cursor, TypingWrapper } from './style';

export interface TypingEffectProps extends TypographyProps {
  children: string;
  duration?: number;
  delay?: number;
  observer?: boolean;
}

export default function TypingEffect(props: TypingEffectProps) {
  const { children, duration = 50, delay = 300, observer, ...restProps } = props;

  const [typedText, setTypedText] = useState('');
  const [textIndex, setTextIndex] = useState(0);

  const elementRef = useRef<HTMLParagraphElement>(null);
  const typingText = useRef(decomposeText(children));

  const play = useCallback(
    () =>
      setTimeout(
        () => {
          if (textIndex === typingText.current.length) return;

          setTypedText((prev) => combineText(prev + typingText.current[textIndex]));
          setTextIndex((prev) => prev + 1);
        },
        textIndex === 0 ? delay : duration
      ),
    [delay, duration, textIndex]
  );

  useEffect(() => {
    let timer: ReturnType<typeof setTimeout> | undefined;
    let intersectionObserver: IntersectionObserver;

    if (observer != null && elementRef.current != null) {
      intersectionObserver = observerFunction(
        elementRef.current,
        () => {
          timer = play();
        },
        { callbackOnce: true }
      );
    } else {
      timer = play();
    }

    return () => {
      clearTimeout(timer);

      if (intersectionObserver != null) {
        intersectionObserver.disconnect();
      }
    };
  }, [observer, play]);

  return (
    <>
      <TypingWrapper
        ref={elementRef}
        {...restProps}>
        {typedText}
      </TypingWrapper>
      <Cursor>|</Cursor>
    </>
  );
}
// style.tsx

import styled from '@emotion/styled';
import { Box, Typography, keyframes } from '@mui/material';
import theme from '../../../styles/theme';

export const blink = keyframes`
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
`;

export const TypingWrapper = styled(Typography)`
  display: inline-block;
`;

export const Cursor = styled(Box)`
  display: inline-block;
  animation: ${blink} 1s infinite;
  color: ${theme.palette.grey[900]};
  animation-timing-function: steps(2, jump-none);
`;
// observer.ts

export interface ObserverOptions {
  root?: HTMLElement;
  rootMargin?: string;
  threshold?: number;
  callbackOnce?: boolean;
}

export default function Observer(
  elements: HTMLElement[] | HTMLElement,
  callback: (_element: Element, _observer: IntersectionObserver) => void,
  options?: ObserverOptions
) {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(({ isIntersecting, target }) => {
      if (isIntersecting) {
        callback(target, observer);

        if (options != null && options.callbackOnce) {
          observer.unobserve(target);
        }
      }
    });
  }, options);

  if (elements instanceof Array) {
    elements.forEach((element) => observer.observe(element));
  } else {
    observer.observe(elements);
  }

  return observer;
}

 

 

완성 ★

 

커서 효과 넣기 전에 만든 이미지라 예제에는 커서가 보이지 않지만 실제로는 타이핑되는 글자에 따라 깜빡인다.

'개발 이야기 > React' 카테고리의 다른 글

React, Key 속성을 왜 사용하는지 알고 사용하자  (0) 2023.07.19
React(리액트)는 Storybook(스토리북)을 적극 사용하자.  (0) 2023.07.05
육각형 그래프 애니메이션을 구현해보았다.  (0) 2023.06.03
'개발 이야기/React' 카테고리의 다른 글
  • React, Key 속성을 왜 사용하는지 알고 사용하자
  • React(리액트)는 Storybook(스토리북)을 적극 사용하자.
  • 육각형 그래프 애니메이션을 구현해보았다.
S.H.S
S.H.S
한또리의 일기장
S.H.S
한또리의 일기장
전체
오늘
어제
  • 분류 전체보기 (35)
    • 개발 이야기 (1)
      • JavaScript (4)
      • TypeScript (0)
      • React (4)
      • Git (3)
      • Next.js (0)
      • Pattern Matching (1)
      • Terminal (1)
      • AWS (1)
      • Unity (0)
      • Python (0)
      • Ubuntu (0)
      • Aduino (0)
    • 즐거운 게임 수학 (9)
    • 개발자 면접 후기 (7)
    • 일상 (4)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 각도
  • 거리
  • 수학
  • 프론트엔드
  • react
  • 패키지 관리자
  • 애니메이션
  • 프론트엔드 면접
  • 신박함
  • 회사
  • 계산
  • 이색테마
  • 개발자
  • code-owners
  • 원
  • git
  • 컴포넌트 기반
  • 컴포넌트 렌더링
  • 수염 자국
  • 컴포넌트 시각화

최근 댓글

최근 글

hELLO · Designed By 정상우.
S.H.S
한글 타이핑 시스템을 구현해보았다.
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.