feat(editor): add unified change events

This commit is contained in:
roymondchen 2026-06-26 19:48:38 +08:00
parent f7afed66aa
commit 7bbb1f24c0
6 changed files with 195 additions and 2 deletions

View File

@ -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}
:::

View File

@ -85,6 +85,7 @@ class CodeBlock extends BaseService {
public async setCodeDsl(codeDsl: CodeBlockDSL): Promise<void> {
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

View File

@ -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

View File

@ -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<MNode>[] = [];
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);

View File

@ -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,

View File

@ -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<typeof vi.fn>): EditorChangeEvent =>
fn.mock.calls[fn.mock.calls.length - 1][0];
test('add 触发 changetype 为 adddata 携带新增 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 触发 changetype 为 removedata 携带被删 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 触发 changetype 为 updatedata.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 触发 changetype 为 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 触发 changetype 为 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)));