refactor(editor): 历史记录改成记录操作而不是记录副本

This commit is contained in:
roymondchen 2026-03-27 15:27:41 +08:00
parent 42e7ac1b2e
commit 637a5bb69a
6 changed files with 291 additions and 92 deletions

View File

@ -560,7 +560,7 @@ export const initServiceEvents = (
depService.clear(nodes);
};
// 由于历史记录变化是更新整个page所以历史记录变化时,需要重新收集依赖
// 历史记录变化时,需要重新收集依赖
const historyChangeHandler = (page: MPage | MPageFragment) => {
collectIdle([page], true).then(() => {
updateStageNode(page);

View File

@ -43,6 +43,7 @@ import type {
AddMNode,
AsyncHookPlugin,
EditorNodeInfo,
HistoryOpType,
PastePosition,
StepValue,
StoreState,
@ -121,6 +122,7 @@ class Editor extends BaseService {
disabledMultiSelect: false,
});
private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null;
constructor() {
super(
@ -390,6 +392,8 @@ class Editor extends BaseService {
* @returns
*/
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const stage = this.get('stage');
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
@ -435,7 +439,16 @@ class Editor extends BaseService {
}
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
this.pushHistoryState();
this.pushOpHistory('add', {
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
});
}
this.emit('add', newNodes);
@ -498,13 +511,29 @@ class Editor extends BaseService {
* @param {Object} node
*/
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) {
const { parent, node: curNode } = this.getNodeInfo(n.id, false);
if (parent && curNode) {
const idx = getNodeIndex(curNode.id, parent);
removedItems.push({
node: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
}
}
}
await Promise.all(nodes.map((node) => this.doRemove(node)));
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
// 更新历史记录
this.pushHistoryState();
if (removedItems.length > 0) {
this.pushOpHistory('remove', { removedItems });
}
this.emit('remove', nodes);
@ -597,12 +626,23 @@ class Editor extends BaseService {
config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[] } = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
this.pushHistoryState();
const curNodes = this.get('nodes');
if (!this.isHistoryStateChange && curNodes.length) {
this.pushOpHistory('update', {
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
})),
});
}
this.isHistoryStateChange = false;
}
this.emit('update', updateData);
@ -616,6 +656,8 @@ class Editor extends BaseService {
* @returns void
*/
public async sort(id1: Id, id2: Id): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -640,9 +682,6 @@ class Editor extends BaseService {
parentId: parent.id,
root: cloneDeep(root),
});
this.addModifiedNodeId(parent.id);
this.pushHistoryState();
}
/**
@ -789,6 +828,8 @@ class Editor extends BaseService {
* @param offset
*/
public async moveLayer(offset: number | LayerOffset): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -817,6 +858,9 @@ class Editor extends BaseService {
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
return;
}
const oldParent = cloneDeep(toRaw(parent));
brothers.splice(index, 1);
brothers.splice(offsetIndex, 0, node);
@ -829,7 +873,9 @@ class Editor extends BaseService {
});
this.addModifiedNodeId(parent.id);
this.pushHistoryState();
this.pushOpHistory('update', {
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
});
this.emit('move-layer', offset);
}
@ -840,12 +886,17 @@ class Editor extends BaseService {
* @param targetId ID
*/
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
this.captureSelectionBeforeOp();
const root = this.get('root');
const { node, parent } = this.getNodeInfo(config.id, false);
const target = this.getNodeById(targetId, false) as MContainer;
const stage = this.get('stage');
if (root && node && parent && stage) {
const oldSourceParent = cloneDeep(toRaw(parent));
const oldTarget = cloneDeep(toRaw(target));
const index = getNodeIndex(node.id, parent);
parent.items?.splice(index, 1);
@ -876,17 +927,36 @@ class Editor extends BaseService {
this.addModifiedNodeId(target.id);
this.addModifiedNodeId(parent.id);
this.pushHistoryState();
this.pushOpHistory('update', {
updatedItems: [
{ oldNode: oldSourceParent, newNode: cloneDeep(toRaw(parent)) },
{ oldNode: oldTarget, newNode: cloneDeep(toRaw(target)) },
],
});
return newConfig;
}
}
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
this.captureSelectionBeforeOp();
if (!targetParent || !Array.isArray(targetParent.items)) return;
const configs = Array.isArray(config) ? config : [config];
const beforeSnapshots = new Map<string, MNode>();
// 收集所有受影响父节点的变更前快照
for (const cfg of configs) {
const { parent } = this.getNodeInfo(cfg.id, false);
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
}
}
if (!beforeSnapshots.has(`${targetParent.id}`)) {
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
}
const sourceIndicesInTargetParent: number[] = [];
const sourceOutTargetParent: MNode[] = [];
@ -946,28 +1016,39 @@ class Editor extends BaseService {
});
}
this.pushHistoryState();
const updatedItems: { oldNode: MNode; newNode: MNode }[] = [];
for (const oldNode of beforeSnapshots.values()) {
const newNode = this.getNodeById(oldNode.id, false);
if (newNode) {
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
}
}
this.pushOpHistory('update', { updatedItems });
this.emit('drag-to', { targetIndex, configs, targetParent });
}
/**
*
* @returns
* @returns
*/
public async undo(): Promise<StepValue | null> {
const value = historyService.undo();
await this.changeHistoryState(value);
if (value) {
await this.applyHistoryOp(value, true);
}
return value;
}
/**
*
* @returns
* @returns
*/
public async redo(): Promise<StepValue | null> {
const value = historyService.redo();
await this.changeHistoryState(value);
if (value) {
await this.applyHistoryOp(value, false);
}
return value;
}
@ -1068,32 +1149,145 @@ class Editor extends BaseService {
}
}
private pushHistoryState() {
const curNode = cloneDeep(toRaw(this.get('node')));
const page = this.get('page');
if (!this.isHistoryStateChange && curNode && page) {
historyService.push({
data: cloneDeep(toRaw(page)),
modifiedNodeIds: this.get('modifiedNodeIds'),
nodeId: curNode.id,
});
private captureSelectionBeforeOp() {
if (this.isHistoryStateChange || this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
}
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>) {
if (this.isHistoryStateChange) {
this.selectionBeforeOp = null;
return;
}
const step: StepValue = {
opType,
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra,
};
historyService.push(step);
this.selectionBeforeOp = null;
this.isHistoryStateChange = false;
}
private async changeHistoryState(value: StepValue | null) {
if (!value) return;
/**
* /
* @param step
* @param reverse true = false =
*/
private async applyHistoryOp(step: StepValue, reverse: boolean) {
this.isHistoryStateChange = true;
await this.update(value.data);
this.set('modifiedNodeIds', value.modifiedNodeIds);
setTimeout(() => {
if (!value.nodeId) return;
this.select(value.nodeId).then(() => {
this.get('stage')?.select(value.nodeId);
});
}, 0);
this.emit('history-change', value.data);
const root = this.get('root');
const stage = this.get('stage');
if (!root) return;
switch (step.opType) {
case 'add': {
if (reverse) {
for (const node of step.nodes ?? []) {
const parent = this.getNodeById(step.parentId!, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
}
} else {
const parent = this.getNodeById(step.parentId!, false) as MContainer;
if (parent?.items) {
for (const node of step.nodes ?? []) {
const idx = step.indexMap?.[node.id] ?? parent.items.length;
parent.items.splice(idx, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
break;
}
case 'remove': {
if (reverse) {
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = this.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
}
} else {
for (const { node, parentId } of step.removedItems ?? []) {
const parent = this.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
}
}
break;
}
case 'update': {
const items = step.updatedItems ?? [];
for (const { oldNode, newNode } of items) {
const config = reverse ? oldNode : newNode;
if (config.type === NodeType.ROOT) {
this.set('root', cloneDeep(config) as MApp);
continue;
}
const info = this.getNodeInfo(config.id, false);
if (!info.parent) continue;
const idx = getNodeIndex(config.id, info.parent);
if (typeof idx !== 'number' || idx === -1) continue;
info.parent.items![idx] = cloneDeep(config);
if (isPage(config) || isPageFragment(config)) {
this.set('page', config as MPage | MPageFragment);
}
}
const curPage = this.get('page');
if (stage && curPage) {
await stage.update({
config: cloneDeep(toRaw(curPage)),
parentId: root.id,
root: cloneDeep(toRaw(root)),
});
}
break;
}
}
this.set('modifiedNodeIds', step.modifiedNodeIds);
const page = toRaw(this.get('page'));
if (page) {
const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
setTimeout(() => {
if (!selectIds.length) return;
if (selectIds.length > 1) {
this.multiSelect(selectIds);
stage?.multiSelect(selectIds);
} else {
this.select(selectIds[0])
.then(() => stage?.select(selectIds[0]))
.catch(() => {});
}
}, 0);
this.emit('history-change', page as MPage | MPageFragment);
}
this.isHistoryStateChange = false;
}
private async toggleFixedPosition(dist: MNode, src: MNode, root: MApp) {

View File

@ -56,15 +56,7 @@ class History extends BaseService {
this.state.pageId = page.id;
if (!this.state.pageSteps[this.state.pageId]) {
const undoRedo = new UndoRedo<StepValue>();
undoRedo.pushElement({
data: page,
modifiedNodeIds: new Map(),
nodeId: page.id,
});
this.state.pageSteps[this.state.pageId] = undoRedo;
this.state.pageSteps[this.state.pageId] = new UndoRedo<StepValue>();
}
this.setCanUndoRedo();

View File

@ -545,10 +545,25 @@ export interface CodeParamStatement {
[key: string]: any;
}
export type HistoryOpType = 'add' | 'remove' | 'update';
export interface StepValue {
data: MPage | MPageFragment;
opType: HistoryOpType;
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>;
nodeId: Id;
/** opType 'add': 新增的节点 */
nodes?: MNode[];
/** opType 'add': 父节点 ID */
parentId?: Id;
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/** opType 'update': 变更前后的节点快照 */
updatedItems?: { oldNode: MNode; newNode: MNode }[];
}
export interface HistoryState {

View File

@ -23,7 +23,7 @@ export class UndoRedo<T = any> {
private listCursor: number;
private listMaxSize: number;
constructor(listMaxSize = 20) {
constructor(listMaxSize = 100) {
const minListMaxSize = 2;
this.elementList = [];
this.listCursor = 0;
@ -42,29 +42,30 @@ export class UndoRedo<T = any> {
}
public canUndo(): boolean {
return this.listCursor > 1;
return this.listCursor > 0;
}
// 返回undo后的当前元素
/** 返回被撤销的操作 */
public undo(): T | null {
if (!this.canUndo()) {
return null;
}
this.listCursor -= 1;
return this.getCurrentElement();
return cloneDeep(this.elementList[this.listCursor]);
}
public canRedo() {
return this.elementList.length > this.listCursor;
}
// 返回redo后的当前元素
/** 返回被重做的操作 */
public redo(): T | null {
if (!this.canRedo()) {
return null;
}
const element = cloneDeep(this.elementList[this.listCursor]);
this.listCursor += 1;
return this.getCurrentElement();
return element;
}
public getCurrentElement(): T | null {

View File

@ -21,60 +21,66 @@ import { UndoRedo } from '@editor/utils/undo-redo';
describe('undo', () => {
let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo();
undoRedo.pushElement(element);
});
test('can no undo: empty list', () => {
test('can not undo: empty list', () => {
expect(undoRedo.canUndo()).toBe(false);
expect(undoRedo.undo()).toEqual(null);
});
test('can undo', () => {
test('can undo after one push', () => {
undoRedo.pushElement({ a: 1 });
expect(undoRedo.canUndo()).toBe(true);
expect(undoRedo.undo()).toEqual({ a: 1 });
expect(undoRedo.canUndo()).toBe(false);
});
test('can undo returns the operation being undone', () => {
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
expect(undoRedo.canUndo()).toBe(true);
expect(undoRedo.undo()).toEqual(element);
expect(undoRedo.undo()).toEqual({ a: 2 });
expect(undoRedo.canUndo()).toBe(true);
expect(undoRedo.undo()).toEqual({ a: 1 });
expect(undoRedo.canUndo()).toBe(false);
});
});
describe('redo', () => {
let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo();
undoRedo.pushElement(element);
});
test('can no redo: empty list', () => {
test('can not redo: empty list', () => {
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toBe(null);
});
test('can no redo: no undo', () => {
test('can not redo: no undo', () => {
for (let i = 0; i < 5; i++) {
undoRedo.pushElement(element);
undoRedo.pushElement({ a: i });
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toBe(null);
}
});
test('can no redo: undo and push', () => {
undoRedo.pushElement(element);
test('can not redo: undo and push', () => {
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo();
undoRedo.pushElement(element);
undoRedo.pushElement({ a: 3 });
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toEqual(null);
});
test('can no redo: redo end', () => {
const element1 = { a: 1 };
const element2 = { a: 2 };
undoRedo.pushElement(element1);
undoRedo.pushElement(element2);
test('can not redo: redo end', () => {
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo();
undoRedo.undo();
undoRedo.redo();
@ -85,23 +91,20 @@ describe('redo', () => {
});
test('can redo', () => {
const element1 = { a: 1 };
const element2 = { a: 2 };
undoRedo.pushElement(element1);
undoRedo.pushElement(element2);
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo();
undoRedo.undo();
expect(undoRedo.canRedo()).toBe(true);
expect(undoRedo.redo()).toEqual(element1);
expect(undoRedo.redo()).toEqual({ a: 1 });
expect(undoRedo.canRedo()).toBe(true);
expect(undoRedo.redo()).toEqual(element2);
expect(undoRedo.redo()).toEqual({ a: 2 });
});
});
describe('get current element', () => {
let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo();
@ -112,44 +115,38 @@ describe('get current element', () => {
});
test('has element', () => {
undoRedo.pushElement(element);
expect(undoRedo.getCurrentElement()).toEqual(element);
undoRedo.pushElement({ a: 1 });
expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
});
});
describe('list max size', () => {
let undoRedo: UndoRedo;
const listMaxSize = 100;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo(listMaxSize);
undoRedo.pushElement(element);
});
test('reach max size', () => {
for (let i = 0; i < listMaxSize; i++) {
for (let i = 0; i <= listMaxSize; i++) {
undoRedo.pushElement({ a: i });
}
undoRedo.pushElement({ a: listMaxSize }); // 这个元素使得list达到maxSize触发数据删除
expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize });
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.canUndo()).toBe(true);
});
test('reach max size, then undo', () => {
for (let i = 0; i < listMaxSize + 1; i++) {
test('reach max size, then undo all', () => {
for (let i = 0; i <= listMaxSize; i++) {
undoRedo.pushElement({ a: i });
}
for (let i = 0; i < listMaxSize - 1; i++) {
for (let i = 0; i < listMaxSize; i++) {
undoRedo.undo();
}
const ele = undoRedo.getCurrentElement();
undoRedo.undo();
expect(ele?.a).toBe(1); // 经过超过maxSize被删元素之后原本a值为0的第一个元素已经被删除现在第一个元素a值为1
expect(undoRedo.canUndo()).toBe(false);
expect(undoRedo.getCurrentElement()).toEqual(element);
expect(undoRedo.getCurrentElement()).toEqual(null);
});
});