From 913904f4188cdcaac133c69490fdfd73cda06845 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:55:28 +0100 Subject: [PATCH] fix(web): escape shortcut handling (#26096) --- web/src/lib/actions/shortcut.ts | 121 ++---------------- .../search-bar/search-bar.svelte | 1 - .../timeline/actions/TagAction.svelte | 6 +- .../actions/TimelineKeyboardActions.svelte | 4 +- web/src/lib/modals/AssetTagModal.svelte | 4 +- 5 files changed, 17 insertions(+), 119 deletions(-) diff --git a/web/src/lib/actions/shortcut.ts b/web/src/lib/actions/shortcut.ts index 8f01ce8924..b047dfc391 100644 --- a/web/src/lib/actions/shortcut.ts +++ b/web/src/lib/actions/shortcut.ts @@ -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 = { - 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 = ( - node: T, - option: ShortcutOptions, -): ActionReturn> => { - const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]); - - return { - update(newOption) { - shortcutsUpdate?.([newOption]); - }, - destroy, - }; -}; - -/** Binds multiple keyboard shortcuts to node */ -export const shortcuts = ( - node: T, - options: ShortcutOptions[], -): ActionReturn[]> => { - 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'; diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index fb1b616109..6a84acff44 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -242,7 +242,6 @@ input?.select() }, { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, ]} diff --git a/web/src/lib/components/timeline/actions/TagAction.svelte b/web/src/lib/components/timeline/actions/TagAction.svelte index 50ff3138e0..63748cd214 100644 --- a/web/src/lib/components/timeline/actions/TagAction.svelte +++ b/web/src/lib/components/timeline/actions/TagAction.svelte @@ -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(); + } }; diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index 77f5300180..a5fa34289b 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -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 []; } diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index c0c7f8b10a..74daf75659 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -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) => {