5. 글쓰기 페이지 구현하기

이번에는 글쓰기 페이지를 구현 할 차례입니다. 에디터는 Quill 을 사용하겠습니다. 리액트에서 Quill 을 사용 할 때는 react-quill 이라고 해서 리액트 컴포넌트형태로 추상화된 라이브러리를 사용하여 구현 할 수도 있습니다. 하지만, 이렇게 외부 라이브러리를 사용하게 되는 상황에서는 아무리 리액트 버전이 따로 있다고 해도 리액트 버전을 사용하지 않고 자바스크립트로만 작성된 원본 라이브러리를 사용하시는 것을 추천드립니다.

만약에 리액트 버전으로 만들어진 라이브러리가 기존 라이브러리 팀이 공식적으로 만들어서 배포한거라면 사용하는 것에 큰 문제는 없지만, 만약 해당 리액트 버전으로 만들어진 라이브러리가 비공식적으로 만들어진 써드 파티 라이브러리라면 추후 커스터마이징이 어려워질수도있고, 운이 나쁠때에는 해결 할 수 없는 문제사항을 만나게 될 때도 있습니다.

우선, quill 라이브러리를 설치해주세요.

$ yarn add quill

UI 만들기

Editor 컴포넌트 만들기

먼저 Editor 컴포넌트부터 만들어주겠습니다. 이번에 만들 컴포넌트들은 write 라는 이름으로 분류하여 저장합니다.

components/write/Editor.js

import React, { Component } from 'react';
import 'quill/dist/quill.bubble.css';
import './Editor.scss';

import Quill from 'quill';

class Editor extends Component {
  editor = React.createRef();
  quill = null;
  state = {
    title: ''
  };

  initialize = () => {
    const quill = new Quill(this.editor.current, {
      theme: 'bubble',
      placeholder: '내용을 작성하세요.',
      modules: {
        toolbar: [
          ['bold', 'italic'],
          [{ header: '1' }, { header: '2' }],
          ['blockquote', 'code-block', 'link', 'image']
        ]
      }
    });
    quill.on('text-change', (delta, oldDelta, source) => {
      const selection = quill.getSelection();
      if (!selection) return;
      if (selection.index === quill.getLength() - 1) {
        window.scrollTo(0, document.body.scrollHeight);
      }
    });
    this.quill = quill;
  };

  handleChangeTitle = e => {
    this.setState({
      title: e.target.value
    });
  };

  componentDidMount() {
    this.initialize();
  }

  render() {
    const { title } = this.state;
    return (
      <div className="Editor">
        <input
          placeholder="제목을 입력하세요."
          className="title"
          value={title}
          onChange={this.handleChangeTitle}
          autoFocus
          onKeyPress={this.handleKeyPress}
        />
        <div
          className="quill-editor"
          ref={this.editor}
          onClick={this.focusEditor}
        />
      </div>
    );
  }
}

export default Editor;

components/write/Editor.scss

.Editor {
  .title {
    width: 100%;
    font-size: 2.5rem;
    font-weight: 800;
    border: none;
    outline: none;
    color: $oc-gray-9;
    &::placeholder {
      font-weight: 500;
      color: $oc-gray-6;
    }
  }
  .quill-editor {
    padding-bottom: 3rem;
    cursor: text;
    min-height: 300px;
    p {
      line-height: 1.5;
    }
    color: $oc-gray-9;
    margin-top: 2rem;
    font-size: 1.125rem;
    .ql-editor {
      padding: 0;
    }
    .ql-blank::before {
      left: 0;
    }
  }
}

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

pages/WritePage.js

import React from 'react';
import Responsive from '../components/base/Responsive';
import Editor from '../components/write/Editor';

/**
 * 글쓰기 페이지
 */
const WritePage = () => {
  return (
    <Responsive>
      <Editor />
    </Responsive>
  );
};

export default WritePage;

EditorHead 컴포넌트 만들기

이번에는 페이지 상단에 두개의 버튼을 렌더링하는 EditorHead 라는 컴포넌트를 만들어보겠습니다.

components/write/EditorHead.js

import React, { Component } from 'react';
import './EditorHead.scss';
import Responsive from '../base/Responsive';
class EditorHead extends Component {
  state = {
    scrolling: false
  };

