1-5 Editor 페이지 UI 구현

Editor 페이지는, 기존에 만들었던 List 페이지와 Post 페이지와 달리, PageTemplate 을 사용하지 않습니다. 이 페이지를 구현하기에 앞서, 어떻게 생겼는지 미리 살펴볼까요?

에디터 페이지는 상단의 헤더 부분에 로고가 사라지고, 좌측엔 뒤로가기 버튼, 우측엔 작성하기 버튼이 있습니다.

그리고, 그 아래에는, 화면이 두개로 분할되어있으며, 가운데 영역을 드래그하여 리사이즈를 할 수 있습니다.

이 에디터는, 마크다운을 작성 할 수 있는 에디터입니다. 마크다운은 텍스트기반 마크업언어로서, 특수문자들을 이용하여 텍스트의 스타일을 쉽고 직관적으로 설정 할 수 있습니다.

좌측에는 CodeMirror 를 사용하여 마크다운 에디터에 작성된 텍스트에 색상을 입혀주었고, 우측엔 Marked 와 Prismjs 라는 라이브러리를 사용하여 마크다운을 HTML 형태로 변환시켜주고 코드에 색상을 입혀줍니다.

이번 섹션에서는, 에디터 페이지의 레이아웃과 리사이즈 기능만 구현하고, 다음 섹션에서 라이브러리들을 연동하겠습니다.

이 페이지에서 필요한 컴포넌트는 총 4개입니다. 다음 컴포넌트들을 src/components/editor 경로에 Generate new component 를 사용하여 만드세요.

  • EditorHeader: 에디터의 상단에 위치하는 파란색 헤더입니다
  • EditorTemplate: 에디터 페이지를 위한 템플릿입니다. 리사이즈 기능을 여기서 구현하게 됩니다.
  • EditorPane: 글을 작성하는 영역입니다.
  • PreviewPane: 마크다운이 html 로 렌더링되는 영역입니다.

EditorTemplate 과 EditorPane 은 상태관리가 필요한 컴포넌트들이니, 컴포넌트를 생성 할 때 클래스형으로 생성하세요.

EditorTemplate 컴포넌트

레이아웃 구성

이번에 만들 EditorTemplate 컴포넌트는 지금까지 다룬 컴포넌트들과 살짝 다릅니다. 기존에, 컴포넌트의 props 로 JSX 형태를 받아와야 하는 경우엔 children 값을 사용했었습니다.

예를 들어, 다음과 같은 Parent 컴포넌트가 있다면:

import React from 'react';

const Parent = ({children}) => {
  return (
    <div>
      {children}
    </div>
  );
};

export default Parent;

이렇게 사용하면, 컴포넌트 JSX 태그 사이의 내용들이 children 으로 전달되지요:

<Parent>
  <div>Hello World</div>
</Parent>

하지만, 지금의 경우엔 children 처럼 JSX 형태로 전달받아 사용 할 내용이 3종류가 됩니다. 3 종류의 JSX 가, 하나의 블록 형태로 붙어있는 것이 아니라 각자 다른 곳에 렌더링 되야 하기 때문에, children 을 사용하지 않고, header, editor, preview 라는 props 를 받아서 알맞는 곳에 렌더링해줍니다.

src/components/editor/EditorTemplate/EditorTemplate.js

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

const cx = classNames.bind(styles);

class EditorTemplate extends Component {
  render() {
    const { header, editor, preview } = this.props;
    return (
      <div className={cx('editor-template')}>
        { header }
        <div className={cx('panes')}>
          <div className={cx('pane', 'editor')}>
            {editor}
          </div>
          <div className={cx('pane', 'preview')}>
            {preview}
          </div>
        </div>
      </div>
    );
  }
}

export default EditorTemplate;

이제 이 컴포넌트를 위한 스타일링을 해봅시다. 일부 스타일은 이 컴포넌트의 레이아웃이 제대로 작동하는지 확인하기 위한 임시 코드입니다.

