From cbe89c5353702a7bfebd548a74e58ce46e6ba455 Mon Sep 17 00:00:00 2001 From: DustyShoe Date: Thu, 26 Mar 2026 08:38:07 +0200 Subject: [PATCH] Chore: Code cleanup --- invokeai/frontend/web/public/locales/en.json | 1 + .../hooks/useMiddleClickOpenInNewTab.ts | 72 +++++++++++++++++++ .../web/src/common/util/openImageInNewTab.ts | 3 + .../web/src/features/dnd/DndImage.tsx | 9 ++- .../MenuItems/ContextMenuItemOpenInNewTab.tsx | 3 +- .../components/ImageGrid/GalleryImage.tsx | 4 ++ .../SettingsModal/SettingsModal.tsx | 17 +++++ .../src/features/system/store/systemSlice.ts | 14 +++- .../web/src/features/system/store/types.ts | 3 +- 9 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 invokeai/frontend/web/src/common/hooks/useMiddleClickOpenInNewTab.ts create mode 100644 invokeai/frontend/web/src/common/util/openImageInNewTab.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0c72fc95107..65c3d450575 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1616,6 +1616,7 @@ "informationalPopoversDisabledDesc": "Informational popovers have been disabled. Enable them in Settings.", "enableModelDescriptions": "Enable Model Descriptions in Dropdowns", "enableHighlightFocusedRegions": "Highlight Focused Regions", + "middleClickOpenInNewTab": "Use Middle Click to Open Images in New Tab", "modelDescriptionsDisabled": "Model Descriptions in Dropdowns Disabled", "modelDescriptionsDisabledDesc": "Model descriptions in dropdowns have been disabled. Enable them in Settings.", "enableInvisibleWatermark": "Enable Invisible Watermark", diff --git a/invokeai/frontend/web/src/common/hooks/useMiddleClickOpenInNewTab.ts b/invokeai/frontend/web/src/common/hooks/useMiddleClickOpenInNewTab.ts new file mode 100644 index 00000000000..56f58e26a38 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useMiddleClickOpenInNewTab.ts @@ -0,0 +1,72 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { openImageInNewTab } from 'common/util/openImageInNewTab'; +import { selectSystemShouldUseMiddleClickToOpenInNewTab } from 'features/system/store/systemSlice'; +import type { RefObject } from 'react'; +import { useEffect } from 'react'; + +type Options = { + requireDirectTarget?: boolean; +}; + +const shouldHandleMiddleClick = ( + event: MouseEvent, + element: T, + requireDirectTarget: boolean +) => { + if (event.button !== 1) { + return false; + } + + if (requireDirectTarget && event.target !== element) { + return false; + } + + return true; +}; + +export const useMiddleClickOpenInNewTab = ( + ref: RefObject, + imageUrl: string, + { requireDirectTarget = false }: Options = {} +) => { + const shouldUseMiddleClickToOpenInNewTab = useAppSelector(selectSystemShouldUseMiddleClickToOpenInNewTab); + + useEffect(() => { + const element = ref.current; + + if (!element || !shouldUseMiddleClickToOpenInNewTab) { + return; + } + + // If auxclick is unsupported, leave the browser's default middle-click behavior intact. + if (!('onauxclick' in element)) { + return; + } + + const onMouseDown = (event: MouseEvent) => { + if (!shouldHandleMiddleClick(event, element, requireDirectTarget)) { + return; + } + + event.preventDefault(); + }; + + const onAuxClick = (event: MouseEvent) => { + if (!shouldHandleMiddleClick(event, element, requireDirectTarget)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + openImageInNewTab(imageUrl); + }; + + element.addEventListener('mousedown', onMouseDown); + element.addEventListener('auxclick', onAuxClick); + + return () => { + element.removeEventListener('mousedown', onMouseDown); + element.removeEventListener('auxclick', onAuxClick); + }; + }, [imageUrl, ref, requireDirectTarget, shouldUseMiddleClickToOpenInNewTab]); +}; diff --git a/invokeai/frontend/web/src/common/util/openImageInNewTab.ts b/invokeai/frontend/web/src/common/util/openImageInNewTab.ts new file mode 100644 index 00000000000..3e8e13334c2 --- /dev/null +++ b/invokeai/frontend/web/src/common/util/openImageInNewTab.ts @@ -0,0 +1,3 @@ +export const openImageInNewTab = (imageUrl: string) => { + window.open(imageUrl, '_blank', 'noopener,noreferrer'); +}; diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 2c7e4e8ad30..ecc11ee5975 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -2,7 +2,7 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; -import { useAppStore } from 'app/store/storeHooks'; +import { useMiddleClickOpenInNewTab } from 'common/hooks/useMiddleClickOpenInNewTab'; import { singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; @@ -15,7 +15,6 @@ const sx = { objectFit: 'contain', maxW: 'full', maxH: 'full', - cursor: 'grab', '&[data-is-dragging=true]': { opacity: 0.3, }, @@ -28,13 +27,13 @@ type Props = { export const DndImage = memo( forwardRef(({ imageDTO, asThumbnail, ...rest }: Props, forwardedRef) => { - const store = useAppStore(); - const [isDragging, setIsDragging] = useState(false); const ref = useRef(null); useImperativeHandle(forwardedRef, () => ref.current!, []); const [dragPreviewState, setDragPreviewState] = useState(null); + useMiddleClickOpenInNewTab(ref, imageDTO.image_url); + useEffect(() => { const element = ref.current; if (!element) { @@ -62,7 +61,7 @@ export const DndImage = memo( }, }) ); - }, [forwardedRef, imageDTO, store]); + }, [imageDTO]); useImageContextMenu(imageDTO, ref); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx index 80e99fbfb7a..4d086136d92 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx @@ -1,4 +1,5 @@ import { IconMenuItem } from 'common/components/IconMenuItem'; +import { openImageInNewTab } from 'common/util/openImageInNewTab'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,7 +9,7 @@ export const ContextMenuItemOpenInNewTab = memo(() => { const { t } = useTranslation(); const imageDTO = useImageDTOContext(); const onClick = useCallback(() => { - window.open(imageDTO.image_url, '_blank'); + openImageInNewTab(imageDTO.image_url); }, [imageDTO]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index ccd58992ef6..7dbc3acf0d1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -5,6 +5,7 @@ import { Flex, Icon, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import type { AppDispatch, AppGetState } from 'app/store/store'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { useMiddleClickOpenInNewTab } from 'common/hooks/useMiddleClickOpenInNewTab'; import { uniq } from 'es-toolkit'; import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage'; @@ -184,6 +185,9 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { }, []); const onClick = useMemo(() => buildOnClick(imageDTO.image_name, store.dispatch, store.getState), [imageDTO, store]); + useMiddleClickOpenInNewTab(ref, imageDTO.image_url, { + requireDirectTarget: true, + }); const onDoubleClick = useCallback>(() => { store.dispatch(imageToCompareChanged(null)); diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index b95d2adb47c..53162881fe8 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -35,6 +35,7 @@ import { selectSystemShouldEnableInformationalPopovers, selectSystemShouldEnableModelDescriptions, selectSystemShouldShowInvocationProgressDetail, + selectSystemShouldUseMiddleClickToOpenInNewTab, selectSystemShouldUseNSFWChecker, selectSystemShouldUseWatermarker, setPrefersNumericAttentionStyle, @@ -43,6 +44,7 @@ import { setShouldEnableModelDescriptions, setShouldHighlightFocusedRegions, setShouldShowInvocationProgressDetail, + setShouldUseMiddleClickToOpenInNewTab, shouldAntialiasProgressImageChanged, shouldConfirmOnNewSessionToggled, shouldUseNSFWCheckerChanged, @@ -83,6 +85,7 @@ const SettingsModal = (props: { children: ReactElement }) => { const shouldEnableInformationalPopovers = useAppSelector(selectSystemShouldEnableInformationalPopovers); const shouldEnableModelDescriptions = useAppSelector(selectSystemShouldEnableModelDescriptions); const shouldHighlightFocusedRegions = useAppSelector(selectSystemShouldEnableHighlightFocusedRegions); + const shouldUseMiddleClickToOpenInNewTab = useAppSelector(selectSystemShouldUseMiddleClickToOpenInNewTab); const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); const shouldShowInvocationProgressDetail = useAppSelector(selectSystemShouldShowInvocationProgressDetail); const onToggleConfirmOnNewSession = useCallback(() => { @@ -165,6 +168,13 @@ const SettingsModal = (props: { children: ReactElement }) => { [dispatch] ); + const handleChangeShouldUseMiddleClickToOpenInNewTab = useCallback( + (e: ChangeEvent) => { + dispatch(setShouldUseMiddleClickToOpenInNewTab(e.target.checked)); + }, + [dispatch] + ); + const handleChangePreferAttentionStyleNumeric = useCallback( (e: ChangeEvent) => { dispatch(setPrefersNumericAttentionStyle(e.target.checked)); @@ -258,6 +268,13 @@ const SettingsModal = (props: { children: ReactElement }) => { onChange={handleChangeShouldHighlightFocusedRegions} /> + + {t('settings.middleClickOpenInNewTab')} + + diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index e4346a17940..f1bc126d877 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -12,7 +12,7 @@ import { assert } from 'tsafe'; import { type Language, type SystemState, zSystemState } from './types'; const getInitialState = (): SystemState => ({ - _version: 2, + _version: 3, shouldConfirmOnDelete: true, shouldAntialiasProgressImage: false, shouldConfirmOnNewSession: true, @@ -26,6 +26,7 @@ const getInitialState = (): SystemState => ({ logNamespaces: [...zLogNamespace.options], shouldShowInvocationProgressDetail: false, shouldHighlightFocusedRegions: false, + shouldUseMiddleClickToOpenInNewTab: false, prefersNumericAttentionWeights: false, }); @@ -79,6 +80,9 @@ const slice = createSlice({ setShouldHighlightFocusedRegions(state, action: PayloadAction) { state.shouldHighlightFocusedRegions = action.payload; }, + setShouldUseMiddleClickToOpenInNewTab(state, action: PayloadAction) { + state.shouldUseMiddleClickToOpenInNewTab = action.payload; + }, }, }); @@ -97,6 +101,7 @@ export const { setShouldShowInvocationProgressDetail, setPrefersNumericAttentionStyle, setShouldHighlightFocusedRegions, + setShouldUseMiddleClickToOpenInNewTab, } = slice.actions; export const systemSliceConfig: SliceConfig = { @@ -113,6 +118,10 @@ export const systemSliceConfig: SliceConfig = { state.language = (state as SystemState).language.replace('_', '-'); state._version = 2; } + if (state._version === 2) { + state.shouldUseMiddleClickToOpenInNewTab = false; + state._version = 3; + } return zSystemState.parse(state); }, }, @@ -141,6 +150,9 @@ export const selectSystemShouldEnableModelDescriptions = createSystemSelector( export const selectSystemShouldEnableHighlightFocusedRegions = createSystemSelector( (system) => system.shouldHighlightFocusedRegions ); +export const selectSystemShouldUseMiddleClickToOpenInNewTab = createSystemSelector( + (system) => system.shouldUseMiddleClickToOpenInNewTab +); export const selectSystemPrefersNumericAttentionWeights = createSystemSelector( (system) => system.prefersNumericAttentionWeights ); diff --git a/invokeai/frontend/web/src/features/system/store/types.ts b/invokeai/frontend/web/src/features/system/store/types.ts index 4cf4fb784b8..106cb5d7094 100644 --- a/invokeai/frontend/web/src/features/system/store/types.ts +++ b/invokeai/frontend/web/src/features/system/store/types.ts @@ -30,7 +30,7 @@ export type Language = z.infer; export const isLanguage = (v: unknown): v is Language => zLanguage.safeParse(v).success; export const zSystemState = z.object({ - _version: z.literal(2), + _version: z.literal(3), shouldConfirmOnDelete: z.boolean(), shouldAntialiasProgressImage: z.boolean(), shouldConfirmOnNewSession: z.boolean(), @@ -44,6 +44,7 @@ export const zSystemState = z.object({ logNamespaces: z.array(zLogNamespace), shouldShowInvocationProgressDetail: z.boolean(), shouldHighlightFocusedRegions: z.boolean(), + shouldUseMiddleClickToOpenInNewTab: z.boolean(), prefersNumericAttentionWeights: z.boolean(), }); export type SystemState = z.infer;