diff --git a/docs/api/editor/editorServiceEvents.md b/docs/api/editor/editorServiceEvents.md index 15cdfdd1..6dfb02c7 100644 --- a/docs/api/editor/editorServiceEvents.md +++ b/docs/api/editor/editorServiceEvents.md @@ -87,3 +87,19 @@ - **详情:** 历史记录改变,[editorService.redo(),editorService.undo()](./editorServiceMethods.md#undo)后触发 - **事件回调函数:** `(data: MPage | MPageFragment) => void` + +## change + +- **详情:** DSL 发生变更后统一触发,免去分别监听 `add` / `remove` / `update` / `move-layer` / `drag-to`。在 [editorService.add()](./editorServiceMethods.md#add)、[editorService.remove()](./editorServiceMethods.md#remove)、[editorService.update()](./editorServiceMethods.md#update)、[editorService.moveLayer()](./editorServiceMethods.md#movelayer)、[editorService.dragTo()](./editorServiceMethods.md#dragto) 后触发。 + + 回调参数 `event` 通过 `type` 区分操作类型,并携带本次变更的节点列表 `data`,每项包含变更的 `node` 及其所属的 `page`(可能为 `null`)。`move-layer` 额外携带层级偏移 `offset`,`drag-to` 额外携带目标位置 `targetIndex` / `targetParent`。 + + ::: warning 注意 + 撤销 / 重做(`undo` / `redo`)内部同样会经由 `add` / `remove` / `update` 触发本事件;如需区分「用户操作」与「撤销重做」,请配合 [history-change](#history-change) 事件判断。 + ::: + +- **事件回调函数:** `(event: EditorChangeEvent) => void` + + ::: details 查看 EditorChangeEvent 类型定义 + <<< @/../packages/editor/src/type.ts#EditorChangeEvent{ts} + ::: diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index 185cb00e..f960445f 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -85,6 +85,7 @@ class CodeBlock extends BaseService { public async setCodeDsl(codeDsl: CodeBlockDSL): Promise { this.state.codeDsl = codeDsl; this.emit('code-dsl-change', this.state.codeDsl); + this.emit('change', this.state.codeDsl); } /** @@ -199,6 +200,7 @@ class CodeBlock extends BaseService { } this.emit('addOrUpdate', id, codeDsl[id]); + this.emit('change', this.getCodeDsl()); } /** @@ -318,6 +320,8 @@ class CodeBlock extends BaseService { this.emit('remove', id); }); + + this.emit('change', this.getCodeDsl()); } // #region AndGetHistoryId diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 91098ebb..95a282d0 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -147,6 +147,7 @@ class DataSource extends BaseService { } this.emit('add', newConfig); + this.emit('change', this.get('dataSources')); return newConfig; } @@ -192,6 +193,7 @@ class DataSource extends BaseService { oldConfig, changeRecords, }); + this.emit('change', this.get('dataSources')); return newConfig; } @@ -220,6 +222,7 @@ class DataSource extends BaseService { } this.emit('remove', id); + this.emit('change', this.get('dataSources')); } // #region AndGetHistoryId diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 002c4284..5995087b 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -535,6 +535,10 @@ class Editor extends BaseService { } this.emit('add', newNodes); + this.emit('change', { + type: 'add', + data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })), + }); return Array.isArray(addNode) ? newNodes : newNodes[0]; } @@ -635,6 +639,9 @@ class Editor extends BaseService { const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList]; + // 删除后节点已从树中移除,无法再反查所属 page,这里在删除前先逐个捕获用于 `change` 事件 + const changeItems = nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })); + const removedItems: StepDiffItem[] = []; let pageForOp: { name: string; id: Id } | null = null; if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) { @@ -670,6 +677,7 @@ class Editor extends BaseService { } this.emit('remove', nodes); + this.emit('change', { type: 'remove', data: changeItems }); } public async doUpdate( @@ -805,6 +813,10 @@ class Editor extends BaseService { } this.emit('update', updateData); + this.emit('change', { + type: 'update', + data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })), + }); return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode; } @@ -1046,6 +1058,11 @@ class Editor extends BaseService { } this.emit('move-layer', offset); + this.emit('change', { + type: 'move-layer', + data: [{ node, page: this.getPageOfNode(node.id) }], + offset, + }); } /** @@ -1225,6 +1242,12 @@ class Editor extends BaseService { } this.emit('drag-to', { targetIndex, configs, targetParent }); + this.emit('change', { + type: 'drag-to', + data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })), + targetIndex, + targetParent, + }); } // #region AndGetHistoryId @@ -1558,6 +1581,20 @@ class Editor extends BaseService { this.get('modifiedNodeIds').set(id, id); } + /** + * 获取指定节点所属的页面 / 页面片: + * - 普通节点返回其所在的 page; + * - 节点本身就是 page / pageFragment 时返回它自己; + * - 找不到时返回 null。 + * 供 `change` 事件携带「变更节点对应的 page」(而非编辑器当前选中页)。 + */ + private getPageOfNode(id: Id): MPage | MPageFragment | null { + const { node, page } = this.getNodeInfo(id, false); + if (page) return page; + if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment; + return null; + } + private captureSelectionBeforeOp() { if (this.selectionBeforeOp) return; this.selectionBeforeOp = this.get('nodes').map((n) => n.id); diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 45d19285..ab23a260 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -1218,8 +1218,41 @@ export interface EditorEvents { 'move-layer': [offset: number | LayerOffset]; 'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }]; 'history-change': [data: MPage | MPageFragment]; + /** + * DSL 发生变更后统一触发,免去分别监听 add / remove / update / move-layer / drag-to。 + * 回调参数为 {@link EditorChangeEvent},按 `type` 区分操作类型并携带各自的操作内容(payload) + * 以及变更所在的当前 page(可能为 null)。撤销 / 重做内部同样会经由 + * add / remove / update 触发本事件;如需区分「用户操作」与「撤销重做」请配合 `history-change`。 + */ + change: [event: EditorChangeEvent]; } +// #region EditorChangeEvent +/** `change` 事件中单个变更项:变更的 node 及其所属的 page(可能为 null)。 */ +export interface EditorChangeItem { + node: MNode; + page: StoreState['page']; +} + +/** `update` 类型变更项:node 为前后快照及 form 端变更记录,page 为其所属页面。 */ +export interface EditorUpdateChangeItem { + node: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }; + page: StoreState['page']; +} + +/** + * {@link EditorEvents.change} 的回调参数:以 `type` 区分操作类型,并携带对应的操作内容。 + * `data` 为本次变更的节点列表,每项包含 node 及其所属的 page(可能为 null); + * `move-layer` 额外带层级偏移 `offset`,`drag-to` 额外带目标位置 `targetIndex` / `targetParent`。 + */ +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 }; +// #endregion EditorChangeEvent + export interface HistoryEvents { change: [ state: BaseStepValue | StepValue | CodeBlockStepValue | DataSourceStepValue, diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index bab1ecbd..fed3d12d 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -16,16 +16,17 @@ * limitations under the License. */ -import { beforeAll, describe, expect, test } from 'vitest'; +import { beforeAll, describe, expect, test, vi } from 'vitest'; import { cloneDeep } from 'lodash-es'; -import type { MApp, MNode } from '@tmagic/core'; +import type { MApp, MContainer, MNode } from '@tmagic/core'; import { NodeType } from '@tmagic/core'; import { getNodePath } from '@tmagic/utils'; import editorService from '@editor/services/editor'; import historyService from '@editor/services/history'; import storageService from '@editor/services/storage'; +import type { EditorChangeEvent } from '@editor/type'; import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils'; setEditorConfig({ @@ -759,6 +760,105 @@ describe('moveLayer', () => { }); }); +describe('change 事件', () => { + // 取出最后一次 change 事件的回调参数 + const lastChangeEvent = (fn: ReturnType): EditorChangeEvent => + fn.mock.calls[fn.mock.calls.length - 1][0]; + + test('add 触发 change:type 为 add,data 携带新增 node 及其所属 page', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.PAGE_ID); + + const fn = vi.fn(); + editorService.on('change', fn); + const newNode = await editorService.add({ type: 'text' }); + editorService.off('change', fn); + + expect(fn).toHaveBeenCalledTimes(1); + const event = lastChangeEvent(fn); + expect(event.type).toBe('add'); + expect(event.data).toHaveLength(1); + const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id; + expect(event.data[0].node.id).toBe(addedId); + // 新增节点归属于当前页面 + expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID); + }); + + test('remove 触发 change:type 为 remove,data 携带被删 node 及删除前所属 page', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.PAGE_ID); + + const fn = vi.fn(); + editorService.on('change', fn); + await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }); + editorService.off('change', fn); + + const event = lastChangeEvent(fn); + expect(event.type).toBe('remove'); + expect(event.data).toHaveLength(1); + expect(event.data[0].node.id).toBe(NodeId.NODE_ID); + // 删除前捕获,page 仍能正确反查到 + expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID); + }); + + test('update 触发 change:type 为 update,data.node 携带 newNode / oldNode', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.PAGE_ID); + + const fn = vi.fn(); + editorService.on('change', fn); + await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'changed' }); + editorService.off('change', fn); + + const event = lastChangeEvent(fn); + expect(event.type).toBe('update'); + if (event.type === 'update') { + expect(event.data[0].node.newNode.id).toBe(NodeId.NODE_ID); + expect(event.data[0].node.newNode.text).toBe('changed'); + expect(event.data[0].node.oldNode.id).toBe(NodeId.NODE_ID); + expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID); + } + }); + + test('moveLayer 触发 change:type 为 move-layer,并携带 offset', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.NODE_ID); + + const fn = vi.fn(); + editorService.on('change', fn); + await editorService.moveLayer(1); + editorService.off('change', fn); + + const event = lastChangeEvent(fn); + expect(event.type).toBe('move-layer'); + if (event.type === 'move-layer') { + expect(event.offset).toBe(1); + expect(event.data[0].node.id).toBe(NodeId.NODE_ID); + expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID); + } + }); + + test('dragTo 触发 change:type 为 drag-to,并携带 targetIndex / targetParent', 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); + await editorService.dragTo({ id: NodeId.NODE_ID2, type: 'text' }, page, 0); + editorService.off('change', fn); + + const event = lastChangeEvent(fn); + expect(event.type).toBe('drag-to'); + if (event.type === 'drag-to') { + expect(event.targetIndex).toBe(0); + expect(event.targetParent.id).toBe(NodeId.PAGE_ID); + expect(event.data[0].node.id).toBe(NodeId.NODE_ID2); + expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID); + } + }); +}); + describe('undo redo', () => { beforeAll(() => editorService.set('root', cloneDeep(root)));