src/components/EditorTemplate/EditorTemplate.scss

@import 'utils';

.editor-template {
  .panes {
    height: calc(100vh - 4rem); // 페이지 높이에서 EditorHeader 크기 빼기
    display: flex;
    background: $oc-gray-1; // 임시
    .pane {
      display: flex;
      flex: 1; // 임시
    }
  }
}

이 컴포넌트를 EditorPage 에서 렌더링 해볼까요?

src/pages/EditorPage.js

import React from 'react';
import EditorTemplate from 'components/editor/EditorTemplate';

const EditorPage = () => {
  return (
    <EditorTemplate
      header="헤더"
      editor="에디터"
      preview="프리뷰"
    />
  );
};

export default EditorPage;

지금은, JSX 대신에 텍스트를 props 로 설정했습니다. 이제 http://localhost:3000/editor 에 들어가서 페이지가 어떻게 나타나는지 확인해보세요.

각 영역에 우리가 설정한 값이 잘 나타났나요?

리사이즈 기능 구현하기

컴포넌트 템플릿이 잘 작동하는것을 확인했다면, 리사이즈 기능을 구현하겠습니다. 각 영역의 사이에 separator 를 렌더링 한 후, 이 DOM 을 클릭 하게 될 때 이벤트를 등록하여, 커서의 위치에 따라 state 를 변경하고, 이 state 에 따라 각 영역의 사이즈를 변경하여 리렌더링 하도록 설정해보겠습니다.

src/components/EditorTemplate/EditorTemplate.js

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

const cx = classNames.bind(styles);

class EditorTemplate extends Component {
  state = {
    leftPercentage: 0.5
  }

  // separator 클릭 후 마우스를 움직이면 그에 따라 leftPercentage 업데이트
  handleMouseMove = (e) => {
    this.setState({
      leftPercentage: e.clientX / window.innerWidth
    });
  }

  // 마우스를 땠을 때 등록한 이벤트 제거
  handleMouseUp = (e) => {
    document.body.removeEventListener('mousemove', this.handleMouseMove);
    window.removeEventListener('mouseup', this.handleMouseUp);
  }

  // separator 클릭시
  handleSeparatorMouseDown = (e) => {
    document.body.addEventListener('mousemove', this.handleMouseMove);
    window.addEventListener('mouseup', this.handleMouseUp);
  }

  render() {
    const { header, editor, preview } = this.props;
    const { leftPercentage } = this.state;
    const { handleSeparatorMouseDown } = this;

    // 각 섹션에 flex 값 적용
    const leftStyle = {
      flex: leftPercentage
    };
    const rightStyle = {
      flex: 1 - leftPercentage
    };

    // separator 위치 설정
    const separatorStyle = {
      left: `${leftPercentage * 100}%`
    };

    return (
      <div className={cx('editor-template')}>
        { header }
        <div className={cx('panes')}>
          <div className={cx('pane', 'editor')} style={leftStyle}>
            {editor}
          </div>
          <div className={cx('pane', 'preview')} style={rightStyle}>
            {preview}
          </div>
          <div 
            className={cx('separator')} 
            style={separatorStyle}
            onMouseDown={handleSeparatorMouseDown}/>
        </div>
      </div>
    );
  }
}

export default EditorTemplate;

이 과정에서, mousedown 을 제외한 이벤트는, document.body 와 window 에 적용이 되었습니다. 그 이유는, mouseup과 mousemove 이벤트를 separator 쪽에만 넣어준다면, 마우스 커서가 separator 영역을 벗어났을 때 이벤트가 제대로 실행되지 않기 때문입니다.

위 코드가 제대로 작동하려면, separator 를 위한 스타일도 작성을 해주어야 합니다. 기존에 작성했던 임시코드들도 제거하세요.

src/components/EditorTemplate/EditorTemplate.scss

@import 'utils';

