富文本编辑器知识体系(三)

接续:项目结构 + 自动保存 + 内容展示 + 完整面试答题模板


十一、完整项目结构参考(续)

csharp 复制代码
src/
├── editor/
│   ├── interface.js                 # 编辑器统一接口定义
│   ├── factory.js                   # 工厂函数
│   │
│   ├── adapters/                    # 适配器层
│   │   ├── tiptap-adapter.js
│   │   ├── wangeditor-adapter.js
│   │   ├── slate-adapter.js
│   │   └── quill-adapter.js
│   │
│   ├── extensions/                  # 自定义扩展
│   │   ├── mention/
│   │   │   ├── MentionNode.js       # @提及节点
│   │   │   ├── MentionList.vue      # 候选人列表弹窗
│   │   │   └── suggestion.js        # 触发逻辑
│   │   │
│   │   ├── image/
│   │   │   ├── ImageNode.js         # 图片节点(支持拖拽缩放)
│   │   │   ├── ImageUploadPlugin.js # 粘贴/拖拽上传
│   │   │   └── ImageResizer.vue     # 缩放手柄组件
│   │   │
│   │   ├── video/
│   │   │   ├── VideoNode.js
│   │   │   └── VideoEmbed.vue
│   │   │
│   │   ├── code-block/
│   │   │   ├── CodeBlockNode.js     # 代码块(+语法高亮)
│   │   │   └── LanguageSelect.vue
│   │   │
│   │   ├── table/
│   │   │   ├── TableNode.js
│   │   │   └── TableMenu.vue        # 表格右键菜单
│   │   │
│   │   └── slash-command/
│   │       ├── SlashCommand.js      # / 命令面板
│   │       └── CommandList.vue
│   │
│   ├── plugins/                     # 通用插件
│   │   ├── auto-save.js             # 自动保存
│   │   ├── word-count.js            # 字数统计
│   │   ├── paste-handler.js         # 粘贴处理
│   │   ├── drag-handle.js           # 块级拖拽
│   │   ├── placeholder.js           # 占位符
│   │   └── read-time.js             # 阅读时间估算
│   │
│   ├── toolbar/                     # 工具栏
│   │   ├── Toolbar.vue              # 固定工具栏
│   │   ├── BubbleMenu.vue           # 浮动工具栏(选中文字时出现)
│   │   ├── FloatingMenu.vue         # 空行菜单(新行时出现)
│   │   └── toolbar-config.js        # 工具栏配置
│   │
│   └── collab/                      # 协同编辑
│       ├── CollabProvider.js         # Yjs Provider 封装
│       ├── CursorPlugin.js          # 协同光标
│       ├── AwarenessWidget.vue       # 在线用户显示
│       └── collab-server.js          # 服务端
│
├── components/
│   ├── RichEditor.vue               # 通用编辑器组件(业务组件直接使用)
│   ├── ReadonlyRenderer.vue         # 只读展示组件
│   └── EditorSkeleton.vue           # 加载骨架屏
│
├── services/
│   ├── upload.js                    # 上传服务
│   └── article.js                   # 文章 CRUD 服务
│
├── utils/
│   ├── sanitize.js                  # XSS 过滤
│   ├── html-to-markdown.js          # HTML ↔ Markdown 转换
│   └── delta-diff.js                # 内容差异对比
│
└── styles/
    ├── editor-base.scss             # 编辑器基础样式
    ├── editor-content.scss          # 内容区域样式(编辑和展示共用)
    └── editor-theme.scss            # 主题变量

十二、自动保存完整实现

javascript 复制代码
// plugins/auto-save.js

/**
 * 自动保存插件
 *
 * 功能:
 * 1. 内容变化后自动保存(防抖)
 * 2. 定时保存(兜底)
 * 3. 离开页面前保存
 * 4. 保存失败重试
 * 5. 本地草稿箱(localStorage 兜底)
 * 6. 增量保存(只传变化部分)
 * 7. 保存状态指示
 */

export class AutoSavePlugin {
  constructor(options = {}) {
    this.options = {
      // 防抖延迟(内容变化后多久触发保存)
      debounceDelay: 3000,
      // 定时保存间隔(兜底)
      intervalDelay: 30000,
      // 保存函数(由业务传入)
      saveFn: null,
      // 文档唯一标识
      docId: null,
      // 最大重试次数
      maxRetry: 3,
      // 重试延迟
      retryDelay: 2000,
      // 本地缓存 key 前缀
      cachePrefix: 'editor_draft_',
      // 状态变化回调
      onStatusChange: null,
      ...options,
    };

    // 内部状态
    this.state = {
      status: 'saved', // saved | saving | unsaved | error
      lastSavedAt: null,
      lastContent: null,
      retryCount: 0,
      dirty: false,
    };

    this.debounceTimer = null;
    this.intervalTimer = null;
    this.destroyed = false;
  }

