feat(frontend): add resizable activity split

This commit is contained in:
User
2026-07-15 15:04:15 +02:00
parent 30e76a968d
commit d177e1c0af
5 changed files with 313 additions and 13 deletions
@@ -0,0 +1,193 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react";
export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384;
export const ACTIVITY_PANEL_MIN_WIDTH = 288;
export const ACTIVITY_PANEL_MAX_WIDTH = 576;
export const ACTIVITY_CENTRAL_MIN_WIDTH = 512;
export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width";
export type ActivityPanelBounds = { min: number; max: number };
export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds {
return {
min: ACTIVITY_PANEL_MIN_WIDTH,
max: Math.max(
ACTIVITY_PANEL_MIN_WIDTH,
Math.min(ACTIVITY_PANEL_MAX_WIDTH, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH),
),
};
}
export function clampActivityPanelWidth(width: number, bounds: ActivityPanelBounds): number {
const candidate = Number.isFinite(width) ? width : ACTIVITY_PANEL_DEFAULT_WIDTH;
return Math.min(bounds.max, Math.max(bounds.min, candidate));
}
export function loadActivityPanelWidth(storage: Pick<Storage, "getItem"> | null): number {
if (!storage) return ACTIVITY_PANEL_DEFAULT_WIDTH;
try {
const raw = storage.getItem(ACTIVITY_PANEL_STORAGE_KEY);
if (raw === null || raw.trim() === "") return ACTIVITY_PANEL_DEFAULT_WIDTH;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : ACTIVITY_PANEL_DEFAULT_WIDTH;
} catch {
return ACTIVITY_PANEL_DEFAULT_WIDTH;
}
}
export function saveActivityPanelWidth(
width: number,
storage: Pick<Storage, "setItem"> | null,
): void {
if (!storage) return;
try {
storage.setItem(ACTIVITY_PANEL_STORAGE_KEY, String(Math.round(width)));
} catch {
// Storage can be unavailable in privacy-restricted browser contexts.
}
}
function getBrowserStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function resizeWidthFromKey(
width: number,
key: string,
shiftKey: boolean,
bounds: ActivityPanelBounds,
): number | null {
const step = shiftKey ? 48 : 16;
if (key === "ArrowLeft") return clampActivityPanelWidth(width - step, bounds);
if (key === "ArrowRight") return clampActivityPanelWidth(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 };
export function useActivityPanelResize(): {
containerRef: RefObject<HTMLDivElement>;
panelWidth: number;
resizing: boolean;
separatorProps: {
role: "separator";
tabIndex: 0;
"aria-label": string;
"aria-orientation": "vertical";
"aria-valuemin": number;
"aria-valuemax": number;
"aria-valuenow": number;
onPointerDown: PointerEventHandler<HTMLDivElement>;
onPointerMove: PointerEventHandler<HTMLDivElement>;
onPointerUp: PointerEventHandler<HTMLDivElement>;
onPointerCancel: PointerEventHandler<HTMLDivElement>;
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
};
} {
const storageRef = useRef<Storage | null>(getBrowserStorage());
const initialWidthRef = useRef(loadActivityPanelWidth(storageRef.current));
const containerRef = useRef<HTMLDivElement>(null);
const widthRef = useRef(initialWidthRef.current);
const boundsRef = useRef<ActivityPanelBounds>({
min: ACTIVITY_PANEL_MIN_WIDTH,
max: ACTIVITY_PANEL_MAX_WIDTH,
});
const dragRef = useRef<DragState | null>(null);
const [panelWidth, setPanelWidth] = useState(initialWidthRef.current);
const [bounds, setBounds] = useState(boundsRef.current);
const [resizing, setResizing] = useState(false);
const applyWidth = useCallback((rawWidth: number, persist: boolean) => {
const next = clampActivityPanelWidth(rawWidth, boundsRef.current);
widthRef.current = next;
setPanelWidth(next);
if (persist) saveActivityPanelWidth(next, storageRef.current);
return next;
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const syncBounds = () => {
if (container.clientWidth < ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH) return;
const nextBounds = getActivityPanelBounds(container.clientWidth);
boundsRef.current = nextBounds;
setBounds(nextBounds);
applyWidth(widthRef.current, true);
};
syncBounds();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncBounds);
observer?.observe(container);
window.addEventListener("resize", syncBounds);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncBounds);
};
}, [applyWidth]);
const onPointerDown: PointerEventHandler<HTMLDivElement> = (event) => {
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: widthRef.current,
};
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 = resizeWidthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current);
if (next === null) return;
event.preventDefault();
applyWidth(next, true);
};
return {
containerRef,
panelWidth,
resizing,
separatorProps: {
role: "separator",
tabIndex: 0,
"aria-label": "Resize model activity",
"aria-orientation": "vertical",
"aria-valuemin": bounds.min,
"aria-valuemax": bounds.max,
"aria-valuenow": panelWidth,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown,
},
};
}