.editor-template {
  .panes {
    height: calc(100vh - 4rem); // 페이지 높이에서 EditorHeader 크기 빼기
    display: flex;
    position: relative; // separator 의 위치설정을 위하여 relative 로 설정
    .pane {
      display: flex;
      min-width: 0; // 내부의 내용이 커도 반대편 영역을 침범하지 않게 해줌
      overflow: auto; // 너무 많이 줄이면 스크롤바가 나타나게 해줌
    }
    .separator {
      width: 1rem; // 클릭 영역을 넓게 설정하기 위함
      height: 100%;
      position: absolute;
      transform: translate(-50%); // 자신의 50% 만큼 왼쪽으로 이동
      cursor: col-resize; // 리사이즈 커서
      background: black; // 임시
    }
  }
}

다시 페이지를 열면, 다음과 같이 검정색 막대가 보여질것입니다. 이를 드래그하여 리사이즈가 잘 되는지 확인해보세요.

작동이 잘 되는것을 확인했다면, 방금 수정한 EditorTemplate 에서 임시 코드라고 주석으로 명시된 background: black 부분을 제거하세요.

EditorHeader 컴포넌트

에디터 페이지의 상단에 위치할 EditorHeader 컴포넌트를 만드세요. 이 컴포넌트엔, 좌측엔 뒤로가기 버튼이 있고 우측엔 작성하기 버튼이 있습니다.

src/components/editor/EditorHeader/EditorHeader.js

import React from 'react';
import styles from './EditorHeader.scss';
import classNames from 'classnames/bind';
import Button from 'components/common/Button';

const cx = classNames.bind(styles);

const EditorHeader = ({onGoBack, onSubmit}) => {
  return (
    <div className={cx('editor-header')}>
      <div className={cx('back')}>
        <Button onClick={onGoBack} theme="outline">뒤로가기</Button>
      </div>
      <div className={cx('submit')}>
        <Button onClick={onSubmit} theme="outline">작성하기</Button>
      </div>
    </div>
  );
};

export default EditorHeader;

우리가 이전에 만든 버튼 컴포넌트를 여기서 재사용 하였습니다. 이 헤더 컴포넌트에서는, onGoBack, 그리고 onSubmit 이라는 props 를 전달받게 됩니다, 각 함수를 양쪽 버튼에 넣어주세요.

그 다음엔, 컴포넌트를 스타일링 하세요.

src/components/editor/EditorHeader/EditorHeader.scss

@import 'utils';

.editor-header {
  background: $oc-blue-6;
  height: 4rem;
  padding-left: 1rem;
  padding-right: 1rem;
  display: flex;
  align-items: center;

  .submit { // 우측 정렬
    margin-left: auto;
  }
}

다 작성하셨다면, EditorPage 파일을 열어 EditorTemplate 의 header props 에서 방금 만든 컴포넌트를 렌더링하세요.

src/pages/EditorPage.js

import React from 'react';
import EditorTemplate from 'components/editor/EditorTemplate';
import EditorHeader from 'components/editor/EditorHeader';

const EditorPage = () => {
  return (
    <EditorTemplate
      header={<EditorHeader/>}
      editor="에디터"
      preview="프리뷰"
    />
  );
};

export default EditorPage;

EditorHeader 컴포넌트가 보여졌나요?

EditorPane 컴포넌트

이번엔 EditorPane 컴포넌트를 만들어봅시다.

이 컴포넌트에는 총 3가지의 인풋이 있습니다 (제목, 내용, 태그). 내용 부분의 경우엔 우리가 나중에 CodeMirror 라이브러리를 연동하여 구현하게 되니, 해당 부분은 div 엘리먼트를 사용하겠습니다.

src/components/editor/EditorPane/EditorPane.js

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

const cx = classNames.bind(styles);

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

export default EditorPane;

이 컴포넌트에서는, 나중에 라이프사이클 메소드와, 커스텀 메소드를 사용해야되기 때문에 클래스 형태로 작성되었습니다.

이제 컴포넌트를 스타일링 해보세요.

src/components/editor/EditorPane/EditorPane.scss

