Responsive Mobile Layout (#3207)

The first draft for a Responsive Mobile Layout for InvokeAI. Some basic
documentation to help contributors. // Notes from: @blessedcoolant

---

The whole rework needs to be done using the `mobile first` concept where
the base design will be catered to mobile and we add responsive changes
as we grow to larger screens.

**Added**

- Basic breakpoints have been added to the `theme.ts` file that indicate
at which values Chakra makes the responsive changes.
- A basic `useResolution` hook has been added that either returns
`mobile`, `tablet` or `desktop` based on the breakpoint. We can
customize this hook further to do more complex checks for us if need be.

**Syntax**

- Any Chakra component is directly capable of taking different values
for the different breakpoints set in our `theme.ts` file. These can be
passed in a few ways with the most descriptive being an object. For
example:

`flexDir={{ base: 'column', xl: 'row' }}` - This would set the `0em and
above` to be column for the flex direction but change to row
automatically when we hit `xl` and above resolutions which in our case
is `80em or 1280px`. This same format is applicable for any element in
Chakra.

`flexDir={['column', null, null, 'row', null]}` - The above syntax can
also be passed as an array to the property with each value in the array
corresponding to each breakpoint we have. Setting `null` just bypasses
it. This is a good short hand but I think we stick to the above syntax
for readability.

**Note**: I've modified a few elements here and there to give an idea on
how the responsive syntax works for reference.

---

**Problems to be solved** @SammCheese 

- Some issues you might run into are with the Resizable components.
We've decided we will get not use resizable components for smaller
resolutions. Doesn't make sense. So you'll need to make conditional
renderings around these.
- Some components that need custom layouts for different screens might
be better if ported over to `Grid` and use `gridTemplateAreas` to swap
out the design layout. I've demonstrated an example of this in a commit
I've made. I'll let you be the judge of where we might need this.
- The header will probably need to be converted to a burger menu of some
sort with the model changing being handled correctly UX wise. We'll
discuss this on discord.

---

Anyone willing to contribute to this PR can feel free to join the
discussion on discord.

https://discord.com/channels/1020123559063990373/1020839344170348605/threads/1097323866780606615
This commit is contained in:
psychedelicious 2023-04-22 22:34:30 +10:00 committed by GitHub
commit 2e70848aa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 364 additions and 176 deletions

View File

@ -6,6 +6,7 @@
"prepare": "cd ../../../ && husky install invokeai/frontend/web/.husky",
"dev": "concurrently \"vite dev\" \"yarn run theme:watch\"",
"dev:nodes": "concurrently \"vite dev --mode nodes\" \"yarn run theme:watch\"",
"dev:host": "concurrently \"vite dev --host\" \"yarn run theme:watch\"",
"build": "yarn run lint && vite build",
"api:web": "openapi -i http://localhost:9090/openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2 --request src/services/fixtures/request.ts",
"api:file": "openapi -i src/services/fixtures/openapi.json -o src/services/api --client axios --useOptions --useUnionTypes --exportSchemas true --indent 2 --request src/services/fixtures/request.ts",

View File

@ -19,7 +19,8 @@
"toggleAutoscroll": "Toggle autoscroll",
"toggleLogViewer": "Toggle Log Viewer",
"showGallery": "Show Gallery",
"showOptionsPanel": "Show Options Panel"
"showOptionsPanel": "Show Options Panel",
"menu": "Menu"
},
"common": {
"hotkeysLabel": "Hotkeys",

View File

@ -67,7 +67,12 @@ const App = (props: Props) => {
h={APP_HEIGHT}
>
{props.children || <SiteHeader />}
<Flex gap={4} w="full" h="full">
<Flex
gap={4}
w={{ base: '100vw', xl: 'full' }}
h="full"
flexDir={{ base: 'column', xl: 'row' }}
>
<InvokeTabs />
<ImageGalleryPanel />
</Flex>

View File

@ -31,13 +31,13 @@ export const DIFFUSERS_SAMPLERS: Array<string> = [
];
// Valid image widths
export const WIDTHS: Array<number> = Array.from(Array(65)).map(
(_x, i) => i * 64
export const WIDTHS: Array<number> = Array.from(Array(64)).map(
(_x, i) => (i + 1) * 64
);
// Valid image heights
export const HEIGHTS: Array<number> = Array.from(Array(65)).map(
(_x, i) => i * 64
export const HEIGHTS: Array<number> = Array.from(Array(64)).map(
(_x, i) => (i + 1) * 64
);
// Valid upscaling levels

View File

@ -0,0 +1,18 @@
import { useBreakpoint } from '@chakra-ui/react';
export default function useResolution():
| 'mobile'
| 'tablet'
| 'desktop'
| 'unknown' {
const breakpointValue = useBreakpoint();
const mobileResolutions = ['base', 'sm'];
const tabletResolutions = ['md', 'lg'];
const desktopResolutions = ['xl', '2xl'];
if (mobileResolutions.includes(breakpointValue)) return 'mobile';
if (tabletResolutions.includes(breakpointValue)) return 'tablet';
if (desktopResolutions.includes(breakpointValue)) return 'desktop';
return 'unknown';
}

View File

@ -425,9 +425,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
return (
<Flex
sx={{
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
columnGap: '0.5em',
gap: 2,
}}
{...props}
>

View File

@ -13,7 +13,7 @@ const CurrentImageHidden = () => {
color: 'base.400',
}}
>
<FaEyeSlash size={'30vh'} />
<FaEyeSlash fontSize="25vh" />
</Flex>
);
};

View File

@ -26,6 +26,8 @@ import {
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import useResolution from 'common/hooks/useResolution';
import { Flex } from '@chakra-ui/react';
const GALLERY_TAB_WIDTHS: Record<
InvokeTabName,
@ -97,6 +99,8 @@ export default function ImageGalleryPanel() {
shouldPinGallery && dispatch(requestCanvasRescale());
};
const resolution = useResolution();
useHotkeys(
'g',
() => {
@ -179,6 +183,25 @@ export default function ImageGalleryPanel() {
[galleryImageMinimumWidth]
);
const calcGalleryMinHeight = () => {
if (resolution === 'desktop') return;
return 300;
};
const imageGalleryContent = () => {
return (
<Flex
w="100vw"
h={{ base: 300, xl: '100vh' }}
paddingRight={{ base: 8, xl: 0 }}
paddingBottom={{ base: 4, xl: 0 }}
>
<ImageGalleryContent />
</Flex>
);
};
const resizableImageGalleryContent = () => {
return (
<ResizableDrawer
direction="right"
@ -196,8 +219,17 @@ export default function ImageGalleryPanel() {
? GALLERY_TAB_WIDTHS[activeTabName].galleryMaxWidth
: undefined
}
minHeight={calcGalleryMinHeight()}
>
<ImageGalleryContent />
</ResizableDrawer>
);
};
const renderImageGallery = () => {
if (['mobile', 'tablet'].includes(resolution)) return imageGalleryContent();
return resizableImageGalleryContent();
};
return renderImageGallery();
}

View File

@ -11,7 +11,7 @@ const NodeEditor = () => {
sx={{
position: 'relative',
width: 'full',
height: 'full',
height: { base: '100vh', xl: 'full' },
borderRadius: 'md',
bg: 'base.850',
}}

View File

@ -76,7 +76,7 @@ const PromptInput = () => {
onKeyDown={handleKeyDown}
resize="vertical"
ref={promptRef}
minH={40}
minH={{ base: 20, lg: 40 }}
/>
</FormControl>
</Box>

View File

@ -10,7 +10,15 @@ const InvokeAILogoComponent = () => {
return (
<Flex alignItems="center" gap={3} ps={1}>
<Image src={InvokeAILogoImage} alt="invoke-ai-logo" w="32px" h="32px" />
<Image
src={InvokeAILogoImage}
alt="invoke-ai-logo"
w="32px"
h="32px"
minW="32px"
minH="32px"
/>
<Flex gap={3} display={{ base: 'inherit', sm: 'none', md: 'inherit' }}>
<Text fontSize="xl">
invoke <strong>ai</strong>
</Text>
@ -24,6 +32,7 @@ const InvokeAILogoComponent = () => {
{appVersion}
</Text>
</Flex>
</Flex>
);
};

View File

@ -1,126 +1,68 @@
import { Flex, Grid, Link } from '@chakra-ui/react';
import { FaBug, FaCube, FaDiscord, FaGithub, FaKeyboard } from 'react-icons/fa';
import IAIIconButton from 'common/components/IAIIconButton';
import HotkeysModal from './HotkeysModal/HotkeysModal';
import ModelManagerModal from './ModelManager/ModelManagerModal';
import { Flex, Grid } from '@chakra-ui/react';
import { useState } from 'react';
import ModelSelect from './ModelSelect';
import SettingsModal from './SettingsModal/SettingsModal';
import StatusIndicator from './StatusIndicator';
import ThemeChanger from './ThemeChanger';
import LanguagePicker from './LanguagePicker';
import { useTranslation } from 'react-i18next';
import { MdSettings } from 'react-icons/md';
import InvokeAILogoComponent from './InvokeAILogoComponent';
import SiteHeaderMenu from './SiteHeaderMenu';
import useResolution from 'common/hooks/useResolution';
import { FaBars } from 'react-icons/fa';
import { IAIIconButton } from 'exports';
import { useTranslation } from 'react-i18next';
/**
* Header, includes color mode toggle, settings button, status message.
*/
const SiteHeader = () => {
const [menuOpened, setMenuOpened] = useState(false);
const resolution = useResolution();
const { t } = useTranslation();
return (
<Grid gridTemplateColumns="auto max-content">
<Grid
gridTemplateColumns={{ base: 'auto', sm: 'auto max-content' }}
paddingRight={{ base: 10, xl: 0 }}
gap={2}
>
<Flex justifyContent={{ base: 'center', sm: 'start' }}>
<InvokeAILogoComponent />
<Flex alignItems="center" gap={2}>
</Flex>
<Flex
alignItems="center"
gap={2}
justifyContent={{ base: 'center', sm: 'start' }}
>
<StatusIndicator />
<ModelSelect />
<ModelManagerModal>
{resolution === 'desktop' ? (
<SiteHeaderMenu />
) : (
<IAIIconButton
aria-label={t('modelManager.modelManager')}
tooltip={t('modelManager.modelManager')}
size="sm"
variant="link"
data-variant="link"
fontSize={20}
icon={<FaCube />}
/>
</ModelManagerModal>
<HotkeysModal>
<IAIIconButton
aria-label={t('common.hotkeysLabel')}
tooltip={t('common.hotkeysLabel')}
size="sm"
variant="link"
data-variant="link"
fontSize={20}
icon={<FaKeyboard />}
/>
</HotkeysModal>
<ThemeChanger />
<LanguagePicker />
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI/issues"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.reportBugLabel')}
tooltip={t('common.reportBugLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaBug />}
/>
</Link>
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.githubLabel')}
tooltip={t('common.githubLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaGithub />}
/>
</Link>
<Link
isExternal
href="https://discord.gg/ZmtBAhwWhy"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.discordLabel')}
tooltip={t('common.discordLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaDiscord />}
/>
</Link>
<SettingsModal>
<IAIIconButton
aria-label={t('common.settingsLabel')}
tooltip={t('common.settingsLabel')}
variant="link"
data-variant="link"
fontSize={22}
size="sm"
icon={<MdSettings />}
/>
</SettingsModal>
icon={<FaBars />}
aria-label={t('accessibility.menu')}
background={menuOpened ? 'base.800' : 'none'}
_hover={{ background: menuOpened ? 'base.800' : 'none' }}
onClick={() => setMenuOpened(!menuOpened)}
p={0}
></IAIIconButton>
)}
</Flex>
{resolution !== 'desktop' && menuOpened && (
<Flex
position="absolute"
right={6}
top={{ base: 28, sm: 16 }}
backgroundColor="base.800"
padding={4}
borderRadius={4}
zIndex={{ base: 99, xl: 0 }}
>
<SiteHeaderMenu />
</Flex>
)}
</Grid>
);
};

View File

@ -0,0 +1,113 @@
import { Flex, Link } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { FaCube, FaKeyboard, FaBug, FaGithub, FaDiscord } from 'react-icons/fa';
import { MdSettings } from 'react-icons/md';
import HotkeysModal from './HotkeysModal/HotkeysModal';
import LanguagePicker from './LanguagePicker';
import ModelManagerModal from './ModelManager/ModelManagerModal';
import SettingsModal from './SettingsModal/SettingsModal';
import ThemeChanger from './ThemeChanger';
import IAIIconButton from 'common/components/IAIIconButton';
const SiteHeaderMenu = () => {
const { t } = useTranslation();
return (
<Flex
alignItems="center"
flexDirection={{ base: 'column', xl: 'row' }}
gap={{ base: 4, xl: 1 }}
>
<ModelManagerModal>
<IAIIconButton
aria-label={t('modelManager.modelManager')}
tooltip={t('modelManager.modelManager')}
size="sm"
variant="link"
data-variant="link"
fontSize={20}
icon={<FaCube />}
/>
</ModelManagerModal>
<HotkeysModal>
<IAIIconButton
aria-label={t('common.hotkeysLabel')}
tooltip={t('common.hotkeysLabel')}
size="sm"
variant="link"
data-variant="link"
fontSize={20}
icon={<FaKeyboard />}
/>
</HotkeysModal>
<ThemeChanger />
<LanguagePicker />
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI/issues"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.reportBugLabel')}
tooltip={t('common.reportBugLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaBug />}
/>
</Link>
<Link
isExternal
href="http://github.com/invoke-ai/InvokeAI"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.githubLabel')}
tooltip={t('common.githubLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaGithub />}
/>
</Link>
<Link
isExternal
href="https://discord.gg/ZmtBAhwWhy"
marginBottom="-0.25rem"
>
<IAIIconButton
aria-label={t('common.discordLabel')}
tooltip={t('common.discordLabel')}
variant="link"
data-variant="link"
fontSize={20}
size="sm"
icon={<FaDiscord />}
/>
</Link>
<SettingsModal>
<IAIIconButton
aria-label={t('common.settingsLabel')}
tooltip={t('common.settingsLabel')}
variant="link"
data-variant="link"
fontSize={22}
size="sm"
icon={<MdSettings />}
/>
</SettingsModal>
</Flex>
);
};
SiteHeaderMenu.displayName = 'SiteHeaderMenu';
export default SiteHeaderMenu;

View File

@ -138,9 +138,16 @@ export default function InvokeTabs() {
dispatch(setActiveTab(index));
}}
flexGrow={1}
flexDir={{ base: 'column', xl: 'row' }}
gap={{ base: 4 }}
isLazy
>
<TabList pt={2} gap={4}>
<TabList
pt={2}
gap={4}
flexDir={{ base: 'row', xl: 'column' }}
justifyContent={{ base: 'center', xl: 'start' }}
>
{tabs}
</TabList>
<TabPanels>{tabPanels}</TabPanels>

View File

@ -1,4 +1,4 @@
import { Box, BoxProps, Flex } from '@chakra-ui/react';
import { Box, BoxProps, Grid, GridItem } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
import { initialImageSelected } from 'features/parameters/store/generationSlice';
@ -52,12 +52,32 @@ const InvokeWorkarea = (props: InvokeWorkareaProps) => {
};
return (
<Flex {...rest} pos="relative" w="full" h={APP_CONTENT_HEIGHT} gap={4}>
<Grid
{...rest}
gridTemplateAreas={{
base: `'workarea-display' 'workarea-panel'`,
xl: `'workarea-panel workarea-display'`,
}}
gridAutoRows={{ base: 'auto', xl: 'auto' }}
gridAutoColumns={{ md: 'max-content auto' }}
pos="relative"
w="full"
h={{ base: 'auto', xl: APP_CONTENT_HEIGHT }}
gap={4}
>
<ParametersPanel>{parametersPanelContent}</ParametersPanel>
<Box pos="relative" w="100%" h="100%" onDrop={handleDrop}>
<GridItem gridArea="workarea-display">
<Box
pos="relative"
w={{ base: '100vw', xl: 'full' }}
paddingRight={{ base: 8, xl: 0 }}
h={{ base: 600, xl: '100%' }}
onDrop={handleDrop}
>
{children}
</Box>
</Flex>
</GridItem>
</Grid>
);
};

View File

@ -19,6 +19,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { activeTabNameSelector, uiSelector } from '../store/uiSelectors';
import { isEqual } from 'lodash';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import useResolution from 'common/hooks/useResolution';
const parametersPanelSelector = createSelector(
[uiSelector, activeTabNameSelector, lightboxSelector],
@ -58,6 +59,8 @@ const ParametersPanel = ({ children }: ParametersPanelProps) => {
dispatch(setShouldShowParametersPanel(false));
};
const resolution = useResolution();
useHotkeys(
'o',
() => {
@ -88,21 +91,16 @@ const ParametersPanel = ({ children }: ParametersPanelProps) => {
},
[]
);
const parametersPanelContent = () => {
return (
<ResizableDrawer
direction="left"
isResizable={isResizable || !shouldPinParametersPanel}
isOpen={shouldShowParametersPanel}
onClose={closeParametersPanel}
isPinned={shouldPinParametersPanel || isLightboxOpen}
sx={{
p: shouldPinParametersPanel ? 0 : 4,
bg: 'base.900',
}}
initialWidth={PARAMETERS_PANEL_WIDTH}
minWidth={PARAMETERS_PANEL_WIDTH}
<Flex
flexDir="column"
position="relative"
h={{ base: 600, xl: 'full' }}
w={{ sm: 'full', lg: '100vw', xl: 'full' }}
paddingRight={{ base: 8, xl: 0 }}
>
<Flex flexDir="column" position="relative" h="full" w="full">
{!shouldPinParametersPanel && (
<Flex
paddingTop={1.5}
@ -111,18 +109,47 @@ const ParametersPanel = ({ children }: ParametersPanelProps) => {
alignItems="center"
>
<InvokeAILogoComponent />
<PinParametersPanelButton />
{resolution == 'desktop' && <PinParametersPanelButton />}
</Flex>
)}
<Scrollable>{children}</Scrollable>
{shouldPinParametersPanel && (
{shouldPinParametersPanel && resolution == 'desktop' && (
<PinParametersPanelButton
sx={{ position: 'absolute', top: 0, insetInlineEnd: 0 }}
/>
)}
</Flex>
);
};
const resizableParametersPanelContent = () => {
return (
<ResizableDrawer
direction="left"
isResizable={isResizable || !shouldPinParametersPanel}
isOpen={shouldShowParametersPanel}
onClose={closeParametersPanel}
isPinned={shouldPinParametersPanel || isLightboxOpen}
sx={{
borderColor: 'base.700',
p: shouldPinParametersPanel ? 0 : 4,
bg: 'base.900',
}}
initialWidth={PARAMETERS_PANEL_WIDTH}
minWidth={PARAMETERS_PANEL_WIDTH}
>
{parametersPanelContent()}
</ResizableDrawer>
);
};
const renderParametersPanel = () => {
if (['mobile', 'tablet'].includes(resolution))
return parametersPanelContent();
return resizableParametersPanelContent();
};
return renderParametersPanel();
};
export default memo(ParametersPanel);

View File

@ -33,6 +33,7 @@ const PinParametersPanelButton = (props: PinParametersPanelButtonProps) => {
icon={shouldPinParametersPanel ? <BsPinAngleFill /> : <BsPinAngle />}
variant="ghost"
size="sm"
px={{ base: 10, xl: 0 }}
sx={{
color: 'base.700',
_hover: {

View File

@ -29,7 +29,10 @@ export const theme: ThemeOverride = {
body: {
bg: 'base.900',
color: 'base.50',
overflow: 'hidden',
overflow: {
base: 'scroll',
xl: 'hidden',
},
},
'*': { ...no_scrollbar },
}),
@ -38,6 +41,14 @@ export const theme: ThemeOverride = {
fonts: {
body: `'Inter', sans-serif`,
},
breakpoints: {
base: '0em', // 0px and onwards
sm: '30em', // 480px and onwards
md: '48em', // 768px and onwards
lg: '62em', // 992px and onwards
xl: '80em', // 1280px and onwards
'2xl': '96em', // 1536px and onwards
},
shadows: {
light: {
accent: `0 0 10px 0 var(--invokeai-colors-accent-300)`,