Vue3.0富文本编辑器

8,973 阅读1分钟

最近在开发项目中用到了富文本编辑器,在使用的过程中感觉还可以,就想写篇分享给以后可能需要朋友,如果有错欢迎大家指出!

tiptap基于ProseMirror,支持表格、图片、视频、多人协同。支持TS。

tiptap初始化以后是没有任何css的,都需要自己开发,可以完全按照自己喜欢的样式来开发

安装@tiptap/starter-kit,@tiptap/vue-3依赖

yarn add @tiptap/starter-kit @tiptap/vue-3

创建Editor.vue

1. template

<template>
    <div class="editor" v-if="editor" :style="{width}">
        <editor-content class="editor-content":editor="editor" />
    </div>
</template>

2.script

<script>
    import { defineComponent } from 'vue'
    import { useEditor, EditorContent } from '@tiptap/vue-3'
    import StarterKit from '@tiptap/starter-kit'
    export default defineComponent({
      components: {
        EditorContent
      },
      props: {
        width: {
          type: String,
          default: '800px'
        }
      },
      setup() {
        const editor = useEditor({
          content: '<p>I’m running Tiptap with vue-next. 🎉</p>',
          extensions: [
            StarterKit
          ]
        })
        return {
          editor
        }
      }
    })
</script>

3.效果

image.png

现在的页面只有一个输入框,我们可以开始开发自己的工具菜单

1.创建MenuItem.vue和MenuBar.vue

MenuItem.vue

<template>
  <div>
    <template v-for="(item, index) in items">
      <div class="divider" v-if="item.type === 'divider'" :key="`divider${index}`" />
      <menu-item v-else :key="index" v-bind="item" />
    </template>
  </div>
</template>

<script>
import MenuItem from './MenuItem.vue'

export default {
  components: {
    MenuItem
  },

  props: {
    editor: {
      type: Object,
      required: true
    }
  },
  setup(props){
      const items = reactive([
        {
          icon: 'bold',
          title: 'Bold',
          action: () => props.editor.chain().focus().toggleBold().run(),
          isActive: () => props.editor.isActive('bold')
        },
        {
          icon: 'italic',
          title: 'Italic',
          action: () => props.editor.chain().focus().toggleItalic().run(),
          isActive: () => props.editor.isActive('italic')
        },
        {
          icon: 'strikethrough',
          title: 'Strike',
          action: () => props.editor.chain().focus().toggleStrike().run(),
          isActive: () => props.editor.isActive('strike')
        },
        {
          icon: 'code-view',
          title: 'Code',
          action: () => props.editor.chain().focus().toggleCode().run(),
          isActive: () => props.editor.isActive('code')
        },
        {
          icon: 'mark-pen-line',
          title: 'Highlight',
          action: () => props.editor.chain().focus().toggleHighlight().run(),
          isActive: () => props.editor.isActive('highlight')
        },
        {
          type: 'divider'
        },
        {
          icon: 'h-1',
          title: 'Heading 1',
          action: () => props.editor.chain().focus().toggleHeading({ level: 1 }).run(),
          isActive: () => props.editor.isActive('heading', { level: 1 })
        },
        {
          icon: 'h-2',
          title: 'Heading 2',
          action: () => props.editor.chain().focus().toggleHeading({ level: 2 }).run(),
          isActive: () => props.editor.isActive('heading', { level: 2 })
        },
        {
          icon: 'paragraph',
          title: 'Paragraph',
          action: () => props.editor.chain().focus().setParagraph().run(),
          isActive: () => props.editor.isActive('paragraph')
        },
        {
          icon: 'list-unordered',
          title: 'Bullet List',
          action: () => props.editor.chain().focus().toggleBulletList().run(),
          isActive: () => props.editor.isActive('bulletList')
        },
        {
          icon: 'list-ordered',
          title: 'Ordered List',
          action: () => props.editor.chain().focus().toggleOrderedList().run(),
          isActive: () => props.editor.isActive('orderedList')
        },
        {
          icon: 'list-check-2',
          title: 'Task List',
          action: () => props.editor.chain().focus().toggleTaskList().run(),
          isActive: () => props.editor.isActive('taskList')
        },
        {
          icon: 'code-box-line',
          title: 'Code Block',
          action: () => props.editor.chain().focus().toggleCodeBlock().run(),
          isActive: () => props.editor.isActive('codeBlock')
        },
        {
          type: 'divider'
        },
        {
          icon: 'double-quotes-l',
          title: 'Blockquote',
          action: () => props.editor.chain().focus().toggleBlockquote().run(),
          isActive: () => props.editor.isActive('blockquote')
        },
        {
          icon: 'separator',
          title: 'Horizontal Rule',
          action: () => props.editor.chain().focus().setHorizontalRule().run()
        },
        {
          type: 'divider'
        },
        {
          icon: 'text-wrap',
          title: 'Hard Break',
          action: () => props.editor.chain().focus().setHardBreak().run()
        },
        {
          icon: 'format-clear',
          title: 'Clear Format',
          action: () => props.editor.chain()
            .focus()
            .clearNodes()
            .unsetAllMarks()
            .run()
        },
        {
          type: 'divider'
        },
        {
          icon: 'arrow-go-back-line',
          title: 'Undo',
          action: () => props.editor.chain().focus().undo().run()
        },
        {
          icon: 'arrow-go-forward-line',
          title: 'Redo',
          action: () => props.editor.chain().focus().redo().run()
        }
      ])
      return {
          items
      }
  },
}
</script>

