Skip to content

feat(ui): Deeplinks for StudioInitAction #7277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import 'i18n';

import type { Middleware } from '@reduxjs/toolkit';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { fillStudioInitAction } from 'app/hooks/useStudioInitAction';
import type { LoggingOverrides } from 'app/logging/logger';
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
import { $authToken } from 'app/store/nanostores/authToken';
@@ -70,6 +71,8 @@ const InvokeAIUI = ({
workflowCategories,
loggingOverrides,
}: Props) => {
studioInitAction = fillStudioInitAction(studioInitAction);

useLayoutEffect(() => {
/*
* We need to configure logging before anything else happens - useLayoutEffect ensures we set this at the first
140 changes: 124 additions & 16 deletions invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { logger } from 'app/logging/logger';
import { useAppStore } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { withResultAsync } from 'common/util/result';
@@ -8,6 +9,7 @@ import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'
import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { sentImageToCanvas } from 'features/gallery/store/actions';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
@@ -16,26 +18,119 @@ import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/stor
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images';
import { getStylePreset } from 'services/api/endpoints/stylePresets';
import { z } from 'zod';

type _StudioInitAction<T extends string, U> = { type: T; data: U };
const zLoadWorkflowAction = z.object({
type: z.literal('loadWorkflow'),
data: z.object({ workflowId: z.string() }),
});
// type LoadWorkflowAction = z.infer<typeof zLoadWorkflowAction>;

type LoadWorkflowAction = _StudioInitAction<'loadWorkflow', { workflowId: string }>;
type SelectStylePresetAction = _StudioInitAction<'selectStylePreset', { stylePresetId: string }>;
type SendToCanvasAction = _StudioInitAction<'sendToCanvas', { imageName: string }>;
type UseAllParametersAction = _StudioInitAction<'useAllParameters', { imageName: string }>;
type StudioDestinationAction = _StudioInitAction<
'goToDestination',
{ destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' }
>;
const zSelectBoardAction = z.object({
type: z.literal('selectBoard'),
data: z.object({ boardId: z.string() }),
});
// type SelectBoardAction = z.infer<typeof zSelectBoardAction>;

export type StudioInitAction =
| LoadWorkflowAction
| SelectStylePresetAction
| SendToCanvasAction
| UseAllParametersAction
| StudioDestinationAction;
const zSelectImageAction = z.object({
type: z.literal('selectImage'),
data: z.object({ imageName: z.string() }),
});
// type SelectImageAction = z.infer<typeof zSelectImageAction>;

const zSelectStylePresetAction = z.object({
type: z.literal('selectStylePreset'),
data: z.object({ stylePresetId: z.string() }),
});
// type SelectStylePresetAction = z.infer<typeof zSelectStylePresetAction>;

const zSendToCanvasAction = z.object({
type: z.literal('sendToCanvas'),
data: z.object({ imageName: z.string() }),
});
// type SendToCanvasAction = z.infer<typeof zSendToCanvasAction>;

const zUseAllParametersAction = z.object({
type: z.literal('useAllParameters'),
data: z.object({ imageName: z.string() }),
});
// type UseAllParametersAction = z.infer<typeof zUseAllParametersAction>;

const zStudioDestinationAction = z.object({
type: z.literal('goToDestination'),
data: z.object({
destination: z.enum(['generation', 'canvas', 'workflows', 'upscaling', 'viewAllWorkflows', 'viewAllStylePresets']),
}),
});
type StudioDestinationAction = z.infer<typeof zStudioDestinationAction>;

export const zStudioInitAction = z.discriminatedUnion('type', [
zLoadWorkflowAction,
zSelectBoardAction,
zSelectImageAction,
zSelectStylePresetAction,
zSendToCanvasAction,
zUseAllParametersAction,
zStudioDestinationAction,
]);

export type StudioInitAction = z.infer<typeof zStudioInitAction>;

/**
* Converts a given hashbang string to a valid StudioInitAction
* @see fillStudioInitAction
* @param {string} hashBang
* @returns {StudioInitAction}
* @throws {z.ZodError | Error} If there is a validation error.
*/
export const genHashBangStudioInitAction = (hashBang: string): StudioInitAction => {
if (!hashBang.startsWith('#!')) {
throw new Error("The given string isn't a valid hashbang action");
}
const parts = hashBang.substring(2).split('&');
return zStudioInitAction.parse({
type: parts.shift(),
data: Object.fromEntries(new URLSearchParams(parts.join('&'))),
});
};

/**
* Uses the HashBang fragment to populate an unset StudioInitAction in case the user tries to execute a StudioInitAction on startup via a location.hash fragment
* If any studioInitAction is given, it will early bail with it.
* this will interpret and validate the hashbang as an studioInitAction
* @returns {StudioInitAction | undefined} undefined if nothing can be resolved
*/
export const fillStudioInitAction = (
studioInitAction?: StudioInitAction,
clearHashBang: boolean = false
): StudioInitAction | undefined => {
if (studioInitAction !== undefined) {
return studioInitAction;
}
if (!location.hash.startsWith('#!')) {
return undefined;
}

try {
studioInitAction = genHashBangStudioInitAction(location.hash);
if (clearHashBang) {
location.hash = ''; //reset the hash to "acknowledge" the initAction (and push the history forward)
}
} catch (err) {
const log = logger('system');
if (err instanceof z.ZodError) {
log.error({ error: serializeError(err) }, 'Problem persisting the studioInitAction from the given hashbang');
} else if (err instanceof Error) {
log.error({ error: serializeError(err) }, 'Problem interpreting the hashbang');
} else {
log.error({ error: serializeError(err) }, 'Problem while filling StudioInitAction');
}
}
return studioInitAction;
};

/**
* A hook that performs an action when the studio is initialized. This is useful for deep linking into the studio.
@@ -44,7 +139,7 @@ export type StudioInitAction =
*
* In this hook, we prefer to use imperative APIs over hooks to avoid re-rendering the parent component. For example:
* - Use `getImageDTO` helper instead of `useGetImageDTO`
* - Usee the `$imageViewer` atom instead of `useImageViewer`
* - Use the `$imageViewer` atom instead of `useImageViewer`
*/
export const useStudioInitAction = (action?: StudioInitAction) => {
useAssertSingleton('useStudioInitAction');
@@ -114,6 +209,15 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
[getAndLoadWorkflow, store]
);

const handleSelectBoard = useCallback(
(boardId: string) => {
//TODO: validate given boardID
store.dispatch(boardIdSelected({ boardId: boardId }));
//TODO: scroll into view
},
[store]
);

const handleSelectStylePreset = useCallback(
async (stylePresetId: string) => {
const getStylePresetResult = await withResultAsync(() => getStylePreset(stylePresetId));
@@ -184,6 +288,9 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
case 'loadWorkflow':
handleLoadWorkflow(action.data.workflowId);
break;
case 'selectBoard':
handleSelectBoard(action.data.boardId);
break;
case 'selectStylePreset':
handleSelectStylePreset(action.data.stylePresetId);
break;
@@ -202,6 +309,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
handleUseAllMetadata,
action,
handleLoadWorkflow,
handleSelectBoard,
handleSelectStylePreset,
handleGoToDestination,
]);
2 changes: 1 addition & 1 deletion invokeai/frontend/web/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ if (import.meta.env.MODE === 'package') {
fallbackLng: 'en',
debug: false,
backend: {
loadPath: `${window.location.href.replace(/\/$/, '')}/locales/{{lng}}.json`,
loadPath: '/locales/{{lng}}.json',
},
interpolation: {
escapeValue: false,
2 changes: 1 addition & 1 deletion invokeai/frontend/web/src/services/api/index.ts
Original file line number Diff line number Diff line change
@@ -59,7 +59,7 @@ const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryE
(typeof args === 'string' && args.includes('openapi.json'));

const fetchBaseQueryArgs: FetchBaseQueryArgs = {
baseUrl: baseUrl || window.location.href.replace(/\/$/, ''),
baseUrl: baseUrl ?? window.location.origin,
};

// When fetching the openapi.json, we need to remove circular references from the JSON.