Compare commits

...

10 Commits

Author SHA1 Message Date
psychedelicious
f82640b5df fix(ui): brush size and layer cycle hotkeys conflict
Closes #6829
2024-09-10 09:20:19 -04:00
psychedelicious
e3e50abc5a fix(ui): do not show count on layers tab when no layers 2024-09-10 09:20:19 -04:00
psychedelicious
061bff2814 chore: release v5.0.0.a2 2024-09-10 09:20:19 -04:00
psychedelicious
e5a53be42b feat(ui): add canvas context menu
So far, this includes:
- Save Canvas to Gallery
- Save Bbox to Gallery
- Send Bbox to Regional IP Adapter
- Send Bbox to Global IP Adapter
- Send Bbox to Control Layer
- Send Bbox to Raster Layer
2024-09-10 09:20:19 -04:00
psychedelicious
54c94bd713 chore(ui): bump @invoke-ai/ui-library
Fixes an issue where modifier keys get stuck on when you change tabs or windows.
2024-09-10 09:20:19 -04:00
psychedelicious
8d56becf04 fix(ui): retain global canvas manager instance
To prevent losing all ephemeral canvas stage when switching tabs, we will refrain from destroying the canvas manager instance when its tab unmounts, and use the existing canvas manager instance on mount, if there is one.

One small change required in `CanvasStageModule` - a `setContainer` method to update the konva stage DOM element.
2024-09-10 09:20:19 -04:00
psychedelicious
dc51ccd9a6 feat(ui): simplify canvas component & hook API 2024-09-10 09:20:19 -04:00
psychedelicious
f5eefedc49 feat(ui): add count to layers tab button 2024-09-10 09:20:19 -04:00
psychedelicious
136891ec3d fix(ui): translation string for gallery tab 2024-09-10 09:20:19 -04:00
psychedelicious
c5543e42c7 fix(ui): drag image over tab switches to wrong tab 2024-09-10 09:20:19 -04:00
18 changed files with 440 additions and 189 deletions

View File

@@ -58,7 +58,7 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.0.20",
"@invoke-ai/ui-library": "^0.0.33",
"@invoke-ai/ui-library": "^0.0.34",
"@nanostores/react": "^0.7.3",
"@reduxjs/toolkit": "2.2.3",
"@roarr/browser-log-writer": "^1.3.0",

View File

@@ -24,8 +24,8 @@ dependencies:
specifier: ^5.0.20
version: 5.0.20
'@invoke-ai/ui-library':
specifier: ^0.0.33
version: 0.0.33(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.20)(@types/react@18.3.3)(i18next@23.12.2)(react-dom@18.3.1)(react@18.3.1)
specifier: ^0.0.34
version: 0.0.34(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.20)(@types/react@18.3.3)(i18next@23.12.2)(react-dom@18.3.1)(react@18.3.1)
'@nanostores/react':
specifier: ^0.7.3
version: 0.7.3(nanostores@0.11.2)(react@18.3.1)
@@ -3574,8 +3574,8 @@ packages:
prettier: 3.3.3
dev: true
/@invoke-ai/ui-library@0.0.33(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.20)(@types/react@18.3.3)(i18next@23.12.2)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-YLydTCOTUEgju4Ex6yXt/bvNBcO97y6zc1cGYjt7vtJMS8e6deA89cC5JejjbmVgntdnn49cDyeUxB8Z24gZew==}
/@invoke-ai/ui-library@0.0.34(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.20)(@types/react@18.3.3)(i18next@23.12.2)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-iDSjFQV2U4LfQ8+UdZ9Uy6J1iKKTSsXM0uhkWrwcIghbgN5QwY3ABVLhqJrSWVTwp7puEDhe/lRQ9QhTZBkVzw==}
peerDependencies:
'@fontsource-variable/inter': ^5.0.16
react: ^18.2.0

View File