  /**
   * 初始化(在编辑器挂载后调用)
   */
  init(getContent) {
    this.getContent = getContent;
    this.state.lastContent = getContent();

    // 启动定时保存
    this.intervalTimer = setInterval(() => {
      if (this.state.dirty && !this.destroyed) {
        this.save();
      }
    }, this.options.intervalDelay);

    // 页面离开前保存
    this._beforeUnload = (e) => {
      if (this.state.dirty) {
        // 同步保存到 localStorage
        this.saveToLocal();
        // 提示用户
        e.preventDefault();
        e.returnValue = '您有未保存的更改,确定要离开吗?';
        return e.returnValue;
      }
    };
    window.addEventListener('beforeunload', this._beforeUnload);

    // 页面可见性变化时保存(切tab时)
    this._visibilityChange = () => {
      if (document.hidden && this.state.dirty) {
        this.save();
      }
    };
    document.addEventListener('visibilitychange', this._visibilityChange);

    // 检查是否有未恢复的本地草稿
    this.checkLocalDraft();

    return this;
  }

  /**
   * 内容变化时调用(由编辑器的onChange触发)
   */
  onChange() {
    this.state.dirty = true;
    this.updateStatus('unsaved');

    // 防抖保存
    clearTimeout(this.debounceTimer);
    this.debounceTimer = setTimeout(() => {
      this.save();
    }, this.options.debounceDelay);

    // 每次变化都保存到localStorage(同步,快速)
    this.saveToLocal();
  }

  /**
   * 执行保存
   */
  async save() {
    if (this.destroyed) return;
    if (!this.state.dirty) return;
    if (this.state.status === 'saving') return; // 避免重复保存

    const content = this.getContent();

    // 内容没有实际变化
    if (content === this.state.lastContent) {
      this.state.dirty = false;
      this.updateStatus('saved');
      return;
    }

    this.updateStatus('saving');

    try {
      if (typeof this.options.saveFn !== 'function') {
        throw new Error('saveFn is not configured');
      }

      await this.options.saveFn({
        docId: this.options.docId,
        content,
        // 增量信息(可选,后端根据这个做diff存储)
        previousContent: this.state.lastContent,
        timestamp: Date.now(),
      });

      // 保存成功
      this.state.lastContent = content;
      this.state.dirty = false;
      this.state.retryCount = 0;
      this.state.lastSavedAt = new Date();
      this.updateStatus('saved');

      // 清除本地草稿(服务端已保存)
      this.clearLocalDraft();

      console.log(`[AutoSave] 保存成功 ${this.state.lastSavedAt.toLocaleTimeString()}`);
    } catch (error) {
      console.error('[AutoSave] 保存失败:', error);

      // 重试逻辑
      if (this.state.retryCount < this.options.maxRetry) {
        this.state.retryCount++;
        this.updateStatus('error');
        console.log(
          `[AutoSave] ${this.options.retryDelay}ms 后重试 (${this.state.retryCount}/${this.options.maxRetry})`
        );
        setTimeout(() => this.save(), this.options.retryDelay * this.state.retryCount);
      } else {
        this.updateStatus('error');
        // 确保本地有备份
        this.saveToLocal();
        console.error('[AutoSave] 达到最大重试次数,内容已保存到本地');
      }
    }
  }

  /**
   * 保存到 localStorage(兜底方案)
   */
  saveToLocal() {
    try {
      const key = this.options.cachePrefix + this.options.docId;
      const data = {
        content: this.getContent(),
        timestamp: Date.now(),
        docId: this.options.docId,
      };
      localStorage.setItem(key, JSON.stringify(data));
    } catch (e) {
      // localStorage 可能满了
      console.warn('[AutoSave] localStorage 保存失败:', e.message);
      // 尝试清理旧草稿
      this.cleanOldDrafts();
    }
  }

  /**
   * 检查本地草稿
   */
  checkLocalDraft() {
    try {
      const key = this.options.cachePrefix + this.options.docId;
      const stored = localStorage.getItem(key);
      if (!stored) return null;

      const data = JSON.parse(stored);
      const age = Date.now() - data.timestamp;

      // 超过7天的草稿忽略
      if (age > 7 * 24 * 60 * 60 * 1000) {
        localStorage.removeItem(key);
        return null;
      }

      return data;
    } catch {
      return null;
    }
  }

  /**
   * 恢复本地草稿
   */
  recoverLocalDraft() {
    const draft = this.checkLocalDraft();
    if (draft) {
      return draft.content;
    }
    return null;
  }

  /**
   * 清除本地草稿
   */
  clearLocalDraft() {
    const key = this.options.cachePrefix + this.options.docId;
    localStorage.removeItem(key);
  }

