feat: redesign persisted session summaries
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
|
||||
|
||||
export const SESSION_PANEL_DEFAULT_WIDTH = 384;
|
||||
export const SESSION_PANEL_MIN_WIDTH = 320;
|
||||
export const SESSION_PANEL_FALLBACK_MAX_WIDTH = 576;
|
||||
export const SESSION_PANEL_RIGHT_MIN_WIDTH = 512;
|
||||
export const SESSION_PANEL_STORAGE_KEY = "thothii:session-documents-panel-width";
|
||||
|
||||
type Bounds = { min: number; max: number };
|
||||
|
||||
export function getSessionPanelBounds(containerWidth: number): Bounds {
|
||||
return {
|
||||
min: SESSION_PANEL_MIN_WIDTH,
|
||||
max: Math.max(
|
||||
SESSION_PANEL_MIN_WIDTH,
|
||||
Math.min(containerWidth * 0.5, containerWidth - SESSION_PANEL_RIGHT_MIN_WIDTH),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function clampWidth(width: number, bounds: Bounds): number {
|
||||
const candidate = Number.isFinite(width) ? width : SESSION_PANEL_DEFAULT_WIDTH;
|
||||
return Math.min(bounds.max, Math.max(bounds.min, candidate));
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadWidth(storage: Pick<Storage, "getItem"> | null): number {
|
||||
if (!storage) return SESSION_PANEL_DEFAULT_WIDTH;
|
||||
try {
|
||||
const raw = storage.getItem(SESSION_PANEL_STORAGE_KEY);
|
||||
if (!raw?.trim()) return SESSION_PANEL_DEFAULT_WIDTH;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : SESSION_PANEL_DEFAULT_WIDTH;
|
||||
} catch {
|
||||
return SESSION_PANEL_DEFAULT_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
function saveWidth(width: number, storage: Pick<Storage, "setItem"> | null): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(SESSION_PANEL_STORAGE_KEY, String(Math.round(width)));
|
||||
} catch {
|
||||
// Storage may be unavailable in privacy-restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function widthFromKey(width: number, key: string, shiftKey: boolean, bounds: Bounds) {
|
||||
const step = shiftKey ? 48 : 16;
|
||||
if (key === "ArrowLeft") return clampWidth(width - step, bounds);
|
||||
if (key === "ArrowRight") return clampWidth(width + step, bounds);
|
||||
if (key === "Home") return bounds.min;
|
||||
if (key === "End") return bounds.max;
|
||||
return null;
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
target: HTMLDivElement;
|
||||
};
|
||||
|
||||
export function useSessionPanelResize(
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const storageRef = useRef<Storage | null>(browserStorage());
|
||||
const initialWidthRef = useRef(loadWidth(storageRef.current));
|
||||
const widthRef = useRef(initialWidthRef.current);
|
||||
const boundsRef = useRef<Bounds>({
|
||||
min: SESSION_PANEL_MIN_WIDTH,
|
||||
max: SESSION_PANEL_FALLBACK_MAX_WIDTH,
|
||||
});
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
|
||||
const [bounds, setBounds] = useState(boundsRef.current);
|
||||
const [desktopSplit, setDesktopSplit] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
|
||||
const next = clampWidth(rawWidth, boundsRef.current);
|
||||
widthRef.current = next;
|
||||
setPanelWidth(next);
|
||||
if (persist) saveWidth(next, storageRef.current);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const cancelDrag = () => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.target.hasPointerCapture?.(drag.pointerId)) {
|
||||
drag.target.releasePointerCapture?.(drag.pointerId);
|
||||
}
|
||||
dragRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
const syncLayout = () => {
|
||||
const viewportIsDesktop = window.matchMedia?.("(min-width: 1024px)").matches
|
||||
?? window.innerWidth >= 1024;
|
||||
const usableWidth = SESSION_PANEL_MIN_WIDTH + SESSION_PANEL_RIGHT_MIN_WIDTH;
|
||||
const nextDesktopSplit = enabled && viewportIsDesktop && container.clientWidth >= usableWidth;
|
||||
setDesktopSplit(nextDesktopSplit);
|
||||
if (!nextDesktopSplit) {
|
||||
cancelDrag();
|
||||
return;
|
||||
}
|
||||
const nextBounds = getSessionPanelBounds(container.clientWidth);
|
||||
boundsRef.current = nextBounds;
|
||||
setBounds(nextBounds);
|
||||
applyWidth(widthRef.current, true);
|
||||
};
|
||||
syncLayout();
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout);
|
||||
observer?.observe(container);
|
||||
const media = window.matchMedia?.("(min-width: 1024px)");
|
||||
media?.addEventListener?.("change", syncLayout);
|
||||
window.addEventListener("resize", syncLayout);
|
||||
return () => {
|
||||
cancelDrag();
|
||||
observer?.disconnect();
|
||||
media?.removeEventListener?.("change", syncLayout);
|
||||
window.removeEventListener("resize", syncLayout);
|
||||
};
|
||||
}, [applyWidth, containerRef, enabled]);
|
||||
|
||||
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth: widthRef.current,
|
||||
target: event.currentTarget,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setResizing(true);
|
||||
};
|
||||
const onPointerMove: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
applyWidth(drag.startWidth + event.clientX - drag.startX, false);
|
||||
};
|
||||
const onPointerUp: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
applyWidth(drag.startWidth + event.clientX - drag.startX, true);
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
dragRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
const onPointerCancel: PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
applyWidth(drag.startWidth, false);
|
||||
dragRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
const onKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
const next = widthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
applyWidth(next, true);
|
||||
};
|
||||
|
||||
return {
|
||||
panelWidth,
|
||||
desktopSplit,
|
||||
resizing,
|
||||
separatorProps: {
|
||||
role: "separator" as const,
|
||||
tabIndex: 0,
|
||||
"aria-label": "Resize session summary",
|
||||
"aria-orientation": "vertical" as const,
|
||||
"aria-valuemin": bounds.min,
|
||||
"aria-valuemax": bounds.max,
|
||||
"aria-valuenow": panelWidth,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onKeyDown,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user