본문 바로가기

Angular나 React에도 에디터 컴포넌트가 있겠지만 Vue에도 쓸만한 에디터 컴포넌트가 있습니다. Awesome Vue.jsRich Text Editing를 통해 알게된 에디터 컴포넌트입니다. 얼마전까지는 vue-quill-editor가 가장 많은 github Star(현재 5.8k)를 받고 있었지만 언제 부턴가 tiptap에디터가 github Star(현재 7.6k)를 넘어서 현재 1위를 달리고 있습니다. Star가 많다는 것은 그만큼 많은 사람들이 만족하게 사용하고 있다는 뜻이기도 합니다.

tiptap.dev

Prosemirror를 기반으로 한 renderless 에디터라고 하네요. Prosemirror를 이번에 알게 되었지만 Prosemirror github star가 4.5k 정도이니 tiptap이 엄마보다 더 스타인가 봅니다. ^^

기본 정보

이 에디터로 작성되는 내용은 Form의 textarea에 써지는 것이 아니고 div에 HTML로 써지고 미리 정의되어 있는 CSS Style로 형태를 바로바로 보여주는 식인것 같습니다. 렌더리스라는게 이런 방식을 말하는것이 아닌가 생각해 봅니다.
Tiptap에서는 기본적으로 CSS스타일이 정의 되어 있지 않다고 합니다. 본인에 맞게 설정을 해야 합니다. 다만 샘플로 제공하고 있는 css를 가져다 사용할 수는 있을 것 같습니다.

기본 설치하기

먼저 패키지매니저로 tiptap을 설치해야 합니다.

npm을 사용할 경우
npm install tiptap --save

또는

yarn을 사용할 경우
yarn add tiptap

사용방법

Tiptap이 컴포넌트 형태로 제공하기 때문에 컴포넌트를 사용하는 방식으로 불러와 사용하면 됩니다.

<template>
  <editor-content :editor="editor" />
</template>

<script>
import { Editor, EditorContent } from 'tiptap'   

export default {

  components: {
    EditorContent,
  },

  data() {
    return {
      editor: null,
    }
  },

  mounted() {
    this.editor = new Editor({
      content: '<p>This is just a boring paragraph</p>',
    })
  },

  beforeDestroy() {
    this.editor.destroy()
  },

}
</script>

▲ mounted()에서 Editor클래스 인스턴스를 작성 하고 EditorContent컴포넌트에 전달하여 실행되는 구조 입니다. 그래서 에디터의 설정 값이나 내용이 Editor클래스 인스턴스에 작성이 되야 합니다.

확장 설치하기

Prosemirror에디터의 강점이자 tiptap의 강점은 필요한 기능만 불러와 사용할 수 있다는 것입니다. 확장기능을 사용하기 위해서는 확장모듈을 추가로 설치해야 합니다.

npm을 사용할 경우
npm install tiptap-extensions --save

또는

yarn을 사용할 경우
yarn add tiptap-extensions

사용방법

먼저 설치한 tiptap에 추가로 extensions을 적용하면 됩니다.

import { Editor, EditorContent } from 'tiptap' 
import { Heading } from 'tiptap-extensions'

const editor = new Editor({
  extensions: [
    new Heading(),
  ],
})

extensions: [ new Heading() ]처럼 필요한 기능의 인스턴스를 추가해서 불러오면 됩니다.
또한 각 기능의 인스턴스는 자신만의 설정을 추가/변경 할 수 있습니다. 그래서 불러 올 기능의 설정값도 잘 살펴보고 적용해야 오류가 없습니다. Heading()의 경우는 아래와 같이 levels의 옵션이 있네요.

const editor = new Editor({
    new Heading({
        levels: [1, 2, 3],
    }),
})

tiptap-extensions에는 아래와 같이 다양한 기능이 있습니다.

import { Editor, EditorContent } from 'tiptap' 
import { Bold, Italic, Link, HardBreak, Heading } from 'tiptap-extensions'

const editor = new Editor({
  extensions: [
    new Bold(),
    new Italic(),
    new Link(),
    new HardBreak(),
    new Heading()
  ],
})

EditorMenuBar 설치하기

tiptap에디터의 또하나 중요한 요소인 메뉴바에 대한 사용방법입니다. 에디터에서 메뉴바를 빼먹을 수는 없겠지요. EditorMenuBar는 기본 tiptap에 포함되어 있기 때문에 모듈을 불러오기만 하면 됩니다. 메뉴바는 현재 총 3개의 형태를 가지고 있습니다.

  • EditorMenuBar
  • EditorMenuBubble
  • EditorFloatingMenu

EditorMenuBar


▲ 가장 기본적인 형태입니다. 에디터 창 위에 메뉴바가 보이고 실행 할 수 있는 형태 입니다.

EditorMenuBubble


▲ 마우스로 내용을 긁어서 선택했을 때 버블창 형태로 메뉴바가 보이고 실행 할 수 있는 형태 입니다.

