update
This commit is contained in:
236
src/renderer/src/components/Splitter.tsx
Normal file
236
src/renderer/src/components/Splitter.tsx
Normal 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)
|
||||
Reference in New Issue
Block a user