mirror of
https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux
synced 2026-04-26 01:25:11 +02:00
* docs: update README to clarify window transparency handling * feat: implement rendering environment detection * chore: optimize rendering environment hook * fix: improve NVIDIA detection
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
import { clsx } from 'clsx'
|
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
|
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
|
import { listen } from '@tauri-apps/api/event'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { useClipboardHistory } from './hooks/useClipboardHistory'
|
|
import { TabBar, TabBarRef } from './components/TabBar'
|
|
import { DragHandle } from './components/DragHandle'
|
|
import { EmojiPicker } from './components/EmojiPicker'
|
|
import { GifPicker } from './components/GifPicker'
|
|
import { KaomojiPicker } from './components/KaomojiPicker'
|
|
import { SymbolPicker } from './components/SymbolPicker'
|
|
import { calculateSecondaryOpacity, calculateTertiaryOpacity } from './utils/themeUtils'
|
|
import { useSystemThemePreference } from './utils/systemTheme'
|
|
import { useRenderingEnv } from './hooks/useRenderingEnv'
|
|
import type { ActiveTab, UserSettings } from './types/clipboard'
|
|
import { ClipboardTab } from './components/ClipboardTab'
|
|
|
|
const DEFAULT_SETTINGS: UserSettings = {
|
|
theme_mode: 'system',
|
|
dark_background_opacity: 0.7,
|
|
light_background_opacity: 0.7,
|
|
enable_smart_actions: true,
|
|
enable_ui_polish: true,
|
|
enable_dynamic_tray_icon: true,
|
|
max_history_size: 50,
|
|
auto_delete_interval: 0,
|
|
auto_delete_unit: 'hours',
|
|
custom_kaomojis: [],
|
|
ui_scale: 1,
|
|
}
|
|
|
|
/**
|
|
* Maps theme mode setting to actual dark mode state.
|
|
* For 'system' mode, this hook delegates system theme detection
|
|
* to useSystemThemePreference().
|
|
*/
|
|
function useThemeMode(themeMode: 'system' | 'dark' | 'light'): boolean {
|
|
const systemPrefersDark = useSystemThemePreference()
|
|
|
|
if (themeMode === 'dark') return true
|
|
if (themeMode === 'light') return false
|
|
return systemPrefersDark // 'system' mode
|
|
}
|
|
|
|
/**
|
|
* Applies background opacity CSS variables based on user settings
|
|
* Opacity: 0.0 = fully transparent, 1.0 = fully opaque
|
|
*/
|
|
function applyBackgroundOpacity(settings: UserSettings) {
|
|
const root = document.documentElement
|
|
|
|
// The gradient end is slightly less opaque than start for a subtle effect
|
|
// Using a small offset (0.03) to create a gentle gradient
|
|
const darkStart = settings.dark_background_opacity
|
|
const darkEnd = darkStart >= 1 ? 1 : Math.max(0, darkStart - 0.03)
|
|
const lightStart = settings.light_background_opacity
|
|
const lightEnd = lightStart >= 1 ? 1 : Math.max(0, lightStart - 0.05)
|
|
|
|
root.style.setProperty('--win11-dark-bg-alpha-start', darkStart.toString())
|
|
root.style.setProperty('--win11-dark-bg-alpha-end', darkEnd.toString())
|
|
root.style.setProperty('--win11-light-bg-alpha-start', lightStart.toString())
|
|
root.style.setProperty('--win11-light-bg-alpha-end', lightEnd.toString())
|
|
}
|
|
|
|
/**
|
|
* Updates the document's dark class based on theme
|
|
*/
|
|
function applyThemeClass(isDark: boolean) {
|
|
if (isDark) {
|
|
document.documentElement.classList.add('dark')
|
|
} else {
|
|
document.documentElement.classList.remove('dark')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the UI scale/zoom level to the webview
|
|
*/
|
|
async function applyUIScale(scale: number) {
|
|
try {
|
|
await getCurrentWebview().setZoom(scale)
|
|
} catch (err) {
|
|
console.error('Failed to apply UI scale:', err)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main Clipboard App Component
|
|
*/
|
|
function ClipboardApp() {
|
|
const [activeTab, setActiveTab] = useState<ActiveTab>('clipboard')
|
|
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
|
|
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
|
|
|
const renderingEnv = useRenderingEnv()
|
|
const isDark = useThemeMode(settings.theme_mode)
|
|
|
|
// When transparency is disabled (NVIDIA / AppImage) force opacity to fully opaque
|
|
const effectiveDarkOpacity = renderingEnv.transparency_disabled
|
|
? 1
|
|
: settings.dark_background_opacity
|
|
const effectiveLightOpacity = renderingEnv.transparency_disabled
|
|
? 1
|
|
: settings.light_background_opacity
|
|
|
|
const opacity = isDark ? effectiveDarkOpacity : effectiveLightOpacity
|
|
const secondaryOpacity = calculateSecondaryOpacity(opacity)
|
|
const tertiaryOpacity = calculateTertiaryOpacity(opacity)
|
|
|
|
const { history, isLoading, clearHistory, deleteItem, togglePin, pasteItem } =
|
|
useClipboardHistory()
|
|
|
|
// Refs for focus management
|
|
const tabBarRef = useRef<TabBarRef>(null)
|
|
const contentContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Load initial settings and set up listener for changes
|
|
useEffect(() => {
|
|
// Load initial settings
|
|
invoke<UserSettings>('get_user_settings')
|
|
.then((loadedSettings) => {
|
|
setSettings(loadedSettings)
|
|
applyBackgroundOpacity(loadedSettings)
|
|
applyUIScale(loadedSettings.ui_scale)
|
|
setSettingsLoaded(true)
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to load user settings:', err)
|
|
applyBackgroundOpacity(DEFAULT_SETTINGS)
|
|
applyUIScale(DEFAULT_SETTINGS.ui_scale)
|
|
setSettingsLoaded(true)
|
|
})
|
|
|
|
// Listen for settings changes from the settings window
|
|
const unlistenPromise = listen<UserSettings>('app-settings-changed', (event) => {
|
|
const newSettings = event.payload
|
|
setSettings(newSettings)
|
|
applyBackgroundOpacity(newSettings)
|
|
applyUIScale(newSettings.ui_scale)
|
|
})
|
|
|
|
// Listen for switch-tab events from Rust (e.g., when Super+. is pressed)
|
|
const unlistenSwitchTab = listen<string>('switch-tab', (event) => {
|
|
const tabName = event.payload as ActiveTab
|
|
if (['clipboard', 'emoji', 'gifs', 'kaomoji', 'symbols'].includes(tabName)) {
|
|
setActiveTab(tabName)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
unlistenPromise.then((unlisten) => unlisten())
|
|
unlistenSwitchTab.then((unlisten) => unlisten())
|
|
}
|
|
}, [])
|
|
|
|
// Re-apply CSS opacity variables whenever renderingEnv or settings change
|
|
useEffect(() => {
|
|
if (renderingEnv.transparency_disabled) {
|
|
// Force fully opaque CSS variables
|
|
const opaque: UserSettings = {
|
|
...settings,
|
|
dark_background_opacity: 1,
|
|
light_background_opacity: 1,
|
|
}
|
|
applyBackgroundOpacity(opaque)
|
|
} else {
|
|
applyBackgroundOpacity(settings)
|
|
}
|
|
}, [renderingEnv.transparency_disabled, settings])
|
|
|
|
// Apply theme class when isDark changes
|
|
useEffect(() => {
|
|
applyThemeClass(isDark)
|
|
}, [isDark])
|
|
|
|
// Handle ESC key to close/hide window
|
|
useEffect(() => {
|
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
try {
|
|
await getCurrentWindow().hide()
|
|
} catch (err) {
|
|
console.error('Failed to hide window:', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
globalThis.addEventListener('keydown', handleKeyDown)
|
|
return () => globalThis.removeEventListener('keydown', handleKeyDown)
|
|
}, [])
|
|
|
|
// Use refs to store current values for the focus handler (to avoid re-registering listener)
|
|
const activeTabRef = useRef(activeTab)
|
|
|
|
// Keep refs in sync
|
|
useEffect(() => {
|
|
activeTabRef.current = activeTab
|
|
}, [activeTab])
|
|
|
|
// Handle window-shown event for focus management (registered once)
|
|
useEffect(() => {
|
|
const focusFirstItem = () => {
|
|
// Small delay to ensure the window is fully rendered and focused
|
|
setTimeout(() => {
|
|
const currentTab = activeTabRef.current
|
|
|
|
if (currentTab !== 'clipboard') {
|
|
// Focus the first tab button if on other tabs
|
|
// Clipboard tab focus is handled inside ClipboardTab component
|
|
tabBarRef.current?.focusFirstTab()
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
// Listen to window-shown event (emitted from Rust when window is toggled visible)
|
|
const unlistenWindowShown = listen('window-shown', focusFirstItem)
|
|
|
|
return () => {
|
|
unlistenWindowShown.then((unlisten) => unlisten())
|
|
}
|
|
}, []) // Empty dependency array - listener is registered once
|
|
|
|
// Handle tab change
|
|
const handleTabChange = useCallback((tab: ActiveTab) => {
|
|
setActiveTab(tab)
|
|
}, [])
|
|
|
|
const handleMouseEnter = () => {
|
|
invoke('set_mouse_state', { inside: true }).catch(console.error)
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
invoke('set_mouse_state', { inside: false }).catch(console.error)
|
|
}
|
|
|
|
// Render content based on active tab
|
|
const renderContent = () => {
|
|
switch (activeTab) {
|
|
case 'clipboard':
|
|
return (
|
|
<ClipboardTab
|
|
history={history}
|
|
isLoading={isLoading}
|
|
isDark={isDark}
|
|
tertiaryOpacity={tertiaryOpacity}
|
|
secondaryOpacity={secondaryOpacity}
|
|
clearHistory={clearHistory}
|
|
deleteItem={deleteItem}
|
|
togglePin={togglePin}
|
|
onPaste={pasteItem}
|
|
settings={settings}
|
|
tabBarRef={tabBarRef}
|
|
/>
|
|
)
|
|
|
|
case 'emoji':
|
|
return <EmojiPicker isDark={isDark} opacity={secondaryOpacity} />
|
|
|
|
case 'gifs':
|
|
return <GifPicker isDark={isDark} opacity={secondaryOpacity} />
|
|
|
|
case 'kaomoji':
|
|
return (
|
|
<KaomojiPicker
|
|
isDark={isDark}
|
|
opacity={secondaryOpacity}
|
|
customKaomojis={settings.custom_kaomojis}
|
|
/>
|
|
)
|
|
|
|
case 'symbols':
|
|
return <SymbolPicker isDark={isDark} opacity={secondaryOpacity} />
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Don't render until settings are loaded to prevent FOUC
|
|
if (!settingsLoaded) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'h-screen w-screen overflow-hidden flex flex-col select-none',
|
|
renderingEnv.transparency_disabled ? 'rounded-none' : 'rounded-win11-lg',
|
|
isDark ? 'glass-effect' : 'glass-effect-light',
|
|
isDark ? 'bg-win11-acrylic-bg' : 'bg-win11Light-acrylic-bg',
|
|
isDark ? 'text-win11-text-primary' : 'text-win11Light-text-primary'
|
|
)}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{/* Drag Handle */}
|
|
<DragHandle isDark={isDark} />
|
|
|
|
{/* Tab bar */}
|
|
<TabBar
|
|
ref={tabBarRef}
|
|
activeTab={activeTab}
|
|
onTabChange={handleTabChange}
|
|
isDark={isDark}
|
|
tertiaryOpacity={tertiaryOpacity}
|
|
/>
|
|
|
|
{/* Scrollable content area */}
|
|
<div
|
|
ref={contentContainerRef}
|
|
className={clsx(
|
|
'flex-1',
|
|
// Only use scrollbar for non-emoji/gif/kaomoji tabs, they have their own virtualized scrolling or containers
|
|
activeTab === 'emoji' ||
|
|
activeTab === 'gifs' ||
|
|
activeTab === 'kaomoji' ||
|
|
activeTab === 'symbols'
|
|
? 'overflow-hidden'
|
|
: 'overflow-y-auto scrollbar-win11'
|
|
)}
|
|
>
|
|
{renderContent()}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ClipboardApp
|