diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index 4e04872d..6cc48c27 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -1019,6 +1019,18 @@ alt: 按住alt键启动识别 - **类型:** `'default' | 'alt' | ''` + +## containerHighlightAddOnly + +- **详情:** + +是否只有新增组件(从组件列表拖入新组件)时才启用识别容器 + +开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别 + +- **默认值:** `false` + +- **类型:** `boolean` ## stageRect diff --git a/docs/api/stage/coreMethods.md b/docs/api/stage/coreMethods.md index e2884fc2..35d3567d 100644 --- a/docs/api/stage/coreMethods.md +++ b/docs/api/stage/coreMethods.md @@ -69,10 +69,11 @@ ## delayedMarkContainer -- **类型**:`(event: MouseEvent, excludeElList?: Element[]) => NodeJS.Timeout | undefined` +- **类型**:`(event: MouseEvent, excludeElList?: Element[], isAdd?: boolean) => NodeJS.Timeout | undefined` - **参数**: - `event`:鼠标事件 - `excludeElList`:计算鼠标所在容器时要排除的元素列表 + - `isAdd`:当前操作是否为新增组件(从组件列表拖入新组件),`containerHighlightType` 为 `addOnly` 时只有新增才会标记容器 - **详情**: 鼠标拖拽着元素,在容器上方悬停,延迟一段时间后,对容器进行标记,如果悬停时间够长将标记成功,悬停时间短,调用方通过返回的timeoutId取消标记 diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index 19d0ec32..7c838400 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -218,6 +218,7 @@ const stageOptions: StageOptions = { containerHighlightClassName: props.containerHighlightClassName, containerHighlightDuration: props.containerHighlightDuration, containerHighlightType: props.containerHighlightType, + containerHighlightAddOnly: props.containerHighlightAddOnly, disabledDragStart: props.disabledDragStart, renderType: props.renderType, guidesOptions: props.guidesOptions, diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts index 889af2b9..587c457a 100644 --- a/packages/editor/src/editorProps.ts +++ b/packages/editor/src/editorProps.ts @@ -82,6 +82,11 @@ export interface EditorProps { containerHighlightDuration?: number; /** 拖入画布中容器时,识别容器的操作类型 */ containerHighlightType?: ContainerHighlightType; + /** + * 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器, + * 开启后在画布中拖动已有组件不会识别容器,默认 false + */ + containerHighlightAddOnly?: boolean; /** 画布大小 */ stageRect?: StageRect; /** monaco editor 的配置 */ @@ -163,6 +168,7 @@ export const defaultEditorProps = { containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME, containerHighlightDuration: 800, containerHighlightType: ContainerHighlightType.DEFAULT, + containerHighlightAddOnly: false, disabledShowSrc: false, disabledDataSource: false, disabledCodeBlock: false, diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index cf4af955..6f76ad1b 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -28,6 +28,7 @@ export const useStage = (stageOptions: StageOptions) => { containerHighlightClassName: stageOptions.containerHighlightClassName, containerHighlightDuration: stageOptions.containerHighlightDuration, containerHighlightType: stageOptions.containerHighlightType, + containerHighlightAddOnly: stageOptions.containerHighlightAddOnly, disabledDragStart: stageOptions.disabledDragStart, renderType: stageOptions.renderType, canSelect: (el, event, stop) => { diff --git a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue index f5e3e1b3..b33a910f 100644 --- a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue +++ b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue @@ -145,6 +145,6 @@ const dragHandler = (e: DragEvent) => { if (timeout || !stage.value) return; - timeout = stage.value.delayedMarkContainer(e); + timeout = stage.value.delayedMarkContainer(e, [], true); }; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index c90f3aea..95f39fc3 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -178,6 +178,11 @@ export interface StageOptions { containerHighlightClassName?: string; containerHighlightDuration?: number; containerHighlightType?: ContainerHighlightType; + /** + * 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器, + * 开启后在画布中拖动已有组件不会识别容器,默认 false + */ + containerHighlightAddOnly?: boolean; disabledDragStart?: boolean; render?: (stage: StageCore) => HTMLDivElement | void | Promise; moveableOptions?: CustomizeMoveableOptions; diff --git a/packages/editor/tests/unit/editorProps.spec.ts b/packages/editor/tests/unit/editorProps.spec.ts index 274b83bb..6b37ab80 100644 --- a/packages/editor/tests/unit/editorProps.spec.ts +++ b/packages/editor/tests/unit/editorProps.spec.ts @@ -21,6 +21,7 @@ describe('defaultEditorProps', () => { test('containerHighlight 默认值', () => { expect(defaultEditorProps.containerHighlightDuration).toBe(800); expect(typeof defaultEditorProps.containerHighlightClassName).toBe('string'); + expect(defaultEditorProps.containerHighlightAddOnly).toBe(false); }); test('数组/对象工厂函数返回空值', () => { diff --git a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts index bd907e3d..64f8647d 100644 --- a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts @@ -135,6 +135,6 @@ describe('ComponentListPanel', () => { const item = wrapper.find('.component-item'); await item.trigger('drag', { clientX: 0, clientY: 0 }); await item.trigger('drag', { clientX: 0, clientY: 0 }); - expect(stage.delayedMarkContainer).toHaveBeenCalled(); + expect(stage.delayedMarkContainer).toHaveBeenCalledWith(expect.anything(), [], true); }); }); diff --git a/packages/stage/src/ActionManager.ts b/packages/stage/src/ActionManager.ts index f3e93a4d..2b049a58 100644 --- a/packages/stage/src/ActionManager.ts +++ b/packages/stage/src/ActionManager.ts @@ -84,6 +84,8 @@ export default class ActionManager extends EventEmitter { private containerHighlightDuration: number; /** 将组件加入容器的操作方式 */ private containerHighlightType?: ContainerHighlightType; + /** 是否仅在新增组件时才启用将组件加入容器 */ + private containerHighlightAddOnly = false; private isAltKeydown = false; private getTargetElement: GetTargetElement; private getElementsFromPoint: GetElementsFromPoint; @@ -124,6 +126,7 @@ export default class ActionManager extends EventEmitter { this.containerHighlightClassName = config.containerHighlightClassName || CONTAINER_HIGHLIGHT_CLASS_NAME; this.containerHighlightDuration = config.containerHighlightDuration || defaultContainerHighlightDuration; this.containerHighlightType = config.containerHighlightType; + this.containerHighlightAddOnly = config.containerHighlightAddOnly ?? false; this.disabledMultiSelect = config.disabledMultiSelect ?? false; this.alwaysMultiSelect = config.alwaysMultiSelect ?? false; this.getTargetElement = config.getTargetElement; @@ -428,10 +431,15 @@ export default class ActionManager extends EventEmitter { * 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器 * @param event 鼠标事件 * @param excludeElList 计算鼠标所在容器时要排除的元素列表 + * @param isAdd 当前操作是否为新增组件(从组件列表拖入),开启 containerHighlightAddOnly 时只有新增才会标记 * @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记 */ - public delayedMarkContainer(event: MouseEvent, excludeElList: Element[] = []): NodeJS.Timeout | undefined { - if (this.canAddToContainer()) { + public delayedMarkContainer( + event: MouseEvent, + excludeElList: Element[] = [], + isAdd = false, + ): NodeJS.Timeout | undefined { + if (this.canAddToContainer(isAdd)) { return globalThis.setTimeout(() => { this.addContainerHighlightClassName(event, excludeElList); }, this.containerHighlightDuration); @@ -609,9 +617,13 @@ export default class ActionManager extends EventEmitter { } /** - * 当前状态下能否将组件加入容器,默认是鼠标悬停一段时间加入,alt模式则是按住alt+鼠标悬停一段时间加入 + * 当前状态下能否将组件加入容器,默认是鼠标悬停一段时间加入,alt模式则是按住alt+鼠标悬停一段时间加入; + * 开启containerHighlightAddOnly时,画布中拖动已有组件不能加入容器 + * @param isAdd 当前操作是否为新增组件 */ - private canAddToContainer(): boolean { + private canAddToContainer(isAdd = false): boolean { + if (this.containerHighlightAddOnly && !isAdd) return false; + return ( this.containerHighlightType === ContainerHighlightType.DEFAULT || (this.containerHighlightType === ContainerHighlightType.ALT && this.isAltKeydown) @@ -624,10 +636,10 @@ export default class ActionManager extends EventEmitter { */ private markContainerEnd(): HTMLElement | null { const doc = this.getRenderDocument(); - if (doc && this.canAddToContainer()) { - return removeClassNameByClassName(doc, this.containerHighlightClassName); - } - return null; + if (!doc) return null; + + const markedContainer = removeClassNameByClassName(doc, this.containerHighlightClassName); + return this.canAddToContainer() ? markedContainer : null; } private initMouseEvent(): void { diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts index de62d80a..3c9f9a2b 100644 --- a/packages/stage/src/StageCore.ts +++ b/packages/stage/src/StageCore.ts @@ -263,10 +263,15 @@ export default class StageCore extends EventEmitter { * 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器 * @param event 鼠标事件 * @param excludeElList 计算鼠标所在容器时要排除的元素列表 + * @param isAdd 当前操作是否为新增组件(从组件列表拖入),开启 containerHighlightAddOnly 时只有新增才会标记 * @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记 */ - public delayedMarkContainer(event: MouseEvent, excludeElList: Element[] = []): NodeJS.Timeout | undefined { - return this.actionManager?.delayedMarkContainer(event, excludeElList); + public delayedMarkContainer( + event: MouseEvent, + excludeElList: Element[] = [], + isAdd = false, + ): NodeJS.Timeout | undefined { + return this.actionManager?.delayedMarkContainer(event, excludeElList, isAdd); } public getMoveableOption(key: K): MoveableOptions[K] | undefined { @@ -368,6 +373,7 @@ export default class StageCore extends EventEmitter { containerHighlightClassName: config.containerHighlightClassName, containerHighlightDuration: config.containerHighlightDuration, containerHighlightType: config.containerHighlightType, + containerHighlightAddOnly: config.containerHighlightAddOnly, moveableOptions: config.moveableOptions, container: this.mask!.content, disabledDragStart: config.disabledDragStart, diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts index 5c0b50e8..e2ef71ee 100644 --- a/packages/stage/src/types.ts +++ b/packages/stage/src/types.ts @@ -53,7 +53,11 @@ export type GetTargetElement = (id: Id) => HTMLElement | null; /** render提供的接口,通过坐标获得坐标下所有HTML元素数组 */ export type GetElementsFromPoint = (point: Point) => HTMLElement[]; export type GetRenderDocument = () => Document | undefined; -export type DelayedMarkContainer = (event: MouseEvent, exclude: Element[]) => NodeJS.Timeout | undefined; +export type DelayedMarkContainer = ( + event: MouseEvent, + exclude?: Element[], + isAdd?: boolean, +) => NodeJS.Timeout | undefined; export type MarkContainerEnd = () => HTMLElement | null; export type GetRootContainer = () => HTMLDivElement | undefined; @@ -74,6 +78,11 @@ export interface StageCoreConfig { containerHighlightClassName?: string; containerHighlightDuration?: number; containerHighlightType?: ContainerHighlightType; + /** + * 是否仅在新增组件(从组件列表拖入新组件)时才启用将组件拖入容器, + * 开启后在画布中拖动已有组件不会识别容器,默认 false + */ + containerHighlightAddOnly?: boolean; moveableOptions?: CustomizeMoveableOptions; /** runtime 的HTML地址,可以是一个HTTP地址,如果和编辑器不同域,需要设置跨域,也可以是一个相对或绝对路径 */ runtimeUrl?: string; @@ -102,6 +111,8 @@ export interface ActionManagerConfig { containerHighlightClassName?: string; containerHighlightDuration?: number; containerHighlightType?: ContainerHighlightType; + /** 见 StageCoreConfig.containerHighlightAddOnly */ + containerHighlightAddOnly?: boolean; moveableOptions?: CustomizeMoveableOptions; disabledDragStart?: boolean; disabledMultiSelect?: boolean; diff --git a/packages/stage/tests/unit/ActionManager.spec.ts b/packages/stage/tests/unit/ActionManager.spec.ts index 2a680f89..26db04fa 100644 --- a/packages/stage/tests/unit/ActionManager.spec.ts +++ b/packages/stage/tests/unit/ActionManager.spec.ts @@ -275,6 +275,84 @@ describe('ActionManager - 交互与事件', () => { expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true); }); + test('delayedMarkContainer 未配置 containerHighlightType 时不标记容器', () => { + am = new ActionManager(createConfig({ isContainer: async () => true })); + expect(am.delayedMarkContainer(mouseAtOrigin())).toBeUndefined(); + }); + + test('delayedMarkContainer alt 模式下需按住 alt 才标记容器', () => { + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.ALT, + isContainer: async () => true, + }), + ); + expect(am.delayedMarkContainer(mouseAtOrigin())).toBeUndefined(); + (am as any).isAltKeydown = true; + expect(am.delayedMarkContainer(mouseAtOrigin())).toBeDefined(); + }); + + test('containerHighlightAddOnly 开启时拖动已有组件不标记容器', () => { + const containerEl = globalThis.document.createElement('div'); + containerEl.dataset.tmagicId = 'container_1'; + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.DEFAULT, + containerHighlightAddOnly: true, + getElementsFromPoint: () => [containerEl], + isContainer: async () => true, + }), + ); + expect(am.delayedMarkContainer(mouseAtOrigin(), [globalThis.document.createElement('div')])).toBeUndefined(); + vi.advanceTimersByTime(900); + expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(false); + }); + + test('containerHighlightAddOnly 开启时新增组件仍按 containerHighlightType 标记容器', async () => { + const containerEl = globalThis.document.createElement('div'); + containerEl.dataset.tmagicId = 'container_1'; + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.DEFAULT, + containerHighlightAddOnly: true, + getElementsFromPoint: () => [containerEl], + isContainer: async () => true, + }), + ); + expect(am.delayedMarkContainer(mouseAtOrigin(), [], true)).toBeDefined(); + vi.advanceTimersByTime(900); + await Promise.resolve(); + expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true); + }); + + test('containerHighlightAddOnly 开启且 containerHighlightType 为 alt 时新增组件也需按住 alt', () => { + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.ALT, + containerHighlightAddOnly: true, + isContainer: async () => true, + }), + ); + expect(am.delayedMarkContainer(mouseAtOrigin(), [], true)).toBeUndefined(); + (am as any).isAltKeydown = true; + expect(am.delayedMarkContainer(mouseAtOrigin(), [], true)).toBeDefined(); + }); + + test('containerHighlightAddOnly 开启时结束标记仍清理高亮但不返回容器', () => { + const containerEl = globalThis.document.createElement('div'); + containerEl.classList.add('tmagic-stage-container-highlight'); + globalThis.document.body.appendChild(containerEl); + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.DEFAULT, + containerHighlightAddOnly: true, + }), + ); + + expect((am as any).markContainerEnd()).toBeNull(); + expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(false); + }); + test('updateMoveableOptions / getDragStatus 可调用', () => { am = new ActionManager(createConfig()); expect(() => am.updateMoveableOptions()).not.toThrow(); diff --git a/packages/stage/tests/unit/StageCore.spec.ts b/packages/stage/tests/unit/StageCore.spec.ts index 63c2372d..f4f4f03a 100644 --- a/packages/stage/tests/unit/StageCore.spec.ts +++ b/packages/stage/tests/unit/StageCore.spec.ts @@ -181,6 +181,16 @@ describe('StageCore', () => { stage.destroy(); }); + test('delayedMarkContainer 将新增标识透传给 actionManager', async () => { + const { host, stage } = createStage(); + await stage.mount(host); + const spy = vi.spyOn(stage.actionManager!, 'delayedMarkContainer'); + const event = mouseAtOrigin(); + stage.delayedMarkContainer(event, [], true); + expect(spy).toHaveBeenCalledWith(event, [], true); + stage.destroy(); + }); + test('autoScrollIntoView 选中时调用 mask.observerIntersection', async () => { const host = globalThis.document.createElement('div'); globalThis.document.body.appendChild(host);