From ccfc8c0733b511861fb2d27c23c45640d1da4ed3 Mon Sep 17 00:00:00 2001 From: manmanyu Date: Fri, 30 Oct 2026 08:28:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=E4=BB=8E?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E9=9D=A2=E6=9D=BF=E7=82=B9=E5=87=BB/?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E6=B7=BB=E5=8A=A0=E7=BB=84=E4=BB=B6=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E5=A4=84=E7=90=86=E5=87=BD=E6=95=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/editor/props.md | 68 +++++++++++++++++++ packages/editor/src/Editor.vue | 2 + packages/editor/src/editorProps.ts | 13 ++++ .../layouts/sidebar/ComponentListPanel.vue | 8 ++- .../src/layouts/workspace/viewer/Stage.vue | 5 ++ packages/editor/src/type.ts | 22 ++++++ packages/editor/tests/unit/Editor.spec.ts | 18 +++++ .../sidebar/ComponentListPanel.spec.ts | 24 +++++++ .../layouts/workspace/viewer/Stage.spec.ts | 39 ++++++++++- 9 files changed, 197 insertions(+), 2 deletions(-) diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index 5536d256..20075af2 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -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` + +- **示例:** + +```html + + + +``` + +::: tip +该钩子只作用于组件列表的点击新增,不影响拖入画布、复制、粘贴、页面新增等其它写入路径。 +::: + +## beforeDropComponent + +- **详情:** + + 组件列表拖入画布、落点前的钩子函数 + + 仅当拖拽数据的 `dragType` 为组件列表(`component-list`)时,在位置计算完成、写入节点前执行。入参是即将新增的节点 `dragData` 和父节点 `parent`。允许异步函数;返回 `false` 时取消本次拖入。返回其他值(包括 `true`、`undefined`)则继续新增。 + + 点击组件列表新增不会触发该钩子,点击请使用 [`beforeAppendComponent`](#beforeappendcomponent)。 + +- **默认值:** `undefined` + +- **类型:** `(dragData: AddMNode, parent: MContainer) => boolean | void | Promise` + +- **示例:** + +```html + + + +``` + ## 表单业务上下文 - **详情:** diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index 5509f1f7..1e0db8b8 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -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); diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts index d64c2db5..b89d2d0b 100644 --- a/packages/editor/src/editorProps.ts +++ b/packages/editor/src/editorProps.ts @@ -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; + /** + * 点击组件列表新增组件前的钩子,入参为当前组件列表项,允许异步函数。 + * 仅点击新增时调用,拖入画布不走该钩子;返回 false 取消本次新增。 + * 可直接修改入参(例如补全 data),修改会反映到最终新增的节点上。 + */ + beforeAppendComponent?: BeforeAppendComponent; + /** + * 组件列表拖入画布、写入节点前的钩子,入参为即将新增的节点和父节点,允许异步函数。 + * 仅当拖拽类型为组件列表时,在位置计算完成之后调用;返回 false 取消本次拖入。 + */ + beforeDropComponent?: BeforeDropComponent; /** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */ beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise | boolean | void; /** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */ diff --git a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue index 032c88cb..ce55ac0d 100644 --- a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue +++ b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue @@ -97,7 +97,13 @@ let timeout: ReturnType | undefined; let clientX: number; let clientY: number; -const appendComponent = ({ text, type, data = {} }: ComponentItem): void => { +const appendComponent = async (item: ComponentItem): Promise => { + if (stageOptions?.beforeAppendComponent) { + const result = await stageOptions.beforeAppendComponent(item); + if (result === false) return; + } + + const { text, type, data = {} } = item; editorService.add( { name: text, diff --git a/packages/editor/src/layouts/workspace/viewer/Stage.vue b/packages/editor/src/layouts/workspace/viewer/Stage.vue index 945f010b..602a42fc 100644 --- a/packages/editor/src/layouts/workspace/viewer/Stage.vue +++ b/packages/editor/src/layouts/workspace/viewer/Stage.vue @@ -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' }); } }; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 8aac1968..d4243ec7 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -225,6 +225,16 @@ export interface StageOptions { iframeSandbox?: string | false; /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */ beforeDblclick?: (event: MouseEvent) => Promise | 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; + +/** + * 组件列表拖入画布、写入节点前的钩子。 + * 入参为即将新增的节点和父节点,允许异步;返回 false 时取消本次拖入。 + */ +export type BeforeDropComponent = (dragData: AddMNode, parent: MContainer) => boolean | void | Promise; + // #region ComponentGroup export interface ComponentGroup { /** 显示文案 */ diff --git a/packages/editor/tests/unit/Editor.spec.ts b/packages/editor/tests/unit/Editor.spec.ts index 115c084e..e3d22f83 100644 --- a/packages/editor/tests/unit/Editor.spec.ts +++ b/packages/editor/tests/unit/Editor.spec.ts @@ -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 }); diff --git a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts index 64f8647d..c525799b 100644 --- a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts @@ -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', + }); + }); }); diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts index 8eb14aa3..dd603133 100644 --- a/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts +++ b/packages/editor/tests/unit/layouts/workspace/viewer/Stage.spec.ts @@ -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(); });