Files
backnotprop__plannotator/packages/ui/hooks/useResizablePanel.ts
Michael Ramos f4f6bdb0e9 feat(ui): handle-wide click-to-collapse + suppressible hover on resize handles (#1028)
* feat(ui): handle-wide click-to-collapse + suppressible hover on resize handles

Rework the sidebar/panel resize-handle interaction:

- useResizablePanel: new `onClick` option that fires on pointer-up only when
  the pointer never traveled past `clickThreshold` (4px default), so a genuine
  click on the handle can be told apart from a drag-start. Plannotator is
  unchanged unless `onClick` is passed.
- ResizeHandle: `hideHoverTrack` to suppress the hover color-change, a
  cursor-following `tooltip`, a `trackClassName` prop, and a
  `[data-resize-track]` attribute (mirrors `[data-collapse]`) so a host can
  restyle/suppress the hover reveal from plain CSS.
- Wire all plan-editor and review-editor handles to the new UX: no track
  highlight, cursor tooltip ("Click to close · Drag to resize"), single-click
  collapse (double-click reset becomes unreachable), chevron retained.
- README: document the new resize-handle host seams.

* refactor(ui): self-review polish on resize-handle UX

- Skip tooltip-position state updates while dragging (they were hidden anyway).
- Extract the repeated resize-handle tooltip string into a per-app constant
  (RESIZE_HANDLE_TOOLTIP) instead of 7 inline literals.

* fix(ui): don't treat pointercancel as a click-to-collapse

pointercancel fires when the browser aborts a gesture (palm rejection, system
gesture, focus loss). It was routed through the same onUp path as pointerup, so
a no-move cancel with onClick set would collapse the panel. Skip the onClick
branch on cancel — only clean up drag state (a real in-progress drag still
commits its width, matching prior behavior).
2026-07-08 22:50:29 -07:00

205 lines
7.4 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import { storage } from '../utils/storage';
interface UseResizablePanelOptions {
storageKey: string;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
side?: 'left' | 'right' | 'top' | 'bottom';
/** Drag axis: 'x' resizes width (default), 'y' resizes height (uses clientY). */
axis?: 'x' | 'y';
/**
* When provided, dragging the panel narrower than `snapCloseRatio * minWidth`
* snaps it shut (calls this) instead of clamping at minWidth.
*/
onSnapClose?: () => void;
snapCloseRatio?: number;
/**
* Imperative live-apply. When provided, the drag drives the width through this
* callback ONCE PER FRAME and does NOT call setState — so the host component
* never re-renders mid-drag (buttery on heavy hosts). React state is committed
* once on release. When omitted, the width is driven through React state
* (still rAF-coalesced).
*/
apply?: (width: number) => void;
/**
* Fires on pointer-up when the pointer never moved beyond `clickThreshold` —
* i.e. a genuine click on the handle, not a drag. The hook owns the pointer
* state machine, so this is the only reliable way to tell a click from a
* drag-start. Lets a host make the whole handle a click target (e.g. click
* anywhere on the handle to collapse the panel). Not fired on a snap-close.
*/
onClick?: () => void;
/** Max pointer travel (px) still counted as a click for `onClick`. Default 4. */
clickThreshold?: number;
}
export interface ResizeHandleProps {
isDragging: boolean;
onPointerDown: (e: React.PointerEvent) => void;
onDoubleClick: () => void;
/** touch-action: none so touch drags don't scroll-hijack. */
style: React.CSSProperties;
}
export function useResizablePanel({
storageKey,
defaultWidth = 288,
minWidth = 200,
maxWidth = 600,
side = 'right',
axis = 'x',
onSnapClose,
snapCloseRatio = 0.6,
apply,
onClick,
clickThreshold = 4,
}: UseResizablePanelOptions) {
const [width, setWidth] = useState(() => {
const saved = storage.getItem(storageKey);
if (saved) {
const n = Number(saved);
if (!Number.isNaN(n) && n >= minWidth && n <= maxWidth) return n;
}
return defaultWidth;
});
const [isDragging, setIsDragging] = useState(false);
// Live/committed width. Drag math reads/writes this without re-rendering.
const widthRef = useRef(width);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const draggingRef = useRef(false);
const snappedRef = useRef(false);
// Latches true once the pointer travels past clickThreshold — distinguishes a
// click from a drag. Reset on each pointerdown.
const movedRef = useRef(false);
const latestXRef = useRef(0);
const rafRef = useRef<number | null>(null);
// Latest callbacks via refs so the rAF loop always sees fresh values.
const applyRef = useRef(apply);
applyRef.current = apply;
const onSnapCloseRef = useRef(onSnapClose);
onSnapCloseRef.current = onSnapClose;
const onClickRef = useRef(onClick);
onClickRef.current = onClick;
// rAF tick: compute the width from the most recent pointer position and apply
// it. At most one DOM/state write per frame regardless of pointer-event rate.
const flush = useCallback(() => {
rafRef.current = null;
if (!draggingRef.current) return;
const delta =
side === 'right' || side === 'bottom'
? startXRef.current - latestXRef.current
: latestXRef.current - startXRef.current;
const raw = startWidthRef.current + delta;
// Drag-to-snap-shut.
if (onSnapCloseRef.current && raw < minWidth * snapCloseRatio) {
snappedRef.current = true;
draggingRef.current = false;
widthRef.current = startWidthRef.current;
applyRef.current?.(startWidthRef.current);
storage.setItem(storageKey, String(startWidthRef.current));
setWidth(startWidthRef.current);
setIsDragging(false);
onSnapCloseRef.current();
return;
}
const w = Math.min(maxWidth, Math.max(minWidth, raw));
widthRef.current = w;
if (applyRef.current) applyRef.current(w);
else setWidth(w);
}, [side, minWidth, maxWidth, snapCloseRatio, storageKey]);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
// Only primary button / touch / pen.
if (e.button !== 0) return;
e.preventDefault();
const pos = axis === 'y' ? e.clientY : e.clientX;
startXRef.current = pos;
startWidthRef.current = widthRef.current;
latestXRef.current = pos;
snappedRef.current = false;
movedRef.current = false;
draggingRef.current = true;
setIsDragging(true);
// Native window listeners — fire for EVERY pointer move anywhere on screen,
// including past the window edge and faster than the cursor. This is the
// reliable path (React's synthetic onPointerMove on the tiny handle drops
// moves once the pointer leaves it).
const onMove = (ev: PointerEvent) => {
if (!draggingRef.current) return;
latestXRef.current = axis === 'y' ? ev.clientY : ev.clientX;
if (Math.abs(latestXRef.current - startXRef.current) > clickThreshold)
movedRef.current = true;
if (rafRef.current == null) rafRef.current = requestAnimationFrame(flush);
};
const cleanup = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
function onUp(e: PointerEvent) {
// pointercancel = the browser aborted the gesture (palm rejection, a
// system gesture, focus loss). It is NOT a completed click — only clean
// up drag state, never treat it as a click-to-collapse.
const cancelled = e?.type === 'pointercancel';
const wasSnapped = snappedRef.current;
draggingRef.current = false;
snappedRef.current = false;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
setIsDragging(false);
if (!wasSnapped) {
if (onClickRef.current && !movedRef.current && !cancelled) {
// A pointerdown+up that never crossed the threshold is a click, not
// a resize — fire onClick and leave the width untouched. Only when a
// host opts in via onClick; otherwise commit exactly as before.
onClickRef.current();
} else {
// Commit the live width to React state + persist.
setWidth(widthRef.current);
storage.setItem(storageKey, String(widthRef.current));
}
}
cleanup();
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
},
[flush, storageKey, axis, clickThreshold],
);
const resetWidth = useCallback(() => {
widthRef.current = defaultWidth;
applyRef.current?.(defaultWidth);
setWidth(defaultWidth);
storage.setItem(storageKey, String(defaultWidth));
}, [defaultWidth, storageKey]);
return {
width,
/** Alias for `width` — reads clearer when axis is 'y' (it's a height). */
size: width,
isDragging,
handleProps: {
isDragging,
onPointerDown,
onDoubleClick: resetWidth,
style: { touchAction: 'none' },
} as ResizeHandleProps,
};
}