fix(editor): 保留历史操作来源并补发插入事件

This commit is contained in:
roymondchen 2026-07-22 17:17:42 +08:00
parent 0feada8d1f
commit 89689bb9fd
3 changed files with 201 additions and 17 deletions

View File

@ -550,6 +550,8 @@ class Editor extends BaseService {
this.emit('change', {
type: 'add',
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource,
doNotPushHistory,
});
return Array.isArray(addNode) ? newNodes : newNodes[0];
@ -692,7 +694,7 @@ class Editor extends BaseService {
}
this.emit('remove', nodes);
this.emit('change', { type: 'remove', data: changeItems });
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
}
public async doUpdate(
@ -846,6 +848,8 @@ class Editor extends BaseService {
this.emit('change', {
type: 'update',
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
historySource,
doNotPushHistory,
});
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
}
@ -1092,6 +1096,8 @@ class Editor extends BaseService {
type: 'move-layer',
data: [{ node, page: this.getPageOfNode(node.id) }],
offset,
historySource,
doNotPushHistory,
});
}
@ -1277,6 +1283,8 @@ class Editor extends BaseService {
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
targetIndex,
targetParent,
historySource,
doNotPushHistory,
});
}
@ -1878,10 +1886,12 @@ class Editor extends BaseService {
/**
* /
*
* DSL `editor.add / remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage`
* / `editor.remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage`
* remove / add step parentId / index
* this.add splice + stage.add add / change emitHistoryInsertEvents
*
* add / remove / update "用户操作""撤销重做触发"
* add / remove / update / change "用户操作""撤销重做触发"
* `history-change`
*
* @param step
@ -1894,7 +1904,14 @@ class Editor extends BaseService {
const stage = this.get('stage');
if (!root) return;
const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
// 撤销/重做内部复用 add/remove/update透传被应用 step 上记录的 source
// 使其触发的 change 事件同样携带「历史来源」
const commonOpts = {
doNotSelect: true,
doNotSwitchPage: true,
doNotPushHistory: true,
historySource: step.source,
} as const;
switch (step.opType) {
case 'add': {
@ -1910,17 +1927,22 @@ class Editor extends BaseService {
}
} else {
// 重做 add按记录的 parentId / index 把节点重新插回父容器。
// 按目标 index 升序逐个插入,先小后大避免索引漂移
// 按目标 index 升序逐个插入,先小后大避免索引漂移。
// 不走 this.adddoAdd 依赖当前选中节点(无选区时直接抛错),且只能插到选中节点之后 / 末尾,
// 无法按 step 记录的 parentId / index 精确还原,还会重算 style 破坏已记录的终态位置。
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { newSchema, parentId, index } of sorted) {
if (!newSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(newSchema);
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, cloneDeep(newSchema));
parent.items.splice(index, 0, addedNode);
} else {
parent.items.push(cloneDeep(newSchema));
parent.items.push(addedNode);
}
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
@ -1929,19 +1951,23 @@ class Editor extends BaseService {
});
}
}
this.emitHistoryInsertEvents(addedNodes, step.source);
}
break;
}
case 'remove': {
const items = step.diff ?? [];
if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移)
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移);不走 this.add 的原因同上
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
const addedNode = cloneDeep(oldSchema);
parent.items.splice(index ?? parent.items.length, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
@ -1950,6 +1976,7 @@ class Editor extends BaseService {
});
}
}
this.emitHistoryInsertEvents(addedNodes, step.source);
} else {
// 重做 remove再删一次
for (const { oldSchema } of items) {
@ -1989,7 +2016,7 @@ class Editor extends BaseService {
return cloneDeep(reverse ? oldNode : newNode);
});
if (configs.length) {
await this.update(configs, { doNotPushHistory: true });
await this.update(configs, { doNotPushHistory: true, historySource: step.source });
}
break;
}
@ -2028,6 +2055,22 @@ class Editor extends BaseService {
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
}
/**
* remove / add splice step parentId / index this.add
* this.add add / change /
*/
private emitHistoryInsertEvents(nodes: MNode[], source?: HistoryOpSource) {
if (!nodes.length) return;
this.emit('add', nodes);
this.emit('change', {
type: 'add',
data: nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource: source,
// 撤销/重做本身不再写入历史记录
doNotPushHistory: true,
});
}
}
export type EditorService = Editor;

View File

