From d86a13cc8920260e50c9a4c0ae7512f541cf5fa7 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:22:04 +0100 Subject: [PATCH 1/3] shortcuts and config menu (#4530) # Description of Changes --- ## 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 --- .../public/locales/en-GB/translation.json | 16 ++ frontend/src/App.tsx | 17 +- .../src/components/hotkeys/HotkeyDisplay.tsx | 58 +++++ .../src/components/shared/AppConfigModal.css | 130 ++++++++++ .../src/components/shared/AppConfigModal.tsx | 245 ++++++++++-------- .../src/components/shared/QuickAccessBar.tsx | 9 +- .../shared/config/configNavSections.tsx | 57 ++++ .../config/configSections/HotkeysSection.tsx | 171 ++++++++++++ .../shared/config/configSections/Overview.tsx | 102 ++++++++ .../src/components/shared/config/types.ts | 19 ++ .../tools/toolPicker/ToolButton.tsx | 18 +- frontend/src/contexts/HotkeyContext.tsx | 211 +++++++++++++++ frontend/src/contexts/ToolWorkflowContext.tsx | 4 +- frontend/src/pages/HomePage.tsx | 14 + frontend/src/styles/theme.css | 18 ++ frontend/src/utils/hotkeys.ts | 191 ++++++++++++++ 16 files changed, 1152 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/hotkeys/HotkeyDisplay.tsx create mode 100644 frontend/src/components/shared/AppConfigModal.css create mode 100644 frontend/src/components/shared/config/configNavSections.tsx create mode 100644 frontend/src/components/shared/config/configSections/HotkeysSection.tsx create mode 100644 frontend/src/components/shared/config/configSections/Overview.tsx create mode 100644 frontend/src/components/shared/config/types.ts create mode 100644 frontend/src/contexts/HotkeyContext.tsx create mode 100644 frontend/src/utils/hotkeys.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index dd9ff0400..c6e7a95a6 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -255,6 +255,22 @@ "cacheInputs": { "name": "Save form inputs", "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": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 529da2643..d926b9f0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { FileContextProvider } from "./contexts/FileContext"; import { NavigationProvider } from "./contexts/NavigationContext"; import { FilesModalProvider } from "./contexts/FilesModalContext"; import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "./contexts/HotkeyContext"; import { SidebarProvider } from "./contexts/SidebarContext"; import ErrorBoundary from "./components/shared/ErrorBoundary"; import HomePage from "./pages/HomePage"; @@ -44,15 +45,17 @@ export default function App() { - - - - + + + + + - - - + + + + diff --git a/frontend/src/components/hotkeys/HotkeyDisplay.tsx b/frontend/src/components/hotkeys/HotkeyDisplay.tsx new file mode 100644 index 000000000..e240f987e --- /dev/null +++ b/frontend/src/components/hotkeys/HotkeyDisplay.tsx @@ -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 = ({ 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 ( + + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && +} + + ))} + + ); +}; + +export default HotkeyDisplay; \ No newline at end of file diff --git a/frontend/src/components/shared/AppConfigModal.css b/frontend/src/components/shared/AppConfigModal.css new file mode 100644 index 000000000..2ada07184 --- /dev/null +++ b/frontend/src/components/shared/AppConfigModal.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/shared/AppConfigModal.tsx b/frontend/src/components/shared/AppConfigModal.tsx index a73e3ff73..2ed7f71b7 100644 --- a/frontend/src/components/shared/AppConfigModal.tsx +++ b/frontend/src/components/shared/AppConfigModal.tsx @@ -1,6 +1,11 @@ -import React from 'react'; -import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core'; -import { useAppConfig } from '../../hooks/useAppConfig'; +import React, { useMemo, useState, useEffect } from 'react'; +import { Modal, Text, ActionIcon } from '@mantine/core'; +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 { opened: boolean; @@ -8,131 +13,143 @@ interface AppConfigModalProps { } const AppConfigModal: React.FC = ({ opened, onClose }) => { - const { config, loading, error, refetch } = useAppConfig(); + const [active, setActive] = useState('overview'); + const isMobile = useMediaQuery("(max-width: 1024px)"); - const renderConfigSection = (title: string, data: any) => { - if (!data || typeof data !== 'object') return null; + useEffect(() => { + 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 ( - - {title} - - {Object.entries(data).map(([key, value]) => ( - - - {key}: - - {typeof value === 'boolean' ? ( - - {value ? 'true' : 'false'} - - ) : typeof value === 'object' ? ( - {JSON.stringify(value, null, 2)} - ) : ( - String(value) || 'null' - )} - - ))} - - - ); + const colors = useMemo(() => ({ + navBg: 'var(--modal-nav-bg)', + sectionTitle: 'var(--modal-nav-section-title)', + navItem: 'var(--modal-nav-item)', + navItemActive: 'var(--modal-nav-item-active)', + navItemActiveBg: 'var(--modal-nav-item-active-bg)', + contentBg: 'var(--modal-content-bg)', + headerBorder: 'var(--modal-header-border)', + }), []); + + // Placeholder logout handler (not needed in open-source but keeps SaaS compatibility) + const handleLogout = () => { + // In SaaS this would sign out, in open-source it does nothing + console.log('Logout placeholder for SaaS compatibility'); }; - const basicConfig = config ? { - appName: config.appName, - appNameNavbar: config.appNameNavbar, - baseUrl: config.baseUrl, - contextPath: config.contextPath, - serverPort: config.serverPort, - } : null; + // Left navigation structure and icons + const configNavSections = useMemo(() => + createConfigNavSections( + Overview, + handleLogout + ), + [] + ); - const securityConfig = config ? { - enableLogin: config.enableLogin, - } : null; + const activeLabel = useMemo(() => { + for (const section of configNavSections) { + const found = section.items.find(i => i.key === active); + if (found) return found.label; + } + return ''; + }, [configNavSections, active]); - const systemConfig = config ? { - enableAlphaFunctionality: config.enableAlphaFunctionality, - enableAnalytics: config.enableAnalytics, - } : null; - - const premiumConfig = config ? { - premiumEnabled: config.premiumEnabled, - 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; + const activeComponent = useMemo(() => { + for (const section of configNavSections) { + const found = section.items.find(i => i.key === active); + if (found) return found.component; + } + return null; + }, [configNavSections, active]); return ( - - - - This modal shows the current application configuration for testing purposes only. - - - +
+ {/* Left navigation */} +
+
+ {configNavSections.map(section => ( +
+ {!isMobile && ( + + {section.title} + + )} +
+ {section.items.map(item => { + const isActive = active === item.key; + const color = isActive ? colors.navItemActive : colors.navItem; + const iconSize = isMobile ? 28 : 18; + return ( +
setActive(item.key)} + className={`modal-nav-item ${isMobile ? 'mobile' : ''}`} + style={{ + background: isActive ? colors.navItemActiveBg : 'transparent', + }} + > + + {!isMobile && ( + + {item.label} + + )} +
+ ); + })} +
+
+ ))} +
+
- {loading && ( - - - Loading configuration... - - )} - - {error && ( - - {error} - - )} - - {config && ( - - - {renderConfigSection('Basic Configuration', basicConfig)} - {renderConfigSection('Security Configuration', securityConfig)} - {renderConfigSection('System Configuration', systemConfig)} - {renderConfigSection('Premium/Enterprise Configuration', premiumConfig)} - {renderConfigSection('Integration Configuration', integrationConfig)} - {renderConfigSection('Legal Configuration', legalConfig)} - - {config.error && ( - - {config.error} - - )} - - - Raw Configuration - - {JSON.stringify(config, null, 2)} - - - - - )} - + {/* Right content */} +
+
+ {/* Sticky header with section title and small close button */} +
+ {activeLabel} + + + +
+
+ {activeComponent} +
+
+
+
); }; diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 8bc536bdb..b7206fa93 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -12,6 +12,7 @@ import { ButtonConfig } from '../../types/sidebar'; import './quickAccessBar/QuickAccessBar.css'; import AllToolsNavButton from './AllToolsNavButton'; import ActiveToolButton from "./quickAccessBar/ActiveToolButton"; +import AppConfigModal from './AppConfigModal'; import { isNavButtonActive, getNavButtonStyle, @@ -217,7 +218,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
{/* Config button at the bottom */} - {/* {buttonConfigs + {buttonConfigs .filter(config => config.id === 'config') .map(config => (
@@ -237,14 +238,14 @@ const QuickAccessBar = forwardRef((_, ref) => { {config.name}
- ))} */} + ))}
- {/* setConfigModalOpen(false)} - /> */} + /> ); }); diff --git a/frontend/src/components/shared/config/configNavSections.tsx b/frontend/src/components/shared/config/configNavSections.tsx new file mode 100644 index 000000000..912ad8647 --- /dev/null +++ b/frontend/src/components/shared/config/configNavSections.tsx @@ -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: + }, + ], + }, + { + title: 'Preferences', + items: [ + { + key: 'hotkeys', + label: 'Keyboard Shortcuts', + icon: 'keyboard-rounded', + component: + }, + ], + }, + ]; + + return sections; +}; \ No newline at end of file diff --git a/frontend/src/components/shared/config/configSections/HotkeysSection.tsx b/frontend/src/components/shared/config/configSections/HotkeysSection.tsx new file mode 100644 index 000000000..e0240f55a --- /dev/null +++ b/frontend/src/components/shared/config/configSections/HotkeysSection.tsx @@ -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(null); + const [error, setError] = useState(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 ( + +
+ Keyboard Shortcuts + + Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel. + +
+ + + + {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 ( + + +
+
+ {tool.name} + + + {!bindingEquals(currentBinding, defaultBinding) && ( + + {t('settings.hotkeys.customBadge', 'Custom')} + + )} + + {t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })} + + +
+ + + + + +
+ + {isEditing && error && ( + + {error} + + )} +
+ + {index < tools.length - 1 && } +
+ ); + })} +
+
+
+ ); +}; + +export default HotkeysSection; \ No newline at end of file diff --git a/frontend/src/components/shared/config/configSections/Overview.tsx b/frontend/src/components/shared/config/configSections/Overview.tsx new file mode 100644 index 000000000..e591655e0 --- /dev/null +++ b/frontend/src/components/shared/config/configSections/Overview.tsx @@ -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 ( + + {title} + + {Object.entries(data).map(([key, value]) => ( + + + {key}: + + {typeof value === 'boolean' ? ( + + {value ? 'true' : 'false'} + + ) : typeof value === 'object' ? ( + {JSON.stringify(value, null, 2)} + ) : ( + String(value) || 'null' + )} + + ))} + + + ); + }; + + 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 ( + + + Loading configuration... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + +
+ Application Configuration + + Current application settings and configuration details. + +
+ + {config && ( + <> + {renderConfigSection('Basic Configuration', basicConfig)} + {renderConfigSection('Security Configuration', securityConfig)} + {renderConfigSection('System Configuration', systemConfig)} + {renderConfigSection('Integration Configuration', integrationConfig)} + + {config.error && ( + + {config.error} + + )} + + )} +
+ ); +}; + +export default Overview; \ No newline at end of file diff --git a/frontend/src/components/shared/config/types.ts b/frontend/src/components/shared/config/types.ts new file mode 100644 index 000000000..2ef734422 --- /dev/null +++ b/frontend/src/components/shared/config/types.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 236cbb49f..40cee33a7 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -1,10 +1,13 @@ import React from "react"; import { Button } from "@mantine/core"; +import { useTranslation } from "react-i18next"; import { Tooltip } from "../../shared/Tooltip"; import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; import { useToolNavigation } from "../../../hooks/useToolNavigation"; import { handleUnlessSpecialClick } from "../../../utils/clickHandlers"; import FitText from "../../shared/FitText"; +import { useHotkeys } from "../../../contexts/HotkeyContext"; +import HotkeyDisplay from "../../hotkeys/HotkeyDisplay"; interface ToolButtonProps { id: string; @@ -17,8 +20,11 @@ interface ToolButtonProps { } const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => { + const { t } = useTranslation(); // Special case: read and multiTool are navigational tools that are always available const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool'; + const { hotkeys } = useHotkeys(); + const binding = hotkeys[id]; const { getToolNavigation } = useToolNavigation(); const handleClick = (id: string) => { @@ -37,7 +43,17 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, const tooltipContent = isUnavailable ? (Coming soon: {tool.description}) - : tool.description; + : ( +
+ {tool.description} + {binding && ( +
+ {t('settings.hotkeys.shortcut', 'Shortcut')} + +
+ )} +
+ ); const buttonContent = ( <> diff --git a/frontend/src/contexts/HotkeyContext.tsx b/frontend/src/contexts/HotkeyContext.tsx new file mode 100644 index 000000000..fe9cbb600 --- /dev/null +++ b/frontend/src/contexts/HotkeyContext.tsx @@ -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; + defaults: Record; + 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(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 => { + const defaults: Record = {}; + 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>(() => { + 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 = {}; + 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 = {}; + 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(() => ({ + 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 ( + + {children} + + ); +}; + +export const useHotkeys = (): HotkeyContextValue => { + const context = useContext(HotkeyContext); + if (!context) { + throw new Error('useHotkeys must be used within a HotkeyProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index e38375daa..98a0fd37d 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -75,7 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { // Tool management (from hook) selectedToolKey: string | null; selectedTool: ToolRegistryEntry | null; - toolRegistry: any; // From useToolManagement + toolRegistry: Record; getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null; // UI Actions @@ -231,7 +231,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // Filter tools based on search query with fuzzy matching (name, description, id, synonyms) const filteredTools = useMemo(() => { if (!toolRegistry) return []; - return filterToolRegistryByQuery(toolRegistry as Record, state.searchQuery); + return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery); }, [toolRegistry, state.searchQuery]); const isPanelVisible = useMemo(() => diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index f283a1caa..4b9514a3c 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -15,6 +15,7 @@ import RightRail from "../components/shared/RightRail"; import FileManager from "../components/FileManager"; import LocalIcon from "../components/shared/LocalIcon"; import { useFilesModalContext } from "../contexts/FilesModalContext"; +import AppConfigModal from "../components/shared/AppConfigModal"; import "./HomePage.css"; @@ -37,6 +38,7 @@ export default function HomePage() { const sliderRef = useRef(null); const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); + const [configModalOpen, setConfigModalOpen] = useState(false); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${ @@ -207,8 +209,20 @@ export default function HomePage() { {t('quickAccess.files', 'Files')} + + setConfigModalOpen(false)} + /> ) : ( = { + 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 => { + return JSON.stringify(bindings); +}; + +export const deserializeBindings = (value: string | null | undefined): Record => { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value) as Record; + 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), +}); \ No newline at end of file From ec05c5c049cc33a5480f77dcee938aaae1608c70 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:29:57 +0100 Subject: [PATCH 2/3] :globe_with_meridians: [V2] Sync Translations + Update README Progress Table (#4569) ### Description of Changes This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made: #### **1. Synchronization of Translation Files** - Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`. - Ensured consistency and synchronization across all supported language files. - Highlighted any missing or incomplete translations. #### **2. Update README.md** - Generated the translation progress table in `README.md`. - Added a summary of the current translation status for all supported languages. - Included up-to-date statistics on translation coverage. #### **Why these changes are necessary** - Keeps translation files aligned with the latest reference updates. - Ensures the documentation reflects the current translation progress. --- Auto-generated by [create-pull-request][1]. [1]: https://github.com/peter-evans/create-pull-request Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- README.md | 48 ++++++++++++++++----------------- scripts/ignore_translation.toml | 2 -- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 77c49ae72..804346223 100644 --- a/README.md +++ b/README.md @@ -115,45 +115,45 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![37%](https://geps.dev/progress/37) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![37%](https://geps.dev/progress/37) | +| Arabic (العربية) (ar_AR) | ![97%](https://geps.dev/progress/97) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![36%](https://geps.dev/progress/36) | | Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) | -| Bulgarian (Български) (bg_BG) | ![40%](https://geps.dev/progress/40) | -| Catalan (Català) (ca_CA) | ![39%](https://geps.dev/progress/39) | -| Croatian (Hrvatski) (hr_HR) | ![36%](https://geps.dev/progress/36) | +| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) | +| Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) | +| Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) | | Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) | | Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) | -| Dutch (Nederlands) (nl_NL) | ![35%](https://geps.dev/progress/35) | +| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![42%](https://geps.dev/progress/42) | -| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) | +| French (Français) (fr_FR) | ![96%](https://geps.dev/progress/96) | +| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) | | Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) | -| Hindi (हिंदी) (hi_IN) | ![40%](https://geps.dev/progress/40) | +| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) | | Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![36%](https://geps.dev/progress/36) | -| Irish (Gaeilge) (ga_IE) | ![40%](https://geps.dev/progress/40) | -| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) | -| Japanese (日本語) (ja_JP) | ![41%](https://geps.dev/progress/41) | -| Korean (한국어) (ko_KR) | ![40%](https://geps.dev/progress/40) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![35%](https://geps.dev/progress/35) | +| Irish (Gaeilge) (ga_IE) | ![39%](https://geps.dev/progress/39) | +| Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) | +| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) | +| Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) | | Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) | -| Persian (فارسی) (fa_IR) | ![39%](https://geps.dev/progress/39) | +| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) | | Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) | -| Portuguese (Português) (pt_PT) | ![40%](https://geps.dev/progress/40) | -| Portuguese Brazilian (Português) (pt_BR) | ![42%](https://geps.dev/progress/42) | +| Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) | +| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) | | Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) | -| Russian (Русский) (ru_RU) | ![43%](https://geps.dev/progress/43) | +| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) | | Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) | -| Simplified Chinese (简体中文) (zh_CN) | ![99%](https://geps.dev/progress/99) | -| Slovakian (Slovensky) (sk_SK) | ![30%](https://geps.dev/progress/30) | -| Slovenian (Slovenščina) (sl_SI) | ![41%](https://geps.dev/progress/41) | -| Spanish (Español) (es_ES) | ![98%](https://geps.dev/progress/98) | +| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) | +| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) | +| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) | +| Spanish (Español) (es_ES) | ![97%](https://geps.dev/progress/97) | | Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) | | Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) | | Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) | | Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) | -| Turkish (Türkçe) (tr_TR) | ![43%](https://geps.dev/progress/43) | -| Ukrainian (Українська) (uk_UA) | ![42%](https://geps.dev/progress/42) | +| Turkish (Türkçe) (tr_TR) | ![42%](https://geps.dev/progress/42) | +| Ukrainian (Українська) (uk_UA) | ![41%](https://geps.dev/progress/41) | | Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) | | Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) | diff --git a/scripts/ignore_translation.toml b/scripts/ignore_translation.toml index 3f03dd0f6..0080ddb5d 100644 --- a/scripts/ignore_translation.toml +++ b/scripts/ignore_translation.toml @@ -192,7 +192,6 @@ ignore = [ 'AddStampRequest.alphabet', 'AddStampRequest.position', 'PDFToBook.selectText.1', - 'addPageNumbers.selectText.3', 'adminUserSettings.team', 'alphabet', 'audit.dashboard.modal.id', @@ -325,7 +324,6 @@ ignore = [ 'AddStampRequest.position', 'AddStampRequest.rotation', 'PDFToBook.selectText.1', - 'addPageNumbers.selectText.3', 'adminUserSettings.actions', 'alphabet', 'compare.document.1', From 510e1c38eb6a86443ae079295ce200ca533e1e00 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:13:54 +0100 Subject: [PATCH 3/3] Fix/v2/automate_settings_gap_fill (#4574) All implemented tools now support automation bar Sign. Sign will need custom automation UI support --------- Co-authored-by: Connor Yoh Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 1 + .../addAttachments/AddAttachmentsSettings.tsx | 119 ++++++++ .../AddPageNumbersAppearanceSettings.tsx | 77 ++++++ .../AddPageNumbersAutomationSettings.tsx | 55 ++++ .../AddPageNumbersPositionSettings.tsx | 70 +++++ .../addStamp/AddStampAutomationSettings.tsx | 43 +++ .../StampPositionFormattingSettings.tsx | 201 ++++++++++++++ .../tools/addStamp/StampSetupSettings.tsx | 112 ++++++++ .../tools/automate/ToolConfigurationModal.tsx | 4 +- .../components/tools/automate/ToolList.tsx | 5 +- .../tools/automate/ToolSelector.tsx | 8 +- .../certSign/CertSignAutomationSettings.tsx | 60 ++++ .../tools/crop/CropAutomationSettings.tsx | 41 +++ .../tools/crop/CropCoordinateInputs.tsx | 101 +++++++ .../components/tools/crop/CropSettings.tsx | 82 ++---- .../tools/removePages/RemovePagesSettings.tsx | 7 +- .../tools/rotate/RotateAutomationSettings.tsx | 43 +++ .../tools/split/SplitAutomationSettings.tsx | 62 +++++ frontend/src/data/toolsTaxonomy.ts | 17 +- .../src/data/useTranslatedToolRegistry.tsx | 130 ++++++--- .../hooks/tools/automate/useAutomationForm.ts | 14 +- .../tools/automate/useSuggestedAutomations.ts | 7 +- frontend/src/hooks/useToolNavigation.ts | 2 +- frontend/src/tools/AddAttachments.tsx | 102 +------ frontend/src/tools/AddPageNumbers.tsx | 105 +------ frontend/src/tools/AddStamp.tsx | 257 ++---------------- frontend/src/utils/automationExecutor.ts | 233 +++++++++------- frontend/src/utils/urlRouting.ts | 20 +- 28 files changed, 1300 insertions(+), 678 deletions(-) create mode 100644 frontend/src/components/tools/addAttachments/AddAttachmentsSettings.tsx create mode 100644 frontend/src/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx create mode 100644 frontend/src/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx create mode 100644 frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx create mode 100644 frontend/src/components/tools/addStamp/AddStampAutomationSettings.tsx create mode 100644 frontend/src/components/tools/addStamp/StampPositionFormattingSettings.tsx create mode 100644 frontend/src/components/tools/addStamp/StampSetupSettings.tsx create mode 100644 frontend/src/components/tools/certSign/CertSignAutomationSettings.tsx create mode 100644 frontend/src/components/tools/crop/CropAutomationSettings.tsx create mode 100644 frontend/src/components/tools/crop/CropCoordinateInputs.tsx create mode 100644 frontend/src/components/tools/rotate/RotateAutomationSettings.tsx create mode 100644 frontend/src/components/tools/split/SplitAutomationSettings.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c6e7a95a6..45d36a371 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -884,6 +884,7 @@ "rotate": { "title": "Rotate PDF", "submit": "Apply Rotation", + "selectRotation": "Select Rotation Angle (Clockwise)", "error": { "failed": "An error occurred while rotating the PDF." }, diff --git a/frontend/src/components/tools/addAttachments/AddAttachmentsSettings.tsx b/frontend/src/components/tools/addAttachments/AddAttachmentsSettings.tsx new file mode 100644 index 000000000..632ed71f1 --- /dev/null +++ b/frontend/src/components/tools/addAttachments/AddAttachmentsSettings.tsx @@ -0,0 +1,119 @@ +/** + * AddAttachmentsSettings - Shared settings component for both tool UI and automation + * + * Allows selecting files to attach to PDFs. + */ + +import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { AddAttachmentsParameters } from "../../../hooks/tools/addAttachments/useAddAttachmentsParameters"; +import LocalIcon from "../../shared/LocalIcon"; + +interface AddAttachmentsSettingsProps { + parameters: AddAttachmentsParameters; + onParameterChange: (key: K, value: AddAttachmentsParameters[K]) => void; + disabled?: boolean; +} + +const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = false }: AddAttachmentsSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + + {t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")} + + + + + + {t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")} + + { + const files = Array.from(e.target.files || []); + // Append to existing attachments instead of replacing + const newAttachments = [...(parameters.attachments || []), ...files]; + onParameterChange('attachments', newAttachments); + // Reset the input so the same file can be selected again + e.target.value = ''; + }} + disabled={disabled} + style={{ display: 'none' }} + id="attachments-input" + /> + + + + {parameters.attachments?.length > 0 && ( + + + {t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({parameters.attachments.length}) + + + + {parameters.attachments.map((file, index) => ( + + + {/* Filename (two-line clamp, wraps, no icon on the left) */} +
+
+ {file.name} +
+
+ + ({(file.size / 1024).toFixed(1)} KB) + +
+ { + const newAttachments = (parameters.attachments || []).filter((_, i) => i !== index); + onParameterChange('attachments', newAttachments); + }} + disabled={disabled} + > + + +
+ ))} +
+
+
+ )} +
+ ); +}; + +export default AddAttachmentsSettings; diff --git a/frontend/src/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx b/frontend/src/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx new file mode 100644 index 000000000..0b7ecb02e --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx @@ -0,0 +1,77 @@ +/** + * AddPageNumbersAppearanceSettings - Customize Appearance step + */ + +import { Stack, Select, TextInput, NumberInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { AddPageNumbersParameters } from "./useAddPageNumbersParameters"; +import { Tooltip } from "../../shared/Tooltip"; + +interface AddPageNumbersAppearanceSettingsProps { + parameters: AddPageNumbersParameters; + onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + disabled?: boolean; +} + +const AddPageNumbersAppearanceSettings = ({ + parameters, + onParameterChange, + disabled = false +}: AddPageNumbersAppearanceSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('fontType', (v as any) || 'Times')} + data={[ + { value: 'Times', label: 'Times Roman' }, + { value: 'Helvetica', label: 'Helvetica' }, + { value: 'Courier', label: 'Courier New' }, + ]} + disabled={disabled} + /> + + + + onParameterChange('customText', e.currentTarget.value)} + placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')} + disabled={disabled} + /> + + + ); +}; + +export default AddPageNumbersAppearanceSettings; diff --git a/frontend/src/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx b/frontend/src/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx new file mode 100644 index 000000000..86888d0e4 --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx @@ -0,0 +1,55 @@ +/** + * AddPageNumbersAutomationSettings - Used for automation only + * + * Combines both position and appearance settings into a single view + */ + +import { Stack, Divider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { AddPageNumbersParameters } from "./useAddPageNumbersParameters"; +import AddPageNumbersPositionSettings from "./AddPageNumbersPositionSettings"; +import AddPageNumbersAppearanceSettings from "./AddPageNumbersAppearanceSettings"; + +interface AddPageNumbersAutomationSettingsProps { + parameters: AddPageNumbersParameters; + onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + disabled?: boolean; +} + +const AddPageNumbersAutomationSettings = ({ + parameters, + onParameterChange, + disabled = false +}: AddPageNumbersAutomationSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {/* Position & Pages Section */} + + {t("addPageNumbers.positionAndPages", "Position & Pages")} + + + + + + {/* Appearance Section */} + + {t("addPageNumbers.customize", "Customize Appearance")} + + + + ); +}; + +export default AddPageNumbersAutomationSettings; diff --git a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx new file mode 100644 index 000000000..be79f4e46 --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx @@ -0,0 +1,70 @@ +/** + * AddPageNumbersPositionSettings - Position & Pages step + */ + +import { Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { AddPageNumbersParameters } from "./useAddPageNumbersParameters"; +import { Tooltip } from "../../shared/Tooltip"; +import PageNumberPreview from "./PageNumberPreview"; + +interface AddPageNumbersPositionSettingsProps { + parameters: AddPageNumbersParameters; + onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + disabled?: boolean; + file?: File | null; + showQuickGrid?: boolean; +} + +const AddPageNumbersPositionSettings = ({ + parameters, + onParameterChange, + disabled = false, + file = null, + showQuickGrid = true +}: AddPageNumbersPositionSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {/* Position Selection */} + + + + + + + {/* Pages & Starting Number Section */} + + {t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')} + + + onParameterChange('pagesToNumber', e.currentTarget.value)} + placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')} + disabled={disabled} + /> + + + + onParameterChange('startingNumber', typeof v === 'number' ? v : 1)} + min={1} + disabled={disabled} + /> + + + + ); +}; + +export default AddPageNumbersPositionSettings; diff --git a/frontend/src/components/tools/addStamp/AddStampAutomationSettings.tsx b/frontend/src/components/tools/addStamp/AddStampAutomationSettings.tsx new file mode 100644 index 000000000..25091d854 --- /dev/null +++ b/frontend/src/components/tools/addStamp/AddStampAutomationSettings.tsx @@ -0,0 +1,43 @@ +/** + * AddStampAutomationSettings - Used for automation only + * + * This component combines all stamp settings into a single step interface + * for use in the automation system. It includes setup and formatting + * settings in one unified component. + */ + +import { Stack } from "@mantine/core"; +import { AddStampParameters } from "./useAddStampParameters"; +import StampSetupSettings from "./StampSetupSettings"; +import StampPositionFormattingSettings from "./StampPositionFormattingSettings"; + +interface AddStampAutomationSettingsProps { + parameters: AddStampParameters; + onParameterChange: (key: K, value: AddStampParameters[K]) => void; + disabled?: boolean; +} + +const AddStampAutomationSettings = ({ parameters, onParameterChange, disabled = false }: AddStampAutomationSettingsProps) => { + return ( + + {/* Stamp Setup (Type, Text/Image, Page Selection) */} + + + {/* Position and Formatting Settings */} + {parameters.stampType && ( + + )} + + ); +}; + +export default AddStampAutomationSettings; diff --git a/frontend/src/components/tools/addStamp/StampPositionFormattingSettings.tsx b/frontend/src/components/tools/addStamp/StampPositionFormattingSettings.tsx new file mode 100644 index 000000000..d1c0d0c9f --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPositionFormattingSettings.tsx @@ -0,0 +1,201 @@ +import { useTranslation } from "react-i18next"; +import { Group, Select, Stack, ColorInput, Button, Slider, Text, NumberInput } from "@mantine/core"; +import { AddStampParameters } from "./useAddStampParameters"; +import LocalIcon from "../../shared/LocalIcon"; +import styles from "./StampPreview.module.css"; +import { Tooltip } from "../../shared/Tooltip"; + +interface StampPositionFormattingSettingsProps { + parameters: AddStampParameters; + onParameterChange: (key: K, value: AddStampParameters[K]) => void; + disabled?: boolean; + showPositionGrid?: boolean; // When true, show the 9-position grid for automation +} + +const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabled = false, showPositionGrid = false }: StampPositionFormattingSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {/* Position Grid - shown in automation settings */} + {showPositionGrid && ( + + {t('AddStampRequest.position', 'Stamp Position')} +
+ {Array.from({ length: 9 }).map((_, i) => { + const idx = (i + 1) as 1|2|3|4|5|6|7|8|9; + const selected = parameters.position === idx; + return ( + + ); + })} +
+
+ )} + {/* Icon pill buttons row */} +
+ + + + + + + + + +
+ + {/* Single slider bound to selected pill */} + {parameters._activePill === 'fontSize' && ( + + + {parameters.stampType === 'image' + ? t('AddStampRequest.imageSize', 'Image Size') + : t('AddStampRequest.fontSize', 'Font Size') + } + + + onParameterChange('fontSize', typeof v === 'number' ? v : 1)} + min={1} + max={400} + step={1} + size="sm" + className={styles.numberInput} + disabled={disabled} + /> + onParameterChange('fontSize', v as number)} + min={1} + max={400} + step={1} + className={styles.slider} + /> + + + )} + {parameters._activePill === 'rotation' && ( + + {t('AddStampRequest.rotation', 'Rotation')} + + onParameterChange('rotation', typeof v === 'number' ? v : 0)} + min={-180} + max={180} + step={1} + size="sm" + className={styles.numberInput} + hideControls + disabled={disabled} + /> + onParameterChange('rotation', v as number)} + min={-180} + max={180} + step={1} + className={styles.sliderWide} + /> + + + )} + {parameters._activePill === 'opacity' && ( + + {t('AddStampRequest.opacity', 'Opacity')} + + onParameterChange('opacity', typeof v === 'number' ? v : 0)} + min={0} + max={100} + step={1} + size="sm" + className={styles.numberInput} + disabled={disabled} + /> + onParameterChange('opacity', v as number)} + min={0} + max={100} + step={1} + className={styles.slider} + /> + + + )} + + {parameters.stampType !== 'image' && ( + onParameterChange('customColor', value)} + format="hex" + disabled={disabled} + /> + )} + + {/* Margin selection for text stamps */} + {parameters.stampType === 'text' && ( +