mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-08 17:48:47 +00:00
feat(editor): 支持自定义组件树节点是否可展开的判断函数
新增 layerNodeIsExpandable 配置项,业务方可自定义"已选组件"面板中 节点是否显示为可展开形态。同时导出默认实现 defaultIsExpandable 与 类型 IsExpandableFunction 供第三方复用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cce8b63fc3
commit
3cde69f6f9
@ -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
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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>>;
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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]>;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
139
packages/editor/tests/unit/utils/tree.spec.ts
Normal file
139
packages/editor/tests/unit/utils/tree.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user