feat(sidebar): 支持侧边栏宽度随拖动实时调整

- 使用 CSS 宽度变量与 RAF 实时更新侧边栏及工作区布局
- 仅在拖动结束后持久化最终宽度,避免每帧触发 Store 重渲染
- 移除侧边栏 ghost 指示线并补充交互清理回归测试
This commit is contained in:
AutumnNazi
2026-07-27 21:40:21 +08:00
parent 70282286fc
commit cebf256a44
4 changed files with 69 additions and 46 deletions

View File

@@ -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'] {

View File

@@ -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}

View File

@@ -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('');
});
});

View File

@@ -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,