Skip to content

Commit dcce85f

Browse files
author
Attila Cseh
committed
canvas tabs added
1 parent 34db96a commit dcce85f

File tree

4 files changed

+205
-0
lines changed

4 files changed

+205
-0
lines changed

invokeai/frontend/web/src/features/controlLayers/store/selectors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ import { assert } from 'tsafe';
2121
*/
2222
const selectCanvasSlice = (state: RootState) => state.canvas.present;
2323

24+
/**
25+
* Selects the canvases
26+
*/
27+
export const selectCanvases = createSelector(selectCanvasSlice, (state) =>
28+
state.canvases.map((canvas) => ({
29+
...canvas,
30+
isSelected: canvas.id === state.selectedCanvasId,
31+
canDelete: state.canvases.length > 1,
32+
}))
33+
);
34+
2435
/**
2536
* Selects the selected canvas
2637
*/
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Flex, IconButton, Input, Text } from '@invoke-ai/ui-library';
2+
import { useAppDispatch } from 'app/store/storeHooks';
3+
import { useBoolean } from 'common/hooks/useBoolean';
4+
import { useEditable } from 'common/hooks/useEditable';
5+
import { canvasNameChanged } from 'features/controlLayers/store/canvasSlice';
6+
import { memo, useCallback, useRef } from 'react';
7+
import { PiPencilBold } from 'react-icons/pi';
8+
9+
interface CanvasTabEditableTitleProps {
10+
id: string;
11+
name: string;
12+
isSelected: boolean;
13+
}
14+
15+
export const CanvasTabEditableTitle = memo(({ id, name, isSelected }: CanvasTabEditableTitleProps) => {
16+
const dispatch = useAppDispatch();
17+
const isHovering = useBoolean(false);
18+
const inputRef = useRef<HTMLInputElement>(null);
19+
20+
const onChange = useCallback(() => {
21+
dispatch(canvasNameChanged({ id, name }));
22+
}, [dispatch, id, name]);
23+
24+
const editable = useEditable({
25+
value: name,
26+
defaultValue: name,
27+
onChange,
28+
inputRef,
29+
onStartEditing: isHovering.setTrue,
30+
});
31+
32+
if (!editable.isEditing) {
33+
return (
34+
<Flex alignItems="center" gap={3} onMouseOver={isHovering.setTrue} onMouseOut={isHovering.setFalse}>
35+
<Text
36+
size="sm"
37+
fontWeight="semibold"
38+
userSelect="none"
39+
color={isSelected ? 'base.100' : 'base.300'}
40+
onDoubleClick={editable.startEditing}
41+
cursor="text"
42+
noOfLines={1}
43+
>
44+
{editable.value}
45+
</Text>
46+
{isHovering.isTrue && (
47+
<IconButton
48+
aria-label="edit name"
49+
icon={<PiPencilBold />}
50+
size="sm"
51+
variant="ghost"
52+
onClick={editable.startEditing}
53+
/>
54+
)}
55+
</Flex>
56+
);
57+
}
58+
59+
return (
60+
<Input
61+
ref={inputRef}
62+
{...editable.inputProps}
63+
variant="outline"
64+
textAlign="center"
65+
_focusVisible={{ borderWidth: 1, borderColor: 'invokeBlueAlpha.400', borderRadius: 'base' }}
66+
/>
67+
);
68+
});
69+
CanvasTabEditableTitle.displayName = 'CanvasTabEditableTitle';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
2+
import { Box, Flex, IconButton } from '@invoke-ai/ui-library';
3+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4+
import { canvasAdded, canvasDeleted, canvasSelected } from 'features/controlLayers/store/canvasSlice';
5+
import { selectCanvases } from 'features/controlLayers/store/selectors';
6+
import { memo, useCallback } from 'react';
7+
import { useTranslation } from 'react-i18next';
8+
import { PiPlusBold, PiXBold } from 'react-icons/pi';
9+
10+
import { CanvasTabEditableTitle } from './CanvasTabEditableTitle';
11+
12+
const _hover: SystemStyleObject = {
13+
bg: 'base.650',
14+
};
15+
16+
const AddCanvasButton = memo(() => {
17+
const { t } = useTranslation();
18+
const dispatch = useAppDispatch();
19+
20+
const onClick = useCallback(() => {
21+
dispatch(canvasAdded({ isSelected: true }));
22+
}, [dispatch]);
23+
24+
return (
25+
<IconButton
26+
size="sm"
27+
onClick={onClick}
28+
aria-label={t('canvas.addNewCanvas')}
29+
tooltip={t('canvas.addNewCanvas')}
30+
icon={<PiPlusBold />}
31+
bg="base.650"
32+
w={8}
33+
h={8}
34+
/>
35+
);
36+
});
37+
AddCanvasButton.displayName = 'AddCanvasButton';
38+
39+
interface CloseCanvasButtonProps {
40+
id: string;
41+
canDelete: boolean;
42+
}
43+
44+
const CloseCanvasButton = memo(({ id, canDelete }: CloseCanvasButtonProps) => {
45+
const { t } = useTranslation();
46+
const dispatch = useAppDispatch();
47+
48+
const onClick = useCallback(() => {
49+
dispatch(canvasDeleted({ id }));
50+
}, [dispatch, id]);
51+
52+
return (
53+
<IconButton
54+
size="sm"
55+
onClick={onClick}
56+
aria-label={t('canvas.closeCanvas')}
57+
tooltip={t('canvas.closeCanvas')}
58+
icon={<PiXBold />}
59+
disabled={!canDelete}
60+
variant="link"
61+
w={8}
62+
h={8}
63+
/>
64+
);
65+
});
66+
CloseCanvasButton.displayName = 'CloseCanvasButton';
67+
68+
interface CanvasTabProps {
69+
id: string;
70+
name: string;
71+
isSelected: boolean;
72+
canDelete: boolean;
73+
}
74+
75+
const CanvasTab = memo(({ id, name, isSelected, canDelete }: CanvasTabProps) => {
76+
const dispatch = useAppDispatch();
77+
78+
const onClick = useCallback(() => {
79+
if (!isSelected) {
80+
dispatch(canvasSelected({ id }));
81+
}
82+
}, [dispatch, id, isSelected]);
83+
84+
return (
85+
<Box position="relative" w="full" h={8}>
86+
<Flex
87+
onClick={onClick}
88+
alignItems="center"
89+
borderRadius="base"
90+
cursor="pointer"
91+
py={1}
92+
ps={1}
93+
pe={1}
94+
gap={4}
95+
bg={isSelected ? 'base.650' : 'base.850'}
96+
_hover={_hover}
97+
w="full"
98+
h="full"
99+
>
100+
<Flex flex={1} justifyContent="center">
101+
<CanvasTabEditableTitle id={id} name={name} isSelected={isSelected} />
102+
</Flex>
103+
<Flex justifyContent="flex-end">
104+
<CloseCanvasButton id={id} canDelete={canDelete} />
105+
</Flex>
106+
</Flex>
107+
</Box>
108+
);
109+
});
110+
CanvasTab.displayName = 'CanvasTab';
111+
112+
export const CanvasTabs = () => {
113+
const canvases = useAppSelector(selectCanvases);
114+
115+
return (
116+
<Flex w="full" gap={2} alignItems="center" px={2}>
117+
<AddCanvasButton />
118+
{canvases.map(({ id, name, isSelected, canDelete }) => (
119+
<CanvasTab key={id} id={id} name={name} isSelected={isSelected} canDelete={canDelete} />
120+
))}
121+
</Flex>
122+
);
123+
};

invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { selectCanvasSessionId } from 'features/controlLayers/store/canvasStagin
2222
import { memo, useCallback } from 'react';
2323
import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi';
2424

25+
import { CanvasTabs } from './CanvasTabs';
2526
import { StagingArea } from './StagingArea';
2627

2728
const MenuContent = memo(() => {
@@ -74,6 +75,7 @@ export const CanvasWorkspacePanel = memo(() => {
7475
<CanvasToolbar />
7576
</CanvasManagerProviderGate>
7677
<Divider />
78+
<CanvasTabs />
7779
<ContextMenu<HTMLDivElement> renderMenu={renderMenu} withLongPress={false}>
7880
{(ref) => (
7981
<Flex ref={ref} sx={canvasBgSx} data-dynamic-grid={dynamicGrid}>

0 commit comments

Comments
 (0)