在Nuxt3中使用Tiptap富文本编辑器(忽略我丑陋的CSS版)

717 阅读11分钟

注意,前方高能!CSS惨不忍睹~

1. 引言

Tiptap 是一个基于 ProseMirror 的富文本编辑器,具有高度的可定制性和扩展性。本文将逐步指导您如何在 Nuxt.js 项目中集成和使用 Tiptap 富文本编辑器,并实现一些常见功能,如表格、图片和视频插入。

2. 安装依赖

首先,我们需要安装 nuxt-tiptap-editor 模块,它可以帮助我们在 Nuxt.js 项目中轻松集成 Tiptap 编辑器。使用以下命令添加依赖:

npx nuxi@latest module add tiptap

3. 配置 nuxt.config.ts

接下来,我们需要在 nuxt.config.ts 文件中添加 nuxt-tiptap-editor 模块,并进行一些基本配置:

export default defineNuxtConfig({
  modules: ['nuxt-tiptap-editor'],
  tiptap: {
    prefix: 'Tiptap', // 为 Tiptap 导入设置前缀,composables 不包括在内
  },
});

4. 创建基础编辑器组件

components 目录下创建 TiptapEditor.vue 文件,并复制以下代码:

<template>
  <div>
    <div v-if="editor" class="toolbar">
      <button
        @click="editor.chain().focus().toggleBold().run()"
        :disabled="!editor.can().chain().focus().toggleBold().run()"
        :class="{ 'is-active': editor.isActive('bold') }"
      >
        bold
      </button>
      <button
        @click="editor.chain().focus().toggleItalic().run()"
        :disabled="!editor.can().chain().focus().toggleItalic().run()"
        :class="{ 'is-active': editor.isActive('italic') }"
      >
        italic
      </button>
      <button
        @click="editor.chain().focus().toggleStrike().run()"
        :disabled="!editor.can().chain().focus().toggleStrike().run()"
        :class="{ 'is-active': editor.isActive('strike') }"
      >
        strike
      </button>
      <button
        @click="editor.chain().focus().toggleCode().run()"
        :disabled="!editor.can().chain().focus().toggleCode().run()"
        :class="{ 'is-active': editor.isActive('code') }"
      >
        code
      </button>
      <button @click="editor.chain().focus().unsetAllMarks().run()">
        clear marks
      </button>
      <button @click="editor.chain().focus().clearNodes().run()">
        clear nodes
      </button>
      <button
        @click="editor.chain().focus().setParagraph().run()"
        :class="{ 'is-active': editor.isActive('paragraph') }"
      >
        paragraph
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
      >
        h1
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
      >
        h2
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
      >
        h3
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
      >
        h4
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
      >
        h5
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
      >
        h6
      </button>
      <button
        @click="editor.chain().focus().toggleBulletList().run()"
        :class="{ 'is-active': editor.isActive('bulletList') }"
      >
        bullet list
      </button>
      <button
        @click="editor.chain().focus().toggleOrderedList().run()"
        :class="{ 'is-active': editor.isActive('orderedList') }"
      >
        ordered list
      </button>
      <button
        @click="editor.chain().focus().toggleCodeBlock().run()"
        :class="{ 'is-active': editor.isActive('codeBlock') }"
      >
        code block
      </button>
      <button
        @click="editor.chain().focus().toggleBlockquote().run()"
        :class="{ 'is-active': editor.isActive('blockquote') }"
      >
        blockquote
      </button>
      <button @click="editor.chain().focus().setHorizontalRule().run()">
        horizontal rule
      </button>
      <button @click="editor.chain().focus().setHardBreak().run()">
        hard break
      </button>
      <button
        @click="editor.chain().focus().undo().run()"
        :disabled="!editor.can().chain().focus().undo().run()"
      >
        undo
      </button>
      <button
        @click="editor.chain().focus().redo().run()"
        :disabled="!editor.can().chain().focus().redo().run()"
      >
        redo
      </button>
    </div>
    <TiptapEditorContent :editor="editor" />
  </div>
</template>

