- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成: icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo - 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效 - 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
|
|
type Props = {
|
|
direction?: "horizontal" | "vertical";
|
|
initialRatio?: number;
|
|
storageKey?: string;
|
|
minPx?: number;
|
|
className?: string;
|
|
children: [ReactNode, ReactNode];
|
|
};
|
|
|
|
function clamp(n: number, lo: number, hi: number) {
|
|
return Math.min(hi, Math.max(lo, n));
|
|
}
|
|
|
|
export function SplitPane({
|
|
direction = "vertical",
|
|
initialRatio = 0.45,
|
|
storageKey,
|
|
minPx = 120,
|
|
className = "",
|
|
children,
|
|
}: Props) {
|
|
const [a, b] = children;
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const [ratio, setRatio] = useState(() => {
|
|
if (storageKey) {
|
|
const raw = localStorage.getItem(storageKey);
|
|
const n = raw ? Number(raw) : NaN;
|
|
if (Number.isFinite(n) && n > 0.1 && n < 0.9) return n;
|
|
}
|
|
return initialRatio;
|
|
});
|
|
const dragging = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (storageKey) localStorage.setItem(storageKey, String(ratio));
|
|
}, [ratio, storageKey]);
|
|
|
|
const onPointerMove = useCallback(
|
|
(e: PointerEvent) => {
|
|
if (!dragging.current || !rootRef.current) return;
|
|
const rect = rootRef.current.getBoundingClientRect();
|
|
if (direction === "vertical") {
|
|
const y = e.clientY - rect.top;
|
|
const min = minPx / rect.height;
|
|
setRatio(clamp(y / rect.height, min, 1 - min));
|
|
} else {
|
|
const x = e.clientX - rect.left;
|
|
const min = minPx / rect.width;
|
|
setRatio(clamp(x / rect.width, min, 1 - min));
|
|
}
|
|
},
|
|
[direction, minPx],
|
|
);
|
|
|
|
const stop = useCallback(() => {
|
|
dragging.current = false;
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("pointermove", onPointerMove);
|
|
window.addEventListener("pointerup", stop);
|
|
return () => {
|
|
window.removeEventListener("pointermove", onPointerMove);
|
|
window.removeEventListener("pointerup", stop);
|
|
};
|
|
}, [onPointerMove, stop]);
|
|
|
|
const vert = direction === "vertical";
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
className={`flex min-h-0 min-w-0 flex-1 ${vert ? "flex-col" : "flex-row"} ${className}`}
|
|
>
|
|
<div className="min-h-0 min-w-0 overflow-hidden" style={vert ? { height: `${ratio * 100}%` } : { width: `${ratio * 100}%` }}>
|
|
{a}
|
|
</div>
|
|
<div
|
|
role="separator"
|
|
aria-orientation={vert ? "horizontal" : "vertical"}
|
|
title="拖动调整;双击复位"
|
|
onPointerDown={(e) => {
|
|
e.preventDefault();
|
|
dragging.current = true;
|
|
document.body.style.cursor = vert ? "row-resize" : "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
}}
|
|
onDoubleClick={() => setRatio(initialRatio)}
|
|
className={
|
|
vert
|
|
? "group relative z-10 h-2 shrink-0 cursor-row-resize"
|
|
: "group relative z-10 w-2 shrink-0 cursor-col-resize"
|
|
}
|
|
style={{ background: "transparent" }}
|
|
>
|
|
<div
|
|
className={
|
|
vert
|
|
? "absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-[var(--border)] transition group-hover:h-1 group-hover:bg-[var(--accent)]"
|
|
: "absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[var(--border)] transition group-hover:w-1 group-hover:bg-[var(--accent)]"
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">{b}</div>
|
|
</div>
|
|
);
|
|
}
|