tinymce源码研究(一)--undomanager

前言

对于一个富文本编辑器, 或者是任何一个文档编辑工具来说, undo/redo都可以说是核心能力之一了, 而我在工作中对于undo/redo的操作积累了一些疑问, 同时对于undo/redo的流程不够熟悉, 想要深入了解一下这里面的工作原理和流程.

以tinymce为例

本次参考源码为tinymce6.6.0

UndoManager

源码位置: modules/tinymce/src/core/main/ts/api/UndoManager.ts

首先查看UndoManager的代码, 可以看到, UndoManager中初始化了beforeBookmark, locks, index三个变量, 在调用beforeChange等函数的时候, 直接调用了Rtc下的同名函数, 并且将locks, beforeBookmark传入到函数中使用.

javascript 复制代码
const UndoManager = (editor: Editor): UndoManager => {
  const beforeBookmark = Singleton.value<Bookmark>();
  const locks: Locks = Cell(0); // 指向在ignore回调中
  const index: Index = Cell(0); // 指向当前的undo列表位置

  const undoManager = {
    data: [], // Gets mutated both internally and externally by plugins like remark, not documented

    /**
     * State if the user is currently typing or not. This will add a typing operation into one undo
     * level instead of one new level for each keystroke.
     *
     * @field {Boolean} typing
     */
    typing: false,

    beforeChange: () => {
      Rtc.beforeChange(editor, locks, beforeBookmark);
    },

    ...
  };

  // 如果还没有注册, 则注册事件
  if (!Rtc.isRtc(editor)) {
    registerEvents(editor, undoManager, locks);
  }

  // Add keyboard shortcuts
  addKeyboardShortcuts(editor);

  return undoManager;
};

RTC

源码位置: modules/tinymce/src/core/main/ts/Rtc.ts

从rtc的代码中, 可以看出调用时会转到Operations下的同名函数上.

javascript 复制代码
const makePlainAdaptor = (editor: Editor): RtcAdaptor => ({
  ...
  undoManager: {
    beforeChange: (locks, beforeBookmark) => Operations.beforeChange(editor, locks, beforeBookmark),
    add: (undoManager, index, locks, beforeBookmark, level, event) =>
      Operations.addUndoLevel(editor, undoManager, index, locks, beforeBookmark, level, event),
    undo: (undoManager, locks, index) => Operations.undo(editor, undoManager, locks, index),
    redo: (index, data) => Operations.redo(editor, index, data),
    clear: (undoManager, index) => Operations.clear(editor, undoManager, index),
    reset: (undoManager) => Operations.reset(undoManager),
    hasUndo: (undoManager, index) => Operations.hasUndo(editor, undoManager, index),
    hasRedo: (undoManager, index) => Operations.hasRedo(undoManager, index),
    transact: (undoManager, locks, callback) => Operations.transact(undoManager, locks, callback),
    ignore: (locks, callback) => Operations.ignore(locks, callback),
    extra: (undoManager, index, callback1, callback2) =>
      Operations.extra(editor, undoManager, index, callback1, callback2)
  },
  ...
});
const getRtcInstanceWithError = (editor: Editor): RtcAdaptor => {
  const rtcInstance = (editor as RtcEditor).rtcInstance;
  if (!rtcInstance) {
    throw new Error('Failed to get RTC instance not yet initialized.');
  } else {
    return rtcInstance;
  }
};

export const beforeChange = (editor: Editor, locks: Locks, beforeBookmark: UndoBookmark): void => {
  getRtcInstanceWithError(editor).undoManager.beforeChange(locks, beforeBookmark);
};

Operations

源码位置: modules/tinymce/src/core/main/ts/undo/Operations.ts

首先观察几个简单函数--从以下代码可以知道, index值指明了当前内容在UndoManager中的位置, 并且用于判断是否存在undo/redo.

javascript 复制代码
export const hasUndo = (editor: Editor, undoManager: UndoManager, index: Index): boolean =>
  index.get() > 0 || (undoManager.typing && undoManager.data[0] && !Levels.isEq(Levels.createFromEditor(editor), undoManager.data[0]));

