Compare commits

...

21 Commits

Author SHA1 Message Date
psychedelicious
70ac58e64a tidy(ui): remove unused props 2025-07-25 18:51:21 +10:00
psychedelicious
e653837236 fix(ui): add separate wrapper components for notes and current image nodes that do not need invocation node context 2025-07-25 18:51:21 +10:00
psychedelicious
2bbfcc2f13 fix(ui): ensure all node context provider wraps all calls to useInvocationNodeContext 2025-07-25 18:51:21 +10:00
psychedelicious
d6e0e439c5 perf(ui): imperatively get nodes and edges in autolayout hook 2025-07-25 18:50:59 +10:00
psychedelicious
26aab60f81 chore: bump version to v6.2.0 2025-07-25 18:41:00 +10:00
Riccardo Giovanetti
7bea2fa11f translationBot(ui): update translation (Italian)
Currently translated at 98.6% (2016 of 2044 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.6% (2015 of 2043 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2025-07-25 17:15:01 +10:00
psychedelicious
169d58ea4c feat(ui): restore clear queue button
It is accessible in two places:
- The queue actions hamburger menu.
- On the queue tab.

If the clear queue app feature is disabled, it is not shown in either of
those places.
2025-07-23 23:38:53 +10:00
psychedelicious
b53d2250f7 feat(ui): reduce snap tolerance to make it easier to break the snap 2025-07-23 23:05:40 +10:00
psychedelicious
242eea8295 fix(ui): incorrect zoom direction w/ small scroll amounts 2025-07-23 23:05:40 +10:00
psychedelicious
4dabe09e0d tests(ui): remove test for no-longer-valid behaviour 2025-07-23 23:03:02 +10:00
psychedelicious
07fa0d3b77 fix(ui): do not attempt toggle when target panel isn't registered 2025-07-23 23:03:02 +10:00
psychedelicious
e97f82292f tests(ui): add tests for disposable handling 2025-07-23 23:03:02 +10:00
psychedelicious
005bab9035 fix(ui): tab disposables not being added correctly 2025-07-23 23:03:02 +10:00
psychedelicious
409173919c tests(ui): add tests for toggleViewer functionality 2025-07-23 23:03:02 +10:00
psychedelicious
7915180047 feat(ui): restore viewer toggle hotkey 2025-07-23 23:03:02 +10:00
Riccardo Giovanetti
4349b8387d translationBot(ui): update translation (Italian)
Currently translated at 97.9% (2000 of 2042 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2025-07-23 12:26:48 +10:00
Kent Keirsey
f95b686bdc reposition export button 2025-07-23 11:55:11 +10:00
Mary Hipp
72afb9c3fd fix iterations for all API models 2025-07-22 13:27:35 -04:00
Mary Hipp
f004fc31f1 update whats new 2025-07-22 12:24:10 -04:00
psychedelicious
2aa163b3a2 feat(ui): add default inpaint mask layer on canvas reset 2025-07-22 10:26:57 +10:00
psychedelicious
f40900c173 chore: bump version to v6.1.0 2025-07-22 08:24:31 +10:00
25 changed files with 967 additions and 152 deletions

View File

@@ -253,6 +253,7 @@
"cancel": "Cancel",
"cancelAllExceptCurrentQueueItemAlertDialog": "Canceling all queue items except the current one will stop pending items but allow the in-progress one to finish.",
"cancelAllExceptCurrentQueueItemAlertDialog2": "Are you sure you want to cancel all pending queue items?",
"cancelAllExceptCurrent": "Cancel All Except Current",
"cancelAllExceptCurrentTooltip": "Cancel All Except Current Item",
"cancelTooltip": "Cancel Current Item",
"cancelSucceeded": "Item Canceled",
@@ -273,7 +274,7 @@
"retryItem": "Retry Item",
"cancelBatchSucceeded": "Batch Canceled",
"cancelBatchFailed": "Problem Canceling Batch",
"clearQueueAlertDialog": "Clearing the queue immediately cancels any processing items and clears the queue entirely. Pending filters will be canceled.",
"clearQueueAlertDialog": "Clearing the queue immediately cancels any processing items and clears the queue entirely. Pending filters will be canceled and the Canvas Staging Area will be reset.",
"clearQueueAlertDialog2": "Are you sure you want to clear the queue?",
"current": "Current",
"next": "Next",
@@ -2630,9 +2631,10 @@
"whatsNew": {
"whatsNewInInvoke": "What's New in Invoke",
"items": [
"Generate images faster with new Launchpads and a simplified Generate tab.",
"Edit with prompts using Flux Kontext Dev.",
"Export to PSD, bulk-hide overlays, organize models & images — all in a reimagined interface built for control."
"New setting to send all Canvas generations directly to the Gallery.",
"New Invert Mask (Shift+V) and Fit BBox to Mask (Shift+B) capabilities.",
"Expanded support for Model Thumbnails and configurations.",
"Various other quality of life updates and fixes"
],
"readReleaseNotes": "Read Release Notes",
"watchRecentReleaseVideos": "Watch Recent Release Videos",

View File

@@ -254,12 +254,16 @@
"desc": "Attiva/disattiva il pannello destro."
},
"resetPanelLayout": {
"title": "Ripristina il layout del pannello",
"desc": "Ripristina le dimensioni e il layout predefiniti dei pannelli sinistro e destro."
"title": "Ripristina lo schema del pannello",
"desc": "Ripristina le dimensioni e lo schema predefiniti dei pannelli sinistro e destro."
},
"togglePanels": {
"title": "Attiva/disattiva i pannelli",
"desc": "Mostra o nascondi contemporaneamente i pannelli sinistro e destro."
},
"selectGenerateTab": {
"title": "Seleziona la scheda Genera",
"desc": "Seleziona la scheda Genera."
}
},
"hotkeys": "Tasti di scelta rapida",
@@ -389,6 +393,23 @@
"behavior": "Comportamento",
"display": "Mostra",
"grid": "Griglia"
},
"invertMask": {
"title": "Inverti maschera",
"desc": "Inverte la maschera di inpaint selezionata, creando una nuova maschera con trasparenza opposta."
},
"fitBboxToMasks": {
"title": "Adatta il riquadro di delimitazione alle maschere",
"desc": "Regola automaticamente il riquadro di delimitazione della generazione per adattarlo alle maschere di inpaint visibili"
},
"applySegmentAnything": {
"title": "Applica Segment Anything",
"desc": "Applica la maschera Segment Anything corrente.",
"key": "invio"
},
"cancelSegmentAnything": {
"title": "Annulla Segment Anything",
"desc": "Annulla l'operazione Segment Anything corrente."
}
},
"workflows": {
@@ -518,6 +539,10 @@
"galleryNavUpAlt": {
"desc": "Uguale a Naviga verso l'alto, ma seleziona l'immagine da confrontare, aprendo la modalità di confronto se non è già aperta.",
"title": "Naviga verso l'alto (Confronta immagine)"
},
"starImage": {
"desc": "Aggiungi/Rimuovi contrassegno all'immagine selezionata.",
"title": "Aggiungi / Rimuovi contrassegno immagine"
}
}
},
@@ -936,7 +961,15 @@
"canvasManagerNotAvailable": "Gestione tela non disponibile",
"promptExpansionFailed": "Abbiamo riscontrato un problema. Riprova a eseguire l'espansione del prompt.",
"uploadAndPromptGenerationFailed": "Impossibile caricare l'immagine e generare il prompt",
"promptGenerationStarted": "Generazione del prompt avviata"
"promptGenerationStarted": "Generazione del prompt avviata",
"invalidBboxDesc": "Il riquadro di delimitazione non ha dimensioni valide",
"invalidBbox": "Riquadro di delimitazione non valido",
"noInpaintMaskSelectedDesc": "Seleziona una maschera di inpaint da invertire",
"noInpaintMaskSelected": "Nessuna maschera di inpaint selezionata",
"noVisibleMasksDesc": "Crea o abilita almeno una maschera inpaint da invertire",
"noVisibleMasks": "Nessuna maschera visibile",
"maskInvertFailed": "Impossibile invertire la maschera",
"maskInverted": "Maschera invertita"
},
"accessibility": {
"invokeProgressBar": "Barra di avanzamento generazione",
@@ -1131,7 +1164,22 @@
"missingField_withName": "Campo \"{{name}}\" mancante",
"unknownFieldEditWorkflowToFix_withName": "Il flusso di lavoro contiene un campo \"{{name}}\" sconosciuto .\nModifica il flusso di lavoro per risolvere il problema.",
"unexpectedField_withName": "Campo \"{{name}}\" inaspettato",
"missingSourceOrTargetHandle": "Identificatore del nodo sorgente o di destinazione mancante"
"missingSourceOrTargetHandle": "Identificatore del nodo sorgente o di destinazione mancante",
"layout": {
"alignmentDR": "In basso a destra",
"autoLayout": "Schema automatico",
"nodeSpacing": "Spaziatura nodi",
"layerSpacing": "Spaziatura livelli",
"layeringStrategy": "Strategia livelli",
"longestPath": "Percorso più lungo",
"layoutDirection": "Direzione schema",
"layoutDirectionRight": "Orizzontale",
"layoutDirectionDown": "Verticale",
"alignment": "Allineamento nodi",
"alignmentUL": "In alto a sinistra",
"alignmentDL": "In basso a sinistra",
"alignmentUR": "In alto a destra"
}
},
"boards": {
"autoAddBoard": "Aggiungi automaticamente bacheca",
@@ -1208,7 +1256,7 @@
"batchQueuedDesc_other": "Aggiunte {{count}} sessioni a {{direction}} della coda",
"graphQueued": "Grafico in coda",
"batch": "Lotto",
"clearQueueAlertDialog": "Lo svuotamento della coda annulla immediatamente tutti gli elementi in elaborazione e cancella completamente la coda. I filtri in sospeso verranno annullati.",
"clearQueueAlertDialog": "La cancellazione della coda annulla immediatamente tutti gli elementi in elaborazione e cancella completamente la coda. I filtri in sospeso verranno annullati e l'area di lavoro della Tela verrà reimpostata.",
"pending": "In attesa",
"completedIn": "Completato in",
"resumeFailed": "Problema nel riavvio dell'elaborazione",
@@ -1264,7 +1312,8 @@
"retrySucceeded": "Elemento rieseguito",
"retryItem": "Riesegui elemento",
"retryFailed": "Problema riesecuzione elemento",
"credits": "Crediti"
"credits": "Crediti",
"cancelAllExceptCurrent": "Annulla tutto tranne quello corrente"
},
"models": {
"noMatchingModels": "Nessun modello corrispondente",
@@ -1679,7 +1728,7 @@
"structure": {
"heading": "Struttura",
"paragraphs": [
"La struttura determina quanto l'immagine finale rispecchierà il layout dell'originale. Una struttura bassa permette cambiamenti significativi, mentre una struttura alta conserva la composizione e il layout originali."
"La struttura determina quanto l'immagine finale rispecchierà il layout dell'originale. Un valore struttura basso permette cambiamenti significativi, mentre un valore struttura alto conserva la composizione e lo schema originali."
]
},
"fluxDevLicense": {
@@ -1845,7 +1894,7 @@
"opened": "Aperto",
"convertGraph": "Converti grafico",
"loadWorkflow": "$t(common.load) Flusso di lavoro",
"autoLayout": "Disposizione automatica",
"autoLayout": "Schema automatico",
"loadFromGraph": "Carica il flusso di lavoro dal grafico",
"userWorkflows": "Flussi di lavoro utente",
"projectWorkflows": "Flussi di lavoro del progetto",
@@ -2444,7 +2493,9 @@
"switchOnStart": "All'inizio",
"switchOnFinish": "Alla fine",
"off": "Spento"
}
},
"invertMask": "Inverti maschera",
"fitBboxToMasks": "Adatta il riquadro di delimitazione alle maschere"
},
"ui": {
"tabs": {
@@ -2597,9 +2648,10 @@
"watchRecentReleaseVideos": "Guarda i video su questa versione",
"watchUiUpdatesOverview": "Guarda le novità dell'interfaccia",
"items": [
"Genera immagini più velocemente con le nuove Rampe di lancio e una scheda Genera semplificata.",
"Modifica con prompt utilizzando Flux Kontext Dev.",
"Esporta in PSD, nascondi sovrapposizioni in blocco, organizza modelli e immagini: il tutto in un'interfaccia riprogettata e pensata per il controllo."
"Nuova impostazione per inviare tutte le generazioni della Tela direttamente alla Galleria.",
"Nuove funzionalità Inverti maschera (Maiusc+V) e Adatta il Riquadro di delimitazione alla maschera (Maiusc+B).",
"Supporto esteso per miniature e configurazioni dei modelli.",
"Vari altri aggiornamenti e correzioni per la qualità della vita"
]
},
"system": {

View File

@@ -1,6 +1,8 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { allEntitiesDeleted } from 'features/controlLayers/store/canvasSlice';
import { canvasReset } from 'features/controlLayers/store/actions';
import { inpaintMaskAdded } from 'features/controlLayers/store/canvasSlice';
import { $canvasManager } from 'features/controlLayers/store/ephemeral';
import { paramsReset } from 'features/controlLayers/store/paramsSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,7 +13,9 @@ export const SessionMenuItems = memo(() => {
const dispatch = useAppDispatch();
const resetCanvasLayers = useCallback(() => {
dispatch(allEntitiesDeleted());
dispatch(canvasReset());
dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true }));
$canvasManager.get()?.stage.fitBboxToStage();
}, [dispatch]);
const resetGenerationSettings = useCallback(() => {
dispatch(paramsReset());

View File

@@ -139,4 +139,13 @@ export const useGlobalHotkeys = () => {
},
dependencies: [getState, deleteImageModalApi],
});
useRegisteredHotkeys({
id: 'toggleViewer',
category: 'viewer',
callback: () => {
navigationApi.toggleViewerPanel();
},
dependencies: [],
});
};

