128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type PointerEvent as ReactPointerEvent,
|
|
type ReactNode,
|
|
} from 'react';
|
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
|
const panelMaxWidth = (minWidth: number) =>
|
|
Math.max(minWidth, Math.min(576, window.innerWidth * 0.4));
|
|
function storedWidth(key: string, fallback: number, minWidth: number) {
|
|
try {
|
|
const value = Number(localStorage.getItem(key));
|
|
return clamp(
|
|
Number.isFinite(value) && value > 0 ? value : fallback,
|
|
minWidth,
|
|
panelMaxWidth(minWidth),
|
|
);
|
|
} catch {
|
|
return clamp(fallback, minWidth, panelMaxWidth(minWidth));
|
|
}
|
|
}
|
|
export function ResizablePanel({
|
|
side,
|
|
storageKey,
|
|
visible = true,
|
|
defaultWidth = 288,
|
|
minWidth = 224,
|
|
children,
|
|
className = '',
|
|
}: {
|
|
side: 'left' | 'right';
|
|
storageKey: string;
|
|
visible?: boolean;
|
|
defaultWidth?: number;
|
|
minWidth?: number;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const [width, setWidth] = useState(() => storedWidth(storageKey, defaultWidth, minWidth)),
|
|
cleanupRef = useRef<() => void>(() => {});
|
|
const update = (next: number) => {
|
|
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
|
|
setWidth(value);
|
|
try {
|
|
localStorage.setItem(storageKey, String(value));
|
|
} catch {
|
|
/* 无持久化权限时仍可调整 */
|
|
}
|
|
};
|
|
useEffect(() => {
|
|
const persist = (next: number) => {
|
|
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
|
|
setWidth(value);
|
|
try {
|
|
localStorage.setItem(storageKey, String(value));
|
|
} catch {
|
|
/* 忽略 */
|
|
}
|
|
},
|
|
resize = () => setWidth((value) => clamp(value, minWidth, panelMaxWidth(minWidth))),
|
|
layout = (event: Event) => {
|
|
const widths = (event as CustomEvent<{ left: number; right: number }>).detail;
|
|
persist(widths[side]);
|
|
};
|
|
window.addEventListener('resize', resize);
|
|
window.addEventListener('mujoco-layout-widths', layout);
|
|
return () => {
|
|
window.removeEventListener('resize', resize);
|
|
window.removeEventListener('mujoco-layout-widths', layout);
|
|
cleanupRef.current();
|
|
};
|
|
}, [minWidth, side, storageKey]);
|
|
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
cleanupRef.current();
|
|
const origin = event.clientX,
|
|
startWidth = width,
|
|
pointerId = event.pointerId;
|
|
const move = (moveEvent: PointerEvent) => {
|
|
if (moveEvent.pointerId === pointerId)
|
|
update(startWidth + (moveEvent.clientX - origin) * (side === 'left' ? 1 : -1));
|
|
};
|
|
const stop = (stopEvent: PointerEvent) => {
|
|
if (stopEvent.pointerId !== pointerId) return;
|
|
cleanup();
|
|
};
|
|
const cleanup = () => {
|
|
window.removeEventListener('pointermove', move);
|
|
window.removeEventListener('pointerup', stop);
|
|
window.removeEventListener('pointercancel', stop);
|
|
cleanupRef.current = () => {};
|
|
};
|
|
cleanupRef.current = cleanup;
|
|
window.addEventListener('pointermove', move);
|
|
window.addEventListener('pointerup', stop);
|
|
window.addEventListener('pointercancel', stop);
|
|
};
|
|
return (
|
|
<div
|
|
hidden={!visible}
|
|
className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side === 'left' ? 'max-lg:left-0' : 'max-lg:right-0'} ${className}`}
|
|
style={{ width }}
|
|
>
|
|
{children}
|
|
<button
|
|
type="button"
|
|
role="separator"
|
|
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
|
|
aria-orientation="vertical"
|
|
aria-valuemin={minWidth}
|
|
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
|
|
aria-valuenow={Math.round(width)}
|
|
onPointerDown={start}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Home') update(minWidth);
|
|
else if (event.key === 'End') update(panelMaxWidth(minWidth));
|
|
else if (event.key === 'ArrowLeft') update(width + (side === 'left' ? -16 : 16));
|
|
else if (event.key === 'ArrowRight') update(width + (side === 'left' ? 16 : -16));
|
|
else return;
|
|
event.preventDefault();
|
|
}}
|
|
className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side === 'left' ? '-right-1' : '-left-1'}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|