mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-01 10:55:49 +00:00
fix(editor): 保留历史操作来源并补发插入事件
This commit is contained in:
parent
0feada8d1f
commit
89689bb9fd
@ -550,6 +550,8 @@ class Editor extends BaseService {
|
|||||||
this.emit('change', {
|
this.emit('change', {
|
||||||
type: 'add',
|
type: 'add',
|
||||||
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
|
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
|
||||||
|
historySource,
|
||||||
|
doNotPushHistory,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
||||||
@ -692,7 +694,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', nodes);
|
this.emit('remove', nodes);
|
||||||
this.emit('change', { type: 'remove', data: changeItems });
|
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async doUpdate(
|
public async doUpdate(
|
||||||
@ -846,6 +848,8 @@ class Editor extends BaseService {
|
|||||||
this.emit('change', {
|
this.emit('change', {
|
||||||
type: 'update',
|
type: 'update',
|
||||||
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
|
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;
|
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
|
||||||
}
|
}
|
||||||
@ -1092,6 +1096,8 @@ class Editor extends BaseService {
|
|||||||
type: 'move-layer',
|
type: 'move-layer',
|
||||||
data: [{ node, page: this.getPageOfNode(node.id) }],
|
data: [{ node, page: this.getPageOfNode(node.id) }],
|
||||||
offset,
|
offset,
|
||||||
|
historySource,
|
||||||
|
doNotPushHistory,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1277,6 +1283,8 @@ class Editor extends BaseService {
|
|||||||
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
|
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
|
||||||
targetIndex,
|
targetIndex,
|
||||||
targetParent,
|
targetParent,
|
||||||
|
historySource,
|
||||||
|
doNotPushHistory,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1878,10 +1886,12 @@ class Editor extends BaseService {
|
|||||||
/**
|
/**
|
||||||
* 应用历史操作(撤销 / 重做)
|
* 应用历史操作(撤销 / 重做)
|
||||||
*
|
*
|
||||||
* 所有 DSL 修改都走 `editor.add / remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
|
* 删除 / 更新类修改走 `editor.remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
|
||||||
* `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底。
|
* `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底;
|
||||||
|
* 「重新插回节点」(撤销 remove / 重做 add)需按 step 记录的 parentId / index 精确还原,
|
||||||
|
* 不走 this.add,由手工 splice + stage.add 完成后补发等价的 add / change 事件(见 emitHistoryInsertEvents)。
|
||||||
*
|
*
|
||||||
* 注意:这些公开方法会发出 add / remove / update 事件,业务侧若需要区分"用户操作"与"撤销重做触发",
|
* 注意:这些路径都会发出 add / remove / update / change 事件,业务侧若需要区分"用户操作"与"撤销重做触发",
|
||||||
* 请监听 `history-change` 事件配合判断。
|
* 请监听 `history-change` 事件配合判断。
|
||||||
*
|
*
|
||||||
* @param step 操作记录
|
* @param step 操作记录
|
||||||
@ -1894,7 +1904,14 @@ class Editor extends BaseService {
|
|||||||
const stage = this.get('stage');
|
const stage = this.get('stage');
|
||||||
if (!root) return;
|
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) {
|
switch (step.opType) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
@ -1910,17 +1927,22 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 重做 add:按记录的 parentId / index 把节点重新插回父容器。
|
// 重做 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 sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
||||||
|
const addedNodes: MNode[] = [];
|
||||||
for (const { newSchema, parentId, index } of sorted) {
|
for (const { newSchema, parentId, index } of sorted) {
|
||||||
if (!newSchema || parentId === undefined) continue;
|
if (!newSchema || parentId === undefined) continue;
|
||||||
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||||
if (parent?.items) {
|
if (parent?.items) {
|
||||||
|
const addedNode = cloneDeep(newSchema);
|
||||||
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
|
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
|
||||||
parent.items.splice(index, 0, cloneDeep(newSchema));
|
parent.items.splice(index, 0, addedNode);
|
||||||
} else {
|
} else {
|
||||||
parent.items.push(cloneDeep(newSchema));
|
parent.items.push(addedNode);
|
||||||
}
|
}
|
||||||
|
addedNodes.push(addedNode);
|
||||||
await stage?.add({
|
await stage?.add({
|
||||||
config: cloneDeep(newSchema),
|
config: cloneDeep(newSchema),
|
||||||
parent: cloneDeep(parent),
|
parent: cloneDeep(parent),
|
||||||
@ -1929,19 +1951,23 @@ class Editor extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.emitHistoryInsertEvents(addedNodes, step.source);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'remove': {
|
case 'remove': {
|
||||||
const items = step.diff ?? [];
|
const items = step.diff ?? [];
|
||||||
if (reverse) {
|
if (reverse) {
|
||||||
// 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
|
// 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移);不走 this.add 的原因同上
|
||||||
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
||||||
|
const addedNodes: MNode[] = [];
|
||||||
for (const { oldSchema, parentId, index } of sorted) {
|
for (const { oldSchema, parentId, index } of sorted) {
|
||||||
if (!oldSchema || parentId === undefined) continue;
|
if (!oldSchema || parentId === undefined) continue;
|
||||||
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||||
if (parent?.items) {
|
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({
|
await stage?.add({
|
||||||
config: cloneDeep(oldSchema),
|
config: cloneDeep(oldSchema),
|
||||||
parent: cloneDeep(parent),
|
parent: cloneDeep(parent),
|
||||||
@ -1950,6 +1976,7 @@ class Editor extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.emitHistoryInsertEvents(addedNodes, step.source);
|
||||||
} else {
|
} else {
|
||||||
// 重做 remove:再删一次
|
// 重做 remove:再删一次
|
||||||
for (const { oldSchema } of items) {
|
for (const { oldSchema } of items) {
|
||||||
@ -1989,7 +2016,7 @@ class Editor extends BaseService {
|
|||||||
return cloneDeep(reverse ? oldNode : newNode);
|
return cloneDeep(reverse ? oldNode : newNode);
|
||||||
});
|
});
|
||||||
if (configs.length) {
|
if (configs.length) {
|
||||||
await this.update(configs, { doNotPushHistory: true });
|
await this.update(configs, { doNotPushHistory: true, historySource: step.source });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -2028,6 +2055,22 @@ class Editor extends BaseService {
|
|||||||
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
|
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
|
||||||
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
|
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;
|
export type EditorService = Editor;
|
||||||
|
|||||||
@ -1338,17 +1338,37 @@ export interface EditorUpdateChangeItem {
|
|||||||
page: StoreState['page'];
|
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` 区分操作类型,并携带对应的操作内容。
|
* {@link EditorEvents.change} 的回调参数:以 `type` 区分操作类型,并携带对应的操作内容。
|
||||||
* `data` 为本次变更的节点列表,每项包含 node 及其所属的 page(可能为 null);
|
* `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 =
|
export type EditorChangeEvent =
|
||||||
| { type: 'add'; data: EditorChangeItem[] }
|
| ({ type: 'add'; data: EditorChangeItem[] } & EditorChangeEventHistoryMeta)
|
||||||
| { type: 'remove'; data: EditorChangeItem[] }
|
| ({ type: 'remove'; data: EditorChangeItem[] } & EditorChangeEventHistoryMeta)
|
||||||
| { type: 'update'; data: EditorUpdateChangeItem[] }
|
| ({ type: 'update'; data: EditorUpdateChangeItem[] } & EditorChangeEventHistoryMeta)
|
||||||
| { type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset }
|
| ({ type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset } & EditorChangeEventHistoryMeta)
|
||||||
| { type: 'drag-to'; data: EditorChangeItem[]; targetIndex: number; targetParent: MContainer };
|
| ({
|
||||||
|
type: 'drag-to';
|
||||||
|
data: EditorChangeItem[];
|
||||||
|
targetIndex: number;
|
||||||
|
targetParent: MContainer;
|
||||||
|
} & EditorChangeEventHistoryMeta);
|
||||||
// #endregion EditorChangeEvent
|
// #endregion EditorChangeEvent
|
||||||
|
|
||||||
export interface HistoryEvents {
|
export interface HistoryEvents {
|
||||||
|
|||||||
@ -857,6 +857,127 @@ describe('change 事件', () => {
|
|||||||
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
|
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', () => {
|
describe('undo redo', () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user