432 lines
13 KiB
Vue
432 lines
13 KiB
Vue
<script setup>
|
||
import { ref, onMounted, onUnmounted } from 'vue'
|
||
|
||
// 修复props和emit的使用方式
|
||
const props = defineProps({
|
||
direction: {
|
||
type: String,
|
||
default: 'vertical', // 'vertical' 或 'horizontal'
|
||
validator: (value) => ['vertical', 'horizontal'].includes(value)
|
||
},
|
||
position: {
|
||
type: String,
|
||
default: 'left', // 'left', 'right', 'top' 或 'bottom'
|
||
validator: (value) => ['left', 'right', 'top', 'bottom'].includes(value)
|
||
},
|
||
initialSize: {
|
||
type: Number,
|
||
default: null
|
||
},
|
||
minSize: {
|
||
type: Number,
|
||
default: 200
|
||
},
|
||
maxSize: {
|
||
type: Number,
|
||
default: 1000
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['resize', 'resizeEnd', 'handleClick', 'collapse', 'expand']) // 添加collapse和expand事件
|
||
|
||
const dividerRef = ref(null)
|
||
const isResizing = ref(false)
|
||
let startX = 0
|
||
let startY = 0
|
||
let startWidth = 0
|
||
let startHeight = 0
|
||
// 使用 ref 管理 currentSize 状态
|
||
// 注意:具体初始化值将在 onMounted 中设置,确保能正确访问 props
|
||
const currentSize = ref(0)
|
||
// 使用 ref 管理 isCollapsed 状态,与 isResizing 保持一致
|
||
const isCollapsed = ref(false) // 用于跟踪面板是否处于折叠状态
|
||
|
||
// 添加统一的边界检查函数
|
||
const clampSize = (size, min, max) => {
|
||
return Math.max(min, Math.min(max, size))
|
||
}
|
||
|
||
// 计算是否处于有效折叠状态(考虑右侧面板的边框影响)
|
||
const isPanelEffectivelyCollapsed = (elementSize, minSize) => {
|
||
// 对于右侧分隔条,需要特殊处理最小尺寸的判断
|
||
// 因为右侧面板有 border-left: 1px,即使宽度为 0,offsetWidth 也可能返回 1
|
||
return elementSize <= minSize + 1
|
||
}
|
||
|
||
// 计算鼠标移动的 delta 值
|
||
const calculateDelta = (e) => {
|
||
const deltaX = e.clientX - startX
|
||
const deltaY = e.clientY - startY
|
||
return { deltaX, deltaY }
|
||
}
|
||
|
||
// 获取目标元素
|
||
const getTargetElement = () => {
|
||
// 边界检查:确保dividerRef.value存在
|
||
if (!dividerRef.value) {
|
||
console.warn('可调整分隔条:dividerRef 不可用')
|
||
return null
|
||
}
|
||
|
||
// 根据position属性获取正确的相邻元素
|
||
let targetElement = null
|
||
if (props.position === 'left' || props.position === 'top') {
|
||
targetElement = dividerRef.value.previousElementSibling
|
||
} else if (props.position === 'right' || props.position === 'bottom') {
|
||
targetElement = dividerRef.value.nextElementSibling
|
||
}
|
||
|
||
return targetElement
|
||
}
|
||
|
||
// 计算新的尺寸
|
||
const calculateNewSize = (startSize, delta, minSize, maxSize, position) => {
|
||
let newSize = startSize
|
||
if (position === 'left' || position === 'top') {
|
||
// 左侧或顶部分隔条:向左/上拖减小尺寸,向右/下拖增大尺寸
|
||
// 当向左拖动时,delta 为负数,startSize + delta 会减小
|
||
// 当向右拖动时,delta 为正数,startSize + delta 会增大
|
||
newSize = startSize + delta
|
||
} else if (position === 'right' || position === 'bottom') {
|
||
// 右侧或底部分隔条:向左/上拖减小尺寸,向右/下拖增大尺寸
|
||
// 当向左拖动时,delta 为负数,startSize - delta 会增大
|
||
// 当向右拖动时,delta 为正数,startSize - delta 会减小
|
||
// 注意:这里的逻辑与左侧相反,因为右侧分隔条控制的是右侧面板
|
||
newSize = startSize - delta
|
||
}
|
||
return Math.max(minSize, Math.min(maxSize, newSize))
|
||
}
|
||
|
||
|
||
const onMouseDown = (e) => {
|
||
// 边界检查:确保dividerRef.value存在
|
||
if (!dividerRef.value) {
|
||
console.warn('可调整分隔条:dividerRef 不可用')
|
||
return
|
||
}
|
||
|
||
isResizing.value = true
|
||
startX = e.clientX
|
||
startY = e.clientY
|
||
|
||
// 获取目标元素
|
||
const targetElement = getTargetElement()
|
||
|
||
// 边界检查:确保targetElement存在
|
||
if (!targetElement) {
|
||
console.warn('可调整分隔条:未找到目标元素')
|
||
isResizing.value = false
|
||
return
|
||
}
|
||
|
||
// 获取相邻元素的初始尺寸
|
||
startWidth = targetElement.offsetWidth
|
||
startHeight = targetElement.offsetHeight
|
||
// 注意:不再重新初始化 currentSize,避免覆盖拖拽时的更新值
|
||
|
||
// 阻止默认行为,防止选中文本
|
||
e.preventDefault()
|
||
|
||
// 添加事件监听器到document
|
||
document.addEventListener('mousemove', onMouseMove)
|
||
document.addEventListener('mouseup', onMouseUp)
|
||
document.body.style.cursor = props.direction === 'vertical' ? 'col-resize' : 'row-resize'
|
||
|
||
// 添加拖拽样式
|
||
dividerRef.value.classList.add('resizing')
|
||
}
|
||
|
||
|
||
|
||
const onHandleClick = (e) => {
|
||
try {
|
||
// 阻止事件冒泡
|
||
e.stopPropagation()
|
||
|
||
// 边界检查:确保dividerRef.value存在
|
||
if (!dividerRef.value) {
|
||
console.warn('可调整分隔条:dividerRef 不可用')
|
||
return
|
||
}
|
||
|
||
// 添加简单的视觉反馈
|
||
dividerRef.value.classList.add('collapsing')
|
||
setTimeout(() => {
|
||
dividerRef.value?.classList.remove('collapsing')
|
||
}, 300)
|
||
|
||
// 获取目标元素
|
||
const targetElement = getTargetElement()
|
||
|
||
// 边界检查:确保targetElement存在
|
||
if (!targetElement) {
|
||
console.warn('可调整分隔条:未找到目标元素')
|
||
return
|
||
}
|
||
|
||
// 获取目标元素的当前尺寸
|
||
const currentElementSize = props.direction === 'vertical' ? targetElement.offsetWidth : targetElement.offsetHeight
|
||
|
||
// 使用统一的尺寸判断逻辑
|
||
const effectivelyCollapsed = isPanelEffectivelyCollapsed(currentElementSize, props.minSize)
|
||
|
||
// 计算切换后的尺寸
|
||
let newSize = 0
|
||
|
||
if (!effectivelyCollapsed) {
|
||
// 当前尺寸大于最小尺寸,切换到最小尺寸(折叠)
|
||
currentSize.value = clampSize(currentElementSize, props.minSize, props.maxSize) // 保存当前尺寸,以便稍后恢复
|
||
newSize = props.minSize
|
||
isCollapsed.value = true
|
||
// 只触发折叠事件,让父组件根据需要处理尺寸更新
|
||
emit('collapse', newSize)
|
||
} else {
|
||
// 当前尺寸等于最小尺寸,切换到之前保存的尺寸(展开)
|
||
// 确保 restoreSize 有合理的值
|
||
const restoreSize = clampSize(
|
||
currentSize.value > props.minSize ? currentSize.value :
|
||
(props.initialSize && props.initialSize > props.minSize ? props.initialSize : 200),
|
||
props.minSize, props.maxSize
|
||
)
|
||
newSize = restoreSize
|
||
isCollapsed.value = false
|
||
// 只触发展开事件,让父组件根据需要处理尺寸更新
|
||
emit('expand', newSize)
|
||
}
|
||
} catch (error) {
|
||
console.error('可调整分隔条:处理单击事件时出错:', error)
|
||
}
|
||
}
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isResizing.value) return
|
||
|
||
// 计算鼠标移动的 delta 值
|
||
const { deltaX, deltaY } = calculateDelta(e)
|
||
|
||
let newSize = 0
|
||
|
||
if (props.direction === 'vertical') {
|
||
// 垂直分隔条:计算新的宽度
|
||
newSize = calculateNewSize(startWidth, deltaX, props.minSize, props.maxSize, props.position)
|
||
|
||
// 获取目标元素的实际尺寸
|
||
const targetElement = getTargetElement()
|
||
const currentElementSize = targetElement ? targetElement.offsetWidth : newSize
|
||
|
||
// 使用统一的尺寸判断逻辑
|
||
const effectivelyCollapsed = isPanelEffectivelyCollapsed(currentElementSize, props.minSize)
|
||
|
||
// 如果不是折叠状态,更新 currentSize
|
||
if (!effectivelyCollapsed) {
|
||
currentSize.value = clampSize(newSize, props.minSize, props.maxSize)
|
||
isCollapsed.value = false
|
||
} else {
|
||
isCollapsed.value = true
|
||
}
|
||
emit('resize', newSize)
|
||
} else {
|
||
// 水平分隔条:计算新的高度
|
||
newSize = calculateNewSize(startHeight, deltaY, props.minSize, props.maxSize, props.position)
|
||
|
||
// 获取目标元素的实际尺寸
|
||
const targetElement = getTargetElement()
|
||
const currentElementSize = targetElement ? targetElement.offsetHeight : newSize
|
||
|
||
// 使用统一的尺寸判断逻辑
|
||
const effectivelyCollapsed = isPanelEffectivelyCollapsed(currentElementSize, props.minSize)
|
||
|
||
// 如果不是折叠状态,更新 currentSize
|
||
if (!effectivelyCollapsed) {
|
||
currentSize.value = clampSize(newSize, props.minSize, props.maxSize)
|
||
isCollapsed.value = false
|
||
} else {
|
||
isCollapsed.value = true
|
||
}
|
||
emit('resize', newSize)
|
||
}
|
||
}
|
||
|
||
const onMouseUp = (e) => {
|
||
isResizing.value = false
|
||
|
||
// 计算鼠标移动的 delta 值
|
||
const deltaX = e.clientX - startX
|
||
const deltaY = e.clientY - startY
|
||
|
||
// 计算最终尺寸
|
||
const finalSize = props.direction === 'vertical' ?
|
||
calculateNewSize(startWidth, deltaX, props.minSize, props.maxSize, props.position) :
|
||
calculateNewSize(startHeight, deltaY, props.minSize, props.maxSize, props.position)
|
||
|
||
// 获取目标元素的实际尺寸
|
||
const targetElement = getTargetElement()
|
||
const currentElementSize = targetElement ?
|
||
(props.direction === 'vertical' ? targetElement.offsetWidth : targetElement.offsetHeight) :
|
||
finalSize
|
||
|
||
// 使用统一的尺寸判断逻辑
|
||
const effectivelyCollapsed = isPanelEffectivelyCollapsed(currentElementSize, props.minSize)
|
||
|
||
// 如果不是折叠状态,更新 currentSize
|
||
if (!effectivelyCollapsed) {
|
||
currentSize.value = clampSize(finalSize, props.minSize, props.maxSize)
|
||
}
|
||
|
||
// 触发拖拽结束事件
|
||
emit('resizeEnd', finalSize)
|
||
|
||
// 移除事件监听器
|
||
document.removeEventListener('mousemove', onMouseMove)
|
||
document.removeEventListener('mouseup', onMouseUp)
|
||
document.body.style.cursor = ''
|
||
|
||
// 移除拖拽样式
|
||
if (dividerRef.value) {
|
||
dividerRef.value.classList.remove('resizing')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 初始化 currentSize 为合理的默认值
|
||
if (props.initialSize && props.initialSize > props.minSize) {
|
||
currentSize.value = clampSize(props.initialSize, props.minSize, props.maxSize)
|
||
isCollapsed.value = false
|
||
} else {
|
||
currentSize.value = 200 // 默认值
|
||
isCollapsed.value = false
|
||
}
|
||
|
||
// 初始化时设置初始尺寸
|
||
if (props.initialSize) {
|
||
emit('resize', props.initialSize)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
// 清理全局事件监听器,避免内存泄漏
|
||
document.removeEventListener('mousemove', onMouseMove)
|
||
document.removeEventListener('mouseup', onMouseUp)
|
||
|
||
// 清理DOM相关资源
|
||
if (dividerRef.value) {
|
||
// 移除拖拽和折叠样式
|
||
dividerRef.value.classList.remove('resizing', 'collapsing')
|
||
}
|
||
|
||
// 清理全局样式
|
||
document.body.style.cursor = ''
|
||
|
||
// 重置组件状态
|
||
isResizing.value = false
|
||
isCollapsed.value = false
|
||
|
||
// Vue的响应式数据会自动清理,无需额外处理
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="dividerRef"
|
||
class="cube-splitter"
|
||
:class="direction"
|
||
@mousedown="onMouseDown"
|
||
>
|
||
<div class="splitter-handle" @click.stop.prevent="onHandleClick"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* CSS 变量定义 */
|
||
:root {
|
||
--cube-splitter-color: #e0e0e0;
|
||
--cube-splitter-hover-color: #667eea;
|
||
--cube-splitter-active-color: #5568d3;
|
||
--cube-splitter-handle-color: #fff;
|
||
--cube-splitter-handle-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
|
||
--cube-splitter-collapsing-color: #667eea;
|
||
--cube-splitter-collapsing-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
|
||
--cube-splitter-transition-speed: 0.3s;
|
||
}
|
||
|
||
.cube-splitter {
|
||
position: relative;
|
||
background-color: var(--cube-splitter-color);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
/* 确保可以接收鼠标事件 */
|
||
cursor: pointer;
|
||
user-select: none;
|
||
flex-shrink: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
.cube-splitter:hover {
|
||
background-color: var(--cube-splitter-hover-color);
|
||
}
|
||
|
||
.cube-splitter.vertical {
|
||
width: 6px;
|
||
height: 100%;
|
||
cursor: col-resize;
|
||
}
|
||
|
||
.cube-splitter.horizontal {
|
||
width: 100%;
|
||
height: 6px;
|
||
cursor: row-resize;
|
||
}
|
||
|
||
.splitter-handle {
|
||
position: absolute;
|
||
opacity: 0;
|
||
}
|
||
|
||
.cube-splitter.vertical .splitter-handle {
|
||
width: 4px;
|
||
height: 40px;
|
||
background-color: var(--cube-splitter-handle-color);
|
||
box-shadow: var(--cube-splitter-handle-shadow);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.cube-splitter.horizontal .splitter-handle {
|
||
width: 40px;
|
||
height: 4px;
|
||
background-color: var(--cube-splitter-handle-color);
|
||
box-shadow: var(--cube-splitter-handle-shadow);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.cube-splitter:hover .splitter-handle {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* 拖拽时的样式 */
|
||
.cube-splitter:active,
|
||
.cube-splitter.resizing {
|
||
background-color: var(--cube-splitter-active-color);
|
||
transition: none;
|
||
}
|
||
|
||
.cube-splitter:active .splitter-handle,
|
||
.cube-splitter.resizing .splitter-handle {
|
||
opacity: 1;
|
||
transition: none;
|
||
}
|
||
|
||
/* 折叠/展开时的视觉反馈 */
|
||
.cube-splitter.collapsing {
|
||
background-color: var(--cube-splitter-collapsing-color);
|
||
transition: background-color var(--cube-splitter-transition-speed) ease;
|
||
box-shadow: var(--cube-splitter-collapsing-shadow);
|
||
}
|
||
|
||
.cube-splitter.collapsing .splitter-handle {
|
||
opacity: 1;
|
||
transform: scale(1.1);
|
||
transition: transform var(--cube-splitter-transition-speed) ease;
|
||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.8);
|
||
}
|
||
</style> |