mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-17 06:37:55 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ac58e64a | ||
|
|
e653837236 | ||
|
|
2bbfcc2f13 | ||
|
|
d6e0e439c5 | ||
|
|
26aab60f81 | ||
|
|
7bea2fa11f | ||
|
|
169d58ea4c | ||
|
|
b53d2250f7 | ||
|
|
242eea8295 | ||
|
|
4dabe09e0d | ||
|
|
07fa0d3b77 | ||
|
|
e97f82292f | ||
|
|
005bab9035 | ||
|
|
409173919c | ||
|
|
7915180047 | ||
|
|
4349b8387d | ||
|
|
f95b686bdc | ||
|
|
72afb9c3fd | ||
|
|
f004fc31f1 | ||
|
|
2aa163b3a2 | ||
|
|
f40900c173 |
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -139,4 +139,13 @@ export const useGlobalHotkeys = () => {
|
||||
},
|
||||
dependencies: [getState, deleteImageModalApi],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'toggleViewer',
|
||||
category: 'viewer',
|
||||
callback: () => {
|
||||
navigationApi.toggleViewerPanel();
|
||||
},
|
||||
dependencies: [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1618,7 +1618,6 @@ export const {
|
||||
entityArrangedToBack,
|
||||
entityOpacityChanged,
|
||||
entitiesReordered,
|
||||
allEntitiesDeleted,
|
||||
allEntitiesOfTypeIsHiddenToggled,
|
||||
allNonRasterLayersIsHiddenToggled,
|
||||
// bbox
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
const [useClearQueueConfirmationAlertDialog] = buildUseBoolean(false);
|
||||
|
||||
const useClearQueueDialog = () => {
|
||||
export const useClearQueueDialog = () => {
|
||||
const dialog = useClearQueueConfirmationAlertDialog();
|
||||
const clearQueue = useClearQueue();
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "6.1.0rc2"
|
||||
__version__ = "6.2.0"
|
||||
|
||||
Reference in New Issue
Block a user