20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
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>