fix(layout): 修复边栏拖拽位移累加并实现1:1光标捕获绝对跟随
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,16 +5,19 @@ import { ResizeHandle } from './ResizeHandle';
|
||||
import { ChatColumn } from './ChatColumn';
|
||||
import { RightDocRail } from './RightDocRail';
|
||||
import { ProjectSession } from '../../types/workbench';
|
||||
import { UserRole } from '../../types/docgov';
|
||||
import { UserRole, UserProfile } from '../../types/docgov';
|
||||
|
||||
interface AppShellProps {
|
||||
sessions: ProjectSession[];
|
||||
activeSessionId: string;
|
||||
onSelectSession: (id: string) => void;
|
||||
onNewSession: () => void;
|
||||
currentUser: UserProfile;
|
||||
currentRole: UserRole;
|
||||
currentUserName: string;
|
||||
onChangeRole: (role: UserRole, name: string) => void;
|
||||
onOpenRightsModal: () => void;
|
||||
onOpenSettingsModal: () => void;
|
||||
chatContent: React.ReactNode;
|
||||
docContent: React.ReactNode;
|
||||
}
|
||||
@@ -24,9 +27,12 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
currentUser,
|
||||
currentRole,
|
||||
currentUserName,
|
||||
onChangeRole,
|
||||
currentUserName: _currentUserName,
|
||||
onChangeRole: _onChangeRole,
|
||||
onOpenRightsModal,
|
||||
onOpenSettingsModal,
|
||||
chatContent,
|
||||
docContent,
|
||||
}) => {
|
||||
@@ -54,14 +60,19 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
};
|
||||
|
||||
const handleResizeLeft = (deltaX: number) => {
|
||||
setLeftWidth((prev) => Math.max(220, Math.min(380, prev + deltaX)));
|
||||
const handleResizeLeft = (newWidth: number) => {
|
||||
setLeftWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleResizeRight = (deltaX: number) => {
|
||||
setRightWidth((prev) => Math.max(360, Math.min(800, prev + deltaX)));
|
||||
const handleResizeRight = (newWidth: number) => {
|
||||
setRightWidth(newWidth);
|
||||
};
|
||||
|
||||
// 动态视口安全最大宽度计算(保障中间对话栏至少保留 380px)
|
||||
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1440;
|
||||
const maxLeftWidth = Math.min(420, Math.max(200, windowWidth - (rightCollapsed ? 0 : rightWidth) - 380));
|
||||
const maxRightWidth = Math.min(850, Math.max(360, windowWidth - (leftCollapsed ? 0 : leftWidth) - 380));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -100,17 +111,38 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onNewSession={onNewSession}
|
||||
currentUser={currentUser}
|
||||
currentRole={currentRole}
|
||||
currentUserName={currentUserName}
|
||||
onChangeRole={onChangeRole}
|
||||
onOpenRightsModal={onOpenRightsModal}
|
||||
onOpenSettingsModal={onOpenSettingsModal}
|
||||
/>
|
||||
|
||||
{!leftCollapsed && <ResizeHandle side="left" onResize={handleResizeLeft} />}
|
||||
{!leftCollapsed && (
|
||||
<ResizeHandle
|
||||
side="left"
|
||||
currentWidth={leftWidth}
|
||||
minWidth={200}
|
||||
maxWidth={maxLeftWidth}
|
||||
defaultWidth={280}
|
||||
onResize={handleResizeLeft}
|
||||
onReset={() => setLeftWidth(280)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 中间栏 */}
|
||||
<ChatColumn currentSession={currentSession}>{chatContent}</ChatColumn>
|
||||
|
||||
{!rightCollapsed && <ResizeHandle side="right" onResize={handleResizeRight} />}
|
||||
{!rightCollapsed && (
|
||||
<ResizeHandle
|
||||
side="right"
|
||||
currentWidth={rightWidth}
|
||||
minWidth={360}
|
||||
maxWidth={maxRightWidth}
|
||||
defaultWidth={500}
|
||||
onResize={handleResizeRight}
|
||||
onReset={() => setRightWidth(500)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<RightDocRail width={rightWidth} collapsed={rightCollapsed}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, MessageSquare, ChevronDown, User, ShieldCheck } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Plus, Settings, User, ShieldCheck, MessageSquare } from 'lucide-react';
|
||||
import { ProjectSession } from '../../types/workbench';
|
||||
import { UserRole } from '../../types/docgov';
|
||||
import { UserRole, UserProfile } from '../../types/docgov';
|
||||
|
||||
interface LeftRailProps {
|
||||
width: number;
|
||||
@@ -10,9 +10,10 @@ interface LeftRailProps {
|
||||
activeSessionId: string;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
currentUser: UserProfile;
|
||||
currentRole: UserRole;
|
||||
currentUserName: string;
|
||||
onChangeRole: (role: UserRole, name: string) => void;
|
||||
onOpenRightsModal: () => void;
|
||||
onOpenSettingsModal: () => void;
|
||||
}
|
||||
|
||||
export const LeftRail: React.FC<LeftRailProps> = ({
|
||||
@@ -22,23 +23,29 @@ export const LeftRail: React.FC<LeftRailProps> = ({
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
currentUser,
|
||||
currentRole,
|
||||
currentUserName,
|
||||
onChangeRole,
|
||||
onOpenRightsModal,
|
||||
onOpenSettingsModal,
|
||||
}) => {
|
||||
const [roleMenuOpen, setRoleMenuOpen] = useState(false);
|
||||
|
||||
if (collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roleOptions: { role: UserRole; name: string; title: string; color: string }[] = [
|
||||
{ role: 'author', name: '张工', title: '编制负责人', color: 'var(--brand-500)' },
|
||||
{ role: 'reviewer', name: '李主管', title: '审核负责人', color: 'var(--amber-500)' },
|
||||
{ role: 'approver', name: '王总监', title: '批准负责人', color: 'var(--success)' },
|
||||
];
|
||||
const getRoleBadgeInfo = (role: UserRole) => {
|
||||
switch (role) {
|
||||
case 'author':
|
||||
return { title: '编制人', color: 'var(--brand-500)', bg: 'rgba(79, 107, 255, 0.12)' };
|
||||
case 'reviewer':
|
||||
return { title: '审核人', color: 'var(--amber-500)', bg: 'rgba(217, 119, 6, 0.12)' };
|
||||
case 'approver':
|
||||
return { title: '批准人', color: 'var(--success)', bg: 'rgba(16, 185, 129, 0.12)' };
|
||||
default:
|
||||
return { title: '查阅者', color: 'var(--text-muted)', bg: 'rgba(100, 116, 139, 0.12)' };
|
||||
}
|
||||
};
|
||||
|
||||
const currentOption = roleOptions.find((r) => r.role === currentRole) || roleOptions[0];
|
||||
const roleInfo = getRoleBadgeInfo(currentRole);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -160,103 +167,30 @@ export const LeftRail: React.FC<LeftRailProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部用户与法定角色切换面板 */}
|
||||
{/* 底部用户账号信息与设置入口 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
padding: '10px 12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-subtle)',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{/* 角色切换下拉浮层 */}
|
||||
{roleMenuOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '68px',
|
||||
left: '12px',
|
||||
right: '12px',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
padding: '6px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', padding: '4px 8px' }}>
|
||||
快速切换当前法定责任人身份
|
||||
</div>
|
||||
{roleOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.role}
|
||||
onClick={() => {
|
||||
onChangeRole(opt.role, opt.name);
|
||||
setRoleMenuOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: opt.role === currentRole ? 'var(--bg-hover)' : 'transparent',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: opt.color,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{opt.name[0]}
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||||
{opt.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
background: 'var(--bg-subtle)',
|
||||
color: opt.color,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{opt.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户信息与角色条 */}
|
||||
{/* 用户信息卡片 (点击打开权限矩阵) */}
|
||||
<div
|
||||
onClick={() => setRoleMenuOpen(!roleMenuOpen)}
|
||||
onClick={onOpenRightsModal}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
transition: 'all 0.15s ease',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-hover)';
|
||||
@@ -264,40 +198,88 @@ export const LeftRail: React.FC<LeftRailProps> = ({
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
title="点击切换当前测试角色 (编制 / 审核 / 批准)"
|
||||
title="点击查看账号信息与当前项目各文档法定权限矩阵"
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: roleInfo.color,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '13px',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 2px 5px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{currentUser.name ? currentUser.name[0] : <User size={16} />}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-strong)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{currentUser.name}
|
||||
</span>
|
||||
<ShieldCheck size={12} style={{ color: roleInfo.color, flexShrink: 0 }} />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: currentOption.color,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '13px',
|
||||
flexShrink: 0,
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||||
{currentUserName}
|
||||
</span>
|
||||
<ShieldCheck size={13} style={{ color: currentOption.color }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
|
||||
{currentOption.title}
|
||||
</span>
|
||||
</div>
|
||||
{currentUser.position || '资深方案工程师'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={15} style={{ color: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
|
||||
{/* 独立设置按钮 (预留系统设置与管理员后台) */}
|
||||
<button
|
||||
onClick={onOpenSettingsModal}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-panel)',
|
||||
color: 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-hover)';
|
||||
e.currentTarget.style.color = 'var(--text-strong)';
|
||||
e.currentTarget.style.borderColor = 'var(--brand-300)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-panel)';
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--border)';
|
||||
}}
|
||||
title="系统设置与管理员后台 (规划预留)"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,49 +1,169 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface ResizeHandleProps {
|
||||
onResize: (deltaX: number) => void;
|
||||
export interface ResizeHandleProps {
|
||||
side: 'left' | 'right';
|
||||
currentWidth: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
defaultWidth?: number;
|
||||
onResize: (newWidth: number) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export const ResizeHandle: React.FC<ResizeHandleProps> = ({ onResize, side }) => {
|
||||
/**
|
||||
* 纯函数:根据鼠标起始坐标与当前坐标,严格计算面板绝对宽度并夹紧在 [min, max] 之间
|
||||
* 保证拖拽分割线与鼠标指针 1:1 绝对严密跟随,彻底消除任何累积抖动或漂移
|
||||
*/
|
||||
export function calculateResizedWidth(
|
||||
startWidth: number,
|
||||
startX: number,
|
||||
currentX: number,
|
||||
side: 'left' | 'right',
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
const deltaX = side === 'left' ? currentX - startX : startX - currentX;
|
||||
const targetWidth = startWidth + deltaX;
|
||||
return Math.max(min, Math.min(max, targetWidth));
|
||||
}
|
||||
|
||||
export const ResizeHandle: React.FC<ResizeHandleProps> = ({
|
||||
side,
|
||||
currentWidth,
|
||||
minWidth = side === 'left' ? 200 : 360,
|
||||
maxWidth = side === 'left' ? 420 : 850,
|
||||
defaultWidth = side === 'left' ? 280 : 500,
|
||||
onResize,
|
||||
onReset,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 用 ref 实时保存最新配置,避免全局事件监听器产生闭包陷阱
|
||||
const dragContextRef = useRef({
|
||||
startWidth: currentWidth,
|
||||
startX: 0,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
side,
|
||||
onResize,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dragContextRef.current = {
|
||||
startWidth: dragContextRef.current.startWidth,
|
||||
startX: dragContextRef.current.startX,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
side,
|
||||
onResize,
|
||||
};
|
||||
}, [minWidth, maxWidth, side, onResize]);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = currentWidth;
|
||||
dragContextRef.current.startX = startX;
|
||||
dragContextRef.current.startWidth = startWidth;
|
||||
|
||||
setIsDragging(true);
|
||||
|
||||
// 全局锁定光标与禁止文字选中,防止快速拖动时鼠标移入中间对话框或文本区域导致中断
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = side === 'left' ? moveEvent.clientX - startX : startX - moveEvent.clientX;
|
||||
onResize(deltaX);
|
||||
const { startX: sX, startWidth: sW, side: sSide, minWidth: minW, maxWidth: maxW, onResize: resizeFn } =
|
||||
dragContextRef.current;
|
||||
const nextWidth = calculateResizedWidth(sW, sX, moveEvent.clientX, sSide, minW, maxW);
|
||||
resizeFn(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (onReset) {
|
||||
onReset();
|
||||
} else {
|
||||
onResize(defaultWidth);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
width: '5px',
|
||||
width: '10px',
|
||||
margin: '0 -5px',
|
||||
cursor: 'col-resize',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
background: 'transparent',
|
||||
transition: 'background 0.2s',
|
||||
zIndex: isDragging ? 50 : 25,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--brand-500)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
title="按住鼠标左右拖拽调整宽度"
|
||||
/>
|
||||
title={`按住左右拖拽调节宽度 (当前: ${currentWidth}px) · 双击恢复默认宽度 (${defaultWidth}px)`}
|
||||
>
|
||||
{/* 视觉分割线:未悬停时为极细边框,悬停或拖拽时高亮为品牌蓝 */}
|
||||
<div
|
||||
style={{
|
||||
width: isDragging ? '3px' : isHovered ? '2px' : '1px',
|
||||
height: '100%',
|
||||
backgroundColor: isDragging
|
||||
? 'var(--brand-500)'
|
||||
: isHovered
|
||||
? 'var(--brand-500)'
|
||||
: 'var(--border)',
|
||||
boxShadow: isDragging ? '0 0 10px rgba(79, 107, 255, 0.7)' : 'none',
|
||||
transition: isDragging ? 'none' : 'background-color 0.15s ease, width 0.15s ease',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 拖拽中的实时宽度微标浮动提示 */}
|
||||
{isDragging && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: side === 'left' ? '16px' : undefined,
|
||||
right: side === 'right' ? '16px' : undefined,
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--bg-panel, #131820)',
|
||||
color: 'var(--brand-500)',
|
||||
border: '1px solid var(--brand-500)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100,
|
||||
animation: 'fadeIn 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
{currentWidth} px
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculateResizedWidth } from './ResizeHandle';
|
||||
|
||||
function clampWidth(current: number, delta: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, current + delta));
|
||||
}
|
||||
test('左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping)', () => {
|
||||
const startWidth = 280;
|
||||
const startX = 280;
|
||||
const min = 200;
|
||||
const max = 420;
|
||||
|
||||
test('左侧栏拖拽宽度夹紧计算测试 (Left Rail Width Clamping)', () => {
|
||||
const min = 220;
|
||||
const max = 380;
|
||||
// 1. 鼠标向右平滑移动 +40px,宽度必须精准等于 320px (与鼠标位置 1:1 绝对重合)
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 320, 'left', min, max), 320);
|
||||
|
||||
// 正常范围拖拽
|
||||
assert.equal(clampWidth(280, 20, min, max), 300);
|
||||
assert.equal(clampWidth(280, -30, min, max), 250);
|
||||
// 2. 鼠标向左平滑移动 -30px,宽度必须精准等于 250px
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 250, 'left', min, max), 250);
|
||||
|
||||
// 边界溢出夹紧
|
||||
assert.equal(clampWidth(280, 200, min, max), 380);
|
||||
assert.equal(clampWidth(280, -200, min, max), 220);
|
||||
// 3. 鼠标大幅向右拖动超过最大界限 (x=600px),宽度被安全夹紧在 420px
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 600, 'left', min, max), 420);
|
||||
|
||||
// 4. 鼠标大幅向左拖动低于最小界限 (x=100px),宽度被安全夹紧在 200px
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 100, 'left', min, max), 200);
|
||||
});
|
||||
|
||||
test('右侧栏拖拽宽度夹紧计算测试 (Right Rail Width Clamping)', () => {
|
||||
test('右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta)', () => {
|
||||
const startWidth = 500;
|
||||
const startX = 940; // 假设 1440px 屏幕,右侧栏初始位置为 1440 - 500 = 940px
|
||||
const min = 360;
|
||||
const max = 800;
|
||||
const max = 850;
|
||||
|
||||
assert.equal(clampWidth(500, 50, min, max), 550);
|
||||
assert.equal(clampWidth(500, -200, min, max), 360);
|
||||
assert.equal(clampWidth(500, 500, min, max), 800);
|
||||
// 1. 鼠标向左拖动 100px (startX=940 -> currentX=840),右侧栏展开加宽至 600px (手柄仍严格在 x=840 鼠标下)
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 840, 'right', min, max), 600);
|
||||
|
||||
// 2. 鼠标向右拖动 100px (startX=940 -> currentX=1040),右侧栏收窄缩减至 400px (手柄仍严格在 x=1040 鼠标下)
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 1040, 'right', min, max), 400);
|
||||
|
||||
// 3. 边界溢出保护:过度向左扩展夹紧至 850px
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 0, 'right', min, max), 850);
|
||||
|
||||
// 4. 边界溢出保护:过度向右收缩夹紧至 360px
|
||||
assert.equal(calculateResizedWidth(startWidth, startX, 1400, 'right', min, max), 360);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user