Spaces:
Runtime error
Runtime error
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} />
);
}
|