From 1248112fe069c6e5f4b1bea580842086303bb661 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 21 Oct 2026 12:22:45 -0700 Subject: [PATCH] Smooth splitter dragging (#765) --- packages/ui/src/components/ResizeHandle.tsx | 61 ++-- packages/ui/src/components/SidebarLayout.tsx | 83 ++++-- packages/ui/src/components/SplitLayout.tsx | 142 ++++----- packages/ui/src/components/resizeDrag.test.ts | 270 ++++++++++++++++++ packages/ui/src/components/resizeDrag.ts | 216 ++++++++++++++ 5 files changed, 641 insertions(+), 131 deletions(-) create mode 100644 packages/ui/src/components/resizeDrag.test.ts create mode 100644 packages/ui/src/components/resizeDrag.ts diff --git a/packages/ui/src/components/ResizeHandle.tsx b/packages/ui/src/components/ResizeHandle.tsx index 73cc23da2..114ea8c60 100644 --- a/packages/ui/src/components/ResizeHandle.tsx +++ b/packages/ui/src/components/ResizeHandle.tsx @@ -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(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) => { - 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); diff --git a/packages/ui/src/components/SidebarLayout.tsx b/packages/ui/src/components/SidebarLayout.tsx index 448bbe9b6..f6d7d59a2 100644 --- a/packages/ui/src/components/SidebarLayout.tsx +++ b/packages/ui/src/components/SidebarLayout.tsx @@ -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(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(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( - () => ({ - 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); - } - }, - [minWidth, hidden, onHiddenChange, onWidthChange, defaultWidth], - ); + 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); + }, + }); + 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); diff --git a/packages/ui/src/components/SplitLayout.tsx b/packages/ui/src/components/SplitLayout.tsx index b5fd516de..151f20dc6 100644 --- a/packages/ui/src/components/SplitLayout.tsx +++ b/packages/ui/src/components/SplitLayout.tsx @@ -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(() => { - return { + const styles = useMemo( + () => ({ ...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(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, - minHeightPx, - setHeight, - minWidthPx, - setWidth, - ], + 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, + 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 && ( <> - {secondSlot({ style: areaR, orientation: vertical ? "vertical" : "horizontal" })} + {second} )} ); } - -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); -} diff --git a/packages/ui/src/components/resizeDrag.test.ts b/packages/ui/src/components/resizeDrag.test.ts new file mode 100644 index 000000000..e5f3ca4ea --- /dev/null +++ b/packages/ui/src/components/resizeDrag.test.ts @@ -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; + +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[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(); + }); +}); diff --git a/packages/ui/src/components/resizeDrag.ts b/packages/ui/src/components/resizeDrag.ts new file mode 100644 index 000000000..f248b8072 --- /dev/null +++ b/packages/ui/src/components/resizeDrag.ts @@ -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(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; + +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;