EditorFloatingMenu


▲ 단락 단위로 메뉴바가 보이고 실행 할 수 있는 형태 입니다.

사용방법

기본형태인 EditorMenuBar에 대한 예제 코드입니다.

<template>
  <div>
    <editor-menu-bar :editor="editor" v-slot="{ commands, isActive }">
      <button :class="{ 'is-active': isActive.bold() }" @click="commands.bold">
        Bold
      </button>
    </editor-menu-bar>
    <editor-content :editor="editor" />
  </div>
</template>

<script>
import { Editor, EditorContent, EditorMenuBar } from 'tiptap'
import { Bold } from 'tiptap-extensions'

export default {
  components: {
    EditorMenuBar,
    EditorContent,
  },
  data() {
    return {
      editor: new Editor({
        extensions: [
          new Bold(),
        ],
      }),
    }
  },
  beforeDestroy() {
    this.editor.destroy()
  },
}
</script>

▲ 메뉴바 컴포넌트는 <editor-menu-bar></editor-menu-bar> 컴포넌트에 적용할 수 있습니다.

위의 예제는 메뉴바에 간단히 Bold()버튼만 나오고 기능이 동작되도록 설정한 예제입니다.

    <editor-menu-bar :editor="editor" v-slot="{ commands, isActive }">
      <button :class="{ 'is-active': isActive.bold() }" @click="commands.bold">
        Bold
      </button>
    </editor-menu-bar>
  • :editor="editor" : editor-menu-bar의 에디터로 editor를 사용한다는 뜻입니다.
  • v-slot="{ commands, isActive }" : 메뉴바를 눌렀을 때 메뉴바로 부터 commands, isActive의 값을 받아서 사용한다는 뜻입니다.

제가 사용한 코드 구조

참고가 될런지 모르겠지만 저는 Tiptap에디터를 따로 별도의 컴포넌트로 만들어서 필요할 때마다 컴포넌트를 호출해서 사용하고 있습니다.

EditorTiptap.vue

<template>
    <div class="editor">
        <editor-menu-bar v-if="swMenubar" :editor="editor" v-slot="{ commands, isActive, getMarkAttrs }">
            <div class="menubar">
                <button class="menubar__button" @click.prevent="showLinkMenu(getMarkAttrs('link'))" :class="{ 'is-active': isActive.link() }">
                    <font-awesome-icon :icon="['fas', 'link']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.bold() }" @click.prevent="commands.bold">
                    <font-awesome-icon :icon="['fas', 'bold']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.italic() }" @click.prevent="commands.italic">
                    <font-awesome-icon :icon="['fas', 'italic']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.strike() }" @click.prevent="commands.strike">
                    <font-awesome-icon :icon="['fas', 'strikethrough']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.underline() }" @click.prevent="commands.underline">
                    <font-awesome-icon :icon="['fas', 'underline']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.code() }" @click.prevent="commands.code">
                    <font-awesome-icon :icon="['fas', 'code']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.code_block() }" @click.prevent="commands.code_block">
                    <font-awesome-icon :icon="['fas', 'file-code']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.paragraph() }" @click.prevent="commands.paragraph">
                    <font-awesome-icon :icon="['fas', 'paragraph']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.heading({ level: 1 }) }" @click.prevent="commands.heading({ level: 1 })">
                    H1
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.heading({ level: 2 }) }" @click.prevent="commands.heading({ level: 2 })">
                    H2
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.heading({ level: 3 }) }" @click.prevent="commands.heading({ level: 3 })">
                    H3
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.bullet_list() }" @click.prevent="commands.bullet_list">
                    <font-awesome-icon :icon="['fas', 'list-ul']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.ordered_list() }" @click.prevent="commands.ordered_list">
                    <font-awesome-icon :icon="['fas', 'list-ol']" />
                </button>

                <button class="menubar__button" :class="{ 'is-active': isActive.blockquote() }" @click.prevent="commands.blockquote">
                    <font-awesome-icon :icon="['fas', 'quote-left']" />
                </button>

                <button class="menubar__button" @click.prevent="commands.horizontal_rule">
                    <font-awesome-icon :icon="['fas', 'window-minimize']" />
                </button>

                <button class="menubar__button" @click.prevent="commands.undo">
                    <font-awesome-icon :icon="['fas', 'undo']" />
                </button>

                <button class="menubar__button" @click.prevent="commands.redo">
                    <font-awesome-icon :icon="['fas', 'redo']" />
                </button>
            </div>
        </editor-menu-bar>

        <editor-content class="editor__content" :editor="editor" />
    </div>
</template>

