/** * 可拖拽分隔条 —— 编辑器↔结果区(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(null) // 用 ref 缓存 callback,避免父组件 re-render 时拖到一半 onDelta 引用变了导致闭包捕获错值 const onDeltaRef = useRef(onDelta) onDeltaRef.current = onDelta // 拖拽态全用 ref —— 同步访问,React state 那时还没 commit const draggingRef = useRef(false) const lastPosRef = useRef(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) => { // 只响应主键;中键右键让浏览器自己处理(右键菜单等) 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) => { 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) => { // 键盘控制:箭头单步,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 */
) } export default memo(Splitter)