  // 스크롤바가 맨 위에 있는게 아니라면, 그림자를 보여준다.
  handleScroll = () => {
    const scrollTop =
      document.body.scrollTop || document.documentElement.scrollTop;
    const scrolling = scrollTop > 0;
    if (this.state.scrolling === scrolling) return; 
    this.setState({
      scrolling
    });
  };
  componentDidMount() {
    window.addEventListener('scroll', this.handleScroll);
  }

  componentWillUnmount() {
    window.removeEventListener('scroll', this.handleScroll);
  }

  render() {
    const { onSubmit, onCancel } = this.props;
    return (
      <>
        <div
          className={[
            'EditorHead',
            this.state.scrolling ? 'scrolling' : ''
          ].join(' ')}
        >
          <Responsive className="wrapper">
            <button className="cancel" onClick={onCancel}>
              취소
            </button>
            <button className="submit" onClick={onSubmit}>
              작성하기
            </button>
          </Responsive>
        </div>
        <div className="padding" />
      </>
    );
  }
}

export default EditorHead;

components/EditorHead.scss

.EditorHead {
  background: rgba(255, 255, 255, 0.875);
  transition: 0.3s all ease-in;
  &.scrolling {
    box-shadow: 0 4px 16px 0px rgba(181, 181, 181, 0.16);
  }
  position: fixed;
  left: 0;
  width: 100%;
  z-index: 5;
  min-height: 0;
  .wrapper {
    padding-top: 1rem;
    padding-bottom: 1rem;
    display: flex;
    justify-content: flex-end;
  }
  button {
    display: flex;
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
    width: 5rem;
    line-height: 1;
    display: flex;
    justify-content: center;
    font-weight: 600;
    cursor: pointer;
    &.cancel {
      border: 1px solid $oc-gray-7;
      color: $oc-gray-7;
      &:hover {
        color: $oc-red-6;
        border: 1px solid $oc-red-6;
      }
    }
    &.submit {
      background: $oc-gray-9;
      color: white;
      &:hover {
        background: $oc-gray-7;
      }
    }
  }
  button + button {
    margin-left: 0.5rem;
  }
  & + .padding {
    height: 4.125rem;
  }
}

다 만드셨으면 이 컴포넌트를 Editor 내부에 렌더링해보세요.

components/write/Editor.js

import React, { Component } from 'react';
import 'quill/dist/quill.bubble.css';
import './Editor.scss';

import Quill from 'quill';
import EditorHead from './EditorHead';

class Editor extends Component {
  editor = React.createRef();
  quill = null;
  state = {
    title: ''
  };

  initialize = () => {
    const quill = new Quill(this.editor.current, {
      theme: 'bubble',
      placeholder: '내용을 작성하세요.',
      modules: {
        toolbar: [
          ['bold', 'italic'],
          [{ header: '1' }, { header: '2' }],
          ['blockquote', 'code-block', 'link', 'image']
        ]
      }
    });
    quill.on('text-change', (delta, oldDelta, source) => {
      const selection = quill.getSelection();
      if (!selection) return;
      if (selection.index === quill.getLength() - 1) {
        window.scrollTo(0, document.body.scrollHeight);
      }
    });
    this.quill = quill;
  };

  handleChangeTitle = e => {
    this.setState({
      title: e.target.value
    });
  };

  componentDidMount() {
    this.initialize();
  }

  focusEditor = () => {
    this.quill.focus();
  };

  render() {
    const { title } = this.state;
    return (
      <div className="Editor">
        <EditorHead />
        <input
          placeholder="제목을 입력하세요."
          className="title"
          value={title}
          onChange={this.handleChangeTitle}
          autoFocus
          onKeyPress={this.handleKeyPress}
        />
        <div
          className="quill-editor"
          ref={this.editor}
          onClick={this.focusEditor}
        />
      </div>
    );
  }
}

export default Editor;

내용이 길어져서 스크롤바가 생겼을 때 상단에 위 이미지와 같이 그림자가 나타나는지 확인하세요.

API 연동하기

우선 API 함수를 만들어줍시다.

lib/api/posts.js

import client from './client';

export const listPosts = (page = 1) => client.get(`/api/posts?page=${page}`);
export const read = id => client.get(`/api/posts/${id}`);
export const write = ({ title, body }) =>
  client.post('/api/posts', { title, body });

그리고, write 리덕스 모듈을 만들고 루트 리듀서에 추가시키세요.

modules/write.js

