1-6. 마크다운 에디터 구현하기

마크다운 에디터를 구현하기 위해선, 우선 3개의 라이브러리를 설치해주어야 합니다.

$ yarn add codemirror marked prismjs

CodeMirror 적용하기

CodeMirror 는 코드 에디터 라이브러리입니다. 코드에 색상을 입혀주는 역할을 하는데요, 우리가 마크다운을 작성 할 때 각 문법에 따라 다른 색상을 입혀주고, 마크다운 내부에 작성되는 코드에도 (예: 자바스크립트) 문법에 따라 색을 입혀줍니다.

EditorPane 와서 CodeMirror 관련 자바스크립트 파일과 스타일을 불러오고, componentDidMount 가 되었을 때, CodeMirror 인스턴스를 생성하여 페이지에 나타나게 하겠습니다. 이 과정에서, code-editor 클래스를 가진 div 에 ref 를 설정하여 해당 DOM 에 CodeMirror 를 삽입합니다.

src/components/editor/EditorPane/EditorPane.js

import React, { Component } from 'react';
import styles from './EditorPane.scss';
import classNames from 'classnames/bind';

import CodeMirror from 'codemirror';

import 'codemirror/mode/markdown/markdown'; // 마크다운 문법 색상
// 마크다운 내부에 들어가는 코드 색상
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/jsx/jsx';
import 'codemirror/mode/css/css';
import 'codemirror/mode/shell/shell';

// CodeMirror 를 위한 CSS 스타일
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/monokai.css';

const cx = classNames.bind(styles);

class EditorPane extends Component {

  editor = null // 에디터 ref
  codeMirror = null // CodeMirror 인스턴스

  initializeEditor = () => {
    this.codeMirror = CodeMirror(this.editor, {
      mode: 'markdown',
      theme: 'monokai',
      lineNumbers: true, // 좌측에 라인넘버 띄우기
      lineWrapping: true // 내용이 너무 길면 다음 줄에 작성
    });
  }

  componentDidMount() {
    this.initializeEditor();
  }

  render() {
    return (
      <div className={cx('editor-pane')}>
        <input className={cx('title')} placeholder="제목을 입력하세요" name="title"/>
        <div className={cx('code-editor')} ref={ref=>this.editor=ref}></div>
        <div className={cx('tags')}>
          <div className={cx('description')}>태그</div>
          <input name="tags" placeholder="태그를 입력하세요 (쉼표로 구분)"/>
        </div>
      </div>
    );
  }
}

export default EditorPane;

이렇게 코드를 저장하고나면, 페이지에 에디터가 나타납니다.

html 을 조사해보면, code-editor 클래스를 가진 div 내부에 CodeMirror 가 생성되었습니다. 자, 이제 이 부분을 스타일링 해주겠습니다. 폰트를 우리가 이전에 설정해놓은 D2 Coding 으로 설정하고, 세로 크기를 전부 차지하도록 설정하겠습니다.

src/components/editor/EditorPane/EditorPane.scss - .code-editor

  .code-editor {
    flex: 1; // 남는 영역 다 차지하기
    background: $oc-gray-9;
    display: flex;
    flex-direction: column; // .CodeMirror 가 세로영역을 전부 차지
    :global .CodeMirror {
      font-size: 1rem;
      flex: 1;
      font-family: 'D2 Coding';
    }
  }

그냥 .CodeMirror 라고 하면, CSS Module 이 적용되어 고유아이디를 가진 클래스명이 생성되니, 앞부분에 :global 키워드를 붙여주면 해당 클래스에는 CSS Module 이 적용되지 않습니다.

스타일을 작성하고 나면 다음과 같이 에디터가 세로 길이를 전부 차지하게 되며, 에디터의 폰트도 변경됩니다.

에디터 상태 관리하기

editor 모듈 작성

이제, Editor 에서 작성되는 제목, 내용, 그리고 태그들의 상태를 리덕스에서 관리하겠습니다. 리덕스의 모듈 중 editor.js 를 다음과 같이 수정하세요.

