mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 15:39:43 +08:00
✨ feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复
- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题 - 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM - 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条 - 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动) - 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题 - 新增白色主题全局滚动条样式适配透明模式(App.css) - App.tsx主题token与组件样式优化 - refs #147
This commit is contained in:
@@ -57,6 +57,29 @@ body[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for light mode (transparent-friendly) */
|
||||
body[data-theme='light'] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
body[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.30);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Ensure body background matches theme to avoid white flashes, but kept transparent for window composition */
|
||||
body {
|
||||
transition: color 0.3s;
|
||||
@@ -102,11 +125,13 @@ body[data-theme='dark'] .ant-switch.ant-switch-checked {
|
||||
background: #d8a93b !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td {
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td,
|
||||
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell {
|
||||
background: rgba(246, 196, 83, 0.18) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td {
|
||||
body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td,
|
||||
body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell {
|
||||
background: rgba(246, 196, 83, 0.26) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/A
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
const MIN_FONT_SIZE = 12;
|
||||
const MAX_FONT_SIZE = 20;
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -26,11 +32,28 @@ function App() {
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const setAppearance = useStore(state => state.setAppearance);
|
||||
const uiScale = useStore(state => state.uiScale);
|
||||
const setUiScale = useStore(state => state.setUiScale);
|
||||
const fontSize = useStore(state => state.fontSize);
|
||||
const setFontSize = useStore(state => state.setFontSize);
|
||||
const startupFullscreen = useStore(state => state.startupFullscreen);
|
||||
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||
const globalProxy = useStore(state => state.globalProxy);
|
||||
const setGlobalProxy = useStore(state => state.setGlobalProxy);
|
||||
const darkMode = themeMode === 'dark';
|
||||
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
|
||||
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
|
||||
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
|
||||
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
|
||||
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
|
||||
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
|
||||
const tokenControlHeightSM = Math.max(20, Math.round(24 * effectiveUiScale));
|
||||
const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale));
|
||||
const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle');
|
||||
const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale));
|
||||
const toolbarHeight = Math.max(32, Math.round(36 * effectiveUiScale));
|
||||
const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
|
||||
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
@@ -834,7 +857,9 @@ function App() {
|
||||
document.body.style.backgroundColor = 'transparent';
|
||||
document.body.style.color = darkMode ? '#ffffff' : '#000000';
|
||||
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
document.body.style.fontSize = `${effectiveFontSize}px`;
|
||||
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
|
||||
}, [darkMode, effectiveFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
isAboutOpenRef.current = isAboutOpen;
|
||||
@@ -916,9 +941,16 @@ function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
componentSize={appComponentSize}
|
||||
theme={{
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
fontSize: tokenFontSize,
|
||||
fontSizeSM: tokenFontSizeSM,
|
||||
fontSizeLG: tokenFontSizeLG,
|
||||
controlHeight: tokenControlHeight,
|
||||
controlHeightSM: tokenControlHeightSM,
|
||||
controlHeightLG: tokenControlHeightLG,
|
||||
colorBgLayout: 'transparent',
|
||||
colorBgContainer: darkMode
|
||||
? `rgba(29, 29, 29, ${effectiveOpacity})`
|
||||
@@ -982,7 +1014,7 @@ function App() {
|
||||
<div
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
style={{
|
||||
height: 32,
|
||||
height: titleBarHeight,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -992,10 +1024,11 @@ function App() {
|
||||
userSelect: 'none',
|
||||
WebkitAppRegion: 'drag', // Wails drag region
|
||||
'--wails-draggable': 'drag',
|
||||
paddingLeft: 16
|
||||
paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
|
||||
fontSize: tokenFontSize
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600 }}>
|
||||
{/* Logo can be added here if available */}
|
||||
GoNavi
|
||||
</div>
|
||||
@@ -1007,13 +1040,13 @@ function App() {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MinusOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => (window as any).runtime.WindowMinimise()}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BorderOutlined />}
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => (window as any).runtime.WindowToggleMaximise()}
|
||||
/>
|
||||
<Button
|
||||
@@ -1021,7 +1054,7 @@ function App() {
|
||||
icon={<CloseOutlined />}
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: 46 }}
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={() => (window as any).runtime.Quit()}
|
||||
/>
|
||||
</div>
|
||||
@@ -1029,13 +1062,13 @@ function App() {
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
height: toolbarHeight,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 4,
|
||||
padding: '0 8px',
|
||||
gap: Math.max(2, Math.round(4 * effectiveUiScale)),
|
||||
padding: `0 ${Math.max(6, Math.round(8 * effectiveUiScale))}px`,
|
||||
borderBottom: 'none',
|
||||
background: bgMain,
|
||||
}}
|
||||
@@ -1088,13 +1121,13 @@ function App() {
|
||||
onClick={() => setIsLogPanelOpen(!isLogPanelOpen)}
|
||||
style={isLogPanelOpen ? {
|
||||
width: '100%',
|
||||
height: 34,
|
||||
height: floatingLogButtonHeight,
|
||||
borderRadius: 999,
|
||||
boxShadow: floatingLogButtonShadow,
|
||||
pointerEvents: 'auto'
|
||||
} : {
|
||||
width: '100%',
|
||||
height: 34,
|
||||
height: floatingLogButtonHeight,
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${floatingLogButtonBorderColor}`,
|
||||
color: floatingLogButtonTextColor,
|
||||
@@ -1216,6 +1249,37 @@ function App() {
|
||||
width={460}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>界面缩放 (UI Scale)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_UI_SCALE}
|
||||
max={MAX_UI_SCALE}
|
||||
step={0.05}
|
||||
value={effectiveUiScale}
|
||||
onChange={(v) => setUiScale(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 建议小屏设备设置为 85%-95%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>基础字体大小 (Font Size)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
value={effectiveFontSize}
|
||||
onChange={(v) => setFontSize(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>背景不透明度 (Opacity)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
@@ -1264,6 +1328,17 @@ function App() {
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ opacity: 1.0, blur: 0 });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Tabs, Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { MenuProps, TabsProps } from 'antd';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
@@ -35,44 +35,18 @@ const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined):
|
||||
};
|
||||
|
||||
type SortableTabLabelProps = {
|
||||
tabId: string;
|
||||
displayTitle: string;
|
||||
menuItems: MenuProps['items'];
|
||||
draggingTabId: string | null;
|
||||
onSelect: (tabId: string) => void;
|
||||
};
|
||||
|
||||
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
tabId,
|
||||
displayTitle,
|
||||
menuItems,
|
||||
draggingTabId,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
opacity: isDragging ? 0.88 : 1,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
touchAction: 'none',
|
||||
};
|
||||
const isDragBlocked = !!draggingTabId && draggingTabId !== tabId;
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<span
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`tab-dnd-label ${isDragging ? 'is-dragging' : ''}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={() => {
|
||||
if (!isDragBlocked) onSelect(tabId);
|
||||
}}
|
||||
className="tab-dnd-label"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title="拖拽调整标签顺序"
|
||||
>
|
||||
@@ -82,9 +56,36 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
type DraggableTabNodeProps = {
|
||||
node: React.ReactElement;
|
||||
};
|
||||
|
||||
const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||
const tabId = String(node.key || '').trim();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId });
|
||||
const style: React.CSSProperties = {
|
||||
...(node.props.style || {}),
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
opacity: isDragging ? 0.88 : 1,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
zIndex: isDragging ? 2 : node.props.style?.zIndex,
|
||||
};
|
||||
|
||||
return React.cloneElement(node, {
|
||||
ref: setNodeRef,
|
||||
style,
|
||||
...attributes,
|
||||
...listeners,
|
||||
className: `${node.props.className || ''} tab-dnd-node${isDragging ? ' is-dragging' : ''}`,
|
||||
});
|
||||
};
|
||||
|
||||
const TabManager: React.FC = () => {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveTab = useStore(state => state.setActiveTab);
|
||||
const closeTab = useStore(state => state.closeTab);
|
||||
@@ -93,6 +94,7 @@ const TabManager: React.FC = () => {
|
||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||
const moveTab = useStore(state => state.moveTab);
|
||||
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const suppressClickUntilRef = useRef<number>(0);
|
||||
const sensors = useSensors(
|
||||
@@ -111,11 +113,6 @@ const TabManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabSelect = (tabId: string) => {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const sourceId = String(event.active.id || '').trim();
|
||||
setDraggingTabId(sourceId || null);
|
||||
@@ -138,11 +135,21 @@ const TabManager: React.FC = () => {
|
||||
|
||||
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
|
||||
|
||||
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
|
||||
<DefaultTabBar {...tabBarProps}>
|
||||
{(node) => <DraggableTabNode key={node.key} node={node} />}
|
||||
</DefaultTabBar>
|
||||
);
|
||||
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
|
||||
const displayTitle = buildTabDisplayTitle(tab, connectionName);
|
||||
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
|
||||
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
if (!shouldRenderContent) {
|
||||
content = null;
|
||||
} else if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
} else if (tab.type === 'table') {
|
||||
content = <DataViewer tab={tab} />;
|
||||
@@ -189,17 +196,14 @@ const TabManager: React.FC = () => {
|
||||
return {
|
||||
label: (
|
||||
<SortableTabLabel
|
||||
tabId={tab.id}
|
||||
displayTitle={displayTitle}
|
||||
menuItems={menuItems}
|
||||
draggingTabId={draggingTabId}
|
||||
onSelect={handleTabSelect}
|
||||
/>
|
||||
),
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, draggingTabId]);
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -248,7 +252,7 @@ const TabManager: React.FC = () => {
|
||||
display: none !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-nav::before {
|
||||
border-bottom: none !important;
|
||||
border-bottom: 1px solid ${tabsNavBorderColor} !important;
|
||||
}
|
||||
.main-tabs .ant-tabs-tab {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease;
|
||||
@@ -256,8 +260,12 @@ const TabManager: React.FC = () => {
|
||||
.main-tabs .tab-dnd-label {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.main-tabs .tab-dnd-label.is-dragging {
|
||||
.main-tabs .tab-dnd-node.is-dragging,
|
||||
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible {
|
||||
@@ -289,11 +297,15 @@ const TabManager: React.FC = () => {
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
type="editable-card"
|
||||
onChange={onChange}
|
||||
onChange={(newActiveKey) => {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
onChange(newActiveKey);
|
||||
}}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
hideAdd
|
||||
renderTabBar={renderTabBar}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -3,6 +3,12 @@ import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||
|
||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const MIN_FONT_SIZE = 12;
|
||||
const MAX_FONT_SIZE = 20;
|
||||
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||
const OPACITY_EPSILON = 1e-6;
|
||||
@@ -107,6 +113,13 @@ const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: num
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeFloatInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallbackValue;
|
||||
if (parsed < min || parsed > max) return fallbackValue;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const isValidHostEntry = (entry: string): boolean => {
|
||||
if (!entry) return false;
|
||||
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
|
||||
@@ -318,6 +331,8 @@ interface AppState {
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
globalProxy: GlobalProxyConfig;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
@@ -347,6 +362,8 @@ interface AppState {
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
@@ -441,6 +458,14 @@ const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||||
return value === true;
|
||||
};
|
||||
|
||||
const sanitizeUiScale = (value: unknown): number => {
|
||||
return normalizeFloatInRange(value, DEFAULT_UI_SCALE, MIN_UI_SCALE, MAX_UI_SCALE);
|
||||
};
|
||||
|
||||
const sanitizeFontSize = (value: unknown): number => {
|
||||
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
};
|
||||
|
||||
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
|
||||
@@ -477,6 +502,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
@@ -607,6 +634,8 @@ export const useStore = create<AppState>()(
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
|
||||
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
@@ -646,6 +675,8 @@ export const useStore = create<AppState>()(
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
@@ -663,6 +694,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
fontSize: sanitizeFontSize(state.fontSize),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
@@ -676,6 +709,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
|
||||
Reference in New Issue
Block a user