2025-12-12 16:46:56 +01:00
|
|
|
import React, { useMemo, useState, useEffect } from 'react';
|
2025-02-28 15:17:55 -05:00
|
|
|
import { useSystem, hotkeys as hotkeysModule } from '@ohif/core';
|
|
|
|
|
import { UserPreferencesModal, FooterAction } from '@ohif/ui-next';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import i18n from '@ohif/i18n';
|
|
|
|
|
|
|
|
|
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@ohif/ui-next';
|
|
|
|
|
|
|
|
|
|
const { availableLanguages, defaultLanguage, currentLanguage: currentLanguageFn } = i18n;
|
|
|
|
|
|
|
|
|
|
interface HotkeyDefinition {
|
|
|
|
|
keys: string;
|
|
|
|
|
label: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface HotkeyDefinitions {
|
|
|
|
|
[key: string]: HotkeyDefinition;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 10:13:58 -04:00
|
|
|
const MODIFIER_OPTIONS = [
|
|
|
|
|
{ value: '16', label: 'Shift' },
|
|
|
|
|
{ value: '17', label: 'Ctrl' },
|
|
|
|
|
{ value: '18', label: 'Alt' },
|
|
|
|
|
{ value: '91', label: 'Meta' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TOOL_BINDINGS_STORAGE_KEY = 'user-preferred-tool-bindings';
|
|
|
|
|
|
2026-05-05 15:22:43 -04:00
|
|
|
function getToolModifier(
|
|
|
|
|
toolGroupService: any,
|
|
|
|
|
toolGroupId: string,
|
|
|
|
|
toolName: string,
|
|
|
|
|
mouseButton: number
|
|
|
|
|
): string | null {
|
2026-04-30 10:13:58 -04:00
|
|
|
if (!toolGroupService) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const bindings = toolGroupService.getToolBindings(toolGroupId, toolName);
|
|
|
|
|
if (!bindings?.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const modifierBinding = bindings.find(
|
2026-05-05 15:22:43 -04:00
|
|
|
binding =>
|
|
|
|
|
binding.mouseButton === mouseButton &&
|
|
|
|
|
binding.modifierKey != null &&
|
|
|
|
|
binding.numTouchPoints == null
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return modifierBinding?.modifierKey != null ? String(modifierBinding.modifierKey) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getModifierFromBindings(
|
|
|
|
|
bindings: Array<Record<string, unknown>> | undefined,
|
|
|
|
|
mouseButton: number
|
|
|
|
|
): string | null {
|
|
|
|
|
if (!bindings?.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modifierBinding = bindings.find(
|
|
|
|
|
binding =>
|
|
|
|
|
binding.mouseButton === mouseButton &&
|
|
|
|
|
binding.modifierKey != null &&
|
|
|
|
|
binding.numTouchPoints == null
|
2026-04-30 10:13:58 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return modifierBinding?.modifierKey != null ? String(modifierBinding.modifierKey) : null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-28 15:17:55 -05:00
|
|
|
function UserPreferencesModalDefault({ hide }: { hide: () => void }) {
|
2026-04-30 10:13:58 -04:00
|
|
|
const { hotkeysManager, servicesManager } = useSystem();
|
2025-12-12 16:46:56 +01:00
|
|
|
const { t, i18n: i18nextInstance } = useTranslation('UserPreferencesModal');
|
2026-04-30 10:13:58 -04:00
|
|
|
const toolGroupService = (servicesManager as any)?.services?.toolGroupService;
|
2025-02-28 15:17:55 -05:00
|
|
|
|
|
|
|
|
const { hotkeyDefinitions = {}, hotkeyDefaults = {} } = hotkeysManager;
|
|
|
|
|
|
2025-12-12 16:46:56 +01:00
|
|
|
const fallbackHotkeyDefinitions = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
hotkeysManager.getValidHotkeyDefinitions(
|
|
|
|
|
hotkeysModule.defaults.hotkeyBindings
|
|
|
|
|
) as HotkeyDefinitions,
|
|
|
|
|
[hotkeysManager]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!Object.keys(hotkeyDefaults).length) {
|
|
|
|
|
hotkeysManager.setDefaultHotKeys(hotkeysModule.defaults.hotkeyBindings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!Object.keys(hotkeyDefinitions).length) {
|
|
|
|
|
hotkeysManager.setHotkeys(fallbackHotkeyDefinitions);
|
|
|
|
|
}
|
|
|
|
|
}, [hotkeysManager, hotkeyDefaults, hotkeyDefinitions, fallbackHotkeyDefinitions]);
|
|
|
|
|
|
|
|
|
|
const resolvedHotkeyDefaults = Object.keys(hotkeyDefaults).length
|
|
|
|
|
? (hotkeyDefaults as HotkeyDefinitions)
|
|
|
|
|
: fallbackHotkeyDefinitions;
|
|
|
|
|
|
|
|
|
|
const initialHotkeyDefinitions = Object.keys(hotkeyDefinitions).length
|
|
|
|
|
? (hotkeyDefinitions as HotkeyDefinitions)
|
|
|
|
|
: resolvedHotkeyDefaults;
|
|
|
|
|
|
2025-02-28 15:17:55 -05:00
|
|
|
const currentLanguage = currentLanguageFn();
|
|
|
|
|
|
2026-04-30 10:13:58 -04:00
|
|
|
const initialCrosshairModifier = useMemo(
|
2026-05-05 15:22:43 -04:00
|
|
|
() => getToolModifier(toolGroupService, 'mpr', 'Crosshairs', 1),
|
|
|
|
|
[toolGroupService]
|
|
|
|
|
);
|
|
|
|
|
const defaultCrosshairBindings = useMemo(
|
|
|
|
|
() => toolGroupService?.getDefaultToolBindings?.('mpr', 'Crosshairs'),
|
2026-04-30 10:13:58 -04:00
|
|
|
[toolGroupService]
|
|
|
|
|
);
|
|
|
|
|
|
2025-02-28 15:17:55 -05:00
|
|
|
const [state, setState] = useState({
|
2025-12-12 16:46:56 +01:00
|
|
|
hotkeyDefinitions: initialHotkeyDefinitions,
|
2025-02-28 15:17:55 -05:00
|
|
|
languageValue: currentLanguage.value,
|
2026-04-30 10:13:58 -04:00
|
|
|
crosshairModifier: initialCrosshairModifier,
|
2025-02-28 15:17:55 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const onLanguageChangeHandler = (value: string) => {
|
|
|
|
|
setState(state => ({ ...state, languageValue: value }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onHotkeyChangeHandler = (id: string, newKeys: string) => {
|
|
|
|
|
setState(state => ({
|
|
|
|
|
...state,
|
|
|
|
|
hotkeyDefinitions: {
|
|
|
|
|
...state.hotkeyDefinitions,
|
|
|
|
|
[id]: {
|
|
|
|
|
...state.hotkeyDefinitions[id],
|
|
|
|
|
keys: newKeys,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onResetHandler = () => {
|
|
|
|
|
setState(state => ({
|
|
|
|
|
...state,
|
|
|
|
|
languageValue: defaultLanguage.value,
|
2025-12-12 16:46:56 +01:00
|
|
|
hotkeyDefinitions: resolvedHotkeyDefaults,
|
2026-05-05 15:22:43 -04:00
|
|
|
crosshairModifier: getModifierFromBindings(defaultCrosshairBindings, 1),
|
2025-02-28 15:17:55 -05:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
hotkeysManager.restoreDefaultBindings();
|
2026-05-05 15:22:43 -04:00
|
|
|
if (toolGroupService && defaultCrosshairBindings?.length) {
|
|
|
|
|
toolGroupService.setToolBindings('mpr', 'Crosshairs', defaultCrosshairBindings);
|
|
|
|
|
toolGroupService.applyToolBindings('mpr', 'Crosshairs', {
|
|
|
|
|
replaceExisting: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
toolGroupService?.removePersistedToolBindings('mpr', 'Crosshairs');
|
2025-02-28 15:17:55 -05:00
|
|
|
};
|
|
|
|
|
|
2025-12-12 16:46:56 +01:00
|
|
|
const displayNames = React.useMemo(() => {
|
|
|
|
|
if (typeof Intl === 'undefined' || typeof Intl.DisplayNames !== 'function') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const locales = [state.languageValue, currentLanguage.value, i18nextInstance.language, 'en'];
|
|
|
|
|
const uniqueLocales = Array.from(new Set(locales.filter(Boolean)));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return new Intl.DisplayNames(uniqueLocales, { type: 'language', fallback: 'none' });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Intl.DisplayNames not supported for locales', uniqueLocales, error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}, [state.languageValue, currentLanguage.value, i18nextInstance.language]);
|
|
|
|
|
|
|
|
|
|
const getLanguageLabel = React.useCallback(
|
|
|
|
|
(languageValue: string, fallbackLabel: string) => {
|
|
|
|
|
const translationKey = `LanguageName.${languageValue}`;
|
|
|
|
|
if (i18nextInstance.exists(translationKey, { ns: 'UserPreferencesModal' })) {
|
|
|
|
|
return t(translationKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (displayNames) {
|
|
|
|
|
try {
|
|
|
|
|
const localized = displayNames.of(languageValue);
|
|
|
|
|
if (localized && localized.toLowerCase() !== languageValue.toLowerCase()) {
|
|
|
|
|
return localized.charAt(0).toUpperCase() + localized.slice(1);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.debug(`Unable to resolve display name for ${languageValue}`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fallbackLabel;
|
|
|
|
|
},
|
|
|
|
|
[displayNames, i18nextInstance, t]
|
|
|
|
|
);
|
|
|
|
|
|
2025-02-28 15:17:55 -05:00
|
|
|
return (
|
|
|
|
|
<UserPreferencesModal>
|
|
|
|
|
<UserPreferencesModal.Body>
|
|
|
|
|
{/* Language Section */}
|
|
|
|
|
<div className="mb-3 flex items-center space-x-14">
|
|
|
|
|
<UserPreferencesModal.SubHeading>{t('Language')}</UserPreferencesModal.SubHeading>
|
|
|
|
|
<Select
|
|
|
|
|
defaultValue={state.languageValue}
|
|
|
|
|
onValueChange={onLanguageChangeHandler}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
className="w-60"
|
|
|
|
|
aria-label="Language"
|
|
|
|
|
>
|
|
|
|
|
<SelectValue placeholder={t('Select language')} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{availableLanguages.map(lang => (
|
|
|
|
|
<SelectItem
|
|
|
|
|
key={lang.value}
|
|
|
|
|
value={lang.value}
|
|
|
|
|
>
|
2025-12-12 16:46:56 +01:00
|
|
|
{getLanguageLabel(lang.value, lang.label)}
|
2025-02-28 15:17:55 -05:00
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<UserPreferencesModal.SubHeading>{t('Hotkeys')}</UserPreferencesModal.SubHeading>
|
|
|
|
|
<UserPreferencesModal.HotkeysGrid>
|
|
|
|
|
{Object.entries(state.hotkeyDefinitions).map(([id, definition]) => (
|
|
|
|
|
<UserPreferencesModal.Hotkey
|
|
|
|
|
key={id}
|
|
|
|
|
label={t(definition.label)}
|
|
|
|
|
value={definition.keys}
|
|
|
|
|
onChange={newKeys => onHotkeyChangeHandler(id, newKeys)}
|
|
|
|
|
placeholder={definition.keys}
|
|
|
|
|
hotkeys={hotkeysModule}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</UserPreferencesModal.HotkeysGrid>
|
2026-04-30 10:13:58 -04:00
|
|
|
|
|
|
|
|
{state.crosshairModifier != null && (
|
|
|
|
|
<>
|
|
|
|
|
<UserPreferencesModal.SubHeading>
|
|
|
|
|
{t('ModifierKeys', { defaultValue: 'Modifier Keys' })}
|
|
|
|
|
</UserPreferencesModal.SubHeading>
|
|
|
|
|
<UserPreferencesModal.HotkeysGrid>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-foreground text-base">
|
|
|
|
|
{t('CrosshairsModifier', { defaultValue: 'Crosshairs' })}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<span className="text-muted-foreground text-sm">
|
|
|
|
|
{t('PlusLeftClick', { defaultValue: 'Left Click +' })}
|
|
|
|
|
</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={state.crosshairModifier}
|
|
|
|
|
onValueChange={val => setState(s => ({ ...s, crosshairModifier: val }))}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="w-16">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{MODIFIER_OPTIONS.map(opt => (
|
|
|
|
|
<SelectItem
|
|
|
|
|
key={opt.value}
|
|
|
|
|
value={opt.value}
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</UserPreferencesModal.HotkeysGrid>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-02-28 15:17:55 -05:00
|
|
|
</UserPreferencesModal.Body>
|
|
|
|
|
<FooterAction>
|
|
|
|
|
<FooterAction.Left>
|
|
|
|
|
<FooterAction.Auxiliary onClick={onResetHandler}>
|
|
|
|
|
{t('Reset to defaults')}
|
|
|
|
|
</FooterAction.Auxiliary>
|
|
|
|
|
</FooterAction.Left>
|
|
|
|
|
<FooterAction.Right>
|
|
|
|
|
<FooterAction.Secondary
|
|
|
|
|
onClick={() => {
|
|
|
|
|
hotkeysModule.stopRecord();
|
|
|
|
|
hotkeysModule.unpause();
|
|
|
|
|
hide();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('Cancel')}
|
|
|
|
|
</FooterAction.Secondary>
|
|
|
|
|
<FooterAction.Primary
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (state.languageValue !== currentLanguage.value) {
|
|
|
|
|
i18n.changeLanguage(state.languageValue);
|
2025-12-12 16:46:56 +01:00
|
|
|
// Force page reload after language change to ensure all translations are applied
|
|
|
|
|
window.location.reload();
|
|
|
|
|
return; // Exit early since we're reloading
|
2025-02-28 15:17:55 -05:00
|
|
|
}
|
|
|
|
|
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
|
2026-04-30 10:13:58 -04:00
|
|
|
|
|
|
|
|
if (toolGroupService && state.crosshairModifier != null) {
|
|
|
|
|
const bindings = [
|
|
|
|
|
{ mouseButton: 1, modifierKey: Number(state.crosshairModifier) },
|
|
|
|
|
];
|
|
|
|
|
toolGroupService.setToolBindings('mpr', 'Crosshairs', bindings);
|
2026-05-05 15:22:43 -04:00
|
|
|
toolGroupService.applyToolBindings('mpr', 'Crosshairs', {
|
|
|
|
|
replaceExisting: true,
|
|
|
|
|
});
|
|
|
|
|
toolGroupService.persistToolBindings('mpr', 'Crosshairs', bindings);
|
2026-04-30 10:13:58 -04:00
|
|
|
}
|
|
|
|
|
|
2025-02-28 15:17:55 -05:00
|
|
|
hotkeysModule.stopRecord();
|
|
|
|
|
hotkeysModule.unpause();
|
|
|
|
|
hide();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('Save')}
|
|
|
|
|
</FooterAction.Primary>
|
|
|
|
|
</FooterAction.Right>
|
|
|
|
|
</FooterAction>
|
|
|
|
|
</UserPreferencesModal>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
'ohif.userPreferencesModal': UserPreferencesModalDefault,
|
2026-04-30 10:13:58 -04:00
|
|
|
'ohif.userPreferences.toolBindingsStorageKey': DEFAULT_TOOL_BINDINGS_STORAGE_KEY,
|
2025-02-28 15:17:55 -05:00
|
|
|
};
|