코딩 기록소
article thumbnail
반응형

소스 코드 보기

https://github.com/seungyong/blog/blob/main/tiptap/README.md

 

Tiptap이란?

공식 문서 사이트에서는 Tiptap을 다음과 같이 소개하고 있습니다.


Tiptap is an open source headless content editor and real-time collaboration framework to craft exactly the content experience you’d like to have


Tiptab은 사용자가 원하는대로 정확하게 만들 수 있는 오픈 소스 헤드리스 텍스트 편집기이자 실시간 협업 프레임워크입니다.

Headless란?
사용자 인터페이스를 갖고 있지 않는 것을 의미합니다. 즉, 사용자 인터페이스를 가지지 않고, 텍스트 편집에 대한 초점을 맞춘 프레임워크이다라고 보시면 될 것 같습니다.

 

기본 구성

tiptap 설치

Next js 14 (App Router), Typescript, Sass를 사용하였습니다.

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit
// tiptap link 기능 사용을 위한 extensions
npm i @tiptap/extension-link
// tiptap markdown 기능 사용을 위한 extensions
npm i tiptap-markdown

밑에 두 개는 간단한 커스텀 마이징을 적용할 간단한 extendsions입니다.

확장명 설명
@tiptap/extension-link 텍스트 편집기에서 하이퍼 링크를 걸 수 있게 하는 기능
tiptap-markdown 텍스트 편집기에서 Markdown 문법을 사용할 수 있게 하는 기능 (Codeblcok도 가능)

 

tiptap 띄워보기

/app/page.tsx

"use client"

import { useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Markdown } from "tiptap-markdown";

import styles from "./page.module.scss";

export default function Home() {
  const [text, setText] = useState("Hello World!");
  const editor = useEditor({
    extensions: [
      StarterKit,
      Link.extend({ inclusive: false }).configure({
        openOnClick: false,
      }),
      Markdown,
    ],
    content: text,
    onUpdate({ editor }) {
      setText(editor.getHTML());
    }
  });

  return (
    <main className={styles.main}>
      <EditorContent editor={editor} />
    </main>
  );
}

하나의 큰 Textarea (진짜 Textarea 태그는 아닙니다)가 생성되었습니다!

tiptap 설명처럼 Headless이기 때문에 사용자 인터페이스를 제공하지 않기 때문에 아무런 style이 적용되어있지 않은 상태입니다.

가장 중요한 점은 "use client"를 사용하여 client component에서 구동했다는 점입니다. tiptap은 서버 컴포넌트에서 작동하지 않기 때문에 client component를 지정해줘야 합니다.

 

tiptap extensions

tiptap 강력한 기능 중 하나입니다. 사용자가 extension를 만들수도 있으며, 또는 이미 만들어진 extensions를 사용해 텍스트 편집기의 기능을 추가할 수 있습니다.

tiptap extensions 문서 같은 경우에는 정말 사용방법이 잘 나와있기 때문에 보면서 따라해도 큰 지장이 없습니다.

하단 2개의 링크를 통해서 tiptap에서 제공하는 기능과 사용 예시를 볼 수 있습니다.

https://tiptap.dev/docs/editor/api/nodes

 

Nodes – Tiptap

 

tiptap.dev

https://tiptap.dev/docs/editor/api/marks

 

Marks – Tiptap

 

tiptap.dev

 

tiptap Toolbar

이제는 tiptap을 꾸미면서 사용할 toolbar를 만들어보겠습니다.

Toolbar에서 사용되는 아이콘들은 다음 주소에서 무료로 사용하실 수 있습니다.

https://fonts.google.com/icons

 

Material Symbols and Icons - Google Fonts

Material Symbols are our newest icons consolidating over 2,500 glyphs in a single font file with a wide range of design variants.

fonts.google.com

 

/app/components/toolbar.tsx

"use client";
import React from 'react'
import styles from './toolbar.module.scss';
import { Editor } from '@tiptap/react';

type ToolbarProps = {
  editor: Editor;
}

