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 };
}