mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-15 06:18:03 -05:00
feat(ui): panel state persistence (WIP)
This commit is contained in:
@@ -112,7 +112,8 @@ const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Register panels with navigation API
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
@@ -125,23 +126,7 @@ const MainPanel = memo(() => {
|
||||
|
||||
const onReady = useCallback<IDockviewReactProps['onReady']>(
|
||||
({ api }) => {
|
||||
const panels = initializeCenterPanelLayout(tab, api);
|
||||
panels.launchpad.api.setActive();
|
||||
|
||||
const disposables = [
|
||||
api.onWillShowOverlay((e) => {
|
||||
if (e.kind === 'header_space' || e.kind === 'tab') {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach((disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
};
|
||||
initializeCenterPanelLayout(tab, api);
|
||||
},
|
||||
[tab]
|
||||
);
|
||||
|
||||
@@ -91,7 +91,8 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Register panels with navigation API
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
|
||||
@@ -103,23 +104,7 @@ const MainPanel = memo(() => {
|
||||
|
||||
const onReady = useCallback<IDockviewReactProps['onReady']>(
|
||||
({ api }) => {
|
||||
const { launchpad } = initializeMainPanelLayout(tab, api);
|
||||
launchpad.api.setActive();
|
||||
|
||||
const disposables = [
|
||||
api.onWillShowOverlay((e) => {
|
||||
if (e.kind === 'header_space' || e.kind === 'tab') {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach((disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
};
|
||||
initializeMainPanelLayout(tab, api);
|
||||
},
|
||||
[tab]
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
|
||||
import { GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
import { DockviewPanel, GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,18 @@ type Waiter = {
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export type NavigationAppApi = {
|
||||
activeTab: {
|
||||
get: () => TabName;
|
||||
set: (tab: TabName) => void;
|
||||
};
|
||||
panelStorage: {
|
||||
get: (id: string) => StoredDockviewPanelState | StoredGridviewPanelState | undefined;
|
||||
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => void;
|
||||
delete: (id: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export class NavigationApi {
|
||||
/**
|
||||
* Map of registered panels, keyed by tab and panel ID in this format:
|
||||
@@ -55,33 +68,22 @@ export class NavigationApi {
|
||||
*/
|
||||
KEY_SEPARATOR = ':';
|
||||
|
||||
/**
|
||||
* Private imperative method to set the current app tab.
|
||||
*/
|
||||
_setAppTab: ((tab: TabName) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Private imperative method to get the current app tab.
|
||||
*/
|
||||
_getAppTab: (() => TabName) | null = null;
|
||||
_app: NavigationAppApi | null = null;
|
||||
|
||||
/**
|
||||
* Connect to the application to manage tab switching.
|
||||
* @param arg.setAppTab - Function to set the current app tab
|
||||
* @param arg.getAppTab - Function to get the current app tab
|
||||
* @param api - The application API that provides methods to set and get the current app tab and manage panel
|
||||
* state storage.
|
||||
*/
|
||||
connectToApp = (arg: { setAppTab: (tab: TabName) => void; getAppTab: () => TabName }): void => {
|
||||
const { setAppTab, getAppTab } = arg;
|
||||
this._setAppTab = setAppTab;
|
||||
this._getAppTab = getAppTab;
|
||||
connectToApp = (api: NavigationAppApi): void => {
|
||||
this._app = api;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect from the application, clearing the tab management functions.
|
||||
*/
|
||||
disconnectFromApp = (): void => {
|
||||
this._setAppTab = null;
|
||||
this._getAppTab = null;
|
||||
this._app = null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -97,13 +99,13 @@ export class NavigationApi {
|
||||
clearTimeout(this.switchingTabsTimeout);
|
||||
this.switchingTabsTimeout = null;
|
||||
}
|
||||
if (tab === this._getAppTab?.()) {
|
||||
if (tab === this._app?.activeTab.get?.()) {
|
||||
return true;
|
||||
}
|
||||
this.$isSwitchingTabs.set(true);
|
||||
log.debug(`Switching to tab: ${tab}`);
|
||||
if (this._setAppTab) {
|
||||
this._setAppTab(tab);
|
||||
if (this._app) {
|
||||
this._app.activeTab.set(tab);
|
||||
return true;
|
||||
} else {
|
||||
log.error('No setAppTab function available to switch tabs');
|
||||
@@ -123,6 +125,100 @@ export class NavigationApi {
|
||||
}, SWITCH_TABS_FAKE_DELAY_MS);
|
||||
};
|
||||
|
||||
_initGridviewPanelStorage = (key: string, panel: IGridviewPanel) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
const storedState = this._app.panelStorage.get(key);
|
||||
if (!storedState) {
|
||||
log.debug('No stored state for panel, setting initial state');
|
||||
const { height, width } = panel.api;
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'gridview-panel',
|
||||
dimensions: { height, width },
|
||||
});
|
||||
} else {
|
||||
if (storedState.type !== 'gridview-panel') {
|
||||
log.error(`Panel ${key} type mismatch: expected gridview-panel, got ${storedState.type}`);
|
||||
this._app.panelStorage.delete(key);
|
||||
return;
|
||||
}
|
||||
log.debug({ storedState }, 'Found stored state for panel, restoring');
|
||||
|
||||
panel.api.setSize(storedState.dimensions);
|
||||
}
|
||||
const { dispose } = panel.api.onDidDimensionsChange(
|
||||
debounce(({ width, height }) => {
|
||||
log.debug({ key, width, height }, 'Panel dimensions changed');
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'gridview-panel',
|
||||
dimensions: { width, height },
|
||||
});
|
||||
}, 1000)
|
||||
);
|
||||
|
||||
return dispose;
|
||||
};
|
||||
|
||||
_initDockviewPanelStorage = (key: string, panel: IDockviewPanel) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
const storedState = this._app.panelStorage.get(key);
|
||||
if (!storedState) {
|
||||
const { isActive } = panel.api;
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'dockview-panel',
|
||||
isActive,
|
||||
});
|
||||
} else {
|
||||
if (storedState.type !== 'dockview-panel') {
|
||||
log.error(`Panel ${key} type mismatch: expected dockview-panel, got ${storedState.type}`);
|
||||
this._app.panelStorage.delete(key);
|
||||
return;
|
||||
}
|
||||
if (storedState.isActive) {
|
||||
panel.api.setActive();
|
||||
}
|
||||
}
|
||||
|
||||
const { dispose } = panel.api.onDidActiveChange(
|
||||
debounce(({ isActive }) => {
|
||||
if (!this._app) {
|
||||
log.error('App not connected');
|
||||
return;
|
||||
}
|
||||
this._app.panelStorage.set(key, {
|
||||
id: key,
|
||||
type: 'dockview-panel',
|
||||
isActive,
|
||||
});
|
||||
}, 1000)
|
||||
);
|
||||
|
||||
return dispose;
|
||||
};
|
||||
|
||||
_initPanelStorage = (key: string, panel: PanelType) => {
|
||||
if (panel instanceof GridviewPanel) {
|
||||
return this._initGridviewPanelStorage(key, panel);
|
||||
} else if (panel instanceof DockviewPanel) {
|
||||
return this._initDockviewPanelStorage(key, panel);
|
||||
} else {
|
||||
log.error(`Unsupported panel type: ${panel.constructor.name}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a panel with the navigation API.
|
||||
*
|
||||
@@ -136,6 +232,8 @@ export class NavigationApi {
|
||||
|
||||
this.panels.set(key, panel);
|
||||
|
||||
const cleanupPanelStorage = this._initPanelStorage(key, panel);
|
||||
|
||||
// Resolve any pending waiters for this panel, notifying them that the panel is now registered.
|
||||
const waiter = this.waiters.get(key);
|
||||
if (waiter) {
|
||||
@@ -149,6 +247,7 @@ export class NavigationApi {
|
||||
log.debug(`Registered panel ${key}`);
|
||||
|
||||
return () => {
|
||||
cleanupPanelStorage?.();
|
||||
this.panels.delete(key);
|
||||
log.debug(`Unregistered panel ${key}`);
|
||||
};
|
||||
@@ -280,7 +379,7 @@ export class NavigationApi {
|
||||
* }
|
||||
*/
|
||||
focusPanelInActiveTab = (panelId: string, timeout = 2000): Promise<boolean> => {
|
||||
const activeTab = this._getAppTab ? this._getAppTab() : null;
|
||||
const activeTab = this._app?.activeTab.get() ?? null;
|
||||
if (!activeTab) {
|
||||
log.error('No active tab found');
|
||||
return Promise.resolve(false);
|
||||
@@ -326,7 +425,7 @@ export class NavigationApi {
|
||||
* @returns True if the panel was toggled, false if it was not found or an error occurred
|
||||
*/
|
||||
toggleLeftPanel = (): boolean => {
|
||||
const activeTab = this._getAppTab ? this._getAppTab() : null;
|
||||
const activeTab = this._app?.activeTab.get() ?? null;
|
||||
if (!activeTab) {
|
||||
log.warn('No active tab found to toggle left panel');
|
||||
return false;
|
||||
@@ -359,7 +458,7 @@ export class NavigationApi {
|
||||
* @returns True if the panel was toggled, false if it was not found or an error occurred
|
||||
*/
|
||||
toggleRightPanel = (): boolean => {
|
||||
const activeTab = this._getAppTab ? this._getAppTab() : null;
|
||||
const activeTab = this._app?.activeTab.get() ?? null;
|
||||
if (!activeTab) {
|
||||
log.warn('No active tab found to toggle right panel');
|
||||
return false;
|
||||
@@ -393,7 +492,7 @@ export class NavigationApi {
|
||||
* @returns True if the panels were toggled, false if they were not found or an error occurred
|
||||
*/
|
||||
toggleLeftAndRightPanels = (): boolean => {
|
||||
const activeTab = this._getAppTab ? this._getAppTab() : null;
|
||||
const activeTab = this._app?.activeTab.get() ?? null;
|
||||
if (!activeTab) {
|
||||
log.warn('No active tab found to toggle right panel');
|
||||
return false;
|
||||
@@ -433,7 +532,7 @@ export class NavigationApi {
|
||||
* @returns True if the panels were reset, false if they were not found or an error occurred
|
||||
*/
|
||||
resetLeftAndRightPanels = (): boolean => {
|
||||
const activeTab = this._getAppTab ? this._getAppTab() : null;
|
||||
const activeTab = this._app?.activeTab.get() ?? null;
|
||||
if (!activeTab) {
|
||||
log.warn('No active tab found to toggle right panel');
|
||||
return false;
|
||||
|
||||
@@ -91,7 +91,8 @@ const initializeCenterPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Register panels with navigation API
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
|
||||
@@ -102,23 +103,7 @@ const MainPanel = memo(() => {
|
||||
const { tab } = useAutoLayoutContext();
|
||||
const onReady = useCallback<IDockviewReactProps['onReady']>(
|
||||
({ api }) => {
|
||||
const panels = initializeCenterPanelLayout(tab, api);
|
||||
panels.launchpad.api.setActive();
|
||||
|
||||
const disposables = [
|
||||
api.onWillShowOverlay((e) => {
|
||||
if (e.kind === 'header_space' || e.kind === 'tab') {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach((disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
};
|
||||
initializeCenterPanelLayout(tab, api);
|
||||
},
|
||||
[tab]
|
||||
);
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
import { useAppStore } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import type { TabName } from 'features/ui/store/uiTypes';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { panelStateChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import type { StoredDockviewPanelState, StoredGridviewPanelState, TabName } from 'features/ui/store/uiTypes';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { navigationApi } from './navigation-api';
|
||||
|
||||
/**
|
||||
* Hook that initializes the global navigation API with callbacks to access and modify the active tab.
|
||||
* Hook that initializes the global navigation API with callbacks to access and modify the active tab and handle
|
||||
* stored panel states.
|
||||
*/
|
||||
export const useNavigationApi = () => {
|
||||
useAssertSingleton('useNavigationApi');
|
||||
const store = useAppStore();
|
||||
|
||||
const getAppTab = useCallback(() => {
|
||||
return selectActiveTab(store.getState());
|
||||
}, [store]);
|
||||
const setAppTab = useCallback(
|
||||
(tab: TabName) => {
|
||||
store.dispatch(setActiveTab(tab));
|
||||
},
|
||||
const appApi = useMemo(
|
||||
() => ({
|
||||
activeTab: {
|
||||
get: (): TabName => {
|
||||
return selectActiveTab(store.getState());
|
||||
},
|
||||
set: (tab: TabName) => {
|
||||
store.dispatch(setActiveTab(tab));
|
||||
},
|
||||
},
|
||||
panelStorage: {
|
||||
get: (id: string) => {
|
||||
return store.getState().ui.panels[id];
|
||||
},
|
||||
set: (id: string, state: StoredDockviewPanelState | StoredGridviewPanelState) => {
|
||||
store.dispatch(panelStateChanged({ id, state }));
|
||||
},
|
||||
delete: (id: string) => {
|
||||
store.dispatch(panelStateChanged({ id, state: undefined }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
[store]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
navigationApi.connectToApp({ getAppTab, setAppTab });
|
||||
}, [getAppTab, setAppTab, store]);
|
||||
navigationApi.connectToApp(appApi);
|
||||
}, [appApi, store]);
|
||||
};
|
||||
|
||||
@@ -109,7 +109,8 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Register panels with navigation API
|
||||
launchpad.api.setActive();
|
||||
|
||||
navigationApi.registerPanel(tab, LAUNCHPAD_PANEL_ID, launchpad);
|
||||
navigationApi.registerPanel(tab, WORKSPACE_PANEL_ID, workspace);
|
||||
navigationApi.registerPanel(tab, VIEWER_PANEL_ID, viewer);
|
||||
@@ -122,23 +123,7 @@ const MainPanel = memo(() => {
|
||||
|
||||
const onReady = useCallback<IDockviewReactProps['onReady']>(
|
||||
({ api }) => {
|
||||
const panels = initializeMainPanelLayout(tab, api);
|
||||
panels.launchpad.api.setActive();
|
||||
|
||||
const disposables = [
|
||||
api.onWillShowOverlay((e) => {
|
||||
if (e.kind === 'header_space' || e.kind === 'tab') {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposables.forEach((disposable) => {
|
||||
disposable.dispose();
|
||||
});
|
||||
};
|
||||
initializeMainPanelLayout(tab, api);
|
||||
},
|
||||
[tab]
|
||||
);
|
||||
|
||||
@@ -51,6 +51,20 @@ export const uiSlice = createSlice({
|
||||
const { id, size } = action.payload;
|
||||
state.textAreaSizes[id] = size;
|
||||
},
|
||||
panelStateChanged: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
id: keyof UIState['panels'];
|
||||
state: UIState['panels'][keyof UIState['panels']] | undefined;
|
||||
}>
|
||||
) => {
|
||||
const { id, state: panelState } = action.payload;
|
||||
if (panelState) {
|
||||
state.panels[id] = panelState;
|
||||
} else {
|
||||
delete state.panels[id];
|
||||
}
|
||||
},
|
||||
shouldShowNotificationChanged: (state, action: PayloadAction<UIState['shouldShowNotificationV2']>) => {
|
||||
state.shouldShowNotificationV2 = action.payload;
|
||||
},
|
||||
@@ -66,6 +80,7 @@ export const {
|
||||
expanderStateChanged,
|
||||
shouldShowNotificationChanged,
|
||||
textAreaSizesStateChanged,
|
||||
panelStateChanged,
|
||||
} = uiSlice.actions;
|
||||
|
||||
export const selectUiSlice = (state: RootState) => state.ui;
|
||||
|
||||
@@ -10,6 +10,25 @@ const zPartialDimensions = z.object({
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
const zDimensions = z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
});
|
||||
|
||||
const zDockviewPanelState = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('dockview-panel'),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
export type StoredDockviewPanelState = z.infer<typeof zDockviewPanelState>;
|
||||
|
||||
const zGridviewPanelState = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('gridview-panel'),
|
||||
dimensions: zDimensions,
|
||||
});
|
||||
export type StoredGridviewPanelState = z.infer<typeof zGridviewPanelState>;
|
||||
|
||||
const zUIState = z.object({
|
||||
_version: z.literal(3).default(3),
|
||||
activeTab: zTabName.default('canvas'),
|
||||
@@ -19,6 +38,7 @@ const zUIState = z.object({
|
||||
accordions: z.record(z.string(), z.boolean()).default(() => ({})),
|
||||
expanders: z.record(z.string(), z.boolean()).default(() => ({})),
|
||||
textAreaSizes: z.record(z.string(), zPartialDimensions).default({}),
|
||||
panels: z.record(z.string(), z.discriminatedUnion('type', [zDockviewPanelState, zGridviewPanelState])).default({}),
|
||||
shouldShowNotificationV2: z.boolean().default(true),
|
||||
});
|
||||
const INITIAL_STATE = zUIState.parse({});
|
||||
|
||||
Reference in New Issue
Block a user