feat(editor): 新增从组件面板点击/拖动添加组件前的处理函数配置

This commit is contained in:
manmanyu 2026-10-30 08:28:44 +00:00 committed by roymondchen
parent c4cf3b9525
commit ccfc8c0733
9 changed files with 197 additions and 2 deletions

View File

@ -1590,6 +1590,74 @@ const onLayerNodeDblclick = (event, data) => {
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
:::
## beforeAppendComponent
- **详情:**
从组件列表新增组件前的钩子函数
在用户点击组件列表项时,先于写入节点执行。入参是当前组件列表项 `ComponentItem`(见 [`componentGroupList`](#componentgrouplist))。允许异步函数;返回 `false` 时取消本次新增。返回其他值(包括 `true`、`undefined`)则继续新增。
拖入画布不走该钩子,拖入请使用 [`beforeDropComponent`](#beforedropcomponent)。
可以直接修改入参(例如补全 `data`),修改会反映到最终写入的节点上。
- **默认值:** `undefined`
- **类型:** `(item: ComponentItem) => boolean | void | Promise<boolean | void>`
- **示例:**
```html
<template>
<m-editor :before-append-component="beforeAppendComponent"></m-editor>
</template>
<script setup>
const beforeAppendComponent = async (item) => {
if (item.type === 'paid-button') {
const allowed = await checkPermission(item.type);
if (!allowed) return false;
}
item.data = { ...item.data, from: 'component-list' };
};
</script>
```
::: tip
该钩子只作用于组件列表的点击新增,不影响拖入画布、复制、粘贴、页面新增等其它写入路径。
:::
## beforeDropComponent
- **详情:**
组件列表拖入画布、落点前的钩子函数
仅当拖拽数据的 `dragType` 为组件列表(`component-list`)时,在位置计算完成、写入节点前执行。入参是即将新增的节点 `dragData` 和父节点 `parent`。允许异步函数;返回 `false` 时取消本次拖入。返回其他值(包括 `true`、`undefined`)则继续新增。
点击组件列表新增不会触发该钩子,点击请使用 [`beforeAppendComponent`](#beforeappendcomponent)。
- **默认值:** `undefined`
- **类型:** `(dragData: AddMNode, parent: MContainer) => boolean | void | Promise<boolean | void>`
- **示例:**
```html
<template>
<m-editor :before-drop-component="beforeDropComponent"></m-editor>
</template>
<script setup>
const beforeDropComponent = async (dragData, parent) => {
if (parent.type === 'page' && dragData.type === 'paid-button') {
return false;
}
};
</script>
```
## 表单业务上下文
- **详情:**

View File

@ -230,6 +230,8 @@ const stageOptions: StageOptions = {
alwaysMultiSelect: props.alwaysMultiSelect,
disabledFlashTip: props.disabledFlashTip,
beforeDblclick: props.beforeDblclick,
beforeAppendComponent: props.beforeAppendComponent,
beforeDropComponent: props.beforeDropComponent,
};
stageOverlayService.set('stageOptions', stageOptions);

View File

@ -13,6 +13,8 @@ import StageCore, {
import { getIdFromEl } from '@tmagic/utils';
import type {
BeforeAppendComponent,
BeforeDropComponent,
CanDropInFunction,
ComponentGroup,
CustomContentMenuFunction,
@ -160,6 +162,17 @@ export interface EditorProps {
canDropIn?: CanDropInFunction;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
/**
* 点击组件列表新增组件前的钩子,入参为当前组件列表项,允许异步函数。
* 仅点击新增时调用,拖入画布不走该钩子;返回 false 取消本次新增。
* 可直接修改入参(例如补全 data),修改会反映到最终新增的节点上。
*/
beforeAppendComponent?: BeforeAppendComponent;
/**
* 组件列表拖入画布、写入节点前的钩子,入参为即将新增的节点和父节点,允许异步函数。
* 仅当拖拽类型为组件列表时,在位置计算完成之后调用;返回 false 取消本次拖入。
*/
beforeDropComponent?: BeforeDropComponent;
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */

View File

@ -97,7 +97,13 @@ let timeout: ReturnType<typeof setTimeout> | undefined;
let clientX: number;
let clientY: number;
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
const appendComponent = async (item: ComponentItem): Promise<void> => {
if (stageOptions?.beforeAppendComponent) {
const result = await stageOptions.beforeAppendComponent(item);
if (result === false) return;
}
const { text, type, data = {} } = item;
editorService.add(
{
name: text,

View File

@ -450,6 +450,11 @@ const dropHandler = async (e: DragEvent) => {
dragData.inputEvent = e;
if (props.stageOptions.beforeDropComponent) {
const result = await props.stageOptions.beforeDropComponent(dragData, parent);
if (result === false) return;
}
editorService.add(dragData, parent, { historySource: 'component-panel' });
}
};

View File

@ -225,6 +225,16 @@ export interface StageOptions {
iframeSandbox?: string | false;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
/**
* 点击组件列表新增组件前的钩子,详见 EditorProps.beforeAppendComponent。
* 仅点击新增时调用;返回 false 取消新增,支持异步。
*/
beforeAppendComponent?: BeforeAppendComponent;
/**
* 组件列表拖入画布、写入节点前的钩子,详见 EditorProps.beforeDropComponent。
* 仅 dragType 为组件列表时调用;返回 false 取消本次拖入,支持异步。
*/
beforeDropComponent?: BeforeDropComponent;
}
// #region NodeInvalidInfo
@ -678,6 +688,18 @@ export interface ComponentItem {
}
// #endregion ComponentItem
/**
* 点击组件列表新增组件前的钩子。
* 入参为当前组件列表项,允许异步;返回 false 时取消本次新增。
*/
export type BeforeAppendComponent = (item: ComponentItem) => boolean | void | Promise<boolean | void>;
/**
* 组件列表拖入画布、写入节点前的钩子。
* 入参为即将新增的节点和父节点,允许异步;返回 false 时取消本次拖入。
*/
export type BeforeDropComponent = (dragData: AddMNode, parent: MContainer) => boolean | void | Promise<boolean | void>;
// #region ComponentGroup
export interface ComponentGroup {
/** 显示文案 */

View File

@ -211,6 +211,24 @@ describe('Editor', () => {
expect(canDropIn).toHaveBeenLastCalledWith(['s1'], 't1', 'stage-drag');
});
test('beforeDropComponent 透传到 stageOptions', async () => {
const beforeDropComponent = vi.fn();
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
mount(Editor, { props: { beforeDropComponent } as any });
await nextTick();
const stageOptions = stageOverlayMod.default.set.mock.calls.find((c: any[]) => c[0] === 'stageOptions')?.[1];
expect(stageOptions.beforeDropComponent).toBe(beforeDropComponent);
});
test('beforeAppendComponent 透传到 stageOptions', async () => {
const beforeAppendComponent = vi.fn();
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
mount(Editor, { props: { beforeAppendComponent } as any });
await nextTick();
const stageOptions = stageOverlayMod.default.set.mock.calls.find((c: any[]) => c[0] === 'stageOptions')?.[1];
expect(stageOptions.beforeAppendComponent).toBe(beforeAppendComponent);
});
test('未传 canDropIn 时 stageOptions.canDropIn 为 undefined', async () => {
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
mount(Editor, { props: {} as any });

View File

@ -8,6 +8,7 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import ComponentListPanel from '@editor/layouts/sidebar/ComponentListPanel.vue';
import type { ComponentItem } from '@editor/type';
const editorService = {
get: vi.fn(),
@ -137,4 +138,27 @@ describe('ComponentListPanel', () => {
await item.trigger('drag', { clientX: 0, clientY: 0 });
expect(stage.delayedMarkContainer).toHaveBeenCalledWith(expect.anything(), [], true);
});
test('beforeAppendComponent 返回 false 时不新增', async () => {
const beforeAppendComponent = vi.fn(async () => false);
const wrapper = mount(ComponentListPanel, {
global: { provide: { stageOptions: { beforeAppendComponent } } },
});
await wrapper.find('.component-item').trigger('click');
expect(beforeAppendComponent).toHaveBeenCalledWith(expect.objectContaining({ text: '按钮', type: 'button' }));
expect(editorService.add).not.toHaveBeenCalled();
});
test('beforeAppendComponent 异步修改组件后再新增', async () => {
const beforeAppendComponent = vi.fn(async (item: ComponentItem) => {
item.data = { text: 'hello' };
});
const wrapper = mount(ComponentListPanel, {
global: { provide: { stageOptions: { beforeAppendComponent } } },
});
await wrapper.find('.component-item').trigger('click');
expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button', text: 'hello' }, undefined, {
historySource: 'component-panel',
});
});
});

View File

@ -281,13 +281,50 @@ describe('Stage', () => {
});
test('drop 非 COMPONENT_LIST 时不处理', async () => {
const wrapper = mountIt();
const beforeDropComponent = vi.fn();
const wrapper = mountIt({
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', beforeDropComponent },
});
await nextTick();
const event = createDropEvent('{"dragType":"other","data":{"name":"text","style":{}}}');
const stageContainer = wrapper.find('.m-editor-stage-container').element;
startInternalDrag();
stageContainer.dispatchEvent(event);
await waitMacrotask();
expect(beforeDropComponent).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop beforeDropComponent 返回 false 时不新增', async () => {
editorService.getNodeById.mockReturnValue(null);
const beforeDropComponent = vi.fn(async () => false);
const wrapper = mountIt({
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', beforeDropComponent },
});
await nextTick();
const event = createDropEvent(COMPONENT_LIST_JSON);
event.clientX = 10;
event.clientY = 10;
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(event);
await waitMacrotask();
expect(beforeDropComponent).toHaveBeenCalledWith(
expect.objectContaining({ name: 'text', inputEvent: event }),
expect.objectContaining({ id: 'p1' }),
);
expect(editorService.add).not.toHaveBeenCalled();
});
test('drop 缺少 data 时不调用 beforeDropComponent', async () => {
const beforeDropComponent = vi.fn(async () => undefined);
const wrapper = mountIt({
stageOptions: { runtimeUrl: 'http://x', containerHighlightClassName: 'highlight', beforeDropComponent },
});
await nextTick();
startInternalDrag();
wrapper.find('.m-editor-stage-container').element.dispatchEvent(createDropEvent('{"dragType":"component-list"}'));
await waitMacrotask();
expect(beforeDropComponent).not.toHaveBeenCalled();
expect(editorService.add).not.toHaveBeenCalled();
});