View File

@@ -165,9 +165,9 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
<Spacer />
</Flex>
{type === 'raster_layer' && <RasterLayerExportPSDButton />}
<CanvasEntityMergeVisibleButton type={type} />
<CanvasEntityTypeIsHiddenToggle type={type} />
{type === 'raster_layer' && <RasterLayerExportPSDButton />}
<CanvasEntityAddOfTypeButton type={type} />
</Flex>
<Collapse in={collapse.isTrue} style={fixTooltipCloseOnScrollStyles}>

View File

@@ -42,7 +42,7 @@ const DEFAULT_CONFIG: CanvasStageModuleConfig = {
SCALE_FACTOR: 0.999,
FIT_LAYERS_TO_STAGE_PADDING_PX: 48,
SCALE_SNAP_POINTS: [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 5],
SCALE_SNAP_TOLERANCE: 0.05,
SCALE_SNAP_TOLERANCE: 0.02,
};
export class CanvasStageModule extends CanvasModuleBase {
@@ -366,11 +366,22 @@ export class CanvasStageModule extends CanvasModuleBase {
if (deltaT > 300) {
dynamicScaleFactor = this.config.SCALE_FACTOR + (1 - this.config.SCALE_FACTOR) / 2;
} else if (deltaT < 300) {
dynamicScaleFactor = this.config.SCALE_FACTOR + (1 - this.config.SCALE_FACTOR) * (deltaT / 200);
// Ensure dynamic scale factor stays below 1 to maintain zoom-out direction - if it goes over, we could end up
// zooming in the wrong direction with small scroll amounts
const maxScaleFactor = 0.9999;
dynamicScaleFactor = Math.min(
this.config.SCALE_FACTOR + (1 - this.config.SCALE_FACTOR) * (deltaT / 200),
maxScaleFactor
);
}
// Update the intended scale based on the last intended scale, creating a continuous zoom feel
const newIntendedScale = this._intendedScale * dynamicScaleFactor ** scrollAmount;
// Handle the sign explicitly to prevent direction reversal with small scroll amounts
const scaleFactor =
scrollAmount > 0
? dynamicScaleFactor ** Math.abs(scrollAmount)
: (1 / dynamicScaleFactor) ** Math.abs(scrollAmount);
const newIntendedScale = this._intendedScale * scaleFactor;
this._intendedScale = this.constrainScale(newIntendedScale);
// Pass control to the snapping logic
@@ -397,6 +408,9 @@ export class CanvasStageModule extends CanvasModuleBase {
// User has scrolled far enough to break the snap
this._activeSnapPoint = null;
this._applyScale(this._intendedScale, center);
} else {
// Reset intended scale to prevent drift while snapped
this._intendedScale = this._activeSnapPoint;
}
// Else, do nothing - we remain snapped at the current scale, creating a "dead zone"
return;

View File

@@ -1618,7 +1618,6 @@ export const {
entityArrangedToBack,
entityOpacityChanged,
entitiesReordered,
allEntitiesDeleted,
allEntitiesOfTypeIsHiddenToggled,
allNonRasterLayersIsHiddenToggled,
// bbox

View File

@@ -21,7 +21,14 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
await newCanvasFromImage({
imageDTO,
withResize: false,
withInpaintMask: true,
type: 'raster_layer',
dispatch,
getState,
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -32,7 +39,14 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
await newCanvasFromImage({
imageDTO,
withResize: false,
withInpaintMask: true,
type: 'control_layer',
dispatch,
getState,
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -43,7 +57,14 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
await newCanvasFromImage({
imageDTO,
withResize: true,
withInpaintMask: true,
type: 'raster_layer',
dispatch,
getState,
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -54,7 +75,14 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
await newCanvasFromImage({
imageDTO,
withResize: true,
withInpaintMask: true,
type: 'control_layer',
dispatch,
getState,
});
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),

View File

@@ -6,7 +6,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { DndImage } from 'features/dnd/DndImage';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import type { AnimationProps } from 'framer-motion';
import { motion } from 'framer-motion';
@@ -58,13 +58,14 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
}, []);
const { t } = useTranslation();
return (
<NodeWrapper nodeId={props.nodeProps.id} selected={props.nodeProps.selected} width={384}>
<NonInvocationNodeWrapper nodeId={props.nodeProps.id} selected={props.nodeProps.selected} width={384}>
<Flex
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={DRAG_HANDLE_CLASSNAME}
position="relative"
flexDirection="column"
aspectRatio="1/1"
>
<Flex layerStyle="nodeHeader" borderTopRadius="base" alignItems="center" justifyContent="center" h={8}>
<Text fontSize="sm" fontWeight="semibold" color="base.200">
@@ -80,7 +81,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
)}
</Flex>
</Flex>
</NodeWrapper>
</NonInvocationNodeWrapper>
);
};

View File

@@ -3,8 +3,8 @@ import { createSelector } from '@reduxjs/toolkit';
import type { Node, NodeProps } from '@xyflow/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton';
import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import NonInvocationNodeTitle from 'features/nodes/components/flow/nodes/common/NonInvocationNodeTitle';
import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper';
import { notesNodeValueChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants';
@@ -34,7 +34,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
}
return (
<NodeWrapper nodeId={nodeId} selected={selected}>
<NonInvocationNodeWrapper nodeId={nodeId} selected={selected}>
<Flex
layerStyle="nodeHeader"
borderTopRadius="base"
@@ -44,7 +44,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
h={8}
>
<NodeCollapseButton nodeId={nodeId} isOpen={isOpen} />
<NodeTitle nodeId={nodeId} title="Notes" />
<NonInvocationNodeTitle nodeId={nodeId} title="Notes" />
<Box minW={8} />
</Flex>
{isOpen && (
@@ -73,7 +73,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
</Flex>
</>
)}
</NodeWrapper>
</NonInvocationNodeWrapper>
);
};

View File

@@ -1,4 +1,4 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
@@ -12,6 +12,8 @@ import { zNodeStatus } from 'features/nodes/types/invocation';
import type { MouseEvent, PropsWithChildren } from 'react';
import { memo, useCallback } from 'react';
import { containerSx, inProgressSx, shadowsSx } from './shared';
type NodeWrapperProps = PropsWithChildren & {
nodeId: string;
selected: boolean;
@@ -19,100 +21,6 @@ type NodeWrapperProps = PropsWithChildren & {
isMissingTemplate?: boolean;
};
// Certain CSS transitions are disabled as a performance optimization - they can cause massive slowdowns in large
// workflows even when the animations are GPU-accelerated CSS.
const containerSx: SystemStyleObject = {
h: 'full',
position: 'relative',
borderRadius: 'base',
transitionProperty: 'none',
cursor: 'grab',
'--border-color': 'var(--invoke-colors-base-500)',
'--border-color-selected': 'var(--invoke-colors-blue-300)',
'--header-bg-color': 'var(--invoke-colors-base-900)',
'&[data-status="warning"]': {
'--border-color': 'var(--invoke-colors-warning-500)',
'--border-color-selected': 'var(--invoke-colors-warning-500)',
'--header-bg-color': 'var(--invoke-colors-warning-700)',
},
'&[data-status="error"]': {
'--border-color': 'var(--invoke-colors-error-500)',
'--border-color-selected': 'var(--invoke-colors-error-500)',
'--header-bg-color': 'var(--invoke-colors-error-700)',
},
// The action buttons are hidden by default and shown on hover
'& .node-selection-overlay': {
display: 'block',
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
transitionProperty: 'none',
pointerEvents: 'none',
shadow: '0 0 0 1px var(--border-color)',
},
'&[data-is-mouse-over-node="true"] .node-selection-overlay': {
display: 'block',
},
'&[data-is-mouse-over-form-field="true"] .node-selection-overlay': {
display: 'block',
bg: 'invokeBlueAlpha.100',
},
_hover: {
'& .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 1px var(--border-color-selected)',
},
'&[data-is-selected="true"] .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 2px var(--border-color-selected)',
},
},
'&[data-is-selected="true"] .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 2px var(--border-color-selected)',
},
'&[data-is-editor-locked="true"]': {
'& *': {
cursor: 'not-allowed',
pointerEvents: 'none',
},
},
};
const shadowsSx: SystemStyleObject = {
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
pointerEvents: 'none',
zIndex: -1,
shadow: 'var(--invoke-shadows-xl), var(--invoke-shadows-base), var(--invoke-shadows-base)',
};
const inProgressSx: SystemStyleObject = {
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'md',
pointerEvents: 'none',
transitionProperty: 'none',
opacity: 0.7,
zIndex: -1,
display: 'none',
shadow: '0 0 0 2px var(--invoke-colors-yellow-400), 0 0 20px 2px var(--invoke-colors-orange-700)',
'&[data-is-in-progress="true"]': {
display: 'block',
},
};
const NodeWrapper = (props: NodeWrapperProps) => {
const { nodeId, width, children, isMissingTemplate, selected } = props;
const ctx = useInvocationNodeContext();

View File

@@ -0,0 +1,69 @@
import { Flex, Input, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
import { selectNodes } from 'features/nodes/store/selectors';
import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
title: string;
};
const NonInvocationNodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const selectNodeLabel = useMemo(
() =>
createSelector(selectNodes, (nodes) => {
const node = nodes.find((n) => n.id === nodeId);
return node?.data?.label ?? '';
}),
[nodeId]
);
const label = useAppSelector(selectNodeLabel);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(
(label: string) => {
dispatch(nodeLabelChanged({ nodeId, label }));
},
[dispatch, nodeId]
);
const editable = useEditable({
value: label || title || t('nodes.problemSettingTitle'),
defaultValue: title || t('nodes.problemSettingTitle'),
onChange,
inputRef,
});
return (
<Flex overflow="hidden" w="full" h="full" alignItems="center" justifyContent="center">
{!editable.isEditing && (
<Text
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
fontWeight="semibold"
color="base.200"
onDoubleClick={editable.startEditing}
noOfLines={1}
>
{editable.value}
</Text>
)}
{editable.isEditing && (
<Input
ref={inputRef}
{...editable.inputProps}
variant="outline"
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
/>
)}
</Flex>
);
};
export default memo(NonInvocationNodeTitle);