@ -1338,17 +1338,37 @@ export interface EditorUpdateChangeItem {
page: StoreState['page'];
}
/** {@link EditorEvents.change} 各操作类型共有的历史相关字段。 */
export interface EditorChangeEventHistoryMeta {
/**
* DSL {@link HistoryOpOptions.historySource}
* / step `source` undefined
*/
historySource?: HistoryOpSource;
/**
* {@link HistoryOpOptions.doNotPushHistory} false
* / true/
*/
doNotPushHistory?: boolean;
}
/**
* {@link EditorEvents.change} `type`
* `data` node page null
* `move-layer` `offset``drag-to` `targetIndex` / `targetParent`
* `move-layer` `offset``drag-to` `targetIndex` / `targetParent`
* historySource / doNotPushHistory {@link EditorChangeEventHistoryMeta}
*/
export type EditorChangeEvent =
| { type: 'add'; data: EditorChangeItem[] }
| { type: 'remove'; data: EditorChangeItem[] }
| { type: 'update'; data: EditorUpdateChangeItem[] }
| { type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset }
| { type: 'drag-to'; data: EditorChangeItem[]; targetIndex: number; targetParent: MContainer };
| ({ type: 'add'; data: EditorChangeItem[] } & EditorChangeEventHistoryMeta)
| ({ type: 'remove'; data: EditorChangeItem[] } & EditorChangeEventHistoryMeta)
| ({ type: 'update'; data: EditorUpdateChangeItem[] } & EditorChangeEventHistoryMeta)
| ({ type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset } & EditorChangeEventHistoryMeta)
| ({
type: 'drag-to';
data: EditorChangeItem[];
targetIndex: number;
targetParent: MContainer;
} & EditorChangeEventHistoryMeta);
// #endregion EditorChangeEvent
export interface HistoryEvents {

View File

@ -857,6 +857,127 @@ describe('change 事件', () => {
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('change 事件携带历史来源 historySource未传时为 undefined', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const page = editorService.get('page') as unknown as MContainer;
const fn = vi.fn();
editorService.on('change', fn);
// update
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'source' }, { historySource: 'props' });
expect(lastChangeEvent(fn).historySource).toBe('props');
// moveLayer
await editorService.select(NodeId.NODE_ID);
await editorService.moveLayer(1, { historySource: 'stage' });
expect(lastChangeEvent(fn).historySource).toBe('stage');
// dragTo
await editorService.select(NodeId.PAGE_ID);
await editorService.dragTo({ id: NodeId.NODE_ID2, type: 'text' }, page, 0, { historySource: 'stage' });
expect(lastChangeEvent(fn).historySource).toBe('stage');
// add
const newNode = (await editorService.add({ type: 'text' }, undefined, {
historySource: 'component-panel',
})) as MNode;
expect(lastChangeEvent(fn).historySource).toBe('component-panel');
// remove
await editorService.remove(newNode, { historySource: 'shortcut' });
expect(lastChangeEvent(fn).historySource).toBe('shortcut');
// 未传 historySource 时为 undefined
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'no-source' });
expect(lastChangeEvent(fn).historySource).toBeUndefined();
// doNotPushHistory 透传true 表示本次变更未写入历史记录,缺省为 false
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'no-history' }, { doNotPushHistory: true });
expect(lastChangeEvent(fn).doNotPushHistory).toBe(true);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'with-history' });
expect(lastChangeEvent(fn).doNotPushHistory).toBe(false);
editorService.off('change', fn);
});
test('撤销/重做触发的 change 事件携带被应用 step 的 source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'undo-source' }, { historySource: 'props' });
const fn = vi.fn();
editorService.on('change', fn);
await editorService.undo();
expect(lastChangeEvent(fn).historySource).toBe('props');
await editorService.redo();
expect(lastChangeEvent(fn).historySource).toBe('props');
editorService.off('change', fn);
});
test('撤销 remove重新插回节点时触发 changetype 为 add携带 step source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }, { historySource: 'shortcut' });
expect(editorService.getNodeById(NodeId.NODE_ID)).toBeNull();
const fn = vi.fn();
editorService.on('change', fn);
await editorService.undo();
editorService.off('change', fn);
// 节点已按 step 记录的位置插回
expect(editorService.getNodeById(NodeId.NODE_ID)?.id).toBe(NodeId.NODE_ID);
const events = fn.mock.calls.map((call) => call[0]) as EditorChangeEvent[];
const addEvent = events.find((e) => e.type === 'add');
expect(addEvent).toBeTruthy();
expect(addEvent?.historySource).toBe('shortcut');
// 撤销/重做补发的事件不写入历史记录
expect(addEvent?.doNotPushHistory).toBe(true);
if (addEvent?.type === 'add') {
expect(addEvent.data[0].node.id).toBe(NodeId.NODE_ID);
expect(addEvent.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('重做 add重新插入节点时触发 changetype 为 add携带 step source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const newNode = (await editorService.add({ type: 'text' }, undefined, {
historySource: 'component-panel',
})) as MNode;
// 撤销 add使节点从 DSL 中移除
await editorService.undo();
expect(editorService.getNodeById(newNode.id)).toBeNull();
const fn = vi.fn();
editorService.on('change', fn);
await editorService.redo();
editorService.off('change', fn);
// 节点已重新插入
expect(editorService.getNodeById(newNode.id)?.id).toBe(newNode.id);
const events = fn.mock.calls.map((call) => call[0]) as EditorChangeEvent[];
const addEvent = events.find((e) => e.type === 'add');
expect(addEvent).toBeTruthy();
expect(addEvent?.historySource).toBe('component-panel');
// 撤销/重做补发的事件不写入历史记录
expect(addEvent?.doNotPushHistory).toBe(true);
if (addEvent?.type === 'add') {
expect(addEvent.data[0].node.id).toBe(newNode.id);
}
});
});
describe('undo redo', () => {