Merge branch 'V2' into feature/v2/more-landing-zones

This commit is contained in:
Reece Browne 2025-10-02 10:37:15 +01:00 committed by GitHub
commit 136dc59d27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2476 additions and 832 deletions

View File

@ -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) |

View File

@ -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": {
@ -868,6 +884,7 @@
"rotate": {
"title": "Rotate PDF",
"submit": "Apply Rotation",
"selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},

View File

@ -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() {
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>

View 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;

View 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;
}

View File

@ -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<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) => {
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 (
<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 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 (
<Modal
opened={opened}
onClose={onClose}
title="App Configuration (Testing)"
size="lg"
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
style={{ zIndex: 1000 }}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
This modal shows the current application configuration for testing purposes only.
</Text>
<Button size="xs" variant="light" onClick={refetch}>
Refresh
</Button>
</Group>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<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 && (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
)}
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{config && (
<ScrollArea h={400}>
<Stack gap="lg">
{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 && (
<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>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
</div>
</div>
</div>
</div>
</Modal>
);
};

View File

@ -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<HTMLDivElement>((_, ref) => {
<div className="spacer" />
{/* Config button at the bottom */}
{/* {buttonConfigs
{buttonConfigs
.filter(config => config.id === 'config')
.map(config => (
<div key={config.id} className="flex flex-col items-center gap-1">
@ -237,14 +238,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{config.name}
</span>
</div>
))} */}
))}
</div>
</div>
{/* <AppConfigModal
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/> */}
/>
</div>
);
});

View 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;
};

View File

@ -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;

View File

@ -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;

View 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

View File

@ -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: <K extends keyof AddAttachmentsParameters>(key: K, value: AddAttachmentsParameters[K]) => void;
disabled?: boolean;
}
const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = false }: AddAttachmentsSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
onChange={(e) => {
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"
/>
<Button
size="xs"
color="blue"
component="label"
htmlFor="attachments-input"
disabled={disabled}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
>
{parameters.attachments?.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")
}
</Button>
</Stack>
{parameters.attachments?.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({parameters.attachments.length})
</Text>
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
<Stack gap="xs">
{parameters.attachments.map((file, index) => (
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
{/* Filename (two-line clamp, wraps, no icon on the left) */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 400,
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2 as any,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
style={{ flexShrink: 0 }}
onClick={() => {
const newAttachments = (parameters.attachments || []).filter((_, i) => i !== index);
onParameterChange('attachments', newAttachments);
}}
disabled={disabled}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
);
};
export default AddAttachmentsSettings;

View File

@ -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: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
disabled?: boolean;
}
const AddPageNumbersAppearanceSettings = ({
parameters,
onParameterChange,
disabled = false
}: AddPageNumbersAppearanceSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
<Select
label={t('addPageNumbers.selectText.2', 'Margin')}
value={parameters.customMargin}
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('sizes.small', 'Small') },
{ value: 'medium', label: t('sizes.medium', 'Medium') },
{ value: 'large', label: t('sizes.large', 'Large') },
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
]}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
<NumberInput
label={t('addPageNumbers.fontSize', 'Font Size')}
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 12)}
min={1}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
<Select
label={t('addPageNumbers.fontName', 'Font Type')}
value={parameters.fontType}
onChange={(v) => onParameterChange('fontType', (v as any) || 'Times')}
data={[
{ value: 'Times', label: 'Times Roman' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Courier', label: 'Courier New' },
]}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
<TextInput
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
value={parameters.customText || ''}
onChange={(e) => onParameterChange('customText', e.currentTarget.value)}
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
disabled={disabled}
/>
</Tooltip>
</Stack>
);
};
export default AddPageNumbersAppearanceSettings;

View File

@ -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: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
disabled?: boolean;
}
const AddPageNumbersAutomationSettings = ({
parameters,
onParameterChange,
disabled = false
}: AddPageNumbersAutomationSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
{/* Position & Pages Section */}
<Stack gap="md">
<Text size="sm" fw={600}>{t("addPageNumbers.positionAndPages", "Position & Pages")}</Text>
<AddPageNumbersPositionSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
file={null}
showQuickGrid={true}
/>
</Stack>
<Divider />
{/* Appearance Section */}
<Stack gap="md">
<Text size="sm" fw={600}>{t("addPageNumbers.customize", "Customize Appearance")}</Text>
<AddPageNumbersAppearanceSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
</Stack>
);
};
export default AddPageNumbersAutomationSettings;

View File

@ -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: <K extends keyof AddPageNumbersParameters>(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 (
<Stack gap="lg">
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
parameters={parameters}
onParameterChange={onParameterChange}
file={file}
showQuickGrid={showQuickGrid}
/>
</Stack>
<Divider />
{/* Pages & Starting Number Section */}
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={parameters.pagesToNumber || ''}
onChange={(e) => onParameterChange('pagesToNumber', e.currentTarget.value)}
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
<NumberInput
label={t('addPageNumbers.selectText.4', 'Starting Number')}
value={parameters.startingNumber}
onChange={(v) => onParameterChange('startingNumber', typeof v === 'number' ? v : 1)}
min={1}
disabled={disabled}
/>
</Tooltip>
</Stack>
</Stack>
);
};
export default AddPageNumbersPositionSettings;

View File

@ -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: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
}
const AddStampAutomationSettings = ({ parameters, onParameterChange, disabled = false }: AddStampAutomationSettingsProps) => {
return (
<Stack gap="lg">
{/* Stamp Setup (Type, Text/Image, Page Selection) */}
<StampSetupSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
{/* Position and Formatting Settings */}
{parameters.stampType && (
<StampPositionFormattingSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
showPositionGrid={true}
/>
)}
</Stack>
);
};
export default AddStampAutomationSettings;

View File