src/store/modules/editor.js

import { createAction, handleActions } from 'redux-actions';

import { Map } from 'immutable';
import { pender } from 'redux-pender';

// action types
const INITIALIZE = 'editor/INITIALIZE';
const CHANGE_INPUT = 'editor/CHANGE_INPUT';

// action creators
export const initialize = createAction(INITIALIZE);
export const changeInput = createAction(CHANGE_INPUT);

// initial state
const initialState = Map({
  title: '',
  markdown: '',
  tags: ''
});

// reducer
export default handleActions({
  [INITIALIZE]: (state, action) => initialState,
  [CHANGE_INPUT]: (state, action) => {
    const { name, value } = action.payload;
    return state.set(name, value);
  }
}, initialState)

INITIALIZE 와 CHANGE_INPUT 액션을 만들어주었습니다.

EditorPaneContainer 컴포넌트 작성

이제, 이 모듈이 가진 상태와 액션들을 사용하는 컴포넌트인 EditorPaneContainer 를 만들겠습니다.

containers/editor/ 경로에 EditorPaneContainer.js 를 다음과 같이 생성하세요:

`src/containers/editor/EditorPaneContainer.js

import React, { Component } from 'react';
import EditorPane from 'components/editor/EditorPane';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as editorActions from 'store/modules/editor';

class EditorPaneContainer extends Component {

  handleChangeInput = ({name, value}) => {
    const { EditorActions } = this.props;
    EditorActions.changeInput({name, value});
  }

  render() {
    const { title, tags, markdown } = this.props;
    const { handleChangeInput } = this;

    return (
      <EditorPane
        title={title}
        markdown={markdown}
        tags={tags}
        onChangeInput={handleChangeInput}
      />
    );
  }
}

export default connect(
  (state) => ({
    title: state.editor.get('title'),
    markdown: state.editor.get('markdown'),
    tags: state.editor.get('tags')
  }),
  (dispatch) => ({
    EditorActions: bindActionCreators(editorActions, dispatch)
  })
)(EditorPaneContainer);

title, markdown, tags 를 연결해주었고, handleChangeInput 이라는 메소드를 만들어서 CHANE_INPUT 액션을 실행하도록 설정했습니다.

이제 EditorPage 에서 EditorPane 대신 EditorPaneContainer 를 불러와서 렌더링하세요.

src/pages/Editorpage.js

import React from 'react';
import EditorTemplate from 'components/editor/EditorTemplate';
import EditorHeader from 'components/editor/EditorHeader';
import EditorPaneContainer from 'containers/editor/EditorPaneContainer';
import PreviewPane from 'components/editor/PreviewPane';

const EditorPage = () => {
  return (
    <EditorTemplate
      header={<EditorHeader/>}
      editor={<EditorPaneContainer/>}
      preview={<PreviewPane/>}
    />
  );
};

export default EditorPage;

EditorPane 수정

이제 props 로 받은 값들을 각 input 에 설정하고, 변화가 일어나면 props 로 전달받은 onChangeInput 을 호출해주겠습니다.

제목과 태그의 경우엔 인풋에 onChange 이벤트를 설정하여 값을 설정해줄수있지만, CodeMirror 의 경우엔 initializeEditor 함수가 호출 될 때 이벤트를 직접 등록해주어야 합니다.

또한, props 로 받은 markdown 값을 CodeMirror 인스턴스에 반영시켜줘야 하기 때문에, componentDidUpdate 부분에서 markdown 값이 바뀌었으면 setValue 를 통하여 내용을 변경해주고, 이 과정에서 cursor 의 위치가 초기화 될 수 있기 때문에 cursor 를 유지하기 위하여 cursor 값도 setCursor 를 통하여 설정해줍니다.

src/components/editor/EditorPane/EditorPane.js

(...)

class EditorPane extends Component {

  editor = null // 에디터 ref
  codeMirror = null // CodeMirror 인스턴스
  cursor = null // 에디터의 텍스트 cursor 위치

  initializeEditor = () => {
    this.codeMirror = CodeMirror(this.editor, {
      mode: 'markdown',
      theme: 'monokai',
      lineNumbers: true, // 좌측에 라인넘버 띄우기
      lineWrapping: true // 내용이 너무 길면 다음 줄에 작성
    });
    this.codeMirror.on('change', this.handleChangeMarkdown);
  }

  componentDidMount() {
    this.initializeEditor();
  }

  handleChange = (e) => {
    const { onChangeInput } = this.props;
    const { value, name } = e.target;
    onChangeInput({name, value});
  }

  handleChangeMarkdown = (doc) => {
    const { onChangeInput } = this.props;
    this.cursor = doc.getCursor(); // 텍스트 cursor 의 위치를 저장합니다
    onChangeInput({
      name: 'markdown',
      value: doc.getValue()
    });
  }

  componentDidUpdate(prevProps, prevState) {
    // markdown이 변경되면 에디터의 값도 변경해줍니다.
    // 이 과정에서 텍스트 커서의 위치가 초기화 되기 때문에, 
    // 저장해둔 커서의 위치가 있으면 해당 위치로 설정합니다.
    if(prevProps.markdown !== this.props.markdown) {
      const { codeMirror, cursor } = this;
      if(!codeMirror) return; // 인스턴스가 아직 안만들어진 경우
      codeMirror.setValue(this.props.markdown);
      if(!cursor) return; // 커서가 없는 경우
      codeMirror.setCursor(cursor);
    }
  }



  render() {
    const { handleChange } = this;
    const { tags, title } = this.props;

    return (
      <div className={cx('editor-pane')}>
        <input 
          className={cx('title')} 
          placeholder="제목을 입력하세요" 
          name="title"
          value={title}
          onChange={handleChange}
        />
        <div className={cx('code-editor')} ref={ref=>this.editor=ref}></div>
        <div className={cx('tags')}>
          <div className={cx('description')}>태그</div>
          <input 
            name="tags"
            placeholder="태그를 입력하세요 (쉼표로 구분)"
            value={tags}
            onChange={handleChange}
          />
        </div>
      </div>
    );
  }
}

export default EditorPane;

코드를 다 작성하였다면, 에디터 페이지를 열어서 제목, 내용, 태그를 입력해보세요. 그리고 리덕스 개발자 도구를 확인해보세요. 상태가 제대로 바뀌었나요?

에디터에 작성한 값들이 리덕스 스토어에 잘 저장이 되고 있습니다. 이제 이 값들을, 다른 컴포넌트로 전달하여 마크다운을 HTML 으로 변환해주겠습니다.

마크다운 변환하기

이제 에디터로 작성한 마크다운을 HTML 로 변환하여 화면에 띄워줍시다.

마크다운을 렌더링 하는것은, 에디터에서도 이뤄지지만, 포스트를 볼 때도 이뤄집니다. 따라서, MarkdownRender 라는 컴포넌트를 common 경로에 만들어서 사용하겠습니다.

MarkdownRender 컴포넌트 만들기

components/common 경로에 MarkdownRender 컴포넌트를 클래스 형태로 생성하세요.

그리고, 다음과 같이 marked 를 사용하여 마크다운을 html 로 변환하고, 이를 렌더링하는 코드를 작성하세요.

src/components/common/MarkdownRender/MarkdownRender.js

import React, { Component } from 'react';
import styles from './MarkdownRender.scss';
import classNames from 'classnames/bind';

import marked from 'marked';

const cx = classNames.bind(styles);

class MarkdownRender extends Component {
  state = {
    html: ''
  }

  renderMarkdown = () => {
    const { markdown } = this.props;
   // 마크다운이 존재하지 않는다면 공백처리
   if(!markdown) {
      this.setState({ html : '' });
      return;
    }
    this.setState({
      html: marked(markdown, {
        breaks: true, // 일반 엔터로 새 줄 입력
        sanitize: true // 마크다운 내부 html 무시
      })
    });
  }

  constructor(props) {
    super(props);
    const { markdown } = props;
    // 서버사이드 렌더링에서도 마크다운 처리가 되도록 constructor 쪽에서도 구현합니다.
    this.state = {
      html: markdown ? marked(props.markdown, { breaks: true, sanitize: true }) : ''
    }
  }


  componentDidUpdate(prevProps, prevState) {
    // markdown 값이 변경되면, renderMarkdown 을 호출합니다.
    if(prevProps.markdown !== this.props.markdown) {
      this.renderMarkdown();
    }
  }

  render() {
    const { html } = this.state;

    // React 에서 html 을 렌더링 하려면 객체를 만들어서 내부에
    // __html 값을 설정해야합니다.
    const markup = {
      __html: html
    };

    // 그리고, dangerouslySetInnerHTML 값에 해당 객체를 넣어주면 됩니다.
    return (
      <div className={cx('markdown-render')} dangerouslySetInnerHTML={markup}/>
    );
  }
}

export default MarkdownRender;

컴포넌트가 생성될 때 호출되는 constructor 와 componentDidUpdate 에서 마크다운 변환 작업을 처리해주었습니다. constructor 에서 마크다운 변환 작업을 해주는 이유는 constructor 함수는 서버사이드 렌더링을 하게 될 때도 호출되기 때문입니다. 반면, 이 작업을 만약에 componentDidMount 에서 하게된다면, 브라우저 쪽에서만 실행 되고, 나중에 서버쪽에서는 호출되지 않게 되겠죠.

이제 이 컴포넌트를 사용 할 차례입니다. 우리는, PreviewPaneContainer 를 만들어주고, PreviewPane 에선 전달받은 markdown 을 MarkdownRender 를 통하여 렌더링하겠습니다.

PreviewPaneContainer 컴포넌트 만들기

title 과 markdown 값을 스토어에서 받아와서 PreviewPane 에 넣어줍시다.

src/containers/editor/PreviewPaneContainer.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import PreviewPane from 'components/editor/PreviewPane';

class PreviewPaneContainer extends Component {
  render() {
    const { markdown, title } = this.props;
    return (
      <PreviewPane title={title} markdown={markdown}/>
    );
  }
}

export default connect(
  (state) => ({
    title: state.editor.get('title'),
    markdown: state.editor.get('markdown')
  })
)(PreviewPaneContainer);

그 다음엔 EditorPage 에서 PreviewPane 을 PreviewPaneContainer 로 교체하세요.

src/pages/EditorPage.js

import React from 'react';
import EditorTemplate from 'components/editor/EditorTemplate';
import EditorHeader from 'components/editor/EditorHeader';
import EditorPaneContainer from 'containers/editor/EditorPaneContainer';
import PreviewPaneContainer from 'containers/editor/PreviewPaneContainer';

const EditorPage = () => {
  return (
    <EditorTemplate
      header={<EditorHeader/>}
      editor={<EditorPaneContainer/>}
      preview={<PreviewPaneContainer/>}
    />
  );
};

export default EditorPage;

PreviewPane 컴포넌트에서 MarkdownRender 컴포넌트 사용

컨테이너 컴포넌트를 통하여 전달받은 title 값과 markdown 값을 보여주겠습니다. title 부분은 기존 텍스트가 있던 부분을 교체하면 되고, markdown 의 경우엔 MarkdownRender 컴포넌트를 불러와서 props 로 markdown 을 전달해주세요.

`src/components/editor/PreviewPane/PreviewPane.js

