import { useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent, type ReactNode, } from "react"; import { cn } from "./cn.js"; export type SplitPaneOrientation = "vertical" | "horizontal"; export interface SplitPaneProps extends Omit, "children"> { primary: ReactNode; secondary: ReactNode; primarySize: number; onPrimarySizeChange: (primarySize: number) => void; orientation?: SplitPaneOrientation; minPrimarySize?: number; minSecondarySize?: number; step?: number; resizable?: boolean; separatorLabel: string; } const clamp = (value: number, minimum: number, maximum: number) => ( Math.min(Math.max(value, minimum), maximum) ); const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => { const primary = clamp(minPrimarySize, 0, 100); const secondary = clamp(minSecondarySize, 0, 100); if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary }; const scale = 100 / (primary + secondary); return { minimum: primary * scale, maximum: 100 - secondary * scale }; }; export function SplitPane({ primary, secondary, primarySize, onPrimarySizeChange, orientation = "vertical", minPrimarySize = 20, minSecondarySize = 20, step = 2, resizable = true, separatorLabel, className, style, ...props }: SplitPaneProps) { const rootRef = useRef(null); const activePointerIdRef = useRef(null); const [dragging, setDragging] = useState(false); const limits = normalizedLimits(minPrimarySize, minSecondarySize); const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum); const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2; const emitPointerSize = (event: PointerEvent) => { const root = rootRef.current; if (!root) return; const rect = root.getBoundingClientRect(); const available = orientation === "vertical" ? rect.width : rect.height; if (available <= 0) return; const offset = orientation === "vertical" ? event.clientX - rect.left : event.clientY - rect.top; onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum)); }; const handleKeyDown = (event: KeyboardEvent) => { const multiplier = event.shiftKey ? 5 : 1; const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp"; const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown"; let next: number | null = null; if (event.key === decrement) next = size - keyboardIncrement * multiplier; if (event.key === increment) next = size + keyboardIncrement * multiplier; if (event.key === "Home") next = limits.minimum; if (event.key === "End") next = limits.maximum; if (next === null) return; event.preventDefault(); onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum)); }; const splitStyle = { ...style, "--nodedc-split-pane-primary": `${size}%`, } as CSSProperties; return (
{primary}
{secondary}
{resizable ? (
{ if (event.button !== 0) return; event.currentTarget.focus(); activePointerIdRef.current = event.pointerId; event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); emitPointerSize(event); event.preventDefault(); }} onPointerMove={(event) => { if (activePointerIdRef.current !== event.pointerId) return; emitPointerSize(event); event.preventDefault(); }} onPointerUp={(event) => { if (activePointerIdRef.current !== event.pointerId) return; activePointerIdRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } setDragging(false); }} onPointerCancel={(event) => { if (activePointerIdRef.current !== event.pointerId) return; activePointerIdRef.current = null; setDragging(false); }} onLostPointerCapture={(event) => { if (activePointerIdRef.current !== event.pointerId) return; activePointerIdRef.current = null; setDragging(false); }} /> ) : null}
); }