@tiptap/vue-3を使ってWYSIWYGエディタを作ってみる

# はじめに
こちらの記事でTiptapというヘッドレスのリッチテキストエディタのことを知りましたので、Tiptapを利用して最低限のやりたいことを詰め込んだWYSIWYGエディタをVue3で作成してみようと思い立ちました。
今回はその際の記録です。

https://zenn.dev/up_tri/articles/f725cdc0a2486c

# 機能要件
HTMLメールエディタ等としての使用を想定して、以下のような要件で作成していきます。
- 文字列を追加できること
- 文字列にリンクが設定できること
- 文字列のスタイルを変更できること （Bold, Italic）
- 見出しを追加できること
- 画像を追加できること
- 分割線を追加できること
- undo / redo ができること
- 生成されるhtmlを確認できること

# セットアップ
Vue3 appは作成済みの想定で記載していきます。

まずTiptapをインストールします。
```
yarn add @tiptap/vue-3 @tiptap/pm @tiptap/starter-kit
```

続いて、画像とリンクの操作のために拡張ツールをインストールします。
```
yarn add @tiptap/extension-image @tiptap/extension-link
```

# Preflightへの対応
今回はtailwindcssを使用していますが、Preflightを有効にしているとエディタ内部にもリセットcssが効いてしまします。
今回は、リセット前に近い内容のカスタムCSSでオーバーライドして回避することにしました。

```scss:tailwindcss.scss
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  .sandbox {
    h1 {
      @apply font-bold;
      @apply text-3xl;
    }
    h2 {
      @apply font-bold;
      @apply text-2xl;
    }
    h3 {
      @apply font-bold;
      @apply text-lg;
    }
    h4 {
      @apply font-bold;
    }
    h5 {
      @apply font-bold;
      @apply text-sm;
    }
    h6 {
      @apply font-bold;
      @apply text-xs;
    }
    a {
      @apply text-blue-500;
      @apply underline;
    }
  }
}

```

# エディタ
エディタのデザインと基本機能を定義していきます。

### 実装イメージ
文字列の見出し化はリスト選択による操作、他はボタンによる操作としてUIを作成します。
ボタンはアイコンを用意するのをサボったので絵文字です。

![スクリーンショット 2023-12-22 18.01.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333070/e6773eda-c206-4a73-9330-0c26176d21be.png)

### ソース