  /**
   * 清理旧草稿(localStorage 空间不足时)
   */
  cleanOldDrafts() {
    const prefix = this.options.cachePrefix;
    const keys = [];

    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith(prefix)) {
        try {
          const data = JSON.parse(localStorage.getItem(key));
          keys.push({ key, timestamp: data.timestamp });
        } catch {
          localStorage.removeItem(key); // 损坏的数据直接删
        }
      }
    }

    // 按时间排序,删除最旧的一半
    keys.sort((a, b) => a.timestamp - b.timestamp);
    const deleteCount = Math.ceil(keys.length / 2);
    keys.slice(0, deleteCount).forEach(({ key }) => {
      localStorage.removeItem(key);
    });
  }

  /**
   * 更新状态
   */
  updateStatus(status) {
    this.state.status = status;
    this.options.onStatusChange?.({
      status,
      lastSavedAt: this.state.lastSavedAt,
      retryCount: this.state.retryCount,
    });
  }

  /**
   * 手动保存(用户点击保存按钮)
   */
  async forceSave() {
    this.state.dirty = true;
    clearTimeout(this.debounceTimer);
    await this.save();
  }

  /**
   * 获取当前状态
   */
  getStatus() {
    return { ...this.state };
  }

  /**
   * 销毁
   */
  destroy() {
    this.destroyed = true;

    // 最后保存一次
    if (this.state.dirty) {
      this.saveToLocal();
    }

    clearTimeout(this.debounceTimer);
    clearInterval(this.intervalTimer);
    window.removeEventListener('beforeunload', this._beforeUnload);
    document.removeEventListener('visibilitychange', this._visibilityChange);
  }
}

在 Vue 组件中集成:

vue 复制代码
<template>
  <div class="editor-page">
    <!-- 保存状态指示器 -->
    <div class="save-status">
      <span v-if="saveStatus === 'saved'" class="status-saved">
        ✓ 已保存
        <small v-if="lastSavedAt">{{ formatTime(lastSavedAt) }}</small>
      </span>
      <span v-else-if="saveStatus === 'saving'" class="status-saving">
        <i class="loading-icon" /> 保存中...
      </span>
      <span v-else-if="saveStatus === 'unsaved'" class="status-unsaved">
        ● 未保存
      </span>
      <span v-else-if="saveStatus === 'error'" class="status-error">
        ✗ 保存失败
        <button @click="retrySave">重试</button>
      </span>
    </div>

    <!-- 草稿恢复提示 -->
    <div v-if="hasDraft" class="draft-banner">
      <span>检测到未保存的草稿({{ formatTime(draftTime) }})</span>
      <button @click="recoverDraft">恢复草稿</button>
      <button @click="dismissDraft">忽略</button>
    </div>

    <!-- 编辑器 -->
    <RichEditor
      ref="editor"
      v-model="content"
      @change="onContentChange"
    />

    <!-- 手动保存按钮 -->
    <div class="actions">
      <button @click="handleSave" :disabled="saveStatus === 'saving'">
        {{ saveStatus === 'saving' ? '保存中...' : '保存' }}
      </button>
      <span class="shortcut-hint">Ctrl+S</span>
    </div>
  </div>
</template>

<script>
import RichEditor from '@/components/RichEditor.vue';
import { AutoSavePlugin } from '@/editor/plugins/auto-save';
import { saveArticle } from '@/services/article';

export default {
  components: { RichEditor },

  props: {
    articleId: { type: String, required: true },
  },

  data() {
    return {
      content: '',
      saveStatus: 'saved',
      lastSavedAt: null,
      hasDraft: false,
      draftTime: null,
      autoSave: null,
    };
  },

  async mounted() {
    // 1. 加载文章内容
    await this.loadArticle();

    // 2. 初始化自动保存
    this.autoSave = new AutoSavePlugin({
      docId: this.articleId,
      debounceDelay: 3000,
      intervalDelay: 30000,
      maxRetry: 3,
      saveFn: async ({ docId, content }) => {
        await saveArticle(docId, { content });
      },
      onStatusChange: ({ status, lastSavedAt }) => {
        this.saveStatus = status;
        this.lastSavedAt = lastSavedAt;
      },
    }).init(() => this.content);

    // 3. 检查本地草稿
    const draft = this.autoSave.checkLocalDraft();
    if (draft && draft.timestamp > this.serverTimestamp) {
      this.hasDraft = true;
      this.draftTime = new Date(draft.timestamp);
    }

    // 4. Ctrl+S 快捷键
    this._keydown = (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault();
        this.handleSave();
      }
    };
    document.addEventListener('keydown', this._keydown);
  },

  methods: {
    async loadArticle() {
      const res = await fetch(`/api/articles/${this.articleId}`);
      const data = await res.json();
      this.content = data.content;
      this.serverTimestamp = data.updatedAt;
    },

    onContentChange() {
      this.autoSave?.onChange();
    },

    async handleSave() {
      await this.autoSave?.forceSave();
    },

    retrySave() {
      this.autoSave.state.retryCount = 0;
      this.autoSave.save();
    },

    recoverDraft() {
      const draftContent = this.autoSave.recoverLocalDraft();
      if (draftContent) {
        this.content = draftContent;
        this.hasDraft = false;
      }
    },

    dismissDraft() {
      this.autoSave.clearLocalDraft();
      this.hasDraft = false;
    },

    formatTime(date) {
      if (!date) return '';
      const d = new Date(date);
      return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
    },
  },

  beforeDestroy() {
    this.autoSave?.destroy();
    document.removeEventListener('keydown', this._keydown);
  },
};
</script>

