237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
/**
|
|
* 可拖拽分隔条 —— 编辑器↔结果区(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)
|