mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Claude <noreply@anthropic.com>
211 lines
6.6 KiB
TypeScript
211 lines
6.6 KiB
TypeScript
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;
|
|
}; |