272 lines
9.3 KiB
Vue
272 lines
9.3 KiB
Vue
<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>
|