mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Smooth splitter dragging (#765)
This commit is contained in:
1 parent
8a85fb3dcd
commit
1248112fe0
5 files changed
+636
-126
No files matched your search
@@ -1,8 +1,7 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const START_DISTANCE = 7;
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createHandleDrag } from "./resizeDrag";
|
||||
|
||||
export interface ResizeHandleEvent {
|
||||
x: number;
|
||||
@@ -34,45 +33,47 @@ export function ResizeHandle({
|
||||
}: Props) {
|
||||
const vertical = side === "top";
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{
|
||||
move: (e: MouseEvent) => void;
|
||||
up: (e: MouseEvent) => void;
|
||||
calledStart: boolean;
|
||||
xStart: number;
|
||||
yStart: number;
|
||||
} | null>(null);
|
||||
const stopDrag = useRef<(() => void) | null>(null);
|
||||
|
||||
// A handle that goes away mid-drag ends the drag, so its owner is not left resizing
|
||||
useEffect(() => () => stopDrag.current?.(), []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
function move(e: MouseEvent) {
|
||||
if (moveState.current == null) return;
|
||||
stopDrag.current?.();
|
||||
|
||||
const xDistance = moveState.current.xStart - e.clientX;
|
||||
const yDistance = moveState.current.yStart - e.clientY;
|
||||
const distance = Math.abs(vertical ? yDistance : xDistance);
|
||||
if (moveState.current.calledStart) {
|
||||
onResizeMove?.({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
xStart: moveState.current.xStart,
|
||||
yStart: moveState.current.yStart,
|
||||
});
|
||||
} else if (distance > START_DISTANCE) {
|
||||
const drag = createHandleDrag({
|
||||
vertical,
|
||||
xStart: e.clientX,
|
||||
yStart: e.clientY,
|
||||
onResizeStart: () => {
|
||||
onResizeStart?.();
|
||||
moveState.current.calledStart = 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() {
|
||||
removeListeners();
|
||||
setIsResizing(false);
|
||||
moveState.current = null;
|
||||
document.documentElement.removeEventListener("mousemove", move);
|
||||
document.documentElement.removeEventListener("mouseup", up);
|
||||
onResizeEnd?.();
|
||||
drag.up();
|
||||
}
|
||||
|
||||
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("mouseup", up);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
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 { Overlay } from "./Overlay";
|
||||
import type { ResizeHandleEvent } from "./ResizeHandle";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import type { SidebarDrag, SidebarSize } from "./resizeDrag";
|
||||
import { createSidebarDrag, sidebarGridTemplate } from "./resizeDrag";
|
||||
|
||||
const FLOATING_BREAKPOINT = 600;
|
||||
|
||||
@@ -53,44 +55,65 @@ export function SidebarLayout({
|
||||
}, [floating]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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>(
|
||||
() => ({
|
||||
gridTemplate: `
|
||||
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
|
||||
/ ${sideWidth}px 0 1fr`,
|
||||
}),
|
||||
() => ({ gridTemplate: sidebarGridTemplate(sideWidth) }),
|
||||
[sideWidth],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(() => {
|
||||
startWidth.current = width;
|
||||
setIsResizing(true);
|
||||
}, [width]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
startWidth.current = null;
|
||||
const applySize = useCallback((size: SidebarSize) => {
|
||||
if (containerRef.current == null) return;
|
||||
containerRef.current.style.gridTemplate = sidebarGridTemplate(size.hidden ? 0 : size.width);
|
||||
}, []);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
({ x, xStart }: ResizeHandleEvent) => {
|
||||
if (startWidth.current == null) return;
|
||||
// Something else re-rendering mid-drag may write the saved width back; put the live one back
|
||||
// before it is painted.
|
||||
useLayoutEffect(() => {
|
||||
const size = liveDrag.current?.size;
|
||||
if (size != null) applySize(size);
|
||||
});
|
||||
|
||||
const newWidth = startWidth.current + (x - xStart);
|
||||
if (newWidth < minWidth) {
|
||||
onHiddenChange?.(true);
|
||||
onWidthChange(defaultWidth);
|
||||
} else {
|
||||
if (hidden) onHiddenChange?.(false);
|
||||
onWidthChange(newWidth);
|
||||
}
|
||||
const handleResizeStart = useCallback(() => {
|
||||
const from = { width: shownWidth, hidden: shownHidden };
|
||||
liveDrag.current = createSidebarDrag({
|
||||
startWidth: shownWidth,
|
||||
minWidth,
|
||||
defaultWidth,
|
||||
apply: applySize,
|
||||
commit: (to) => {
|
||||
setCommitted({ from, to });
|
||||
if (to.hidden !== from.hidden) onHiddenChange?.(to.hidden);
|
||||
onWidthChange(to.width);
|
||||
},
|
||||
[minWidth, hidden, onHiddenChange, onWidthChange, defaultWidth],
|
||||
);
|
||||
});
|
||||
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(() => {
|
||||
onWidthChange(defaultWidth);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import classNames from "classnames";
|
||||
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 { useContainerSize } from "../hooks/useContainerSize";
|
||||
import { clamp } from "../lib/clamp";
|
||||
import type { ResizeHandleEvent } from "./ResizeHandle";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import type { SplitDrag } from "./resizeDrag";
|
||||
import { clampSplitRatio, createSplitDrag, splitGridTemplate } from "./resizeDrag";
|
||||
|
||||
export type SplitLayoutLayout = "responsive" | "horizontal" | "vertical";
|
||||
|
||||
@@ -63,71 +64,78 @@ export function SplitLayout({
|
||||
const renderedWidth = clampSplitRatio(width, minWidthPx, size.width);
|
||||
const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0;
|
||||
|
||||
const styles = useMemo<CSSProperties>(() => {
|
||||
return {
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...style,
|
||||
gridTemplate: vertical
|
||||
? `
|
||||
' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr)
|
||||
' ${areaD.gridArea}' 0
|
||||
' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - renderedWidth}fr 0 ${renderedWidth}fr
|
||||
`,
|
||||
};
|
||||
}, [style, vertical, renderedHeight, renderedWidth]);
|
||||
gridTemplate: splitGridTemplate(vertical, vertical ? renderedHeight : renderedWidth),
|
||||
}),
|
||||
[style, vertical, renderedHeight, renderedWidth],
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
const applySplit = useCallback((vertical: boolean, ratio: number) => {
|
||||
if (containerRef.current == null) return;
|
||||
containerRef.current.style.gridTemplate = splitGridTemplate(vertical, ratio);
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
else setWidth(defaultRatio);
|
||||
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: ResizeHandleEvent) => {
|
||||
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,
|
||||
const handleResizeStart = useCallback(() => {
|
||||
liveDrag.current = createSplitDrag({
|
||||
vertical,
|
||||
startRatio: vertical ? renderedHeight : renderedWidth,
|
||||
minPx: vertical ? minHeightPx : minWidthPx,
|
||||
measure: () => {
|
||||
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,
|
||||
setHeight,
|
||||
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 (
|
||||
@@ -136,7 +144,7 @@ export function SplitLayout({
|
||||
style={styles}
|
||||
className={classNames(className, "grid w-full h-full overflow-hidden")}
|
||||
>
|
||||
{firstSlot({ style: areaL, orientation: vertical ? "vertical" : "horizontal" })}
|
||||
{first}
|
||||
{secondSlot && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
@@ -145,24 +153,16 @@ export function SplitLayout({
|
||||
resizeHandleClassName,
|
||||
vertical ? "-translate-y-1" : "-translate-x-1",
|
||||
)}
|
||||
onResizeStart={handleResizeStart}
|
||||
onResizeMove={handleResizeMove}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onReset={handleReset}
|
||||
side={vertical ? "top" : "left"}
|
||||
justify="center"
|
||||
/>
|
||||
{secondSlot({ style: areaR, orientation: vertical ? "vertical" : "horizontal" })}
|
||||
{second}
|
||||
</>
|
||||
)}
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user