欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

tinymce 源代码研究(I)--undomanager

最编程 2024-05-05 13:58:56
...

前言

对于一个富文本编辑器, 或者是任何一个文档编辑工具来说, 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传入到函数中使用.

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下的同名函数上.

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.

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
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
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列表中.
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移动光标位置.

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.

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. 粘贴或者拖动插入的时候
 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);
    }
  });