const Toolbar = ({ editor }: ToolbarProps) => {
  return (
    <div className={styles.toolbar}>
      <div className={styles.itemBox}>
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.h1} ${
            editor.isActive("heading", { level: 2 }) ? styles.active : styles.none
          }`}
          onClick={() =>
            editor.chain().focus().toggleHeading({ level: 2 }).run()
          }
          disabled={
            !editor.can().chain().focus().toggleHeading({ level: 2 }).run()
          }
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.h2} ${
            editor.isActive("heading", { level: 3 }) ? styles.active : styles.none
          }`}
          onClick={() =>
            editor.chain().focus().toggleHeading({ level: 3 }).run()
          }
          disabled={
            !editor.can().chain().focus().toggleHeading({ level: 3 }).run()
          }
        />
      </div>
      <div className={styles.line} />
      <div className={styles.itemBox}>
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.bold} ${
            editor.isActive("bold") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleBold().run()}
          disabled={!editor.can().chain().focus().toggleBold().run()}
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.italic} ${
            editor.isActive("italic") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleItalic().run()}
          disabled={!editor.can().chain().focus().toggleItalic().run()}
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.strike} ${
            editor.isActive("strike") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleStrike().run()}
          disabled={!editor.can().chain().focus().toggleStrike().run()}
        />
      </div>
      <div className={`${styles.line} `} />
      <div className={styles.itemBox}>
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.bulleted} ${
            editor.isActive("bulletList") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleBulletList().run()}
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.numbered} ${
            editor.isActive("orderedList") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
        />
      </div>
      <div className={styles.line} />
      <div className={styles.itemBox}>
        <button
            type="button"
            className={`${styles.toolbarBtn} ${styles.link} ${
              editor.isActive("link") ? styles.active : styles.none
            }`}
            // Link는 아직 구현되지 않은 상태가 맞습니다.
            // Customizing 파트에서 구현해보겠습니다.
            onClick={() => {}}
          />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.newline} ${styles.none}`}
          onClick={() => editor.chain().focus().setHorizontalRule().run()}
        />
      </div>
    </div>
  )
}

export default Toolbar

 

/app/components/toolbar.module.scss

toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  padding: 5px 10px;
  border-bottom: 1px solid #cacad6;

  .itemBox {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    height: 100%;

    .toolbarBtn {
      width: 30px;
      height: 30px;
      background-size: 26px;
      background-repeat: no-repeat;
      background-position: center;

      &:not(:last-child) {
        margin-right: 10px;
      }
    }

    .none {
      background-color: #ffffff;
    }

    .active {
      background-color: #e7e7e7;
    }
  }

  .line {
    width: 1px;
    height: 25px;
    margin: 0 15px;
    background-color: #cacad6;
  }
}

.h1 {
  background-image: url("/editor_h1.svg");
}

.h2 {
  background-image: url("/editor_h2.svg");
}

.h3 {
  background-image: url("/editor_h3.svg");
}

.bold {
  background-image: url("/editor_bold.svg");
}

.italic {
  background-image: url("/editor_italic.svg");
}

.underline {
  background-image: url("/editor_underline.svg");
}

.strike {
  background-image: url("/editor_strike.svg");
}

.bulleted {
  background-size: 24px !important;
  background-image: url("/editor_list_bulleted.svg");
}

.numbered {
  background-size: 24px !important;
  background-image: url("/editor_list_numbered.svg");
}

.link {
  background-image: url("/editor_link.svg");
}

.image {
  background-image: url("/editor_image.svg");
}

.newline {
  background-image: url("/editor_line.svg");
}

 

/app/page.tsx

Toolbar를 적용하겠습니다.

"use client"

import { useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Markdown } from "tiptap-markdown";

import styles from "./page.module.scss";
import Toolbar from "./components/toolbar";

export default function Home() {
  const [text, setText] = useState("Hello World!");
  const editor = useEditor({
    extensions: [
      StarterKit,
      Link.extend({ inclusive: false }).configure({
        openOnClick: false,
      }),
      Markdown,
    ],
    content: text,
    onUpdate({ editor }) {
      setText(editor.getHTML());
    }
  });

  return (
    <main className={styles.main}>
      <div className={styles.editor}>
        { editor && <Toolbar editor={editor} /> }
        <EditorContent editor={editor} />
      </div>
    </main>
  );
}

 

/app/page.moudle.scss

.main {
  padding: 55px 45px;

  .editor {
    padding: 20px;
    border: 1px solid #cacad6;
  }
}

 

/app/globals.scss

해당 파일은 CSS 초기화 및 Tiptap 안에 있는 Class를 사용해 style을 적용했습니다.

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
}

/* 여백 초기화 */
body,
div,
ul,
li,
dl,
dd,
dt,
ol,
h1,
h2,
h3,
h4,
h5,
h6,
input,
fieldset,
legend,
p,
select,
table,
th,
td,
tr,
textarea,
button,
form,
figure,
figcaption {
  margin: 0;
  padding: 0;
}

/* a 링크 초기화 */
a {
  text-decoration: none;
  color: inherit;
}
a:hover {
  text-decoration: none;
}

/* 폰트 스타일 초기화 */
address {
  font-style: normal;
}

/* 블릿기호 초기화 */
ul,
li,
ol {
  list-style: none;
}

/* 테이블 초기화 */
table {
  border-collapse: collapse;
  border-spacing: 0;

  th {
    font-weight: inherit;
  }
}

/* 버튼초기화 */
button {
  border: 0;
}

/* Tiptap Style  */
.ProseMirror-focused {
  border: none !important;
  outline: none !important;
}

.ProseMirror {
  min-height: 300px;
  padding: 15px 0px;
  padding-bottom: 1.25rem;
}

.tiptap .is-editor-empty:first-child::before {
  color: #adb5bd;
  content: attr(data-placeholder);
  float: left;
  height: 0;
  pointer-events: none;
  font-size: 0.875rem;
}

.tiptap {
  p {
    padding: 0.375rem 0;
  }

  pre {
    background: #0d0d0d;
    border-radius: 0.5rem;
    color: #fff;
    font-family: inherit;
    padding: 0.75rem 1rem;
    line-height: 1.6rem;

    code {
      background: none;
      color: inherit;
      font-size: 1rem;
      padding: 0;
    }
  }

  ul,
  ol {
    padding: 0 2rem;

    p {
      padding: 0;
    }
  }

  ul > li {
    list-style: disc;

    li {
      list-style: circle;
    }
  }

  ol > li {
    list-style: decimal;
  }

  blockquote {
    border-left: 3px solid var(--color-border);
    margin-left: 0;
    margin-right: 0;
    padding-left: 0.625rem;
  }

  a {
    text-decoration: underline;
    color: #477bff;
  }

  iframe {
    padding: 0.625rem 0;
  }

  .tableWrapper {
    margin: 1.25rem 0;
  }

  table {
    overflow: hidden;
    border-collapse: collapse;
    table-layout: fixed;
    width: 100%;
    border: 1px solid var(--color-border);
    user-select: contain;

    tr {
      &:nth-child(2n) {
        background-color: var(--color-background);
      }

      &:nth-child(2n + 1) {
        background-color: var(--color-deep-background);
      }

      td {
        padding: 0.9375rem 0.375rem;
        border-right: 1px solid var(--color-border);
        border-bottom: 1px solid var(--color-border);
      }
    }
  }
}

.resize-cursor {
  td:has(.column-resize-handle) {
    border-right: 2px solid #477bff !important;
    cursor: col-resize;
  }
}

 

결과 보기

CodeBlock을 사용하고싶으면 ```언어명 + Enter를 입력하시면 됩니다. ex) ```typescript + Enter 키

하지만 CodeBlock에서 이상함을 느낀 사람들이 있을겁니다. 네. Code Highlighting이 적용되어 있지 않아, 코드의 가독성이 떨어진다는 단점을 가지고 있습니다.

 

Customizing

tiptap에서는 필요한 기능은 사용자가 만들어서 사용하라는 느낌이 강합니다. 그래서 가장 보편적인 3개를 만들어서 가져와봤습니다. 첫 번째는 텍스트 편집기 들여쓰기, 내어쓰기CodeBlock에 들여쓰기, 내어쓰기 그리고 드래그해서 Link 넣기입니다.

해당 부분에서는 모든 tiptap 함수들은 https://tiptap.dev/docs/editor/api에서 확인 가능합니다.

Code Highlight와 Tab, Shift-Tap 구현

tiptap extensions와 highlight 패키지 설치

전체적인 tiptap/extension-code-block-lowlight 사용 방법은 정말 자세히 잘 나와있습니다.

https://tiptap.dev/docs/editor/api/nodes/code-block-lowlight#lowlight

npm install @tiptap/extension-code-block-lowlight lowlight@2.4.0 highlight.js
확장명  
@tiptap/extension-code-block-lowlight code-block과 lowlight를 연결하여 code  highlighting을 해주는 확장 패키지입니다.
lowlight code highlighting을 해주는 패키지입니다.

현재 3.x.x 버전까지 나왔는데 타입 에러가 발생한다면 다음 이슈를 확인하고 바꾸면 됩니다.
https://github.com/ueberdosis/tiptap/issues/2116#issuecomment-976858821
highlight.js 대표적인 code highlighting 라이브러리이며, 여기서는 lowlight와 연결하기 위해 사용합니다.

 

/app/layout.tsx

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.scss";
// 해당 부분이 추가 되었습니다.
import "highlight.js/styles/stackoverflow-dark.min.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

 

/app/page.tsx

"use client"

import { useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Markdown } from "tiptap-markdown";

import styles from "./page.module.scss";
import Toolbar from "./components/toolbar";

// 해당 부분이 추가 되었습니다.
import CustomCodeBlockLowlight from "./util/codeBlockIndent";

export default function Home() {
  const [text, setText] = useState("Hello World!");
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        codeBlock: false,
      }),
      Link.extend({ inclusive: false }).configure({
        openOnClick: false,
      }),
      Markdown,
      // 해당 부분이 추가 되었습니다.
      CustomCodeBlockLowlight
    ],
    content: text,
    onUpdate({ editor }) {
      setText(editor.getHTML());
    }
  });

  return (
    <main className={styles.main}>
      <div className={styles.editor}>
        { editor && <Toolbar editor={editor} /> }
        <EditorContent editor={editor} />
      </div>
    </main>
  );
}

 

/app/util/codeBlockIndent.ts

codeblock인 경우에는 hljs로 인해서 뒤죽박죽으로 html element들이 지정되어 있습니다. 그렇기에, style을 통한 처리방법 보다는 공백을 활용하여 구현했습니다.

import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight";

// 해당 부분에서 필요한 언어를 import 하여 lowlight에 적용할 수 있습니다.
import html from "highlight.js/lib/languages/xml";
import css from "highlight.js/lib/languages/css";
import js from "highlight.js/lib/languages/javascript";
import ts from "highlight.js/lib/languages/typescript";
import python from "highlight.js/lib/languages/python";
import cpp from "highlight.js/lib/languages/cpp";
import json from "highlight.js/lib/languages/json";
import java from "highlight.js/lib/languages/java";
import c from "highlight.js/lib/languages/c";

lowlight.registerLanguage("css", css);
lowlight.registerLanguage("js", js);
lowlight.registerLanguage("javascript", js);
lowlight.registerLanguage("jsx", js);
lowlight.registerLanguage("ts", ts);
lowlight.registerLanguage("tsx", ts);
lowlight.registerLanguage("typescript", ts);
lowlight.registerLanguage("json", json);
lowlight.registerLanguage("html", html);
lowlight.registerLanguage("xml", html);
lowlight.registerLanguage("python", python);
lowlight.registerLanguage("cpp", cpp);
lowlight.registerLanguage("c", c);
lowlight.registerLanguage("java", java);

const CustomCodeBlockLowlight = CodeBlockLowlight.extend({
  addKeyboardShortcuts() {
    return {
      Tab: () => {
        const { state } = this.editor;
        const { selection } = state;
        const { from, to } = selection;
        const { $from } = selection;

        // 현재 선택 영역의 노드 가져오기
        const nodeAtSelection = $from.node();

        if (nodeAtSelection && nodeAtSelection.type.name === "codeBlock") {
          let tr;
          // 텍스트가 드래그되었는지 확인
          const isTextSelected = selection.from < selection.to;

          if (isTextSelected) {
            // select 된 텍스트 맨 앞을 기준으로 tab을 함
            tr = state.tr.insertText("  ", from);
          } else {
            tr = state.tr.insertText("  ", from, to);
          }

          this.editor.view.dispatch(tr);
        }

        return true;
      },
      "Shift-Tab": () => {
        const { state } = this.editor;
        const { selection } = state;
        const { $from } = selection;

        const nodeAtSelection = $from.node();

        if (nodeAtSelection && nodeAtSelection.type.name === "codeBlock") {
          let tr;
          const isTextSelected = selection.from < selection.to;

          if (isTextSelected) {
            const startPos = $from.pos;
            const endPos = $from.end();

            const lineStartPos = state.doc.resolve(startPos).start();
            const lineEndPos = state.doc.resolve(endPos).end();

            const lineText = state.doc.textBetween(
              lineStartPos,
              lineEndPos,
              " ",
            );

            if (lineText.startsWith("  ")) {
              tr = state.tr.delete(lineStartPos, lineStartPos + 2);
            }
          } else {
            const { $to } = selection;
            const endPos = $to.pos;

            // 들여쓰기는 최소 2칸의 여유가 있어야 함.
            if (endPos <= 1) return true;

            const endSlice = state.doc.slice(endPos - 2, endPos);
            const endText = endSlice.content.firstChild?.text;

            if (endText === "  ") {
              tr = state.tr.delete(endPos - 2, endPos);
            }
          }

          if (tr) {
            this.editor.view.dispatch(tr);
          }
        }

        return true;
      },
    };
  },
}).configure({
  lowlight,
});

export default CustomCodeBlockLowlight;

 

사용 예시

 

Indent

CodeBlcok이 아닌 텍스트 편집기에서도 들여쓰기내어쓰기를 구현하고싶은 사람들은 다음 코드를 사용하시면 됩니다.

해당 Github issue를 보고 살짝 가공했습니다.
CodeBlock과는 다르게 html element가 정해져 있기 때문에 style로 처리되었습니다.

https://github.com/ueberdosis/tiptap/issues/1036#issue-864043820

 

Indent Extension For Tiptap 2 (just want to share) · Issue #1036 · ueberdosis/tiptap

Is your feature request related to a problem? Please describe. #819 has a thorough list of extensions and it's stated there that there is an indent extension already implemented at https://github.c...

github.com

 

/app/page.tsx

"use client"

import { useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Markdown } from "tiptap-markdown";

import styles from "./page.module.scss";
import Toolbar from "./components/toolbar";

import CustomCodeBlockLowlight from "./util/codeBlockIndent";
// 이 부분이 추가 되었습니다.
import { Indent } from "./util/indent";

export default function Home() {
  const [text, setText] = useState("Hello World!");
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        codeBlock: false,
      }),
      Link.extend({ inclusive: false }).configure({
        openOnClick: false,
      }),
      Markdown,
      CustomCodeBlockLowlight,
      // 이 부분이 추가 되었습니다.
      Indent
    ],
    content: text,
    onUpdate({ editor }) {
      setText(editor.getHTML());
    }
  });

  return (
    <main className={styles.main}>
      <div className={styles.editor}>
        { editor && <Toolbar editor={editor} /> }
        <EditorContent editor={editor} />
      </div>
    </main>
  );
}

 

/page/util/indent.ts

import { Command, Extension, KeyboardShortcutCommand } from "@tiptap/core";
import { Node } from "prosemirror-model";
import { TextSelection, AllSelection, Transaction } from "prosemirror-state";

type IndentOptions = {
  types: string[];
  indentLevels: number[];
  defaultIndentLevel: number;
};

declare module "@tiptap/core" {
  interface Commands {
    indent: {
      indent: () => Command;
      outdent: () => Command;
    };
  }
}

export function clamp(val: number, min: number, max: number): number {
  if (val < min) {
    return min;
  }
  if (val > max) {
    return max;
  }
  return val;
}

export enum IndentProps {
  min = 0,
  max = 210,

  more = 30,
  less = -30,
}

export function isBulletListNode(node: Node): boolean {
  return node.type.name === "bulletList";
}

export function isOrderedListNode(node: Node): boolean {
  return node.type.name === "orderedList";
}

export function isTodoListNode(node: Node): boolean {
  return node.type.name === "listItem";
}

export function isListNode(node: Node): boolean {
  return (
    isBulletListNode(node) || isOrderedListNode(node) || isTodoListNode(node)
  );
}

function setNodeIndentMarkup(
  tr: Transaction,
  pos: number,
  delta: number,
): Transaction {
  if (!tr.doc) return tr;

  const node = tr.doc.nodeAt(pos);
  if (!node) return tr;

  const minIndent = IndentProps.min;
  const maxIndent = IndentProps.max;

  const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent);

  if (indent === node.attrs.indent) return tr;

  const nodeAttrs = {
    ...node.attrs,
    indent,
  };

  return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks);
}

function updateIndentLevel(tr: Transaction, delta: number): Transaction {
  const { doc, selection } = tr;

  if (!doc || !selection) return tr;

  if (
    !(selection instanceof TextSelection || selection instanceof AllSelection)
  ) {
    return tr;
  }

  const { from, to } = selection;

  doc.nodesBetween(from, to, (node, pos) => {
    const nodeType = node.type;

    if (nodeType.name === "paragraph" || nodeType.name === "heading") {
      tr = setNodeIndentMarkup(tr, pos, delta);
      return false;
    }
    if (isListNode(node)) {
      return false;
    }
    return true;
  });

  return tr;
}

export const Indent = Extension.create<IndentOptions>({
  name: "indent",

  addOptions: () => ({
    types: ["heading", "paragraph"],
    indentLevels: [0, 30, 60, 90, 120, 150, 180, 210],
    defaultIndentLevel: 0,
  }),

  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          indent: {
            default: this.options.defaultIndentLevel,
            renderHTML: (attributes) => {
              return {
                style: `margin-left: ${attributes.indent}px !important`,
              };
            },
            parseHTML: (element) => {
              return (
                parseInt(element.style.marginLeft) ||
                this.options.defaultIndentLevel
              );
            },
          },
        },
      },
    ];
  },

  addCommands() {
    return {
      indent:
        () =>
        ({ tr, state, dispatch }) => {
          const { selection } = state;
          tr = tr.setSelection(selection);
          tr = updateIndentLevel(tr, IndentProps.more);

          if (tr.docChanged) {
            // eslint-disable-next-line no-unused-expressions
            dispatch && dispatch(tr);
            return true;
          }

          return false;
        },
      outdent:
        () =>
        ({ tr, state, dispatch }) => {
          const { selection } = state;
          tr = tr.setSelection(selection);
          tr = updateIndentLevel(tr, IndentProps.less);

          if (tr.docChanged) {
            // eslint-disable-next-line no-unused-expressions
            dispatch && dispatch(tr);
            return true;
          }

          return false;
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      Tab: () => this.editor.commands.indent(),
      "Shift-Tab": () => this.editor.commands.outdent(),
    } as unknown as { [key: string]: KeyboardShortcutCommand };
  },
});

 

사용 예시

 

Drag Link

드래그 해서 Link를 지정하고, 대체 텍스트를 입력할 수 있게 합니다.

절대 귀찮아서 prompt로 입력 처리 한 거 아닙니다.

 

/app/page.tsx

"use client";
import React, { useCallback } from 'react'
import styles from './toolbar.module.scss';
import { Editor } from '@tiptap/react';

type ToolbarProps = {
  editor: Editor;
}

const Toolbar = ({ editor }: ToolbarProps) => {
  // 여기 부분이 추가 되었습니다.
  const setLink = useCallback(() => {
    const href = prompt("Enter the URL", "https://");
    const text = prompt("Enter the text", "Some Text");

    if (!href || !text) return;

    const { state } = editor;
    const { selection } = state;
    const { from, to } = selection;
    const { $from } = selection;

    const isTextSelected = from < to;
    const nodeAtSelection = $from.node();
    let tr;

    // 드래그 한 후 텍스트 선택 시
    if (
      nodeAtSelection &&
      nodeAtSelection.type.name !== "codeBlock" &&
      isTextSelected
    ) {
      tr = state.tr.deleteSelection();
      tr = state.tr.insertText(text as string);

      const linkMarkType = state.schema.marks.link;
      const linkMark = linkMarkType.create({ href });
      // 새로 넣은 텍스트 시작 위치(from)부터 끝 위치(to)를 링크로 변경
      tr = tr.addMark(from, from + (text as string).length, linkMark);

      editor.view.dispatch(tr);
    } else if (nodeAtSelection.type.name !== "codeBlock") {
      editor
        .chain()
        .focus()
        .setLink({ href })
        .insertContent(text)
        .run();
    }
  }, [editor]);

  return (
    <div className={styles.toolbar}>
      <div className={styles.itemBox}>
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.h1} ${
            editor.isActive("heading", { level: 2 }) ? styles.active : styles.none
          }`}
          onClick={() =>
            editor.chain().focus().toggleHeading({ level: 2 }).run()
          }
          disabled={
            !editor.can().chain().focus().toggleHeading({ level: 2 }).run()
          }
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.h2} ${
            editor.isActive("heading", { level: 3 }) ? styles.active : styles.none
          }`}
          onClick={() =>
            editor.chain().focus().toggleHeading({ level: 3 }).run()
          }
          disabled={
            !editor.can().chain().focus().toggleHeading({ level: 3 }).run()
          }
        />
      </div>
      <div className={styles.line} />
      <div className={styles.itemBox}>
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.bold} ${
            editor.isActive("bold") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleBold().run()}
          disabled={!editor.can().chain().focus().toggleBold().run()}
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.italic} ${
            editor.isActive("italic") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleItalic().run()}
          disabled={!editor.can().chain().focus().toggleItalic().run()}
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.strike} ${
            editor.isActive("strike") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleStrike().run()}
          disabled={!editor.can().chain().focus().toggleStrike().run()}
        />
      </div>
      <div className={`${styles.line} `} />
      <div className={styles.itemBox}>
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.bulleted} ${
            editor.isActive("bulletList") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleBulletList().run()}
        />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.numbered} ${
            editor.isActive("orderedList") ? styles.active : styles.none
          }`}
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
        />
      </div>
      <div className={styles.line} />
      <div className={styles.itemBox}>
        <button
            type="button"
            className={`${styles.toolbarBtn} ${styles.link} ${
              editor.isActive("link") ? styles.active : styles.none
            }`}
            // 여기 부분이 추가 되었습니다.
            onClick={setLink}
          />
        <button
          type="button"
          className={`${styles.toolbarBtn} ${styles.newline} ${styles.none}`}
          onClick={() => editor.chain().focus().setHorizontalRule().run()}
        />
      </div>
    </div>
  )
}

export default Toolbar

 

사용 예시

 

마무리

이번 프로젝트를 하면서 Code 작성도 가능한 Editor를 사용하는 게 프론트엔드에서는 대표 기능 중 하나였다고 생각한다. 들여쓰기와 내어쓰기가 구현되어 있지 않아 많이 당황했었는데 나와 같은 처지에 빠진 사람이라면 내 코드가 조금이나마 도움이 되었으면 합니다.

반응형
profile

코딩 기록소

@seungyong20

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!