import { handleActions } from 'redux-actions';
import createPromiseThunk from '../lib/createPromiseThunk';
import * as postsAPI from '../lib/api/posts';

const SUBMIT_POST = 'write/SUBMIT_POST';
const SUBMIT_POST_SUCCESS = 'write/SUBMIT_POST_SUCCESS';

export const submitPost = createPromiseThunk(SUBMIT_POST, postsAPI.write);

const initialState = {
  post: null
};

export default handleActions(
  {
    [SUBMIT_POST_SUCCESS]: (state, { payload }) => {
      return {
        ...state,
        post: payload.data
      };
    }
  },
  initialState
);

modules/index.js

import { combineReducers } from 'redux';
import auth from './auth';
import user from './user';
import posts from './posts';
import write from './write';

const rootReducer = combineReducers({
  auth,
  user,
  posts,
  write
});

export default rootReducer;

그 다음에는 EditorContainer 를 만들어보세요.

EditorContainer.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import Editor from '../../components/write/Editor';
import { submitPost } from '../../modules/write';
import { withRouter } from 'react-router-dom';

class EditorContainer extends Component {
  handleCancel = () => {
    this.props.history.goBack();
  };
  handleSubmit = async ({ title, body }) => {
    try {
      await this.props.submitPost({
        title,
        body
      });

      const { post } = this.props;
      this.props.history.push(`/posts/${post.id}`);
    } catch (e) {
      console.log(e);
    }
  };
  render() {
    return <Editor onSubmit={this.handleSubmit} onCancel={this.handleCancel} />;
  }
}

export default withRouter(
  connect(
    state => ({
      post: state.write.post
    }),
    {
      submitPost
    }
  )(EditorContainer)
);

이제 Editor 에서 onSubmit 과 onCancel 을 사용하겠습니다.

components/write/Editor.js

import React, { Component } from 'react';
import 'quill/dist/quill.bubble.css';
import './Editor.scss';

import Quill from 'quill';
import EditorHead from './EditorHead';

class Editor extends Component {
  editor = React.createRef();
  quill = null;
  state = {
    title: ''
  };

  initialize = () => {
    const quill = new Quill(this.editor.current, {
      theme: 'bubble',
      placeholder: '내용을 작성하세요.',
      modules: {
        toolbar: [
          ['bold', 'italic'],
          [{ header: '1' }, { header: '2' }],
          ['blockquote', 'code-block', 'link', 'image']
        ]
      }
    });
    quill.on('text-change', (delta, oldDelta, source) => {
      const selection = quill.getSelection();
      if (!selection) return;
      if (selection.index === quill.getLength() - 1) {
        window.scrollTo(0, document.body.scrollHeight);
      }
    });
    this.quill = quill;
  };

  handleChangeTitle = e => {
    this.setState({
      title: e.target.value
    });
  };

  componentDidMount() {
    this.initialize();
  }

  focusEditor = () => {
    this.quill.focus();
  };

  handleSubmit = () => {
    this.props.onSubmit({
      title: this.state.title,
      body: this.quill.root.innerHTML // 에디터 안의 내용을 html 형태로 출력
    });
  };

  render() {
    const { title } = this.state;
    return (
      <div className="Editor">
        <EditorHead
          onSubmit={this.handleSubmit}
          onCancel={this.props.onCancel}
        />
        <input
          placeholder="제목을 입력하세요."
          className="title"
          value={title}
          onChange={this.handleChangeTitle}
          autoFocus
          onKeyPress={this.handleKeyPress}
        />
        <div
          className="quill-editor"
          ref={this.editor}
          onClick={this.focusEditor}
        />
      </div>
    );
  }
}

export default Editor;

마지막으로, WritePage 에서 Editor 를 EditorContainer 로 대체시키세요.

pages/WritePage.js

import React from 'react';
import Responsive from '../components/base/Responsive';
import EditorContainer from '../containers/write/EditorContainer';

/**
 * 글쓰기 페이지
 */
const WritePage = () => {
  return (
    <Responsive>
      <EditorContainer />
    </Responsive>
  );
};

export default WritePage;

글쓰기 페이지에서 새 글을 작성해보세요. /posts/:id 형태의 주소로 이동이 되고 있나요?

그리고, 취소를 했을 때 뒤로가기가 제대로 되는지도 확인해보세요.

results matching ""

    No results matching ""