import React from 'react';
import styles from './PreviewPane.scss';
import classNames from 'classnames/bind';
import MarkdownRender from 'components/common/MarkdownRender';

const cx = classNames.bind(styles);

const PreviewPane = ({markdown, title}) => (
  <div className={cx('preview-pane')}>
    <h1 className={cx('title')}>
      {title}
    </h1>
    <div>
      <MarkdownRender markdown={markdown}/>
    </div>
  </div>
);

export default PreviewPane;

이제 에디터를 열어서 마크다운을 입력해보세요. 실시간으로 우측에 반영 될 것입니다.

현재 마크다운을 html 으로 변환해주는 작업만 했기 때문에, 작성한 코드에 색상이 입혀지지는 않았습니다. 코드에 색상을 입혀주려면, Prismjs 를 사용해주어야 합니다.

Prismjs 를 사용하여 코드에 색상 입히기

Prismjs 를 사용하여 코드블록들을 아름답게 해줍시다! Prismjs 관련 코드를 불러온 다음에, Prism.highlightAll() 함수를 호출하면 화면상에 있는 코드블록에 스타일이 입혀집니다.

이 함수는, 마크다운이 변환되어 html 이 렌더링 된 다음에 반영되어야 합니다. 따라서, componentDidUpdate 에서 state 값이 바뀔 때 이 코드를 호출해주세요.