@import 'utils';

.editor-pane {
  flex: 1; // 자신에게 주어진 영역을 다 채우기
  // 세로방향으로 내용 나열
  display: flex;
  flex-direction: column;

  .title {
    background: $oc-gray-7;
    border: none;
    outline: none;
    font-size: 1.5rem;
    padding: 1rem;
    color: white;
    font-weight: 500;
    &::placeholder {
      color: rgba(255,255,255,0.75);
    }
  }

  .code-editor {
    flex: 1; // 남는 영역 다 차지하기
    background: $oc-gray-9;
  }

  .tags {
    padding-left: 1rem;
    padding-right: 1rem;
    height: 2rem;
    background: $oc-gray-7;
    display: flex;
    align-items: center;
    .description {
      font-size: 0.85rem;
      color: white;
      font-weight: 600;
      margin-right: 1rem;
    }

    input {
      font-size: 0.85rem;
      border: none;
      flex: 1;
      background: none;
      outline: none;
      font-weight: 600;
      color: rgba(255,255,255,0.9);
      &::placeholder {
        color: rgba(255,255,255,0.75);
      }
    }
  }
}

자 이제 이 컴포넌트를 EditorPage 에서 렌더링해보세요.

src/pages/EditorPage.js

import React from 'react';
import EditorTemplate from 'components/editor/EditorTemplate';
import EditorHeader from 'components/editor/EditorHeader';
import EditorPane from 'components/editor/EditorPane';

const EditorPage = () => {
  return (
    <EditorTemplate
      header={<EditorHeader/>}
      editor={<EditorPane/>}
      preview="프리뷰"
    />
  );
};

export default EditorPage;

EditorPane 이 잘 나타났나요?

PreviewPane 컴포넌트

에디터 페이지의 마지막 컴포넌트인 PreviewPane 을 만들어봅시다.

src/components/editor/PreviewPane/PreviewPane.js

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

const cx = classNames.bind(styles);

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

export default PreviewPane;

이 컴포넌트는 꽤 간단하지요? 이 컴포넌트는 title 과 markdown 을 props 로 받아와서 보여줍니다. 받아온 값을 렌더링 하는건 나중에 하고, 지금은 텍스트를 바로 렌더링 해주겠습니다.

컴포넌트를 스타일링도 해주세요.

src/components/editor/PreviewPane/PreviewPane.scss

@import 'utils';
.preview-pane {
  flex: 1;
  padding: 2rem;
  overflow-y: auto; // 사이즈 초과시 스크롤바 나타나게 설정
  font-size: 1.125rem;
  .title {
    font-size: 2.5rem;
    font-weight: 300;
    padding-bottom: 2rem;
    border-bottom: 1px solid $oc-gray-4;
  }
}

그 다음에, PreviewPane 도 EditorPage 에서 렌더링하세요.

src/pages/EditorPage.js

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

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

export default EditorPage;

양쪽 컴포넌트가 잘 렌더링 되었나요? 현재 상태에선, 브라우저의 가로길이가 짧을땐 조금 이상하게 나옵니다.

이렇게 화면이 2분할로 나뉘어지면 모바일에선 사용하기가 힘들겠죠? 우측의 미리보기 부분은 모바일에서 숨겨줍시다. EditorTemplate 의 스타일을 다음과 같이 수정하세요

src/components/editor/EditorTemplate/EditorTemplate.scss

@import 'utils';

.editor-template {
  .panes {
    (...)

    @include media("<medium") {
      .editor {
        flex: 1!important;
      }
      .preview, .separator {
        display: none;
      }
    }
  }
}

display: none 속성을 사용해서 preview 와 separator 클래스를 숨겨주었습니다.

드디어 프로젝트에서 필요한 대부분의 유저인터페이스가 완성되었습니다. 프로젝트가 어떻게 보여질지, 준비가 어느정도 끝났으니, 이제 실제 기능들을 넣어봅시다!

results matching ""

    No results matching ""