View File

@@ -0,0 +1,80 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode';
import { selectNodeOpacity } from 'features/nodes/store/workflowSettingsSlice';
import { DRAG_HANDLE_CLASSNAME, NO_FIT_ON_DOUBLE_CLICK_CLASS, NODE_WIDTH } from 'features/nodes/types/constants';
import { zNodeStatus } from 'features/nodes/types/invocation';
import type { MouseEvent, PropsWithChildren } from 'react';
import { memo, useCallback } from 'react';
import { containerSx, inProgressSx, shadowsSx } from './shared';
type NonInvocationNodeWrapperProps = PropsWithChildren & {
nodeId: string;
selected: boolean;
width?: ChakraProps['w'];
};
const NonInvocationNodeWrapper = (props: NonInvocationNodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const mouseOverNode = useMouseOverNode(nodeId);
const zoomToNode = useZoomToNode(nodeId);
const isLocked = useIsWorkflowEditorLocked();
const executionState = useNodeExecutionState(nodeId);
const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS;
const opacity = useAppSelector(selectNodeOpacity);
const globalMenu = useGlobalMenuClose();
const onDoubleClick = useCallback(
(e: MouseEvent) => {
if (!(e.target instanceof HTMLElement)) {
// We have to manually narrow the type here thanks to a TS quirk
return;
}
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement ||
e.target instanceof HTMLButtonElement ||
e.target instanceof HTMLAnchorElement
) {
// Don't fit the view if the user is editing a text field, select, button, or link
return;
}
if (e.target.closest(`.${NO_FIT_ON_DOUBLE_CLICK_CLASS}`) !== null) {
// This target is marked as not fitting the view on double click
return;
}
zoomToNode();
},
[zoomToNode]
);
return (
<Box
onClick={globalMenu.onCloseGlobal}
onDoubleClick={onDoubleClick}
onMouseOver={mouseOverNode.handleMouseOver}
onMouseOut={mouseOverNode.handleMouseOut}
className={DRAG_HANDLE_CLASSNAME}
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-editor-locked={isLocked}
data-is-selected={selected}
>
<Box sx={shadowsSx} />
<Box sx={inProgressSx} data-is-in-progress={isInProgress} />
{children}
<Box className="node-selection-overlay" />
</Box>
);
};
export default memo(NonInvocationNodeWrapper);