@ -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: <K extends keyof AddStampParameters>(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 (
<Stack gap="md" justify="space-between">
{/* Position Grid - shown in automation settings */}
{showPositionGrid && (
<Stack gap="xs">
<Text size="sm" fw={500}>{t('AddStampRequest.position', 'Stamp Position')}</Text>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '0.5rem',
maxWidth: '200px'
}}>
{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 (
<Button
key={idx}
variant={selected ? 'filled' : 'outline'}
onClick={() => {
onParameterChange('position', idx);
// Ensure we're using grid positioning, not custom overrides
onParameterChange('overrideX', -1 as any);
onParameterChange('overrideY', -1 as any);
}}
disabled={disabled}
styles={{
root: {
height: '50px',
padding: '0',
}
}}
>
{idx}
</Button>
);
})}
</div>
</Stack>
)}
{/* Icon pill buttons row */}
<div className="flex justify-between gap-[0.5rem]">
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
<Button
variant={parameters._activePill === 'rotation' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => onParameterChange('_activePill', 'rotation')}
>
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
<Button
variant={parameters._activePill === 'opacity' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => onParameterChange('_activePill', 'opacity')}
>
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
<Button
variant={parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => onParameterChange('_activePill', 'fontSize')}
>
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
</div>
{/* Single slider bound to selected pill */}
{parameters._activePill === 'fontSize' && (
<Stack gap="xs">
<Text className={styles.labelText}>
{parameters.stampType === 'image'
? t('AddStampRequest.imageSize', 'Image Size')
: t('AddStampRequest.fontSize', 'Font Size')
}
</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 1)}
min={1}
max={400}
step={1}
size="sm"
className={styles.numberInput}
disabled={disabled}
/>
<Slider
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', v as number)}
min={1}
max={400}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{parameters._activePill === 'rotation' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.rotation}
onChange={(v) => onParameterChange('rotation', typeof v === 'number' ? v : 0)}
min={-180}
max={180}
step={1}
size="sm"
className={styles.numberInput}
hideControls
disabled={disabled}
/>
<Slider
value={parameters.rotation}
onChange={(v) => onParameterChange('rotation', v as number)}
min={-180}
max={180}
step={1}
className={styles.sliderWide}
/>
</Group>
</Stack>
)}
{parameters._activePill === 'opacity' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.opacity}
onChange={(v) => onParameterChange('opacity', typeof v === 'number' ? v : 0)}
min={0}
max={100}
step={1}
size="sm"
className={styles.numberInput}
disabled={disabled}
/>
<Slider
value={parameters.opacity}
onChange={(v) => onParameterChange('opacity', v as number)}
min={0}
max={100}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{parameters.stampType !== 'image' && (
<ColorInput
label={t('AddStampRequest.customColor', 'Custom Text Color')}
value={parameters.customColor}
onChange={(value) => onParameterChange('customColor', value)}
format="hex"
disabled={disabled}
/>
)}
{/* Margin selection for text stamps */}
{parameters.stampType === 'text' && (
<Select
label={t('AddStampRequest.margin', 'Margin')}
value={parameters.customMargin}
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('margin.small', 'Small') },
{ value: 'medium', label: t('margin.medium', 'Medium') },
{ value: 'large', label: t('margin.large', 'Large') },
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
]}
disabled={disabled}
/>
)}
</Stack>
);
};
export default StampPositionFormattingSettings;

View File

@ -0,0 +1,112 @@
import { useTranslation } from "react-i18next";
import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
import { AddStampParameters } from "./useAddStampParameters";
import ButtonSelector from "../../shared/ButtonSelector";
import styles from "./StampPreview.module.css";
import { getDefaultFontSizeForAlphabet } from "./StampPreviewUtils";
interface StampSetupSettingsProps {
parameters: AddStampParameters;
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
}
const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: StampSetupSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<TextInput
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
value={parameters.pageNumbers}
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
disabled={disabled}
/>
<Divider/>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
<ButtonSelector
value={parameters.stampType}
onChange={(v: 'text' | 'image') => onParameterChange('stampType', v)}
options={[
{ value: 'text', label: t('watermark.type.1', 'Text') },
{ value: 'image', label: t('watermark.type.2', 'Image') },
]}
disabled={disabled}
buttonClassName={styles.modeToggleButton}
textClassName={styles.modeToggleButtonText}
/>
</div>
{parameters.stampType === 'text' && (
<>
<Textarea
label={t('AddStampRequest.stampText', 'Stamp Text')}
value={parameters.stampText}
onChange={(e) => onParameterChange('stampText', e.currentTarget.value)}
autosize
minRows={2}
disabled={disabled}
/>
<Select
label={t('AddStampRequest.alphabet', 'Alphabet')}
value={parameters.alphabet}
onChange={(v) => {
const nextAlphabet = (v as any) || 'roman';
onParameterChange('alphabet', nextAlphabet);
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
onParameterChange('fontSize', nextDefault);
}}
data={[
{ value: 'roman', label: 'Roman' },
{ value: 'arabic', label: 'العربية' },
{ value: 'japanese', label: '日本語' },
{ value: 'korean', label: '한국어' },
{ value: 'chinese', label: '简体中文' },
{ value: 'thai', label: 'ไทย' },
]}
disabled={disabled}
/>
</>
)}
{parameters.stampType === 'image' && (
<Stack gap="xs">
<input
type="file"
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onParameterChange('stampImage', file);
}}
disabled={disabled}
style={{ display: 'none' }}
id="stamp-image-input"
/>
<Button
size="xs"
component="label"
htmlFor="stamp-image-input"
disabled={disabled}
>
{t('chooseFile', 'Choose File')}
</Button>
{parameters.stampImage && (
<Stack gap="xs">
<img
src={URL.createObjectURL(parameters.stampImage)}
alt="Selected stamp image"
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
/>
<Text size="xs" c="dimmed">
{parameters.stampImage.name}
</Text>
</Stack>
)}
</Stack>
)}
</Stack>
);
};
export default StampSetupSettings;

View File

