본문 바로가기
React

highlight.js를 이용한 실시간 코드 하이라이팅 에디터 만들기

by Luke K 2022. 11. 19.

부스트 캠프 그룹 프로젝트로 코드를 작성하고 코드 리뷰를 할 수 있는 플랫폼을 개발하고 있다.

가독성이 중요한 플랫폼인데 하이라이팅이 안 된 코드는 가독성이 좋지 않다.

따라서 코드 하이라이팅을 개발해야 했는데 실제로 모든 언어의 하이라이팅을 분기 처리해서 개발하기에는 일정이 빠듯한 관계로 highlight.js라는 라이브러리를 이용하였다.

이 글은 그 과정에서 겪었던 어려움과 해결 과정, 해결한 코드를 포함한다.

최종 화면

겪은 문제

모든 언어의 하이라이팅이 적용될 범위를 파싱 해서 스타일링하는 것은 어렵다.

현재 개발 중인 서비스는 프로그래밍 언어를 가리지 않고 많은 사용자들이 참가할 수 있게 하기 위해 다양한 종류의 프로그래밍 언어를 지원해야 한다. 필자는 다양한 언어의 의미 있는 토큰 (function, class, this 등등)을 알고 있지 못해서 공부해서 언어별로 만들기에는 시간이 굉장히 오래 걸릴 것이고 확실히 그 토큰들의 특성을 이해하고 개발하지 못해 사용자들이 불편함을 느낄 수 있다.

사용자가 하나의 태그에서 입력하는 value들은 텍스트는 부분 스타일링이 불가하다.

<textarea
  value={code}
  onChange={changeCode}
  className="code-editor__textarea"
  autoComplete="false"
  spellCheck="false"
/>

textarea나 input은 하나의 html 태그고 이 내부에 있는 각각의 의미 있는 토큰(ex: function, #include, console, class)을 각각 다르게 스타일링할 수 없다.

div 등의 글쓰기 기능이 없는 태그들도 contentEditable 속성을 이용하면 textarea나 input 태그처럼 글자는 입력할 수 있지만 하나의 태그 내부에서 모든 토큰들을 다르게 스타일링하는 것은 불가능하다.

해결 과정

모든 언어의 토큰들을 공부해서 파싱 하기보다는 파싱 후 각 태그의 class명까지 자동으로 입력되는 라이브러리인 highlight.js를 사용하기로 했다.

highlight.js에서 제공해주는 API인 highlight 메서드를 텍스트에 적용하면 html 태그로 바꾸어준다.

hljs.highlight("function(){\n  console.log('hi');\n}", { language: "javascript" }).value;

위 코드의 반환 값은 이런 모양의 HTML이다.

<span class="hljs-keyword">function</span>(<span class="hljs-params"></span>){
  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;hi&#x27;</span>);
}

즉 이런 HTML을 사용자가 코드를 입력할 때 만들어 사용자에게 보여주면 된다.

textarea에 입력되는 코드를 실시간으로 스타일링해서 보여주기 위해 textarea는 숨기고 사용자 입력부와 같은 위치에 highlight가 바꿔주는 HTML을 넣어준다.

레이아웃을 살펴보자.

return (
  <div className="code-editor">
    <textarea
      value={code}
      onChange={changeCode}
      className="code-editor__textarea"
      autoComplete="false"
      spellCheck="false"
    />
    <pre className="code-editor__present">
      <code
        dangerouslySetInnerHTML={createMarkUpCode(highlightedHTML)}
      ></code>
    </pre>
  </div>
);

리턴을 살펴보면 div로 감싸진 textarea와 pre가 있다.

필자는 같은 크기의 textarea와 pre를 div내에 만들고 내부에 있는 code에 highlight.js가 만들어준 html을 innerHTML 하여 textarea의 각각의 코드들에 스타일을 입힐 수 없는 문제를 해결했다.

해당 레이아웃의 style은 다음과 같다.

.code-editor{
    position: relative;
    width: 100%;
    height: 90%;
    border-radius: 0.25rem;
  &__textarea, &__present {
    color: $weview-white;
    font-size: 1.25rem;
    position: absolute;
    margin: 0;
    width: calc(100% - 1em);
    height: calc(100% - 1em);
    padding: 0.5em;
  }
  &__textarea {
    caret-color: $alert;
    color: transparent;
    background-color: transparent;
    z-index: 1;
    border: none;
    resize: none;
  }
  &__present{
    background-color: $codeblock-color;
    color: #c9d1d9;
    z-index: 0;
    border-radius: 0.25rem;
    text-overflow: ellipsis;
  }
}

.code-editorposition: relative;를 주고 자식 태그인 textarea와 pre태그에 position: absolute를 적용하여 레이아웃을 맞추고 widthheight는 아래 패딩에 맞추어 설정한다.

이후 textarea에만 z-index를 높여 클릭 시 textarea가 클릭되도록 적용하고 colorbackground-color 속성을 transparent로 적용하여 textarea 태그에 있는 배경과 글씨는 보이지 않도록 한다.

 

코드에 테마 적용하기 

필자의 경우에는 github-dark 테마를 적용했는데 이 스타일은 highlight.js Github에서 가져올 수 있다.

github-dark 테마 스타일 바로가기

위 스타일을 복사한다음에 스타일이 적용되는 .css 혹은 .scss 혹은 styled-component등에 붙여넣기 하면 적용시킬 수 있다.

highlight가 만들어주는 태그의 class 이름 별로 styling이 돼있기 때문이다.

github-dark 테마 외에 다른 테마를 적용하고 싶다면 highlight.js 깃헙에 src/styles 폴더 내부에서 선택할 수 있다.

 

이제 전체 코드를 살펴보며 react component 내부에서 어떤 식의 흐름으로 동작하는지 살펴보자.

import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
import useWritingStore from "@/store/useWritingStore";
import hljs from "highlight.js";

const CodeEditor = (): JSX.Element => {
  const [highlightedHTML, setHighlightedCode] = useState("");
  const { code, setCode, language } = useWritingStore((state) => ({
    code: state.code,
    setCode: state.setCode,
    language: state.language,
  }));
  useEffect(() => {
    setHighlightedCode(
      hljs.highlight(code, { language }).value.replace(/" "/g, "&nbsp; ")
    );
  }, [code]);

  const changeCode = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
    setCode(e.target.value);
  }, []);

  const createMarkUpCode = useCallback(
    (code: string): { __html: string } => ({
      __html: code,
    }),
    [code]
  );

  return (
    <div className="code-editor">
      <textarea
        value={code}
        onChange={changeCode}
        className="code-editor__textarea"
        autoComplete="false"
        spellCheck="false"
      />
      <pre className="code-editor__present">
        <code
          dangerouslySetInnerHTML={createMarkUpCode(highlightedHTML)}
        ></code>
      </pre>
    </div>
  );
};

