diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 461f022c..8d95d75d 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 2805630a..1e828521 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -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 { diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index b014cce0..e7a24a0d 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -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', () => {