feat(editor): 新增 canDropIn 配置统一控制 layer/stage 拖拽放入行为

支持通过 scene 区分图层树、画布拖动、组件库新增三种场景;
返回 false 阻止放入,返回 Id 可重定向放入目标节点。
layer 场景下若禁用某节点的 inner,其子节点的 before/after 也会被同步禁用以避免被绕过。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-09 16:38:10 +08:00
parent d8133629b4
commit 5af9f6e27a
12 changed files with 277 additions and 16 deletions

View File

@ -793,6 +793,87 @@ const isContainer = (el) =>
</script>
```
## canDropIn
- **详情:**
用于自定义判断当前正在拖动的源是否可以拖入目标节点内部。同时覆盖"组件树拖动"和"画布拖入"两类场景,通过第三个参数 `scene` 区分;返回值有 3 种语义。
**scene 取值:**
| scene | 触发场景 | `sourceIds` | `targetId` |
| --- | --- | --- | --- |
| `'layer'` | "已选组件"面板组件树拖动 | 被拖动节点 id单选时长度为 1 | 目标节点 id |
| `'stage-drag'` | 画布上拖动已有组件 | 被拖动组件 id 列表(多选时为多个) | 候选容器节点 id |
| `'stage-add'` | 从左侧组件列表拖入新组件到画布 | 始终为空数组(尚无 id可仅依据 `targetId` 判断) | 候选容器节点 id |
**返回值语义:**
| 返回值 | layer | stage-drag | stage-add |
| --- | --- | --- | --- |
| `false` | 禁用 inner同时禁用所有"target 子节点的 before/after"(这些位置等价于放入 target避免被绕过 | 阻止该容器被高亮命中 | 取消此次拖入 |
| `Id`string \| number | 将 inner 拖入目标重定向为该 id 对应的节点;与 `false` 一致禁用所有"target 子节点的 before/after" | 高亮命中切换到该 id 对应元素,最终拖入到该节点 | 直接将组件添加到该 id 对应节点layout 坐标也基于其 DOM 重新计算) |
| `true` / `void` / `undefined` | 按原 targetId 正常拖入 | 同左 | 同左 |
`scene``'stage-drag'``'stage-add'` 时该函数会被透传给 `StageCore``canDropIn`,因此直接使用 `@tmagic/stage` 时同样生效
:::tip
- 可通过 `editorService.getNodeById(id, false)` 把 id 还原为 `MNode` 以便基于业务字段(`type``name` 等)做判断。
- 该函数为**同步**调用(拖动事件在浏览器中需要立即响应,不接受异步返回)。
- 重定向到一个不存在或非容器的目标 id 时会被忽略layer/stage-add 场景会取消此次拖入stage-drag 场景不会高亮。
:::
- **默认值:** `undefined`
- **类型:** `(sourceIds: Id[], targetId: Id, scene: 'layer' | 'stage-drag' | 'stage-add') => Id | boolean | void`
- **示例 1禁止某些组件拖入特定容器**
```html
<template>
<m-editor :can-drop-in="canDropIn"></m-editor>
</template>
<script setup>
import { editorService } from '@tmagic/editor';
// 禁止 button 类型的组件被拖入 list 容器内部,组件树拖动与画布拖入均生效
const canDropIn = (sourceIds, targetId, scene) => {
const targetNode = editorService.getNodeById(targetId, false);
if (targetNode?.type !== 'list') return true;
// 从组件列表新增组件时直接放行
if (scene === 'stage-add') return true;
return sourceIds.every((id) => {
const node = editorService.getNodeById(id, false);
return node?.type !== 'button';
});
};
</script>
```
- **示例 2将拖入"卡片外壳"重定向到"卡片内容"内层容器**
```html
<template>
<m-editor :can-drop-in="canDropIn"></m-editor>
</template>
<script setup>
import { editorService } from '@tmagic/editor';
// 当用户拖入到 card 节点时,自动改为放入其 card-content 内层容器
const canDropIn = (sourceIds, targetId) => {
const targetNode = editorService.getNodeById(targetId, false);
if (targetNode?.type !== 'card') return true;
const innerContent = targetNode.items?.find((item) => item.type === 'card-content');
return innerContent?.id ?? true;
};
</script>
```
## containerHighlightClassName
- **详情:**

View File

@ -27,6 +27,7 @@
:indent="treeIndent"
:next-level-indent-increment="treeNextLevelIndentIncrement"
:layer-node-is-expandable="layerNodeIsExpandable"
:can-drop-in="canDropIn"
>
<template #layer-panel-header>
<slot name="layer-panel-header"></slot>
@ -202,6 +203,11 @@ const stageOptions: StageOptions = {
canSelect: props.canSelect,
updateDragEl: props.updateDragEl,
isContainer: props.isContainer,
// sourceIds id
canDropIn: props.canDropIn
? (sourceIds, targetId) =>
props.canDropIn!(sourceIds, targetId, sourceIds.length === 0 ? 'stage-add' : 'stage-drag')
: undefined,
containerHighlightClassName: props.containerHighlightClassName,
containerHighlightDuration: props.containerHighlightDuration,
containerHighlightType: props.containerHighlightType,

View File

@ -11,6 +11,7 @@ import StageCore, {
import { getIdFromEl } from '@tmagic/utils';
import type {
CanDropInFunction,
ComponentGroup,
CustomContentMenuFunction,
DatasourceTypeOption,
@ -101,6 +102,16 @@ export interface EditorProps {
customContentMenu?: CustomContentMenuFunction;
/** 用于自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态) */
layerNodeIsExpandable?: IsExpandableFunction;
/**
*
*
* scene
* - `'layer'` "已选组件" false inner before/after
* - `'stage'` false "组件列表拖入新组件""画布上拖动已有组件"
*
* layer Promise true
*/
canDropIn?: CanDropInFunction;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;

View File

@ -24,6 +24,7 @@ export const useStage = (stageOptions: StageOptions) => {
zoom: stageOptions.zoom ?? zoom.value,
autoScrollIntoView: stageOptions.autoScrollIntoView,
isContainer: stageOptions.isContainer,
canDropIn: stageOptions.canDropIn,
containerHighlightClassName: stageOptions.containerHighlightClassName,
containerHighlightDuration: stageOptions.containerHighlightDuration,
containerHighlightType: stageOptions.containerHighlightType,

View File

@ -162,6 +162,7 @@ import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height'
import { useFloatBox } from '@editor/hooks/use-float-box';
import { useServices } from '@editor/hooks/use-services';
import {
type CanDropInFunction,
ColumnLayout,
CustomContentMenuFunction,
type IsExpandableFunction,
@ -194,6 +195,8 @@ const props = withDefaults(
customContentMenu: CustomContentMenuFunction;
/** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
layerNodeIsExpandable?: IsExpandableFunction;
/** 自定义判断当前正在拖动的源是否可以拖入目标节点内部,详见 EditorProps.canDropIn */
canDropIn?: CanDropInFunction;
}>(),
{
data: () => ({
@ -252,6 +255,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
indent: props.indent,
nextLevelIndentIncrement: props.nextLevelIndentIncrement,
isExpandable: props.layerNodeIsExpandable,
canDropIn: props.canDropIn,
},
component: LayerPanel,
slots: {},

View File

@ -58,6 +58,7 @@ import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/hooks/use-filter';
import { useServices } from '@editor/hooks/use-services';
import type {
CanDropInFunction,
CustomContentMenuFunction,
IsExpandableFunction,
LayerPanelSlots,
@ -79,13 +80,15 @@ defineOptions({
name: 'MEditorLayerPanel',
});
defineProps<{
const props = defineProps<{
layerContentMenu: (MenuButton | MenuComponent)[];
indent?: number;
nextLevelIndentIncrement?: number;
customContentMenu: CustomContentMenuFunction;
/** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
isExpandable?: IsExpandableFunction;
/** 自定义判断当前拖动节点是否可以拖入目标节点内部的函数,返回 false 则禁止拖入 */
canDropIn?: CanDropInFunction;
}>();
const services = useServices();
@ -123,7 +126,9 @@ const collapseAllHandler = () => {
}
};
const { handleDragStart, handleDragEnd, handleDragLeave, handleDragOver } = useDrag(services);
const { handleDragStart, handleDragEnd, handleDragLeave, handleDragOver } = useDrag(services, {
canDropIn: (sourceIds, targetId) => props.canDropIn?.(sourceIds, targetId, 'layer'),
});
//
const menuRef = useTemplateRef<InstanceType<typeof LayerMenu>>('menu');

View File

@ -11,6 +11,8 @@ const dragState: {
dropType: NodeDropType | '';
container: HTMLElement | null;
nodeId?: Id;
/** canDropIn 返回 Id 时记录的重定向目标 idhandleDragEnd 阶段会改用该 id 对应的节点作为 inner 拖入的父节点 */
redirectedTargetId?: Id;
} = {
dragOverNodeId: '',
dropType: '',
@ -37,12 +39,22 @@ const removeStatusClass = (el: HTMLElement | null) => {
});
};
export interface UseDragOptions {
/**
*
* - `false` inner before/after
* - `Id` inner id
* - targetId
*/
canDropIn?: (sourceIds: Id[], targetId: Id) => Id | boolean | void;
}
/**
* dragstart/dragleave/dragend
* dragover
* dom事件触发的
*/
export const useDrag = ({ editorService }: Services) => {
export const useDrag = ({ editorService }: Services, options: UseDragOptions = {}) => {
const handleDragStart = (event: DragEvent) => {
if (!event.dataTransfer || !event.target || !event.currentTarget) return;
@ -102,13 +114,43 @@ export const useDrag = ({ editorService }: Services) => {
}
}
if (distance < targetHeight / 3) {
// 通过用户配置的钩子判断当前拖动节点是否允许拖入目标节点内部
// - false禁止 inner 拖入
// - Id :将 inner 拖入的父节点重定向为该 id 对应的节点
// - 其他:按原 targetNodeId 正常拖入
let canDropInTarget = isContainer;
let redirectedTargetId: Id | undefined;
if (canDropInTarget && options.canDropIn && nodeId && targetNodeId !== nodeId) {
const result = options.canDropIn([nodeId], targetNodeId);
if (result === false) {
canDropInTarget = false;
} else if (typeof result === 'string' || typeof result === 'number') {
redirectedTargetId = result;
}
}
// before/after 模式下新节点会成为 target 的兄弟(即 target 的直接父节点的子节点),
// 所以应该用 target 的直接父节点 id 再次调用 canDropIn 校验。
// 若该父节点禁止拖入false或要求重定向Id都视为"不应放入此父节点"
// 故对应的 before/after 也禁用——避免绕过 inner 限制。
let canDropAsSibling = true;
const directParentId = parentsId?.[parentsId.length - 1];
if (options.canDropIn && nodeId && directParentId && directParentId !== nodeId) {
const siblingResult = options.canDropIn([nodeId], directParentId);
if (siblingResult === false || typeof siblingResult === 'string' || typeof siblingResult === 'number') {
canDropAsSibling = false;
}
}
// 显式重置 dropType避免上一次 dragover 的残留值影响本次判断
dragState.dropType = '';
if (distance < targetHeight / 3 && canDropAsSibling) {
dragState.dropType = 'before';
addClassName(labelEl, globalThis.document, 'drag-before');
} else if (distance > (targetHeight * 2) / 3) {
} else if (distance > (targetHeight * 2) / 3 && canDropAsSibling) {
dragState.dropType = 'after';
addClassName(labelEl, globalThis.document, 'drag-after');
} else if (isContainer) {
} else if (canDropInTarget) {
dragState.dropType = 'inner';
addClassName(labelEl, globalThis.document, 'drag-inner');
}
@ -119,6 +161,8 @@ export const useDrag = ({ editorService }: Services) => {
dragState.dragOverNodeId = targetNodeId;
dragState.container = event.currentTarget as HTMLElement;
// 仅 inner 时才使用重定向before/after 是相对于 dragOverNodeId 的兄弟插入,重定向无意义
dragState.redirectedTargetId = dragState.dropType === 'inner' ? redirectedTargetId : undefined;
event.preventDefault();
};
@ -158,8 +202,19 @@ export const useDrag = ({ editorService }: Services) => {
let targetIndex = -1;
if (Array.isArray(targetNode.items) && dragState.dropType === 'inner') {
targetIndex = targetNode.items.length;
targetParent = targetNode as MContainer;
// 优先使用 canDropIn 返回的重定向 id 对应的节点作为父节点
if (dragState.redirectedTargetId !== undefined) {
const redirectedNode = editorService.getNodeInfo(dragState.redirectedTargetId, false).node;
if (!redirectedNode || !Array.isArray((redirectedNode as MContainer).items)) {
// 重定向目标无效或不是容器,放弃此次拖入
return;
}
targetParent = redirectedNode as MContainer;
targetIndex = (redirectedNode as MContainer).items!.length;
} else {
targetIndex = targetNode.items.length;
targetParent = targetNode as MContainer;
}
} else {
targetIndex = getNodeIndex(dragState.dragOverNodeId, targetParent);
}
@ -180,6 +235,7 @@ export const useDrag = ({ editorService }: Services) => {
dragState.dragOverNodeId = '';
dragState.dropType = '';
dragState.container = null;
dragState.redirectedTargetId = undefined;
};
return {

View File

@ -317,12 +317,34 @@ const dropHandler = async (e: DragEvent) => {
);
let parent: MContainer | undefined | null = page.value;
let resolvedParentEl: HTMLElement | null | undefined = parentEl;
const parentId = getIdFromEl()(parentEl);
if (parentId) {
parent = editorService.getNodeById(parentId, false) as MContainer;
}
if (parent && stageContainerEl.value && stage) {
//
// delayedMarkContainer /
// - false
// - Id id layout DOM
// - 使
// sourceIds id
if (props.stageOptions.canDropIn) {
const result = props.stageOptions.canDropIn([], parent.id);
if (result === false) {
return;
}
if (typeof result === 'string' || typeof result === 'number') {
const redirectedNode = editorService.getNodeById(result, false) as MContainer | undefined;
if (!redirectedNode) {
return;
}
parent = redirectedNode;
resolvedParentEl = stage.renderer?.getTargetElement(result) ?? null;
}
}
const layout = await editorService.getLayout(parent);
const containerRect = stageContainerEl.value.getBoundingClientRect();
@ -342,8 +364,8 @@ const dropHandler = async (e: DragEvent) => {
top = e.clientY - containerRect.top + scrollTop;
left = e.clientX - containerRect.left + scrollLeft;
if (parentEl) {
const { left: parentLeft, top: parentTop } = getOffset(parentEl);
if (resolvedParentEl) {
const { left: parentLeft, top: parentTop } = getOffset(resolvedParentEl);
left = left - parentLeft * zoom.value;
top = top - parentTop * zoom.value;
}

View File

@ -26,6 +26,7 @@ import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
CanDropIn,
ContainerHighlightType,
CustomizeMoveableOptions,
GuidesOptions,
@ -168,6 +169,14 @@ export interface StageOptions {
moveableOptions?: CustomizeMoveableOptions;
canSelect?: (el: HTMLElement) => boolean | Promise<boolean>;
isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
/**
*
* isContainer false
* - sourceIds id
* - sourceIds id targetId
* StageCore canDropIn
*/
canDropIn?: CanDropIn;
updateDragEl?: UpdateDragEl;
renderType?: RenderType;
guidesOptions?: Partial<GuidesOptions>;
@ -682,6 +691,34 @@ export interface TreeNodeData {
/** 判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
export type IsExpandableFunction = (_data: TreeNodeData, _nodeStatusMap: Map<Id, LayerNodeStatus>) => boolean;
/** canDropIn 的调用场景 */
export type CanDropInScene =
/** 在"已选组件"面板的组件树中拖动节点 */
| 'layer'
/** 在画布上拖动已有组件被拖动组件本身已经存在于画布中sourceIds 包含其 id */
| 'stage-drag'
/** 从组件列表拖入新组件到画布被拖入的组件尚不存在sourceIds 为空数组) */
| 'stage-add';
/**
*
* @param _sourceIds id
* - `layer` id 1
* - `stage-drag` id
* - `stage-add` id
* @param _targetId id
* @param _scene {@link CanDropInScene}
* @returns
* - `false`
* - `layer` inner before/after
* - `stage-drag`
* - `stage-add`退
* - `Id`string | number id
* "卡片外壳""卡片内容"
* - `true` / `void` / `undefined` targetId
*/
export type CanDropInFunction = (_sourceIds: Id[], _targetId: Id, _scene: CanDropInScene) => Id | boolean | void;
export type AsyncBeforeHook<Value extends Array<string>, C extends Record<Value[number], (...args: any) => any>> = {
[K in Value[number]]?: (...args: Parameters<C[K]>) => Promise<Parameters<C[K]>> | Parameters<C[K]>;
};

View File

@ -42,6 +42,7 @@ import StageMultiDragResize from './StageMultiDragResize';
import type {
ActionManagerConfig,
ActionManagerEvents,
CanDropIn,
CanSelect,
CustomizeMoveableOptions,
CustomizeMoveableOptionsCallbackConfig,
@ -88,6 +89,8 @@ export default class ActionManager extends EventEmitter {
private getElementsFromPoint: GetElementsFromPoint;
private canSelect: CanSelect;
private isContainer?: IsContainer;
/** 见 ActionManagerConfig.canDropIn */
private canDropIn?: CanDropIn;
private getRenderDocument: GetRenderDocument;
private disabledMultiSelect = false;
private config: ActionManagerConfig;
@ -125,6 +128,7 @@ export default class ActionManager extends EventEmitter {
this.canSelect = config.canSelect || ((el: HTMLElement) => Boolean(getIdFromEl()(el)));
this.getRenderDocument = config.getRenderDocument;
this.isContainer = config.isContainer;
this.canDropIn = config.canDropIn;
this.dr = this.createDr(config);
@ -371,14 +375,28 @@ export default class ActionManager extends EventEmitter {
if (!doc) return;
const els = this.getElementsFromPoint(event);
// 取出源元素的节点 id 列表(多选拖动时为多个;从组件列表拖入时为空数组)
const sourceIds: Id[] = excludeElList
.map((el) => (el instanceof HTMLElement ? getIdFromEl()(el) : undefined))
.filter((id): id is string => Boolean(id));
for (const el of els) {
if (
!getIdFromEl()(el)?.startsWith(GHOST_EL_ID_PREFIX) &&
(await this.isContainer?.(el)) &&
!excludeElList.includes(el)
) {
addClassName(el, doc, this.containerHighlightClassName);
const targetId = getIdFromEl()(el);
if (!targetId?.startsWith(GHOST_EL_ID_PREFIX) && (await this.isContainer?.(el)) && !excludeElList.includes(el)) {
// 用户配置的钩子可以:
// - 返回 false 阻止某些源拖入命中的容器(例如禁止 button 拖入 list
// - 返回 Id 将拖入目标重定向到另一个容器(例如把命中的 list 重定向到其内层的 list-content
let highlightEl: HTMLElement = el;
if (targetId && this.canDropIn) {
const result = this.canDropIn(sourceIds, targetId);
if (result === false) continue;
if (typeof result === 'string' || typeof result === 'number') {
const redirectedEl = this.getTargetElement(result);
if (!redirectedEl) continue;
highlightEl = redirectedEl;
}
}
addClassName(highlightEl, doc, this.containerHighlightClassName);
break;
}
}

View File

@ -348,6 +348,7 @@ export default class StageCore extends EventEmitter {
disabledMultiSelect: config.disabledMultiSelect,
canSelect: config.canSelect,
isContainer: config.isContainer,
canDropIn: config.canDropIn,
updateDragEl: config.updateDragEl,
getRootContainer: () => this.container,
getRenderDocument: () => this.renderer!.getDocument(),

View File

@ -30,6 +30,18 @@ export type TargetElement = HTMLElement | SVGElement;
export type CanSelect = (el: HTMLElement, event: MouseEvent, stop: () => boolean) => boolean | Promise<boolean>;
export type IsContainer = (el: HTMLElement) => boolean | Promise<boolean>;
/**
*
* @param sourceIds id
* - id
* - id targetId
* @param targetId isContainer id
* @returns
* - `false`
* - `Id`string | number id
* - `true` / `void` / `undefined` targetId
*/
export type CanDropIn = (sourceIds: Id[], targetId: Id) => Id | boolean | void;
export type CustomizeRender = (renderer: StageCore) => Promise<HTMLElement | void> | HTMLElement | void;
export type CustomizeMoveableOptionsFunction = (config: CustomizeMoveableOptionsCallbackConfig) => MoveableOptions;
@ -54,6 +66,11 @@ export interface StageCoreConfig {
zoom?: number;
canSelect?: CanSelect;
isContainer?: IsContainer;
/**
* isContainer
* "某些源不允许拖入某些容器内部" false
*/
canDropIn?: CanDropIn;
containerHighlightClassName?: string;
containerHighlightDuration?: number;
containerHighlightType?: ContainerHighlightType;
@ -80,6 +97,8 @@ export interface ActionManagerConfig {
disabledMultiSelect?: boolean;
canSelect?: CanSelect;
isContainer?: IsContainer;
/** 见 StageCoreConfig.canDropIn */
canDropIn?: CanDropIn;
getRootContainer: GetRootContainer;
getRenderDocument: GetRenderDocument;
updateDragEl?: UpdateDragEl;