20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'
|
||||
import SplitterPanel from './panel.vue'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
/** 分割方向:horizontal 水平 / vertical 垂直 */
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (v) => ['horizontal', 'vertical'].includes(v),
|
||||
},
|
||||
/** 分割线是否仅在 hover / 聚焦 / 拖拽时显示,默认常显 */
|
||||
barOnHover: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['resize-start', 'resize', 'resize-end'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const rootRef = ref(null)
|
||||
// 拖拽/非受控后的固定尺寸(px,undefined 表示仍由 flex 自动分配或走 default-size)
|
||||
const innerSizes = ref([])
|
||||
// 容器主轴尺寸(px),用于百分比换算与 ARIA 值
|
||||
const containerSize = ref(0)
|
||||
// 当前拖拽的分隔条索引,-1 表示无
|
||||
const activeBar = ref(-1)
|
||||
|
||||
// 拖拽过程中的临时状态(普通变量,不驱动渲染)
|
||||
let dragState = null
|
||||
let rafId = null
|
||||
let pendingEvent = null
|
||||
|
||||
const isVertical = computed(() => props.direction === 'vertical')
|
||||
|
||||
// 收集默认插槽中的 SplitterPanel 节点,过滤掉注释/空节点;
|
||||
// 统一为 { node, props } 结构:Vue 中未传 props 的 VNode 其 props 为 null,
|
||||
// 规范化后后续访问 panel.props.xxx 安全
|
||||
const panelItems = computed(() =>
|
||||
(slots.default?.() ?? [])
|
||||
.filter((node) => node.type === SplitterPanel)
|
||||
.map((node) => ({ node, props: node.props ?? {} })),
|
||||
)
|
||||
|
||||
/* ---------- 尺寸工具 ---------- */
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
// number 按 px,百分比字符串按容器换算,数字字符串按 px 解析
|
||||
function toPx(value, container) {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'number') return value
|
||||
const text = String(value).trim()
|
||||
if (text.endsWith('%')) return (Number.parseFloat(text) / 100) * container
|
||||
return Number.parseFloat(text)
|
||||
}
|
||||
|
||||
function measureContainer() {
|
||||
const el = rootRef.value
|
||||
if (!el) return 0
|
||||
return isVertical.value ? el.clientHeight : el.clientWidth
|
||||
}
|
||||
|
||||
// 读取当前各面板实际 px(仅拖拽开始时调用)
|
||||
function measurePanelSizes() {
|
||||
const nodes = rootRef.value?.querySelectorAll('.fms-splitter-panel') ?? []
|
||||
return Array.from(nodes).map((el) => (isVertical.value ? el.offsetHeight : el.offsetWidth))
|
||||
}
|
||||
|
||||
// 传给面板的显示尺寸:受控 size > 内部固定 > default-size
|
||||
function displaySize(index) {
|
||||
const panel = panelItems.value[index]
|
||||
if (!panel) return undefined
|
||||
if (panel.props.size !== undefined) return panel.props.size
|
||||
if (innerSizes.value[index] !== undefined) return innerSizes.value[index]
|
||||
return panel.props.defaultSize
|
||||
}
|
||||
|
||||
// 分隔条两侧面板都可调整时才允许拖拽/聚焦
|
||||
function canResize(index) {
|
||||
const prev = panelItems.value[index]
|
||||
const next = panelItems.value[index + 1]
|
||||
return prev && next && prev.props.resizable !== false && next.props.resizable !== false
|
||||
}
|
||||
|
||||
/* ---------- ARIA 值(预计算,避免模板内计算) ---------- */
|
||||
const ariaValues = computed(() => {
|
||||
const container = containerSize.value
|
||||
const items = panelItems.value
|
||||
if (!container || items.length < 2) return []
|
||||
// 各面板估算 px:内部固定 > size/default-size 换算 > 自动均分剩余
|
||||
const pxList = items.map((panel, i) => {
|
||||
if (innerSizes.value[i] !== undefined) return innerSizes.value[i]
|
||||
const raw = panel.props.size ?? panel.props.defaultSize
|
||||
return toPx(raw, container)
|
||||
})
|
||||
const known = pxList.filter((v) => v !== undefined)
|
||||
const knownTotal = known.reduce((sum, v) => sum + v, 0)
|
||||
const autoCount = pxList.length - known.length
|
||||
const autoPx = autoCount ? (container - knownTotal) / autoCount : 0
|
||||
const final = pxList.map((v) => v ?? autoPx)
|
||||
|
||||
const result = []
|
||||
let acc = 0
|
||||
for (let i = 0; i < items.length - 1; i += 1) {
|
||||
acc += final[i]
|
||||
const prevMin = toPx(items[i].props.min, container) ?? 0
|
||||
const nextMin = toPx(items[i + 1].props.min, container) ?? 0
|
||||
result.push({
|
||||
now: Math.round((acc / container) * 100),
|
||||
min: Math.round((prevMin / container) * 100),
|
||||
max: Math.round(100 - (nextMin / container) * 100),
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
/* ---------- 拖拽 ---------- */
|
||||
function applyOffset(index, startSizes, container, delta) {
|
||||
let offset = delta
|
||||
const prev = startSizes[index]
|
||||
const next = startSizes[index + 1]
|
||||
const prevMin = toPx(panelItems.value[index].props.min, container) ?? 0
|
||||
const prevMax = toPx(panelItems.value[index].props.max, container) ?? container
|
||||
const nextMin = toPx(panelItems.value[index + 1].props.min, container) ?? 0
|
||||
const nextMax = toPx(panelItems.value[index + 1].props.max, container) ?? container
|
||||
offset = clamp(offset, prevMin - prev, prevMax - prev)
|
||||
offset = clamp(offset, next - nextMax, next - nextMin)
|
||||
const result = [...startSizes]
|
||||
result[index] = prev + offset
|
||||
result[index + 1] = next - offset
|
||||
return result
|
||||
}
|
||||
|
||||
function applySizes(result, index) {
|
||||
// 只固定分隔条两侧面板,其余仍保持自动分配
|
||||
const next = [...innerSizes.value]
|
||||
next[index] = result[index]
|
||||
next[index + 1] = result[index + 1]
|
||||
innerSizes.value = next
|
||||
}
|
||||
|
||||
function onBarPointerDown(index, event) {
|
||||
if (event.button !== 0 || !canResize(index)) return
|
||||
event.preventDefault()
|
||||
const container = measureContainer()
|
||||
const startSizes = measurePanelSizes()
|
||||
dragState = { index, startX: event.clientX, startY: event.clientY, startSizes, container }
|
||||
activeBar.value = index
|
||||
containerSize.value = container
|
||||
emit('resize-start', [...startSizes])
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.webkitUserSelect = 'none'
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
// 用 rAF 合并高频指针事件
|
||||
pendingEvent = event
|
||||
if (rafId == null) {
|
||||
rafId = requestAnimationFrame(processMove)
|
||||
}
|
||||
}
|
||||
|
||||
function processMove() {
|
||||
rafId = null
|
||||
const event = pendingEvent
|
||||
pendingEvent = null
|
||||
if (!event || !dragState) return
|
||||
const { index, startX, startY, startSizes, container } = dragState
|
||||
const delta = isVertical.value ? event.clientY - startY : event.clientX - startX
|
||||
const result = applyOffset(index, startSizes, container, delta)
|
||||
applySizes(result, index)
|
||||
emit('resize', [...result])
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
pendingEvent = null
|
||||
}
|
||||
if (!dragState) return
|
||||
const { index, startSizes } = dragState
|
||||
// 发送最终尺寸:以拖拽起点为基础,覆盖两侧面板的最新内部尺寸
|
||||
const finalSizes = [...startSizes]
|
||||
if (innerSizes.value[index] !== undefined) finalSizes[index] = innerSizes.value[index]
|
||||
if (innerSizes.value[index + 1] !== undefined) finalSizes[index + 1] = innerSizes.value[index + 1]
|
||||
dragState = null
|
||||
activeBar.value = -1
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.webkitUserSelect = ''
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
emit('resize-end', finalSizes)
|
||||
}
|
||||
|
||||
/* ---------- 键盘操作 ---------- */
|
||||
function onBarKeydown(index, event) {
|
||||
if (!canResize(index)) return
|
||||
const step = event.shiftKey ? 50 : 10
|
||||
let delta = 0
|
||||
if (isVertical.value) {
|
||||
if (event.key === 'ArrowUp') delta = -step
|
||||
else if (event.key === 'ArrowDown') delta = step
|
||||
} else {
|
||||
if (event.key === 'ArrowLeft') delta = -step
|
||||
else if (event.key === 'ArrowRight') delta = step
|
||||
}
|
||||
if (!delta) return
|
||||
event.preventDefault()
|
||||
const container = measureContainer()
|
||||
const startSizes = measurePanelSizes()
|
||||
const result = applyOffset(index, startSizes, container, delta)
|
||||
applySizes(result, index)
|
||||
emit('resize', [...result])
|
||||
emit('resize-end', [...result])
|
||||
}
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
function syncContainer() {
|
||||
containerSize.value = measureContainer()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncContainer()
|
||||
window.addEventListener('resize', syncContainer)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncContainer)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
if (rafId != null) cancelAnimationFrame(rafId)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.webkitUserSelect = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="fms-splitter" :class="`fms-splitter-${direction}`">
|
||||
<template v-for="(item, index) in panelItems" :key="index">
|
||||
<component :is="item.node" :size="displaySize(index)" />
|
||||
<div
|
||||
v-if="index < panelItems.length - 1"
|
||||
class="fms-splitter-bar"
|
||||
:class="{
|
||||
'fms-splitter-bar-active': activeBar === index,
|
||||
'fms-splitter-bar-hover': barOnHover,
|
||||
}"
|
||||
role="separator"
|
||||
:aria-orientation="isVertical ? 'horizontal' : 'vertical'"
|
||||
:aria-valuenow="ariaValues[index]?.now ?? 50"
|
||||
:aria-valuemin="ariaValues[index]?.min ?? 0"
|
||||
:aria-valuemax="ariaValues[index]?.max ?? 100"
|
||||
:tabindex="canResize(index) ? 0 : undefined"
|
||||
@pointerdown="onBarPointerDown(index, $event)"
|
||||
@keydown="onBarKeydown(index, $event)"
|
||||
>
|
||||
<span class="fms-splitter-bar-grip" aria-hidden="true"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
Reference in new issue
Block a user