TipTap 表格内嵌富文本编辑器

TipTap 表格内嵌富文本编辑器

一、概述

在编辑场景中,AI 生成的行程回答包含 Markdown 表格,其中「行程说明」列的数据是以 HTML <ul><li> 标签包裹的纯文本。原有方案使用 el-input type="textarea" 编辑该列内容,用户看到的是原始 HTML 标签(如 <li>09:00-10:00 游览故宫</li>),无法直观看到带圆点的列表样式,编辑体验差。

本方案在保持原有 el-table 表格框架不变的前提下,仅将「行程说明」列(col3)替换为基于 TipTap 的轻量级富文本编辑器,实现所见即所得的列表编辑。

二、技术栈

技术/库 版本 用途
Vue 3 ^3.2.0 框架
Element Plus ^2.3.0 表格、弹窗、按钮等 UI 组件
TipTap (@tiptap/vue-3) latest 富文本编辑器核心
@tiptap/starter-kit latest TipTap 基础扩展包(段落、文本、加粗、斜体、列表)
@tiptap/extension-table latest 表格扩展(含 TableRow/TableCell/TableHeader)

TipTap StarterKit 包含的扩展

StarterKit 是一个预设包,集成了常用扩展,无需逐个安装:

  • Document / Paragraph / Text --- 文档基础结构
  • Bold / Italic --- 行内加粗、斜体
  • BulletList / OrderedList / ListItem --- 无序/有序列表
  • History --- 撤销/重做

三、文件清单

文件路径 说明
src/common/htmlToTipTapConverter.js HTML ↔ TipTap JSON 双向转换器
src/components/TipTapCellEditor/index.vue 轻量级 TipTap 单元格编辑器组件
src/views/journey/components/ModifyDialog.vue 行程表格编辑弹窗(主入口)

四、核心实现

4.1 HTML → TipTap JSON 转换器

文件: src/common/htmlToTipTapConverter.js

使用浏览器原生 DOMParser 解析 HTML,递归遍历 DOM 节点树,映射为 TipTap 的 ProseMirror JSON 结构:

复制代码
<ul>                    →  { type: 'bulletList', content: [...] }
  <li>文本</li>          →    { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '文本' }] }] }
<ol>                    →  { type: 'orderedList', content: [...] }
<strong>粗体</strong>    →  { type: 'text', text: '粗体', marks: [{ type: 'bold' }] }
<em>斜体</em>           →  { type: 'text', text: '斜体', marks: [{ type: 'italic' }] }

核心函数:

函数 输入 输出 说明
htmlToTipTapJson(html) HTML 字符串 TipTap JSON 将 HTML 解析为结构化 JSON
tipTapJsonToHtml(json) TipTap JSON HTML 字符串 将 JSON 序列化回 HTML

4.2 TipTap 单元格编辑器组件

文件: src/components/TipTapCellEditor/index.vue

一个精简的 TipTap 编辑器,仅加载必要的扩展:

js 复制代码
import { useEditor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      heading: false,
      blockquote: false,
      codeBlock: false,
      horizontalRule: false,
      hardBreak: false,
      dropcursor: false,
      gapcursor: false
    })
  ],
  content: htmlToTipTapJson(props.modelValue),
  onUpdate: ({ editor: ed }) => {
    const html = tipTapJsonToHtml(ed.getJSON());
    emit('update:modelValue', html);
  }
});

特性:

  • 使用 editorProps.attributes.class 设置 tiptap-cell 类名,确保 :deep(.tiptap-cell) CSS 选择器正确匹配
  • 双向绑定:外部 HTML 变化时自动同步到编辑器,编辑器内容变化时输出 HTML
  • 无需工具栏,用户通过快捷键操作列表(Shift+Cmd+8 无序列表、Shift+Cmd+7 有序列表)

4.3 ModifyDialog 中的集成

文件: src/views/journey/components/ModifyDialog.vue

el-table-column 的模板中,对 col3(行程说明列)做条件渲染:

vue 复制代码
<template #default="{ row }">
  <!-- col3: 行程说明 → 使用 TipTap 富文本编辑器 -->
  <div v-if="colIndex === 3" class="cell-editor-wrapper">
    <TipTapCellEditor v-model="row[`col${colIndex}`]" />
  </div>

  <!-- col0-2: 日期/城市/景点 → 保持原有 el-input -->
  <el-input v-else v-model="row[`col${colIndex}`]" ... />

  <!-- col2: 景点参考按钮 -->
  <el-button v-if="colIndex === 2" @click="handleOpenReference(row)">
    景点参考
  </el-button>
