feat(editor): 支持自定义组件树节点是否可展开的判断函数

新增 layerNodeIsExpandable 配置项,业务方可自定义"已选组件"面板中
节点是否显示为可展开形态。同时导出默认实现 defaultIsExpandable 与
类型 IsExpandableFunction 供第三方复用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-07 13:53:51 +08:00
parent cce8b63fc3
commit 3cde69f6f9
10 changed files with 218 additions and 11 deletions

View File

@ -1171,6 +1171,46 @@ const customContentMenu = (menus, { node }) => {
</script>
```
## layerNodeIsExpandable
- **详情:**
用于自定义判断"已选组件"面板中组件树节点是否可展开(即是否要展示为拥有子节点的形态)
该函数返回 `true` 时,节点会显示展开图标,并在展开后渲染子节点容器;返回 `false` 时,展开图标显示为透明占位,且不渲染子节点容器
默认行为:当节点的 `items` 中至少存在一个 `visible` 状态为 `true` 的子节点时认为可展开(被搜索过滤隐藏的子节点不会让父节点显示为可展开)
- **默认值:** `defaultIsExpandable`
- **类型:** `(data: TreeNodeData, nodeStatusMap: Map<Id, LayerNodeStatus>) => boolean`
- **示例:**
```html
<template>
<m-editor :layer-node-is-expandable="layerNodeIsExpandable"></m-editor>
</template>
<script setup>
import { defaultIsExpandable } from '@tmagic/editor';
// 即使没有可见子节点,特定类型的容器节点也保持展开图标可见
const layerNodeIsExpandable = (data, nodeStatusMap) => {
if (data.type === 'my-special-container') {
return true;
}
return defaultIsExpandable(data, nodeStatusMap);
};
</script>
```
::: tip
该函数仅作用于"已选组件"面板的组件树节点,不影响代码块、数据源等其它面板内的树。
第三方业务可从 `@tmagic/editor` 直接导入 `defaultIsExpandable` 复用默认逻辑作为兜底。
:::
## extendFormState
- **详情:**

View File

@ -26,6 +26,7 @@
:custom-content-menu="customContentMenu"
:indent="treeIndent"
:next-level-indent-increment="treeNextLevelIndentIncrement"
:layer-node-is-expandable="layerNodeIsExpandable"
>
<template #layer-panel-header>
<slot name="layer-panel-header"></slot>

View File

@ -8,6 +8,7 @@
:indent="indent"
:next-level-indent-increment="nextLevelIndentIncrement"
:node-status-map="nodeStatusMap"
:is-expandable="isExpandable"
>
<template #tree-node-content="{ data: nodeData }">
<slot name="tree-node-content" :data="nodeData"> </slot>
@ -33,7 +34,7 @@ import { provide } from 'vue';
import type { Id } from '@tmagic/core';
import type { LayerNodeStatus, TreeNodeData } from '@editor/type';
import type { IsExpandableFunction, LayerNodeStatus, TreeNodeData } from '@editor/type';
import TreeNode from './TreeNode.vue';
@ -66,6 +67,8 @@ withDefaults(
indent?: number;
nextLevelIndentIncrement?: number;
emptyText?: string;
/** 自定义判断节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
isExpandable?: IsExpandableFunction;
}>(),
{
indent: 0,

View File

@ -20,7 +20,7 @@
>
<MIcon
class="expand-icon"
:style="hasChildren ? '' : 'color: transparent; cursor: default'"
:style="isExpandable(data, nodeStatusMap) ? '' : 'color: transparent; cursor: default'"
:icon="expanded ? ArrowDown : ArrowRight"
@click="expandHandler"
></MIcon>
@ -37,7 +37,7 @@
</div>
</div>
<div v-if="hasChildren && expanded" class="m-editor-tree-node-children">
<div v-if="isExpandable(data, nodeStatusMap) && expanded" class="m-editor-tree-node-children">
<TreeNode
v-for="item in data.items"
:key="item.id"
@ -46,6 +46,7 @@
:parentsId="[...parentsId, data.id]"
:node-status-map="nodeStatusMap"
:indent="indent + nextLevelIndentIncrement"
:is-expandable="isExpandable"
>
<template #tree-node-content="{ data: nodeData }">
<slot name="tree-node-content" :data="nodeData"> </slot>
@ -68,8 +69,8 @@ import { ArrowDown, ArrowRight } from '@element-plus/icons-vue';
import type { Id } from '@tmagic/core';
import MIcon from '@editor/components/Icon.vue';
import type { LayerNodeStatus, TreeNodeData } from '@editor/type';
import { updateStatus } from '@editor/utils/tree';
import type { IsExpandableFunction, LayerNodeStatus, TreeNodeData } from '@editor/type';
import { defaultIsExpandable, updateStatus } from '@editor/utils/tree';
defineSlots<{
'tree-node-label'(_props: { data: TreeNodeData }): any;
@ -100,11 +101,14 @@ const props = withDefaults(
nodeStatusMap: Map<Id, LayerNodeStatus>;
indent?: number;
nextLevelIndentIncrement?: number;
/** 自定义判断节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
isExpandable?: IsExpandableFunction;
}>(),
{
indent: 0,
nextLevelIndentIncrement: 11,
parentsId: () => [],
isExpandable: defaultIsExpandable,
},
);
@ -123,10 +127,6 @@ const selected = computed(() => nodeStatus.value.selected);
const visible = computed(() => nodeStatus.value.visible);
const draggable = computed(() => nodeStatus.value.draggable);
const hasChildren = computed(
() => Array.isArray(props.data.items) && props.data.items.some((item) => props.nodeStatusMap.get(item.id)?.visible),
);
const handleDragStart = (event: DragEvent) => {
treeEmit?.('node-dragstart', event, props.data);
};

View File

@ -14,6 +14,7 @@ import type {
ComponentGroup,
CustomContentMenuFunction,
DatasourceTypeOption,
IsExpandableFunction,
MenuBarData,
MenuButton,
MenuComponent,
@ -98,6 +99,8 @@ export interface EditorProps {
isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
/** 用于自定义组件树与画布的右键菜单 */
customContentMenu?: CustomContentMenuFunction;
/** 用于自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态) */
layerNodeIsExpandable?: IsExpandableFunction;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;

View File

@ -164,6 +164,7 @@ import { useServices } from '@editor/hooks/use-services';
import {
ColumnLayout,
CustomContentMenuFunction,
type IsExpandableFunction,
type MenuButton,
type MenuComponent,
type SideBarData,
@ -191,6 +192,8 @@ const props = withDefaults(
indent?: number;
nextLevelIndentIncrement?: number;
customContentMenu: CustomContentMenuFunction;
/** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
layerNodeIsExpandable?: IsExpandableFunction;
}>(),
{
data: () => ({
@ -248,6 +251,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
customContentMenu: props.customContentMenu,
indent: props.indent,
nextLevelIndentIncrement: props.nextLevelIndentIncrement,
isExpandable: props.layerNodeIsExpandable,
},
component: LayerPanel,
slots: {},

View File

@ -12,6 +12,7 @@
:node-status-map="nodeStatusMap"
:indent="indent"
:next-level-indent-increment="nextLevelIndentIncrement"
:is-expandable="isExpandable"
@node-dragover="handleDragOver"
@node-dragstart="handleDragStart"
@node-dragleave="handleDragLeave"
@ -56,7 +57,14 @@ import SearchInput from '@editor/components/SearchInput.vue';
import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/hooks/use-filter';
import { useServices } from '@editor/hooks/use-services';
import type { CustomContentMenuFunction, LayerPanelSlots, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
import type {
CustomContentMenuFunction,
IsExpandableFunction,
LayerPanelSlots,
MenuButton,
MenuComponent,
TreeNodeData,
} from '@editor/type';
import LayerMenu from './LayerMenu.vue';
import LayerNodeTool from './LayerNodeTool.vue';
@ -76,6 +84,8 @@ defineProps<{
indent?: number;
nextLevelIndentIncrement?: number;
customContentMenu: CustomContentMenuFunction;
/** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
isExpandable?: IsExpandableFunction;
}>();
const services = useServices();

View File

@ -669,6 +669,9 @@ export interface TreeNodeData {
[key: string]: any;
}
/** 判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
export type IsExpandableFunction = (_data: TreeNodeData, _nodeStatusMap: Map<Id, LayerNodeStatus>) => boolean;
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

@ -1,7 +1,7 @@
import type { Id } from '@tmagic/core';
import { getKeys } from '@tmagic/utils';
import type { LayerNodeStatus } from '@editor/type';
import type { IsExpandableFunction, LayerNodeStatus } from '@editor/type';
export const updateStatus = (nodeStatusMap: Map<Id, LayerNodeStatus>, id: Id, status: Partial<LayerNodeStatus>) => {
const nodeStatus = nodeStatusMap.get(id);
@ -13,3 +13,7 @@ export const updateStatus = (nodeStatusMap: Map<Id, LayerNodeStatus>, id: Id, st
}
});
};
/** 默认的组件树节点是否可展开的判断函数:当节点的子项中至少存在一个可见节点时认为可展开 */
export const defaultIsExpandable: IsExpandableFunction = (data, nodeStatusMap) =>
Array.isArray(data.items) && data.items.some((item) => nodeStatusMap.get(item.id)?.visible);

View File

@ -0,0 +1,139 @@
// @vitest-environment node
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, test } from 'vitest';
import type { Id } from '@tmagic/core';
import type { LayerNodeStatus, TreeNodeData } from '@editor/type';
import { defaultIsExpandable, updateStatus } from '@editor/utils/tree';
const buildStatus = (overrides: Partial<LayerNodeStatus> = {}): LayerNodeStatus => ({
visible: true,
expand: false,
selected: false,
draggable: false,
...overrides,
});
const buildStatusMap = (entries: [Id, Partial<LayerNodeStatus>][]): Map<Id, LayerNodeStatus> => {
const map = new Map<Id, LayerNodeStatus>();
entries.forEach(([id, status]) => map.set(id, buildStatus(status)));
return map;
};
describe('defaultIsExpandable', () => {
test('节点没有 items 时返回 false', () => {
const data: TreeNodeData = { id: 'node_1' };
const statusMap = buildStatusMap([['node_1', { visible: true }]]);
expect(defaultIsExpandable(data, statusMap)).toBe(false);
});
test('items 为空数组时返回 false', () => {
const data: TreeNodeData = { id: 'node_1', items: [] };
const statusMap = buildStatusMap([['node_1', { visible: true }]]);
expect(defaultIsExpandable(data, statusMap)).toBe(false);
});
test('items 中存在至少一个可见子节点时返回 true', () => {
const data: TreeNodeData = {
id: 'parent_1',
items: [{ id: 'child_1' }, { id: 'child_2' }],
};
const statusMap = buildStatusMap([
['parent_1', { visible: true }],
['child_1', { visible: false }],
['child_2', { visible: true }],
]);
expect(defaultIsExpandable(data, statusMap)).toBe(true);
});
test('所有子节点都不可见时返回 false被搜索过滤的场景', () => {
const data: TreeNodeData = {
id: 'parent_1',
items: [{ id: 'child_1' }, { id: 'child_2' }],
};
const statusMap = buildStatusMap([
['parent_1', { visible: true }],
['child_1', { visible: false }],
['child_2', { visible: false }],
]);
expect(defaultIsExpandable(data, statusMap)).toBe(false);
});
test('子节点状态在 statusMap 中缺失时视为不可见', () => {
const data: TreeNodeData = {
id: 'parent_1',
items: [{ id: 'child_1' }],
};
const statusMap = buildStatusMap([['parent_1', { visible: true }]]);
expect(defaultIsExpandable(data, statusMap)).toBe(false);
});
test('items 不是数组时返回 false', () => {
const data: TreeNodeData = { id: 'node_1', items: undefined };
const statusMap = buildStatusMap([['node_1', { visible: true }]]);
expect(defaultIsExpandable(data, statusMap)).toBe(false);
});
});
describe('updateStatus', () => {
test('更新已存在节点的部分状态字段', () => {
const statusMap = buildStatusMap([['node_1', { visible: true, expand: false, selected: false, draggable: false }]]);
updateStatus(statusMap, 'node_1', { expand: true, selected: true });
expect(statusMap.get('node_1')).toEqual({
visible: true,
expand: true,
selected: true,
draggable: false,
});
});
test('节点不存在时静默返回,不抛错', () => {
const statusMap = buildStatusMap([]);
expect(() => updateStatus(statusMap, 'node_missing', { expand: true })).not.toThrow();
expect(statusMap.has('node_missing')).toBe(false);
});
test('忽略状态对象中值为 undefined 的字段', () => {
const statusMap = buildStatusMap([['node_1', { visible: true, expand: true }]]);
updateStatus(statusMap, 'node_1', { expand: undefined, selected: true });
const status = statusMap.get('node_1')!;
expect(status.expand).toBe(true);
expect(status.selected).toBe(true);
});
test('将传入的非 boolean 值强制转换为 boolean', () => {
const statusMap = buildStatusMap([['node_1', { visible: false }]]);
updateStatus(statusMap, 'node_1', { visible: 1 as unknown as boolean });
expect(statusMap.get('node_1')?.visible).toBe(true);
});
});