<style scoped>
.save-status {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  font-size: 13px;
}
.status-saved { color: #52c41a; }
.status-saving { color: #1890ff; }
.status-unsaved { color: #faad14; }
.status-error { color: #ff4d4f; }

.loading-icon {
  display: inline-block;
  width: 12px;
  height: 12px;
  border: 2px solid #1890ff;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.draft-banner {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  background: #fffbe6;
  border: 1px solid #ffe58f;
  border-radius: 4px;
  margin: 8px 16px;
  font-size: 14px;
}
</style>

十三、内容安全展示组件

vue 复制代码
<!-- components/ReadonlyRenderer.vue -->
<!-- 用于文章详情页、评论展示等,安全渲染 HTML -->

<template>
  <div
    class="rich-content-renderer"
    :class="[`theme-${theme}`, { 'content-compact': compact }]"
    v-html="sanitizedHtml"
  />
</template>

<script>
import { sanitizeForDisplay } from '@/utils/sanitize';

export default {
  name: 'ReadonlyRenderer',

  props: {
    html: { type: String, default: '' },
    theme: {
      type: String,
      default: 'default', // default | github | notion
    },
    compact: { type: Boolean, default: false },
    // 是否允许图片点击放大
    imagePreview: { type: Boolean, default: true },
    // 是否自动添加锚点
    autoAnchor: { type: Boolean, default: true },
  },

  computed: {
    sanitizedHtml() {
      let html = sanitizeForDisplay(this.html);

      // 处理标题锚点
      if (this.autoAnchor) {
        html = this.addHeadingAnchors(html);
      }

      // 外链处理
      html = this.processLinks(html);

      // 代码块处理
      html = this.processCodeBlocks(html);

      return html;
    },
  },

  mounted() {
    if (this.imagePreview) {
      this.setupImagePreview();
    }
    // 代码块添加复制按钮
    this.setupCodeCopy();
  },

  updated() {
    if (this.imagePreview) {
      this.setupImagePreview();
    }
    this.setupCodeCopy();
  },

  methods: {
    /**
     * 标题添加锚点
     */
    addHeadingAnchors(html) {
      const div = document.createElement('div');
      div.innerHTML = html;
      const headings = div.querySelectorAll('h1,h2,h3,h4,h5,h6');

      headings.forEach((heading, index) => {
        const text = heading.textContent.trim();
        const id = `heading-${index}-${encodeURIComponent(text)}`;
        heading.id = id;
        heading.style.position = 'relative';

        // 添加锚点链接
        const anchor = document.createElement('a');
        anchor.href = `#${id}`;
        anchor.className = 'heading-anchor';
        anchor.textContent = '#';
        anchor.setAttribute('aria-hidden', 'true');
        heading.prepend(anchor);
      });

      return div.innerHTML;
    },

    /**
     * 外链添加安全属性 + 图标
     */
    processLinks(html) {
      const div = document.createElement('div');
      div.innerHTML = html;
      const links = div.querySelectorAll('a[href]');

      links.forEach((link) => {
        const href = link.getAttribute('href');

        // 外链
        if (href && (href.startsWith('http') || href.startsWith('//'))) {
          link.setAttribute('target', '_blank');
          link.setAttribute('rel', 'noopener noreferrer nofollow');
          link.classList.add('external-link');
        }
      });

      return div.innerHTML;
    },

    /**
     * 代码块添加语言标签
     */
    processCodeBlocks(html) {
      const div = document.createElement('div');
      div.innerHTML = html;
      const codeBlocks = div.querySelectorAll('pre > code');

      codeBlocks.forEach((code) => {
        const pre = code.parentElement;
        pre.classList.add('code-block-wrapper');

        // 从 class 中提取语言
        const langClass = Array.from(code.classList).find((c) => c.startsWith('language-'));
        const lang = langClass ? langClass.replace('language-', '') : 'text';

        // 添加语言标签
        const langLabel = document.createElement('span');
        langLabel.className = 'code-lang-label';
        langLabel.textContent = lang;
        pre.insertBefore(langLabel, code);
      });

      return div.innerHTML;
    },

    /**
     * 图片点击预览
     */
    setupImagePreview() {
      const images = this.$el.querySelectorAll('img');
      images.forEach((img) => {
        img.style.cursor = 'zoom-in';
        img.removeEventListener('click', this._imageClickHandler);
        img.addEventListener('click', this._imageClickHandler = () => {
          this.showImagePreview(img.src);
        });
      });
    },

    /**
     * 图片预览弹窗(简化版)
     */
    showImagePreview(src) {
      const overlay = document.createElement('div');
      overlay.style.cssText = `
        position:fixed; top:0; left:0; right:0; bottom:0;
        background:rgba(0,0,0,0.8); display:flex;
        align-items:center; justify-content:center;
        z-index:9999; cursor:zoom-out;
      `;

      const img = document.createElement('img');
      img.src = src;
      img.style.cssText = `
        max-width:90vw; max-height:90vh;
        object-fit:contain; border-radius:4px;
      `;

      overlay.appendChild(img);
      overlay.addEventListener('click', () => overlay.remove());
      document.addEventListener('keydown', function handler(e) {
        if (e.key === 'Escape') {
          overlay.remove();
          document.removeEventListener('keydown', handler);
        }
      });

      document.body.appendChild(overlay);
    },

    /**
     * 代码块复制按钮
     */
    setupCodeCopy() {
      const codeBlocks = this.$el.querySelectorAll('pre');
      codeBlocks.forEach((pre) => {
        if (pre.querySelector('.code-copy-btn')) return; // 已添加

        const btn = document.createElement('button');
        btn.className = 'code-copy-btn';
        btn.textContent = '复制';
        btn.addEventListener('click', async () => {
          const code = pre.querySelector('code');
          const text = code ? code.textContent : pre.textContent;

          try {
            await navigator.clipboard.writeText(text);
            btn.textContent = '已复制 ✓';
            setTimeout(() => {
              btn.textContent = '复制';
            }, 2000);
          } catch {
            // 降级方案
            const textarea = document.createElement('textarea');
            textarea.value = text;
            textarea.style.cssText = 'position:fixed;opacity:0;';
            document.body.appendChild(textarea);
            textarea.select();
            document.execCommand('copy');
            document.body.removeChild(textarea);
            btn.textContent = '已复制 ✓';
            setTimeout(() => {
              btn.textContent = '复制';
            }, 2000);
          }
        });

        pre.style.position = 'relative';
        pre.appendChild(btn);
      });
    },
  },

  beforeDestroy() {
    // 清理事件监听
    const images = this.$el.querySelectorAll('img');
    images.forEach((img) => {
      img.removeEventListener('click', this._imageClickHandler);
    });
  },
};
</script>

<style lang="scss">
.rich-content-renderer {
  font-size: 16px;
  line-height: 1.8;
  color: #333;
  word-wrap: break-word;
  overflow-wrap: break-word;

  // ===== 标题 =====
  h1, h2, h3, h4, h5, h6 {
    margin-top: 1.5em;
    margin-bottom: 0.5em;
    font-weight: 600;
    line-height: 1.4;

    &:first-child { margin-top: 0; }
  }
  h1 { font-size: 1.75em; }
  h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
  h3 { font-size: 1.25em; }

  // 标题锚点
  .heading-anchor {
    position: absolute;
    left: -1.2em;
    color: #1890ff;
    text-decoration: none;
    opacity: 0;
    transition: opacity 0.2s;
    font-weight: normal;
  }
  h1:hover .heading-anchor,
  h2:hover .heading-anchor,
  h3:hover .heading-anchor {
    opacity: 1;
  }

  // ===== 段落 =====
  p {
    margin: 0.8em 0;
    &:first-child { margin-top: 0; }
    &:last-child { margin-bottom: 0; }
  }

  // ===== 列表 =====
  ul, ol {
    padding-left: 1.5em;
    margin: 0.5em 0;
  }
  li {
    margin: 0.25em 0;
  }

  // ===== 引用 =====
  blockquote {
    margin: 1em 0;
    padding: 0.5em 1em;
    border-left: 4px solid #1890ff;
    background: #f6f8fa;
    color: #555;
    border-radius: 0 4px 4px 0;

    p { margin: 0.3em 0; }
  }

  // ===== 代码 =====
  code {
    padding: 0.15em 0.4em;
    background: #f0f0f0;
    border-radius: 3px;
    font-family: 'Fira Code', 'Consolas', monospace;
    font-size: 0.9em;
    color: #d56161;
  }
  pre {
    margin: 1em 0;
    padding: 16px;
    background: #282c34;
    border-radius: 8px;
    overflow-x: auto;
    position: relative;

    code {
      padding: 0;
      background: none;
      color: #abb2bf;
      font-size: 14px;
      line-height: 1.6;
      border-radius: 0;
    }

    // 语言标签
    .code-lang-label {
      position: absolute;
      top: 8px;
      right: 60px;
      font-size: 12px;
      color: #636d83;
      text-transform: uppercase;
      user-select: none;
    }

    // 复制按钮
    .code-copy-btn {
      position: absolute;
      top: 8px;
      right: 8px;
      padding: 2px 10px;
      font-size: 12px;
      color: #abb2bf;
      background: rgba(255, 255, 255, 0.1);
      border: 1px solid rgba(255, 255, 255, 0.15);
      border-radius: 4px;
      cursor: pointer;
      opacity: 0;
      transition: opacity 0.2s, background 0.2s;

      &:hover {
        background: rgba(255, 255, 255, 0.2);
      }
    }

    &:hover .code-copy-btn {
      opacity: 1;
    }
  }

  // ===== 图片 =====
  img {
    max-width: 100%;
    height: auto;
    border-radius: 4px;
    margin: 0.5em 0;
    transition: transform 0.2s;

    &:hover {
      transform: scale(1.01);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    }
  }

  // ===== 表格 =====
  table {
    width: 100%;
    border-collapse: collapse;
    margin: 1em 0;
    font-size: 14px;

    th, td {
      border: 1px solid #ddd;
      padding: 10px 14px;
      text-align: left;
    }

    th {
      background: #f6f8fa;
      font-weight: 600;
      color: #333;
    }

    tr:nth-child(even) {
      background: #fafbfc;
    }

    tr:hover {
      background: #f0f7ff;
    }
  }

  // ===== 链接 =====
  a {
    color: #1890ff;
    text-decoration: none;
    border-bottom: 1px solid transparent;
    transition: border-color 0.2s;

    &:hover {
      border-bottom-color: #1890ff;
    }

    // 外链图标
    &.external-link::after {
      content: '↗';
      font-size: 0.75em;
      margin-left: 2px;
      vertical-align: super;
    }
  }

  // ===== 分割线 =====
  hr {
    border: none;
    border-top: 1px solid #e8e8e8;
    margin: 2em 0;
  }

  // ===== 提及 =====
  .mention-tag {
    color: #1890ff;
    background: #e6f7ff;
    padding: 1px 4px;
    border-radius: 2px;
    cursor: pointer;

    &:hover {
      background: #bae7ff;
    }
  }

  // ===== 删除线 =====
  del, s {
    color: #999;
    text-decoration: line-through;
  }

  // ===== 标记高亮 =====
  mark {
    background: #ffffb8;
    padding: 1px 2px;
    border-radius: 2px;
  }

  // ===== 紧凑模式(用于评论等场景) =====
  &.content-compact {
    font-size: 14px;
    line-height: 1.6;

    h1, h2, h3 { font-size: 1.15em; margin-top: 0.8em; }
    p { margin: 0.4em 0; }
    blockquote { padding: 0.3em 0.8em; margin: 0.5em 0; }
    pre { padding: 12px; margin: 0.5em 0; }
    img { max-height: 400px; object-fit: contain; }
  }

  // ===== GitHub 主题 =====
  &.theme-github {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, sans-serif;
    color: #24292f;

    h2 { border-bottom: 1px solid #d0d7de; }
    blockquote { border-left-color: #d0d7de; color: #656d76; }
    code { background: rgba(175, 184, 193, 0.2); color: inherit; }
  }

  // ===== Notion 主题 =====
  &.theme-notion {
    font-family: 'Segoe UI', Helvetica, sans-serif;
    color: rgb(55, 53, 47);

    h1, h2, h3 { font-weight: 700; }
    blockquote {
      border-left-color: #000;
      background: transparent;
      font-size: 1.1em;
    }
    code { background: rgba(135, 131, 120, 0.15); color: #eb5757; }
  }
}
</style>

十四、目录提取组件

vue 复制代码
<!-- components/TableOfContents.vue -->
<!-- 从编辑器内容中提取标题,生成可点击的目录 -->

<template>
  <nav class="toc" v-if="headings.length > 0">
    <h4 class="toc-title">目录</h4>
    <ul class="toc-list">
      <li
        v-for="(heading, index) in headings"
        :key="index"
        :class="[
          `toc-level-${heading.level}`,
          { active: activeId === heading.id }
        ]"
      >
        <a
          :href="`#${heading.id}`"
          @click.prevent="scrollToHeading(heading.id)"
          :title="heading.text"
        >
          {{ heading.text }}
        </a>
      </li>
    </ul>
  </nav>
</template>

<script>
export default {
  name: 'TableOfContents',

  props: {
    html: { type: String, default: '' },
    // 监听哪个容器的滚动
    scrollContainer: { type: String, default: null },
    // 偏移量(固定头部高度)
    offsetTop: { type: Number, default: 80 },
  },

  data() {
    return {
      headings: [],
      activeId: '',
      observer: null,
    };
  },

  watch: {
    html: {
      immediate: true,
      handler(val) {
        this.$nextTick(() => {
          this.extractHeadings(val);
          this.setupScrollSpy();
        });
      },
    },
  },

  methods: {
    /**
     * 从 HTML 中提取标题
     */
    extractHeadings(html) {
      const div = document.createElement('div');
      div.innerHTML = html;
      const elements = div.querySelectorAll('h1, h2, h3, h4');

      this.headings = Array.from(elements).map((el, index) => {
        const text = el.textContent.trim();
        const level = parseInt(el.tagName[1]);
        const id = el.id || `heading-${index}-${encodeURIComponent(text)}`;

        return { id, text, level };
      });
    },

    /**
     * 滚动到指定标题
     */
    scrollToHeading(id) {
      const el = document.getElementById(id);
      if (!el) return;

      const top = el.getBoundingClientRect().top + window.pageYOffset - this.offsetTop;
      window.scrollTo({ top, behavior: 'smooth' });
      this.activeId = id;

      // 更新 URL hash(不触发跳转)
      history.replaceState(null, '', `#${id}`);
    },

    /**
     * 滚动监听:高亮当前所在标题
     */
    setupScrollSpy() {
      // 清理旧的 observer
      this.observer?.disconnect();

      this.observer = new IntersectionObserver(
        (entries) => {
          // 找到第一个进入视口的标题
          for (const entry of entries) {
            if (entry.isIntersecting) {
              this.activeId = entry.target.id;
              break;
            }
          }
        },
        {
          rootMargin: `-${this.offsetTop}px 0px -70% 0px`,
        }
      );

      // 观察所有标题元素
      this.$nextTick(() => {
        this.headings.forEach(({ id }) => {
          const el = document.getElementById(id);
          if (el) {
            this.observer.observe(el);
          }
        });
      });
    },
  },

  beforeDestroy() {
    this.observer?.disconnect();
  },
};
</script>

<style scoped>
.toc {
  position: sticky;
  top: 80px;
  max-height: calc(100vh - 120px);
  overflow-y: auto;
  padding: 16px;
  border-left: 2px solid #f0f0f0;
}

.toc-title {
  font-size: 14px;
  color: #999;
  margin: 0 0 12px 0;
  text-transform: uppercase;
  letter-spacing: 1px;
}

.toc-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.toc-list li {
  margin: 4px 0;
  transition: all 0.2s;
}

.toc-list li a {
  display: block;
  padding: 4px 8px;
  font-size: 13px;
  color: #666;
  text-decoration: none;
  border-radius: 4px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: all 0.2s;
}

.toc-list li a:hover {
  color: #1890ff;
  background: #f0f7ff;
}

/* 缩进层级 */
.toc-level-1 { padding-left: 0; }
.toc-level-1 a { font-weight: 600; font-size: 14px; }
.toc-level-2 { padding-left: 12px; }
.toc-level-3 { padding-left: 24px; }
.toc-level-3 a { font-size: 12px; }
.toc-level-4 { padding-left: 36px; }
.toc-level-4 a { font-size: 12px; color: #999; }

/* 高亮当前标题 */
.active a {
  color: #1890ff !important;
  font-weight: 600;
  background: #e6f7ff;
  border-left: 3px solid #1890ff;
}
</style>

十五、面试完整答题模板

模板:当被问到"你们项目的富文本编辑器是怎么做的?"

markdown 复制代码
============================================
答题框架(3-5分钟完整回答)
============================================

第一部分:背景和选型(30秒)
──────────────────────────
"我们项目是一个内容管理平台/知识库/博客系统,
核心场景是文章编辑,需要支持基础排版、图片上传、
@提及、代码块等功能。

选型上我们对比了几个方案:
- wangEditor:开箱即用但扩展性不足
- Quill:体积小但大文档性能一般
- Tiptap:基于ProseMirror,扩展性最好
- Slate.js:灵活但上手成本高

最终选了 Tiptap/wangEditor(根据实际情况),
主要考虑:Vue生态适配好、社区活跃、满足功能需求。"


第二部分:架构设计(1分钟)
──────────────────────────
"架构上我做了几个关键设计:

1)适配器模式封装:
定义了统一的编辑器接口(getHTML/setContent/insertImage等),
底层通过适配器对接具体编辑器。
这样如果将来需要换编辑器,只需要新增适配器,
业务组件完全不用改。

2)插件化扩展:
图片上传、@提及、自动保存都做成独立插件,
按需加载,职责分离。

3)工具栏配置化:
工具栏通过配置数组驱动,不同场景(文章编辑/评论/邮件)
传不同配置即可复用同一个编辑器组件。"


第三部分:核心难点(1-2分钟,挑2-3个讲)
──────────────────────────

难点1:图片上传
"图片支持工具栏按钮、粘贴、拖拽三种方式上传。
流程是:前端压缩 → 上传服务(支持进度展示)→ 返回CDN URL → 插入编辑器。
做了并发控制(最多3张同时传)、失败重试(3次)、大图压缩(>1920px缩放)。"

难点2:XSS 安全
"富文本的XSS防护我们做了纵深防御:
- 编辑器层:粘贴时剥离危险标签
- 提交前:DOMPurify 白名单过滤
- 服务端:二次过滤(最关键,不信任前端)
- 展示层:CSP头 + img域名白名单
主要防的攻击向量包括script注入、事件属性注入、
javascript:协议注入等。"

难点3:自动保存
"做了防抖保存(3秒)+ 定时兜底(30秒)+ 
beforeunload 保存 + localStorage 草稿箱。
保存失败自动重试3次,重试失败保证本地有备份。
还做了草稿恢复提示------如果检测到本地有比服务端更新的草稿,
会提示用户选择恢复或忽略。"

难点4(如果问到协同编辑):
"协同编辑用的 Yjs + WebSocket。
Yjs 是 CRDT 方案,每个字符有全局唯一ID,
通过 leftOrigin/rightOrigin 定位,天然支持冲突合并。
相比 OT 方案不需要中心服务器排序,支持离线后合并。
在线用户的光标位置通过 Awareness 协议同步,
做了颜色区分和名字标签展示。"


第四部分:性能优化(30秒)
──────────────────────────
"性能方面做了几个优化:
- 编辑器组件异步加载(减少首屏体积200KB+)
- onChange 防抖(避免高频序列化)
- 图片懒加载(IntersectionObserver)
- 自动保存用增量比较,内容没变不发请求
- 历史记录限制100步 + 连续输入合并"


第五部分:收尾(15秒)
──────────────────────────
"整体这套方案上线后运行稳定,
支撑了日均 XX 篇文章的编辑需求,
用户反馈编辑体验比之前好很多。
如果要继续优化的话,我会考虑:
- Markdown 快捷输入(输入##自动转标题)
- 更完善的表格编辑
- 离线编辑能力"

十六、最后总结

less 复制代码
┌────────────────────────────────────────────────────────┐
│              富文本编辑器知识体系总览                     │
├────────────────────────────────────────────────────────┤
│                                                        │
│  理论基础                                              │
│  ├── contentEditable 原理与局限                        │
│  ├── 文档模型设计(树形 vs 扁平)                      │
│  ├── 选区(Selection/Range)管理                       │
│  ├── 中文输入法(IME Composition)处理                 │
│  └── 操作变换(OT)与 CRDT 冲突解决                   │
│                                                        │
│  工程实践                                              │
│  ├── 编辑器选型(场景驱动决策)                        │
│  ├── 适配器模式封装(可替换底层实现)                  │
│  ├── 插件化架构(图片/提及/@/自动保存)                │
│  ├── 图片上传(压缩/并发控制/粘贴拖拽)               │
│  ├── XSS 纵深防御(前端+后端+CSP)                    │
│  ├── 自动保存(防抖+定时+本地兜底+恢复)              │
│  ├── 安全展示(过滤HTML+代码复制+图片预览)           │
│  └── 性能优化(懒加载/防抖/Worker序列化)             │
│                                                        │
│  面试要点                                              │
│  ├── 能说清楚选型理由和取舍                            │
│  ├── 能画出整体架构图                                  │
│  ├── 能深入 2-3 个技术难点                             │
│  ├── 有性能优化意识                                    │
│  └── 有安全意识(XSS不是说说而已)                    │
│                                                        │
│  加分项                                                │
│  ├── 了解 ProseMirror 文档模型和 Transaction 机制     │
│  ├── 了解 CRDT/OT 原理                                │
│  ├── 了解 Lexical 的双缓冲架构                        │
│  ├── 做过协同编辑                                      │
│  └── 做过编辑器自定义扩展/插件                         │
│                                                        │
└────────────────────────────────────────────────────────┘

以上是完整的富文本编辑器技术方案,涵盖了:

模块 内容
选型 5大编辑器横向对比 + 决策树
架构 适配器模式 + 统一接口 + 工厂函数
封装 Vue通用组件 + v-model双向绑定
图片上传 压缩/并发/粘贴拖拽/进度/重试
XSS安全 DOMPurify白名单 + 后端二次过滤 + CSP
自动保存 防抖+定时+离开保存+本地草稿+恢复提示
内容展示 安全渲染+代码复制+图片预览+目录提取
性能优化 懒加载/防抖/Worker/虚拟滚动
面试 9道高频题 + 完整答题模板
相关推荐
小小小小宇2 小时前
富文本编辑器知识体系(二)
前端
品克缤2 小时前
Trading-Analysis:基于“规则+LLM”的行情分析终端(兼谈 Vibe Coding 实战感)
前端·后端·node.js·vue·express·ai编程·llama
隔壁小邓2 小时前
前端Vue项目打包部署实战教程
前端·javascript·vue.js
TON_G-T2 小时前
javascript中 Iframe 处理多端通信、鉴权
开发语言·前端·javascript
周淳APP2 小时前
【JS之闭包防抖节流,this指向,原型&原型链,数据类型,深浅拷贝】简单梳理啦!
开发语言·前端·javascript·ecmascript
kyriewen2 小时前
console.log 骗了我一整个通宵:原来它才是时间旅行者
前端·javascript·chrome
忆江南2 小时前
# iOS 动态库与静态库全面解析
前端
冴羽3 小时前
在浏览器控制台调试的 6 个秘密技巧
前端·javascript·chrome
青莲8433 小时前
查找算法详解
android·前端