前言
对于一个富文本编辑器, 或者是任何一个文档编辑工具来说, 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
执行撤销操作时
- 首先, 判断typing为true时, 先添加一个undo内容, 并且将typing设置为false(这样在撤销时就是撤销当前输入内容, 而不是撤销到上一次输入).
- 其次, 判断存在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
执行重做操作时
- 判断当前是否不是在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列表逻辑:
- 首先创建出一个level对象, 然后判断新的level和上一个level是否一致(也就是有没有内容变动), 如果是, 则跳过操作.
- 设置
bookmark
, 然后清除undo列表尾部多余内容. - 将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;
}
...
});
自动添加的场景
- 总结一下会自动添加undo data的操作:
- 触发保存事件, 丢失焦点, 拖动完成
- 输入时按下
home
,end
,Insert
,PageUp
,PageDown
,ArrowUp
,ArrowDown
,ArrowLeft
,ArrowRight
这几个键或者ctrl
,meta
键时 - 输入的时候, 触发鼠标点击事件
- 粘贴或者拖动插入的时候
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);
}
});