Line data Source code
1 : import * as React from 'react';
2 :
3 : import { classNames } from '../../utils/classnames.js';
4 : import * as styles from './vertical_split.module.css';
5 :
6 1 : const DEFAULT_SEPARATOR_SIZE = 12;
7 1 : const KEYBOARD_STEP = 0.05;
8 : const MIN_RATIO = 0;
9 : const MAX_RATIO = 1;
10 :
11 : export interface VerticalSplitProps {
12 : first: React.ReactNode;
13 : second: React.ReactNode;
14 : className?: string;
15 : defaultRatio?: number;
16 : minFirstSize?: number;
17 : minSecondSize?: number;
18 : separatorLabel?: string;
19 : secondCollapsed?: boolean;
20 : collapsedSecondSize?: number;
21 : /// The height of the separator row in pixels
22 : separatorSize?: number;
23 : separatorClassName?: string;
24 : }
25 :
26 1 : function clamp(value: number, minimum: number, maximum: number): number {
27 1 : return Math.min(maximum, Math.max(minimum, value));
28 1 : }
29 :
30 1 : export const VerticalSplit: React.FC<VerticalSplitProps> = (props) => {
31 1 : const defaultRatio = clamp(props.defaultRatio ?? 0.4, 0, 1);
32 1 : const minFirstSize = props.minFirstSize ?? 120;
33 1 : const minSecondSize = props.minSecondSize ?? 120;
34 1 : const collapsedSecondSize = props.collapsedSecondSize ?? 40;
35 1 : const separatorSize = props.separatorSize ?? DEFAULT_SEPARATOR_SIZE;
36 1 : const [ratio, setRatio] = React.useState(defaultRatio);
37 1 : const [ratioBounds, setRatioBounds] = React.useState<[number, number]>([defaultRatio, defaultRatio]);
38 1 : const [isDragging, setIsDragging] = React.useState(false);
39 1 : const containerRef = React.useRef<HTMLDivElement>(null);
40 1 : const stopDraggingRef = React.useRef<(() => void) | null>(null);
41 1 : const ratioWhenDraggingStartedRef = React.useRef(defaultRatio);
42 :
43 1 : const getRatioBounds = React.useCallback((): [number, number] => {
44 1 : const height = containerRef.current?.getBoundingClientRect().height ?? 0;
45 1 : const availableHeight = Math.max(0, height - separatorSize);
46 1 : if (availableHeight === 0 || availableHeight < minFirstSize + minSecondSize) {
47 1 : return [defaultRatio, defaultRatio];
48 : }
49 1 : return [minFirstSize / availableHeight, 1 - minSecondSize / availableHeight];
50 1 : }, [defaultRatio, minFirstSize, minSecondSize, separatorSize]);
51 :
52 1 : const resizeToPointer = React.useCallback((clientY: number) => {
53 1 : const container = containerRef.current;
54 1 : if (container == null) return;
55 1 : const rect = container.getBoundingClientRect();
56 1 : const availableHeight = rect.height - separatorSize;
57 1 : if (availableHeight <= 0) return;
58 1 : const [minimum, maximum] = getRatioBounds();
59 1 : const nextRatio = (clientY - rect.top - separatorSize / 2) / availableHeight;
60 1 : setRatio(clamp(nextRatio, minimum, maximum));
61 1 : }, [getRatioBounds, separatorSize]);
62 :
63 1 : React.useLayoutEffect(() => {
64 1 : const container = containerRef.current;
65 1 : if (container == null) return;
66 1 : const updateBounds = () => {
67 1 : const bounds = getRatioBounds();
68 1 : setRatioBounds(current => current[0] === bounds[0] && current[1] === bounds[1] ? current : bounds);
69 1 : setRatio(current => clamp(current, bounds[0], bounds[1]));
70 1 : };
71 1 : updateBounds();
72 1 : const observer = new ResizeObserver(updateBounds);
73 1 : observer.observe(container);
74 1 : return () => observer.disconnect();
75 1 : }, [getRatioBounds]);
76 :
77 1 : const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
78 1 : if (event.button !== 0) return;
79 1 : event.preventDefault();
80 1 : stopDraggingRef.current?.();
81 1 : setIsDragging(true);
82 1 : ratioWhenDraggingStartedRef.current = ratio;
83 :
84 1 : const pointerId = event.pointerId;
85 1 : const startY = event.clientY;
86 1 : let pointerMoved = false;
87 1 : const handlePointerMove = (moveEvent: PointerEvent) => {
88 1 : if (moveEvent.pointerId !== pointerId) return;
89 1 : moveEvent.preventDefault();
90 1 : pointerMoved ||= Math.abs(moveEvent.clientY - startY) >= 3;
91 1 : if (!pointerMoved) return;
92 1 : resizeToPointer(moveEvent.clientY);
93 1 : };
94 1 : const stopDragging = () => {
95 1 : window.removeEventListener('pointermove', handlePointerMove);
96 1 : window.removeEventListener('pointerup', handlePointerEnd);
97 1 : window.removeEventListener('pointercancel', handlePointerEnd);
98 1 : if (stopDraggingRef.current === stopDragging) stopDraggingRef.current = null;
99 1 : setIsDragging(false);
100 1 : };
101 1 : const handlePointerEnd = (endEvent: PointerEvent) => {
102 1 : if (endEvent.pointerId !== pointerId) return;
103 1 : if (endEvent.type === 'pointercancel') {
104 1 : setRatio(ratioWhenDraggingStartedRef.current);
105 1 : } else if (!pointerMoved) {
106 1 : const [minimum, maximum] = getRatioBounds();
107 1 : setRatio(clamp(1 - ratio, minimum, maximum));
108 1 : }
109 1 : stopDragging();
110 1 : };
111 :
112 1 : window.addEventListener('pointermove', handlePointerMove);
113 1 : window.addEventListener('pointerup', handlePointerEnd);
114 1 : window.addEventListener('pointercancel', handlePointerEnd);
115 1 : stopDraggingRef.current = stopDragging;
116 1 : }, [getRatioBounds, ratio, resizeToPointer]);
117 :
118 1 : const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
119 1 : const [minimum, maximum] = getRatioBounds();
120 1 : let nextRatio: number | null = null;
121 1 : switch (event.key) {
122 0 : case 'ArrowUp':
123 0 : nextRatio = ratio - KEYBOARD_STEP;
124 0 : break;
125 1 : case 'ArrowDown':
126 1 : nextRatio = ratio + KEYBOARD_STEP;
127 1 : break;
128 1 : case 'Home':
129 1 : nextRatio = minimum;
130 1 : break;
131 1 : case 'End':
132 1 : nextRatio = maximum;
133 : break;
134 1 : }
135 1 : if (nextRatio == null) return;
136 1 : event.preventDefault();
137 1 : setRatio(clamp(nextRatio, minimum, maximum));
138 1 : }, [getRatioBounds, ratio]);
139 :
140 1 : React.useEffect(() => () => stopDraggingRef.current?.(), []);
141 :
142 1 : React.useEffect(() => {
143 1 : if (props.secondCollapsed) stopDraggingRef.current?.();
144 1 : }, [props.secondCollapsed]);
145 :
146 1 : const ratioPercent = Math.round(ratio * 100);
147 1 : const gridTemplateRows = props.secondCollapsed
148 1 : ? `minmax(0, 1fr) ${separatorSize}px ${collapsedSecondSize}px`
149 1 : : `minmax(${minFirstSize}px, ${ratio}fr) ${separatorSize}px minmax(${minSecondSize}px, ${1 - ratio}fr)`;
150 1 : return (
151 1 : <div
152 1 : ref={containerRef}
153 1 : className={classNames(styles.container, props.className, {
154 1 : [styles.dragging]: isDragging,
155 1 : })}
156 1 : style={{ gridTemplateRows }}
157 : >
158 1 : <div className={styles.first}>{props.first}</div>
159 1 : {props.secondCollapsed ? (
160 1 : <div className={styles.collapsed_separator} aria-hidden="true" />
161 : ) : (
162 1 : <div
163 1 : className={classNames(styles.separator, props.separatorClassName)}
164 1 : role="separator"
165 1 : data-electron-drag-region="false"
166 1 : tabIndex={0}
167 1 : aria-label={props.separatorLabel ?? 'Resize panels'}
168 1 : aria-orientation="horizontal"
169 1 : aria-valuemin={Math.round(ratioBounds[0] * 100)}
170 1 : aria-valuemax={Math.round(ratioBounds[1] * 100)}
171 1 : aria-valuenow={ratioPercent}
172 1 : aria-valuetext={`${ratioPercent}% for the top panel`}
173 1 : title="Drag to resize; click to swap panel sizes"
174 1 : onPointerDown={handlePointerDown}
175 1 : onKeyDown={handleKeyDown}
176 : >
177 1 : <span className={styles.handle} aria-hidden="true" />
178 1 : </div>
179 : )}
180 1 : <div className={styles.second}>{props.second}</div>
181 1 : </div>
182 : );
183 1 : };
|