View File

@@ -0,0 +1,95 @@
// Certain CSS transitions are disabled as a performance optimization - they can cause massive slowdowns in large
// workflows even when the animations are GPU-accelerated CSS.
import type { SystemStyleObject } from '@invoke-ai/ui-library';
export const containerSx: SystemStyleObject = {
h: 'full',
position: 'relative',
borderRadius: 'base',
transitionProperty: 'none',
cursor: 'grab',
'--border-color': 'var(--invoke-colors-base-500)',
'--border-color-selected': 'var(--invoke-colors-blue-300)',
'--header-bg-color': 'var(--invoke-colors-base-900)',
'&[data-status="warning"]': {
'--border-color': 'var(--invoke-colors-warning-500)',
'--border-color-selected': 'var(--invoke-colors-warning-500)',
'--header-bg-color': 'var(--invoke-colors-warning-700)',
},
'&[data-status="error"]': {
'--border-color': 'var(--invoke-colors-error-500)',
'--border-color-selected': 'var(--invoke-colors-error-500)',
'--header-bg-color': 'var(--invoke-colors-error-700)',
},
// The action buttons are hidden by default and shown on hover
'& .node-selection-overlay': {
display: 'block',
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
transitionProperty: 'none',
pointerEvents: 'none',
shadow: '0 0 0 1px var(--border-color)',
},
'&[data-is-mouse-over-node="true"] .node-selection-overlay': {
display: 'block',
},
'&[data-is-mouse-over-form-field="true"] .node-selection-overlay': {
display: 'block',
bg: 'invokeBlueAlpha.100',
},
_hover: {
'& .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 1px var(--border-color-selected)',
},
'&[data-is-selected="true"] .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 2px var(--border-color-selected)',
},
},
'&[data-is-selected="true"] .node-selection-overlay': {
display: 'block',
shadow: '0 0 0 2px var(--border-color-selected)',
},
'&[data-is-editor-locked="true"]': {
'& *': {
cursor: 'not-allowed',
pointerEvents: 'none',
},
},
};
export const shadowsSx: SystemStyleObject = {
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'base',
pointerEvents: 'none',
zIndex: -1,
shadow: 'var(--invoke-shadows-xl), var(--invoke-shadows-base), var(--invoke-shadows-base)',
};
export const inProgressSx: SystemStyleObject = {
position: 'absolute',
top: 0,
insetInlineEnd: 0,
bottom: 0,
insetInlineStart: 0,
borderRadius: 'md',
pointerEvents: 'none',
transitionProperty: 'none',
opacity: 0.7,
zIndex: -1,
display: 'none',
shadow: '0 0 0 2px var(--invoke-colors-yellow-400), 0 0 20px 2px var(--invoke-colors-orange-700)',
'&[data-is-in-progress="true"]': {
display: 'block',
},
};

