mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
shortcuts and config menu (#4530)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Claude <[email protected]>
This commit is contained in:
co-authored by
Claude
parent
85dedf4b28
commit
d86a13cc89
@@ -0,0 +1,211 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys';
|
||||
import { useToolWorkflow } from './ToolWorkflowContext';
|
||||
import { ToolId } from '../types/toolId';
|
||||
|
||||
interface HotkeyContextValue {
|
||||
hotkeys: Record<string, HotkeyBinding>;
|
||||
defaults: Record<string, HotkeyBinding>;
|
||||
isMac: boolean;
|
||||
updateHotkey: (toolId: string, binding: HotkeyBinding) => void;
|
||||
resetHotkey: (toolId: string) => void;
|
||||
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean;
|
||||
pauseHotkeys: () => void;
|
||||
resumeHotkeys: () => void;
|
||||
areHotkeysPaused: boolean;
|
||||
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
|
||||
}
|
||||
|
||||
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf.hotkeys';
|
||||
|
||||
const KEY_ORDER: string[] = [
|
||||
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
|
||||
'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP',
|
||||
'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL',
|
||||
'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM',
|
||||
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
||||
];
|
||||
|
||||
const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record<string, HotkeyBinding> => {
|
||||
const defaults: Record<string, HotkeyBinding> = {};
|
||||
let index = 0;
|
||||
let useShift = false;
|
||||
|
||||
const nextBinding = (): HotkeyBinding => {
|
||||
if (index >= KEY_ORDER.length) {
|
||||
index = 0;
|
||||
if (!useShift) {
|
||||
useShift = true;
|
||||
} else {
|
||||
// If we somehow run out of combinations, wrap back around (unlikely given tool count)
|
||||
useShift = false;
|
||||
}
|
||||
}
|
||||
|
||||
const code = KEY_ORDER[index];
|
||||
index += 1;
|
||||
|
||||
return {
|
||||
code,
|
||||
alt: true,
|
||||
shift: useShift,
|
||||
meta: macLike,
|
||||
ctrl: !macLike,
|
||||
};
|
||||
};
|
||||
|
||||
toolIds.forEach(toolId => {
|
||||
defaults[toolId] = nextBinding();
|
||||
});
|
||||
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const editable = target.closest('input, textarea, [contenteditable="true"], [role="textbox"]');
|
||||
return Boolean(editable);
|
||||
};
|
||||
|
||||
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { toolRegistry, handleToolSelect } = useToolWorkflow();
|
||||
const isMac = useMemo(() => isMacLike(), []);
|
||||
const [customBindings, setCustomBindings] = useState<Record<string, HotkeyBinding>>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
return deserializeBindings(window.localStorage?.getItem(STORAGE_KEY));
|
||||
});
|
||||
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
|
||||
|
||||
const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]);
|
||||
|
||||
const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]);
|
||||
|
||||
// Remove bindings for tools that are no longer present
|
||||
useEffect(() => {
|
||||
setCustomBindings(prev => {
|
||||
const next: Record<string, HotkeyBinding> = {};
|
||||
let changed = false;
|
||||
Object.entries(prev).forEach(([toolId, binding]) => {
|
||||
if (toolRegistry[toolId]) {
|
||||
next[toolId] = binding;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [toolRegistry]);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
const merged: Record<string, HotkeyBinding> = {};
|
||||
toolIds.forEach(toolId => {
|
||||
const custom = customBindings[toolId];
|
||||
merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId];
|
||||
});
|
||||
return merged;
|
||||
}, [customBindings, defaults, toolIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
|
||||
}, [customBindings]);
|
||||
|
||||
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
return Object.entries(resolved).every(([toolId, existing]) => {
|
||||
if (toolId === excludeToolId) {
|
||||
return true;
|
||||
}
|
||||
return !bindingEquals(existing, normalized);
|
||||
});
|
||||
}, [resolved]);
|
||||
|
||||
const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => {
|
||||
setCustomBindings(prev => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
const defaultsForTool = defaults[toolId];
|
||||
const next = { ...prev };
|
||||
if (defaultsForTool && bindingEquals(defaultsForTool, normalized)) {
|
||||
delete next[toolId];
|
||||
} else {
|
||||
next[toolId] = normalized;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [defaults]);
|
||||
|
||||
const resetHotkey = useCallback((toolId: string) => {
|
||||
setCustomBindings(prev => {
|
||||
if (!(toolId in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[toolId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pauseHotkeys = useCallback(() => setHotkeysPaused(true), []);
|
||||
const resumeHotkeys = useCallback(() => setHotkeysPaused(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (areHotkeysPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (shouldIgnoreTarget(event.target)) return;
|
||||
|
||||
const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>;
|
||||
for (const [toolId, binding] of entries) {
|
||||
if (bindingMatchesEvent(binding, event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleToolSelect(toolId as ToolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
}, [resolved, areHotkeysPaused, handleToolSelect]);
|
||||
|
||||
const contextValue = useMemo<HotkeyContextValue>(() => ({
|
||||
hotkeys: resolved,
|
||||
defaults,
|
||||
isMac,
|
||||
updateHotkey,
|
||||
resetHotkey,
|
||||
isBindingAvailable,
|
||||
pauseHotkeys,
|
||||
resumeHotkeys,
|
||||
areHotkeysPaused,
|
||||
getDisplayParts: (binding) => getDisplayParts(binding ?? null, isMac),
|
||||
}), [resolved, defaults, isMac, updateHotkey, resetHotkey, isBindingAvailable, pauseHotkeys, resumeHotkeys, areHotkeysPaused]);
|
||||
|
||||
return (
|
||||
<HotkeyContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HotkeyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHotkeys = (): HotkeyContextValue => {
|
||||
const context = useContext(HotkeyContext);
|
||||
if (!context) {
|
||||
throw new Error('useHotkeys must be used within a HotkeyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -75,7 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
// Tool management (from hook)
|
||||
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(() =>
|
||||
|
||||
Reference in New Issue
Block a user