@ -35,7 +35,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
const SettingsComponent = toolInfo?.settingsComponent;
const SettingsComponent = toolInfo?.automationSettings;
// Initialize parameters from tool (which should contain defaults from registry)
useEffect(() => {
@ -109,7 +109,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
</Text>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
<div style={{ maxHeight: '60vh', overflowY: 'auto', overflowX: "hidden" }}>
{renderToolSettings()}
</div>

View File

@ -34,11 +34,14 @@ export default function ToolList({
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
const toolEntry = toolRegistry[newOperation];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
onToolUpdate(index, {
operation: newOperation,
name: getToolName(newOperation),
configured: false,
configured: isConfigured,
parameters: defaultParams,
});
};

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolRegistryEntry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
@ -28,9 +28,11 @@ export default function ToolSelector({
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Filter out excluded tools (like 'automate' itself)
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
const baseFilteredTools = useMemo(() => {
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
return Object.entries(toolRegistry).filter(([key, tool]) =>
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
);
}, [toolRegistry, excludeTools]);
// Apply search filter

View File

@ -0,0 +1,60 @@
/**
* CertSignAutomationSettings - Used for automation only
*
* This component combines all certificate signing settings into a single step interface
* for use in the automation system. It includes sign mode, certificate format, certificate files,
* and signature appearance settings in one unified component.
*/
import { Stack } from "@mantine/core";
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
import CertificateTypeSettings from "./CertificateTypeSettings";
import CertificateFormatSettings from "./CertificateFormatSettings";
import CertificateFilesSettings from "./CertificateFilesSettings";
import SignatureAppearanceSettings from "./SignatureAppearanceSettings";
interface CertSignAutomationSettingsProps {
parameters: CertSignParameters;
onParameterChange: <K extends keyof CertSignParameters>(key: K, value: CertSignParameters[K]) => void;
disabled?: boolean;
}
const CertSignAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CertSignAutomationSettingsProps) => {
return (
<Stack gap="lg">
{/* Sign Mode Selection (Manual vs Auto) */}
<CertificateTypeSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
{/* Certificate Format - only show for Manual mode */}
{parameters.signMode === 'MANUAL' && (
<CertificateFormatSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Certificate Files - only show for Manual mode */}
{parameters.signMode === 'MANUAL' && (
<CertificateFilesSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Signature Appearance Settings */}
<SignatureAppearanceSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
);
};
export default CertSignAutomationSettings;

View File

@ -0,0 +1,41 @@
/**
* CropAutomationSettings - Used for automation only
*
* Simplified crop settings for automation that doesn't require a file preview.
* Allows users to manually enter crop coordinates and dimensions.
*/
import { Stack } from "@mantine/core";
import { CropParameters } from "../../../hooks/tools/crop/useCropParameters";
import { Rectangle } from "../../../utils/cropCoordinates";
import CropCoordinateInputs from "./CropCoordinateInputs";
interface CropAutomationSettingsProps {
parameters: CropParameters;
onParameterChange: <K extends keyof CropParameters>(key: K, value: CropParameters[K]) => void;
disabled?: boolean;
}
const CropAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CropAutomationSettingsProps) => {
// Handle coordinate changes
const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) return;
const newCropArea = { ...parameters.cropArea, [field]: numValue };
onParameterChange('cropArea', newCropArea);
};
return (
<Stack gap="md">
<CropCoordinateInputs
cropArea={parameters.cropArea}
onCoordinateChange={handleCoordinateChange}
disabled={disabled}
showAutomationInfo={true}
/>
</Stack>
);
};
export default CropAutomationSettings;

View File

@ -0,0 +1,101 @@
import { Stack, Text, Group, NumberInput, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Rectangle, PDFBounds } from "../../../utils/cropCoordinates";
interface CropCoordinateInputsProps {
cropArea: Rectangle;
onCoordinateChange: (field: keyof Rectangle, value: number | string) => void;
disabled?: boolean;
pdfBounds?: PDFBounds;
showAutomationInfo?: boolean;
}
const CropCoordinateInputs = ({
cropArea,
onCoordinateChange,
disabled = false,
pdfBounds,
showAutomationInfo = false
}: CropCoordinateInputsProps) => {
const { t } = useTranslation();
return (
<Stack gap="xs">
{showAutomationInfo && (
<Alert color="blue" variant="light">
<Text size="xs">
{t("crop.automation.info", "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.")}
</Text>
</Alert>
)}
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Position and Size")}
</Text>
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X Position")}
description={showAutomationInfo ? t("crop.coordinates.x.desc", "Left edge (points)") : undefined}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => onCoordinateChange('x', value)}
disabled={disabled}
min={0}
max={pdfBounds?.actualWidth}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.y", "Y Position")}
description={showAutomationInfo ? t("crop.coordinates.y.desc", "Bottom edge (points)") : undefined}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => onCoordinateChange('y', value)}
disabled={disabled}
min={0}
max={pdfBounds?.actualHeight}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
</Group>
<Group grow>
<NumberInput
label={t("crop.coordinates.width", "Width")}
description={showAutomationInfo ? t("crop.coordinates.width.desc", "Crop width (points)") : undefined}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => onCoordinateChange('width', value)}
disabled={disabled}
min={0.1}
max={pdfBounds?.actualWidth}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.height", "Height")}
description={showAutomationInfo ? t("crop.coordinates.height.desc", "Crop height (points)") : undefined}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => onCoordinateChange('height', value)}
disabled={disabled}
min={0.1}
max={pdfBounds?.actualHeight}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
</Group>
{showAutomationInfo && (
<Alert color="gray" variant="light">
<Text size="xs">
{t("crop.automation.reference", "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points.")}
</Text>
</Alert>
)}
</Stack>
);
};
export default CropCoordinateInputs;

View File

@ -1,10 +1,11 @@
import { useMemo, useState, useEffect } from "react";
import { Stack, Text, Box, Group, NumberInput, ActionIcon, Center, Alert } from "@mantine/core";
import { Stack, Text, Box, Group, ActionIcon, Center, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters";
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
import CropAreaSelector from "./CropAreaSelector";
import CropCoordinateInputs from "./CropCoordinateInputs";
import { DEFAULT_CROP_AREA } from "../../../constants/cropConstants";
import { PAGE_SIZES } from "../../../constants/pageSizeConstants";
import {
@ -190,71 +191,22 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
</Stack>
{/* Manual Coordinate Input */}
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Position and Size")}
</Text>
<CropCoordinateInputs
cropArea={cropArea}
onCoordinateChange={handleCoordinateChange}
disabled={disabled}
pdfBounds={pdfBounds}
showAutomationInfo={false}
/>
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X Position")}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => handleCoordinateChange('x', value)}
disabled={disabled}
min={0}
max={pdfBounds.actualWidth}
step={0.1}
decimalScale={1}
size="xs"
/>
<NumberInput
label={t("crop.coordinates.y", "Y Position")}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => handleCoordinateChange('y', value)}
disabled={disabled}
min={0}
max={pdfBounds.actualHeight}
step={0.1}
decimalScale={1}
size="xs"
/>
</Group>
<Group grow>
<NumberInput
label={t("crop.coordinates.width", "Width")}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => handleCoordinateChange('width', value)}
disabled={disabled}
min={0.1}
max={pdfBounds.actualWidth}
step={0.1}
decimalScale={1}
size="xs"
/>
<NumberInput
label={t("crop.coordinates.height", "Height")}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => handleCoordinateChange('height', value)}
disabled={disabled}
min={0.1}
max={pdfBounds.actualHeight}
step={0.1}
decimalScale={1}
size="xs"
/>
</Group>
{/* Validation Alert */}
{!isCropValid && (
<Alert color="red" variant="light">
<Text size="xs">
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
</Text>
</Alert>
)}
</Stack>
{/* Validation Alert */}
{!isCropValid && (
<Alert color="red" variant="light">
<Text size="xs">
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
</Text>
</Alert>
)}
</Stack>
);
};

View File

