20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,143 @@
|
||||
/* ---------- 容器 ---------- */
|
||||
.fms-splitter {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fms-splitter-horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.fms-splitter-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- 面板 ---------- */
|
||||
.fms-splitter-panel {
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
/* 允许 flex 子项收缩,避免内容撑破布局 */
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------- 分隔条 ---------- */
|
||||
.fms-splitter-bar {
|
||||
position: relative;
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 命中区:6px;水平方向调整宽度,垂直方向调整高度 */
|
||||
.fms-splitter-horizontal .fms-splitter-bar {
|
||||
width: var(--fms-splitter-trigger-size);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.fms-splitter-vertical .fms-splitter-bar {
|
||||
height: var(--fms-splitter-trigger-size);
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
/* 视觉条:居中 2px 细线 */
|
||||
.fms-splitter-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--fms-border);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.fms-splitter-horizontal .fms-splitter-bar::before {
|
||||
width: var(--fms-splitter-bar-size);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fms-splitter-vertical .fms-splitter-bar::before {
|
||||
width: 100%;
|
||||
height: var(--fms-splitter-bar-size);
|
||||
}
|
||||
|
||||
/* 中央手柄:hover/聚焦/拖拽时高亮,提示可拖 */
|
||||
.fms-splitter-bar-grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--fms-text-secondary);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.fms-splitter-horizontal .fms-splitter-bar-grip {
|
||||
width: var(--fms-splitter-bar-size);
|
||||
height: var(--fms-splitter-handle-size);
|
||||
}
|
||||
|
||||
.fms-splitter-vertical .fms-splitter-bar-grip {
|
||||
width: var(--fms-splitter-handle-size);
|
||||
height: var(--fms-splitter-bar-size);
|
||||
}
|
||||
|
||||
.fms-splitter-bar:hover .fms-splitter-bar-grip,
|
||||
.fms-splitter-bar-active .fms-splitter-bar-grip,
|
||||
.fms-splitter-bar:focus-visible .fms-splitter-bar-grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fms-splitter-bar:hover::before,
|
||||
.fms-splitter-bar-active::before,
|
||||
.fms-splitter-bar:focus-visible::before {
|
||||
background: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
/* hover 模式(barOnHover):分割线平时隐藏,hover / 聚焦 / 拖拽时才显示。
|
||||
线宽固定为 --fms-splitter-bar-size-hover(居中,略宽于命中区),
|
||||
命中区保持 6px 不变,拖拽热区不随之扩大 */
|
||||
.fms-splitter-bar-hover::before {
|
||||
background: var(--fms-text-secondary);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fms-splitter-horizontal .fms-splitter-bar-hover::before {
|
||||
width: var(--fms-splitter-bar-size-hover);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fms-splitter-vertical .fms-splitter-bar-hover::before {
|
||||
width: 100%;
|
||||
height: var(--fms-splitter-bar-size-hover);
|
||||
}
|
||||
|
||||
.fms-splitter-bar-hover .fms-splitter-bar-grip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fms-splitter-bar-hover:hover::before,
|
||||
.fms-splitter-bar-hover.fms-splitter-bar-active::before,
|
||||
.fms-splitter-bar-hover:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 键盘焦点 */
|
||||
.fms-splitter-bar:focus-visible {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fms-splitter-bar::before,
|
||||
.fms-splitter-bar-grip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
/** 面板最小尺寸(px 数字或百分比字符串),拖拽约束用 */
|
||||
min: { type: [Number, String], default: undefined },
|
||||
/** 面板最大尺寸(px 数字或百分比字符串),拖拽约束用 */
|
||||
max: { type: [Number, String], default: undefined },
|
||||
/** 受控尺寸:由 Splitter 注入或父级传入;传入后拖拽只触发 resize 事件,显示跟随该值 */
|
||||
size: { type: [Number, String], default: undefined },
|
||||
/** 非受控初始尺寸(px 数字或百分比字符串),拖拽后由内部接管 */
|
||||
defaultSize: { type: [Number, String], default: undefined },
|
||||
/** 是否允许通过相邻分隔条调整尺寸 */
|
||||
resizable: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
/* ---------- 公共 computed ---------- */
|
||||
// 有尺寸时固定 flexBasis,否则由 flex 自动分配剩余空间
|
||||
const panelStyle = computed(() => {
|
||||
if (props.size != null && props.size !== '') {
|
||||
const basis = typeof props.size === 'number' ? `${props.size}px` : props.size
|
||||
return { flexBasis: basis, flexGrow: 0, flexShrink: 0 }
|
||||
}
|
||||
return { flexBasis: '0%', flexGrow: 1, flexShrink: 1 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fms-splitter-panel" :style="panelStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -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