feat(desktop): add UI zoom scaling
Add zoom controls to Theme settings (slider 50-200%) with Tauri native webview zoom and font-size fallback. Keyboard shortcuts (Ctrl+=/Ctrl+-/Ctrl+0) wired in desktop shell. Zoom level persists in localStorage.
This commit is contained in:
parent
4cebbb73e8
commit
50c7d1d1e4
@ -7,6 +7,7 @@
|
|||||||
"core:app:default",
|
"core:app:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:webview:default",
|
"core:webview:default",
|
||||||
|
"core:webview:allow-set-webview-zoom",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"sql:default",
|
"sql:default",
|
||||||
"sql:allow-load",
|
"sql:allow-load",
|
||||||
|
|||||||
@ -116,7 +116,21 @@ export function DesktopShell({ children }: DesktopShellProps) {
|
|||||||
const { registerShortcuts } = await import(
|
const { registerShortcuts } = await import(
|
||||||
"@/lib/desktop/shortcuts"
|
"@/lib/desktop/shortcuts"
|
||||||
)
|
)
|
||||||
unregister = await registerShortcuts({ triggerSync })
|
const { WindowManager } = await import("@/lib/desktop/window-manager")
|
||||||
|
unregister = await registerShortcuts({
|
||||||
|
triggerSync,
|
||||||
|
onZoomIn: () => {
|
||||||
|
const current = WindowManager.getZoom()
|
||||||
|
WindowManager.setZoom(Math.round((current + 0.1) * 10) / 10)
|
||||||
|
},
|
||||||
|
onZoomOut: () => {
|
||||||
|
const current = WindowManager.getZoom()
|
||||||
|
WindowManager.setZoom(Math.round((current - 0.1) * 10) / 10)
|
||||||
|
},
|
||||||
|
onZoomReset: () => {
|
||||||
|
WindowManager.setZoom(1.0)
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to register desktop shortcuts:", error)
|
console.error("Failed to register desktop shortcuts:", error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { Check, Moon, Sparkles, Sun, Trash2 } from "lucide-react"
|
import { Check, Moon, RotateCcw, Sparkles, Sun, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useCompassTheme } from "@/components/theme-provider"
|
import { useCompassTheme } from "@/components/theme-provider"
|
||||||
import { useAgentOptional } from "@/components/agent/chat-provider"
|
import { useAgentOptional } from "@/components/agent/chat-provider"
|
||||||
@ -172,6 +173,59 @@ export function AppearanceTab() {
|
|||||||
|
|
||||||
const isDark = resolvedTheme === "dark"
|
const isDark = resolvedTheme === "dark"
|
||||||
|
|
||||||
|
const [zoomLevel, setZoomLevel] = React.useState(1.0)
|
||||||
|
|
||||||
|
// Load persisted zoom level on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("compass-zoom-level")
|
||||||
|
if (stored) {
|
||||||
|
const level = parseFloat(stored)
|
||||||
|
if (!isNaN(level) && level >= 0.5 && level <= 2.0) {
|
||||||
|
setZoomLevel(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function applyZoom(level: number): Promise<void> {
|
||||||
|
const clamped = Math.min(2.0, Math.max(0.5, level))
|
||||||
|
try {
|
||||||
|
localStorage.setItem("compass-zoom-level", String(clamped))
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
// Use Tauri native webview zoom (true browser-level zoom)
|
||||||
|
try {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core")
|
||||||
|
await invoke("plugin:webview|set_webview_zoom", {
|
||||||
|
label: "main",
|
||||||
|
scaleFactor: clamped,
|
||||||
|
})
|
||||||
|
// Clear any CSS fallback
|
||||||
|
document.documentElement.style.fontSize = ""
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Not in Tauri or permission denied — CSS fallback
|
||||||
|
}
|
||||||
|
// Fallback: scale root font-size (slightly thicker icons but functional)
|
||||||
|
document.documentElement.style.fontSize = `${clamped * 16}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomChange(value: number[]): void {
|
||||||
|
const level = value[0]
|
||||||
|
if (level === undefined) return
|
||||||
|
setZoomLevel(level)
|
||||||
|
void applyZoom(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomReset(): void {
|
||||||
|
setZoomLevel(1.0)
|
||||||
|
void applyZoom(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
|
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
|
||||||
() => [...THEME_PRESETS, ...customThemes],
|
() => [...THEME_PRESETS, ...customThemes],
|
||||||
[customThemes],
|
[customThemes],
|
||||||
@ -246,6 +300,40 @@ export function AppearanceTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ui scale */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium">UI Scale</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{Math.round(zoomLevel * 100)}%
|
||||||
|
</span>
|
||||||
|
{zoomLevel !== 1.0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleZoomReset}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[zoomLevel]}
|
||||||
|
onValueChange={handleZoomChange}
|
||||||
|
min={0.5}
|
||||||
|
max={2.0}
|
||||||
|
step={0.1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>50%</span>
|
||||||
|
<span>200%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* theme grid */}
|
{/* theme grid */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Theme</p>
|
<p className="text-sm font-medium">Theme</p>
|
||||||
|
|||||||
@ -8,6 +8,9 @@ export interface ShortcutHandlers {
|
|||||||
onNew?: () => void
|
onNew?: () => void
|
||||||
onSearch?: () => void
|
onSearch?: () => void
|
||||||
onSettings?: () => void
|
onSettings?: () => void
|
||||||
|
onZoomIn?: () => void
|
||||||
|
onZoomOut?: () => void
|
||||||
|
onZoomReset?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegisteredShortcut {
|
interface RegisteredShortcut {
|
||||||
@ -56,6 +59,18 @@ export async function registerShortcuts(
|
|||||||
...(handlers.onSettings
|
...(handlers.onSettings
|
||||||
? [{ shortcut: `${modifier}+,`, handler: handlers.onSettings }]
|
? [{ shortcut: `${modifier}+,`, handler: handlers.onSettings }]
|
||||||
: []),
|
: []),
|
||||||
|
// Zoom in: Cmd/Ctrl + =
|
||||||
|
...(handlers.onZoomIn
|
||||||
|
? [{ shortcut: `${modifier}+=`, handler: handlers.onZoomIn }]
|
||||||
|
: []),
|
||||||
|
// Zoom out: Cmd/Ctrl + -
|
||||||
|
...(handlers.onZoomOut
|
||||||
|
? [{ shortcut: `${modifier}+-`, handler: handlers.onZoomOut }]
|
||||||
|
: []),
|
||||||
|
// Zoom reset: Cmd/Ctrl + 0
|
||||||
|
...(handlers.onZoomReset
|
||||||
|
? [{ shortcut: `${modifier}+0`, handler: handlers.onZoomReset }]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
// Register each shortcut
|
// Register each shortcut
|
||||||
@ -128,4 +143,7 @@ export const SHORTCUTS = {
|
|||||||
reload: "Cmd/Ctrl + R",
|
reload: "Cmd/Ctrl + R",
|
||||||
devTools: "Cmd/Ctrl + Shift + I",
|
devTools: "Cmd/Ctrl + Shift + I",
|
||||||
quit: "Cmd/Ctrl + Q",
|
quit: "Cmd/Ctrl + Q",
|
||||||
|
zoomIn: "Cmd/Ctrl + =",
|
||||||
|
zoomOut: "Cmd/Ctrl + -",
|
||||||
|
zoomReset: "Cmd/Ctrl + 0",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface WindowState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WINDOW_STATE_KEY = "compass-window-state"
|
const WINDOW_STATE_KEY = "compass-window-state"
|
||||||
|
const ZOOM_LEVEL_KEY = "compass-zoom-level"
|
||||||
|
|
||||||
// Internal state cache
|
// Internal state cache
|
||||||
let cachedState: WindowState | null = null
|
let cachedState: WindowState | null = null
|
||||||
@ -85,6 +86,9 @@ export const WindowManager = {
|
|||||||
|
|
||||||
// Also cache current state
|
// Also cache current state
|
||||||
cachedState = await loadWindowStateFromStore()
|
cachedState = await loadWindowStateFromStore()
|
||||||
|
|
||||||
|
// Restore zoom level
|
||||||
|
await this.restoreZoom()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to restore window state:", error)
|
console.error("Failed to restore window state:", error)
|
||||||
}
|
}
|
||||||
@ -175,4 +179,46 @@ export const WindowManager = {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set webview zoom via Tauri native API with CSS font-size fallback
|
||||||
|
async setZoom(level: number): Promise<void> {
|
||||||
|
const clamped = Math.min(2.0, Math.max(0.5, level))
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ZOOM_LEVEL_KEY, String(clamped))
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core")
|
||||||
|
await invoke("plugin:webview|set_webview_zoom", {
|
||||||
|
label: "main",
|
||||||
|
scaleFactor: clamped,
|
||||||
|
})
|
||||||
|
document.documentElement.style.fontSize = ""
|
||||||
|
} catch {
|
||||||
|
document.documentElement.style.fontSize = `${clamped * 16}px`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get stored zoom level (defaults to 1.0)
|
||||||
|
getZoom(): number {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(ZOOM_LEVEL_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const level = parseFloat(stored)
|
||||||
|
if (!isNaN(level) && level >= 0.5 && level <= 2.0) return level
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
return 1.0
|
||||||
|
},
|
||||||
|
|
||||||
|
// Restore zoom from stored level
|
||||||
|
async restoreZoom(): Promise<void> {
|
||||||
|
const level = this.getZoom()
|
||||||
|
if (level !== 1.0) {
|
||||||
|
await this.setZoom(level)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user