增加Splitter

This commit is contained in:
zqm
2026-01-29 16:29:09 +08:00
parent e8cff47482
commit 9c0c1f3439
3 changed files with 637 additions and 7 deletions

View File

@@ -22,15 +22,217 @@ yarn add joyd.web.vue.cubelib
## 使用
```javascript
import { CubeButton } from 'joyd.web.vue.cubelib'
### 全局注册
app.use(CubeButton)
```javascript
import { createApp } from 'vue'
import App from './App.vue'
import CubeLib from 'joyd.web.vue.cubelib'
const app = createApp(App)
app.use(CubeLib)
app.mount('#app')
```
## 文档
### 按需导入
查看 [在线文档](https://docs.joyd.com) 获取详细的使用说明和示例。
```javascript
import { CubeButton, CubeSplitter } from 'joyd.web.vue.cubelib'
// 在组件中使用
export default {
components: {
CubeButton,
CubeSplitter
}
}
```
## 组件文档
### CubeButton
按钮组件,提供基本的按钮样式和交互功能。
#### Props
- `variant`: 按钮变体,可选值:`primary`, `secondary`, `outline`, `ghost`
- `size`: 按钮尺寸,可选值:`sm`, `md`, `lg`
- `disabled`: 是否禁用按钮
#### Events
- `click`: 点击按钮时触发
### CubeSplitter
可调整大小的分隔条组件,用于在布局中创建可调整大小的面板。
#### Props
- `direction`: 分隔条方向,可选值:`vertical` (垂直), `horizontal` (水平)
- `position`: 分隔条位置,可选值:`left`, `right`, `top`, `bottom`
- `initialSize`: 初始尺寸
- `minSize`: 最小尺寸
- `maxSize`: 最大尺寸
#### Events
- `resize`: 调整大小时触发
- `resizeEnd`: 调整大小结束时触发
- `collapse`: 折叠面板时触发
- `expand`: 展开面板时触发
#### 使用示例
```vue
<template>
<div class="container">
<div class="left-panel" :style="{ width: leftPanelWidth + 'px' }"></div>
<CubeSplitter
direction="vertical"
position="left"
:min-size="50"
:max-size="500"
:initial-size="200"
@resize="onLeftResize"
@resize-end="onLeftResizeEnd"
@collapse="onLeftCollapse"
@expand="onLeftExpand"
/>
<div class="middle-panel"></div>
<CubeSplitter
direction="vertical"
position="right"
:min-size="0"
:max-size="500"
:initial-size="200"
@resize="onRightResize"
@resize-end="onRightResizeEnd"
@collapse="onRightCollapse"
@expand="onRightExpand"
/>
<div class="right-panel" :style="{ width: rightPanelWidth + 'px' }"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const leftPanelWidth = ref(200)
const rightPanelWidth = ref(200)
// 从 localStorage 加载保存的尺寸
onMounted(() => {
const savedLeftWidth = localStorage.getItem('leftPanelWidth')
const savedRightWidth = localStorage.getItem('rightPanelWidth')
if (savedLeftWidth) {
leftPanelWidth.value = parseInt(savedLeftWidth)
}
if (savedRightWidth) {
rightPanelWidth.value = parseInt(savedRightWidth)
}
})
// 左侧分隔条调整
const onLeftResize = (newWidth) => {
leftPanelWidth.value = newWidth
}
const onLeftResizeEnd = (finalWidth) => {
// 只保存正常尺寸,不保存折叠状态
if (finalWidth > 50) {
localStorage.setItem('leftPanelWidth', finalWidth.toString())
}
}
const onLeftCollapse = (newSize) => {
leftPanelWidth.value = newSize
}
const onLeftExpand = (newSize) => {
leftPanelWidth.value = newSize
}
// 右侧分隔条调整
const onRightResize = (newWidth) => {
rightPanelWidth.value = newWidth
}
const onRightResizeEnd = (finalWidth) => {
// 只保存正常尺寸,不保存折叠状态
if (finalWidth > 0) {
localStorage.setItem('rightPanelWidth', finalWidth.toString())
}
}
const onRightCollapse = (newSize) => {
rightPanelWidth.value = newSize
}
const onRightExpand = (newSize) => {
rightPanelWidth.value = newSize
}
</script>
<style scoped>
.container {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
}
.left-panel {
background-color: #f0f0f0;
min-width: 50px;
}
.middle-panel {
flex: 1;
background-color: #ffffff;
}
.right-panel {
background-color: #f0f0f0;
min-width: 0;
border-left: 1px solid #e0e0e0;
}
</style>
```
#### CSS 变量
CubeSplitter 组件支持通过 CSS 变量进行定制:
```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;
}
```
## 常见问题解答
### Q: 如何保存分隔条的位置?
A: 可以使用 `@resize-end` 事件来保存最终的尺寸到 localStorage 或其他存储中,然后在组件挂载时从存储中加载。
### Q: 如何在最小尺寸和当前尺寸之间切换?
A: 单击分隔条的句柄即可在最小尺寸和当前尺寸之间切换。
### Q: 为什么右侧分隔条的拖拽方向与左侧不同?
A: CubeSplitter 组件统一了拖拽方向逻辑:向左/上拖动减小尺寸,向右/下拖动增大尺寸,无论分隔条位置是左还是右。
## 贡献

View File

@@ -0,0 +1,426 @@
<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即使宽度为 0offsetWidth 也可能返回 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' || 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>

View File

@@ -1,8 +1,10 @@
import { App, defineComponent } from 'vue'
import CubeButton from './components/CubeButton.vue'
import CubeSplitter from './components/CubeSplitter.vue'
const components = {
CubeButton
CubeButton,
CubeSplitter
}
const install = (app: App) => {
@@ -17,4 +19,4 @@ const CubeLib = {
}
export default CubeLib
export { CubeButton }
export { CubeButton, CubeSplitter }