src/components/MarkdownRender/MarkdownRender.js

import React, { Component } from 'react';
import styles from './MarkdownRender.scss';
import classNames from 'classnames/bind';

import marked from 'marked';

// prism 관련 코드 불러오기
import Prism from 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
// 지원 할 코드 형식들을 불러옵니다
// http://prismjs.com/#languages-list 참조
import 'prismjs/components/prism-bash.min.js';
import 'prismjs/components/prism-javascript.min.js'
import 'prismjs/components/prism-jsx.min.js';
import 'prismjs/components/prism-css.min.js';

const cx = classNames.bind(styles);

class MarkdownRender extends Component {
  (...)
  componentDidMount() {
    Prism.highlightAll();
  }
  componentDidUpdate(prevProps, prevState) {
    // markdown 값이 변경되면, renderMarkdown 을 호출합니다.
    if(prevProps.markdown !== this.props.markdown) {
      this.renderMarkdown();
    }
    // state 가 바뀌면 코드 하이라이팅
    if(prevState.html !== this.state.html) {
      Prism.highlightAll();
    }
  }

  (...)
}

export default MarkdownRender;

자, 다시 에디터에 코드를 입력해보세요.

만약에 지원하고 싶은 언어가 더 있다면 http://prismjs.com/#languages-list 를 참조하여 더 불러오면 됩니다.

