Files
Stirling-PDF/frontend/src/core/contexts/HotkeyContext.tsx
T
James BruntonandGitHub d2b38ef4b8 Restructure frontend code to allow for extensions (#4721)
# Description of Changes
Move frontend code into `core` folder and add infrastructure for
`proprietary` folder to include premium, non-OSS features
2025-10-28 10:29:36 +00:00

206 lines
6.7 KiB
TypeScript

import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '@app/utils/hotkeys';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { ToolId } from '@app/types/toolId';
import { ToolCategoryId, ToolRegistryEntry } from '@app/data/toolsTaxonomy';
type Bindings = Partial<Record<ToolId, HotkeyBinding>>;
interface HotkeyContextValue {
hotkeys: Bindings;
defaults: Bindings;
isMac: boolean;
updateHotkey: (toolId: ToolId, binding: HotkeyBinding) => void;
resetHotkey: (toolId: ToolId) => void;
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: ToolId) => 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 generateDefaultHotkeys = (toolEntries: [ToolId, ToolRegistryEntry][], macLike: boolean): Bindings => {
const defaults: Bindings = {};
// Get Quick Access tools (RECOMMENDED_TOOLS category) from registry
const quickAccessTools = toolEntries
.filter(([_, tool]) => tool.categoryId === ToolCategoryId.RECOMMENDED_TOOLS)
.map(([toolId, _]) => toolId);
// Assign Cmd+Option+Number (Mac) or Ctrl+Alt+Number (Windows) to Quick Access tools
quickAccessTools.forEach((toolId, index) => {
if (index < 9) { // Limit to Digit1-9
const digitNumber = index + 1;
defaults[toolId] = {
code: `Digit${digitNumber}`,
alt: true,
shift: false,
meta: macLike,
ctrl: !macLike,
};
}
});
// All other tools have no default (will be undefined in the record)
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<Bindings>(() => {
if (typeof window === 'undefined') {
return {};
}
return deserializeBindings(window.localStorage?.getItem(STORAGE_KEY));
});
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
const toolEntries = useMemo(() => Object.entries(toolRegistry), [toolRegistry]) as [ToolId, ToolRegistryEntry][];
const defaults = useMemo(() => generateDefaultHotkeys(toolEntries, isMac), [toolRegistry, isMac]);
// Remove bindings for tools that are no longer present
useEffect(() => {
setCustomBindings(prev => {
const next: Bindings = {};
let changed = false;
(Object.entries(prev) as [ToolId, HotkeyBinding][]).forEach(([toolId, binding]) => {
if (toolRegistry[toolId]) {
next[toolId] = binding;
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [toolRegistry]);
const resolved = useMemo(() => {
const merged: Bindings = {};
toolEntries.forEach(([toolId, _]) => {
const custom = customBindings[toolId];
const defaultBinding = defaults[toolId];
// Only add to resolved if there's a custom binding or a default binding
if (custom) {
merged[toolId] = normalizeBinding(custom);
} else if (defaultBinding) {
merged[toolId] = defaultBinding;
}
// If neither exists, don't add to merged (tool has no hotkey)
});
return merged;
}, [customBindings, defaults, toolEntries]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
}, [customBindings]);
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: ToolId) => {
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: ToolId, 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: ToolId) => {
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 [ToolId, HotkeyBinding][];
for (const [toolId, binding] of entries) {
if (bindingMatchesEvent(binding, event)) {
event.preventDefault();
event.stopPropagation();
handleToolSelect(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;
};