File size: 8,509 Bytes
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from "react";

/** The notebook's side panels, resized by dragging the line between a panel and the chat. Widths are kept per browser;
 * nothing set means the stylesheet's default width. A panel never goes under its minimum, and the chat always keeps
 * room, so a narrower window simply takes the extra width back. */

export const PANE_MIN = { left: 220, right: 300 } as const;
const CHAT_MIN = 440;
const GUTTER = 16; // the space between the chat and the right panel (.nbv-side's margin)
const KEYS = { left: "ns_nbv_left", right: "ns_nbv_right" } as const;

type Side = "left" | "right";
type Widths = { left: number | null; right: number | null };

function read(side: Side): number | null {
  try {
    const v = Number(localStorage.getItem(KEYS[side]));
    return Number.isFinite(v) && v > 0 ? v : null;
  } catch {
    return null;
  }
}

function write(side: Side, value: number | null) {
  try {
    if (value === null) localStorage.removeItem(KEYS[side]);
    else localStorage.setItem(KEYS[side], String(Math.round(value)));
  } catch {
    /* Private browsing can prevent persistence; resizing still works for this visit. */
  }
}

function useMedia(query: string): boolean {
  const [on, setOn] = useState(() => typeof window !== "undefined" && window.matchMedia(query).matches);
  useEffect(() => {
    const m = window.matchMedia(query);
    const change = () => setOn(m.matches);
    change();
    m.addEventListener("change", change);
    return () => m.removeEventListener("change", change);
  }, [query]);
  return on;
}

/** Where the panes are now: the container's width and the two boundaries (px from its left edge). */
type Frame = { width: number; leftEdge: number | null; rightEdge: number | null };

export function usePaneWidths(container: RefObject<HTMLElement | null>, opts: { leftOpen: boolean }) {
  const [saved, setSaved] = useState<Widths>(() => ({ left: read("left"), right: read("right") }));
  const [frame, setFrame] = useState<Frame>({ width: 0, leftEdge: null, rightEdge: null });
  // The left panel sits beside the chat from 900px; the right one from 1350px (below that it stacks under the chat).
  const leftActive = useMedia("(min-width: 900px)") && opts.leftOpen;
  const rightActive = useMedia("(min-width: 1350px)");

  // Measure the real boundaries after layout changes, so the handles sit exactly on them (state only changes when a
  // number does, so this cannot loop).
  const measure = useCallback(() => {
    const el = container.current;
    if (!el) return;
    const box = el.getBoundingClientRect();
    const history = el.querySelector<HTMLElement>(":scope > .nbv-history");
    const side = el.querySelector<HTMLElement>(":scope > .nbv-side");
    const next: Frame = {
      width: Math.round(box.width),
      leftEdge: history ? Math.round(history.getBoundingClientRect().right - box.left) : null,
      rightEdge: side ? Math.round(side.getBoundingClientRect().left - box.left - GUTTER / 2) : null,
    };
    setFrame((cur) => (cur.width === next.width && cur.leftEdge === next.leftEdge && cur.rightEdge === next.rightEdge ? cur : next));
  }, [container]);
  useLayoutEffect(() => {
    measure();
  });
  useEffect(() => {
    const el = container.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    el.querySelectorAll(":scope > .nbv-history, :scope > .nbv-side").forEach((p) => ro.observe(p));
    window.addEventListener("resize", measure);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", measure);
    };
  }, [container, measure, leftActive, rightActive]);

  const { width } = frame;
  // What each panel measures by default (the stylesheet's width), when nothing is saved for it.
  const measuredLeft = frame.leftEdge ?? 0;
  const measuredRight = frame.rightEdge !== null ? width - frame.rightEdge - GUTTER / 2 : 0;
  const wantLeft = leftActive ? (saved.left ?? measuredLeft) : 0;
  const wantRight = rightActive ? (saved.right ?? measuredRight) : 0;
  // Each panel: at least its minimum, and never so wide that the chat gets less than CHAT_MIN.
  const maxLeft = Math.max(PANE_MIN.left, width - CHAT_MIN - (rightActive ? wantRight + GUTTER : 0));
  const maxRight = Math.max(PANE_MIN.right, width - CHAT_MIN - GUTTER - (leftActive ? wantLeft : 0));
  const fit = (v: number, max: number, min: number) => Math.max(min, Math.min(v, max));
  const left = leftActive && saved.left !== null && width ? fit(saved.left, maxLeft, PANE_MIN.left) : null;
  const right = rightActive && saved.right !== null && width ? fit(saved.right, maxRight, PANE_MIN.right) : null;

  const set = useCallback((side: Side, value: number | null, persist = false) => {
    setSaved((cur) => ({ ...cur, [side]: value }));
    if (persist) write(side, value);
  }, []);

  const style: CSSProperties = {
    ...(left !== null ? { ["--nbv-left" as string]: `${Math.round(left)}px` } : {}),
    ...(right !== null ? { ["--nbv-right" as string]: `${Math.round(right + GUTTER)}px` } : {}),
  };

  return {
    style,
    frame,
    active: { left: leftActive && frame.leftEdge !== null, right: rightActive && frame.rightEdge !== null },
    current: { left: left ?? measuredLeft, right: right ?? measuredRight },
    bounds: { left: { min: PANE_MIN.left, max: maxLeft }, right: { min: PANE_MIN.right, max: maxRight } },
    set,
  };
}

