mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 08:13:29 +08:00
✨ feat(sidebar): 支持侧边栏宽度随拖动实时调整
- 使用 CSS 宽度变量与 RAF 实时更新侧边栏及工作区布局 - 仅在拖动结束后持久化最终宽度,避免每帧触发 Store 重渲染 - 移除侧边栏 ghost 指示线并补充交互清理回归测试
This commit is contained in:
@@ -2528,6 +2528,8 @@ body[data-ui-version] .ant-layout-sider[data-sidebar-panel='true'] {
|
||||
body[data-ui-version] .ant-layout-sider[data-sidebar-panel='true'][data-sidebar-resizing='true'],
|
||||
body[data-sidebar-resizing='true'] .ant-layout-sider[data-sidebar-panel='true'] {
|
||||
transition: none !important;
|
||||
width: var(--gonavi-sidebar-resize-width) !important;
|
||||
flex: 0 0 var(--gonavi-sidebar-resize-width) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version] .ant-layout-sider[data-sidebar-collapsed='true'] {
|
||||
|
||||
@@ -4243,7 +4243,6 @@ function App() {
|
||||
}, [t]);
|
||||
|
||||
const {
|
||||
ghostRef,
|
||||
handleSidebarMouseDown,
|
||||
sidebarResizeHandleWidth,
|
||||
siderRef,
|
||||
@@ -9028,22 +9027,6 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ghost Resize Line for Sidebar */}
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '4px',
|
||||
background: resizeGuideColor,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
display: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Ghost Resize Line for Log Panel */}
|
||||
<div
|
||||
ref={logGhostRef}
|
||||
|
||||
@@ -46,7 +46,27 @@ class FakeAttributeHost {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStyle {
|
||||
private properties = new Map<string, string>();
|
||||
|
||||
setProperty(name: string, value: string) {
|
||||
this.properties.set(name, value);
|
||||
}
|
||||
|
||||
removeProperty(name: string) {
|
||||
const previous = this.properties.get(name) || '';
|
||||
this.properties.delete(name);
|
||||
return previous;
|
||||
}
|
||||
|
||||
getPropertyValue(name: string) {
|
||||
return this.properties.get(name) || '';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLElement extends FakeAttributeHost {
|
||||
style = new FakeStyle();
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { right: 240, width: 240 };
|
||||
}
|
||||
@@ -81,7 +101,6 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
let resize: ReturnType<typeof useAppSidebarResize> | null = null;
|
||||
let fakeWindow: FakeEventTarget & { getComputedStyle: () => { minWidth: string; maxWidth: string }; innerWidth: number };
|
||||
let fakeDocument: FakeEventTarget & { body: FakeBody };
|
||||
let ghost: { style: { display: string; left: string } };
|
||||
let scheduledFrames: Map<number, FrameRequestCallback>;
|
||||
let nextFrameId: number;
|
||||
let setSidebarWidth: ReturnType<typeof vi.fn>;
|
||||
@@ -117,8 +136,6 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
fakeDocument = Object.assign(new FakeEventTarget(), {
|
||||
body: new FakeBody(),
|
||||
});
|
||||
ghost = { style: { display: 'none', left: '' } };
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: fakeWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: fakeDocument });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', { configurable: true, value: FakeHTMLElement });
|
||||
@@ -139,7 +156,6 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
renderer = create(<Harness />);
|
||||
});
|
||||
(resize!.siderRef as React.MutableRefObject<any>).current = new FakeHTMLElement();
|
||||
(resize!.ghostRef as React.MutableRefObject<any>).current = ghost;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -170,7 +186,6 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
userSelect: 'none',
|
||||
webkitUserSelect: 'none',
|
||||
});
|
||||
expect(ghost.style.display).toBe('block');
|
||||
expect(fakeWindow.listenerCount('blur')).toBe(1);
|
||||
|
||||
act(() => fakeWindow.dispatch('blur'));
|
||||
@@ -180,7 +195,6 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
userSelect: 'text',
|
||||
webkitUserSelect: 'auto',
|
||||
});
|
||||
expect(ghost.style.display).toBe('none');
|
||||
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
|
||||
expect(fakeDocument.listenerCount('mouseup')).toBe(0);
|
||||
expect(fakeWindow.listenerCount('blur')).toBe(0);
|
||||
@@ -193,14 +207,36 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
act(() => fakeDocument.dispatch('mousemove', { buttons: 0, clientX: 260 }));
|
||||
|
||||
expect(setSidebarWidth).toHaveBeenCalledWith(300);
|
||||
expect(ghost.style.display).toBe('none');
|
||||
expect((resize?.siderRef.current as unknown as FakeHTMLElement).style.getPropertyValue('--gonavi-sidebar-resize-width')).toBe('300px');
|
||||
expect(fakeDocument.body.style.cursor).toBe('wait');
|
||||
expect(fakeDocument.body.style.userSelect).toBe('text');
|
||||
expect(fakeDocument.listenerCount('mousemove')).toBe(0);
|
||||
expect(fakeWindow.listenerCount('blur')).toBe(0);
|
||||
});
|
||||
|
||||
it('previews the sidebar width while dragging and persists it only after release', () => {
|
||||
const sider = resize!.siderRef.current as unknown as FakeHTMLElement;
|
||||
|
||||
beginResize();
|
||||
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientX: 260 }));
|
||||
|
||||
expect(setSidebarWidth).not.toHaveBeenCalled();
|
||||
expect(sider.style.getPropertyValue('--gonavi-sidebar-resize-width')).toBe('240px');
|
||||
|
||||
act(() => flushAnimationFrames(scheduledFrames, 1));
|
||||
|
||||
expect(sider.style.getPropertyValue('--gonavi-sidebar-resize-width')).toBe('300px');
|
||||
expect(setSidebarWidth).not.toHaveBeenCalled();
|
||||
|
||||
act(() => fakeDocument.dispatch('mouseup', { clientX: 260 }));
|
||||
|
||||
expect(setSidebarWidth).toHaveBeenCalledTimes(1);
|
||||
expect(setSidebarWidth).toHaveBeenCalledWith(300);
|
||||
});
|
||||
|
||||
it('cancels pending work and restores interaction state when unmounted mid-resize', () => {
|
||||
const sider = resize!.siderRef.current as unknown as FakeHTMLElement;
|
||||
|
||||
beginResize();
|
||||
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientX: 250 }));
|
||||
expect(scheduledFrames.size).toBe(1);
|
||||
@@ -210,7 +246,7 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
|
||||
expect(scheduledFrames.size).toBe(0);
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(ghost.style.display).toBe('none');
|
||||
expect(sider.style.getPropertyValue('--gonavi-sidebar-resize-width')).toBe('');
|
||||
expect(fakeDocument.body.style).toEqual({
|
||||
cursor: 'wait',
|
||||
userSelect: 'text',
|
||||
@@ -239,5 +275,6 @@ describe('useAppSidebarResize interaction cleanup', () => {
|
||||
|
||||
expect(sider.getAttribute('data-sidebar-resizing')).toBe(null);
|
||||
expect(fakeDocument.body.getAttribute('data-sidebar-resizing')).toBe(null);
|
||||
expect(sider.style.getPropertyValue('--gonavi-sidebar-resize-width')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ type SidebarResizeBounds = { minWidth: number; maxWidth: number };
|
||||
type SidebarResizeDragState = SidebarResizeBounds & {
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
startGuideLeft: number;
|
||||
};
|
||||
type SidebarResizeListeners = {
|
||||
blur: () => void;
|
||||
@@ -39,6 +38,8 @@ const clampSidebarResizeWidth = (width: number, bounds: SidebarResizeBounds): nu
|
||||
Math.max(bounds.minWidth, Math.min(bounds.maxWidth, width))
|
||||
);
|
||||
|
||||
const SIDEBAR_RESIZE_WIDTH_CSS_VARIABLE = '--gonavi-sidebar-resize-width';
|
||||
|
||||
type UseAppSidebarResizeOptions = {
|
||||
effectiveUiScale: number;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
@@ -53,7 +54,6 @@ export const useAppSidebarResize = ({
|
||||
const sidebarDragRef = useRef<SidebarResizeDragState | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const clearResizingFrameRef = useRef<number | null>(null);
|
||||
const ghostRef = useRef<HTMLDivElement>(null);
|
||||
const siderRef = useRef<HTMLDivElement | null>(null);
|
||||
const sidebarDragBodyStyleRef = useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null);
|
||||
const sidebarResizeListenersRef = useRef<SidebarResizeListeners | null>(null);
|
||||
@@ -80,6 +80,7 @@ export const useAppSidebarResize = ({
|
||||
sider.setAttribute('data-sidebar-resizing', 'true');
|
||||
} else {
|
||||
sider.removeAttribute('data-sidebar-resizing');
|
||||
sider.style.removeProperty(SIDEBAR_RESIZE_WIDTH_CSS_VARIABLE);
|
||||
}
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -91,6 +92,12 @@ export const useAppSidebarResize = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const previewSidebarWidth = useCallback((width: number) => {
|
||||
const sider = siderRef.current;
|
||||
if (!(sider instanceof HTMLElement)) return;
|
||||
sider.style.setProperty(SIDEBAR_RESIZE_WIDTH_CSS_VARIABLE, `${width}px`);
|
||||
}, []);
|
||||
|
||||
const scheduleClearSidebarResizing = useCallback(() => {
|
||||
cancelClearResizingFrame();
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -142,21 +149,20 @@ export const useAppSidebarResize = ({
|
||||
rafRef.current = null;
|
||||
}
|
||||
|
||||
if (ghostRef.current) {
|
||||
ghostRef.current.style.display = 'none';
|
||||
}
|
||||
detachSidebarResizeListeners();
|
||||
restoreSidebarDragBodyStyles();
|
||||
|
||||
if (commit && dragState) {
|
||||
const finalMouseX = Number.isFinite(clientX) ? clientX as number : latestMouseX.current;
|
||||
const delta = finalMouseX - dragState.startX;
|
||||
// Keep transition disabled across the state commit + first paint.
|
||||
setSidebarResizing(true);
|
||||
setSidebarWidthRef.current(clampSidebarResizeWidth(
|
||||
const finalWidth = clampSidebarResizeWidth(
|
||||
dragState.startWidth + delta,
|
||||
dragState,
|
||||
));
|
||||
);
|
||||
// Keep transition disabled across the state commit + first paint.
|
||||
previewSidebarWidth(finalWidth);
|
||||
setSidebarResizing(true);
|
||||
setSidebarWidthRef.current(finalWidth);
|
||||
scheduleClearSidebarResizing();
|
||||
return;
|
||||
}
|
||||
@@ -166,6 +172,7 @@ export const useAppSidebarResize = ({
|
||||
}, [
|
||||
cancelClearResizingFrame,
|
||||
detachSidebarResizeListeners,
|
||||
previewSidebarWidth,
|
||||
restoreSidebarDragBodyStyles,
|
||||
scheduleClearSidebarResizing,
|
||||
setSidebarResizing,
|
||||
@@ -183,7 +190,6 @@ export const useAppSidebarResize = ({
|
||||
|
||||
finishSidebarResize(undefined, false);
|
||||
cancelClearResizingFrame();
|
||||
setSidebarResizing(true);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
sidebarDragBodyStyleRef.current = {
|
||||
@@ -197,19 +203,15 @@ export const useAppSidebarResize = ({
|
||||
}
|
||||
|
||||
const siderRect = siderRef.current?.getBoundingClientRect();
|
||||
const startGuideLeft = siderRect?.right ?? sidebarWidth;
|
||||
const startWidth = siderRect?.width ?? sidebarWidth;
|
||||
const resizeBounds = resolveSidebarResizeBounds(siderRef.current);
|
||||
|
||||
if (ghostRef.current) {
|
||||
ghostRef.current.style.left = `${startGuideLeft}px`;
|
||||
ghostRef.current.style.display = 'block';
|
||||
}
|
||||
previewSidebarWidth(startWidth);
|
||||
setSidebarResizing(true);
|
||||
|
||||
sidebarDragRef.current = {
|
||||
startX: e.clientX,
|
||||
startWidth,
|
||||
startGuideLeft,
|
||||
...resizeBounds,
|
||||
};
|
||||
latestMouseX.current = e.clientX;
|
||||
@@ -225,11 +227,11 @@ export const useAppSidebarResize = ({
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
if (!sidebarDragRef.current || !ghostRef.current) return;
|
||||
const { startX, startWidth, startGuideLeft, minWidth, maxWidth } = sidebarDragRef.current;
|
||||
if (!sidebarDragRef.current) return;
|
||||
const { startX, startWidth, minWidth, maxWidth } = sidebarDragRef.current;
|
||||
const delta = latestMouseX.current - startX;
|
||||
const newWidth = clampSidebarResizeWidth(startWidth + delta, { minWidth, maxWidth });
|
||||
ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;
|
||||
previewSidebarWidth(newWidth);
|
||||
});
|
||||
};
|
||||
const handleUp = (event: MouseEvent) => finishSidebarResize(event.clientX);
|
||||
@@ -243,7 +245,7 @@ export const useAppSidebarResize = ({
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
}, [cancelClearResizingFrame, finishSidebarResize, setSidebarResizing, sidebarWidth]);
|
||||
}, [cancelClearResizingFrame, finishSidebarResize, previewSidebarWidth, setSidebarResizing, sidebarWidth]);
|
||||
|
||||
useEffect(() => () => {
|
||||
finishSidebarResize(undefined, false);
|
||||
@@ -251,7 +253,6 @@ export const useAppSidebarResize = ({
|
||||
}, [cancelClearResizingFrame, finishSidebarResize]);
|
||||
|
||||
return {
|
||||
ghostRef,
|
||||
handleSidebarMouseDown,
|
||||
sidebarResizeHandleWidth,
|
||||
siderRef,
|
||||
|
||||
Reference in New Issue
Block a user