<style lang="scss">
.divider {
    width: 2px;
    height: 1.25rem;
    background-color: rgba(#000, .1);
    margin-left: .5rem;
    margin-right: .75rem;
}
</style>

安装工具栏图标库,在MenuItem.vue中使用

yarn add remixicon

MenuItem.vue

<template>
  <button
    class="menu-item"
    :class="{ 'is-active': isActive ? isActive(): null }"
    @click="action"
    :title="title"
  >
    <svg class="remix">
      <use :xlink:href="`${iconUrl}#ri-${icon}`" />
    </svg>
  </button>
</template>

<script>
import remixiconUrl from 'remixicon/fonts/remixicon.symbol.svg'

export default {
  props: {
    icon: {
      type: String,
      required: true
    },

    title: {
      type: String,
      required: true
    },

    action: {
      type: Function,
      required: true
    },

    isActive: {
      type: Function,
      default: null
    }
  },
  setup(){
      const iconUrl = ref(remixiconUrl)
      return {
          iconUrl
      }
  }
}
</script>

<style lang="scss">
.menu-item {
    width: 1.75rem;
    height: 1.75rem;
    color: #0d0d0d;
    border: none;
    background-color: transparent;
    border-radius: .4rem;
    padding: .25rem;
    margin-right: .25rem;

    svg {
        width: 100%;
        height: 100%;
        fill: currentColor;
    }

    &.is-active,
    &:hover {
        color: #fff;
        background-color: #0d0d0d;
    }
}
</style>

回到刚刚Editor.vue,引入我们写好的工具栏

<template>
    <div class="editor" v-if="editor" :style="{width}">
        <MenuBar class="editor-header" :editor="editor" />
        <editor-content class="editor-content" :editor="editor" />
    </div>
</template>

<script>
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { defineComponent } from 'vue'
import MenuBar from './MenuBar.vue'

export default defineComponent({
  components: {
    EditorContent,
    MenuBar
  },
  props: {
    width: {
      type: String,
      default: '800px'
    }
  },
  setup() {
    const editor = useEditor({
      content: '<p>I’m running Tiptap with vue-next. 🎉</p>',
      extensions: [
        StarterKit
      ]
    })
    return {
      editor
    }
  }
})
</script>

效果

image.png 因为tiptap没有任何css所以所有css都需要我们自己定义

写一套css让富文本好看些

<style lang="scss">
.editor {
    display: flex;
    flex-direction: column;
    max-height: 26rem;
    color: #0d0d0d;
    background-color: #fff;
    border: 3px solid #0d0d0d;
    border-radius: .75rem;

    &-header {
        display: flex;
        align-items: center;
        flex: 0 0 auto;
        flex-wrap: wrap;
        padding: .25rem;
        border-bottom: 3px solid #0d0d0d;
    }

    &-content {
        padding: .7rem .5rem;
        flex: 1 1 auto;
        overflow-x: hidden;
        overflow-y: auto;
        -webkit-overflow-scrolling: touch;
    }
}


/* 基本编辑器样式 */
.ProseMirror {
    height: 100%;

    &:focus {
        outline: none;
    }

    ul,
    ol {
        padding: 0 1rem;
    }

    h1,
    h2,
    h3,
    h4,
    h5,
    h6 {
        line-height: 1.1;
    }

    code {
        background-color: rgba(#616161, .1);
        color: #616161;
    }

    pre {
        background: #0d0d0d;
        color: #fff;
        font-family: 'JetBrainsMono', monospace;
        padding: .75rem 1rem;
        border-radius: .5rem;

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

    mark {
        background-color: #faf594;
    }

    img {
        max-width: 100%;
        height: auto;
    }

    hr {
        margin: 1rem 0;
    }

    blockquote {
        padding-left: 1rem;
        border-left: 2px solid rgba(#0d0d0d, .1);
    }

    hr {
        border: none;
        border-top: 2px solid rgba(#0d0d0d, .1);
        margin: 2rem 0;
    }

    ul[data-type="taskList"] {
        list-style: none;
        padding: 0;

        li {
            display: flex;
            align-items: center;

            > label {
                flex: 0 0 auto;
                margin-right: .5rem;
                user-select: none;
            }

            > div {
                flex: 1 1 auto;
            }
        }
    }
}
</style>

最终效果

image.png

官网文档写的很好,还有更多的api,大家可以自己去学习使用。