immich/web/src/lib/actions/shortcut.svelte.ts
2025-10-17 12:53:38 +00:00

311 lines
8.4 KiB
TypeScript

import {
alwaysTrueFactory,
type KeyCombo,
keyDownListenerFactory,
type KeyDownListenerFactory,
type KeyInput,
normalizeKeyInput,
} from '$lib/actions/input';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { modalManager } from '@immich/ui';
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { SvelteMap } from 'svelte/reactivity';
import { get } from 'svelte/store';
export enum Category {
Application = 'app_actions',
AssetActions = 'asset_actions',
ViewActions = 'view_actions',
QuickActions = 'quick_actions',
Navigation = 'navigation',
Selection = 'selection',
}
export const getCategoryString = (category: Category) => get(t)(category);
export const category = (category: Category, text: string, variant?: ShortcutVariant): ShortcutHelp => {
return {
variant,
category,
text,
};
};
const explicitCategoryList = [
Category.QuickActions,
Category.AssetActions,
Category.ViewActions,
Category.Selection,
Category.Navigation,
Category.Application,
];
export const sortCategories = (categories: Category[]) =>
[...categories].sort((a, b) => {
const indexA = explicitCategoryList.indexOf(a);
const indexB = explicitCategoryList.indexOf(b);
return (indexA === -1 ? Infinity : indexA) - (indexB === -1 ? Infinity : indexB);
});
export enum ShortcutVariant {
SelectAll,
DeselectAll,
AddAlbum,
AddSharedAlbum,
PrevAsset,
NextAsset,
Delete,
PermDelete,
PreviousAsset,
PreviousDay,
NextDay,
PreviousMonth,
NextMonth,
PreviousYear,
NextYear,
Trash,
Search,
SearchFilter,
FocusNext,
FocusPrevious,
}
type ShortcutHelp = {
variant?: ShortcutVariant;
category?: Category;
text: string;
info?: string;
};
export type KeyboardHelp = ShortcutHelp & { key: string[][] };
type InternalKeyboardHelp = KeyboardHelp & { scope: number; $InternalHelpId: string };
type KeyTargets = HTMLElement | Document | Window;
const isMacOS = /Mac(intosh|Intel)/.test(globalThis.navigator.userAgent);
// state variables
let helpArray: InternalKeyboardHelp[] = $state([]);
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let shortcutVariants = $state(new SvelteMap<ShortcutVariant, ShortcutVariant>());
let currentScope = $state(0);
let showingShortcuts = $state(false);
const activeScopeShortcuts: KeyboardHelp[] = $derived(
helpArray.filter((helpObjectArrayObject) => helpObjectArrayObject.scope === currentScope),
);
function isLetter(c: string) {
return c.toLowerCase() != c.toUpperCase();
}
const expandKeys = (shortcuts: KeyCombo[]) => {
return shortcuts.map((s) => {
const keys: string[] = [];
const keyIsLetter = isLetter(s.key);
if (s.shift && isMacOS) {
keys.push('⇧');
} else if (s.shift) {
keys.push('Shift');
}
if (s.ctrl && isMacOS) {
keys.push('⌃');
} else if (s.ctrl) {
keys.push('Ctrl');
}
if (s.alt && isMacOS) {
keys.push('⌥');
} else if (s.alt) {
keys.push('Alt');
}
if (s.meta && isMacOS) {
keys.push('⌘');
} else if (s.meta) {
keys.push('❖');
}
switch (s.key) {
case ' ': {
if (isMacOS) {
keys.push('␣');
} else {
keys.push('space');
}
break;
}
case 'ArrowLeft': {
keys.push('←');
break;
}
case 'ArrowRight': {
keys.push('→');
break;
}
case 'Escape': {
keys.push('esc');
break;
}
case 'Delete': {
if (isMacOS) {
keys.push('⌦');
} else {
keys.push('del');
}
break;
}
default: {
if (keyIsLetter && s.shift && !s.alt && !s.ctrl && !s.meta) {
keys.splice(0);
keys.push(s.key.toUpperCase());
} else {
keys.push(s.key);
}
}
}
return keys;
});
};
function normalizeHelp(help: ShortcutHelp | string, shortcuts: KeyCombo[]): KeyboardHelp | null {
if (!help) {
return null;
} else if (typeof help === 'string') {
return {
text: help,
category: Category.Application,
key: expandKeys(shortcuts),
};
} else {
return { category: Category.Application, ...help, key: expandKeys(shortcuts) };
}
}
function generateId() {
const timestamp = Date.now().toString(36); // Current timestamp in base 36
const random = Math.random().toString(36).slice(2, 9); // Random string from Math.random()
return timestamp + random;
}
export const attachmentFactory =
(help: KeyboardHelp | null, listenerFactory: KeyDownListenerFactory): Attachment<KeyTargets> =>
(element: KeyTargets) => {
return untrack(() => {
const listener = listenerFactory(element);
const internalId = generateId();
let helpObject: InternalKeyboardHelp;
if (help) {
helpObject = {
...help,
scope: currentScope,
$InternalHelpId: internalId,
};
helpArray.push(helpObject);
}
element.addEventListener('keydown', listener as EventListener);
return () => {
if (helpObject) {
const index = helpArray.findIndex((helpObject) => helpObject && helpObject.$InternalHelpId === internalId);
if (index !== -1) {
helpArray.splice(index, 1);
}
}
element.removeEventListener('keydown', listener as EventListener);
};
});
};
export const registerShortcutVariant = (first: ShortcutVariant, other: ShortcutVariant) => {
return () => {
shortcutVariants.set(first, other);
return () => {
shortcutVariants.delete(first);
};
};
};
export const shortcut = (input: KeyInput, help: ShortcutHelp | string, callback: (event: KeyboardEvent) => unknown) => {
const normalized = normalizeKeyInput(input);
return attachmentFactory(normalizeHelp(help, normalized), () =>
keyDownListenerFactory(isActiveFactory, {}, normalized, callback),
);
};
export const conditionalShortcut = (condition: () => boolean, shortcut: () => Attachment<KeyTargets>) => {
if (condition()) {
return shortcut();
}
return () => void 0;
};
const isActiveFactory = () => {
const savedScope = currentScope;
return () => modalManager.openCount === 0 && savedScope === currentScope;
};
const pushScope = () => untrack(() => currentScope++);
const popScope = () => untrack(() => currentScope--);
export const newShortcutScope = () => {
pushScope();
return () => popScope();
};
export const showShortcutsModal = async () => {
if (showingShortcuts) {
return;
}
showingShortcuts = true;
await modalManager.show(ShortcutsModal, { shortcutVariants, shortcuts: activeScopeShortcuts });
showingShortcuts = false;
};
export const resetModal = () => {
// only used by ShortcutsModal - used to restore state after HMR.
// do not use for any other reason
showingShortcuts = false;
};
const startup = () => {
// add the default '?' shortcut to launch the help menu
const unregister = attachmentFactory(
{ category: Category.Application, text: 'Open Shortcuts Help', key: [['?']] },
() => keyDownListenerFactory(alwaysTrueFactory, {}, [{ key: '?', shift: true }], showShortcutsModal),
)(globalThis as unknown as Window);
// put global variants here
shortcutVariants.set(ShortcutVariant.AddAlbum, ShortcutVariant.AddSharedAlbum);
return unregister as () => void;
};
const registerHmr = () => {
const hot = import.meta.hot;
if (!hot) {
startup();
return;
}
if (import.meta.hot!.data?.shortcut_state) {
const shortcut_state = import.meta.hot!.data.shortcut_state;
const _pairMap = new SvelteMap<ShortcutVariant, ShortcutVariant>();
for (const element of shortcut_state.pairMap.keys()) {
_pairMap.set(element, shortcut_state.pairMap.get(element));
}
if (shortcut_state) {
helpArray = shortcut_state.helpArray;
showingShortcuts = shortcut_state.showingShortcuts;
currentScope = shortcut_state.currentScope;
shortcutVariants = _pairMap;
}
}
// startup() must be called after the hot-state has been restored
const unregister = startup();
hot.on('vite:beforeUpdate', () => {
const shortcut_state = {
helpArray: [...$state.snapshot(helpArray)],
showingShortcuts,
currentScope,
pairMap: $state.snapshot(shortcutVariants),
};
unregister();
import.meta.hot!.data.shortcut_state = shortcut_state;
});
};
registerHmr();