@@ -1662,8 +1662,12 @@
"bookmark": "Bookmark for Quick Switch",
"fitBboxToLayers": "Fit Bbox To Layers",
"removeBookmark": "Remove Bookmark",
"saveCanvasToGallery": "Save Canvas To Gallery",
"saveBboxToGallery": "Save Bbox To Gallery",
"saveCanvasToGallery": "Save Canvas to Gallery",
"saveBboxToGallery": "Save Bbox to Gallery",
"sendBboxToRegionalIPAdapter": "Send Bbox to Regional IP Adapter",
"sendBboxToGlobalIPAdapter": "Send Bbox to Global IP Adapter",
"sendBboxToControlLayer": "Send Bbox to Control Layer",
"sendBboxToRasterLayer": "Send Bbox to Raster Layer",
"savedToGalleryOk": "Saved to Gallery",
"savedToGalleryError": "Error saving to gallery",
"mergeVisible": "Merge Visible",
@@ -1756,6 +1760,8 @@
"noLayersAdded": "No Layers Added",
"layer_one": "Layer",
"layer_other": "Layers",
"layer_withCount_one": "Layer ({{count}})",
"layer_withCount_other": "Layers ({{count}})",
"objects_zero": "empty",
"objects_one": "{{count}} object",
"objects_other": "{{count}} objects",
@@ -1925,7 +1931,9 @@
"queue": "Queue",
"queueTab": "$t(ui.tabs.queue) $t(common.tab)",
"upscaling": "Upscaling",
"upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)"
"upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)",
"gallery": "Gallery",
"galleryTab": "$t(ui.tabs.gallery) $t(common.tab)"
}
},
"system": {

View File

@@ -0,0 +1,49 @@
import { MenuItem } from '@invoke-ai/ui-library';
import {
useIsSavingCanvas,
useSaveBboxAsControlLayer,
useSaveBboxAsGlobalIPAdapter,
useSaveBboxAsRasterLayer,
useSaveBboxAsRegionalGuidanceIPAdapter,
useSaveBboxToGallery,
useSaveCanvasToGallery,
} from 'features/controlLayers/hooks/saveCanvasHooks';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold, PiShareFatBold } from 'react-icons/pi';
export const CanvasContextMenuItems = memo(() => {
const { t } = useTranslation();
const isSaving = useIsSavingCanvas();
const saveCanvasToGallery = useSaveCanvasToGallery();
const saveBboxToGallery = useSaveBboxToGallery();
const saveBboxAsRegionalGuidanceIPAdapter = useSaveBboxAsRegionalGuidanceIPAdapter();
const saveBboxAsIPAdapter = useSaveBboxAsGlobalIPAdapter();
const saveBboxAsRasterLayer = useSaveBboxAsRasterLayer();
const saveBboxAsControlLayer = useSaveBboxAsControlLayer();
return (
<>
<MenuItem icon={<PiFloppyDiskBold />} isLoading={isSaving.isTrue} onClick={saveCanvasToGallery}>
{t('controlLayers.saveCanvasToGallery')}
</MenuItem>
<MenuItem icon={<PiFloppyDiskBold />} isLoading={isSaving.isTrue} onClick={saveBboxToGallery}>
{t('controlLayers.saveBboxToGallery')}
</MenuItem>
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsIPAdapter}>
{t('controlLayers.sendBboxToGlobalIPAdapter')}
</MenuItem>
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsRegionalGuidanceIPAdapter}>
{t('controlLayers.sendBboxToRegionalIPAdapter')}
</MenuItem>
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsControlLayer}>
{t('controlLayers.sendBboxToControlLayer')}
</MenuItem>
<MenuItem icon={<PiShareFatBold />} isLoading={isSaving.isTrue} onClick={saveBboxAsRasterLayer}>
{t('controlLayers.sendBboxToRasterLayer')}
</MenuItem>
</>
);
});
CanvasContextMenuItems.displayName = 'CanvasContextMenuItems';

View File

