Files
workspace/code/fms/fms-vue/src/components/ui/splitter/splitter.vue
T
2026-08-16 22:01:32 +08:00

272 lines
9.3 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.
<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>