View File

@@ -18,6 +18,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import { withResultAsync } from 'common/util/result';
import { parseify } from 'common/util/serialize';
import { ExternalLink } from 'features/gallery/components/ImageViewer/NoContentForViewer';
import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context';
import { NodeFieldElementOverlay } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import {
@@ -89,7 +90,11 @@ const OutputFields = memo(() => {
{t('workflows.builder.noOutputNodeSelected')}
</Text>
)}
{outputNodeId && <OutputFieldsContent outputNodeId={outputNodeId} />}
{outputNodeId && (
<InvocationNodeContextProvider nodeId={outputNodeId}>
<OutputFieldsContent outputNodeId={outputNodeId} />
</InvocationNodeContextProvider>
)}
</Flex>
);
});
@@ -127,7 +132,11 @@ const PublishableInputFields = memo(() => {
<Text fontWeight="semibold">{t('workflows.builder.publishedWorkflowInputs')}</Text>
<Divider />
{inputs.publishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
return (
<InvocationNodeContextProvider nodeId={nodeId} key={`${nodeId}-${fieldName}`}>
<NodeInputFieldPreview nodeId={nodeId} fieldName={fieldName} />
</InvocationNodeContextProvider>
);
})}
</Flex>
);
@@ -149,7 +158,11 @@ const UnpublishableInputFields = memo(() => {
</Text>
<Divider />
{inputs.unpublishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
return (
<InvocationNodeContextProvider nodeId={nodeId} key={`${nodeId}-${fieldName}`}>
<NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
</InvocationNodeContextProvider>
);
})}
</Flex>
);

View File

@@ -1,6 +1,6 @@
import { graphlib, layout } from '@dagrejs/dagre';
import type { Edge, NodePositionChange } from '@xyflow/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectEdges, selectNodes } from 'features/nodes/store/selectors';
import {
@@ -36,9 +36,7 @@ const getNodeWidth = (node: AnyNode): number => {
};
export const useAutoLayout = (): (() => void) => {
const dispatch = useAppDispatch();
const nodes = useAppSelector(selectNodes);
const edges = useAppSelector(selectEdges);
const store = useAppStore();
const nodeSpacing = useAppSelector(selectNodeSpacing);
const layerSpacing = useAppSelector(selectLayerSpacing);
const layeringStrategy = useAppSelector(selectLayeringStrategy);
@@ -46,6 +44,9 @@ export const useAutoLayout = (): (() => void) => {
const nodeAlignment = useAppSelector(selectNodeAlignment);
const autoLayout = useCallback(() => {
const state = store.getState();
const nodes = selectNodes(state);
const edges = selectEdges(state);
// We'll do graph layout using dagre, then convert the results to reactflow position changes
const g = new graphlib.Graph();
@@ -131,8 +132,8 @@ export const useAutoLayout = (): (() => void) => {
return { id: node.id, type: 'position', position: newPosition };
});
dispatch(nodesChanged(positionChanges));
}, [dispatch, edges, nodes, nodeSpacing, layerSpacing, layeringStrategy, layoutDirection, nodeAlignment]);
store.dispatch(nodesChanged(positionChanges));
}, [layerSpacing, layeringStrategy, layoutDirection, nodeAlignment, nodeSpacing, store]);
return autoLayout;
};

View File

