use-inspector-width.ts4.0 KBView on GitHub 'use client';
/**
* How wide this viewer keeps the node inspector, and the drag that changes it.
*
* A per-viewer preference, not document state: "I want to read the profile, not the chart"
* is a fact about the person and the minute they are having, and storing it on the document
* would have one reader's drag reflow everyone else's canvas.
*
* The panel is anchored to the canvas's RIGHT edge, so the handle is on its LEFT and dragging
* left makes it wider — hence `startWidth + (startX - clientX)`. Getting that sign wrong is
* the classic version of this bug and it is invisible until someone drags.
*
* Every read AND every write is wrapped: `localStorage` is not merely empty in a private
* window, the accessor itself THROWS when a browser is set to block site data, and an
* unguarded read would take the whole inspector down with it.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
const STORAGE_KEY=[redacted];
/**
* Wide enough to read a paragraph of a profile without it going four words to a line, narrow
* enough that the chart behind it is still a chart.
*/
export const INSPECTOR_DEFAULT_WIDTH = 480;
export const INSPECTOR_MIN_WIDTH = 320;
export const INSPECTOR_MAX_WIDTH = 900;
function clampWidth(width: number): number {
return Math.min(INSPECTOR_MAX_WIDTH, Math.max(INSPECTOR_MIN_WIDTH, Math.round(width)));
}
function readWidth(): number {
try {
const stored = Number(window.localStorage.getItem(STORAGE_KEY));
return Number.isFinite(stored) && stored > 0 ? clampWidth(stored) : INSPECTOR_DEFAULT_WIDTH;
} catch {
return INSPECTOR_DEFAULT_WIDTH;
}
}
export interface InspectorWidth {
width: number;
/** True while a drag is in flight — the caller suppresses its width transition. */
dragging: boolean;
/** Put this on the handle's `onPointerDown`. */
onPointerDown: (event: React.PointerEvent) => void;
}
export function useInspectorWidth(): InspectorWidth {
// Lazy initialiser rather than an effect: reading in an effect renders the panel at the
// default for one frame before it snaps to the stored width, which is a visible jump every
// single time a node is clicked.
const [width, setWidth] = useState(readWidth);
const [dragging, setDragging] = useState(false);
// A ref, not state: the move handler is registered once per drag and must see the live
// origin, and re-registering it on every pixel would drop events between renders.
const origin = useRef<{ x: number; width: number } | null>(null);
const onPointerDown = useCallback(
(event: React.PointerEvent) => {
event.preventDefault();
origin.current = { x: event.clientX, width };
setDragging(true);
},
[width],
);
useEffect(() => {
if (!dragging) return;
const onMove = (event: PointerEvent) => {
const start = origin.current;
if (!start) return;
setWidth(clampWidth(start.width + (start.x - event.clientX)));
};
const onUp = () => {
origin.current = null;
setDragging(false);
};
// On `window`, not the handle: a fast drag outruns the 6px strip, and a listener on the
// element would stop tracking the moment the pointer left it.
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
}, [dragging]);
useEffect(() => {
// Persisted on every settled width rather than on pointer-up, so a drag interrupted by a
// closed tab still leaves the width the reader chose.
if (dragging) return;
try {
window.localStorage.setItem(STORAGE_KEY, String(width));
} catch {
// A viewer who cannot persist the choice still gets it for this session.
}
}, [width, dragging]);
return { width, dragging, onPointerDown };
}