@ -16,16 +16,17 @@ const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }
// Allow user to type naturally - don't normalize input in real-time
onParameterChange('pageNumbers', value);
};
console.log('Current pageNumbers input:', parameters.pageNumbers, disabled);
// Check if current input is valid
const isValid = validatePageNumbers(parameters.pageNumbers);
const hasValue = parameters.pageNumbers.trim().length > 0;
const isValid = validatePageNumbers(parameters.pageNumbers || '');
const hasValue = (parameters?.pageNumbers?.trim().length ?? 0) > 0;
return (
<Stack gap="md">
<TextInput
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
value={parameters.pageNumbers}
value={parameters.pageNumbers || ''}
onChange={(event) => handlePageNumbersChange(event.currentTarget.value)}
placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')}
disabled={disabled}

View File

@ -0,0 +1,43 @@
/**
* RotateAutomationSettings - Used for automation only
*
* Simplified rotation settings for automation that allows selecting
* one of four 90-degree rotation angles.
*/
import { Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RotateParameters } from "../../../hooks/tools/rotate/useRotateParameters";
import ButtonSelector from "../../shared/ButtonSelector";
interface RotateAutomationSettingsProps {
parameters: RotateParameters;
onParameterChange: <K extends keyof RotateParameters>(key: K, value: RotateParameters[K]) => void;
disabled?: boolean;
}
const RotateAutomationSettings = ({ parameters, onParameterChange, disabled = false }: RotateAutomationSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Text size="sm" fw={500}>
{t("rotate.selectRotation", "Select Rotation Angle (Clockwise)")}
</Text>
<ButtonSelector
value={parameters.angle}
onChange={(value: number) => onParameterChange('angle', value)}
options={[
{ value: 0, label: "0°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 270, label: "270°" },
]}
disabled={disabled}
/>
</Stack>
);
};
export default RotateAutomationSettings;

View File

@ -0,0 +1,62 @@
/**
* SplitAutomationSettings - Used for automation only
*
* Combines split method selection and method-specific settings
* into a single component for automation workflows.
*/
import { Stack, Text, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SplitParameters } from "../../../hooks/tools/split/useSplitParameters";
import { METHOD_OPTIONS, SplitMethod } from "../../../constants/splitConstants";
import SplitSettings from "./SplitSettings";
interface SplitAutomationSettingsProps {
parameters: SplitParameters;
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
disabled?: boolean;
}
const SplitAutomationSettings = ({ parameters, onParameterChange, disabled = false }: SplitAutomationSettingsProps) => {
const { t } = useTranslation();
// Convert METHOD_OPTIONS to Select data format
const methodSelectOptions = METHOD_OPTIONS.map((option) => {
const prefix = t(option.prefixKey, "Split");
const name = t(option.nameKey, "Method");
return {
value: option.value,
label: `${prefix} ${name}`,
};
});
return (
<Stack gap="lg">
{/* Method Selection */}
<Select
label={t("split.steps.chooseMethod", "Choose Method")}
placeholder={t("split.selectMethod", "Select a split method")}
value={parameters.method}
onChange={(value) => onParameterChange('method', value as (SplitMethod | '') || '')}
data={methodSelectOptions}
disabled={disabled}
/>
{/* Method-Specific Settings */}
{parameters.method && (
<>
<Text size="sm" fw={500}>
{t("split.steps.settings", "Settings")}
</Text>
<SplitSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
</Stack>
);
};
export default SplitAutomationSettings;

View File

@ -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<ToolButtonProps> = ({ 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<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const tooltipContent = isUnavailable
? (<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 = (
<>

View 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;
};

View File

@ -75,7 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: any; // From useToolManagement
toolRegistry: Record<string, ToolRegistryEntry>;
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<string, ToolRegistryEntry>, state.searchQuery);
return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery);
}, [toolRegistry, state.searchQuery]);
const isPanelVisible = useMemo(() =>

View File

@ -37,14 +37,14 @@ export type ToolRegistryEntry = {
endpoints?: string[];
link?: string;
type?: string;
// URL path for routing (e.g., '/split-pdfs', '/compress-pdf')
urlPath?: string;
// Workbench type for navigation
workbench?: WorkbenchType;
// Operation configuration for automation
operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration
settingsComponent?: React.ComponentType<any>;
automationSettings: React.ComponentType<any> | null;
// Whether this tool supports automation (defaults to true)
supportsAutomate?: boolean;
// Synonyms for search (optional)
synonyms?: string[];
}
@ -130,8 +130,8 @@ export const getToolWorkbench = (tool: ToolRegistryEntry): WorkbenchType => {
/**
* Get URL path for a tool
*/
export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string => {
return tool.urlPath || `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
export const getToolUrlPath = (toolId: string): string => {
return `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
};
/**
@ -140,3 +140,10 @@ export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string
export const isValidToolId = (toolId: string, registry: ToolRegistry): boolean => {
return toolId in registry;
};
/**
* Check if a tool supports automation (defaults to true)
*/
export const getToolSupportsAutomate = (tool: ToolRegistryEntry): boolean => {
return tool.supportsAutomate !== false;
};

View File

@ -61,22 +61,19 @@ import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
import { removeAnnotationsOperationConfig } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import { removePagesOperationConfig } from "../hooks/tools/removePages/useRemovePagesOperation";
import { removeBlanksOperationConfig } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
import RepairSettings from "../components/tools/repair/RepairSettings";
import UnlockPdfFormsSettings from "../components/tools/unlockPdfForms/UnlockPdfFormsSettings";
import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/AddWatermarkSingleStepSettings";
import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings";
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import RotateSettings from "../components/tools/rotate/RotateSettings";
import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import ReplaceColor from "../tools/ReplaceColor";
@ -89,15 +86,22 @@ import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustP
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
import SignSettings from "../components/tools/sign/SignSettings";
import CropSettings from "../components/tools/crop/CropSettings";
import AddPageNumbers from "../tools/AddPageNumbers";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import RemoveAnnotations from "../tools/RemoveAnnotations";
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings";
import ExtractImages from "../tools/ExtractImages";
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
import AddStampAutomationSettings from "../components/tools/addStamp/AddStampAutomationSettings";
import CertSignAutomationSettings from "../components/tools/certSign/CertSignAutomationSettings";
import CropAutomationSettings from "../components/tools/crop/CropAutomationSettings";
import RotateAutomationSettings from "../components/tools/rotate/RotateAutomationSettings";
import SplitAutomationSettings from "../components/tools/split/SplitAutomationSettings";
import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttachmentsSettings";
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -199,6 +203,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
synonyms: getSynonyms(t, "multiTool"),
supportsAutomate: false,
automationSettings: null
},
merge: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
@ -210,7 +216,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings,
automationSettings: MergeSettings,
synonyms: getSynonyms(t, "merge")
},
// Signing
@ -225,7 +231,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["cert-sign"],
operationConfig: certSignOperationConfig,
settingsComponent: CertificateTypeSettings,
automationSettings: CertSignAutomationSettings,
},
sign: {
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
@ -235,8 +241,9 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING,
operationConfig: signOperationConfig,
settingsComponent: SignSettings,
synonyms: getSynonyms(t, "sign")
automationSettings: SignSettings, // TODO:: not all settings shown, suggested next tools shown
synonyms: getSynonyms(t, "sign"),
supportsAutomate: false, //TODO make support Sign
},
// Document Security
@ -251,7 +258,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["add-password"],
operationConfig: addPasswordOperationConfig,
settingsComponent: AddPasswordSettings,
automationSettings: AddPasswordSettings,
synonyms: getSynonyms(t, "addPassword")
},
watermark: {
@ -264,7 +271,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"],
operationConfig: addWatermarkOperationConfig,
settingsComponent: AddWatermarkSingleStepSettings,
automationSettings: AddWatermarkSingleStepSettings,
synonyms: getSynonyms(t, "watermark")
},
addStamp: {
@ -278,6 +285,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["add-stamp"],
operationConfig: addStampOperationConfig,
automationSettings: AddStampAutomationSettings,
},
sanitize: {
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,
@ -289,7 +297,7 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
endpoints: ["sanitize-pdf"],
operationConfig: sanitizeOperationConfig,
settingsComponent: SanitizeSettings,
automationSettings: SanitizeSettings,
synonyms: getSynonyms(t, "sanitize")
},
flatten: {
@ -302,7 +310,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["flatten"],
operationConfig: flattenOperationConfig,
settingsComponent: FlattenSettings,
automationSettings: FlattenSettings,
synonyms: getSynonyms(t, "flatten")
},
unlockPDFForms: {
@ -315,8 +323,8 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["unlock-pdf-forms"],
operationConfig: unlockPdfFormsOperationConfig,
settingsComponent: UnlockPdfFormsSettings,
synonyms: getSynonyms(t, "unlockPDFForms"),
automationSettings: null
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@ -328,7 +336,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["add-password"],
operationConfig: changePermissionsOperationConfig,
settingsComponent: ChangePermissionsSettings,
automationSettings: ChangePermissionsSettings,
synonyms: getSynonyms(t, "changePermissions"),
},
getPdfInfo: {
@ -339,6 +347,8 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false,
automationSettings: null
},
validateSignature: {
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
@ -348,6 +358,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
synonyms: getSynonyms(t, "validateSignature"),
automationSettings: null
},
// Document Review
@ -363,7 +374,9 @@ export function useFlatToolRegistry(): ToolRegistry {
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
synonyms: getSynonyms(t, "read")
synonyms: getSynonyms(t, "read"),
supportsAutomate: false,
automationSettings: null
},
changeMetadata: {
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
@ -375,7 +388,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["update-metadata"],
operationConfig: changeMetadataOperationConfig,
settingsComponent: ChangeMetadataSingleStep,
automationSettings: ChangeMetadataSingleStep,
synonyms: getSynonyms(t, "changeMetadata")
},
// Page Formatting
@ -390,7 +403,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["crop"],
operationConfig: cropOperationConfig,
settingsComponent: CropSettings,
automationSettings: CropAutomationSettings,
},
rotate: {
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
@ -402,7 +415,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["rotate-pdf"],
operationConfig: rotateOperationConfig,
settingsComponent: RotateSettings,
automationSettings: RotateAutomationSettings,
synonyms: getSynonyms(t, "rotate")
},
split: {
@ -413,7 +426,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
operationConfig: splitOperationConfig,
settingsComponent: SplitSettings,
automationSettings: SplitAutomationSettings,
synonyms: getSynonyms(t, "split")
},
reorganizePages: {
@ -428,7 +441,9 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["rearrange-pages"],
operationConfig: reorganizePagesOperationConfig,
synonyms: getSynonyms(t, "reorganizePages")
synonyms: getSynonyms(t, "reorganizePages"),
automationSettings: null
},
scalePages: {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
@ -440,7 +455,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
settingsComponent: AdjustPageScaleSettings,
automationSettings: AdjustPageScaleSettings,
synonyms: getSynonyms(t, "scalePages")
},
addPageNumbers: {
@ -450,6 +465,7 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
automationSettings: AddPageNumbersAutomationSettings,
maxFiles: -1,
endpoints: ["add-page-numbers"],
operationConfig: addPageNumbersOperationConfig,
@ -464,7 +480,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["multi-page-layout"],
settingsComponent: PageLayoutSettings,
automationSettings: PageLayoutSettings,
synonyms: getSynonyms(t, "pageLayout")
},
bookletImposition: {
@ -472,7 +488,7 @@ export function useFlatToolRegistry(): ToolRegistry {
name: t("home.bookletImposition.title", "Booklet Imposition"),
component: BookletImposition,
operationConfig: bookletImpositionOperationConfig,
settingsComponent: BookletImpositionSettings,
automationSettings: BookletImpositionSettings,
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
@ -487,10 +503,10 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
urlPath: '/pdf-to-single-page',
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig,
synonyms: getSynonyms(t, "pdfToSinglePage")
synonyms: getSynonyms(t, "pdfToSinglePage"),
automationSettings: null,
},
addAttachments: {
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
@ -503,6 +519,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: 1,
endpoints: ["add-attachments"],
operationConfig: addAttachmentsOperationConfig,
automationSettings: AddAttachmentsSettings,
},
// Extraction
@ -514,7 +531,8 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION,
synonyms: getSynonyms(t, "extractPages")
synonyms: getSynonyms(t, "extractPages"),
automationSettings: null,
},
extractImages: {
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,
@ -526,7 +544,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["extract-images"],
operationConfig: extractImagesOperationConfig,
settingsComponent: ExtractImagesSettings,
automationSettings: ExtractImagesSettings,
synonyms: getSynonyms(t, "extractImages")
},
@ -541,7 +559,9 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-pages"],
synonyms: getSynonyms(t, "removePages")
synonyms: getSynonyms(t, "removePages"),
operationConfig: removePagesOperationConfig,
automationSettings: RemovePagesSettings,
},
removeBlanks: {
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
@ -552,7 +572,9 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-blanks"],
synonyms: getSynonyms(t, "removeBlanks")
synonyms: getSynonyms(t, "removeBlanks"),
operationConfig: removeBlanksOperationConfig,
automationSettings: RemoveBlanksSettings,
},
removeAnnotations: {
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
@ -563,7 +585,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
operationConfig: removeAnnotationsOperationConfig,
settingsComponent: RemoveAnnotationsSettings,
automationSettings: null,
synonyms: getSynonyms(t, "removeAnnotations")
},
removeImage: {
@ -577,6 +599,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["remove-image-pdf"],
operationConfig: undefined,
synonyms: getSynonyms(t, "removeImage"),
automationSettings: null,
},
removePassword: {
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
@ -588,7 +611,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["remove-password"],
maxFiles: -1,
operationConfig: removePasswordOperationConfig,
settingsComponent: RemovePasswordSettings,
automationSettings: RemovePasswordSettings,
synonyms: getSynonyms(t, "removePassword")
},
removeCertSign: {
@ -602,6 +625,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["remove-certificate-sign"],
operationConfig: removeCertificateSignOperationConfig,
synonyms: getSynonyms(t, "removeCertSign"),
automationSettings: null,
},
// Automation
@ -620,6 +644,7 @@ export function useFlatToolRegistry(): ToolRegistry {
supportedFormats: CONVERT_SUPPORTED_FORMATS,
endpoints: ["handleData"],
synonyms: getSynonyms(t, "automate"),
automationSettings: null,
},
autoRename: {
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
@ -632,6 +657,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
synonyms: getSynonyms(t, "autoRename"),
automationSettings: null,
},
// Advanced Formatting
@ -644,6 +670,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "adjustContrast"),
automationSettings: null,
},
repair: {
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
@ -655,8 +682,8 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["repair"],
operationConfig: repairOperationConfig,
settingsComponent: RepairSettings,
synonyms: getSynonyms(t, "repair")
synonyms: getSynonyms(t, "repair"),
automationSettings: null
},
scannerImageSplit: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
@ -668,7 +695,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["extract-image-scans"],
operationConfig: scannerImageSplitOperationConfig,
settingsComponent: ScannerImageSplitSettings,
automationSettings: ScannerImageSplitSettings,
synonyms: getSynonyms(t, "ScannerImageSplit"),
},
overlayPdfs: {
@ -679,6 +706,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "overlayPdfs"),
automationSettings: null
},
replaceColor: {
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
@ -690,7 +718,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["replace-invert-pdf"],
operationConfig: replaceColorOperationConfig,
settingsComponent: ReplaceColorSettings,
automationSettings: ReplaceColorSettings,
synonyms: getSynonyms(t, "replaceColor"),
},
addImage: {
@ -701,6 +729,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
editTableOfContents: {
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
@ -710,6 +739,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "editTableOfContents"),
automationSettings: null
},
scannerEffect: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
@ -719,6 +749,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "scannerEffect"),
automationSettings: null
},
// Developer Tools
@ -731,6 +762,8 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
synonyms: getSynonyms(t, "showJS"),
supportsAutomate: false,
automationSettings: null
},
devApi: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@ -741,6 +774,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
synonyms: getSynonyms(t, "devApi"),
supportsAutomate: false,
automationSettings: null
},
devFolderScanning: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@ -751,6 +786,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/",
synonyms: getSynonyms(t, "devFolderScanning"),
supportsAutomate: false,
automationSettings: null
},
devSsoGuide: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@ -761,6 +798,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
synonyms: getSynonyms(t, "devSsoGuide"),
supportsAutomate: false,
automationSettings: null
},
devAirgapped: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@ -771,6 +810,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Pro/#activation",
synonyms: getSynonyms(t, "devAirgapped"),
supportsAutomate: false,
automationSettings: null
},
// Recommended Tools
@ -781,7 +822,9 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
synonyms: getSynonyms(t, "compare")
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
automationSettings: null
},
compress: {
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
@ -792,7 +835,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
operationConfig: compressOperationConfig,
settingsComponent: CompressSettings,
automationSettings: CompressSettings,
synonyms: getSynonyms(t, "compress")
},
convert: {
@ -822,7 +865,7 @@ export function useFlatToolRegistry(): ToolRegistry {
],
operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings,
automationSettings: ConvertSettings,
synonyms: getSynonyms(t, "convert")
},
@ -834,9 +877,8 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
urlPath: '/ocr-pdf',
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings,
automationSettings: OCRSettings,
synonyms: getSynonyms(t, "ocr")
},
redact: {
@ -849,7 +891,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
settingsComponent: RedactSingleStepSettings,
automationSettings: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact")
},
};

