feat(editor,stage): 新增 containerHighlightAddOnly 配置

开启后仅新增组件(从组件列表拖入)时才识别容器,画布中拖动已有组件不再被加入其他容器,避免误操作
This commit is contained in:
roymondchen 2026-07-27 17:22:13 +08:00
parent be7b38f97e
commit e6cf694a03
14 changed files with 158 additions and 14 deletions

View File

@ -1019,6 +1019,18 @@ alt: 按住alt键启动识别
- **类型:** `'default' | 'alt' | ''`
## containerHighlightAddOnly
- **详情:**
是否只有新增组件(从组件列表拖入新组件)时才启用识别容器
开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别
- **默认值:** `false`
- **类型:** `boolean`
## stageRect

View File

@ -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取消标记

View File

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

View File

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

View File

@ -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) => {

View File

@ -145,6 +145,6 @@ const dragHandler = (e: DragEvent) => {
if (timeout || !stage.value) return;
timeout = stage.value.delayedMarkContainer(e);
timeout = stage.value.delayedMarkContainer(e, [], true);
};
</script>

View File

@ -178,6 +178,11 @@ export interface StageOptions {
containerHighlightClassName?: string;
containerHighlightDuration?: number;
containerHighlightType?: ContainerHighlightType;
/**
*
* false
*/
containerHighlightAddOnly?: boolean;
disabledDragStart?: boolean;
render?: (stage: StageCore) => HTMLDivElement | void | Promise<HTMLDivElement | void>;
moveableOptions?: CustomizeMoveableOptions;

View File

@ -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('数组/对象工厂函数返回空值', () => {

View File

@ -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);
});
});

View File

@ -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 {
* 12
* @param event
* @param excludeElList
* @param isAdd containerHighlightAddOnly
* @returns timeoutIdtimeout
*/
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 {

View File

@ -263,10 +263,15 @@ export default class StageCore extends EventEmitter {
* 12
* @param event
* @param excludeElList
* @param isAdd containerHighlightAddOnly
* @returns timeoutIdtimeout
*/
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<K extends keyof MoveableOptions>(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,

View File

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

View File

@ -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();

View File

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