fix(web): escape shortcut handling (#26096)

This commit is contained in:
Michel Heusschen 2026-02-11 18:55:28 +01:00 committed by GitHub
parent e54678e0d6
commit 913904f418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 17 additions and 119 deletions

View File

@ -1,112 +1,9 @@
import type { ActionReturn } from 'svelte/action';
export type Shortcut = {
key: string;
alt?: boolean;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
};
export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut;
/** If true, the event handler will not execute if the event comes from an input field */
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
};
export const shortcutLabel = (shortcut: Shortcut) => {
let label = '';
if (shortcut.ctrl) {
label += 'Ctrl ';
}
if (shortcut.alt) {
label += 'Alt ';
}
if (shortcut.meta) {
label += 'Cmd ';
}
if (shortcut.shift) {
label += '⇧';
}
label += shortcut.key.toUpperCase();
return label;
};
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
* - The element dispatching the event is a map canvas
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
}
const type = (event.target as HTMLInputElement).type;
return (
['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type) ||
(event.target instanceof HTMLCanvasElement && event.target.classList.contains('maplibregl-canvas'))
);
};
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
return (
shortcut.key.toLowerCase() === event.key.toLowerCase() &&
Boolean(shortcut.alt) === event.altKey &&
Boolean(shortcut.ctrl) === event.ctrlKey &&
Boolean(shortcut.shift) === event.shiftKey &&
Boolean(shortcut.meta) === event.metaKey
);
};
/** Bind a single keyboard shortcut to node. */
export const shortcut = <T extends HTMLElement>(
node: T,
option: ShortcutOptions<T>,
): ActionReturn<ShortcutOptions<T>> => {
const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]);
return {
update(newOption) {
shortcutsUpdate?.([newOption]);
},
destroy,
};
};
/** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>(
node: T,
options: ShortcutOptions<T>[],
): ActionReturn<ShortcutOptions<T>[]> => {
function onKeydown(event: KeyboardEvent) {
const ignoreShortcut = shouldIgnoreEvent(event);
for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) {
if (ignoreInputFields && ignoreShortcut) {
continue;
}
if (matchesShortcut(event, shortcut)) {
if (preventDefault) {
event.preventDefault();
}
onShortcut(event as KeyboardEvent & { currentTarget: T });
return;
}
}
}
node.addEventListener('keydown', onKeydown);
return {
update(newOptions) {
options = newOptions;
},
destroy() {
node.removeEventListener('keydown', onKeydown);
},
};
};
export {
matchesShortcut,
shortcut,
shortcutLabel,
shortcuts,
shouldIgnoreEvent,
type Shortcut,
type ShortcutOptions,
} from '@immich/ui';

View File

@ -242,7 +242,6 @@
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}

View File

@ -20,8 +20,10 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
const didUpdate = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (didUpdate) {
clearSelect();
}
};
</script>

View File

@ -21,7 +21,7 @@
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { isModalOpen, modalManager } from '@immich/ui';
type Props = {
timelineManager: TimelineManager;
@ -142,7 +142,7 @@
};
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
if (searchStore.isSearchEnabled || $showAssetViewer || isModalOpen()) {
return [];
}

View File

@ -10,7 +10,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: () => void;
onClose: (updated?: boolean) => void;
assetIds: string[];
}
@ -33,7 +33,7 @@
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
onClose();
onClose(true);
};
const handleSelect = async (option?: ComboBoxOption) => {