This commit is contained in:
2026-09-12 14:19:56 +08:00
commit 87ab0b794b
130 changed files with 30042 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
/**
* 可拖拽分隔条 —— 编辑器↔结果区(vertical) 和 主区↔终端(horizontal) 共用。
*
* 设计要点:
* - 受控:不持有尺寸,只把像素变化交给 onDelta,父组件用 useResizableFraction
* 把像素换算成 fraction 并 clamp。父组件负责持久化 / 多窗口同步。
* - role="separator":SR 把它当可调分隔条读出来。aria-orientation / aria-valuenow /
* aria-valuemin / aria-valuemax 让 SR 知道当前尺寸比例;aria-controls 标两侧面板。
* - 键盘可调:ArrowLeft/Right(vertical)/ArrowUp/Down(horizontal)每次 step 像素
* (默认 16);Shift+Arrow、PageUp/Down 走大步进(step * 8);Home/End 跳到 max 边界
* (传超出范围的值让父组件 clamp 到 min/max)。
* - 拖拽:onPointerDown 启动,document 上同时监听 pointermove / pointerup /
* pointercancel —— 用户拖到 splitter 外面也能继续;jsdom 里 fireEvent 触发的
* 事件冒泡到 document 也走这条路径。
* - setPointerCapture 让用户在 splitter 之外松手也能正常结束拖拽。
* - 拖拽期间锁 body cursor + user-select:防止鼠标移出 splitter 后 cursor 恢复默认。
* pointerup / pointercancel 时还原原始值(用 bodyLockRef 标记「是我锁的」避免
* 多次 lock 互相覆盖)。
* - data-orientation / data-dragging 属性:测试 + CSS 钩子用,不参与 a11y。
* data-dragging 直接 ref + setAttribute 同步写,不用 React state —— 测试在
* pointerdown 同步返回后立刻断言,那时 useState 还没 commit。
* - aria-valuenow / valuemin / valuemax 可选:不传就不输出该属性,允许 splitter
* 在「还没拿到尺寸」等过渡场景下不暴露误导性的 0 值。
* - useRef 缓存 onDelta 避免每次父组件 re-render 都重建监听(拖动过程中
* 父组件 setState 会触发 re-render)。
*/
import { memo, useCallback, useEffect, useRef } from 'react'
type Orientation = 'vertical' | 'horizontal'
interface Props {
/** vertical = 分隔左右两栏(拖动方向是 X);horizontal = 分隔上下两区(拖动方向是 Y) */
orientation: Orientation
/** 鼠标 / 触屏拖拽时调用,参数是像素 delta(直接交给 applyDelta 即可) */
onDelta: (deltaPx: number) => void
/** 屏幕阅读器读出来的标签(中文/英文由父组件从 i18n 取) */
ariaLabel: string
/** 两侧面板的 id,给 aria-controls 用 —— 不传则省略该 ARIA 属性 */
ariaControls?: string[]
/** 当前 fraction / px,aria-valuenow 直接给百分比或 px 都行。不传则不输出该属性。 */
ariaValueNow?: number
ariaValueMin?: number
ariaValueMax?: number
/** 键盘箭头单步(px),默认 16。Shift / PageUp / PageDown 用 step * 8。 */
step?: number
}
function Splitter({
orientation,
onDelta,
ariaLabel,
ariaControls,
ariaValueNow,
ariaValueMin,
ariaValueMax,
step = 16
}: Props) {
const elRef = useRef<HTMLDivElement>(null)
// 用 ref 缓存 callback,避免父组件 re-render 时拖到一半 onDelta 引用变了导致闭包捕获错值
const onDeltaRef = useRef(onDelta)
onDeltaRef.current = onDelta
// 拖拽态全用 ref —— 同步访问,React state 那时还没 commit
const draggingRef = useRef(false)
const lastPosRef = useRef<number | null>(null)
// 拖拽期间锁 body cursor + user-select 的原始值,pointerup 时还原 —— 多 splitters 共存,
// 但同一时刻只有一个在拖,bodyLockRef 作「我锁的」标记,避免相互覆盖
const bodyLockRef = useRef<{ cursor: string; userSelect: string } | null>(null)
const setDraggingAttr = useCallback((val: boolean) => {
if (elRef.current) {
elRef.current.setAttribute('data-dragging', val ? 'true' : 'false')
}
}, [])
const lockBody = useCallback(() => {
if (bodyLockRef.current) return
bodyLockRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect
}
document.body.style.cursor = orientation === 'vertical' ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
}, [orientation])
const releaseBody = useCallback(() => {
if (!bodyLockRef.current) return
document.body.style.cursor = bodyLockRef.current.cursor
document.body.style.userSelect = bodyLockRef.current.userSelect
bodyLockRef.current = null
}, [])
const endDrag = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
lastPosRef.current = null
setDraggingAttr(false)
releaseBody()
}, [setDraggingAttr, releaseBody])
// 拖拽中监听 document:用户拖到 splitter 外面也能继续;jsdom 里 fireEvent.pointerMove(sep)
// 会冒泡到 document,所以这一份也能接住测试事件。这是唯一一份 move 监听 —— 不在
// React 元素上挂 onPointerMove,否则 document 监听和 React 合成事件会重复触发 onDelta。
useEffect(() => {
const onDocMove = (e: PointerEvent) => {
if (!draggingRef.current) return
const last = lastPosRef.current
if (last === null) return
const current = orientation === 'vertical' ? e.clientX : e.clientY
const delta = current - last
if (delta === 0) return // 静止不重复触发
onDeltaRef.current(delta)
lastPosRef.current = current
}
const onDocUp = () => endDrag()
document.addEventListener('pointermove', onDocMove)
document.addEventListener('pointerup', onDocUp)
document.addEventListener('pointercancel', onDocUp)
return () => {
document.removeEventListener('pointermove', onDocMove)
document.removeEventListener('pointerup', onDocUp)
document.removeEventListener('pointercancel', onDocUp)
}
}, [orientation, endDrag])
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// 只响应主键;中键右键让浏览器自己处理(右键菜单等)
if (e.button !== 0) return
e.preventDefault()
draggingRef.current = true
lastPosRef.current = orientation === 'vertical' ? e.clientX : e.clientY
setDraggingAttr(true)
lockBody()
// pointer capture:拖到 splitter 外面也能继续,松手在别处也走 pointerup
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
/* 某些测试环境无 Pointer Capture,忽略 */
}
}
const onPointerUpLocal = (e: React.PointerEvent<HTMLDivElement>) => {
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
} catch {
/* 某些测试环境(jsdom)无 Pointer Capture 方法,忽略 */
}
// element-level pointerup 也走 endDrag(document 监听会兜底,这里幂等)
endDrag()
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// 键盘控制:箭头单步,Shift/PageUp/PageDown 大步进,Home/End 跳到 max 边界
// (传 ±(max+1) 让父组件 clamp 到 min/max —— 父组件是 fraction 的话会 saturate 到 0/1)
const isVertical = orientation === 'vertical'
const bigStep = step * 8
let delta: number | null = null
switch (e.key) {
case 'ArrowLeft':
if (isVertical) delta = e.shiftKey ? -bigStep : -step
break
case 'ArrowRight':
if (isVertical) delta = e.shiftKey ? bigStep : step
break
case 'ArrowUp':
if (!isVertical) delta = e.shiftKey ? -bigStep : -step
break
case 'ArrowDown':
if (!isVertical) delta = e.shiftKey ? bigStep : step
break
case 'PageUp':
delta = -bigStep
break
case 'PageDown':
delta = bigStep
break
case 'Home':
// ariaValueMax 没传时 Home/End 没有语义边界,直接 swallow —— 之前
// ariaValueMax ?? 0 + 1 = 1 会以「1 像素 delta」被发出,在「容器还没拿到尺寸」
// 这类过渡场景里产生一次莫名其妙的 1px 拖拽。
if (ariaValueMax === undefined) return
delta = -ariaValueMax - 1
break
case 'End':
if (ariaValueMax === undefined) return
delta = ariaValueMax + 1
break
default:
return
}
if (delta === null) return
e.preventDefault()
onDeltaRef.current(delta)
}
// vertical splitter 是左右两栏中间的一条竖线。
// 视觉保持 1px (w-px / h-px) 不变 —— 装饰线本来就该细;鼠标命中区由 before: pseudo
// 元素扩展到 20px 宽,以 1px 装饰线为中心 (left-[-10px] 让 before 左边缘在装饰线左侧
// 10px,w-5 = 20px 让右边缘在装饰线右侧 10px),鼠标偏离中线也能拖到。
// 之前 w-1 hover:w-1.5 (4→6px) 实际命中只有 ~6px,鼠标偏 1-2px 就掉进相邻面板,
// 触发不到拖拽 —— 用 hit-area 扩展解决,视觉不"变粗"更克制。
const orientationClass =
orientation === 'vertical'
? 'relative w-px cursor-col-resize before:absolute before:inset-y-0 before:left-[-10px] before:w-5 before:content-[""]'
: 'relative h-px cursor-row-resize before:absolute before:inset-x-0 before:top-[-10px] before:h-5 before:content-[""]'
// ariaValueNow/Min/Max 可选 —— 不传则不输出该属性
const ariaProps: { 'aria-valuenow'?: number; 'aria-valuemin'?: number; 'aria-valuemax'?: number } = {}
if (ariaValueNow !== undefined) ariaProps['aria-valuenow'] = ariaValueNow
if (ariaValueMin !== undefined) ariaProps['aria-valuemin'] = ariaValueMin
if (ariaValueMax !== undefined) ariaProps['aria-valuemax'] = ariaValueMax
return (
// role=separator 是 ARIA 规范里定义的可调分隔条,本身就该接受键盘和指针交互
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
<div
ref={elRef}
role="separator"
tabIndex={0}
aria-orientation={orientation}
aria-label={ariaLabel}
aria-controls={ariaControls?.join(' ')}
data-orientation={orientation}
data-dragging="false"
{...ariaProps}
onPointerDown={onPointerDown}
onPointerUp={onPointerUpLocal}
onKeyDown={onKeyDown}
className={`shrink-0 self-stretch bg-border transition-colors hover:bg-accent focus:bg-accent focus:outline-none ${orientationClass}`}
/>
)
}
export default memo(Splitter)