</template>

「景点参考」功能保持不变:

  • 点击按钮从 row.col1(城市列)提取城市信息
  • 弹窗选择景点后,将结果写入 row.col2(景点列)
  • 通过 rebuildMarkdown() 将表格数据重新组装为 Markdown 格式,调用 updateChatMessage 接口保存

五、数据流

复制代码
AI 回复(Markdown 表格)
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│  ModifyDialog.vue                                           │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ parseTables() --- 解析 Markdown 表格为 tableData      │    │
│  │ tableData.rows[].col3 = "<ul><li>...</li></ul>"      │    │
│  └─────────────────────────────────────────────────────┘    │
│                         │                                   │
│                         ▼                                   │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ col0-2: el-input textarea(纯文本编辑)              │    │
│  │ col3: TipTapCellEditor(富文本编辑)                 │    │
│  └─────────────────────────────────────────────────────┘    │
│                         │                                   │
│                         ▼                                   │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ TipTapCellEditor                                    │    │
│  │  htmlToTipTapJson() → TipTap JSON → 渲染 WYSIWYG    │    │
│  │  用户编辑(回车新增列表项、退格删除列表项)          │    │
│  │  onUpdate → tipTapJsonToHtml() → HTML 字符串        │    │
│  └─────────────────────────────────────────────────────┘    │
│                         │                                   │
│                         ▼                                   │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ tableData.rows[].col3 = 新 HTML                      │    │
│  │ rebuildMarkdown() → Markdown 字符串                 │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
updateChatMessage({ recordId, content: markdown })

六、使用效果

编辑前(el-input textarea)

用户看到的原始 HTML 文本:

复制代码
<ul><li>09:00-09:30 抵达深圳,乘车前往【深圳湾公园】</li><li>09:30-11:30 游览【深圳湾公园】,漫步海韵栈道...</li></ul>

编辑后(TipTapCellEditor)

用户看到的是带圆点的无序列表,可直接编辑:

  • 09:00-09:30 抵达深圳,乘车前往深圳湾公园
  • 09:30-11:30 游览深圳湾公园,漫步海韵栈道...

支持的操作:

  • 回车 --- 在当前列表项下方新增一个列表项
  • 退格(空项时) --- 退出列表,变为普通段落
  • Tab --- 列表项缩进(嵌套子列表)
  • Shift+Tab --- 列表项提升层级
  • Cmd/Ctrl+B --- 加粗选中文字
  • Cmd/Ctrl+I --- 斜体选中文字

保存后

编辑器输出的 HTML 与输入格式保持一致:

html 复制代码
<ul>
  <li>09:00-09:30 抵达深圳,乘车前往<strong>深圳湾公园</strong></li>
  <li>09:30-11:30 游览<strong>深圳湾公园</strong>,漫步海韵栈道...</li>
</ul>

rebuildMarkdown() 将表格重新组装为 Markdown 格式,通过 updateChatMessage 接口保存到后端。

七、样式说明

编辑器的列表样式通过 :deep(.tiptap-cell) 穿透作用域设置:

scss 复制代码
:deep(.tiptap-cell) {
  ul { list-style-type: disc; padding-left: 18px; }
  ol { list-style-type: decimal; padding-left: 18px; }
  li { margin: 2px 0; line-height: 1.5; }
  strong { font-weight: 600; }
  em { font-style: italic; }
}

八、未来扩展

扩展方向 方案
为 col3 添加工具栏 TipTapCellEditor 中增加 BubbleMenu 或固定工具栏,提供加粗/斜体/列表切换按钮
全表格使用 TipTap 将整个 el-table 替换为 TipTap Table 扩展,支持表格内嵌套表格、合并单元格等
支持更多 HTML 标签 htmlToTipTapConverter.js 中扩展 domNodeToTipTap,支持 <h1-h6><blockquote><img>
Markdown 表格编辑 复用 tiptapMarkdownConverter.js,将 Markdown 表格整体转为 TipTap 文档进行编辑

十、使用

组件

html 复制代码
<TipTapCellEditor
  v-model="row[`col${colIndex}`]"
 />

组件使用

html 复制代码
<template>
  <div class="tiptap-cell-editor">
    <editor-content :editor="editor" class="editor-content" />
  </div>
</template>

