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,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>