Smooth splitter dragging (#765)

This commit is contained in:
Gregory Schier authored and GitHub committed 2026-10-21 12:22:45 -07:00
1 parent 8a85fb3dcd
commit 1248112fe0
5 files changed
+641 -131

No files matched your search

+31 -30
View File
@@ -1,8 +1,7 @@
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react"; import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createHandleDrag } from "./resizeDrag";
const START_DISTANCE = 7;
export interface ResizeHandleEvent { export interface ResizeHandleEvent {
x: number; x: number;
@@ -34,45 +33,47 @@ export function ResizeHandle({
}: Props) { }: Props) {
const vertical = side === "top"; const vertical = side === "top";
const [isResizing, setIsResizing] = useState<boolean>(false); const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ const stopDrag = useRef<(() => void) | null>(null);
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void; // A handle that goes away mid-drag ends the drag, so its owner is not left resizing
calledStart: boolean; useEffect(() => () => stopDrag.current?.(), []);
xStart: number;
yStart: number;
} | null>(null);
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => { (e: ReactMouseEvent<HTMLDivElement>) => {
function move(e: MouseEvent) { stopDrag.current?.();
if (moveState.current == null) return;
const xDistance = moveState.current.xStart - e.clientX; const drag = createHandleDrag({
const yDistance = moveState.current.yStart - e.clientY; vertical,
const distance = Math.abs(vertical ? yDistance : xDistance); xStart: e.clientX,
if (moveState.current.calledStart) { yStart: e.clientY,
onResizeMove?.({ onResizeStart: () => {
x: e.clientX,
y: e.clientY,
xStart: moveState.current.xStart,
yStart: moveState.current.yStart,
});
} else if (distance > START_DISTANCE) {
onResizeStart?.(); onResizeStart?.();
moveState.current.calledStart = true;
setIsResizing(true); setIsResizing(true);
} },
onResizeMove,
onResizeEnd,
});
function move(e: MouseEvent) {
drag.move(e);
}
function removeListeners() {
stopDrag.current = null;
document.documentElement.removeEventListener("mousemove", move);
document.documentElement.removeEventListener("mouseup", up);
} }
function up() { function up() {
removeListeners();
setIsResizing(false); setIsResizing(false);
moveState.current = null; drag.up();
document.documentElement.removeEventListener("mousemove", move);
document.documentElement.removeEventListener("mouseup", up);
onResizeEnd?.();
} }
moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up }; stopDrag.current = () => {
removeListeners();
drag.abort();
};
document.documentElement.addEventListener("mousemove", move); document.documentElement.addEventListener("mousemove", move);
document.documentElement.addEventListener("mouseup", up); document.documentElement.addEventListener("mouseup", up);
+53 -30
View File
@@ -1,11 +1,13 @@
import classNames from "classnames"; import classNames from "classnames";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useContainerSize } from "../hooks/useContainerSize"; import { useContainerSize } from "../hooks/useContainerSize";
import { Overlay } from "./Overlay"; import { Overlay } from "./Overlay";
import type { ResizeHandleEvent } from "./ResizeHandle"; import type { ResizeHandleEvent } from "./ResizeHandle";
import { ResizeHandle } from "./ResizeHandle"; import { ResizeHandle } from "./ResizeHandle";
import type { SidebarDrag, SidebarSize } from "./resizeDrag";
import { createSidebarDrag, sidebarGridTemplate } from "./resizeDrag";
const FLOATING_BREAKPOINT = 600; const FLOATING_BREAKPOINT = 600;
@@ -53,44 +55,65 @@ export function SidebarLayout({
}, [floating]); // eslint-disable-line react-hooks/exhaustive-deps }, [floating]); // eslint-disable-line react-hooks/exhaustive-deps
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const startWidth = useRef<number | null>(null);
const sideWidth = hidden ? 0 : width; // While dragging, the width is written straight to the DOM and only handed to the owner when the
// drag ends. Until the owner's props catch up with that (hiding may be saved asynchronously),
// keep showing what the drag ended on, so the sidebar does not flash back open.
const liveDrag = useRef<SidebarDrag | null>(null);
const [committed, setCommitted] = useState<{ from: SidebarSize; to: SidebarSize } | null>(null);
if (committed != null) {
const { from, to } = committed;
const settled = (prop: number | boolean, key: keyof SidebarSize) =>
prop === to[key] || prop !== from[key];
if (settled(hidden, "hidden") && settled(width, "width")) setCommitted(null);
}
const shownWidth = committed?.to.width ?? width;
const shownHidden = committed?.to.hidden ?? hidden;
const sideWidth = shownHidden ? 0 : shownWidth;
const styles = useMemo<CSSProperties>( const styles = useMemo<CSSProperties>(
() => ({ () => ({ gridTemplate: sidebarGridTemplate(sideWidth) }),
gridTemplate: `
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`,
}),
[sideWidth], [sideWidth],
); );
const handleResizeStart = useCallback(() => { const applySize = useCallback((size: SidebarSize) => {
startWidth.current = width; if (containerRef.current == null) return;
setIsResizing(true); containerRef.current.style.gridTemplate = sidebarGridTemplate(size.hidden ? 0 : size.width);
}, [width]);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
startWidth.current = null;
}, []); }, []);
const handleResizeMove = useCallback( // Something else re-rendering mid-drag may write the saved width back; put the live one back
({ x, xStart }: ResizeHandleEvent) => { // before it is painted.
if (startWidth.current == null) return; useLayoutEffect(() => {
const size = liveDrag.current?.size;
if (size != null) applySize(size);
});
const newWidth = startWidth.current + (x - xStart); const handleResizeStart = useCallback(() => {
if (newWidth < minWidth) { const from = { width: shownWidth, hidden: shownHidden };
onHiddenChange?.(true); liveDrag.current = createSidebarDrag({
onWidthChange(defaultWidth); startWidth: shownWidth,
} else { minWidth,
if (hidden) onHiddenChange?.(false); defaultWidth,
onWidthChange(newWidth); apply: applySize,
} commit: (to) => {
}, setCommitted({ from, to });
[minWidth, hidden, onHiddenChange, onWidthChange, defaultWidth], if (to.hidden !== from.hidden) onHiddenChange?.(to.hidden);
); onWidthChange(to.width);
},
});
setIsResizing(true);
}, [shownWidth, shownHidden, minWidth, defaultWidth, applySize, onHiddenChange, onWidthChange]);
const handleResizeMove = useCallback((e: ResizeHandleEvent) => liveDrag.current?.move(e), []);
const handleResizeEnd = useCallback(() => {
const d = liveDrag.current;
liveDrag.current = null;
d?.end();
setIsResizing(false);
}, []);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
onWidthChange(defaultWidth); onWidthChange(defaultWidth);
+71 -71
View File
@@ -1,11 +1,12 @@
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useLayoutEffect, useMemo, useRef } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { useContainerSize } from "../hooks/useContainerSize"; import { useContainerSize } from "../hooks/useContainerSize";
import { clamp } from "../lib/clamp";
import type { ResizeHandleEvent } from "./ResizeHandle"; import type { ResizeHandleEvent } from "./ResizeHandle";
import { ResizeHandle } from "./ResizeHandle"; import { ResizeHandle } from "./ResizeHandle";
import type { SplitDrag } from "./resizeDrag";
import { clampSplitRatio, createSplitDrag, splitGridTemplate } from "./resizeDrag";
export type SplitLayoutLayout = "responsive" | "horizontal" | "vertical"; export type SplitLayoutLayout = "responsive" | "horizontal" | "vertical";
@@ -63,71 +64,78 @@ export function SplitLayout({
const renderedWidth = clampSplitRatio(width, minWidthPx, size.width); const renderedWidth = clampSplitRatio(width, minWidthPx, size.width);
const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0; const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0;
const styles = useMemo<CSSProperties>(() => { const styles = useMemo<CSSProperties>(
return { () => ({
...style, ...style,
gridTemplate: vertical gridTemplate: splitGridTemplate(vertical, vertical ? renderedHeight : renderedWidth),
? ` }),
' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr) [style, vertical, renderedHeight, renderedWidth],
' ${areaD.gridArea}' 0 );
' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
/ 1fr // While dragging, the split is written straight to the DOM and only saved (and rendered) when
` // the drag ends, so the panes are not re-rendered on every move.
: ` const liveDrag = useRef<SplitDrag | null>(null);
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - renderedWidth}fr 0 ${renderedWidth}fr const applySplit = useCallback((vertical: boolean, ratio: number) => {
`, if (containerRef.current == null) return;
}; containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio);
}, [style, vertical, renderedHeight, renderedWidth]); }, []);
// Something else re-rendering mid-drag (a response arriving, say) may write the saved split
// back; put the live one back before it is painted.
useLayoutEffect(() => {
const d = liveDrag.current;
if (d?.ratio != null) applySplit(d.vertical, d.ratio);
});
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio); if (vertical) setHeight(defaultRatio);
else setWidth(defaultRatio); else setWidth(defaultRatio);
}, [vertical, setHeight, defaultRatio, setWidth]); }, [vertical, setHeight, defaultRatio, setWidth]);
const handleResizeMove = useCallback( const handleResizeStart = useCallback(() => {
(e: ResizeHandleEvent) => { liveDrag.current = createSplitDrag({
if (containerRef.current === null) return;
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
containerRef.current,
);
const $c = containerRef.current;
const containerWidth =
$c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
const containerHeight =
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) {
return;
}
const mouseStartX = e.xStart;
const mouseStartY = e.yStart;
const startWidth = containerWidth * renderedWidth;
const startHeight = containerHeight * renderedHeight;
if (vertical) {
const minHeight = Math.min(minHeightPx, containerHeight);
const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx);
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx);
setHeight(newHeightPx / containerHeight);
} else {
const minWidth = Math.min(minWidthPx, containerWidth);
const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx);
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx);
setWidth(newWidthPx / containerWidth);
}
},
[
renderedWidth,
renderedHeight,
vertical, vertical,
minHeightPx, startRatio: vertical ? renderedHeight : renderedWidth,
setHeight, minPx: vertical ? minHeightPx : minWidthPx,
minWidthPx, measure: () => {
setWidth, const $c = containerRef.current;
], if ($c == null) return 0;
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle($c);
return vertical
? $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom)
: $c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
},
apply: (ratio) => applySplit(vertical, ratio),
commit: (ratio) => (vertical ? setHeight(ratio) : setWidth(ratio)),
});
}, [
vertical,
renderedHeight,
renderedWidth,
minHeightPx,
minWidthPx,
applySplit,
setHeight,
setWidth,
]);
const handleResizeMove = useCallback((e: ResizeHandleEvent) => liveDrag.current?.move(e), []);
const handleResizeEnd = useCallback(() => {
const d = liveDrag.current;
liveDrag.current = null;
d?.end();
}, []);
// The container's size changes often (another splitter being dragged, the window resizing) and
// re-renders this component; reuse the slots then, so the panes are only re-rendered when the
// owner re-renders or the orientation flips.
const orientation = vertical ? "vertical" : "horizontal";
const first = useMemo(() => firstSlot({ style: areaL, orientation }), [firstSlot, orientation]);
const second = useMemo(
() => secondSlot?.({ style: areaR, orientation }) ?? null,
[secondSlot, orientation],
); );
return ( return (
@@ -136,7 +144,7 @@ export function SplitLayout({
style={styles} style={styles}
className={classNames(className, "grid w-full h-full overflow-hidden")} className={classNames(className, "grid w-full h-full overflow-hidden")}
> >
{firstSlot({ style: areaL, orientation: vertical ? "vertical" : "horizontal" })} {first}
{secondSlot && ( {secondSlot && (
<> <>
<ResizeHandle <ResizeHandle
@@ -145,24 +153,16 @@ export function SplitLayout({
resizeHandleClassName, resizeHandleClassName,
vertical ? "-translate-y-1" : "-translate-x-1", vertical ? "-translate-y-1" : "-translate-x-1",
)} )}
onResizeStart={handleResizeStart}
onResizeMove={handleResizeMove} onResizeMove={handleResizeMove}
onResizeEnd={handleResizeEnd}
onReset={handleReset} onReset={handleReset}
side={vertical ? "top" : "left"} side={vertical ? "top" : "left"}
justify="center" justify="center"
/> />
{secondSlot({ style: areaR, orientation: vertical ? "vertical" : "horizontal" })} {second}
</> </>
)} )}
</div> </div>
); );
} }
function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number {
if (containerPx <= 0 || minPx <= 0) {
return ratio;
}
const minRatio = Math.min(1, minPx / containerPx);
const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio;
return clamp(ratio, minRatio, maxRatio);
}
@@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import type { ResizeHandleEvent } from "./ResizeHandle";
import {
clampSplitRatio,
createFrameCoalescer,
createHandleDrag,
createSidebarDrag,
createSplitDrag,
} from "./resizeDrag";
let frames: Map<number, FrameRequestCallback>;
function runFrame() {
const callbacks = [...frames.values()];
frames.clear();
for (const cb of callbacks) cb(0);
}
beforeEach(() => {
frames = new Map();
let nextId = 1;
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
const id = nextId++;
frames.set(id, cb);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
});
afterEach(() => vi.unstubAllGlobals());
const at = (x: number, y = 0): ResizeHandleEvent => ({ x, y, xStart: 500, yStart: 400 });
describe("createFrameCoalescer", () => {
test("delivers only the latest of many values pushed in one frame", () => {
const fn = vi.fn<(v: number) => void>();
const moves = createFrameCoalescer(fn);
for (let i = 1; i <= 50; i++) moves.push(i);
expect(fn).not.toHaveBeenCalled();
runFrame();
expect(fn.mock.calls).toEqual([[50]]);
runFrame();
expect(fn).toHaveBeenCalledTimes(1);
});
test("delivers once per frame across frames", () => {
const fn = vi.fn<(v: number) => void>();
const moves = createFrameCoalescer(fn);
moves.push(1);
moves.push(2);
runFrame();
moves.push(3);
runFrame();
expect(fn.mock.calls).toEqual([[2], [3]]);
});
test("flush delivers the pending value now and drops its frame", () => {
const fn = vi.fn<(v: number) => void>();
const moves = createFrameCoalescer(fn);
moves.push(1);
moves.push(2);
moves.flush();
expect(fn.mock.calls).toEqual([[2]]);
expect(frames.size).toBe(0);
moves.flush();
expect(fn).toHaveBeenCalledTimes(1);
});
test("cancel drops the pending value", () => {
const fn = vi.fn<(v: number) => void>();
const moves = createFrameCoalescer(fn);
moves.push(1);
moves.cancel();
runFrame();
moves.flush();
expect(fn).not.toHaveBeenCalled();
expect(frames.size).toBe(0);
});
});
describe("createHandleDrag", () => {
function handle(vertical = false) {
const calls: string[] = [];
const drag = createHandleDrag({
vertical,
xStart: 500,
yStart: 400,
onResizeStart: () => calls.push("start"),
onResizeMove: (e) => calls.push(`move ${e.x},${e.y}`),
onResizeEnd: () => calls.push("end"),
});
return { drag, calls };
}
test("starts only once the pointer has moved more than 7px along its axis", () => {
const { drag, calls } = handle();
drag.move({ clientX: 505, clientY: 900 });
expect(calls).toEqual([]);
drag.move({ clientX: 492, clientY: 400 });
expect(calls).toEqual(["start"]);
const v = handle(true);
v.drag.move({ clientX: 900, clientY: 405 });
v.drag.move({ clientX: 900, clientY: 408 });
expect(v.calls).toEqual(["start"]);
});
test("coalesces many moves in a frame into one, and delivers the last before end", () => {
const { drag, calls } = handle();
drag.move({ clientX: 480, clientY: 400 });
for (let i = 1; i <= 50; i++) drag.move({ clientX: 480 - i, clientY: 400 });
runFrame();
expect(calls).toEqual(["start", "move 430,400"]);
drag.move({ clientX: 420, clientY: 400 });
drag.move({ clientX: 410, clientY: 400 });
drag.up();
expect(calls).toEqual(["start", "move 430,400", "move 410,400", "end"]);
runFrame();
expect(calls).toHaveLength(4);
});
test("ends on release even when no drag started, like a click", () => {
const { drag, calls } = handle();
drag.up();
expect(calls).toEqual(["end"]);
});
test("abort drops the pending move and ends only a started drag", () => {
const idle = handle();
idle.drag.abort();
expect(idle.calls).toEqual([]);
const { drag, calls } = handle();
drag.move({ clientX: 480, clientY: 400 });
drag.move({ clientX: 470, clientY: 400 });
drag.abort();
runFrame();
expect(calls).toEqual(["start", "end"]);
expect(frames.size).toBe(0);
});
});
describe("createSplitDrag", () => {
function split(overrides: Partial<Parameters<typeof createSplitDrag>[0]> = {}) {
const apply = vi.fn<(r: number) => void>();
const commit = vi.fn<(r: number) => void>();
const drag = createSplitDrag({
vertical: false,
startRatio: 0.5,
minPx: 10,
measure: () => 1000,
apply,
commit,
...overrides,
});
return { drag, apply, commit };
}
test("applies every move and commits the last ratio once, on end", () => {
const { drag, apply, commit } = split();
for (let i = 1; i <= 50; i++) drag.move(at(500 - i * 4));
expect(apply).toHaveBeenCalledTimes(50);
expect(commit).not.toHaveBeenCalled();
drag.end();
expect(commit.mock.calls).toEqual([[0.7]]);
});
test("anchors each move to where the drag started rather than compounding", () => {
const { drag, commit } = split({ startRatio: 0.25 });
drag.move(at(400));
drag.move(at(400));
drag.move(at(450));
drag.end();
expect(commit.mock.calls).toEqual([[0.3]]);
});
test("follows y when vertical", () => {
const { drag, commit } = split({ vertical: true, measure: () => 800 });
drag.move(at(0, 200));
drag.end();
expect(commit.mock.calls).toEqual([[0.75]]);
});
test("keeps both panes at least minPx", () => {
const { drag, apply, commit } = split({ minPx: 100 });
drag.move(at(-5000));
expect(apply).toHaveBeenLastCalledWith(0.9);
drag.move(at(5000));
drag.end();
expect(commit.mock.calls).toEqual([[0.1]]);
});
test("ignores moves while the container has no size", () => {
const { drag, apply, commit } = split({ measure: () => 0 });
drag.move(at(300));
drag.end();
expect(apply).not.toHaveBeenCalled();
expect(commit).not.toHaveBeenCalled();
});
test("commits nothing when it never moved", () => {
const { drag, commit } = split();
drag.end();
expect(commit).not.toHaveBeenCalled();
});
});
describe("clampSplitRatio", () => {
test("clamps to the min size on either side", () => {
expect(clampSplitRatio(0.01, 100, 1000)).toBe(0.1);
expect(clampSplitRatio(0.99, 100, 1000)).toBe(0.9);
expect(clampSplitRatio(0.4, 100, 1000)).toBe(0.4);
});
test("leaves the ratio alone with no size or no minimum", () => {
expect(clampSplitRatio(0.01, 100, 0)).toBe(0.01);
expect(clampSplitRatio(0.01, 0, 1000)).toBe(0.01);
});
});
describe("createSidebarDrag", () => {
function sidebar() {
const apply = vi.fn();
const commit = vi.fn();
const drag = createSidebarDrag({
startWidth: 250,
minWidth: 50,
defaultWidth: 250,
apply,
commit,
});
return { drag, apply, commit };
}
test("commits the last width once, on end", () => {
const { drag, apply, commit } = sidebar();
for (let i = 1; i <= 50; i++) drag.move({ x: 500 + i * 2, y: 0, xStart: 500, yStart: 0 });
expect(apply).toHaveBeenCalledTimes(50);
expect(commit).not.toHaveBeenCalled();
drag.end();
expect(commit.mock.calls).toEqual([[{ width: 350, hidden: false }]]);
});
test("collapses under the min width and reopens when dragged back out", () => {
const { drag, apply, commit } = sidebar();
drag.move({ x: 290, y: 0, xStart: 500, yStart: 0 });
expect(apply).toHaveBeenLastCalledWith({ width: 250, hidden: true });
drag.move({ x: 340, y: 0, xStart: 500, yStart: 0 });
expect(apply).toHaveBeenLastCalledWith({ width: 90, hidden: false });
drag.move({ x: 200, y: 0, xStart: 500, yStart: 0 });
drag.end();
expect(commit.mock.calls).toEqual([[{ width: 250, hidden: true }]]);
});
test("commits nothing when it never moved", () => {
const { drag, commit } = sidebar();
drag.end();
expect(commit).not.toHaveBeenCalled();
});
});
+216
View File
@@ -0,0 +1,216 @@
import { clamp } from "../lib/clamp";
import type { ResizeHandleEvent } from "./ResizeHandle";
/**
* Coalesces values pushed faster than the screen refreshes: `fn` gets the latest value at most once
* per animation frame. `flush` delivers a pending value now, `cancel` drops it.
*/
export function createFrameCoalescer<T>(fn: (value: T) => void) {
let frame: number | null = null;
let pending: { value: T } | null = null;
function cancelFrame() {
if (frame != null) cancelAnimationFrame(frame);
frame = null;
}
function flush() {
cancelFrame();
if (pending == null) return;
const { value } = pending;
pending = null;
fn(value);
}
return {
push(value: T) {
pending = { value };
if (frame == null) {
frame = requestAnimationFrame(() => {
frame = null;
flush();
});
}
},
flush,
cancel() {
cancelFrame();
pending = null;
},
};
}
const START_DISTANCE = 7;
/**
* One press of a ResizeHandle. Nothing happens until the pointer has moved more than a few px
* along the handle's axis; after that, moves reach `onResizeMove` at most once per animation frame,
* and the last one always arrives before `onResizeEnd`.
*/
export function createHandleDrag({
vertical,
xStart,
yStart,
onResizeStart,
onResizeMove,
onResizeEnd,
}: {
vertical: boolean;
xStart: number;
yStart: number;
onResizeStart?: () => void;
onResizeMove?: (e: ResizeHandleEvent) => void;
onResizeEnd?: () => void;
}) {
let started = false;
const moves = createFrameCoalescer((e: { clientX: number; clientY: number }) => {
onResizeMove?.({ x: e.clientX, y: e.clientY, xStart, yStart });
});
return {
move(e: { clientX: number; clientY: number }) {
if (started) {
moves.push(e);
return;
}
const distance = Math.abs(vertical ? yStart - e.clientY : xStart - e.clientX);
if (distance > START_DISTANCE) {
started = true;
onResizeStart?.();
}
},
/** The pointer was released */
up() {
moves.flush();
onResizeEnd?.();
},
/** The handle went away mid-press; a drag that had started still ends */
abort() {
moves.cancel();
if (started) onResizeEnd?.();
},
};
}
export function splitGridTemplate(vertical: boolean, ratio: number): string {
return vertical
? `
' left' minmax(0,${1 - ratio}fr)
' drag' 0
' right' minmax(0,${ratio}fr)
/ 1fr
`
: `
' left drag right' minmax(0,1fr)
/ ${1 - ratio}fr 0 ${ratio}fr
`;
}
export function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number {
if (containerPx <= 0 || minPx <= 0) {
return ratio;
}
const minRatio = Math.min(1, minPx / containerPx);
const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio;
return clamp(ratio, minRatio, maxRatio);
}
/**
* One drag of a SplitLayout handle. The ratio of the second pane follows the pointer from where it
* was when the drag started, is shown with `apply` as it changes, and is handed to `commit` once,
* when the drag ends.
*/
export function createSplitDrag({
vertical,
startRatio,
minPx,
measure,
apply,
commit,
}: {
vertical: boolean;
startRatio: number;
minPx: number;
/** The container's content size along the drag axis, in px */
measure: () => number;
apply: (ratio: number) => void;
commit: (ratio: number) => void;
}) {
let ratio: number | null = null;
return {
vertical,
get ratio() {
return ratio;
},
move(e: ResizeHandleEvent) {
const containerPx = measure();
if (containerPx <= 0) return;
const startPx = containerPx * startRatio;
const deltaPx = vertical ? e.y - e.yStart : e.x - e.xStart;
const min = Math.min(minPx, containerPx);
const max = Math.max(min, containerPx - minPx);
ratio = clamp(startPx - deltaPx, min, max) / containerPx;
apply(ratio);
},
end() {
if (ratio != null) commit(ratio);
},
};
}
export type SplitDrag = ReturnType<typeof createSplitDrag>;
export function sidebarGridTemplate(sideWidth: number): string {
return `
' side drag body' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`;
}
export interface SidebarSize {
width: number;
hidden: boolean;
}
/**
* One drag of the SidebarLayout handle. Dragging under `minWidth` collapses the sidebar (and
* resets its width to `defaultWidth`, for when it is shown again); dragging back out reopens it.
* Each change is shown with `apply`, and the result is handed to `commit` once, when the drag ends.
*/
export function createSidebarDrag({
startWidth,
minWidth,
defaultWidth,
apply,
commit,
}: {
startWidth: number;
minWidth: number;
defaultWidth: number;
apply: (size: SidebarSize) => void;
commit: (size: SidebarSize) => void;
}) {
let size: SidebarSize | null = null;
return {
get size() {
return size;
},
move({ x, xStart }: ResizeHandleEvent) {
const newWidth = startWidth + (x - xStart);
size =
newWidth < minWidth
? { width: defaultWidth, hidden: true }
: { width: newWidth, hidden: false };
apply(size);
},
end() {
if (size != null) commit(size);
},
};
}
export type SidebarDrag = ReturnType<typeof createSidebarDrag>;