<script setup>
import { watch, onBeforeUnmount } from 'vue';
import { useEditor, EditorContent } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import { htmlToTipTapJson, tipTapJsonToHtml } from '@/common/htmlToTipTapConverter.js';

const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
});

const emit = defineEmits(['update:modelValue']);

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      heading: false,
      blockquote: false,
      codeBlock: false,
      horizontalRule: false,
      hardBreak: false,
      dropcursor: false,
      gapcursor: false
    })
  ],
  editorProps: {
    attributes: {
      class: 'tiptap-cell',
    },
  },
  content: htmlToTipTapJson(props.modelValue),
  onUpdate: ({ editor: ed }) => {
    const html = tipTapJsonToHtml(ed.getJSON());
    emit('update:modelValue', html);
  }
});

// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
  if (!editor.value) return;
  const currentHtml = tipTapJsonToHtml(editor.value.getJSON());
  if (newVal !== currentHtml) {
    editor.value.commands.setContent(htmlToTipTapJson(newVal), false);
  }
});

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

<style lang="scss" scoped>
.tiptap-cell-editor {
  min-height: 60px;
}

.editor-content {
  :deep(.tiptap-cell) {
    outline: none;
    min-height: 60px;
    font-size: 13px;
    line-height: 1.6;

    p {
      margin: 0 0 4px 0;
    }

    ul, ol {
      margin: 0;
      padding-left: 18px;
    }

    ul {
      list-style-type: disc;
    }

    ol {
      list-style-type: decimal;
    }

    li {
      margin: 2px 0;
      line-height: 1.5;
    }

    li > p {
      margin: 0;
    }

    strong {
      font-weight: 600;
    }

    em {
      font-style: italic;
    }
  }
}
</style>

htmlToTipTapConverter.js 包

js 复制代码
/**
 * HTML 与 TipTap JSON 双向转换器
 * 用于表格单元格内 HTML 列表内容的编辑
 */

/**
 * 将 HTML 字符串解析为 TipTap JSON 文档
 * @param {string} html - HTML 字符串
 * @returns {object} TipTap JSON 文档对象
 */
export function htmlToTipTapJson(html) {
  if (!html || !html.trim()) {
    return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] };
  }

  const parser = new DOMParser();
  const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html');
  const root = doc.body.firstChild;

  const content = [];
  if (root) {
    root.childNodes.forEach(node => {
      const nodes = domNodeToTipTap(node);
      if (nodes.length > 0) content.push(...nodes);
    });
  }

  if (content.length === 0) {
    return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }] };
  }

  return { type: 'doc', content };
}

/**
 * 将 DOM 节点转换为 TipTap 节点数组
 * @param {Node} node - DOM 节点
 * @returns {Array} TipTap 节点数组
 */
function domNodeToTipTap(node) {
  if (node.nodeType === Node.TEXT_NODE) {
    const text = node.textContent;
    if (text && text.trim()) {
      return [{ type: 'paragraph', content: [{ type: 'text', text }] }];
    }
    return [];
  }

  if (node.nodeType !== Node.ELEMENT_NODE) return [];

  const tag = node.tagName.toLowerCase();

  switch (tag) {
    case 'ul': {
      const items = [];
      node.childNodes.forEach(child => {
        if (child.nodeType === Node.ELEMENT_NODE && child.tagName.toLowerCase() === 'li') {
          items.push({
            type: 'listItem',
            content: [{ type: 'paragraph', content: extractTextWithMarks(child) }]
          });
        }
      });
      return items.length > 0 ? [{ type: 'bulletList', content: items }] : [];
    }

    case 'ol': {
      const items = [];
      node.childNodes.forEach(child => {
        if (child.nodeType === Node.ELEMENT_NODE && child.tagName.toLowerCase() === 'li') {
          items.push({
            type: 'listItem',
            content: [{ type: 'paragraph', content: extractTextWithMarks(child) }]
          });
        }
      });
      return items.length > 0 ? [{ type: 'orderedList', content: items }] : [];
    }

    case 'li': {
      return [{ type: 'paragraph', content: extractTextWithMarks(node) }];
    }

    case 'p': {
      return [{ type: 'paragraph', content: extractTextWithMarks(node) }];
    }

    case 'strong':
    case 'b':
    case 'em':
    case 'i': {
      return [{ type: 'paragraph', content: extractTextWithMarks(node) }];
    }

    default: {
      const result = [];
      node.childNodes.forEach(child => {
        const nodes = domNodeToTipTap(child);
        result.push(...nodes);
      });
      return result;
    }
  }
}

