mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
shortcuts and config menu (#4530)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
85dedf4b28
commit
d86a13cc89
@ -255,6 +255,22 @@
|
|||||||
"cacheInputs": {
|
"cacheInputs": {
|
||||||
"name": "Save form inputs",
|
"name": "Save form inputs",
|
||||||
"help": "Enable to store previously used inputs for future runs"
|
"help": "Enable to store previously used inputs for future runs"
|
||||||
|
},
|
||||||
|
"hotkeys": {
|
||||||
|
"title": "Keyboard Shortcuts",
|
||||||
|
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
||||||
|
"errorModifier": {
|
||||||
|
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
|
||||||
|
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||||
|
},
|
||||||
|
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||||
|
"none": "Not assigned",
|
||||||
|
"customBadge": "Custom",
|
||||||
|
"defaultLabel": "Default: {{shortcut}}",
|
||||||
|
"capturing": "Press keys… (Esc to cancel)",
|
||||||
|
"change": "Change shortcut",
|
||||||
|
"reset": "Reset",
|
||||||
|
"shortcut": "Shortcut"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"changeCreds": {
|
"changeCreds": {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { FileContextProvider } from "./contexts/FileContext";
|
|||||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||||
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||||
|
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
import HomePage from "./pages/HomePage";
|
import HomePage from "./pages/HomePage";
|
||||||
@ -44,15 +45,17 @@ export default function App() {
|
|||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
<ToolWorkflowProvider>
|
<ToolWorkflowProvider>
|
||||||
<SidebarProvider>
|
<HotkeyProvider>
|
||||||
<ViewerProvider>
|
<SidebarProvider>
|
||||||
<SignatureProvider>
|
<ViewerProvider>
|
||||||
<RightRailProvider>
|
<SignatureProvider>
|
||||||
|
<RightRailProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</SignatureProvider>
|
</SignatureProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
</HotkeyProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
|
|||||||
58
frontend/src/components/hotkeys/HotkeyDisplay.tsx
Normal file
58
frontend/src/components/hotkeys/HotkeyDisplay.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HotkeyBinding } from '../../utils/hotkeys';
|
||||||
|
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||||
|
|
||||||
|
interface HotkeyDisplayProps {
|
||||||
|
binding: HotkeyBinding | null | undefined;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
muted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKeyStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: 'var(--mantine-color-gray-1)',
|
||||||
|
border: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
padding: '0.125rem 0.35rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
||||||
|
minWidth: '1.35rem',
|
||||||
|
color: 'var(--mantine-color-text)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
||||||
|
const { getDisplayParts } = useHotkeys();
|
||||||
|
const parts = getDisplayParts(binding);
|
||||||
|
|
||||||
|
if (!binding || parts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyStyle = size === 'md'
|
||||||
|
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
||||||
|
: baseKeyStyle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||||
|
fontWeight: muted ? 500 : 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parts.map((part, index) => (
|
||||||
|
<React.Fragment key={`${part}-${index}`}>
|
||||||
|
<kbd style={keyStyle}>{part}</kbd>
|
||||||
|
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HotkeyDisplay;
|
||||||
130
frontend/src/components/shared/AppConfigModal.css
Normal file
130
frontend/src/components/shared/AppConfigModal.css
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/* AppConfigModal styles */
|
||||||
|
.modal-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
height: 37.5rem; /* 600px */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav {
|
||||||
|
width: 15rem; /* 240px */
|
||||||
|
height: 37.5rem; /* 600px */
|
||||||
|
border-top-left-radius: 0.75rem; /* 12px */
|
||||||
|
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: compact icon-only navigation */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.modal-container {
|
||||||
|
height: 100vh !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav {
|
||||||
|
width: 5rem; /* 80px - wider for larger icons */
|
||||||
|
height: 100vh !important;
|
||||||
|
max-height: none !important;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-scroll {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-item.mobile {
|
||||||
|
padding: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
height: 100vh !important;
|
||||||
|
max-height: none !important;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-section-items {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-nav-item {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0.625rem; /* 8px 10px */
|
||||||
|
border-radius: 0.5rem; /* 8px */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem; /* 4px */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
flex: 1;
|
||||||
|
height: 37.5rem; /* 600px */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
|
import LocalIcon from './LocalIcon';
|
||||||
|
import Overview from './config/configSections/Overview';
|
||||||
|
import { createConfigNavSections } from './config/configNavSections';
|
||||||
|
import { NavKey } from './config/types';
|
||||||
|
import './AppConfigModal.css';
|
||||||
|
|
||||||
interface AppConfigModalProps {
|
interface AppConfigModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@ -8,131 +13,143 @@ interface AppConfigModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||||
const { config, loading, error, refetch } = useAppConfig();
|
const [active, setActive] = useState<NavKey>('overview');
|
||||||
|
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||||
|
|
||||||
const renderConfigSection = (title: string, data: any) => {
|
useEffect(() => {
|
||||||
if (!data || typeof data !== 'object') return null;
|
const handler = (ev: Event) => {
|
||||||
|
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||||
|
if (detail?.key) {
|
||||||
|
setActive(detail.key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||||
|
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
const colors = useMemo(() => ({
|
||||||
<Stack gap="xs" mb="md">
|
navBg: 'var(--modal-nav-bg)',
|
||||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
sectionTitle: 'var(--modal-nav-section-title)',
|
||||||
<Stack gap="xs" pl="md">
|
navItem: 'var(--modal-nav-item)',
|
||||||
{Object.entries(data).map(([key, value]) => (
|
navItemActive: 'var(--modal-nav-item-active)',
|
||||||
<Group key={key} wrap="nowrap" align="flex-start">
|
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
contentBg: 'var(--modal-content-bg)',
|
||||||
{key}:
|
headerBorder: 'var(--modal-header-border)',
|
||||||
</Text>
|
}), []);
|
||||||
{typeof value === 'boolean' ? (
|
|
||||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||||
{value ? 'true' : 'false'}
|
const handleLogout = () => {
|
||||||
</Badge>
|
// In SaaS this would sign out, in open-source it does nothing
|
||||||
) : typeof value === 'object' ? (
|
console.log('Logout placeholder for SaaS compatibility');
|
||||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
|
||||||
) : (
|
|
||||||
String(value) || 'null'
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const basicConfig = config ? {
|
// Left navigation structure and icons
|
||||||
appName: config.appName,
|
const configNavSections = useMemo(() =>
|
||||||
appNameNavbar: config.appNameNavbar,
|
createConfigNavSections(
|
||||||
baseUrl: config.baseUrl,
|
Overview,
|
||||||
contextPath: config.contextPath,
|
handleLogout
|
||||||
serverPort: config.serverPort,
|
),
|
||||||
} : null;
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const securityConfig = config ? {
|
const activeLabel = useMemo(() => {
|
||||||
enableLogin: config.enableLogin,
|
for (const section of configNavSections) {
|
||||||
} : null;
|
const found = section.items.find(i => i.key === active);
|
||||||
|
if (found) return found.label;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [configNavSections, active]);
|
||||||
|
|
||||||
const systemConfig = config ? {
|
const activeComponent = useMemo(() => {
|
||||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
for (const section of configNavSections) {
|
||||||
enableAnalytics: config.enableAnalytics,
|
const found = section.items.find(i => i.key === active);
|
||||||
} : null;
|
if (found) return found.component;
|
||||||
|
}
|
||||||
const premiumConfig = config ? {
|
return null;
|
||||||
premiumEnabled: config.premiumEnabled,
|
}, [configNavSections, active]);
|
||||||
premiumKey: config.premiumKey ? '***hidden***' : null,
|
|
||||||
runningProOrHigher: config.runningProOrHigher,
|
|
||||||
runningEE: config.runningEE,
|
|
||||||
license: config.license,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const integrationConfig = config ? {
|
|
||||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
|
||||||
SSOAutoLogin: config.SSOAutoLogin,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
const legalConfig = config ? {
|
|
||||||
termsAndConditions: config.termsAndConditions,
|
|
||||||
privacyPolicy: config.privacyPolicy,
|
|
||||||
cookiePolicy: config.cookiePolicy,
|
|
||||||
impressum: config.impressum,
|
|
||||||
accessibilityStatement: config.accessibilityStatement,
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="App Configuration (Testing)"
|
title={null}
|
||||||
size="lg"
|
size={isMobile ? "100%" : 980}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
withCloseButton={false}
|
||||||
style={{ zIndex: 1000 }}
|
style={{ zIndex: 1000 }}
|
||||||
|
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||||
|
padding={0}
|
||||||
|
fullScreen={isMobile}
|
||||||
>
|
>
|
||||||
<Stack>
|
<div className="modal-container">
|
||||||
<Group justify="space-between">
|
{/* Left navigation */}
|
||||||
<Text size="sm" c="dimmed">
|
<div
|
||||||
This modal shows the current application configuration for testing purposes only.
|
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||||
</Text>
|
style={{
|
||||||
<Button size="xs" variant="light" onClick={refetch}>
|
background: colors.navBg,
|
||||||
Refresh
|
borderRight: `1px solid ${colors.headerBorder}`,
|
||||||
</Button>
|
}}
|
||||||
</Group>
|
>
|
||||||
|
<div className="modal-nav-scroll">
|
||||||
|
{configNavSections.map(section => (
|
||||||
|
<div key={section.title} className="modal-nav-section">
|
||||||
|
{!isMobile && (
|
||||||
|
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{section.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div className="modal-nav-section-items">
|
||||||
|
{section.items.map(item => {
|
||||||
|
const isActive = active === item.key;
|
||||||
|
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||||
|
const iconSize = isMobile ? 28 : 18;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setActive(item.key)}
|
||||||
|
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||||
|
style={{
|
||||||
|
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||||
|
{!isMobile && (
|
||||||
|
<Text size="sm" fw={500} style={{ color }}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{/* Right content */}
|
||||||
<Stack align="center" py="md">
|
<div className="modal-content">
|
||||||
<Loader size="sm" />
|
<div className="modal-content-scroll">
|
||||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
{/* Sticky header with section title and small close button */}
|
||||||
</Stack>
|
<div
|
||||||
)}
|
className="modal-header"
|
||||||
|
style={{
|
||||||
{error && (
|
background: colors.contentBg,
|
||||||
<Alert color="red" title="Error">
|
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||||
{error}
|
}}
|
||||||
</Alert>
|
>
|
||||||
)}
|
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||||
|
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||||
{config && (
|
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||||
<ScrollArea h={400}>
|
</ActionIcon>
|
||||||
<Stack gap="lg">
|
</div>
|
||||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
<div className="modal-body">
|
||||||
{renderConfigSection('Security Configuration', securityConfig)}
|
{activeComponent}
|
||||||
{renderConfigSection('System Configuration', systemConfig)}
|
</div>
|
||||||
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
|
</div>
|
||||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
</div>
|
||||||
{renderConfigSection('Legal Configuration', legalConfig)}
|
</div>
|
||||||
|
|
||||||
{config.error && (
|
|
||||||
<Alert color="yellow" title="Configuration Warning">
|
|
||||||
{config.error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
|
|
||||||
<Code block style={{ fontSize: '11px' }}>
|
|
||||||
{JSON.stringify(config, null, 2)}
|
|
||||||
</Code>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { ButtonConfig } from '../../types/sidebar';
|
|||||||
import './quickAccessBar/QuickAccessBar.css';
|
import './quickAccessBar/QuickAccessBar.css';
|
||||||
import AllToolsNavButton from './AllToolsNavButton';
|
import AllToolsNavButton from './AllToolsNavButton';
|
||||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||||
|
import AppConfigModal from './AppConfigModal';
|
||||||
import {
|
import {
|
||||||
isNavButtonActive,
|
isNavButtonActive,
|
||||||
getNavButtonStyle,
|
getNavButtonStyle,
|
||||||
@ -217,7 +218,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
|
||||||
{/* Config button at the bottom */}
|
{/* Config button at the bottom */}
|
||||||
{/* {buttonConfigs
|
{buttonConfigs
|
||||||
.filter(config => config.id === 'config')
|
.filter(config => config.id === 'config')
|
||||||
.map(config => (
|
.map(config => (
|
||||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||||
@ -237,14 +238,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
{config.name}
|
{config.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))} */}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <AppConfigModal
|
<AppConfigModal
|
||||||
opened={configModalOpen}
|
opened={configModalOpen}
|
||||||
onClose={() => setConfigModalOpen(false)}
|
onClose={() => setConfigModalOpen(false)}
|
||||||
/> */}
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
57
frontend/src/components/shared/config/configNavSections.tsx
Normal file
57
frontend/src/components/shared/config/configNavSections.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavKey } from './types';
|
||||||
|
import HotkeysSection from './configSections/HotkeysSection';
|
||||||
|
|
||||||
|
export interface ConfigNavItem {
|
||||||
|
key: NavKey;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
component: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigNavSection {
|
||||||
|
title: string;
|
||||||
|
items: ConfigNavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigColors {
|
||||||
|
navBg: string;
|
||||||
|
sectionTitle: string;
|
||||||
|
navItem: string;
|
||||||
|
navItemActive: string;
|
||||||
|
navItemActiveBg: string;
|
||||||
|
contentBg: string;
|
||||||
|
headerBorder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createConfigNavSections = (
|
||||||
|
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||||
|
onLogoutClick: () => void
|
||||||
|
): ConfigNavSection[] => {
|
||||||
|
const sections: ConfigNavSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Account',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
icon: 'person-rounded',
|
||||||
|
component: <Overview onLogoutClick={onLogoutClick} />
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Preferences',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'hotkeys',
|
||||||
|
label: 'Keyboard Shortcuts',
|
||||||
|
icon: 'keyboard-rounded',
|
||||||
|
component: <HotkeysSection />
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
};
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
|
||||||
|
import { useHotkeys } from '../../../../contexts/HotkeyContext';
|
||||||
|
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
|
||||||
|
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
|
||||||
|
|
||||||
|
const rowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowHeaderStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '0.5rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HotkeysSection: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toolRegistry } = useToolWorkflow();
|
||||||
|
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
|
||||||
|
const [editingTool, setEditingTool] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingTool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pauseHotkeys();
|
||||||
|
return () => {
|
||||||
|
resumeHotkeys();
|
||||||
|
};
|
||||||
|
}, [editingTool, pauseHotkeys, resumeHotkeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingTool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setEditingTool(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = eventToBinding(event as KeyboardEvent);
|
||||||
|
if (!binding) {
|
||||||
|
const osKey = isMac ? 'mac' : 'windows';
|
||||||
|
const fallbackText = isMac
|
||||||
|
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
|
||||||
|
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
|
||||||
|
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => (
|
||||||
|
toolId !== editingTool && bindingEquals(existing, binding)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (conflictEntry) {
|
||||||
|
const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0];
|
||||||
|
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHotkey(editingTool, binding);
|
||||||
|
setEditingTool(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
|
||||||
|
|
||||||
|
const handleStartCapture = (toolId: string) => {
|
||||||
|
setEditingTool(toolId);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
{tools.map(([toolId, tool], index) => {
|
||||||
|
const currentBinding = hotkeys[toolId];
|
||||||
|
const defaultBinding = defaults[toolId];
|
||||||
|
const isEditing = editingTool === toolId;
|
||||||
|
const defaultParts = getDisplayParts(defaultBinding);
|
||||||
|
const defaultLabel = defaultParts.length > 0
|
||||||
|
? defaultParts.join(' + ')
|
||||||
|
: t('settings.hotkeys.none', 'Not assigned');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={toolId}>
|
||||||
|
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
|
||||||
|
<div style={rowHeaderStyle}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
|
||||||
|
<Text fw={600}>{tool.name}</Text>
|
||||||
|
<Group gap="xs" wrap="wrap" align="center">
|
||||||
|
<HotkeyDisplay binding={currentBinding} size="md" />
|
||||||
|
{!bindingEquals(currentBinding, defaultBinding) && (
|
||||||
|
<Badge variant="light" color="orange" radius="sm">
|
||||||
|
{t('settings.hotkeys.customBadge', 'Custom')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={isEditing ? 'filled' : 'default'}
|
||||||
|
color={isEditing ? 'blue' : undefined}
|
||||||
|
onClick={() => handleStartCapture(toolId)}
|
||||||
|
>
|
||||||
|
{isEditing
|
||||||
|
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
|
||||||
|
: t('settings.hotkeys.change', 'Change shortcut')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
disabled={bindingEquals(currentBinding, defaultBinding)}
|
||||||
|
onClick={() => resetHotkey(toolId)}
|
||||||
|
>
|
||||||
|
{t('settings.hotkeys.reset', 'Reset')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && error && (
|
||||||
|
<Alert color="red" radius="sm" variant="filled">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{index < tools.length - 1 && <Divider />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HotkeysSection;
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||||
|
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||||
|
|
||||||
|
const Overview: React.FC = () => {
|
||||||
|
const { config, loading, error } = useAppConfig();
|
||||||
|
|
||||||
|
const renderConfigSection = (title: string, data: any) => {
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" mb="md">
|
||||||
|
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||||
|
<Stack gap="xs" pl="md">
|
||||||
|
{Object.entries(data).map(([key, value]) => (
|
||||||
|
<Group key={key} wrap="nowrap" align="flex-start">
|
||||||
|
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||||
|
{key}:
|
||||||
|
</Text>
|
||||||
|
{typeof value === 'boolean' ? (
|
||||||
|
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||||
|
{value ? 'true' : 'false'}
|
||||||
|
</Badge>
|
||||||
|
) : typeof value === 'object' ? (
|
||||||
|
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||||
|
) : (
|
||||||
|
String(value) || 'null'
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const basicConfig = config ? {
|
||||||
|
appName: config.appName,
|
||||||
|
appNameNavbar: config.appNameNavbar,
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
contextPath: config.contextPath,
|
||||||
|
serverPort: config.serverPort,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const securityConfig = config ? {
|
||||||
|
enableLogin: config.enableLogin,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const systemConfig = config ? {
|
||||||
|
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||||
|
enableAnalytics: config.enableAnalytics,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const integrationConfig = config ? {
|
||||||
|
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||||
|
SSOAutoLogin: config.SSOAutoLogin,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" title="Error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">Application Configuration</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Current application settings and configuration details.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config && (
|
||||||
|
<>
|
||||||
|
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||||
|
{renderConfigSection('Security Configuration', securityConfig)}
|
||||||
|
{renderConfigSection('System Configuration', systemConfig)}
|
||||||
|
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||||
|
|
||||||
|
{config.error && (
|
||||||
|
<Alert color="yellow" title="Configuration Warning">
|
||||||
|
{config.error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Overview;
|
||||||
19
frontend/src/components/shared/config/types.ts
Normal file
19
frontend/src/components/shared/config/types.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export type NavKey =
|
||||||
|
| 'overview'
|
||||||
|
| 'preferences'
|
||||||
|
| 'notifications'
|
||||||
|
| 'connections'
|
||||||
|
| 'general'
|
||||||
|
| 'people'
|
||||||
|
| 'teams'
|
||||||
|
| 'security'
|
||||||
|
| 'identity'
|
||||||
|
| 'plan'
|
||||||
|
| 'payments'
|
||||||
|
| 'requests'
|
||||||
|
| 'developer'
|
||||||
|
| 'api-keys'
|
||||||
|
| 'hotkeys';
|
||||||
|
|
||||||
|
|
||||||
|
// some of these are not used yet, but appear in figma designs
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "../../shared/Tooltip";
|
import { Tooltip } from "../../shared/Tooltip";
|
||||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||||
import { useToolNavigation } from "../../../hooks/useToolNavigation";
|
import { useToolNavigation } from "../../../hooks/useToolNavigation";
|
||||||
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
||||||
import FitText from "../../shared/FitText";
|
import FitText from "../../shared/FitText";
|
||||||
|
import { useHotkeys } from "../../../contexts/HotkeyContext";
|
||||||
|
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
|
||||||
|
|
||||||
interface ToolButtonProps {
|
interface ToolButtonProps {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,8 +20,11 @@ interface ToolButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Special case: read and multiTool are navigational tools that are always available
|
// Special case: read and multiTool are navigational tools that are always available
|
||||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||||
|
const { hotkeys } = useHotkeys();
|
||||||
|
const binding = hotkeys[id];
|
||||||
const { getToolNavigation } = useToolNavigation();
|
const { getToolNavigation } = useToolNavigation();
|
||||||
|
|
||||||
const handleClick = (id: string) => {
|
const handleClick = (id: string) => {
|
||||||
@ -37,7 +43,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
|||||||
|
|
||||||
const tooltipContent = isUnavailable
|
const tooltipContent = isUnavailable
|
||||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||||
: tool.description;
|
: (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span>{tool.description}</span>
|
||||||
|
{binding && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
|
||||||
|
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
|
||||||
|
<HotkeyDisplay binding={binding} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const buttonContent = (
|
const buttonContent = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
211
frontend/src/contexts/HotkeyContext.tsx
Normal file
211
frontend/src/contexts/HotkeyContext.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys';
|
||||||
|
import { useToolWorkflow } from './ToolWorkflowContext';
|
||||||
|
import { ToolId } from '../types/toolId';
|
||||||
|
|
||||||
|
interface HotkeyContextValue {
|
||||||
|
hotkeys: Record<string, HotkeyBinding>;
|
||||||
|
defaults: Record<string, HotkeyBinding>;
|
||||||
|
isMac: boolean;
|
||||||
|
updateHotkey: (toolId: string, binding: HotkeyBinding) => void;
|
||||||
|
resetHotkey: (toolId: string) => void;
|
||||||
|
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean;
|
||||||
|
pauseHotkeys: () => void;
|
||||||
|
resumeHotkeys: () => void;
|
||||||
|
areHotkeysPaused: boolean;
|
||||||
|
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'stirlingpdf.hotkeys';
|
||||||
|
|
||||||
|
const KEY_ORDER: string[] = [
|
||||||
|
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
|
||||||
|
'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP',
|
||||||
|
'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL',
|
||||||
|
'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM',
|
||||||
|
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record<string, HotkeyBinding> => {
|
||||||
|
const defaults: Record<string, HotkeyBinding> = {};
|
||||||
|
let index = 0;
|
||||||
|
let useShift = false;
|
||||||
|
|
||||||
|
const nextBinding = (): HotkeyBinding => {
|
||||||
|
if (index >= KEY_ORDER.length) {
|
||||||
|
index = 0;
|
||||||
|
if (!useShift) {
|
||||||
|
useShift = true;
|
||||||
|
} else {
|
||||||
|
// If we somehow run out of combinations, wrap back around (unlikely given tool count)
|
||||||
|
useShift = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = KEY_ORDER[index];
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
alt: true,
|
||||||
|
shift: useShift,
|
||||||
|
meta: macLike,
|
||||||
|
ctrl: !macLike,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
toolIds.forEach(toolId => {
|
||||||
|
defaults[toolId] = nextBinding();
|
||||||
|
});
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!target || !(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const editable = target.closest('input, textarea, [contenteditable="true"], [role="textbox"]');
|
||||||
|
return Boolean(editable);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { toolRegistry, handleToolSelect } = useToolWorkflow();
|
||||||
|
const isMac = useMemo(() => isMacLike(), []);
|
||||||
|
const [customBindings, setCustomBindings] = useState<Record<string, HotkeyBinding>>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return deserializeBindings(window.localStorage?.getItem(STORAGE_KEY));
|
||||||
|
});
|
||||||
|
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
|
||||||
|
|
||||||
|
const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]);
|
||||||
|
|
||||||
|
const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]);
|
||||||
|
|
||||||
|
// Remove bindings for tools that are no longer present
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomBindings(prev => {
|
||||||
|
const next: Record<string, HotkeyBinding> = {};
|
||||||
|
let changed = false;
|
||||||
|
Object.entries(prev).forEach(([toolId, binding]) => {
|
||||||
|
if (toolRegistry[toolId]) {
|
||||||
|
next[toolId] = binding;
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [toolRegistry]);
|
||||||
|
|
||||||
|
const resolved = useMemo(() => {
|
||||||
|
const merged: Record<string, HotkeyBinding> = {};
|
||||||
|
toolIds.forEach(toolId => {
|
||||||
|
const custom = customBindings[toolId];
|
||||||
|
merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId];
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}, [customBindings, defaults, toolIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
|
||||||
|
}, [customBindings]);
|
||||||
|
|
||||||
|
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => {
|
||||||
|
const normalized = normalizeBinding(binding);
|
||||||
|
return Object.entries(resolved).every(([toolId, existing]) => {
|
||||||
|
if (toolId === excludeToolId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !bindingEquals(existing, normalized);
|
||||||
|
});
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => {
|
||||||
|
setCustomBindings(prev => {
|
||||||
|
const normalized = normalizeBinding(binding);
|
||||||
|
const defaultsForTool = defaults[toolId];
|
||||||
|
const next = { ...prev };
|
||||||
|
if (defaultsForTool && bindingEquals(defaultsForTool, normalized)) {
|
||||||
|
delete next[toolId];
|
||||||
|
} else {
|
||||||
|
next[toolId] = normalized;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [defaults]);
|
||||||
|
|
||||||
|
const resetHotkey = useCallback((toolId: string) => {
|
||||||
|
setCustomBindings(prev => {
|
||||||
|
if (!(toolId in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[toolId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pauseHotkeys = useCallback(() => setHotkeysPaused(true), []);
|
||||||
|
const resumeHotkeys = useCallback(() => setHotkeysPaused(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (areHotkeysPaused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (event.repeat) return;
|
||||||
|
if (shouldIgnoreTarget(event.target)) return;
|
||||||
|
|
||||||
|
const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>;
|
||||||
|
for (const [toolId, binding] of entries) {
|
||||||
|
if (bindingMatchesEvent(binding, event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleToolSelect(toolId as ToolId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handler, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handler, true);
|
||||||
|
};
|
||||||
|
}, [resolved, areHotkeysPaused, handleToolSelect]);
|
||||||
|
|
||||||
|
const contextValue = useMemo<HotkeyContextValue>(() => ({
|
||||||
|
hotkeys: resolved,
|
||||||
|
defaults,
|
||||||
|
isMac,
|
||||||
|
updateHotkey,
|
||||||
|
resetHotkey,
|
||||||
|
isBindingAvailable,
|
||||||
|
pauseHotkeys,
|
||||||
|
resumeHotkeys,
|
||||||
|
areHotkeysPaused,
|
||||||
|
getDisplayParts: (binding) => getDisplayParts(binding ?? null, isMac),
|
||||||
|
}), [resolved, defaults, isMac, updateHotkey, resetHotkey, isBindingAvailable, pauseHotkeys, resumeHotkeys, areHotkeysPaused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HotkeyContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</HotkeyContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHotkeys = (): HotkeyContextValue => {
|
||||||
|
const context = useContext(HotkeyContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useHotkeys must be used within a HotkeyProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -75,7 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
|||||||
// Tool management (from hook)
|
// Tool management (from hook)
|
||||||
selectedToolKey: string | null;
|
selectedToolKey: string | null;
|
||||||
selectedTool: ToolRegistryEntry | null;
|
selectedTool: ToolRegistryEntry | null;
|
||||||
toolRegistry: any; // From useToolManagement
|
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||||
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
|
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
|
||||||
|
|
||||||
// UI Actions
|
// UI Actions
|
||||||
@ -231,7 +231,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
|
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
|
||||||
const filteredTools = useMemo(() => {
|
const filteredTools = useMemo(() => {
|
||||||
if (!toolRegistry) return [];
|
if (!toolRegistry) return [];
|
||||||
return filterToolRegistryByQuery(toolRegistry as Record<string, ToolRegistryEntry>, state.searchQuery);
|
return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery);
|
||||||
}, [toolRegistry, state.searchQuery]);
|
}, [toolRegistry, state.searchQuery]);
|
||||||
|
|
||||||
const isPanelVisible = useMemo(() =>
|
const isPanelVisible = useMemo(() =>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import RightRail from "../components/shared/RightRail";
|
|||||||
import FileManager from "../components/FileManager";
|
import FileManager from "../components/FileManager";
|
||||||
import LocalIcon from "../components/shared/LocalIcon";
|
import LocalIcon from "../components/shared/LocalIcon";
|
||||||
import { useFilesModalContext } from "../contexts/FilesModalContext";
|
import { useFilesModalContext } from "../contexts/FilesModalContext";
|
||||||
|
import AppConfigModal from "../components/shared/AppConfigModal";
|
||||||
|
|
||||||
import "./HomePage.css";
|
import "./HomePage.css";
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ export default function HomePage() {
|
|||||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||||
const isProgrammaticScroll = useRef(false);
|
const isProgrammaticScroll = useRef(false);
|
||||||
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||||
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
|
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
|
||||||
@ -207,8 +209,20 @@ export default function HomePage() {
|
|||||||
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
|
||||||
<span className="mobile-bottom-button-label">{t('quickAccess.files', 'Files')}</span>
|
<span className="mobile-bottom-button-label">{t('quickAccess.files', 'Files')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="mobile-bottom-button"
|
||||||
|
aria-label={t('quickAccess.config', 'Config')}
|
||||||
|
onClick={() => setConfigModalOpen(true)}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="settings-rounded" width="1.5rem" height="1.5rem" />
|
||||||
|
<span className="mobile-bottom-button-label">{t('quickAccess.config', 'Config')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||||
|
<AppConfigModal
|
||||||
|
opened={configModalOpen}
|
||||||
|
onClose={() => setConfigModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
@ -224,6 +224,15 @@
|
|||||||
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
||||||
--unsupported-bar-bg: #5a616e;
|
--unsupported-bar-bg: #5a616e;
|
||||||
--unsupported-bar-border: #6B7280;
|
--unsupported-bar-border: #6B7280;
|
||||||
|
|
||||||
|
/* Config Modal colors (light mode) */
|
||||||
|
--modal-nav-bg: #F5F6F8;
|
||||||
|
--modal-nav-section-title: #6B7280;
|
||||||
|
--modal-nav-item: #374151;
|
||||||
|
--modal-nav-item-active: #0A8BFF;
|
||||||
|
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
|
||||||
|
--modal-content-bg: #ffffff;
|
||||||
|
--modal-header-border: rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] {
|
[data-mantine-color-scheme="dark"] {
|
||||||
@ -414,6 +423,15 @@
|
|||||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||||
--unsupported-bar-bg: #1F2329;
|
--unsupported-bar-bg: #1F2329;
|
||||||
--unsupported-bar-border: #4B525A;
|
--unsupported-bar-border: #4B525A;
|
||||||
|
|
||||||
|
/* Config Modal colors (dark mode) */
|
||||||
|
--modal-nav-bg: #1F2329;
|
||||||
|
--modal-nav-section-title: #9CA3AF;
|
||||||
|
--modal-nav-item: #D0D6DC;
|
||||||
|
--modal-nav-item-active: #0A8BFF;
|
||||||
|
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
||||||
|
--modal-content-bg: #2A2F36;
|
||||||
|
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropzone drop state styling */
|
/* Dropzone drop state styling */
|
||||||
|
|||||||
191
frontend/src/utils/hotkeys.ts
Normal file
191
frontend/src/utils/hotkeys.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
export interface HotkeyBinding {
|
||||||
|
code: string;
|
||||||
|
alt?: boolean;
|
||||||
|
ctrl?: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODIFIER_CODES = new Set([
|
||||||
|
'ShiftLeft',
|
||||||
|
'ShiftRight',
|
||||||
|
'ControlLeft',
|
||||||
|
'ControlRight',
|
||||||
|
'AltLeft',
|
||||||
|
'AltRight',
|
||||||
|
'MetaLeft',
|
||||||
|
'MetaRight',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CODE_LABEL_MAP: Record<string, string> = {
|
||||||
|
Minus: '-',
|
||||||
|
Equal: '=',
|
||||||
|
Backquote: '`',
|
||||||
|
BracketLeft: '[',
|
||||||
|
BracketRight: ']',
|
||||||
|
Backslash: '\\',
|
||||||
|
IntlBackslash: '\\',
|
||||||
|
Semicolon: ';',
|
||||||
|
Quote: '\'',
|
||||||
|
Comma: ',',
|
||||||
|
Period: '.',
|
||||||
|
Slash: '/',
|
||||||
|
Space: 'Space',
|
||||||
|
Tab: 'Tab',
|
||||||
|
Escape: 'Esc',
|
||||||
|
Enter: 'Enter',
|
||||||
|
NumpadEnter: 'Num Enter',
|
||||||
|
NumpadAdd: 'Num +',
|
||||||
|
NumpadSubtract: 'Num -',
|
||||||
|
NumpadMultiply: 'Num *',
|
||||||
|
NumpadDivide: 'Num /',
|
||||||
|
NumpadDecimal: 'Num .',
|
||||||
|
NumpadComma: 'Num ,',
|
||||||
|
NumpadEqual: 'Num =',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isMacLike = (): boolean => {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const platform = navigator.platform?.toLowerCase() ?? '';
|
||||||
|
const userAgent = navigator.userAgent?.toLowerCase() ?? '';
|
||||||
|
return /mac|iphone|ipad|ipod/.test(platform) || /mac|iphone|ipad|ipod/.test(userAgent);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isModifierCode = (code: string): boolean => MODIFIER_CODES.has(code);
|
||||||
|
|
||||||
|
const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code);
|
||||||
|
|
||||||
|
export const bindingEquals = (a?: HotkeyBinding | null, b?: HotkeyBinding | null): boolean => {
|
||||||
|
if (!a && !b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return (
|
||||||
|
a.code === b.code &&
|
||||||
|
Boolean(a.alt) === Boolean(b.alt) &&
|
||||||
|
Boolean(a.ctrl) === Boolean(b.ctrl) &&
|
||||||
|
Boolean(a.meta) === Boolean(b.meta) &&
|
||||||
|
Boolean(a.shift) === Boolean(b.shift)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bindingMatchesEvent = (binding: HotkeyBinding, event: KeyboardEvent): boolean => {
|
||||||
|
return (
|
||||||
|
event.code === binding.code &&
|
||||||
|
event.altKey === Boolean(binding.alt) &&
|
||||||
|
event.ctrlKey === Boolean(binding.ctrl) &&
|
||||||
|
event.metaKey === Boolean(binding.meta) &&
|
||||||
|
event.shiftKey === Boolean(binding.shift)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventToBinding = (event: KeyboardEvent | ReactKeyboardEvent): HotkeyBinding | null => {
|
||||||
|
const code = event.code;
|
||||||
|
if (!code || isModifierCode(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding: HotkeyBinding = {
|
||||||
|
code,
|
||||||
|
alt: event.altKey,
|
||||||
|
ctrl: event.ctrlKey,
|
||||||
|
meta: event.metaKey,
|
||||||
|
shift: event.shiftKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Require at least one modifier to avoid clashing with text input
|
||||||
|
if (!binding.alt && !binding.ctrl && !binding.meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyLabel = (code: string): string => {
|
||||||
|
if (CODE_LABEL_MAP[code]) {
|
||||||
|
return CODE_LABEL_MAP[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.startsWith('Key')) {
|
||||||
|
return code.slice(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.startsWith('Digit')) {
|
||||||
|
return code.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.startsWith('Numpad')) {
|
||||||
|
const remainder = code.slice(6);
|
||||||
|
if (/^[0-9]$/.test(remainder)) {
|
||||||
|
return `Num ${remainder}`;
|
||||||
|
}
|
||||||
|
return `Num ${remainder}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match function keys (F1-F12)
|
||||||
|
if (isFunctionKey(code)) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
return '↑';
|
||||||
|
case 'ArrowDown':
|
||||||
|
return '↓';
|
||||||
|
case 'ArrowLeft':
|
||||||
|
return '←';
|
||||||
|
case 'ArrowRight':
|
||||||
|
return '→';
|
||||||
|
default:
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDisplayParts = (binding: HotkeyBinding | null | undefined, macLike: boolean): string[] => {
|
||||||
|
if (!binding) return [];
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (binding.meta) {
|
||||||
|
parts.push(macLike ? '⌘' : 'Win');
|
||||||
|
}
|
||||||
|
if (binding.ctrl) {
|
||||||
|
parts.push(macLike ? '⌃' : 'Ctrl');
|
||||||
|
}
|
||||||
|
if (binding.alt) {
|
||||||
|
parts.push(macLike ? '⌥' : 'Alt');
|
||||||
|
}
|
||||||
|
if (binding.shift) {
|
||||||
|
parts.push(macLike ? '⇧' : 'Shift');
|
||||||
|
}
|
||||||
|
parts.push(getKeyLabel(binding.code));
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeBindings = (bindings: Record<string, HotkeyBinding>): string => {
|
||||||
|
return JSON.stringify(bindings);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deserializeBindings = (value: string | null | undefined): Record<string, HotkeyBinding> => {
|
||||||
|
if (!value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Record<string, HotkeyBinding>;
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse stored hotkey bindings', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({
|
||||||
|
code: binding.code,
|
||||||
|
alt: Boolean(binding.alt),
|
||||||
|
ctrl: Boolean(binding.ctrl),
|
||||||
|
meta: Boolean(binding.meta),
|
||||||
|
shift: Boolean(binding.shift),
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user