<script setup>
const editor = useEditor({
  content: "<p>I'm running Tiptap with Vue.js. 🎉</p>",
  extensions: [TiptapStarterKit],
});

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

<style scoped>
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 10px;
}

button {
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f9f9f9;
  cursor: pointer;
  transition: background 0.3s;
}

button:hover {
  background: #eee;
}

button.is-active {
  background: #ddd;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
</style>

以上内容来自该包的快速开始翻译

5. 使用编辑器组件

在页面中引入并使用 TiptapEditor 组件:

<template>
  <div>
    <h1>My Nuxt.js App with Tiptap Editor</h1>
    <TiptapEditor />
  </div>
</template>

<script setup lang="ts"></script>

6. 美化编辑器

为了增强编辑器的视觉效果,我们可以添加一些样式,并扩展工具栏按钮的功能:

<template>
  <div class="editor-container">
    <div v-if="editor" class="toolbar">
      <button
        @click="editor.chain().focus().toggleBold().run()"
        :disabled="!editor.can().chain().focus().toggleBold().run()"
        :class="{ 'is-active': editor.isActive('bold') }"
      >
        加粗
      </button>
      <button
        @click="editor.chain().focus().toggleItalic().run()"
        :disabled="!editor.can().chain().focus().toggleItalic().run()"
        :class="{ 'is-active': editor.isActive('italic') }"
      >
        斜体
      </button>
      <button
        @click="editor.chain().focus().toggleStrike().run()"
        :disabled="!editor.can().chain().focus().toggleStrike().run()"
        :class="{ 'is-active': editor.isActive('strike') }"
      >
        删除线
      </button>
      <button
        @click="editor.chain().focus().toggleCode().run()"
        :disabled="!editor.can().chain().focus().toggleCode().run()"
        :class="{ 'is-active': editor.isActive('code') }"
      >
        代码
      </button>
      <button @click="editor.chain().focus().unsetAllMarks().run()">
        清除格式
      </button>
      <button @click="editor.chain().focus().clearNodes().run()">
        清除节点
      </button>
      <button
        @click="editor.chain().focus().setParagraph().run()"
        :class="{ 'is-active': editor.isActive('paragraph') }"
      >
        段落
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
      >
        标题1
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
      >
        标题2
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
      >
        标题3
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
      >
        标题4
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
      >
        标题5
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
      >
        标题6
      </button>
      <button
        @click="editor.chain().focus().toggleBulletList().run()"
        :class="{ 'is-active': editor.isActive('bulletList') }"
      >
        无序列表
      </button>
      <button
        @click="editor.chain().focus().toggleOrderedList().run()"
        :class="{ 'is-active': editor.isActive('orderedList') }"
      >
        有序列表
      </button>
      <button
        @click="editor.chain().focus().toggleCodeBlock().run()"
        :class="{ 'is-active': editor.isActive('codeBlock') }"
      >
        代码块
      </button>
      <button
        @click="editor.chain().focus().toggleBlockquote().run()"
        :class="{ 'is-active': editor.isActive('blockquote') }"
      >
        引用
      </button>
      <button @click="editor.chain().focus().setHorizontalRule().run()">
        水平线
      </button>
      <button @click="editor.chain().focus().setHardBreak().run()">换行</button>
      <button
        @click="editor.chain().focus().undo().run()"
        :disabled="!editor.can().chain().focus().undo().run()"
      >
        撤销
      </button>
      <button
        @click="editor.chain().focus().redo().run()"
        :disabled="!editor.can().chain().focus().redo().run()"
      >
        重做
      </button>
      <button @click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()">
        插入表格
      </button>
      <button @click="editor.chain().focus().addColumnBefore().run()">
        在前面添加列
      </button>
      <button @click="editor.chain().focus().addColumnAfter().run()">
        在后面添加列
      </button>
      <button @click="editor.chain().focus().deleteColumn().run()">
        删除列
      </button>
      <button @click="editor.chain().focus().addRowBefore().run()">
        在上面添加行
      </button>
      <button @click="editor.chain().focus().addRowAfter().run()">
        在下面添加行
      </button>
      <button @click="editor.chain().focus().deleteRow().run()">
        删除行
      </button>
      <button @click="editor.chain().focus().deleteTable().run()">
        删除表格
      </button>
    </div>
    <TiptapEditorContent :editor="editor" class="editor-content" />
  </div>
</template>

<script setup>
import { ref, onBeforeUnmount, watch } from "vue";
import { useEditor, EditorContent as TiptapEditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';

const props = defineProps({
  content: {
    type: String,
    default: "",
  },
  onUpdate: Function,
});

const editorContent = ref(props.content);

const editor = useEditor({
  content: editorContent.value,
  extensions: [
    StarterKit,
    Table.configure({
      resizable: true,
    }),
    TableRow,
    TableCell,
    TableHeader,
  ],
  onUpdate: ({ editor }) => {
    editorContent.value = editor.getHTML();
    if (props.onUpdate) {
      props.onUpdate(editorContent.value);
    }
  },
});

watch(
  () => props.content,
  (newContent) => {
    if (editor.value && newContent !== editorContent.value) {
      editor.value.commands.setContent(newContent);
    }
  }
);

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

<style>
.editor-container {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 20px;
  background: #fff;
  border-radius: 8px;
}
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  background: #ffffff;
  padding: 10px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tiptap {
  box-sizing: border-box;
  line-height: 2rem !important;
  padding: 5px;
}
.editor-content {
  background: #ffffff;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  box-sizing: border-box;
  height: calc(100vh - 152px);
}
button {
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f9f9f9;
  cursor: pointer;
  transition: background 0.3s;
}
button:hover {
  background: #eee;
}
button.is-active {
  background: #ddd;
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
.editor-content table {
  width: 100%;
  border-collapse: collapse;
}

.editor-content th,
.editor-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.editor-content th {
  background-color: #f2f2f2;
  text-align: left;
}
</style>

7. 加入表格功能

为了在编辑器中加入表格功能,我们需要安装相关依赖:

yarn add @tiptap/extension-table @tiptap/extension-table-cell @tiptap/extension-table-header @tiptap/extension-table-row

然后在编辑器中集成表格功能:

<template>
  <div class="editor-container">
    <div v-if="editor" class="toolbar">
      <button
        @click="editor.chain().focus().toggleBold().run()"
        :disabled="!editor.can().chain().focus().toggleBold().run()"
        :class="{ 'is-active': editor.isActive('bold') }"
      >
        粗
      </button>
      <button
        @click="editor.chain().focus().toggleItalic().run()"
        :disabled="!editor.can().chain().focus().toggleItalic().run()"
        :class="{ 'is-active': editor.isActive('italic') }"
      >
        斜
      </button>
      <button
        @click="editor.chain().focus().toggleStrike().run()"
        :disabled="!editor.can().chain().focus().toggleStrike().run()"
        :class="{ 'is-active': editor.isActive('strike') }"
      >
        U
      </button>
      <button
        @click="editor.chain().focus().toggleCode().run()"
        :disabled="!editor.can().chain().focus().toggleCode().run()"
        :class="{ 'is-active': editor.isActive('code') }"
      >
        Code
      </button>
      <button @click="editor.chain().focus().unsetAllMarks().run()">
        清除格式
      </button>
      <button @click="editor.chain().focus().clearNodes().run()">
        清除节点
      </button>
      <button
        @click="editor.chain().focus().setParagraph().run()"
        :class="{ 'is-active': editor.isActive('paragraph') }"
      >
        段落
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
      >
        标题1
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
      >
        标题2
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
      >
        标题3
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
      >
        标题4
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
      >
        标题5
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
      >
        标题6
      </button>
      <button
        @click="editor.chain().focus().toggleBulletList().run()"
        :class="{ 'is-active': editor.isActive('bulletList') }"
      >
        无序列表
      </button>
      <button
        @click="editor.chain().focus().toggleOrderedList().run()"
        :class="{ 'is-active': editor.isActive('orderedList') }"
      >
        有序列表
      </button>
      <button
        @click="editor.chain().focus().toggleCodeBlock().run()"
        :class="{ 'is-active': editor.isActive('codeBlock') }"
      >
        代码块
      </button>
      <button
        @click="editor.chain().focus().toggleBlockquote().run()"
        :class="{ 'is-active': editor.isActive('blockquote') }"
      >
        引用
      </button>
      <button @click="editor.chain().focus().setHorizontalRule().run()">
        水平线
      </button>
      <button @click="editor.chain().focus().setHardBreak().run()">换行</button>
      <button
        @click="editor.chain().focus().undo().run()"
        :disabled="!editor.can().chain().focus().undo().run()"
      >
        撤销
      </button>
      <button
        @click="editor.chain().focus().redo().run()"
        :disabled="!editor.can().chain().focus().redo().run()"
      >
        重做
      </button>
      <button
        @click="
          editor
            .chain()
            .focus()
            .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
            .run()
        "
      >
        插入表格
      </button>
      <button @click="editor.chain().focus().addColumnBefore().run()">
        在前面添加列
      </button>
      <button @click="editor.chain().focus().addColumnAfter().run()">
        在后面添加列
      </button>
      <button @click="editor.chain().focus().deleteColumn().run()">
        删除列
      </button>
      <button @click="editor.chain().focus().addRowBefore().run()">
        在上面添加行
      </button>
      <button @click="editor.chain().focus().addRowAfter().run()">
        在下面添加行
      </button>
      <button @click="editor.chain().focus().deleteRow().run()">删除行</button>
      <button @click="editor.chain().focus().deleteTable().run()">
        删除表格
      </button>
      <!-- 其他按钮代码 -->
    </div>
    <TiptapEditorContent :editor="editor" class="editor-content" />
  </div>
</template>

<script setup>
import { ref, onBeforeUnmount, watch } from "vue";
import { useEditor, EditorContent as TiptapEditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";

const props = defineProps({
  content: {
    type: String,
    default: "",
  },
  onUpdate: Function,
});

const editorContent = ref(props.content);

const editor = useEditor({
  content: editorContent.value,
  extensions: [
    StarterKit,
    Table.configure({
      resizable: true,
    }),
    TableRow,
    TableCell,
    TableHeader,
  ],
  onUpdate: ({ editor }) => {
    editorContent.value = editor.getHTML();
    if (props.onUpdate) {
      props.onUpdate(editorContent.value);
    }
  },
});

watch(
  () => props.content,
  (newContent) => {
    if (editor.value && newContent !== editorContent.value) {
      editor.value.commands.setContent(newContent);
    }
  }
);

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

<style>
.editor-container {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 20px;
  background: #fff;
  border-radius: 8px;
}
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  background: #ffffff;
  padding: 10px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tiptap {
  box-sizing: border-box;
  line-height: 2rem !important;
  padding: 5px;
}
.editor-content {
  background: #ffffff;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  box-sizing: border-box;
  height: calc(100vh - 152px);
}
button {
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f9f9f9;
  cursor: pointer;
  transition: background 0.3s;
}
button:hover {
  background: #eee;
}
button.is-active {
  background: #ddd;
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
.editor-content table {
  width: 100%;
  border-collapse: collapse;
}

.editor-content th,
.editor-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.editor-content th {
  background-color: #f2f2f2;
  text-align: left;
}
</style>

注意!一定要给一点基础的css,不然看不见你的表格长啥样

8. 实现图片插入功能

可以参考一下官方的文档 @Image extension

为了在编辑器中插入图片和视频,我们需要安装相关扩展:

yarn add @tiptap/extension-image

然后在编辑器中集成图片插件:

<template>
  <div class="editor-container">
    <div v-if="editor" class="toolbar">
      <!-- 其他按钮代码 -->
      <button @click="insertImage">插入图片</button>
      <!-- 其他按钮代码 -->
    </div>
    <TiptapEditorContent :editor="editor" class="editor-content" />
  </div>
</template>

<script setup>
import { ref, onBeforeUnmount, watch } from "vue";
import { useEditor, EditorContent as TiptapEditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Image from "@tiptap/extension-image";

const props = defineProps({
  content: {
    type: String,
    default: "",
  },
  onUpdate: Function,
});

const editorContent = ref(props.content);

const editor = useEditor({
  content: editorContent.value,
  extensions: [
    StarterKit,
    Table.configure({
      resizable: true,
    }),
    TableRow,
    TableCell,
    TableHeader,
    Image,
    Video,
  ],
  onUpdate: ({ editor }) => {
    editorContent.value = editor.getHTML();
    if (props.onUpdate) {
      props.onUpdate(editorContent.value);
    }
  },
});

watch(
  () => props.content,
  (newContent) => {
    if (editor.value && newContent !== editorContent.value) {
      editor.value.commands.setContent(newContent);
    }
  }
);

onBeforeUnmount(() => {
  if (editor.value) {
    editor.value.destroy();
  }
});

const insertImage = () => {
  if (!editor) return;
  const url = prompt("请输入图片URL");
  if (url) {
    editor.value.commands.setImage({ src: url });
  }
};
</script>

<style>
.editor-container {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 20px;
  background: #fff;
  border-radius: 8px;
}
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  background: #ffffff;
  padding: 10px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tiptap {
  box-sizing: border-box;
  line-height: 2rem !important;
  padding: 5px;
}
.editor-content {
  background: #ffffff;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  box-sizing: border-box;
  height: calc(100vh - 152px);
}
button {
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f9f9f9;
  cursor: pointer;
  transition: background 0.3s;
}
button:hover {
  background: #eee;
}
button.is-active {
  background: #ddd;
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
.editor-content table {
  width: 100%;
  border-collapse: collapse;
}

.editor-content th,
.editor-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.editor-content th {
  background-color: #f2f2f2;
  text-align: left;
}
</style>

9. 实现视频插入功能

在社区中没有找到video相关的插件,npm上也没搜到这里就自己写了。各位老哥如果有推荐可以评论区见~

有请Nuxt3插件系统

// plugins/extensions/video.ts

import { Node, mergeAttributes, CommandProps, RawCommands } from "@tiptap/core";

interface VideoOptions {
  src: string;
  controls?: boolean;
}

export const Video = Node.create({
  name: "video",

  group: "block",

  selectable: true,

  draggable: true,

  addAttributes() {
    return {
      src: {
        default: null,
        parseHTML: (element: HTMLElement) => element.getAttribute("src"),
        renderHTML: (attributes: { [key: string]: any }) => {
          if (!attributes.src) {
            return {};
          }
          return { src: attributes.src };
        },
      },
      controls: {
        default: true,
        parseHTML: (element: HTMLElement) => element.hasAttribute("controls"),
        renderHTML: (attributes: { [key: string]: any }) => {
          if (!attributes.controls) {
            return {};
          }
          return { controls: attributes.controls };
        },
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: "video",
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      "video",
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
    ];
  },

  addCommands() {
    return {
      setVideo:
        (options: VideoOptions) =>
        ({ commands }: CommandProps) => {
          return commands.insertContent({
            type: this.name,
            attrs: options,
          });
        },
    } as Partial<RawCommands>;
  },
});

在组件中初始化视频插件

<template>
  <div class="editor-container">
    <div v-if="editor" class="toolbar">
      <!-- 其他按钮代码 -->
      <button @click="insertImage">插入图片</button>
      <button @click="insertVideo">插入视频</button>
      <!-- 其他按钮代码 -->
    </div>
    <TiptapEditorContent :editor="editor" class="editor-content" />
  </div>
</template>

<script setup>
import { ref, onBeforeUnmount, watch } from "vue";
import { useEditor, EditorContent as TiptapEditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Image from "@tiptap/extension-image";
import { Video } from "~/plugins/extensions/video";

const props = defineProps({
  content: {
    type: String,
    default: "",
  },
  onUpdate: Function,
});

const editorContent = ref(props.content);

const editor = useEditor({
  content: editorContent.value,
  extensions: [
    StarterKit,
    Table.configure({
      resizable: true,
    }),
    TableRow,
    TableCell,
    TableHeader,
    Image.configure({
      inline: false, // 配置选项
      allowBase64: false, // 配置选项
      HTMLAttributes: {
        class: "my-custom-class", // 自定义 HTML 属性
      },
    }),
    Video,
  ],
  onUpdate: ({ editor }) => {
    editorContent.value = editor.getHTML();
    if (props.onUpdate) {
      props.onUpdate(editorContent.value);
    }
  },
});

watch(
  () => props.content,
  (newContent) => {
    if (editor.value && newContent !== editorContent.value) {
      editor.value.commands.setContent(newContent);
    }
  }
);

onBeforeUnmount(() => {
  if (editor.value) {
    editor.value.destroy();
  }
});

const insertImage = () => {
  if (!editor) return;
  const url = prompt("请输入图片URL");
  if (url) {
    editor.value.commands.setImage({ src: url });
  }
};

const insertVideo = () => {
  if (!editor.value) return; // 确保 editor 已初始化
  const url = prompt("请输入视频URL");
  if (url) {
    editor.value.commands.setVideo({ src: url });
  }
};
</script>

<style>

.my-custom-class {
  width: 90%;
  margin: 0 5%;
}
.editor-container video {
  width: 75%;
  margin: 0 12.5%;
}
</style>

10. 总结

通过以上步骤,我们在 Nuxt.js 项目中成功集成了 Tiptap 富文本编辑器,并实现了包括基础文本格式化、表格操作、图片和视频插入等功能。Tiptap 的高度可扩展性使其成为一个非常强大的富文本编辑器解决方案,适用于各种复杂的编辑需求。希望本文能对您有所帮助,祝您开发愉快!

附件——完整代码

<template>
  <div class="editor-container">
    <div v-if="editor" class="toolbar">
      <!-- 其他按钮代码 -->
      <button
        @click="editor.chain().focus().toggleBold().run()"
        :disabled="!editor.can().chain().focus().toggleBold().run()"
        :class="{ 'is-active': editor.isActive('bold') }"
      >
        加粗
      </button>
      <button
        @click="editor.chain().focus().toggleItalic().run()"
        :disabled="!editor.can().chain().focus().toggleItalic().run()"
        :class="{ 'is-active': editor.isActive('italic') }"
      >
        斜体
      </button>
      <button
        @click="editor.chain().focus().toggleStrike().run()"
        :disabled="!editor.can().chain().focus().toggleStrike().run()"
        :class="{ 'is-active': editor.isActive('strike') }"
      >
        删除线
      </button>
      <button
        @click="editor.chain().focus().toggleCode().run()"
        :disabled="!editor.can().chain().focus().toggleCode().run()"
        :class="{ 'is-active': editor.isActive('code') }"
      >
        代码
      </button>
      <button @click="editor.chain().focus().unsetAllMarks().run()">
        清除格式
      </button>
      <button @click="editor.chain().focus().clearNodes().run()">
        清除节点
      </button>
      <button
        @click="editor.chain().focus().setParagraph().run()"
        :class="{ 'is-active': editor.isActive('paragraph') }"
      >
        段落
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
      >
        标题1
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
      >
        标题2
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
      >
        标题3
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
      >
        标题4
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
      >
        标题5
      </button>
      <button
        @click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
        :class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
      >
        标题6
      </button>
      <button
        @click="editor.chain().focus().toggleBulletList().run()"
        :class="{ 'is-active': editor.isActive('bulletList') }"
      >
        无序列表
      </button>
      <button
        @click="editor.chain().focus().toggleOrderedList().run()"
        :class="{ 'is-active': editor.isActive('orderedList') }"
      >
        有序列表
      </button>
      <button
        @click="editor.chain().focus().toggleCodeBlock().run()"
        :class="{ 'is-active': editor.isActive('codeBlock') }"
      >
        代码块
      </button>
      <button
        @click="editor.chain().focus().toggleBlockquote().run()"
        :class="{ 'is-active': editor.isActive('blockquote') }"
      >
        引用
      </button>
      <button @click="editor.chain().focus().setHorizontalRule().run()">
        水平线
      </button>
      <button @click="editor.chain().focus().setHardBreak().run()">换行</button>
      <button
        @click="editor.chain().focus().undo().run()"
        :disabled="!editor.can().chain().focus().undo().run()"
      >
        撤销
      </button>
      <button
        @click="editor.chain().focus().redo().run()"
        :disabled="!editor.can().chain().focus().redo().run()"
      >
        重做
      </button>
      <button
        @click="
          editor
            .chain()
            .focus()
            .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
            .run()
        "
      >
        插入表格
      </button>
      <button @click="editor.chain().focus().addColumnBefore().run()">
        在前面添加列
      </button>
      <button @click="editor.chain().focus().addColumnAfter().run()">
        在后面添加列
      </button>
      <button @click="editor.chain().focus().deleteColumn().run()">
        删除列
      </button>
      <button @click="editor.chain().focus().addRowBefore().run()">
        在上面添加行
      </button>
      <button @click="editor.chain().focus().addRowAfter().run()">
        在下面添加行
      </button>
      <button @click="editor.chain().focus().deleteRow().run()">删除行</button>
      <button @click="editor.chain().focus().deleteTable().run()">
        删除表格
      </button>
      <button @click="insertImage">插入图片</button>
      <button @click="insertVideo">插入视频</button>
      <!-- 其他按钮代码 -->
    </div>
    <TiptapEditorContent :editor="editor" class="editor-content" />
  </div>
</template>

<script setup>
import { ref, onBeforeUnmount, watch } from "vue";
import { useEditor, EditorContent as TiptapEditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Image from "@tiptap/extension-image";
import { Video } from "~/plugins/extensions/video";

const props = defineProps({
  content: {
    type: String,
    default: "",
  },
  onUpdate: Function,
});

const editorContent = ref(props.content);

const editor = useEditor({
  content: editorContent.value,
  extensions: [
    StarterKit,
    Table.configure({
      resizable: true,
    }),
    TableRow,
    TableCell,
    TableHeader,
    Image.configure({
      inline: false, // 配置选项
      allowBase64: false, // 配置选项
      HTMLAttributes: {
        class: "my-custom-class", // 自定义 HTML 属性
      },
    }),
    Video,
  ],
  onUpdate: ({ editor }) => {
    editorContent.value = editor.getHTML();
    if (props.onUpdate) {
      props.onUpdate(editorContent.value);
    }
  },
});

watch(
  () => props.content,
  (newContent) => {
    if (editor.value && newContent !== editorContent.value) {
      editor.value.commands.setContent(newContent);
    }
  }
);

onBeforeUnmount(() => {
  if (editor.value) {
    editor.value.destroy();
  }
});

const insertImage = () => {
  if (!editor) return;
  const url = prompt("请输入图片URL");
  if (url) {
    editor.value.commands.setImage({ src: url });
  }
};

const insertVideo = () => {
  if (!editor.value) return; // 确保 editor 已初始化
  const url = prompt("请输入视频URL");
  if (url) {
    editor.value.commands.setVideo({ src: url });
  }
};
</script>

<style>
.editor-container {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 20px;
  background: #fff;
  border-radius: 8px;
}
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  background: #ffffff;
  padding: 10px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tiptap {
  box-sizing: border-box;
  line-height: 2rem !important;
  padding: 5px;
}
.editor-content {
  background: #ffffff;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  box-sizing: border-box;
  height: calc(100vh - 152px);
  overflow-y: auto;
}
button {
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f9f9f9;
  cursor: pointer;
  transition: background 0.3s;
}
button:hover {
  background: #eee;
}
button.is-active {
  background: #ddd;
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
.editor-content table {
  width: 100%;
  border-collapse: collapse;
}

.editor-content th,
.editor-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.editor-content th {
  background-color: #f2f2f2;
  text-align: left;
}

.my-custom-class {
  width: 90%;
  margin: 0 5%;
}
.editor-container video {
  width: 75%;
  margin: 0 12.5%;
}
</style>