export const hasRedo = (undoManager: UndoManager, index: Index): boolean =>
  index.get() < undoManager.data.length - 1 && !undoManager.typing;

export const clear = (editor: Editor, undoManager: UndoManager, index: Index): void => {
  undoManager.data = [];
  index.set(0);
  undoManager.typing = false;
  editor.dispatch('ClearUndos');
};

Undo

执行撤销操作时

  1. 首先, 判断typing为true时, 先添加一个undo内容, 并且将typing设置为false(这样在撤销时就是撤销当前输入内容, 而不是撤销到上一次输入).
  2. 其次, 判断存在undo后, 将index指向前一个值, 并且调用Levels.applyToEditor函数将前一个level的内容覆盖到editor, 设置dirty为true
javascript 复制代码
export const undo = (editor: Editor, undoManager: UndoManager, locks: Locks, index: Index): UndoLevel | undefined => {
  let level: UndoLevel | undefined;

  if (undoManager.typing) {
    undoManager.add();
    undoManager.typing = false;
    setTyping(undoManager, false, locks);
  }

  if (index.get() > 0) {
    index.set(index.get() - 1);
    level = undoManager.data[index.get()];
    Levels.applyToEditor(editor, level, true);
    editor.setDirty(true);
    editor.dispatch('Undo', { level });
  }

  return level;
};

Redo

执行重做操作时

  1. 判断当前是否不是在undo队列顶部, 如果不是, 则将index指向下一个值, 并且调用Levels.applyToEditor函数将前一个level的内容覆盖到editor, 设置dirty为true
javascript 复制代码
export const redo = (editor: Editor, index: Index, data: UndoLevel[]): UndoLevel | undefined => {
  let level: UndoLevel | undefined;

  if (index.get() < data.length - 1) {
    index.set(index.get() + 1);
    level = data[index.get()];
    Levels.applyToEditor(editor, level, false);
    editor.setDirty(true);
    editor.dispatch('Redo', { level });
  }

  return level;
};

addUndo

添加undo列表逻辑:

  1. 首先创建出一个level对象, 然后判断新的level和上一个level是否一致(也就是有没有内容变动), 如果是, 则跳过操作.
  2. 设置bookmark, 然后清除undo列表尾部多余内容.
  3. 将level添加到data列表中.
javascript 复制代码
export const addUndoLevel = (
  editor: Editor,
  undoManager: UndoManager,
  index: Index,
  locks: Locks,
  beforeBookmark: UndoBookmark,
  level?: Partial<UndoLevel>,
  event?: Event
): UndoLevel | null => {
  const currentLevel = Levels.createFromEditor(editor);

  const newLevel = Tools.extend(level || {}, currentLevel) as UndoLevel;

  ...

  // Add undo level if needed
  if (lastLevel && Levels.isEq(lastLevel, newLevel)) {
    return null;
  }

  // Set before bookmark on previous level
  if (undoManager.data[index.get()]) {
    beforeBookmark.get().each((bm) => {
      undoManager.data[index.get()].beforeBookmark = bm;
    });
  }


  // Get a non intrusive normalized bookmark
  newLevel.bookmark = GetBookmark.getUndoBookmark(editor.selection);

  // Crop array if needed
  if (index.get() < undoManager.data.length - 1) {
    undoManager.data.length = index.get() + 1;
  }

  undoManager.data.push(newLevel);
  index.set(undoManager.data.length - 1);

  const args = { level: newLevel, lastLevel, originalEvent: event };

  if (index.get() > 0) {
    editor.setDirty(true);
    editor.dispatch('AddUndo', args);
    editor.dispatch('change', args);
  } else {
    editor.dispatch('AddUndo', args);
  }

  return newLevel;
};

Levels

从上面的代码看到, undo的逻辑上判断以data数组存储多个level值, 使用level对象进行存储内容, 同时使用applyToEditor函数来应用到编辑器. 所以还需要研究判断level的代码.

从下面代码可以看到, 在调用applyToEditor时, 直接使用setContent的函数覆盖, 然后判断如果存在bookmark时, 根据bookmark移动光标位置.

