1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

機能要件

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でオーバーライドして回避することにしました。

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

ソース

<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制御で欲しいものも含めて揃っていましたし、なかなか使い勝手の良い印象を受けました。
スタイルやデザインを自分で自由にできるのもプロダクトに組み込む際には非常に有難いですね。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?