마크다운 스타일링

현재 마크다운은 html 의 기본 스타일로 설정되어 있습니다. 포스트가 조금 더 멋져보이도록, 스타일을 조금 변경해주겠습니다.

src/components/common/MarkdownRender/MarkdownRender.scss

@import 'utils';

.markdown-render {
  blockquote {
    border-left: 4px solid $oc-blue-6;
    padding: 1rem;
    background: $oc-gray-1;
    margin-left: 0;
    margin-right: 0;
    p {
      margin: 0;
    }
  }

  h1, h2, h3, h4 {
    font-weight: 500;
  }

  // 텍스트 사이의 코드
  h1, h2, h3, h4, h5, p {
    code {
      font-family: 'D2 Coding';
      background: $oc-gray-0;
      padding: 0.25rem;
      color: $oc-blue-6;
      border: 1px solid $oc-gray-2;
      border-radius: 2px;
    }
  }

  // 코드 블록
  code[class*="language-"], pre[class*="language-"] {
    font-family: 'D2 Coding';
  }

  a {
    color: $oc-blue-6;
    &:hover {
      color: $oc-blue-5;
      text-decoration: underline;
    }
  }

  // 표 스타일
  table {
    border-collapse: collapse;
    width: 100%;
  }

  table, th, td {
    border: 1px solid $oc-gray-4;
  }

  th, td {
    font-size: 0.9rem;
    padding: 0.25rem;
    text-align: left;
  }

  // 이미지 최대사이즈 설정 및 중앙정렬
  img {
    max-width: 100%;
    margin: 0 auto;
    display: block;
  }
}

스타일을 저장한 후, 마크다운 부분에 다음과 같이 입력해보세요:

결과물이 잘 나타났나요? 에디터 구현 관련 작업은 모두 마쳤습니다!

results matching ""

    No results matching ""