roymondchen 2475a4f901 feat(editor): 新增"已选组件"面板节点双击事件 layer-node-dblclick 与 beforeLayerNodeDblclick 钩子
- TreeNode/Tree 增加 node-dblclick 事件透传
- LayerPanel 默认双击切换可展开节点的展开/收起状态,并向上抛出 node-dblclick
- Sidebar/Editor 暴露 layer-node-dblclick 事件与 beforeLayerNodeDblclick 拦截钩子
- 补充 props/events 文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 16:52:15 +08:00

371 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="m-editor-sidebar" v-if="data.type === 'tabs' && data.items.length">
<div class="m-editor-sidebar-header">
<div
class="m-editor-sidebar-header-item"
v-for="(config, index) in sideBarItems"
v-show="!floatBoxStates[config.$key]?.status"
:draggable="config.draggable ?? true"
:key="config.$key ?? index"
:class="{ 'is-active': activeTabName === config.text }"
:style="config.tabStyle || {}"
@click="headerItemClickHandler(config, index)"
@dragstart="dragstartHandler"
@dragend="dragendHandler(config.$key, $event)"
>
<MIcon v-if="config.icon" :icon="config.icon"></MIcon>
<div v-if="config.text" class="magic-editor-tab-panel-title">{{ config.text }}</div>
</div>
</div>
<div
class="m-editor-sidebar-content"
:class="{ 'm-editor-dep-collecting': collecting }"
v-for="(config, index) in sideBarItems"
:key="config.$key ?? index"
v-show="[config.text, config.$key, `${index}`].includes(activeTabName)"
>
<component
v-if="config?.component && !floatBoxStates[config.$key]?.status"
:is="config.component"
v-bind="config.props || {}"
v-on="config?.listeners || {}"
>
<template
#component-list="{ componentGroupList }"
v-if="config.$key === 'component-list' || config.slots?.componentList"
>
<slot
v-if="config.$key === 'component-list'"
name="component-list"
:component-group-list="componentGroupList"
></slot>
<component v-else-if="config.slots?.componentList" :is="config.slots.componentList" />
</template>
<template
#component-list-panel-header
v-if="config.$key === 'component-list' || config.slots?.componentListPanelHeader"
>
<slot v-if="config.$key === 'component-list'" name="component-list-panel-header"></slot>
<component v-else-if="config.slots?.componentListPanelHeader" :is="config.slots.componentListPanelHeader" />
</template>
<template
#component-list-item="{ component }"
v-if="config.$key === 'component-list' || config.slots?.componentListItem"
>
<slot v-if="config.$key === 'component-list'" name="component-list-item" :component="component"></slot>
<component
v-else-if="config.slots?.componentListItem"
:is="config.slots.componentListItem"
:component="component"
/>
</template>
<template #layer-panel-header v-if="config.$key === 'layer' || config.slots?.layerPanelHeader">
<slot v-if="config.$key === 'layer'" name="layer-panel-header"></slot>
<component v-else-if="config.slots?.layerPanelHeader" :is="config.slots.layerPanelHeader" />
</template>
<template #code-block-panel-header v-if="config.$key === 'code-block' || config.slots?.codeBlockPanelHeader">
<slot v-if="config.$key === 'code-block'" name="code-block-panel-header"></slot>
<component v-else-if="config.slots?.codeBlockPanelHeader" :is="config.slots.codeBlockPanelHeader" />
</template>
<template
#code-block-panel-tool="{ id, data }"
v-if="config.$key === 'code-block' || config.slots?.codeBlockPanelTool"
>
<slot v-if="config.$key === 'code-block'" name="code-block-panel-tool" :id="id" :data="data"></slot>
<component v-else-if="config.slots?.codeBlockPanelTool" :is="config.slots.codeBlockPanelTool" />
</template>
<template #code-block-panel-search v-if="config.$key === 'code-block' || config.slots?.codeBlockPanelSearch">
<slot v-if="config.$key === 'code-block'" name="code-block-panel-search"></slot>
<component v-else-if="config.slots?.codeBlockPanelSearch" :is="config.slots.codeBlockPanelSearch" />
</template>
<template
#layer-node-content="{ data: nodeData }"
v-if="config.$key === 'layer' || config.slots?.layerNodeContent"
>
<slot v-if="config.$key === 'layer'" name="layer-node-content" :data="nodeData"></slot>
<component v-else-if="config.slots?.layerNodeContent" :is="config.slots.layerNodeContent" :data="nodeData" />
</template>
<template #layer-node-label="{ data: nodeData }" v-if="config.$key === 'layer' || config.slots?.layerNodeLabel">
<slot v-if="config.$key === 'layer'" name="layer-node-label" :data="nodeData"></slot>
<component v-else-if="config.slots?.layerNodeLabel" :is="config.slots.layerNodeTool" :data="nodeData" />
</template>
<template #layer-node-tool="{ data: nodeData }" v-if="config.$key === 'layer' || config.slots?.layerNodeTool">
<slot v-if="config.$key === 'layer'" name="layer-node-tool" :data="nodeData"></slot>
<component v-else-if="config.slots?.layerNodeTool" :is="config.slots.layerNodeTool" :data="nodeData" />
</template>
<template
#data-source-panel-tool="{ data }"
v-if="config.$key === 'data-source' || config.slots?.dataSourcePanelTool"
>
<slot v-if="config.$key === 'data-source'" name="data-source-panel-tool" :data="data"></slot>
<component v-else-if="config.slots?.DataSourcePanelTool" :is="config.slots.DataSourcePanelTool" />
</template>
<template #data-source-panel-search v-if="config.$key === 'data-source' || config.slots?.dataSourcePanelSearch">
<slot v-if="config.$key === 'data-source'" name="data-source-panel-search"></slot>
<component v-else-if="config.slots?.dataSourcePanelSearch" :is="config.slots.dataSourcePanelSearch" />
</template>
</component>
</div>
<div class="m-editor-sidebar-tips" v-if="tipsBarVisible && collecting && taskLength > 0">
<span>依赖收集中(剩余任务{{ taskLength }})</span>
<MIcon :icon="Close" class="close-icon" @click.stop="tipsBarVisible = false"></MIcon>
</div>
</div>
<Teleport to="body">
<template v-for="(config, index) in sideBarItems">
<FloatingBox
:key="config.$key ?? index"
v-if="floatBoxStates[config.$key]?.status"
v-model:visible="floatBoxStates[config.$key].status"
v-model:height="columnLeftHeight"
:width="columnLeftWidth"
:title="config.text"
:position="{
left: floatBoxStates[config.$key].left,
top: floatBoxStates[config.$key].top,
}"
>
<template #body>
<div class="m-editor-slide-list-box">
<component
v-if="config && floatBoxStates[config.$key].status"
:is="config.component"
v-bind="config.props || {}"
v-on="config?.listeners || {}"
/>
</div>
</template>
</FloatingBox>
</template>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { Close, Coin, EditPen, Goods, List } from '@element-plus/icons-vue';
import FloatingBox from '@editor/components/FloatingBox.vue';
import MIcon from '@editor/components/Icon.vue';
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,
type MenuButton,
type MenuComponent,
type SideBarData,
type SidebarSlots,
type SideComponent,
type SideItem,
SideItemKey,
type TreeNodeData,
} from '@editor/type';
import CodeBlockListPanel from './code-block/CodeBlockListPanel.vue';
import DataSourceListPanel from './data-source/DataSourceListPanel.vue';
import LayerPanel from './layer/LayerPanel.vue';
import ComponentListPanel from './ComponentListPanel.vue';
defineSlots<SidebarSlots>();
defineOptions({
name: 'MEditorSidebar',
});
const emit = defineEmits<{
'layer-node-dblclick': [event: MouseEvent, data: TreeNodeData];
}>();
const props = withDefaults(
defineProps<{
data?: SideBarData;
layerContentMenu: (MenuButton | MenuComponent)[];
indent?: number;
nextLevelIndentIncrement?: number;
customContentMenu: CustomContentMenuFunction;
/** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
layerNodeIsExpandable?: IsExpandableFunction;
/** 自定义判断当前正在拖动的源是否可以拖入目标节点内部,详见 EditorProps.canDropIn */
canDropIn?: CanDropInFunction;
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeLayerNodeDblclick?: (_event: MouseEvent, _data: TreeNodeData) => Promise<boolean | void> | boolean | void;
}>(),
{
data: () => ({
type: 'tabs',
status: '组件',
items: [SideItemKey.COMPONENT_LIST, SideItemKey.LAYER, SideItemKey.CODE_BLOCK, SideItemKey.DATA_SOURCE],
}),
},
);
const { depService, uiService, propsService } = useServices();
const collecting = computed(() => depService.get('collecting'));
const taskLength = computed(() => depService.get('taskLength'));
const tipsBarVisible = ref(true);
const columnLeftWidth = computed(() => uiService.get('columnWidth')[ColumnLayout.LEFT]);
const { height: editorContentHeight } = useEditorContentHeight();
const columnLeftHeight = ref(0);
const unWatchEditorContentHeight = watch(
editorContentHeight,
(height) => {
if (height) {
columnLeftHeight.value = height * 0.5;
nextTick().then(() => {
unWatchEditorContentHeight();
});
}
},
{
immediate: true,
},
);
const activeTabName = ref(props.data?.status);
const getItemConfig = (data: SideItem): SideComponent => {
const map: Record<string, SideComponent> = {
[SideItemKey.COMPONENT_LIST]: {
$key: SideItemKey.COMPONENT_LIST,
type: 'component',
icon: Goods,
text: '组件',
component: ComponentListPanel,
slots: {},
},
layer: {
$key: 'layer',
type: 'component',
icon: List,
text: '已选组件',
props: {
layerContentMenu: props.layerContentMenu,
customContentMenu: props.customContentMenu,
indent: props.indent,
nextLevelIndentIncrement: props.nextLevelIndentIncrement,
isExpandable: props.layerNodeIsExpandable,
canDropIn: props.canDropIn,
beforeNodeDblclick: props.beforeLayerNodeDblclick,
},
listeners: {
'node-dblclick': (event: MouseEvent, nodeData: TreeNodeData) => emit('layer-node-dblclick', event, nodeData),
},
component: LayerPanel,
slots: {},
},
[SideItemKey.CODE_BLOCK]: {
$key: 'code-block',
type: 'component',
icon: EditPen,
text: '代码编辑',
component: CodeBlockListPanel,
props: {
indent: props.indent,
nextLevelIndentIncrement: props.nextLevelIndentIncrement,
customContentMenu: props.customContentMenu,
},
slots: {},
},
[SideItemKey.DATA_SOURCE]: {
$key: SideItemKey.DATA_SOURCE,
type: 'component',
icon: Coin,
text: '数据源',
component: DataSourceListPanel,
props: {
indent: props.indent,
nextLevelIndentIncrement: props.nextLevelIndentIncrement,
customContentMenu: props.customContentMenu,
},
slots: {},
},
};
return typeof data === 'string' ? map[data] : data;
};
const sideBarItems = computed(() =>
props.data.items
.map((item) => getItemConfig(item))
.filter((item) => {
if (item.$key === SideItemKey.DATA_SOURCE) {
return !propsService.getDisabledDataSource();
}
if (item.$key === SideItemKey.CODE_BLOCK) {
return !propsService.getDisabledCodeBlock();
}
return true;
}),
);
watch(
sideBarItems,
(items) => {
uiService.set('sideBarItems', items);
},
{
immediate: true,
},
);
watch(
() => props.data.status,
(status) => {
activeTabName.value = status || '0';
},
);
const slideKeys = computed(() => sideBarItems.value.map((sideBarItem) => sideBarItem.$key));
const { dragstartHandler, dragendHandler, floatBoxStates, showingBoxKeys } = useFloatBox(slideKeys);
watch(
() => showingBoxKeys.value.length,
() => {
const isActiveTabShow = showingBoxKeys.value.some(
(key) => activeTabName.value === sideBarItems.value.find((v) => v.$key === key)?.text,
);
if (!isActiveTabShow && activeTabName.value) return;
const nextSlideBarItem = sideBarItems.value.find((sideBarItem) => !showingBoxKeys.value.includes(sideBarItem.$key));
if (!nextSlideBarItem) {
activeTabName.value = '';
uiService.set('hideSlideBar', true);
return;
}
uiService.set('hideSlideBar', false);
activeTabName.value = nextSlideBarItem?.text;
},
);
const headerItemClickHandler = async (config: SideComponent, index: number) => {
if (typeof config.beforeClick === 'function') {
if ((await config.beforeClick(config)) === false) {
return;
}
}
activeTabName.value = config.text || config.$key || `${index}`;
};
defineExpose({
activeTabName,
});
</script>