type Panes = ReturnType<typeof usePaneWidths>;

/** The line between a side panel and the chat: drag it (or focus it and use the arrow keys) to resize the panel;
 * double click puts the panel back to its default width. */
export function PaneResizer({ side, panes, container, label }: {
  side: Side;
  panes: Panes;
  container: RefObject<HTMLElement | null>;
  label: string;
}) {
  const [dragging, setDragging] = useState(false);
  const frameReq = useRef(0);
  const { min, max } = panes.bounds[side];
  const value = Math.round(panes.current[side]);
  const clamp = (v: number) => Math.max(min, Math.min(max, v));
  const at = side === "left" ? panes.frame.leftEdge : panes.frame.rightEdge;

  const fromPointer = (clientX: number) => {
    const box = container.current?.getBoundingClientRect();
    if (!box) return value;
    return clamp(side === "left" ? clientX - box.left : box.right - clientX - GUTTER / 2);
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return;
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
    document.body.classList.add("nbv-resizing");
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    const x = e.clientX;
    cancelAnimationFrame(frameReq.current);
    frameReq.current = requestAnimationFrame(() => panes.set(side, fromPointer(x)));
  };
  const end = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    cancelAnimationFrame(frameReq.current);
    setDragging(false);
    document.body.classList.remove("nbv-resizing");
    panes.set(side, fromPointer(e.clientX), true);
  };
  useEffect(() => () => document.body.classList.remove("nbv-resizing"), []);

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const step = e.shiftKey ? 64 : 16;
    // The left panel grows to the right; the right panel grows to the left.
    const grow = side === "left" ? "ArrowRight" : "ArrowLeft";
    const shrink = side === "left" ? "ArrowLeft" : "ArrowRight";
    let next: number | null = null;
    if (e.key === grow) next = value + step;
    else if (e.key === shrink) next = value - step;
    else if (e.key === "Home") next = min;
    else if (e.key === "End") next = max;
    else return;
    e.preventDefault();
    panes.set(side, clamp(next), true);
  };

  if (!panes.active[side] || at === null) return null;
  return (
    <div className={`nbv-resizer nbv-resizer-${side}${dragging ? " dragging" : ""}`} style={{ left: at }}
         role="separator" aria-orientation="vertical" aria-label={label} tabIndex={0}
         aria-valuenow={value} aria-valuemin={min} aria-valuemax={Math.round(max)}
         title="Drag to resize. Double click to reset."
         onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={end} onPointerCancel={end}
         onDoubleClick={() => panes.set(side, null, true)} onKeyDown={onKeyDown} />
  );
}