import { effect, h, signal } from '@ui-arch/frame'; import type { Signal } from '@ui-arch/frame'; import type { SplitterProps } from './types.js'; /*** 均分百分比:总数为 n 时每栏等分,末栏吃掉舍入余量保证和为 100 ***/ function even(n: number): number[] { const each = Number((100 / n).toFixed(2)); const out = Array.from({ length: n }, () => each); out[n - 1] = Number((100 - each * (n - 1)).toFixed(2)); return out; } /*** 分栏容器:可拖拽分隔条调整比例,sizes 信号双向绑定,分隔条可聚焦并用方向键微调、双击复位 ***/ export function splitter(props: SplitterProps): HTMLElement { // —— 状态集中声明 —— const dir = props.direction ?? 'horizontal'; // 分割方向 const min = Math.max(props.min ?? 10, 0); // 每栏最小百分比,负值归零 const sizes: Signal = props.sizes ?? signal(even(props.panes.length)); // 各栏比例 const bars: HTMLElement[] = []; // 分隔条,索引 i 位于第 i / i+1 栏之间 const cells: HTMLElement[] = []; // 栏容器,与 panes 一一对应 const isH = dir === 'horizontal'; // 横向:改 flex-basis(宽度) /*** 同步几何:只写 flex-basis,避免触发重排风暴 ***/ const sync = (): void => { const pct = sizes(); cells.forEach((cell, i) => { cell.style.flexBasis = `${pct[i] ?? 0}%`; }); bars.forEach((bar, i) => { bar.setAttribute('aria-valuenow', String(Math.round(pct[i] ?? 0))); }); // 读屏播报当前分界位置 }; /*** 成对调整:把 i 与 i+1 两栏按 delta 对调,各自不低于 min ***/ const shift = (i: number, delta: number): void => { const cur = [...sizes()]; const pair = (cur[i] ?? 0) + (cur[i + 1] ?? 0); // 两栏总量恒定 const next = Math.min(Math.max((cur[i] ?? 0) + delta, min), pair - min); // 下限 min、上限 pair-min if (next === cur[i]) return; // 卫语句:贴边不动 cur[i] = Number(next.toFixed(2)); cur[i + 1] = Number((pair - next).toFixed(2)); sizes(cur); props.onChange?.([...cur]); }; /*** 拖动:按下分隔条后按指针位移折算百分比,松手即解绑 ***/ const startDrag = (i: number, down: PointerEvent): void => { const bar = down.currentTarget as HTMLElement; const box = root.getBoundingClientRect(); const total = (isH ? box.width : box.height) || 1; // 容器主轴尺寸,防 0 除 const from = isH ? down.clientX : down.clientY; // 按下时指针主轴坐标 const startPct = sizes()[i] ?? 0; // 起拖比例,位移全部相对它计算 bar.setPointerCapture(down.pointerId); // 捕获指针:拖出分隔条也不丢事件 bar.classList.add('is-dragging'); const onMove = (e: PointerEvent): void => { const moved = (((isH ? e.clientX : e.clientY) - from) / total) * 100; // 位移折算成百分比 shift(i, Number((startPct + moved - (sizes()[i] ?? 0)).toFixed(2))); // 增量驱动,夹逼交给 shift }; const onUp = (): void => { bar.classList.remove('is-dragging'); bar.removeEventListener('pointermove', onMove); bar.removeEventListener('pointerup', onUp); bar.removeEventListener('pointercancel', onUp); }; bar.addEventListener('pointermove', onMove); bar.addEventListener('pointerup', onUp); bar.addEventListener('pointercancel', onUp); }; const root = h('div', { class: `ua-splitter ua-splitter-${dir}` }) as HTMLElement; props.panes.forEach((pane, i) => { const cell = h('div', { class: 'ua-splitter-pane' }, pane) as HTMLElement; // pane 可为节点或纯文本 cells.push(cell); root.append(cell); if (i === props.panes.length - 1) return; // 最后一栏后面不再放分隔条 const bar = h('div', { class: 'ua-splitter-bar', role: 'separator', tabindex: '0', 'aria-orientation': isH ? 'vertical' : 'horizontal', // 左右分栏时分隔条是竖向的 'aria-label': `调整第 ${i + 1} 栏宽度`, // 读屏可辨识 'aria-valuemin': String(min), 'aria-valuemax': String(100 - min), onpointerdown: (e: PointerEvent) => { e.preventDefault(); startDrag(i, e); }, ondblclick: () => { sizes(even(props.panes.length)); props.onChange?.([...even(props.panes.length)]); }, // 双击复位均分 }) as HTMLElement; bar.addEventListener('keydown', (e: KeyboardEvent) => { const step = e.shiftKey ? 10 : 2; // Shift 加速 const back = isH ? 'ArrowLeft' : 'ArrowUp'; const fwd = isH ? 'ArrowRight' : 'ArrowDown'; if (e.key !== back && e.key !== fwd) return; // 卫语句:非方向键放行 e.preventDefault(); shift(i, e.key === back ? -step : step); }); bars.push(bar); root.append(bar); }); effect(sync); return root; }