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
+641
-131
No files matched your search
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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