<script>
import { Editor, EditorContent, EditorMenuBar } from "tiptap";
import { Blockquote, CodeBlock, HardBreak, Heading, HorizontalRule, OrderedList, BulletList, ListItem, TodoItem, TodoList, Bold, Code, Italic, Link, Strike, Underline, History } from "tiptap-extensions";
export default {
    components: {
        EditorContent,
        EditorMenuBar,
    },
    props: ["description", "menubar", "readOnly"],
    data() {
        return {
            swMenubar: this.menubar,
            linkUrl: null,
            linkMenuIsActive: false,
            editor: null,
        };
    },

    mounted() {
        this.editor = new Editor({
            editable: !this.readOnly,
            extensions: [new Blockquote(), new BulletList(), new CodeBlock(), new HardBreak(), new Heading({ levels: [1, 2, 3] }), new Link({ rel: "", target: "_blank" }), new HorizontalRule(), new ListItem(), new OrderedList(), new TodoItem(), new TodoList(), new Bold(), new Code(), new Italic(), new Strike(), new Underline(), new History()],
            content: this.description,
            onUpdate: ({ getHTML }) => {
                this.$emit("editorContent", getHTML());
            },
        });
    },

    beforeDestroy() {
        this.editor.destroy();
    },

    methods: {
        showLinkMenu(attrs) {
            this.linkUrl = attrs.href;
            this.linkMenuIsActive = true;
            this.$nextTick(() => {
                this.$refs.linkInput.focus();
            });
        },

        hideLinkMenu() {
            this.linkUrl = null;
            this.linkMenuIsActive = false;
        },

        setLinkUrl(command, url) {
            command({ href: url, target: "_blank" });
            this.hideLinkMenu();
        },
    },
};
</script>

<style lang="scss">
.editor {
    position: relative;
    // max-width: 30rem;
    margin: 0 auto 5rem auto;
    .menubar {
        text-align: center;
        border-bottom: 1px solid #ddd;
        padding: 0.2rem 0;
        transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
        &.is-hidden {
            visibility: hidden;
            opacity: 0;
        }

        &.is-focused {
            visibility: visible;
            opacity: 1;
            transition: visibility 0.2s, opacity 0.2s;
        }

        &__button {
            font-weight: bold;
            display: inline-flex;
            background: transparent;
            border: 0;
            color: $black;
            padding: 0.2rem 0.5rem;
            margin-right: 0.2rem;
            border-radius: 3px;
            cursor: pointer;

            &:hover {
                background-color: rgba($black, 0.05);
            }

            &.is-active {
                background-color: rgba($black, 0.1);
            }
        }

        span#{&}__button {
            font-size: 13.3333px;
        }
    }

    .editor__content {
        overflow-wrap: break-word;
        word-wrap: break-word;
        word-break: break-word;
        .ProseMirror {
            min-height: 10rem;
            padding: 0.5rem;
            &:focus {
                outline: none;
            }
            pre {
                padding: 0.7rem 1rem;
                border-radius: 5px;
                background: $black;
                color: $white;
                font-size: 0.8rem;
                overflow-x: auto;

                code {
                    display: block;
                }
            }

            p code {
                padding: 0.2rem 0.4rem;
                border-radius: 5px;
                font-size: 0.8rem;
                font-weight: bold;
                background: rgba($black, 0.1);
                color: rgba($black, 0.8);
            }

            ul,
            ol {
                padding-left: 1rem;
            }

            li > p,
            li > ol,
            li > ul {
                margin: 0;
            }

            a:not(.btn) {
                color: $theme-color-main;
                text-decoration: underline;
            }
            blockquote {
                border-left: 3px solid rgba($black, 0.1);
                color: rgba($black, 0.8);
                padding-left: 0.8rem;
                font-style: italic;

                p {
                    margin: 0;
                }
            }

            img {
                max-width: 100%;
                border-radius: 3px;
            }

            table {
                border-collapse: collapse;
                table-layout: fixed;
                width: 100%;
                margin: 0;
                overflow: hidden;

                td,
                th {
                    min-width: 1em;
                    border: 2px solid $gray-400;
                    padding: 3px 5px;
                    vertical-align: top;
                    box-sizing: border-box;
                    position: relative;
                    > * {
                        margin-bottom: 0;
                    }
                }

                th {
                    font-weight: bold;
                    text-align: left;
                }

                .selectedCell:after {
                    z-index: 2;
                    position: absolute;
                    content: "";
                    left: 0;
                    right: 0;
                    top: 0;
                    bottom: 0;
                    background: rgba(200, 200, 255, 0.4);
                    pointer-events: none;
                }

                .column-resize-handle {
                    position: absolute;
                    right: -2px;
                    top: 0;
                    bottom: 0;
                    width: 4px;
                    z-index: 20;
                    background-color: #adf;
                    pointer-events: none;
                }
            }

            .tableWrapper {
                margin: 1em 0;
                overflow-x: auto;
            }

            .resize-cursor {
                cursor: ew-resize;
                cursor: col-resize;
            }
        }
    }
}
</style>

관련 정보

UX 공작소

고급지게 만들어 저렴하게 배포는 공작소