export default CodeEditor;

아래 코드에서 code, language는 전역 상태여서 전역 store에서 가져오고 setCode라는 action을 가져온다.

const { code, setCode, language } = useWritingStore((state) => ({
  code: state.code,
  setCode: state.setCode,
  language: state.language,
}));

전역 상태 관리가 필요 없을 때는 useState를 사용하면 된다.

const [code, setCode] = useState("");

이후 highlight.js를 import해오기 위해 highlight.js를 install 해주었다.

npm i highlight.js

이후 useEffect를 사용하여 code가 변하는 동시에 하이라이팅을 시켜준다.

useEffect(() => {
  setHighlightedCode(
    hljs.highlight(code, { language }).value.replace(/" "/g, "&nbsp; ")
  );
}, [code]);

textarea의 입력이 바뀌는 이벤트가 일어나면 setCodecode를 변화시켜 하이라이팅 이벤트가 동작하도록 한다.

const changeCode = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
  setCode(e.target.value);
}, []);

react는 dangerouslySetInnerHTML메서드로 innerHTML을 사용할 때에는 xss 공격에 쉽게 노출될 수 있다는 걸 상기할 수 있도록 한다.

지금의 경우에는 highlight.js에서 <&lt; >&gt;로 변환해주기 때문에 <script>의 삽입은 불가하다.

위험한 것은 서버로 가는 요청인데 필자의 서비스에서는 서버로 가는 요청은 따로 한번 걸러준다.

혹시 이 글을 보며 이 로직을 사용하실 거라면 서버로 요청이 가기 전에 한 번의 검증 로직을 추가해주세요!

const createMarkUpCode = useCallback(
  (code: string): { __html: string } => ({
    __html: code,
  }),
  [code]
);

그리고 아까 확인한 return문은 그대로이다.

return (
  <div className="code-editor">
    <textarea
      value={code}
      onChange={changeCode}
      className="code-editor__textarea"
      autoComplete="false"
      spellCheck="false"
    />
    <pre className="code-editor__present">
      <code
        dangerouslySetInnerHTML={createMarkUpCode(highlightedHTML)}
      ></code>
    </pre>
  </div>
);

마무리

프로젝트를 진행하며 재밌는 문제를 마주해서 글을 업로드해본다.

서비스 이름은 WeView이고 코드 리뷰의 어려움, 막막함, 내 코드 읽어줄 사람이 없음 등의 문제를 재밌게 풀어보려고 한다.

위 코드도 서비스에 첨부돼있으니 해당 링크에서 확인해도 좋을 것 같다.

현재 에디터에 재밌는 기능들이 추가될 것 같다.

또 재밌는 문제를 발견하면 해결하는 글을 작성해 보려 한다.

댓글