From 637a5bb69af9d6ff54389cd6a5df1973500e5cac Mon Sep 17 00:00:00 2001 From: roymondchen Date: Fri, 27 Mar 2026 15:27:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(editor):=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=94=B9=E6=88=90=E8=AE=B0=E5=BD=95=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E8=AE=B0=E5=BD=95=E5=89=AF=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor/src/initService.ts | 2 +- packages/editor/src/services/editor.ts | 266 +++++++++++++++--- packages/editor/src/services/history.ts | 10 +- packages/editor/src/type.ts | 19 +- packages/editor/src/utils/undo-redo.ts | 13 +- .../editor/tests/unit/utils/undo-redo.spec.ts | 73 +++-- 6 files changed, 291 insertions(+), 92 deletions(-) diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index b841af83..9b1dd464 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -560,7 +560,7 @@ export const initServiceEvents = ( depService.clear(nodes); }; - // 由于历史记录变化是更新整个page,所以历史记录变化时,需要重新收集依赖 + // 历史记录变化时,需要重新收集依赖 const historyChangeHandler = (page: MPage | MPageFragment) => { collectIdle([page], true).then(() => { updateStageNode(page); diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 09d57994..47784167 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + // 收集所有受影响父节点的变更前快照 + 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 { const value = historyService.undo(); - await this.changeHistoryState(value); + if (value) { + await this.applyHistoryOp(value, true); + } return value; } /** * 恢复到下一步 - * @returns 下一步数据 + * @returns 被恢复的操作 */ public async redo(): Promise { 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) { + 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) { diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 47c1c753..fbae55ce 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -56,15 +56,7 @@ class History extends BaseService { this.state.pageId = page.id; if (!this.state.pageSteps[this.state.pageId]) { - const undoRedo = new UndoRedo(); - - 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(); } this.setCanUndoRedo(); diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index b3c7e91b..7a4dc8cc 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -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; - nodeId: Id; + /** opType 'add': 新增的节点 */ + nodes?: MNode[]; + /** opType 'add': 父节点 ID */ + parentId?: Id; + /** opType 'add': 每个新增节点在父节点 items 中的索引 */ + indexMap?: Record; + /** opType 'remove': 被删除的节点及其位置信息 */ + removedItems?: { node: MNode; parentId: Id; index: number }[]; + /** opType 'update': 变更前后的节点快照 */ + updatedItems?: { oldNode: MNode; newNode: MNode }[]; } export interface HistoryState { diff --git a/packages/editor/src/utils/undo-redo.ts b/packages/editor/src/utils/undo-redo.ts index 1542c284..bd49a0cd 100644 --- a/packages/editor/src/utils/undo-redo.ts +++ b/packages/editor/src/utils/undo-redo.ts @@ -23,7 +23,7 @@ export class UndoRedo { 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 { } 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 { diff --git a/packages/editor/tests/unit/utils/undo-redo.spec.ts b/packages/editor/tests/unit/utils/undo-redo.spec.ts index a04e73d2..ea4ec818 100644 --- a/packages/editor/tests/unit/utils/undo-redo.spec.ts +++ b/packages/editor/tests/unit/utils/undo-redo.spec.ts @@ -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); }); });