@@ -4,6 +4,7 @@ import { range } from 'es-toolkit/compat';
import type { SeedBehaviour } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import type { ModelIdentifierField } from 'features/nodes/types/common';
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
import { API_BASE_MODELS } from 'features/parameters/types/constants';
import type { components } from 'services/api/schema';
import type { Batch, EnqueueBatchArg, Invocation } from 'services/api/types';
import { assert } from 'tsafe';
@@ -18,7 +19,7 @@ const getExtendedPrompts = (arg: {
// Normally, the seed behaviour implicity determines the batch size. But when we use models without seeds (like
// ChatGPT 4o) in conjunction with the per-prompt seed behaviour, we lose out on that implicit batch size. To rectify
// this, we need to create a batch of the right size by repeating the prompts.
if (seedBehaviour === 'PER_PROMPT' || model.base === 'chatgpt-4o' || model.base === 'flux-kontext') {
if (seedBehaviour === 'PER_PROMPT' || API_BASE_MODELS.includes(model.base)) {
return range(iterations).flatMap(() => prompts);
}
return prompts;

View File

@@ -13,14 +13,13 @@ export const CancelAllExceptCurrentButton = memo((props: ButtonProps) => {
<Button
isDisabled={api.isDisabled}
isLoading={api.isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.cancelAllExceptCurrentTooltip')}
leftIcon={<PiXCircle />}
colorScheme="error"
onClick={api.openDialog}
{...props}
>
{t('queue.clear')}
{t('queue.cancelAllExceptCurrentTooltip')}
</Button>
);
});

View File

@@ -0,0 +1,29 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashBold } from 'react-icons/pi';
import { useClearQueueDialog } from './ClearQueueConfirmationAlertDialog';
export const ClearQueueButton = memo((props: ButtonProps) => {
const { t } = useTranslation();
const api = useClearQueueDialog();
return (
<Button
isDisabled={api.isDisabled}
isLoading={api.isLoading}
aria-label={t('queue.clear')}
tooltip={t('queue.clearTooltip')}
leftIcon={<PiTrashBold />}
colorScheme="error"
onClick={api.openDialog}
{...props}
>
{t('queue.clear')}
</Button>
);
});
ClearQueueButton.displayName = 'ClearQueueButton';

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
const [useClearQueueConfirmationAlertDialog] = buildUseBoolean(false);
const useClearQueueDialog = () => {
export const useClearQueueDialog = () => {
const dialog = useClearQueueConfirmationAlertDialog();
const clearQueue = useClearQueue();

View File

@@ -9,15 +9,19 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiListBold, PiPauseFill, PiPlayFill, PiQueueBold, PiXBold, PiXCircle } from 'react-icons/pi';
import { PiListBold, PiPauseFill, PiPlayFill, PiQueueBold, PiTrashBold, PiXBold, PiXCircle } from 'react-icons/pi';
import { useClearQueueDialog } from './ClearQueueConfirmationAlertDialog';
export const QueueActionsMenuButton = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
const isClearAllEnabled = useFeatureStatus('cancelAndClearAll');
const cancelAllExceptCurrent = useCancelAllExceptCurrentQueueItemDialog();
const cancelCurrentQueueItem = useCancelCurrentQueueItem();
const clearQueue = useClearQueueDialog();
const resumeProcessor = useResumeProcessor();
const pauseProcessor = usePauseProcessor();
const openQueue = useCallback(() => {
@@ -55,6 +59,17 @@ export const QueueActionsMenuButton = memo(() => {
>
{t('queue.cancelAllExceptCurrentTooltip')}
</MenuItem>
{isClearAllEnabled && (
<MenuItem
isDestructive
icon={<PiTrashBold />}
onClick={clearQueue.openDialog}
isLoading={clearQueue.isLoading}
isDisabled={clearQueue.isDisabled}
>
{t('queue.clearTooltip')}
</MenuItem>
)}
{isResumeEnabled && (
<MenuItem
icon={<PiPlayFill />}

View File

@@ -4,6 +4,7 @@ import { memo } from 'react';
import { CancelAllExceptCurrentButton } from './CancelAllExceptCurrentButton';
import ClearModelCacheButton from './ClearModelCacheButton';
import { ClearQueueButton } from './ClearQueueButton';
import PauseProcessorButton from './PauseProcessorButton';
import PruneQueueButton from './PruneQueueButton';
import ResumeProcessorButton from './ResumeProcessorButton';
@@ -11,19 +12,20 @@ import ResumeProcessorButton from './ResumeProcessorButton';
const QueueTabQueueControls = () => {
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
const isClearQueueEnabled = useFeatureStatus('cancelAndClearAll');
return (
<Flex flexDir="column" layerStyle="first" borderRadius="base" p={2} gap={2}>
<Flex gap={2}>
{(isPauseEnabled || isResumeEnabled) && (
<ButtonGroup w={28} orientation="vertical" size="sm">
<ButtonGroup orientation="vertical" size="sm">
{isResumeEnabled && <ResumeProcessorButton />}
{isPauseEnabled && <PauseProcessorButton />}
</ButtonGroup>
)}
<ButtonGroup w={28} orientation="vertical" size="sm">
<ButtonGroup orientation="vertical" size="sm">
<PruneQueueButton />
<CancelAllExceptCurrentButton />
{isClearQueueEnabled ? <ClearQueueButton /> : <CancelAllExceptCurrentButton />}
</ButtonGroup>
</Flex>
<ClearModelCacheButton />

View File

@@ -1,5 +1,5 @@
import type { DockviewApi, GridviewApi } from 'dockview';
import { DockviewPanel, GridviewPanel } from 'dockview';
import { DockviewApi as MockedDockviewApi, DockviewPanel, GridviewPanel } from 'dockview';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NavigationAppApi } from './navigation-api';
@@ -12,6 +12,7 @@ import {
RIGHT_PANEL_MIN_SIZE_PX,
SETTINGS_PANEL_ID,
SWITCH_TABS_FAKE_DELAY_MS,
VIEWER_PANEL_ID,
WORKSPACE_PANEL_ID,
} from './shared';
@@ -48,7 +49,7 @@ vi.mock('dockview', async () => {
}
}
// Mock GridviewPanel class for instanceof checks
// Mock DockviewPanel class for instanceof checks
class MockDockviewPanel {
api = {
setActive: vi.fn(),
@@ -58,10 +59,21 @@ vi.mock('dockview', async () => {
};
}
// Mock DockviewApi class for instanceof checks
class MockDockviewApi {
panels = [];
activePanel = null;
toJSON = vi.fn();
fromJSON = vi.fn();
onDidLayoutChange = vi.fn();
onDidActivePanelChange = vi.fn();
}
return {
...actual,
GridviewPanel: MockGridviewPanel,
DockviewPanel: MockDockviewPanel,
DockviewApi: MockDockviewApi,
};
});
@@ -1105,4 +1117,393 @@ describe('AppNavigationApi', () => {
expect(initialize).not.toHaveBeenCalled();
});
});
describe('toggleViewerPanel', () => {
beforeEach(() => {
navigationApi.connectToApp(mockAppApi);
});
it('should switch to viewer panel when not currently on viewer', async () => {
const mockViewerPanel = createMockDockPanel();
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
mockGetAppTab.mockReturnValue('generate');
// Set current panel to something other than viewer
navigationApi._currentActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(true);
expect(mockViewerPanel.api.setActive).toHaveBeenCalledOnce();
});
it('should switch to previous panel when on viewer and previous panel exists', async () => {
const mockPreviousPanel = createMockDockPanel();
const mockViewerPanel = createMockDockPanel();
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockPreviousPanel);
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
mockGetAppTab.mockReturnValue('generate');
// Set current panel to viewer and previous to settings
navigationApi._currentActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(true);
expect(mockPreviousPanel.api.setActive).toHaveBeenCalledOnce();
expect(mockViewerPanel.api.setActive).not.toHaveBeenCalled();
});
it('should switch to launchpad when on viewer and no valid previous panel', async () => {
const mockLaunchpadPanel = createMockDockPanel();
const mockViewerPanel = createMockDockPanel();
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockLaunchpadPanel);
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
mockGetAppTab.mockReturnValue('generate');
// Set current panel to viewer and no previous panel
navigationApi._currentActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('generate', null);
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(true);
expect(mockLaunchpadPanel.api.setActive).toHaveBeenCalledOnce();
expect(mockViewerPanel.api.setActive).not.toHaveBeenCalled();
});
it('should switch to launchpad when on viewer and previous panel is also viewer', async () => {
const mockLaunchpadPanel = createMockDockPanel();
const mockViewerPanel = createMockDockPanel();
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockLaunchpadPanel);
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
mockGetAppTab.mockReturnValue('generate');
// Set current panel to viewer and previous panel was also viewer
navigationApi._currentActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(true);
expect(mockLaunchpadPanel.api.setActive).toHaveBeenCalledOnce();
expect(mockViewerPanel.api.setActive).not.toHaveBeenCalled();
});
it('should return false when no active tab', async () => {
mockGetAppTab.mockReturnValue(null);
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(false);
});
it('should return false when viewer panel is not registered', async () => {
mockGetAppTab.mockReturnValue('generate');
navigationApi._currentActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
// Don't register viewer panel
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(false);
});
it('should return false when previous panel is not registered', async () => {
const mockViewerPanel = createMockDockPanel();
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
mockGetAppTab.mockReturnValue('generate');
// Set current to viewer and previous to unregistered panel
navigationApi._currentActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('generate', 'unregistered-panel');
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(false);
});
it('should return false when launchpad panel is not registered as fallback', async () => {
const mockViewerPanel = createMockDockPanel();
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
mockGetAppTab.mockReturnValue('generate');
// Set current to viewer and no previous panel, but don't register launchpad
navigationApi._currentActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('generate', null);
const result = await navigationApi.toggleViewerPanel();
expect(result).toBe(false);
});
it('should work across different tabs independently', async () => {
const mockViewerPanel1 = createMockDockPanel();
const mockViewerPanel2 = createMockDockPanel();
const mockSettingsPanel1 = createMockDockPanel();
const mockSettingsPanel2 = createMockDockPanel();
const mockLaunchpadPanel = createMockDockPanel();
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel1);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockSettingsPanel1);
navigationApi._registerPanel('canvas', VIEWER_PANEL_ID, mockViewerPanel2);
navigationApi._registerPanel('canvas', SETTINGS_PANEL_ID, mockSettingsPanel2);
navigationApi._registerPanel('canvas', LAUNCHPAD_PANEL_ID, mockLaunchpadPanel);
// Set up different states for different tabs
navigationApi._currentActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
navigationApi._currentActiveDockviewPanel.set('canvas', VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('canvas', SETTINGS_PANEL_ID);
// Test generate tab (should switch to viewer)
mockGetAppTab.mockReturnValue('generate');
const result1 = await navigationApi.toggleViewerPanel();
expect(result1).toBe(true);
expect(mockViewerPanel1.api.setActive).toHaveBeenCalledOnce();
// Test canvas tab (should switch to previous panel - settings panel in canvas)
mockGetAppTab.mockReturnValue('canvas');
const result2 = await navigationApi.toggleViewerPanel();
expect(result2).toBe(true);
expect(mockSettingsPanel2.api.setActive).toHaveBeenCalledOnce();
});
it('should handle sequence of viewer toggles correctly', async () => {
const mockViewerPanel = createMockDockPanel();
const mockSettingsPanel = createMockDockPanel();
const mockLaunchpadPanel = createMockDockPanel();
navigationApi._registerPanel('generate', VIEWER_PANEL_ID, mockViewerPanel);
navigationApi._registerPanel('generate', SETTINGS_PANEL_ID, mockSettingsPanel);
navigationApi._registerPanel('generate', LAUNCHPAD_PANEL_ID, mockLaunchpadPanel);
mockGetAppTab.mockReturnValue('generate');
// Start on settings panel
navigationApi._currentActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set('generate', null);
// First toggle: settings -> viewer
const result1 = await navigationApi.toggleViewerPanel();
expect(result1).toBe(true);
expect(mockViewerPanel.api.setActive).toHaveBeenCalledOnce();
// Simulate panel change tracking (normally done by dockview listener)
navigationApi._prevActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
navigationApi._currentActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
// Second toggle: viewer -> settings (previous panel)
const result2 = await navigationApi.toggleViewerPanel();
expect(result2).toBe(true);
expect(mockSettingsPanel.api.setActive).toHaveBeenCalledOnce();
// Simulate panel change tracking again
navigationApi._prevActiveDockviewPanel.set('generate', VIEWER_PANEL_ID);
navigationApi._currentActiveDockviewPanel.set('generate', SETTINGS_PANEL_ID);
// Third toggle: settings -> viewer again
const result3 = await navigationApi.toggleViewerPanel();
expect(result3).toBe(true);
expect(mockViewerPanel.api.setActive).toHaveBeenCalledTimes(2);
});
});
describe('Disposable Cleanup', () => {
beforeEach(() => {
navigationApi.connectToApp(mockAppApi);
});
it('should add disposable functions for a tab', () => {
const dispose1 = vi.fn();
const dispose2 = vi.fn();
navigationApi._addDisposeForTab('generate', dispose1);
navigationApi._addDisposeForTab('generate', dispose2);
// Check that disposables are stored
const disposables = navigationApi._disposablesForTab.get('generate');
expect(disposables).toBeDefined();
expect(disposables?.size).toBe(2);
expect(disposables?.has(dispose1)).toBe(true);
expect(disposables?.has(dispose2)).toBe(true);
});
it('should handle multiple tabs independently', () => {
const dispose1 = vi.fn();
const dispose2 = vi.fn();
const dispose3 = vi.fn();
navigationApi._addDisposeForTab('generate', dispose1);
navigationApi._addDisposeForTab('generate', dispose2);
navigationApi._addDisposeForTab('canvas', dispose3);
const generateDisposables = navigationApi._disposablesForTab.get('generate');
const canvasDisposables = navigationApi._disposablesForTab.get('canvas');
expect(generateDisposables?.size).toBe(2);
expect(canvasDisposables?.size).toBe(1);
expect(generateDisposables?.has(dispose1)).toBe(true);
expect(generateDisposables?.has(dispose2)).toBe(true);
expect(canvasDisposables?.has(dispose3)).toBe(true);
});
it('should call all dispose functions when unregistering a tab', () => {
const dispose1 = vi.fn();
const dispose2 = vi.fn();
const dispose3 = vi.fn();
// Add disposables for generate tab
navigationApi._addDisposeForTab('generate', dispose1);
navigationApi._addDisposeForTab('generate', dispose2);
// Add disposable for canvas tab (should not be called)
navigationApi._addDisposeForTab('canvas', dispose3);
// Unregister generate tab
navigationApi.unregisterTab('generate');
// Check that generate tab disposables were called
expect(dispose1).toHaveBeenCalledOnce();
expect(dispose2).toHaveBeenCalledOnce();
// Check that canvas tab disposable was not called
expect(dispose3).not.toHaveBeenCalled();
// Check that generate tab disposables are cleared
expect(navigationApi._disposablesForTab.has('generate')).toBe(false);
// Check that canvas tab disposables remain
expect(navigationApi._disposablesForTab.has('canvas')).toBe(true);
});
it('should handle unregistering tab with no disposables gracefully', () => {
// Should not throw when unregistering tab with no disposables
expect(() => navigationApi.unregisterTab('generate')).not.toThrow();
});
it('should handle duplicate dispose functions', () => {
const dispose1 = vi.fn();
// Add the same dispose function twice
navigationApi._addDisposeForTab('generate', dispose1);
navigationApi._addDisposeForTab('generate', dispose1);
const disposables = navigationApi._disposablesForTab.get('generate');
// Set should contain only one instance (sets don't allow duplicates)
expect(disposables?.size).toBe(1);
navigationApi.unregisterTab('generate');
// Should be called only once despite being added twice
expect(dispose1).toHaveBeenCalledOnce();
});
it('should automatically add dispose functions during container registration with DockviewApi', () => {
const tab = 'generate';
const viewId = 'myView';
mockGetStorage.mockReturnValue(undefined);
const initialize = vi.fn();
const panel = { id: 'p1' };
const mockDispose = vi.fn();
// Create a mock that will pass the instanceof DockviewApi check
const mockApi = Object.create(MockedDockviewApi.prototype);
Object.assign(mockApi, {
panels: [panel],
activePanel: { id: 'p1' },
toJSON: vi.fn(() => ({ foo: 'bar' })),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
onDidActivePanelChange: vi.fn(() => ({ dispose: mockDispose })),
});
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
// Check that dispose function was added to disposables
const disposables = navigationApi._disposablesForTab.get(tab);
expect(disposables).toBeDefined();
expect(disposables?.size).toBe(1);
// Unregister tab and check dispose was called
navigationApi.unregisterTab(tab);
expect(mockDispose).toHaveBeenCalledOnce();
});
it('should not add dispose functions for GridviewApi during container registration', () => {
const tab = 'generate';
const viewId = 'myView';
mockGetStorage.mockReturnValue(undefined);
const initialize = vi.fn();
const panel = { id: 'p1' };
// Mock GridviewApi (not DockviewApi)
const mockApi = {
panels: [panel],
toJSON: vi.fn(() => ({ foo: 'bar' })),
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as GridviewApi;
navigationApi.registerContainer(tab, viewId, mockApi, initialize);
// Check that no dispose function was added for GridviewApi
const disposables = navigationApi._disposablesForTab.get(tab);
expect(disposables).toBeUndefined();
});
it('should handle dispose function errors gracefully', () => {
const goodDispose = vi.fn();
const errorDispose = vi.fn(() => {
throw new Error('Dispose error');
});
const anotherGoodDispose = vi.fn();
navigationApi._addDisposeForTab('generate', goodDispose);
navigationApi._addDisposeForTab('generate', errorDispose);
navigationApi._addDisposeForTab('generate', anotherGoodDispose);
// Should not throw even if one dispose function throws
expect(() => navigationApi.unregisterTab('generate')).not.toThrow();
// All dispose functions should have been called
expect(goodDispose).toHaveBeenCalledOnce();
expect(errorDispose).toHaveBeenCalledOnce();
expect(anotherGoodDispose).toHaveBeenCalledOnce();
});
it('should clear panel tracking state when unregistering tab', () => {
const tab = 'generate';
// Set up some panel tracking state
navigationApi._currentActiveDockviewPanel.set(tab, VIEWER_PANEL_ID);
navigationApi._prevActiveDockviewPanel.set(tab, SETTINGS_PANEL_ID);
// Add some disposables
const dispose1 = vi.fn();
const dispose2 = vi.fn();
navigationApi._addDisposeForTab(tab, dispose1);
navigationApi._addDisposeForTab(tab, dispose2);
// Verify state exists before unregistering
expect(navigationApi._currentActiveDockviewPanel.has(tab)).toBe(true);
expect(navigationApi._prevActiveDockviewPanel.has(tab)).toBe(true);
expect(navigationApi._disposablesForTab.has(tab)).toBe(true);
// Unregister tab
navigationApi.unregisterTab(tab);
// Verify all state is cleared
expect(navigationApi._currentActiveDockviewPanel.has(tab)).toBe(false);
expect(navigationApi._prevActiveDockviewPanel.has(tab)).toBe(false);
expect(navigationApi._disposablesForTab.has(tab)).toBe(false);
// Verify dispose functions were called
expect(dispose1).toHaveBeenCalledOnce();
expect(dispose2).toHaveBeenCalledOnce();
});
});
});

View File

@@ -1,19 +1,21 @@
import { logger } from 'app/logging/logger';
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
import { parseify } from 'common/util/serialize';
import type { DockviewApi, GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
import { GridviewPanel } from 'dockview';
import type { GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview';
import { DockviewApi, GridviewPanel } from 'dockview';
import { debounce } from 'es-toolkit';
import type { Serializable, TabName } from 'features/ui/store/uiTypes';
import type { Atom } from 'nanostores';
import { atom } from 'nanostores';
import {
LAUNCHPAD_PANEL_ID,
LEFT_PANEL_ID,
LEFT_PANEL_MIN_SIZE_PX,
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SWITCH_TABS_FAKE_DELAY_MS,
VIEWER_PANEL_ID,
} from './shared';
const log = logger('system');
@@ -69,6 +71,37 @@ export class NavigationApi {
private _$isLoading = atom(false);
$isLoading: Atom<boolean> = this._$isLoading;
/**
* Track the _previous_ active dockview panel for each tab.
*/
_prevActiveDockviewPanel: Map<TabName, string | null> = new Map();
/**
* Track the _current_ active dockview panel for each tab.
*/
_currentActiveDockviewPanel: Map<TabName, string | null> = new Map();
/**
* Map of disposables for each tab.
* This is used to clean up resources when a tab is unregistered.
*/
_disposablesForTab: Map<TabName, Set<() => void>> = new Map();
/**
* Convenience method to add a dispose function for a specific tab.
*/
/**
* Convenience method to add a dispose function for a specific tab.
*/
_addDisposeForTab = (tab: TabName, disposeFn: () => void): void => {
let disposables = this._disposablesForTab.get(tab);
if (!disposables) {
disposables = new Set<() => void>();
this._disposablesForTab.set(tab, disposables);
}
disposables.add(disposeFn);
};
/**
* Separator used to create unique keys for panels. Typo protection.
*/
@@ -209,6 +242,18 @@ export class NavigationApi {
this._registerPanel(tab, panel.id, panel);
}
// Set up tracking for active tab for this panel - needed for viewer toggle functionality
if (api instanceof DockviewApi) {
this._currentActiveDockviewPanel.set(tab, api.activePanel?.id ?? null);
this._prevActiveDockviewPanel.set(tab, null);
const { dispose } = api.onDidActivePanelChange((panel) => {
const previousPanelId = this._currentActiveDockviewPanel.get(tab);
this._prevActiveDockviewPanel.set(tab, previousPanelId ?? null);
this._currentActiveDockviewPanel.set(tab, panel?.id ?? null);
});
this._addDisposeForTab(tab, dispose);
}
api.onDidLayoutChange(
debounce(() => {
this._app?.storage.set(key, api.toJSON());
@@ -545,6 +590,42 @@ export class NavigationApi {
return true;
};
/**
* Toggle between the viewer panel and the previously focused dockview panel in the current tab.
* If currently on viewer and a previous panel exists, switch to the previous panel.
* If not on viewer, switch to viewer.
* If no previous panel exists, defaults to launchpad panel.
* Only operates on dockview panels (panels with tabs), not gridview panels.
*
* @returns Promise that resolves to true if successful, false otherwise
*/
toggleViewerPanel = (): Promise<boolean> => {
const activeTab = this._app?.activeTab.get() ?? null;
if (!activeTab) {
log.warn('No active tab found for viewer toggle');
return Promise.resolve(false);
}
const prevActiveDockviewPanel = this._prevActiveDockviewPanel.get(activeTab);
const currentActiveDockviewPanel = this._currentActiveDockviewPanel.get(activeTab);
let targetPanel;
if (currentActiveDockviewPanel !== VIEWER_PANEL_ID) {
targetPanel = VIEWER_PANEL_ID;
} else if (prevActiveDockviewPanel && prevActiveDockviewPanel !== VIEWER_PANEL_ID) {
targetPanel = prevActiveDockviewPanel;
} else {
targetPanel = LAUNCHPAD_PANEL_ID;
}
if (this.getRegisteredPanels(activeTab).includes(targetPanel)) {
return this.focusPanel(activeTab, targetPanel);
}
return Promise.resolve(false);
};
/**
* Check if a panel is registered.
* @param tab - The tab the panel belongs to
@@ -593,6 +674,18 @@ export class NavigationApi {
this.waiters.delete(key);
}
// Clear previous panel tracking for this tab
this._prevActiveDockviewPanel.delete(tab);
this._currentActiveDockviewPanel.delete(tab);
this._disposablesForTab.get(tab)?.forEach((disposeFn) => {
try {
disposeFn();
} catch (error) {
log.error({ error: parseify(error) }, `Error disposing resource for tab ${tab}`);
}
});
this._disposablesForTab.delete(tab);
log.trace(`Unregistered all panels for tab ${tab}`);
};
}

View File

@@ -1 +1 @@
__version__ = "6.1.0rc2"
__version__ = "6.2.0"