/**
 * 从 DOM 元素中提取带行内标记的文本节点
 * @param {Element} element - DOM 元素
 * @returns {Array} TipTap text 节点数组
 */
function extractTextWithMarks(element) {
  const result = [];

  function traverse(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent;
      if (text) result.push({ type: 'text', text });
      return;
    }

    if (node.nodeType !== Node.ELEMENT_NODE) return;

    const tag = node.tagName.toLowerCase();
    const marks = [];
    if (tag === 'strong' || tag === 'b') marks.push({ type: 'bold' });
    if (tag === 'em' || tag === 'i') marks.push({ type: 'italic' });

    if (marks.length > 0) {
      const text = node.textContent;
      if (text) result.push({ type: 'text', text, marks });
    } else {
      node.childNodes.forEach(traverse);
    }
  }

  element.childNodes.forEach(traverse);

  if (result.length === 0) {
    return [{ type: 'text', text: '' }];
  }

  return result;
}

/**
 * 将 TipTap JSON 序列化为 HTML 字符串
 * @param {object} tiptapJson - TipTap JSON 文档对象
 * @returns {string} HTML 字符串
 */
export function tipTapJsonToHtml(tiptapJson) {
  if (!tiptapJson || !tiptapJson.content) return '';

  const parts = [];
  for (const node of tiptapJson.content) {
    const html = serializeNodeToHtml(node);
    if (html) parts.push(html);
  }

  return parts.join('');
}

/**
 * 序列化单个 TipTap 节点为 HTML
 * @param {object} node - TipTap 节点
 * @returns {string} HTML 字符串
 */
function serializeNodeToHtml(node) {
  if (!node) return '';

  switch (node.type) {
    case 'doc': {
      return node.content ? node.content.map(c => serializeNodeToHtml(c)).join('') : '';
    }

    case 'paragraph': {
      const inner = node.content ? node.content.map(c => serializeTextToHtml(c)).join('') : '';
      return inner;
    }

    case 'bulletList': {
      const items = node.content ? node.content.map(item => {
        const text = item.content ? item.content.map(c => serializeNodeToHtml(c)).join('') : '';
        return `<li>${text}</li>`;
      }).join('') : '';
      return items ? `<ul>${items}</ul>` : '';
    }

    case 'orderedList': {
      const items = node.content ? node.content.map(item => {
        const text = item.content ? item.content.map(c => serializeNodeToHtml(c)).join('') : '';
        return `<li>${text}</li>`;
      }).join('') : '';
      return items ? `<ol>${items}</ol>` : '';
    }

    case 'listItem': {
      const inner = node.content ? node.content.map(c => serializeNodeToHtml(c)).join('') : '';
      return inner;
    }

    case 'text': {
      return serializeTextToHtml(node);
    }

    default: {
      if (node.content) {
        return node.content.map(c => serializeNodeToHtml(c)).join('');
      }
      return '';
    }
  }
}

/**
 * 序列化 text 节点为 HTML
 * @param {object} node - TipTap text 节点
 * @returns {string} HTML 字符串
 */
function serializeTextToHtml(node) {
  if (node.type !== 'text') return '';
  let text = node.text || '';
  if (node.marks) {
    node.marks.forEach(mark => {
      if (mark.type === 'bold') text = `<strong>${text}</strong>`;
      if (mark.type === 'italic') text = `<em>${text}</em>`;
    });
  }
  return text;
}
相关推荐
diygwcom4 个月前
基于35K star+富文本编辑器框架tiptap实现一个自已报表设计器可行
tiptap
亮子AI5 个月前
【Tiptap】在服务器端使用 Tiptap 内容格式转换
tiptap
亮子AI5 个月前
【Tiptap】Tiptap 和 ProseMirror 选哪个?
tiptap
亮子AI5 个月前
【Tiptap】如何使用 ordered list?
数据结构·list·tiptap
亮子AI5 个月前
【Tiptap】怎样高效存储内容?
tiptap
亮子AI6 个月前
【Tiptap】怎样输入/粘贴 Markdown 到编辑器里?
编辑器·tiptap
亮子AI6 个月前
【Tiptap】如何实现增量更新?
tiptap
HBR666_7 个月前
AI编辑器(FIM补全,AI扩写)简介
前端·ai·编辑器·fim·tiptap
HBR666_7 个月前
AI编辑器(二) ---调用模型的fim功能
前端·ai·编辑器·fim·tiptap