```vuejs
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'

type Props = {
  modelValue: string
}

type Emit = {
  (event: 'update:modelValue', value: Props['modelValue']): void
}

type TypographyOption = {
  label: string
  value: string
} & (
  | {
      nodeName: 'paragraph'
      nodeAttrs: {}
    }
  | {
      nodeName: 'heading'
      nodeAttrs: {
        level: 1 | 2 | 3 | 4 | 5 | 6
      }
    }
)

const props = defineProps<Props>()
const emit = defineEmits<Emit>()

const typographyOptions: TypographyOption[] = [
  { label: 'テキスト', value: 'p', nodeName: 'paragraph', nodeAttrs: {} },
  {
    label: '見出し1',
    value: 'h1',
    nodeName: 'heading',
    nodeAttrs: { level: 1 },
  },
  {
    label: '見出し2',
    value: 'h2',
    nodeName: 'heading',
    nodeAttrs: { level: 2 },
  },
  {
    label: '見出し3',
    value: 'h3',
    nodeName: 'heading',
    nodeAttrs: { level: 3 },
  },
  {
    label: '見出し4',
    value: 'h4',
    nodeName: 'heading',
    nodeAttrs: { level: 4 },
  },
  {
    label: '見出し5',
    value: 'h5',
    nodeName: 'heading',
    nodeAttrs: { level: 5 },
  },
  {
    label: '見出し6',
    value: 'h6',
    nodeName: 'heading',
    nodeAttrs: { level: 6 },
  },
]

const editor = new Editor({
  content: props.modelValue,
  extensions: [
    Image,
    Link.configure({
      openOnClick: false,
    }),
    StarterKit,
  ],
  editorProps: {
    attributes: {
      class: 'sandbox min-h-[200px] p-2 border border-base-40 text-sm focus:border-primary-60 focus:outline-none active:border-base-40 text-base-70 placeholder-shown:text-base-50 hover:border-base-60 hover:text-base-80',
    },
  },
})

const previewHtml = ref(false)

const innerValue = computed(() => {
  return editor.isEmpty ? '' : editor.getHTML()
})

const changeTypography = (e: Event) => {
  const value = (e.target as HTMLInputElement).value
  const typography = typographyOptions.find((e) => e.value === value)

  if (typography?.nodeName !== 'heading') {
    editor.chain().focus().clearNodes().run()
    return
  }

  editor.chain().focus().toggleHeading(typography.nodeAttrs).run()
}

const setLink = () => {
  const previousUrl = editor.getAttributes('link').href
  const url = window.prompt('URL', previousUrl)

  if (url === null) {
    return
  }

  if (url === '') {
    editor.chain().focus().extendMarkRange('link').unsetLink().run()

    return
  }

  editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}

const addImage = () => {
  const url = window.prompt('URL')

  if (url) {
    editor.chain().focus().setImage({ src: url }).run()
  }
}

watch(innerValue, (v) => {
  emit('update:modelValue', v)
})

onBeforeUnmount(() => {
  editor.destroy()
})
</script>

<template>
  <div>
    <div
      v-if="editor"
      class="flex h-8 items-center rounded-t border-x border-t border-base-40 bg-base-20 px-2 text-sm"
    >
      <select
        :value="
          typographyOptions.find(({ nodeName, nodeAttrs }) =>
            editor.isActive(nodeName, nodeAttrs)
          )?.value
        "
        class="max-[180px] block h-6 cursor-pointer rounded border border-base-40 px-2 text-xs text-base-80 hover:border-base-60 focus:border-primary-60 focus:outline-none"
        @change="changeTypography"
      >
        <option
          v-for="{ value, label } in typographyOptions"
          :key="value"
          :value="value"
        >
          {{ label }}
        </option>
      </select>
      <hr class="mx-2 h-6 w-[1px] border-l border-base-50" />
      <button
        type="button"
        title="Bold"
        :disabled="!editor.can().chain().focus().toggleBold().run()"
        :class="{
          'text-primary-60': editor.isActive('bold'),
          'bg-base-20 text-base-40': !editor
            .can()
            .chain()
            .focus()
            .toggleBold()
            .run(),
        }"
        class="w-7 font-bold"
        @click="editor.chain().focus().toggleBold().run()"
      >
        B
      </button>
      <button
        type="button"
        title="Italic"
        :disabled="!editor.can().chain().focus().toggleItalic().run()"
        :class="{
          'text-primary-60': editor.isActive('italic'),
          'bg-base-20 text-base-40': !editor
            .can()
            .chain()
            .focus()
            .toggleItalic()
            .run(),
        }"
        class="w-7 italic"
        @click="editor.chain().focus().toggleItalic().run()"
      >
        i
      </button>
      <hr class="mx-2 h-6 w-[1px] border-l border-base-50" />
      <button
        type="button"
        title="イメージ"
        button-style="tertiary"
        class="w-7 px-1"
        @click="addImage"
      >
        &#x1f5bc;
      </button>
      <button
        type="button"
        title="リンク"
        button-style="tertiary"
        class="w-7 text-xs font-semibold"
        @click="setLink"
      >
        &#128279;
      </button>
      <button
        type="button"
        title="分割線"
        button-style="tertiary"
        class="w-7"
        @click="editor.chain().focus().setHorizontalRule().run()"
      >
        &#x2501;
      </button>
      <button
        type="button"
        title="改行"
        button-style="tertiary"
        class="w-7"
        @click="editor.chain().focus().setHardBreak().run()"
      >
        &#x23CE;
      </button>
      <div class="grow"></div>
      <hr class="mx-2 h-6 w-[1px] border-l border-base-50" />
      <button
        type="button"
        title="Undo"
        button-style="tertiary"
        class="w-7 px-2"
        :class="{
          'bg-base-20 text-base-40': !editor.can().chain().focus().undo().run(),
        }"
        :disabled="!editor.can().chain().focus().undo().run()"
        @click="editor.chain().focus().undo().run()"
      >
        &lt;
      </button>
      <button
        type="button"
        title="Redo"
        class="w-7 px-2"
        :class="{
          'bg-base-20 text-base-40': !editor.can().chain().focus().redo().run(),
        }"
        :disabled="!editor.can().chain().focus().redo().run()"
        @click="editor.chain().focus().redo().run()"
      >
        &gt;
      </button>
      <hr class="mx-2 h-6 w-[1px] border-l border-base-50" />
      <button
        type="button"
        title="HTML表示"
        class="w-7 text-xs font-semibold"
        @click="previewHtml = !previewHtml"
      >
        &lt;/&gt;
      </button>
    </div>
    <EditorContent :editor="editor" />
    <div
      v-if="previewHtml"
      class="break-all border-x border-b border-base-40 p-2 text-sm text-base-70"
    >
      {{ innerValue }}
    </div>
    <div class="rounded-b border-x border-b border-base-40 bg-base-20 p-1" />
  </div>
</template>

```

# おわりに
エディタの機能としては、UI制御で欲しいものも含めて揃っていましたし、なかなか使い勝手の良い印象を受けました。
スタイルやデザインを自分で自由にできるのもプロダクトに組み込む際には非常に有難いですね。


# 参考
https://zenn.dev/up_tri/articles/f725cdc0a2486c

https://tiptap.dev/docs/editor/installation/vue3

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

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