@@ -5,8 +5,9 @@ import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasPanelContent } from 'features/controlLayers/components/CanvasPanelContent';
import { CanvasSendToToggle } from 'features/controlLayers/components/CanvasSendToToggle';
import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectEntityCount } from 'features/controlLayers/store/selectors';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { memo, useCallback, useRef, useState } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasRightPanelContent = memo(() => {
@@ -37,6 +38,7 @@ CanvasRightPanelContent.displayName = 'CanvasRightPanelContent';
const PanelTabs = memo(({ setTab }: { setTab: (val: number) => void }) => {
const { t } = useTranslation();
const entityCount = useAppSelector(selectEntityCount);
const sendToCanvas = useAppSelector(selectSendToCanvas);
const tabTimeout = useRef<number | null>(null);
const dndCtx = useDndContext();
@@ -44,7 +46,7 @@ const PanelTabs = memo(({ setTab }: { setTab: (val: number) => void }) => {
const onOnMouseOverLayersTab = useCallback(() => {
tabTimeout.current = window.setTimeout(() => {
if (dndCtx.active) {
setTab(1);
setTab(0);
}
}, 300);
}, [dndCtx.active, setTab]);
@@ -52,7 +54,7 @@ const PanelTabs = memo(({ setTab }: { setTab: (val: number) => void }) => {
const onOnMouseOverGalleryTab = useCallback(() => {
tabTimeout.current = window.setTimeout(() => {
if (dndCtx.active) {
setTab(0);
setTab(1);
}
}, 300);
}, [dndCtx.active, setTab]);
@@ -62,10 +64,20 @@ const PanelTabs = memo(({ setTab }: { setTab: (val: number) => void }) => {
clearTimeout(tabTimeout.current);
}
}, []);
const layersTabLabel = useMemo(() => {
if (entityCount === 0) {
return t('controlLayers.layer_other');
}
return `${t('controlLayers.layer_other')} (${entityCount})`;
}, [entityCount, t]);
return (
<>
<Tab position="relative" onMouseOver={onOnMouseOverLayersTab} onMouseOut={onMouseOut}>
{t('controlLayers.layer_other')}
<Tab position="relative" onMouseOver={onOnMouseOverLayersTab} onMouseOut={onMouseOut} w={32}>
<Box as="span" w="full">
{layersTabLabel}
</Box>
{sendToCanvas && (
<Box position="absolute" top={2} right={2} h={2} w={2} bg="invokeYellow.300" borderRadius="full" />
)}

View File

@@ -1,18 +1,36 @@
/* eslint-disable i18next/no-literal-string */
import { Flex } from '@invoke-ai/ui-library';
import { ContextMenu, Flex, MenuList } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
import { CanvasContextMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItems';
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
import { Filter } from 'features/controlLayers/components/Filters/Filter';
import { StageComponent } from 'features/controlLayers/components/StageComponent';
import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { CanvasSelectedEntityStatusAlert } from 'features/controlLayers/components/HUD/CanvasSelectedEntityStatusAlert';
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
import { Transform } from 'features/controlLayers/components/Transform/Transform';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { memo, useRef } from 'react';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
import { memo, useCallback, useRef } from 'react';
export const CanvasTabContent = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
const renderMenu = useCallback(() => {
return (
<CanvasManagerProviderGate>
<MenuList>
<CanvasContextMenuItems />
</MenuList>
</CanvasManagerProviderGate>
);
}, []);
useScopeOnFocus('canvas', ref);
return (
@@ -29,7 +47,42 @@ export const CanvasTabContent = memo(() => {
justifyContent="center"
>
<CanvasToolbar />
<StageComponent />
<ContextMenu<HTMLDivElement> renderMenu={renderMenu}>
{(ref) => (
<Flex
ref={ref}
position="relative"
w="full"
h="full"
bg={dynamicGrid ? 'base.850' : 'base.900'}
borderRadius="base"
>
{!dynamicGrid && (
<Flex
position="absolute"
borderRadius="base"
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
top={0}
right={0}
bottom={0}
left={0}
opacity={0.1}
/>
)}
<InvokeCanvasComponent />
<CanvasManagerProviderGate>
{showHUD && (
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
<CanvasHUD />
</Flex>
)}
<Flex position="absolute" top={1} insetInlineEnd={1} pointerEvents="none">
<CanvasSelectedEntityStatusAlert />
</Flex>
</CanvasManagerProviderGate>
</Flex>
)}
</ContextMenu>
<Flex position="absolute" bottom={4} gap={2} align="center" justify="center">
<CanvasManagerProviderGate>
<StagingAreaIsStagingGate>

View File

@@ -0,0 +1,23 @@
import { Box } from '@invoke-ai/ui-library';
import { useInvokeCanvas } from 'features/controlLayers/hooks/useInvokeCanvas';
import { memo } from 'react';
export const InvokeCanvasComponent = memo(() => {
const ref = useInvokeCanvas();
return (
<Box
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
ref={ref}
borderRadius="base"
overflow="hidden"
data-testid="control-layers-canvas"
/>
);
});
InvokeCanvasComponent.displayName = 'InvokeCanvasComponent';

View File

@@ -1,116 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $socket } from 'app/hooks/useSocketIO';
import { logger } from 'app/logging/logger';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { CanvasSelectedEntityStatusAlert } from 'features/controlLayers/components/HUD/CanvasSelectedEntityStatusAlert';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice';
import Konva from 'konva';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
const log = logger('canvas');
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
Konva.showWarnings = false;
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null) => {
const store = useAppStore();
const socket = useStore($socket);
const dpr = useDevicePixelRatio({ round: false });
useLayoutEffect(() => {
log.debug('Initializing renderer');
if (!container) {
// Nothing to clean up
log.debug('No stage container, skipping initialization');
return () => {};
}
if (!socket) {
log.debug('Socket not connected, skipping initialization');
return () => {};
}
const manager = new CanvasManager(stage, container, store, socket);
manager.initialize();
return manager.destroy;
}, [container, socket, stage, store]);
useLayoutEffect(() => {
Konva.pixelRatio = dpr;
}, [dpr]);
};
export const StageComponent = memo(() => {
const dynamicGrid = useAppSelector(selectDynamicGrid);
const showHUD = useAppSelector(selectShowHUD);
const [stage] = useState(
() =>
new Konva.Stage({
id: getPrefixedId('konva_stage'),
container: document.createElement('div'),
})
);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
setContainer(el);
}, []);
useStageRenderer(stage, container);
useEffect(
() => () => {
stage.destroy();
},
[stage]
);
return (
<Flex position="relative" w="full" h="full" bg={dynamicGrid ? 'base.850' : 'base.900'} borderRadius="base">
{!dynamicGrid && (
<Flex
position="absolute"
borderRadius="base"
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
top={0}
right={0}
bottom={0}
left={0}
opacity={0.1}
/>
)}
<Flex
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
ref={containerRef}
borderRadius="base"
overflow="hidden"
data-testid="control-layers-canvas"
/>
<CanvasManagerProviderGate>
{showHUD && (
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
<CanvasHUD />
</Flex>
)}
<Flex position="absolute" top={1} insetInlineEnd={1} pointerEvents="none">
<CanvasSelectedEntityStatusAlert />
</Flex>
</CanvasManagerProviderGate>
</Flex>
);
});
StageComponent.displayName = 'StageComponent';

View File

@@ -1,47 +1,24 @@
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { isOk, withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { toast } from 'features/toast/toast';
import { memo, useCallback } from 'react';
import {
useIsSavingCanvas,
useSaveBboxToGallery,
useSaveCanvasToGallery,
} from 'features/controlLayers/hooks/saveCanvasHooks';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { serializeError } from 'serialize-error';
const log = logger('canvas');
const [useIsSaving] = buildUseBoolean(false);
export const CanvasToolbarSaveToGalleryButton = memo(() => {
const { t } = useTranslation();
const shift = useShiftModifier();
const canvasManager = useCanvasManager();
const isSaving = useIsSaving();
const onClick = useCallback(async () => {
isSaving.setTrue();
const rect = shift ? canvasManager.stateApi.getBbox().rect : canvasManager.stage.getVisibleRect('raster_layer');
const result = await withResultAsync(() =>
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, true)
);
if (isOk(result)) {
toast({ title: t('controlLayers.savedToGalleryOk') });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
toast({ title: t('controlLayers.savedToGalleryError'), status: 'error' });
}
isSaving.setFalse();
}, [canvasManager.compositor, canvasManager.stage, canvasManager.stateApi, isSaving, shift, t]);
const isSaving = useIsSavingCanvas();
const saveCanvasToGallery = useSaveCanvasToGallery();
const saveBboxToGallery = useSaveBboxToGallery();
return (
<IconButton
variant="ghost"
onClick={onClick}
onClick={shift ? saveBboxToGallery : saveCanvasToGallery}
icon={<PiFloppyDiskBold />}
isLoading={isSaving.isTrue}
aria-label={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}

View File

@@ -49,20 +49,24 @@ export const selectDefaultControlAdapter = createSelector(
}
);
const selectDefaultIPAdapter = createSelector(selectModelConfigsQuery, selectBase, (query, base): IPAdapterConfig => {
const { data } = query;
let model: IPAdapterModelConfig | null = null;
if (data) {
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig);
const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true));
model = compatibleModels[0] ?? modelConfigs[0] ?? null;
export const selectDefaultIPAdapter = createSelector(
selectModelConfigsQuery,
selectBase,
(query, base): IPAdapterConfig => {
const { data } = query;
let model: IPAdapterModelConfig | null = null;
if (data) {
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data).filter(isIPAdapterModelConfig);
const compatibleModels = modelConfigs.filter((m) => (base ? m.base === base : true));
model = compatibleModels[0] ?? modelConfigs[0] ?? null;
}
const ipAdapter = deepClone(initialIPAdapter);
if (model) {
ipAdapter.model = zModelIdentifierField.parse(model);
}
return ipAdapter;
}
const ipAdapter = deepClone(initialIPAdapter);
if (model) {
ipAdapter.model = zModelIdentifierField.parse(model);
}
return ipAdapter;
});
);
export const useAddControlLayer = () => {
const dispatch = useAppDispatch();

View File

@@ -0,0 +1,167 @@
import { logger } from 'app/logging/logger';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { buildUseBoolean } from 'common/hooks/useBoolean';
import { isOk, withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { controlLayerAdded, ipaAdded, rasterLayerAdded, rgAdded } from 'features/controlLayers/store/canvasSlice';
import type {
CanvasControlLayerState,
CanvasIPAdapterState,
CanvasRasterLayerState,
CanvasRegionalGuidanceState,
Rect,
RegionalGuidanceIPAdapterConfig,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/types';
import { toast } from 'features/toast/toast';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
import type { ImageDTO } from 'services/api/types';
const log = logger('canvas');
export const [useIsSavingCanvas] = buildUseBoolean(false);
type UseSaveCanvasArg = {
region: 'canvas' | 'bbox';
saveToGallery: boolean;
onSave?: (imageDTO: ImageDTO, rect: Rect) => void;
};
const useSaveCanvas = ({ region, saveToGallery, onSave }: UseSaveCanvasArg) => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const isSaving = useIsSavingCanvas();
const saveCanvas = useCallback(async () => {
isSaving.setTrue();
const rect =
region === 'bbox' ? canvasManager.stateApi.getBbox().rect : canvasManager.stage.getVisibleRect('raster_layer');
const result = await withResultAsync(() =>
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, saveToGallery)
);
if (isOk(result)) {
if (onSave) {
onSave(result.value, rect);
}
toast({ title: t('controlLayers.savedToGalleryOk') });
} else {
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
toast({ title: t('controlLayers.savedToGalleryError'), status: 'error' });
}
isSaving.setFalse();
}, [
canvasManager.compositor,
canvasManager.stage,
canvasManager.stateApi,
isSaving,
onSave,
region,
saveToGallery,
t,
]);
return saveCanvas;
};
export const useSaveCanvasToGallery = () => {
const saveCanvasToGalleryArg = useMemo<UseSaveCanvasArg>(() => ({ region: 'canvas', saveToGallery: true }), []);
const saveCanvasToGallery = useSaveCanvas(saveCanvasToGalleryArg);
return saveCanvasToGallery;
};
export const useSaveBboxToGallery = () => {
const saveBboxToGalleryArg = useMemo<UseSaveCanvasArg>(() => ({ region: 'bbox', saveToGallery: true }), []);
const saveBboxToGallery = useSaveCanvas(saveBboxToGalleryArg);
return saveBboxToGallery;
};
export const useSaveBboxAsRegionalGuidanceIPAdapter = () => {
const dispatch = useAppDispatch();
const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter);
const saveBboxAsRegionalGuidanceIPAdapterArg = useMemo<UseSaveCanvasArg>(() => {
const onSave = (imageDTO: ImageDTO) => {
const ipAdapter: RegionalGuidanceIPAdapterConfig = {
...defaultIPAdapter,
id: getPrefixedId('regional_guidance_ip_adapter'),
image: imageDTOToImageWithDims(imageDTO),
};
const overrides: Partial<CanvasRegionalGuidanceState> = {
ipAdapters: [ipAdapter],
};
dispatch(rgAdded({ overrides, isSelected: true }));
};
return { region: 'bbox', saveToGallery: true, onSave };
}, [defaultIPAdapter, dispatch]);
const saveBboxAsRegionalGuidanceIPAdapter = useSaveCanvas(saveBboxAsRegionalGuidanceIPAdapterArg);
return saveBboxAsRegionalGuidanceIPAdapter;
};
export const useSaveBboxAsGlobalIPAdapter = () => {
const dispatch = useAppDispatch();
const defaultIPAdapter = useAppSelector(selectDefaultIPAdapter);
const saveBboxAsIPAdapterArg = useMemo<UseSaveCanvasArg>(() => {
const onSave = (imageDTO: ImageDTO) => {
const overrides: Partial<CanvasIPAdapterState> = {
ipAdapter: {
...defaultIPAdapter,
image: imageDTOToImageWithDims(imageDTO),
},
};
dispatch(ipaAdded({ overrides, isSelected: true }));
};
return { region: 'bbox', saveToGallery: true, onSave };
}, [defaultIPAdapter, dispatch]);
const saveBboxAsIPAdapter = useSaveCanvas(saveBboxAsIPAdapterArg);
return saveBboxAsIPAdapter;
};
export const useSaveBboxAsRasterLayer = () => {
const dispatch = useAppDispatch();
const saveBboxAsRasterLayerArg = useMemo<UseSaveCanvasArg>(() => {
const onSave = (imageDTO: ImageDTO, rect: Rect) => {
const overrides: Partial<CanvasRasterLayerState> = {
objects: [imageDTOToImageObject(imageDTO)],
position: { x: rect.x, y: rect.y },
};
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
};
return { region: 'bbox', saveToGallery: true, onSave };
}, [dispatch]);
const saveBboxAsRasterLayer = useSaveCanvas(saveBboxAsRasterLayerArg);
return saveBboxAsRasterLayer;
};
export const useSaveBboxAsControlLayer = () => {
const dispatch = useAppDispatch();
const defaultControlAdapter = useAppSelector(selectDefaultControlAdapter);
const saveBboxAsControlLayerArg = useMemo<UseSaveCanvasArg>(() => {
const onSave = (imageDTO: ImageDTO, rect: Rect) => {
const overrides: Partial<CanvasControlLayerState> = {
objects: [imageDTOToImageObject(imageDTO)],
controlAdapter: defaultControlAdapter,
position: { x: rect.x, y: rect.y },
};
dispatch(controlLayerAdded({ overrides, isSelected: true }));
};
return { region: 'bbox', saveToGallery: true, onSave };
}, [defaultControlAdapter, dispatch]);
const saveBboxAsControlLayer = useSaveCanvas(saveBboxAsControlLayerArg);
return saveBboxAsControlLayer;
};

View File

@@ -0,0 +1,58 @@
import { useStore } from '@nanostores/react';
import { $socket } from 'app/hooks/useSocketIO';
import { logger } from 'app/logging/logger';
import { useAppStore } from 'app/store/nanostores/store';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { $canvasManager } from 'features/controlLayers/store/canvasSlice';
import Konva from 'konva';
import { useLayoutEffect, useState } from 'react';
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
const log = logger('canvas');
// This will log warnings when layers > 5
Konva.showWarnings = import.meta.env.MODE === 'development';
const useKonvaPixelRatioWatcher = () => {
useAssertSingleton('useKonvaPixelRatioWatcher');
const dpr = useDevicePixelRatio({ round: false });
useLayoutEffect(() => {
Konva.pixelRatio = dpr;
}, [dpr]);
};
export const useInvokeCanvas = (): ((el: HTMLDivElement | null) => void) => {
useAssertSingleton('useInvokeCanvas');
useKonvaPixelRatioWatcher();
const store = useAppStore();
const socket = useStore($socket);
const [container, containerRef] = useState<HTMLDivElement | null>(null);
useLayoutEffect(() => {
log.debug('Initializing renderer');
if (!container) {
// Nothing to clean up
log.debug('No stage container, skipping initialization');
return () => {};
}
if (!socket) {
log.debug('Socket not connected, skipping initialization');
return () => {};
}
const currentManager = $canvasManager.get();
if (currentManager) {
currentManager.stage.setContainer(container);
return;
}
const manager = new CanvasManager(container, store, socket);
manager.initialize();
}, [container, socket, store]);
return containerRef;
};

View File

@@ -61,16 +61,19 @@ export const useNextPrevEntityHotkeys = () => {
useHotkeys(
// “ === alt+[
['alt+[', '“'],
['“'],
selectPrevEntity,
{ preventDefault: true, ignoreModifiers: true },
[selectPrevEntity]
);
useHotkeys(['alt+['], selectPrevEntity, { preventDefault: true }, [selectPrevEntity]);
useHotkeys(
// === alt+]
['alt+]', ''],
[''],
selectNextEntity,
{ preventDefault: true, ignoreModifiers: true },
[selectNextEntity]
);
useHotkeys(['alt+]'], selectNextEntity, { preventDefault: true }, [selectNextEntity]);
};

View File

@@ -77,7 +77,7 @@ export class CanvasManager extends CanvasModuleBase {
*/
$isBusy: Atom<boolean>;
constructor(stage: Konva.Stage, container: HTMLDivElement, store: AppStore, socket: AppSocket) {
constructor(container: HTMLDivElement, store: AppStore, socket: AppSocket) {
super();
this.id = getPrefixedId(this.type);
this.path = [this.id];
@@ -98,7 +98,7 @@ export class CanvasManager extends CanvasModuleBase {
this.socket = socket;
this.stateApi = new CanvasStateApiModule(this.store, this);
this.stage = new CanvasStageModule(stage, container, this);
this.stage = new CanvasStageModule(container, this);
this.worker = new CanvasWorkerModule(this);
this.cache = new CanvasCacheModule(this);
this.entityRenderer = new CanvasEntityRendererModule(this);

View File

@@ -9,7 +9,7 @@ import type {
Rect,
StageAttrs,
} from 'features/controlLayers/store/types';
import type Konva from 'konva';
import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { clamp } from 'lodash-es';
import { atom } from 'nanostores';
@@ -59,7 +59,7 @@ export class CanvasStageModule extends CanvasModuleBase {
subscriptions = new Set<() => void>();
constructor(stage: Konva.Stage, container: HTMLDivElement, manager: CanvasManager) {
constructor(container: HTMLDivElement, manager: CanvasManager) {
super();
this.id = getPrefixedId('stage');
this.parent = manager;
@@ -70,9 +70,19 @@ export class CanvasStageModule extends CanvasModuleBase {
this.log.debug('Creating module');
this.container = container;
this.konva = { stage };
this.konva = {
stage: new Konva.Stage({
id: getPrefixedId('konva_stage'),
container,
}),
};
}
setContainer = (container: HTMLDivElement) => {
this.container = container;
this.konva.stage.container(container);
};
setEventListeners = () => {
this.konva.stage.on('wheel', this.onStageMouseWheel);
this.konva.stage.on('dragmove', this.onStageDragMove);

View File

@@ -1302,4 +1302,7 @@ function actionsThrottlingFilter(action: UnknownAction) {
}
export const $lastCanvasProgressEvent = atom<S['InvocationDenoiseProgressEvent'] | null>(null);
/**
* The global canvas manager instance.
*/
export const $canvasManager = atom<CanvasManager | null>(null);

View File

@@ -29,7 +29,7 @@ export const selectCanvasSlice = (state: RootState) => state.canvas.present;
*
* It does not check for validity of the entities.
*/
const selectEntityCount = createSelector(selectCanvasSlice, (canvas) => {
export const selectEntityCount = createSelector(selectCanvasSlice, (canvas) => {
return (
canvas.regions.entities.length +
canvas.ipAdapters.entities.length +

View File

@@ -1 +1 @@
__version__ = "5.0.0.a1"
__version__ = "5.0.0.a2"