View File

@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface UseAutomationFormProps {
mode: AutomationMode;
@ -41,11 +43,15 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
const operation = typeof op === 'string' ? op : op.operation;
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = mode === AutomationMode.EDIT ? true : !toolEntry?.automationSettings;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: mode === AutomationMode.EDIT ? true : false,
configured: isConfigured,
parameters: typeof op === 'object' ? op.parameters || {} : {}
};
});
@ -65,11 +71,15 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
}, [mode, existingAutomation, t, getToolName]);
const addTool = (operation: string) => {
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
const newTool: AutomationTool = {
id: `${operation}-${Date.now()}`,
operation,
name: getToolName(operation),
configured: false,
configured: isConfigured,
parameters: getToolDefaultParameters(operation)
};

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import React from 'react';
import LocalIcon from '../../../components/shared/LocalIcon';
import { SuggestedAutomation } from '../../../types/automation';
import { SPLIT_METHODS } from '../../../constants/splitConstants';
// Create icon components
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
@ -83,18 +84,18 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
}
},
{
operation: "splitPdf",
operation: "split",
parameters: {
mode: 'bySizeOrCount',
method: SPLIT_METHODS.BY_SIZE,
pages: '',
hDiv: '1',
vDiv: '1',
merge: false,
splitType: 'size',
splitValue: '20MB',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
duplexMode: false,
}
},
{

View File

@ -22,7 +22,7 @@ export function useToolNavigation(): {
const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => {
// Generate SSR-safe relative path
const path = getToolUrlPath(toolId, tool);
const path = getToolUrlPath(toolId);
const href = path; // Relative path, no window.location needed
// Click handler that maintains SPA behavior

View File

@ -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<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("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() {
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t('quickAccess.files', 'Files')}</span>
</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>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
) : (
<Group

View File

@ -224,6 +224,15 @@
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
--unsupported-bar-bg: #5a616e;
--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"] {
@ -414,6 +423,15 @@
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
--unsupported-bar-bg: #1F2329;
--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 */

View File

@ -6,10 +6,8 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddAttachmentsParameters } from "../hooks/tools/addAttachments/useAddAttachmentsParameters";
import { useAddAttachmentsOperation } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import LocalIcon from "../components/shared/LocalIcon";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
// Removed FitText for two-line wrapping with clamping
import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttachmentsSettings";
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -67,99 +65,11 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
isVisible: true,
content: (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
// Append to existing attachments instead of replacing
const newAttachments = [...params.parameters.attachments, ...files];
params.updateParameter('attachments', newAttachments);
// Reset the input so the same file can be selected again
e.target.value = '';
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="attachments-input"
/>
<Button
size="xs"
color="blue"
component="label"
htmlFor="attachments-input"
disabled={endpointLoading}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
>
{params.parameters.attachments.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")
}
</Button>
</Stack>
{params.parameters.attachments && params.parameters.attachments.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length})
</Text>
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
<Stack gap="xs">
{params.parameters.attachments.map((file, index) => (
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
{/* Filename (two-line clamp, wraps, no icon on the left) */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 400,
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2 as any,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
style={{ flexShrink: 0 }}
onClick={() => {
const newAttachments = params.parameters.attachments.filter((_, i) => i !== index);
params.updateParameter('attachments', newAttachments);
}}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
<AddAttachmentsSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
});

View File

@ -6,10 +6,9 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters";
import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import { Select, Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
import { Tooltip } from "../components/shared/Tooltip";
import PageNumberPreview from "../components/tools/addPageNumbers/PageNumberPreview";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
import AddPageNumbersPositionSettings from "../components/tools/addPageNumbers/AddPageNumbersPositionSettings";
import AddPageNumbersAppearanceSettings from "../components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -68,44 +67,13 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="lg">
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
parameters={params.parameters}
onParameterChange={params.updateParameter}
file={selectedFiles[0] || null}
showQuickGrid={true}
/>
</Stack>
<Divider />
{/* Pages & Starting Number Section */}
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={params.parameters.pagesToNumber}
onChange={(e) => params.updateParameter('pagesToNumber', e.currentTarget.value)}
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
<NumberInput
label={t('addPageNumbers.selectText.4', 'Starting Number')}
value={params.parameters.startingNumber}
onChange={(v) => params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
</Stack>
<AddPageNumbersPositionSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
file={selectedFiles[0] || null}
showQuickGrid={true}
/>
),
});
@ -116,56 +84,11 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="md">
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
<Select
label={t('addPageNumbers.selectText.2', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('sizes.small', 'Small') },
{ value: 'medium', label: t('sizes.medium', 'Medium') },
{ value: 'large', label: t('sizes.large', 'Large') },
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
<NumberInput
label={t('addPageNumbers.fontSize', 'Font Size')}
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 12)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
<Select
label={t('addPageNumbers.fontName', 'Font Type')}
value={params.parameters.fontType}
onChange={(v) => params.updateParameter('fontType', (v as any) || 'Times')}
data={[
{ value: 'Times', label: 'Times Roman' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Courier', label: 'Courier New' },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
<TextInput
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
value={params.parameters.customText}
onChange={(e) => params.updateParameter('customText', e.currentTarget.value)}
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
<AddPageNumbersAppearanceSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
});

View File

@ -6,15 +6,14 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddStampParameters } from "../components/tools/addStamp/useAddStampParameters";
import { useAddStampOperation } from "../components/tools/addStamp/useAddStampOperation";
import { Group, Select, Stack, Textarea, TextInput, ColorInput, Button, Slider, Text, NumberInput, Divider } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import StampPreview from "../components/tools/addStamp/StampPreview";
import LocalIcon from "../components/shared/LocalIcon";
import styles from "../components/tools/addStamp/StampPreview.module.css";
import { Tooltip } from "../components/shared/Tooltip";
import ButtonSelector from "../components/shared/ButtonSelector";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
import ObscuredOverlay from "../components/shared/ObscuredOverlay";
import { getDefaultFontSizeForAlphabet } from "../components/tools/addStamp/StampPreviewUtils";
import StampSetupSettings from "../components/tools/addStamp/StampSetupSettings";
import StampPositionFormattingSettings from "../components/tools/addStamp/StampPositionFormattingSettings";
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -70,108 +69,22 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const getSteps = () => {
const steps: any[] = [];
// Step 1: Stamp Setup
// Step 1: Stamp Setup
steps.push({
title: t("AddStampRequest.stampSetup", "Stamp Setup"),
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="md">
<TextInput
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
value={params.parameters.pageNumbers}
onChange={(e) => params.updateParameter('pageNumbers', e.currentTarget.value)}
disabled={endpointLoading}
/>
<Divider/>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
<ButtonSelector
value={params.parameters.stampType}
onChange={(v: 'text' | 'image') => params.updateParameter('stampType', v)}
options={[
{ value: 'text', label: t('watermark.type.1', 'Text') },
{ value: 'image', label: t('watermark.type.2', 'Image') },
]}
disabled={endpointLoading}
buttonClassName={styles.modeToggleButton}
textClassName={styles.modeToggleButtonText}
/>
</div>
{params.parameters.stampType === 'text' && (
<>
<Textarea
label={t('AddStampRequest.stampText', 'Stamp Text')}
value={params.parameters.stampText}
onChange={(e) => params.updateParameter('stampText', e.currentTarget.value)}
autosize
minRows={2}
disabled={endpointLoading}
/>
<Select
label={t('AddStampRequest.alphabet', 'Alphabet')}
value={params.parameters.alphabet}
onChange={(v) => {
const nextAlphabet = (v as any) || 'roman';
params.updateParameter('alphabet', nextAlphabet);
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
params.updateParameter('fontSize', nextDefault);
}}
data={[
{ value: 'roman', label: 'Roman' },
{ value: 'arabic', label: 'العربية' },
{ value: 'japanese', label: '日本語' },
{ value: 'korean', label: '한국어' },
{ value: 'chinese', label: '简体中文' },
{ value: 'thai', label: 'ไทย' },
]}
disabled={endpointLoading}
/>
</>
)}
{params.parameters.stampType === 'image' && (
<Stack gap="xs">
<input
type="file"
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) params.updateParameter('stampImage', file);
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="stamp-image-input"
/>
<Button
size="xs"
component="label"
htmlFor="stamp-image-input"
disabled={endpointLoading}
>
{t('chooseFile', 'Choose File')}
</Button>
{params.parameters.stampImage && (
<Stack gap="xs">
<img
src={URL.createObjectURL(params.parameters.stampImage)}
alt="Selected stamp image"
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
/>
<Text size="xs" c="dimmed">
{params.parameters.stampImage.name}
</Text>
</Stack>
)}
</Stack>
)}
</Stack>
<StampSetupSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
});
// Step 3: Formatting & Position
// Step 2: Formatting & Position
steps.push({
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
@ -209,151 +122,13 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
</div>
)}
<StampPositionFormattingSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
{/* Icon pill buttons row */}
<div className="flex justify-between gap-[0.5rem]">
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
<Button
variant={params.parameters._activePill === 'rotation' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill', 'rotation')}
>
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
<Button
variant={params.parameters._activePill === 'opacity' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill', 'opacity')}
>
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={params.parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
<Button
variant={params.parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill', 'fontSize')}
>
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
</div>
{/* Single slider bound to selected pill */}
{params.parameters._activePill === 'fontSize' && (
<Stack gap="xs">
<Text className={styles.labelText}>
{params.parameters.stampType === 'image'
? t('AddStampRequest.imageSize', 'Image Size')
: t('AddStampRequest.fontSize', 'Font Size')
}
</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 1)}
min={1}
max={400}
step={1}
size="sm"
className={styles.numberInput}
disabled={endpointLoading}
/>
<Slider
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', v as number)}
min={1}
max={400}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{params.parameters._activePill === 'rotation' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.rotation}
onChange={(v) => params.updateParameter('rotation', typeof v === 'number' ? v : 0)}
min={-180}
max={180}
step={1}
size="sm"
className={styles.numberInput}
hideControls
disabled={endpointLoading}
/>
<Slider
value={params.parameters.rotation}
onChange={(v) => params.updateParameter('rotation', v as number)}
min={-180}
max={180}
step={1}
className={styles.sliderWide}
/>
</Group>
</Stack>
)}
{params.parameters._activePill === 'opacity' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.opacity}
onChange={(v) => params.updateParameter('opacity', typeof v === 'number' ? v : 0)}
min={0}
max={100}
step={1}
size="sm"
className={styles.numberInput}
disabled={endpointLoading}
/>
<Slider
value={params.parameters.opacity}
onChange={(v) => params.updateParameter('opacity', v as number)}
min={0}
max={100}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{params.parameters.stampType !== 'image' && (
<ColorInput
label={t('AddStampRequest.customColor', 'Custom Text Color')}
value={params.parameters.customColor}
onChange={(value) => params.updateParameter('customColor', value)}
format="hex"
disabled={endpointLoading}
/>
)}
{/* Margin selection appears when using quick grid (and for text stamps) */}
{(params.parameters.stampType === 'text' || (params.parameters.stampType === 'image' && quickPositionModeSelected)) && (
<Select
label={t('AddStampRequest.margin', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('margin.small', 'Small') },
{ value: 'medium', label: t('margin.medium', 'Medium') },
{ value: 'large', label: t('margin.large', 'Large') },
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
]}
disabled={endpointLoading}
/>
)}
{/* Unified preview wrapped with obscured overlay if no stamp selected in step 4 */}
{/* Unified preview wrapped with obscured overlay if no stamp selected */}
<ObscuredOverlay
obscured={
accordion.currentStep === AddStampStep.POSITION_FORMATTING &&

View File

@ -5,6 +5,127 @@ import { AutomationFileProcessor } from './automationFileProcessor';
import { ToolType } from '../hooks/tools/shared/useToolOperation';
import { processResponse } from './toolResponseProcessor';
/**
* Process multi-file tool response (handles ZIP or single PDF responses)
*/
const processMultiFileResponse = async (
responseData: Blob,
responseHeaders: any,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean
): Promise<File[]> => {
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
if (responseData.type === 'application/pdf' ||
(responseHeaders && responseHeaders['content-type'] === 'application/pdf')) {
// Single PDF response - use processResponse to respect preserveBackendFilename
const processedFiles = await processResponse(
responseData,
files,
filePrefix,
undefined,
preserveBackendFilename ? responseHeaders : undefined
);
return processedFiles;
} else {
// ZIP response
const result = await AutomationFileProcessor.extractAutomationZipFiles(responseData);
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
// Apply prefix to files, replacing any existing prefix
const processedFiles = filePrefix && !preserveBackendFilename
? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
})
: result.files;
return processedFiles;
}
};
/**
* Core execution function for API requests
*/
const executeApiRequest = async (
endpoint: string,
formData: FormData,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean
): Promise<File[]> => {
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
});
return await processMultiFileResponse(
response.data,
response.headers,
files,
filePrefix,
preserveBackendFilename
);
};
/**
* Execute single-file tool operation (processes files one at a time)
*/
const executeSingleFileOperation = async (
config: any,
parameters: any,
files: File[],
filePrefix: string
): Promise<File[]> => {
const resultFiles: File[] = [];
for (const file of files) {
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
const processedFiles = await executeApiRequest(
endpoint,
formData,
[file],
filePrefix,
config.preserveBackendFilename
);
resultFiles.push(...processedFiles);
}
return resultFiles;
};
/**
* Execute multi-file tool operation (processes all files in one request)
*/
const executeMultiFileOperation = async (
config: any,
parameters: any,
files: File[],
filePrefix: string
): Promise<File[]> => {
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
return await executeApiRequest(
endpoint,
formData,
files,
filePrefix,
config.preserveBackendFilename
);
};
/**
* Execute a tool operation directly without using React hooks
@ -28,119 +149,27 @@ export const executeToolOperationWithPrefix = async (
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => {
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
const config = toolRegistry[operationName as keyof ToolRegistry]?.operationConfig;
if (!config) {
console.error(`❌ Tool operation not supported: ${operationName}`);
throw new Error(`Tool operation not supported: ${operationName}`);
}
console.log(`📋 Using config:`, config);
try {
// Check if tool uses custom processor (like Convert tool)
if (config.customProcessor) {
console.log(`🎯 Using custom processor for ${config.operationType}`);
const resultFiles = await config.customProcessor(parameters, files);
console.log(`✅ Custom processor returned ${resultFiles.length} files`);
return resultFiles;
}
// Execute based on tool type
if (config.toolType === ToolType.multiFile) {
// Multi-file processing - single API call with all files
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
console.log(`🌐 Making multi-file request to: ${endpoint}`);
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
console.log(`📤 FormData entries:`, Array.from(formData.entries()));
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
});
console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`);
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
let result;
if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use processResponse to respect preserveBackendFilename
const processedFiles = await processResponse(
response.data,
files,
filePrefix,
undefined,
config.preserveBackendFilename ? response.headers : undefined
);
result = {
success: true,
files: processedFiles,
errors: []
};
} else {
// ZIP response
result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
}
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
// Apply prefix to files, replacing any existing prefix
// Skip prefixing if preserveBackendFilename is true and backend provided a filename
const processedFiles = filePrefix && !config.preserveBackendFilename
? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
})
: result.files;
console.log(`📁 Processed ${processedFiles.length} files from response`);
return processedFiles;
return await executeMultiFileOperation(config, parameters, files, filePrefix);
} else {
// Single-file processing - separate API call per file
console.log(`🔄 Processing ${files.length} files individually`);
const resultFiles: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
console.log(`🌐 Making single-file request ${i+1}/${files.length} to: ${endpoint} for file: ${file.name}`);
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
console.log(`📤 FormData entries:`, Array.from(formData.entries()));
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
});
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
// Create result file using processResponse to respect preserveBackendFilename setting
const processedFiles = await processResponse(
response.data,
[file],
filePrefix,
undefined,
config.preserveBackendFilename ? response.headers : undefined
);
resultFiles.push(...processedFiles);
console.log(`✅ Created result file(s): ${processedFiles.map(f => f.name).join(', ')}`);
}
console.log(`🎉 Single-file processing complete: ${resultFiles.length} files`);
return resultFiles;
return await executeSingleFileOperation(config, parameters, files, filePrefix);
}
} catch (error: any) {
console.error(`Tool operation ${operationName} failed:`, error);
console.error(`${operationName} failed:`, error);
throw new Error(`${operationName} operation failed: ${error.response?.data || error.message}`);
}
};
@ -156,9 +185,8 @@ export const executeAutomationSequence = async (
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void,
onStepError?: (stepIndex: number, error: string) => void
): Promise<File[]> => {
console.log(`🚀 Starting automation sequence: ${automation.name || 'Unnamed'}`);
console.log(`📁 Initial files: ${initialFiles.length}`);
console.log(`🔧 Operations: ${automation.operations?.length || 0}`);
console.log(`🚀 Starting automation: ${automation.name || 'Unnamed'}`);
console.log(`📁 Input: ${initialFiles.length} file(s)`);
if (!automation?.operations || automation.operations.length === 0) {
throw new Error('No operations in automation');
@ -170,9 +198,8 @@ export const executeAutomationSequence = async (
for (let i = 0; i < automation.operations.length; i++) {
const operation = automation.operations[i];
console.log(`📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
console.log(`📄 Input files: ${currentFiles.length}`);
console.log(`⚙️ Parameters:`, operation.parameters || {});
console.log(`\n📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
console.log(` Input: ${currentFiles.length} file(s)`);
try {
onStepStart?.(i, operation.operation);
@ -196,6 +223,6 @@ export const executeAutomationSequence = async (
}
}
console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`);
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
return currentFiles;
};

View 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),
});

View File

@ -33,7 +33,7 @@ export function parseToolRoute(registry: ToolRegistry): ToolRoute {
// Fallback: Try to find tool by primary URL path in registry
for (const [toolId, tool] of Object.entries(registry)) {
const toolUrlPath = getToolUrlPath(toolId, tool);
const toolUrlPath = getToolUrlPath(toolId);
if (path === toolUrlPath && isValidToolId(toolId)) {
return {
workbench: getToolWorkbench(tool),
@ -88,7 +88,7 @@ export function updateToolRoute(toolId: ToolId, registry: ToolRegistry, replace:
return;
}
const toolPath = getToolUrlPath(toolId, tool);
const toolPath = getToolUrlPath(toolId);
const newPath = withBasePath(toolPath);
const searchParams = new URLSearchParams(window.location.search);
@ -116,19 +116,3 @@ export function getToolDisplayName(toolId: ToolId, registry: ToolRegistry): stri
return tool ? tool.name : toolId;
}
/**
* Generate shareable URL for current tool state using registry
*/
export function generateShareableUrl(toolId: ToolId | null, registry: ToolRegistry): string {
const baseUrl = window.location.origin;
if (!toolId || !registry[toolId]) {
return `${baseUrl}${BASE_PATH || ''}`;
}
const tool = registry[toolId];
const toolPath = getToolUrlPath(toolId, tool);
const fullPath = withBasePath(toolPath);
return `${baseUrl}${fullPath}`;
}

View File

@ -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',