mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-24 06:58:13 +00:00
fix(editor): 保留历史操作来源并补发插入事件
This commit is contained in:
parent
0feada8d1f
commit
89689bb9fd
@ -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.add:doAdd 依赖当前选中节点(无选区时直接抛错),且只能插到选中节点之后 / 末尾,
|
||||
// 无法按 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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:重新插回节点时触发 change(type 为 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:重新插入节点时触发 change(type 为 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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user