This commit is contained in:
skycurtain 2025-03-25 14:42:06 +08:00
parent 3b690b03a7
commit dfd3d962c7
2 changed files with 184 additions and 50 deletions

View File

@ -1,43 +1,94 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* SplitLayout 组件
*
* 一个可调整大小的分割面板布局组件支持水平和垂直方向的分割
* 通过拖拽分隔条可以调整各个面板的大小
* 使用provide/inject机制与子组件SplitPanel通信
*/
import { ref, provide, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, provide, computed, onMounted, onBeforeUnmount } from 'vue'
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
//
const props = defineProps({ const props = defineProps({
/**
* 分割方向
* - horizontal: 水平分割面板左右排列
* - vertical: 垂直分割面板上下排列
*/
direction: { direction: {
type: String, type: String,
default: 'horizontal', // 'horizontal' or 'vertical' default: 'horizontal', // 'horizontal' or 'vertical'
validator: (value: string) => ['horizontal', 'vertical'].includes(value), validator: (value: string) => ['horizontal', 'vertical'].includes(value),
}, },
/**
* 分隔条的大小像素
* 控制分隔条的宽度水平方向或高度垂直方向
*/
gutterSize: { gutterSize: {
type: Number, type: Number,
default: 4, default: 4,
}, },
/**
* 面板的默认最小尺寸像素
* 当SplitPanel未指定minSize时使用此值
*/
minSize: { minSize: {
type: Number, type: Number,
default: 50, // default: 50, //
}, },
}) })
const emit = defineEmits(['resize', 'resizeEnd']) //
const emit = defineEmits([
/** 调整大小过程中触发 */
'resize',
/** 调整大小结束时触发 */
'resizeEnd',
])
// DOM
const splitterRef = ref<HTMLElement | null>(null) const splitterRef = ref<HTMLElement | null>(null)
// 使vueuseuseElementSize
const { width, height } = useElementSize(splitterRef) const { width, height } = useElementSize(splitterRef)
//
const isHorizontal = computed(() => props.direction === 'horizontal') const isHorizontal = computed(() => props.direction === 'horizontal')
//
const panes = ref<Array<{ id: string; size: number; minSize: number; maxSize: number }>>([]) const panes = ref<Array<{ id: string; size: number; minSize: number; maxSize: number }>>([])
const dragging = ref(false) //
const activePaneIndex = ref(-1) const dragging = ref(false) //
const startPosition = ref(0) const activePaneIndex = ref(-1) //
const startSizes = ref<number[]>([]) const startPosition = ref(0) //
const startSizes = ref<number[]>([]) //
// Pane /**
* 提供给SplitPanel子组件的上下文
* 通过provide机制向子组件提供方法和状态实现父子组件通信
*/
provide('splitter-context', { provide('splitter-context', {
//
direction: computed(() => props.direction), direction: computed(() => props.direction),
// panes使访
panes, // panes使Pane访 panes, // panes使Pane访
/**
* 注册面板方法
* 子组件挂载时调用此方法注册自身到父组件
* @param id 面板唯一标识
* @param initialSize 初始尺寸百分比
* @param minSize 最小尺寸像素
* @param maxSize 最大尺寸像素
* @returns 面板在数组中的索引
*/
registerPane: (id: string, initialSize: number, minSize: number, maxSize: number) => { registerPane: (id: string, initialSize: number, minSize: number, maxSize: number) => {
panes.value.push({ id, size: initialSize, minSize, maxSize }) panes.value.push({ id, size: initialSize, minSize, maxSize })
return panes.value.length - 1 // return panes.value.length - 1 //
}, },
/**
* 注销面板方法
* 子组件卸载时调用此方法从父组件移除自身
* @param index 面板在数组中的索引
*/
unregisterPane: (index: number) => { unregisterPane: (index: number) => {
if (index >= 0 && index < panes.value.length) { if (index >= 0 && index < panes.value.length) {
panes.value.splice(index, 1) panes.value.splice(index, 1)
@ -45,11 +96,14 @@ provide('splitter-context', {
}, },
}) })
// /**
* 计算每个面板的尺寸百分比
* 处理自动分配尺寸和确保总和为100%
*/
const calculateSizes = () => { const calculateSizes = () => {
if (panes.value.length === 0) return if (panes.value.length === 0) return
// // size <= 0
const unsetPanes = panes.value.filter((pane) => pane.size <= 0) const unsetPanes = panes.value.filter((pane) => pane.size <= 0)
if (unsetPanes.length > 0) { if (unsetPanes.length > 0) {
const totalSetSize = panes.value.reduce((sum, pane) => sum + (pane.size > 0 ? pane.size : 0), 0) const totalSetSize = panes.value.reduce((sum, pane) => sum + (pane.size > 0 ? pane.size : 0), 0)
@ -63,7 +117,7 @@ const calculateSizes = () => {
}) })
} }
// 100% // 100%
const totalSize = panes.value.reduce((sum, pane) => sum + pane.size, 0) const totalSize = panes.value.reduce((sum, pane) => sum + pane.size, 0)
if (totalSize !== 100) { if (totalSize !== 100) {
const ratio = 100 / totalSize const ratio = 100 / totalSize
@ -73,7 +127,12 @@ const calculateSizes = () => {
} }
} }
// /**
* 处理拖动开始事件
* 当用户点击分隔条开始拖拽时触发
* @param index 分隔条对应的面板索引
* @param event 鼠标事件对象
*/
const handleDragStart = (index: number, event: MouseEvent) => { const handleDragStart = (index: number, event: MouseEvent) => {
event.preventDefault() event.preventDefault()
dragging.value = true dragging.value = true
@ -91,7 +150,11 @@ const handleDragStart = (index: number, event: MouseEvent) => {
document.addEventListener('mouseup', handleDragEnd) document.addEventListener('mouseup', handleDragEnd)
} }
// /**
* 处理拖动移动事件
* 当用户拖动分隔条时持续触发计算并更新面板尺寸
* @param event 鼠标事件对象
*/
const handleDragMove = (event: MouseEvent) => { const handleDragMove = (event: MouseEvent) => {
if (!dragging.value || activePaneIndex.value < 0) return if (!dragging.value || activePaneIndex.value < 0) return
@ -103,17 +166,18 @@ const handleDragMove = (event: MouseEvent) => {
const containerSize = isHorizontal.value ? width.value : height.value const containerSize = isHorizontal.value ? width.value : height.value
const deltaPercent = (delta / containerSize) * 100 const deltaPercent = (delta / containerSize) * 100
// //
const currentPane = panes.value[activePaneIndex.value] const currentPane = panes.value[activePaneIndex.value]
const nextPane = panes.value[activePaneIndex.value + 1] const nextPane = panes.value[activePaneIndex.value + 1]
if (!currentPane || !nextPane) return if (!currentPane || !nextPane) return
// //
//
let newCurrentSize = startSizes.value[activePaneIndex.value] + deltaPercent let newCurrentSize = startSizes.value[activePaneIndex.value] + deltaPercent
let newNextSize = startSizes.value[activePaneIndex.value + 1] - deltaPercent let newNextSize = startSizes.value[activePaneIndex.value + 1] - deltaPercent
// //
const currentMinPercent = (currentPane.minSize / containerSize) * 100 const currentMinPercent = (currentPane.minSize / containerSize) * 100
const nextMinPercent = (nextPane.minSize / containerSize) * 100 const nextMinPercent = (nextPane.minSize / containerSize) * 100
@ -131,7 +195,8 @@ const handleDragMove = (event: MouseEvent) => {
nextMinPercent nextMinPercent
} }
// // maxSize > 0
//
if (currentPane.maxSize > 0) { if (currentPane.maxSize > 0) {
const currentMaxPercent = (currentPane.maxSize / containerSize) * 100 const currentMaxPercent = (currentPane.maxSize / containerSize) * 100
if (newCurrentSize > currentMaxPercent) { if (newCurrentSize > currentMaxPercent) {
@ -154,35 +219,49 @@ const handleDragMove = (event: MouseEvent) => {
} }
} }
// //
panes.value[activePaneIndex.value].size = newCurrentSize panes.value[activePaneIndex.value].size = newCurrentSize
panes.value[activePaneIndex.value + 1].size = newNextSize panes.value[activePaneIndex.value + 1].size = newNextSize
// resize
emit( emit(
'resize', 'resize',
panes.value.map((pane) => ({ id: pane.id, size: pane.size })), panes.value.map((pane) => ({ id: pane.id, size: pane.size })),
) )
} }
// /**
* 处理拖动结束事件
* 当用户释放鼠标按钮结束拖拽时触发
*/
const handleDragEnd = () => { const handleDragEnd = () => {
dragging.value = false dragging.value = false
document.removeEventListener('mousemove', handleDragMove) document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', handleDragEnd) document.removeEventListener('mouseup', handleDragEnd)
// resizeEnd
if (activePaneIndex.value >= 0) { if (activePaneIndex.value >= 0) {
// resizeEnd
emit( emit(
'resizeEnd', 'resizeEnd',
panes.value.map((pane) => ({ id: pane.id, size: pane.size })), panes.value.map((pane) => ({ id: pane.id, size: pane.size })),
) )
//
activePaneIndex.value = -1 activePaneIndex.value = -1
} }
} }
/**
* 组件挂载完成后计算初始面板尺寸
*/
onMounted(() => { onMounted(() => {
calculateSizes() calculateSizes()
}) })
/**
* 组件卸载前移除全局事件监听器
* 防止内存泄漏和事件冲突
*/
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleDragMove) document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', handleDragEnd) document.removeEventListener('mouseup', handleDragEnd)
@ -190,6 +269,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<!-- 分割器容器根据方向应用不同的样式类 -->
<div <div
ref="splitterRef" ref="splitterRef"
class="splitter" class="splitter"
@ -198,10 +278,12 @@ onBeforeUnmount(() => {
'splitter--vertical': direction === 'vertical', 'splitter--vertical': direction === 'vertical',
}" }"
> >
<!-- 插槽用于放置SplitPanel子组件 -->
<slot></slot> <slot></slot>
<!-- 分隔条 --> <!-- 分隔条在每个面板之间渲染一个可拖拽的分隔条 -->
<template v-for="(_, index) in panes" :key="index"> <template v-for="(_, index) in panes" :key="index">
<!-- 除了最后一个面板外每个面板后面都有一个分隔条 -->
<div <div
v-if="index < panes.length - 1" v-if="index < panes.length - 1"
class="splitter__gutter" class="splitter__gutter"
@ -210,12 +292,15 @@ onBeforeUnmount(() => {
'splitter__gutter--vertical': direction === 'vertical', 'splitter__gutter--vertical': direction === 'vertical',
}" }"
:style="{ :style="{
/* 根据方向设置分隔条的宽度或高度 */
[direction === 'horizontal' ? 'width' : 'height']: `${gutterSize}px`, [direction === 'horizontal' ? 'width' : 'height']: `${gutterSize}px`,
/* 计算分隔条的位置:根据前面所有面板的尺寸百分比总和确定位置 */
[direction === 'horizontal' ? 'left' : 'top']: [direction === 'horizontal' ? 'left' : 'top']:
`calc(${panes.slice(0, index + 1).reduce((sum, pane) => sum + pane.size, 0)}% - ${gutterSize / 2}px)`, `calc(${panes.slice(0, index + 1).reduce((sum, pane) => sum + pane.size, 0)}% - ${gutterSize / 2}px)`,
}" }"
@mousedown="handleDragStart(index, $event)" @mousedown="handleDragStart(index, $event)"
> >
<!-- 分隔条内部的把手用于视觉提示和交互 -->
<div class="splitter__gutter-handle"></div> <div class="splitter__gutter-handle"></div>
</div> </div>
</template> </template>
@ -223,64 +308,75 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
/* 分割器容器的基本样式 */
.splitter { .splitter {
display: flex; display: flex; /* 使用Flex布局排列子元素 */
width: 100%; width: 100%; /* 容器宽度占满父元素 */
height: 100%; height: 100%; /* 容器高度占满父元素 */
position: relative; position: relative; /* 相对定位,作为分隔条的定位参考 */
overflow: hidden; overflow: hidden; /* 隐藏溢出内容 */
} }
/* 水平分割模式:子元素水平排列 */
.splitter--horizontal { .splitter--horizontal {
flex-direction: row; flex-direction: row; /* 子元素从左到右排列 */
} }
/* 垂直分割模式:子元素垂直排列 */
.splitter--vertical { .splitter--vertical {
flex-direction: column; flex-direction: column; /* 子元素从上到下排列 */
} }
/* 分隔条的基本样式 */
.splitter__gutter { .splitter__gutter {
position: absolute; position: absolute; /* 绝对定位,可以精确控制位置 */
z-index: 1; z-index: 1; /* 确保分隔条显示在面板上方 */
display: flex; display: flex; /* 使用Flex布局居中把手 */
justify-content: center; justify-content: center; /* 水平居中把手 */
align-items: center; align-items: center; /* 垂直居中把手 */
background-color: transparent; background-color: transparent; /* 默认透明背景 */
transition: background-color 0.2s; transition: background-color 0.2s; /* 背景色变化时的平滑过渡效果 */
} }
/* 分隔条悬停效果 */
.splitter__gutter:hover, .splitter__gutter:hover,
.splitter__gutter:active { .splitter__gutter:active {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1); /* 悬停时显示半透明背景 */
} }
/* 水平分隔条样式 */
.splitter__gutter--horizontal { .splitter__gutter--horizontal {
height: 100%; height: 100%; /* 高度占满容器 */
cursor: col-resize; cursor: col-resize; /* 显示列调整光标,提示用户可以水平拖动 */
} }
/* 垂直分隔条样式 */
.splitter__gutter--vertical { .splitter__gutter--vertical {
width: 100%; width: 100%; /* 宽度占满容器 */
cursor: row-resize; cursor: row-resize; /* 显示行调整光标,提示用户可以垂直拖动 */
} }
/* 分隔条把手的基本样式 */
.splitter__gutter-handle { .splitter__gutter-handle {
background-color: #e8e8e8; background-color: #e8e8e8; /* 把手的默认背景色 */
transition: background-color 0.2s; transition: background-color 0.2s; /* 背景色变化时的平滑过渡效果 */
} }
/* 分隔条悬停时把手的样式 */
.splitter__gutter:hover .splitter__gutter-handle, .splitter__gutter:hover .splitter__gutter-handle,
.splitter__gutter:active .splitter__gutter-handle { .splitter__gutter:active .splitter__gutter-handle {
background-color: #1890ff; background-color: #1890ff; /* 悬停时把手变为蓝色 */
} }
/* 水平分隔条把手的尺寸 */
.splitter__gutter--horizontal .splitter__gutter-handle { .splitter__gutter--horizontal .splitter__gutter-handle {
width: 2px; width: 2px; /* 把手宽度 */
height: 30px; height: 30px; /* 把手高度 */
} }
/* 垂直分隔条把手的尺寸 */
.splitter__gutter--vertical .splitter__gutter-handle { .splitter__gutter--vertical .splitter__gutter-handle {
width: 30px; width: 30px; /* 把手宽度 */
height: 2px; height: 2px; /* 把手高度 */
} }
</style> </style>

View File

@ -1,26 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* SplitPanel 组件
*
* 该组件作为SplitLayout的子组件表示一个可调整大小的面板
* 通过provide/inject机制与SplitLayout父组件通信实现面板的注册尺寸计算和更新
*/
import { ref, inject, computed, onMounted, onBeforeUnmount, useId } from 'vue' import { ref, inject, computed, onMounted, onBeforeUnmount, useId } from 'vue'
//
const props = defineProps({ const props = defineProps({
/**
* 面板的初始尺寸百分比
* 值为0时表示自动分配剩余空间
*/
size: { size: {
type: Number, type: Number,
default: 0, // 0 default: 0, // 0
}, },
/**
* 面板的最小尺寸像素
* 拖拽调整大小时不会小于此值
*/
minSize: { minSize: {
type: Number, type: Number,
default: 50, // default: 50, //
}, },
/**
* 面板的最大尺寸像素
* 值为0时表示无限制
*/
maxSize: { maxSize: {
type: Number, type: Number,
default: 0, // 0 default: 0, // 0
}, },
}) })
// ID
const id = useId() const id = useId()
// DOM
const paneRef = ref<HTMLElement | null>(null) const paneRef = ref<HTMLElement | null>(null)
// panes-1
const paneIndex = ref(-1) const paneIndex = ref(-1)
// Splitter /**
* 注入SplitLayout提供的上下文
* 通过inject获取父组件提供的方法和状态实现组件间通信
*/
const splitterContext = inject('splitter-context') as { const splitterContext = inject('splitter-context') as {
direction: { value: string } direction: { value: string }
panes: { value: Array<{ id: string; size: number; minSize: number; maxSize: number }> } panes: { value: Array<{ id: string; size: number; minSize: number; maxSize: number }> }
@ -28,13 +53,17 @@ const splitterContext = inject('splitter-context') as {
unregisterPane: (index: number) => void unregisterPane: (index: number) => void
} }
// /**
* 计算面板样式
* 根据方向和尺寸动态计算面板的CSS样式
*/
const paneStyle = computed(() => { const paneStyle = computed(() => {
const direction = splitterContext.direction.value const direction = splitterContext.direction.value
const isHorizontal = direction === 'horizontal' const isHorizontal = direction === 'horizontal'
// panes使panessize //
// 使propssize // 1. panes使size
// 2. 使propssize
let currentSize = props.size let currentSize = props.size
if (paneIndex.value >= 0 && paneIndex.value < splitterContext.panes.value.length) { if (paneIndex.value >= 0 && paneIndex.value < splitterContext.panes.value.length) {
currentSize = splitterContext.panes.value[paneIndex.value].size currentSize = splitterContext.panes.value[paneIndex.value].size
@ -50,13 +79,22 @@ const paneStyle = computed(() => {
} }
}) })
/**
* 组件挂载时向父组件注册此面板
* 将面板的id尺寸最小尺寸和最大尺寸传递给父组件
* 并获取返回的索引值用于后续更新和注销
*/
onMounted(() => { onMounted(() => {
// //
paneIndex.value = splitterContext.registerPane(id, props.size, props.minSize, props.maxSize) paneIndex.value = splitterContext.registerPane(id, props.size, props.minSize, props.maxSize)
}) })
/**
* 组件卸载前从父组件注销此面板
* 确保面板被正确移除避免内存泄漏和计算错误
*/
onBeforeUnmount(() => { onBeforeUnmount(() => {
// //
splitterContext.unregisterPane(paneIndex.value) splitterContext.unregisterPane(paneIndex.value)
}) })
</script> </script>