javascript 复制代码
const applyToEditor = (editor: Editor, level: UndoLevel, before: boolean): void => {
  const bookmark = before ? level.beforeBookmark : level.bookmark;

  if (level.type === 'fragmented') {
    Fragments.write(level.fragments, editor.getBody());
  } else {
    editor.setContent(level.content, {
      format: 'raw',
      no_selection: Type.isNonNullable(bookmark) && isPathBookmark(bookmark) ? !bookmark.isFakeCaret : true
    });
  }

  if (bookmark) {
    editor.selection.moveToBookmark(bookmark);
    editor.selection.scrollIntoView();
  }
};

最后, 我们还需要知道的是, 编辑器是在什么逻辑下触发自动添加undo列表的.

添加的判断逻辑

tinymce会在输入时, 自动将typing设置为true.

javascript 复制代码
editor.on('keydown', (e) => {
    const keyCode = e.keyCode;
    ...

    // If key isn't Ctrl+Alt/AltGr
    const modKey = (e.ctrlKey && !e.altKey) || e.metaKey;
    if ((keyCode < 16 || keyCode > 20) && keyCode !== 224 && keyCode !== 91 && !undoManager.typing && !modKey) {
      undoManager.beforeChange();
      setTyping(undoManager, true, locks);
      undoManager.add({} as UndoLevel, e);
      ...
      return;
    }

    ...
  });

自动添加的场景

  1. 总结一下会自动添加undo data的操作:
    1. 触发保存事件, 丢失焦点, 拖动完成
    2. 输入时按下home, end, Insert, PageUp, PageDown, ArrowUp, ArrowDown, ArrowLeft, ArrowRight这几个键或者ctrl, meta键时
    3. 输入的时候, 触发鼠标点击事件
    4. 粘贴或者拖动插入的时候
javascript 复制代码
 editor.on('SaveContent ObjectResized blur', addNonTypingUndoLevel);
 editor.on('dragend', addNonTypingUndoLevel);
 editor.on('keyup', (e) => {
    const keyCode = e.keyCode;
    ...
    const isMeta = Env.os.isMacOS() && e.key === 'Meta';

    if ((keyCode >= 33 && keyCode <= 36) || (keyCode >= 37 && keyCode <= 40) || keyCode === 45 || e.ctrlKey || isMeta) {
      addNonTypingUndoLevel();
      editor.nodeChanged();
    }

    ...
  });
    editor.on('keydown', (e) => {
    const keyCode = e.keyCode;
    ...

    // Is character position keys left,right,up,down,home,end,pgdown,pgup,enter
    if ((keyCode >= 33 && keyCode <= 36) || (keyCode >= 37 && keyCode <= 40) || keyCode === 45) {
      if (undoManager.typing) {
        addNonTypingUndoLevel(e);
      }

      return;
    }
    ...
  });
  editor.on('mousedown', (e) => {
    if (undoManager.typing) {
      addNonTypingUndoLevel(e);
    }
  });
  // For detecting when user has replaced text using the browser built-in spell checker or paste/drop events
  editor.on('input', (e) => {
    if (e.inputType && (isInsertReplacementText(e) || isInsertTextDataNull(e) || isInsertFromPasteOrDrop(e))) {
      addNonTypingUndoLevel(e);
    }
  });
相关推荐
xiaofeichaichai1 小时前
Webpack
前端·webpack·node.js
Thecozzy1 小时前
线上 Bug 排查与修复实录
架构
鹏大师运维1 小时前
为什么信创电脑装软件总提示“软件包架构不匹配”?
linux·运维·架构·国产化·麒麟·deb·统信uos
问心无愧05131 小时前
ctf show web入门111
android·前端·笔记
唐某人丶2 小时前
模型越来越强,我们还需要 Agent 工程吗?—— 从价值重估到 Harness 实践
前端·agent·ai编程
智码看视界2 小时前
现代Web开发基础:全栈工程师的起航点
前端·后端·c5全栈
JS菌2 小时前
手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现
前端·人工智能·后端
excel3 小时前
HLS TS 文件损坏的元凶:Git 提交与拉取
前端
Aphasia3113 小时前
https连接传输流程
前端·面试